diff --git a/.circleci/config.yml b/.circleci/config.yml index cc3118229..06e643bdd 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,51 +1,115 @@ -defaults: &defaults - docker: - - image: bepsays/ci-goreleaser:1.13.7-1 - environment: - CGO_ENABLED: "0" +parameters: +# v2: 11m. +defaults: &defaults + resource_class: large + docker: + - image: bepsays/ci-hugoreleaser:1.22400.20000 +environment: &buildenv + GOMODCACHE: /root/project/gomodcache version: 2 jobs: - build: - <<: *defaults + prepare_release: + <<: *defaults + environment: &buildenv + GOMODCACHE: /root/project/gomodcache steps: - - checkout: + - setup_remote_docker + - checkout: path: hugo + - &git-config + run: + command: | + git config --global user.email "bjorn.erik.pedersen+hugoreleaser@gmail.com" + git config --global user.name "hugoreleaser" - run: - command: | - git clone git@github.com:gohugoio/hugoDocs.git - cd hugo - go mod download - sleep 5 - go test -p 1 ./... - - persist_to_workspace: - root: . - paths: . - release: - <<: *defaults + command: | + cd hugo + go mod download + go run -tags release main.go release --step 1 + - save_cache: + key: git-sha-{{ .Revision }} + paths: + - hugo + - gomodcache + build_container1: + <<: [*defaults] + environment: + <<: [*buildenv] steps: - - attach_workspace: - at: /root/project + - &restore-cache + restore_cache: + key: git-sha-{{ .Revision }} - run: - command: | - cd hugo - git config --global user.email "bjorn.erik.pedersen+hugoreleaser@gmail.com" - git config --global user.name "hugoreleaser" - go run -tags release main.go release -r ${CIRCLE_BRANCH} - + no_output_timeout: 20m + command: | + mkdir -p /tmp/files/dist1 + cd hugo + hugoreleaser build -paths "builds/container1/**" -workers 3 -dist /tmp/files/dist1 -chunks $CIRCLE_NODE_TOTAL -chunk-index $CIRCLE_NODE_INDEX + - &persist-workspace + persist_to_workspace: + root: /tmp/files + paths: + - dist1 + - dist2 + parallelism: 7 + build_container2: + <<: [*defaults] + environment: + <<: [*buildenv] + docker: + - image: bepsays/ci-hugoreleaser-linux-arm64:1.22400.20000 + steps: + - *restore-cache + - &attach-workspace + attach_workspace: + at: /tmp/workspace + - run: + command: | + mkdir -p /tmp/files/dist2 + cd hugo + hugoreleaser build -paths "builds/container2/**" -workers 1 -dist /tmp/files/dist2 + - *persist-workspace + archive_and_release: + <<: [*defaults] + environment: + <<: [*buildenv] + steps: + - *restore-cache + - *attach-workspace + - *git-config + - run: + name: Add github.com to known hosts + command: ssh-keyscan github.com >> ~/.ssh/known_hosts + - run: + command: | + cp -a /tmp/workspace/dist1/. ./hugo/dist + cp -a /tmp/workspace/dist2/. ./hugo/dist + - run: + command: | + cd hugo + hugoreleaser archive + hugoreleaser release + go run -tags release main.go release --step 2 workflows: version: 2 release: jobs: - - build: + - prepare_release: filters: branches: only: /release-.*/ - - hold: - type: approval + - build_container1: requires: - - build - - release: + - prepare_release + - build_container2: + requires: + - prepare_release + - archive_and_release: context: org-global requires: - - hold + - build_container1 + - build_container2 + + + diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 12e4b5541..fa2791492 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,11 +1,16 @@ --- name: 'Bug report' -labels: '' +labels: 'Bug, NeedsTriage' assignees: '' about: Create a report to help us improve --- - + + ### What version of Hugo are you using (`hugo version`)? diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..c84d3276b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: SUPPORT, ISSUES and TROUBLESHOOTING + url: https://discourse.gohugo.io/ + about: Please DO NOT use Github for support requests. Please visit https://discourse.gohugo.io for support! You will be helped much faster there. If you ignore this request your issue might be closed with a discourse label. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index da14802fb..c114b3d7f 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,8 +1,11 @@ --- name: Proposal -about: Suggest an idea for Hugo +about: Propose a new feature for Hugo title: '' -labels: 'Proposal' +labels: 'Proposal, NeedsTriage' assignees: '' --- + + + \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/support.md b/.github/ISSUE_TEMPLATE/support.md deleted file mode 100644 index 31837330e..000000000 --- a/.github/ISSUE_TEMPLATE/support.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -name: Support (Do not use) -about: Please do not use Github for support requests. Visit https://discourse.gohugo.io for support -title: '' -labels: support -assignees: '' - ---- - -Issues created with this template will be automatically closed. Please visit https://discourse.gohugo.io for the support you really, really, want! \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..1801e72d9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +# See https://docs.github.com/en/github/administering-a-repository/configuration-options-for-dependency-updates#package-ecosystem +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/stale.yml b/.github/stale.yml deleted file mode 100644 index 692c59659..000000000 --- a/.github/stale.yml +++ /dev/null @@ -1,23 +0,0 @@ -# Number of days of inactivity before an issue becomes stale -daysUntilStale: 120 -# Number of days of inactivity before a stale issue is closed -daysUntilClose: 30 -# Issues with these labels will never be considered stale -exemptLabels: - - Keep - - Security -# Label to use when marking an issue as stale -staleLabel: Stale -# Comment to post when marking an issue as stale. Set to `false` to disable -markComment: > - This issue has been automatically marked as stale because it has not had - recent activity. The resources of the Hugo team are limited, and so we are asking for your help. - - If this is a **bug** and you can still reproduce this error on the master branch, please reply with all of the information you have about it in order to keep the issue open. - - If this is a **feature request**, and you feel that it is still relevant and valuable, please tell us why. - - This issue will automatically be closed in the near future if no further activity occurs. Thank you for all your contributions. - -# Comment to post when closing a stale issue. Set to `false` to disable -closeComment: false diff --git a/.github/workflows/auto_close_support.yml b/.github/workflows/auto_close_support.yml deleted file mode 100644 index 28a0e695f..000000000 --- a/.github/workflows/auto_close_support.yml +++ /dev/null @@ -1,14 +0,0 @@ -on: - schedule: - - cron: 0 5 * * 3 -name: Weekly Issue Closure -jobs: - cycle-weekly-close: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@master - - name: weekly-issue-closure - uses: bdougie/close-issues-based-on-label@master - env: - LABEL: support - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml new file mode 100644 index 000000000..c4f3c34c3 --- /dev/null +++ b/.github/workflows/image.yml @@ -0,0 +1,49 @@ +name: Build Docker image + +on: + release: + types: [published] + pull_request: +permissions: + packages: write + +env: + REGISTRY_IMAGE: ghcr.io/gohugoio/hugo + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + + - name: Docker meta + id: meta + uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5.5.1 + with: + images: ${{ env.REGISTRY_IMAGE }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@988b5a0280414f521da01fcc63a27aeeb4b104db # v3.6.1 + + - name: Login to GHCR + # Login is only needed when the image is pushed + uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + id: build + uses: docker/build-push-action@16ebe778df0e7752d2cfcbd924afdbbd89c1a755 # v6.6.1 + with: + context: . + provenance: mode=max + sbom: true + push: ${{ github.event_name != 'pull_request' }} + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: HUGO_BUILD_TAGS=extended,withdeploy \ No newline at end of file diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 000000000..249c1ab54 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,52 @@ +name: 'Close stale and lock closed issues and PRs' +on: + workflow_dispatch: + schedule: + - cron: '30 1 * * *' +permissions: + contents: read +jobs: + stale: + permissions: + issues: write + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: dessant/lock-threads@7de207be1d3ce97a9abe6ff1306222982d1ca9f9 # v5.0.1 + with: + issue-inactive-days: 21 + add-issue-labels: 'Outdated' + issue-comment: > + This issue has been automatically locked since there + has not been any recent activity after it was closed. + Please open a new issue for related bugs. + pr-comment: > + 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@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0 + with: + operations-per-run: 999 + days-before-issue-stale: 365 + days-before-pr-stale: 365 + days-before-issue-close: 56 + days-before-pr-close: 56 + stale-issue-message: > + This issue has been automatically marked as stale because it has not had + recent activity. The resources of the Hugo team are limited, and so we are asking for your help. + + If this is a **bug** and you can still reproduce this error on the master branch, please reply with all of the information you have about it in order to keep the issue open. + + If this is a **feature request**, and you feel that it is still relevant and valuable, please tell us why. + + This issue will automatically be closed in the near future if no further activity occurs. Thank you for all your contributions. + stale-pr-message: This PR has been automatically marked as stale because it has not had + recent activity. The resources of the Hugo team are limited, and so we are asking for your help. + + Please check https://github.com/gohugoio/hugo/blob/master/CONTRIBUTING.md#code-contribution and verify that this code contribution fits with the description. If yes, tell is in a comment. + + This PR will automatically be closed in the near future if no further activity occurs. Thank you for all your contributions. + stale-issue-label: 'Stale' + exempt-issue-labels: 'Keep,Security' + stale-pr-label: 'Stale' + exempt-pr-labels: 'Keep,Security' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..c49c12371 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,132 @@ +on: + push: + branches: [master] + pull_request: +name: Test +env: + GOPROXY: https://proxy.golang.org + GO111MODULE: on + SASS_VERSION: 1.80.3 + DART_SASS_SHA_LINUX: 7c933edbad0a7d389192c5b79393485c088bd2c4398e32f5754c32af006a9ffd + DART_SASS_SHA_MACOS: 79e060b0e131c3bb3c16926bafc371dc33feab122bfa8c01aa337a072097967b + DART_SASS_SHA_WINDOWS: 0bc4708b37cd1bac4740e83ac5e3176e66b774f77fd5dd364da5b5cfc9bfb469 +permissions: + contents: read +jobs: + test: + strategy: + matrix: + go-version: [1.23.x, 1.24.x] + os: [ubuntu-latest, windows-latest] # macos disabled for now because of disk space issues. + runs-on: ${{ matrix.os }} + steps: + - if: matrix.os == 'ubuntu-latest' + name: Free Disk Space (Ubuntu) + uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 + with: + # this might remove tools that are actually needed, + # if set to "true" but frees about 6 GB + tool-cache: false + android: true + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: true + - name: Checkout code + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - name: Install Go + uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 + with: + go-version: ${{ matrix.go-version }} + check-latest: true + cache: true + cache-dependency-path: | + **/go.sum + **/go.mod + - name: Install Ruby + uses: ruby/setup-ruby@a6e6f86333f0a2523ece813039b8b4be04560854 # v1.190.0 + with: + ruby-version: "2.7" + bundler-cache: true # + - name: Install Python + uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # v5.1.1 + with: + python-version: "3.x" + - name: Install Mage + run: go install github.com/magefile/mage@v1.15.0 + - name: Install asciidoctor + uses: reitzig/actions-asciidoctor@c642db5eedd1d729bb8c92034770d0b2f769eda6 # v2.0.2 + - name: Install docutils + run: | + pip install docutils + rst2html --version + - if: matrix.os == 'ubuntu-latest' + name: Install pandoc on Linux + run: | + sudo apt-get update -y + sudo apt-get install -y pandoc + - if: matrix.os == 'macos-latest' + run: | + brew install pandoc + - if: matrix.os == 'windows-latest' + run: | + choco install pandoc + - run: pandoc -v + - if: matrix.os == 'windows-latest' + run: | + choco install mingw + - if: matrix.os == 'ubuntu-latest' + name: Install dart-sass Linux + run: | + echo "Install Dart Sass version ${SASS_VERSION} ..." + curl -LJO "https://github.com/sass/dart-sass/releases/download/${SASS_VERSION}/dart-sass-${SASS_VERSION}-linux-x64.tar.gz"; + echo "${DART_SASS_SHA_LINUX} dart-sass-${SASS_VERSION}-linux-x64.tar.gz" | sha256sum -c; + tar -xvf "dart-sass-${SASS_VERSION}-linux-x64.tar.gz"; + echo "$GOBIN" + echo "$GITHUB_WORKSPACE/dart-sass/" >> $GITHUB_PATH + - if: matrix.os == 'macos-latest' + name: Install dart-sass MacOS + run: | + echo "Install Dart Sass version ${SASS_VERSION} ..." + curl -LJO "https://github.com/sass/dart-sass/releases/download/${SASS_VERSION}/dart-sass-${SASS_VERSION}-macos-x64.tar.gz"; + echo "${DART_SASS_SHA_MACOS} dart-sass-${SASS_VERSION}-macos-x64.tar.gz" | shasum -a 256 -c; + tar -xvf "dart-sass-${SASS_VERSION}-macos-x64.tar.gz"; + echo "$GITHUB_WORKSPACE/dart-sass/" >> $GITHUB_PATH + - if: matrix.os == 'windows-latest' + name: Install dart-sass Windows + run: | + echo "Install Dart Sass version ${env:SASS_VERSION} ..." + curl -LJO "https://github.com/sass/dart-sass/releases/download/${env:SASS_VERSION}/dart-sass-${env:SASS_VERSION}-windows-x64.zip"; + Expand-Archive -Path "dart-sass-${env:SASS_VERSION}-windows-x64.zip" -DestinationPath .; + echo "$env:GITHUB_WORKSPACE/dart-sass/" | Out-File -FilePath $Env:GITHUB_PATH -Encoding utf-8 -Append + - if: matrix.os == 'ubuntu-latest' + name: Install staticcheck + run: go install honnef.co/go/tools/cmd/staticcheck@latest + - if: matrix.os == 'ubuntu-latest' + name: Run staticcheck + run: staticcheck ./... + - if: matrix.os != 'windows-latest' + name: Check + run: | + sass --version; + mage -v check; + env: + HUGO_BUILD_TAGS: extended,withdeploy + - if: matrix.os == 'windows-latest' + # See issue #11052. We limit the build to regular test (no -race flag) on Windows for now. + name: Test + run: | + mage -v test; + env: + HUGO_BUILD_TAGS: extended,withdeploy + - name: Build tags + run: | + go install -tags extended + - if: matrix.os == 'ubuntu-latest' + name: Build for dragonfly + run: | + go install + env: + GOARCH: amd64 + GOOS: dragonfly diff --git a/.gitignore b/.gitignore index 75d85e8d0..ddad69611 100644 --- a/.gitignore +++ b/.gitignore @@ -1,26 +1,6 @@ -/hugo -docs/public* -/.idea -hugo.exe + *.test -*.prof -nohup.out -cover.out -*.swp -*.swo -.DS_Store -*~ -vendor/*/ -*.bench -*.debug -coverage*.out - -dock.sh - -GoBuilds -dist - -resources/sunset.jpg - -vendor - +imports.* +dist/ +public/ +.DS_Store \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 17145972c..000000000 --- a/.travis.yml +++ /dev/null @@ -1,77 +0,0 @@ -language: go - -dist: bionic - -env: - global: - - CACHE_NAME=${TRAVIS_ARCH} - - GO111MODULE=on - - GOPROXY=https://proxy.golang.org - - HUGO_BUILD_TAGS=extended - -git: - depth: false - -go: - - "1.12.16" - - "1.13.7" - - master - -arch: - - amd64 - - arm64 - -os: - - linux - - osx - - windows - -jobs: - allow_failures: - - go: master - - arch: arm64 - fast_finish: true - exclude: - - os: windows - go: master - - arch: arm64 - os: osx - - arch: arm64 - os: windows - -cache: - directories: - - $HOME/gopath/pkg/mod - - $HOME/.cache/go-build - - $HOME/Library/Caches/go-build - - $HOME/AppData/Local/go-build - -before_install: - - df -h - # https://travis-ci.community/t/go-cant-find-gcc-with-go1-11-1-on-windows/293/5 - - if [ "$TRAVIS_OS_NAME" = "windows" ]; then - choco install mingw -y; - export PATH=/c/tools/mingw64/bin:"$PATH"; - fi - - gem install asciidoctor - - type asciidoctor - -install: - - mkdir -p $HOME/src - - mv $TRAVIS_BUILD_DIR $HOME/src - - export TRAVIS_BUILD_DIR=$HOME/src/hugo - - cd $HOME/src/hugo - - go get github.com/magefile/mage - -script: - - go mod download || true - - mage -v test - - if [ "$TRAVIS_ARCH" = "amd64" ]; then - mage -v check; - else - HUGO_TIMEOUT=30000 mage -v check; - fi - - mage -v hugo - - ./hugo -s docs/ - - ./hugo --renderToMemory -s docs/ - - df -h diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 58092274a..ddd3efcf2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,3 +1,5 @@ +>**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, @@ -48,11 +50,12 @@ Hugo has become a fully featured static site generator, so any new functionality * strive not to break existing sites. * close or update an open [Hugo issue](https://github.com/gohugoio/hugo/issues) -If it is of some complexity, the contributor is expected to maintain and support the new future (answer questions on the forum, fix any bugs etc.). +If it is of some complexity, the contributor is expected to maintain and support the new feature in the future (answer questions on the forum, fix any bugs etc.). -It is recommended to open up a discussion on the [Hugo Forum](https://discourse.gohugo.io/) to get feedback on your idea before you begin. If you are submitting a complex feature, create a small design proposal on the [Hugo issue tracker](https://github.com/gohugoio/hugo/issues) before you start. +Any non-trivial code change needs to update an open [issue](https://github.com/gohugoio/hugo/issues). A non-trivial code change without an issue reference with one of the labels `bug` or `enhancement` will not be merged. -Note that we do not accept new features that needs [CGO](https://github.com/golang/go/wiki/cgo). We have one exeption to tuat rule and that is LibSASS. +Note that we do not accept new features that require [CGO](https://github.com/golang/go/wiki/cgo). +We have one exception to this rule which is LibSASS. **Bug fixes are, of course, always welcome.** @@ -78,19 +81,23 @@ To make the contribution process as seamless as possible, we ask for the followi ### Git Commit Message Guidelines -This [blog article](http://chris.beams.io/posts/git-commit/) is a good resource for learning how to write good commit messages, +This [blog article](https://cbea.ms/git-commit/) is a good resource for learning how to write good commit messages, the most important part being that each commit message should have a title/subject in imperative mood starting with a capital letter and no trailing period: -*"Return error on wrong use of the Paginator"*, **NOT** *"returning some error."* +*"js: Return error when option x is not set"*, **NOT** *"returning some error."* + +Most title/subjects should have a lower-cased prefix with a colon and one whitespace. The prefix can be: + +* The name of the package where (most of) the changes are made (e.g. `media: Add text/calendar`) +* If the package name is deeply nested/long, try to shorten it from the left side, e.g. `markup/goldmark` is OK, `resources/resource_transformers/js` can be shortened to `js`. +* If this commit touches several packages with a common functional topic, use that as a prefix, e.g. `errors: Resolve correct line numbers`) +* If this commit touches many packages without a common functional topic, prefix with `all:` (e.g. `all: Reformat Go code`) +* If this is a documentation update, prefix with `docs:`. +* If nothing of the above applies, just leave the prefix out. +* Note that the above excludes nouns seen in other repositories, e.g. "chore:". Also, if your commit references one or more GitHub issues, always end your commit message body with *See #1234* or *Fixes #1234*. Replace *1234* with the GitHub issue ID. The last example will close the issue when the commit is merged into *master*. -Sometimes it makes sense to prefix the commit message with the package name (or docs folder) all lowercased ending with a colon. -That is fine, but the rest of the rules above apply. -So it is "tpl: Add emojify template func", not "tpl: add emojify template func.", and "docs: Document emoji", not "doc: document emoji." - -Please use a short and descriptive branch name, e.g. **NOT** "patch-1". It's very common but creates a naming conflict each time when a submission is pulled for a review. - An example: ```text @@ -116,12 +123,10 @@ cd hugo go install ``` ->Note: Some Go tools may not be fully updated to support Go Modules yet. One example would be LiteIDE. Follow [this workaround](https://github.com/visualfc/liteide/issues/986#issuecomment-428117702) for how to continue to work with Hugo below `GOPATH`. - For some convenient build and test targets, you also will want to install Mage: ```bash -go get github.com/magefile/mage +go install github.com/magefile/mage ``` Now, to make a change to Hugo's source: @@ -143,7 +148,7 @@ Now, to make a change to Hugo's source: 1. Add your fork as a new remote (the remote name, "fork" in this example, is arbitrary): ```bash - git remote add fork git://github.com/USERNAME/hugo.git + git remote add fork git@github.com:USERNAME/hugo.git ``` 1. Push the changes to your new remote: diff --git a/Dockerfile b/Dockerfile index 3ec305f7b..a0e34353f 100755 --- a/Dockerfile +++ b/Dockerfile @@ -2,44 +2,98 @@ # Twitter: https://twitter.com/gohugoio # Website: https://gohugo.io/ -FROM golang:1.13-alpine AS build +ARG GO_VERSION="1.24" +ARG ALPINE_VERSION="3.22" +ARG DART_SASS_VERSION="1.79.3" -# Optionally set HUGO_BUILD_TAGS to "extended" when building like so: -# docker build --build-arg HUGO_BUILD_TAGS=extended . -ARG HUGO_BUILD_TAGS +FROM --platform=$BUILDPLATFORM tonistiigi/xx:1.5.0 AS xx +FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS gobuild +FROM golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS gorun -ARG CGO=1 -ENV CGO_ENABLED=${CGO} -ENV GOOS=linux -ENV GO111MODULE=on + +FROM gobuild AS build + +RUN apk add clang lld + +# Set up cross-compilation helpers +COPY --from=xx / / + +ARG TARGETPLATFORM +RUN xx-apk add musl-dev gcc g++ + +# Optionally set HUGO_BUILD_TAGS to "none" or "withdeploy" when building like so: +# docker build --build-arg HUGO_BUILD_TAGS=withdeploy . +# +# We build the extended version by default. +ARG HUGO_BUILD_TAGS="extended" +ENV CGO_ENABLED=1 +ENV GOPROXY=https://proxy.golang.org +ENV GOCACHE=/root/.cache/go-build +ENV GOMODCACHE=/go/pkg/mod +ARG TARGETPLATFORM WORKDIR /go/src/github.com/gohugoio/hugo -COPY . /go/src/github.com/gohugoio/hugo/ +# For --mount=type=cache the value of target is the default cache id, so +# for the go mod cache it would be good if we could share it with other Go images using the same setup, +# but the go build cache needs to be per platform. +# See this comment: https://github.com/moby/buildkit/issues/1706#issuecomment-702238282 +RUN --mount=target=. \ + --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build,id=go-build-$TARGETPLATFORM < +[bep]: https://github.com/bep +[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 +[documentation repository]: https://github.com/gohugoio/hugoDocs +[documentation]: https://gohugo.io/documentation +[dragonfly bsd, freebsd, netbsd, and openbsd]: https://gohugo.io/installation/bsd +[features]: https://gohugo.io/about/features/ +[forum]: https://discourse.gohugo.io +[friends]: https://github.com/gohugoio/hugo/graphs/contributors +[go]: https://go.dev/ +[hugo modules]: https://gohugo.io/hugo-modules/ +[installation]: https://gohugo.io/installation +[issue queue]: https://github.com/gohugoio/hugo/issues +[linux]: https://gohugo.io/installation/linux +[macos]: https://gohugo.io/installation/macos +[prebuilt binary]: https://github.com/gohugoio/hugo/releases/latest +[requesting help]: https://discourse.gohugo.io/t/requesting-help/9132 +[spf13]: https://github.com/spf13 +[static site generator]: https://en.wikipedia.org/wiki/Static_site_generator +[support]: https://discourse.gohugo.io +[themes]: https://themes.gohugo.io/ +[website]: https://gohugo.io +[windows]: https://gohugo.io/installation/windows -A Fast and Flexible Static Site Generator built with love by [bep](https://github.com/bep), [spf13](http://spf13.com/) and [friends](https://github.com/gohugoio/hugo/graphs/contributors) in [Go][]. +Hugo -[Website](https://gohugo.io) | -[Forum](https://discourse.gohugo.io) | -[Documentation](https://gohugo.io/getting-started/) | -[Installation Guide](https://gohugo.io/getting-started/installing/) | -[Contribution Guide](CONTRIBUTING.md) | -[Twitter](https://twitter.com/gohugoio) +A fast and flexible static site generator built with love by [bep], [spf13], and [friends] in [Go]. + +--- [![GoDoc](https://godoc.org/github.com/gohugoio/hugo?status.svg)](https://godoc.org/github.com/gohugoio/hugo) -[![Linux and macOS Build Status](https://api.travis-ci.org/gohugoio/hugo.svg?branch=master&label=Windows+and+Linux+and+macOS+build "Windows, Linux and macOS Build Status")](https://travis-ci.org/gohugoio/hugo) +[![Tests on Linux, MacOS and Windows](https://github.com/gohugoio/hugo/workflows/Test/badge.svg)](https://github.com/gohugoio/hugo/actions?query=workflow%3ATest) [![Go Report Card](https://goreportcard.com/badge/github.com/gohugoio/hugo)](https://goreportcard.com/report/github.com/gohugoio/hugo) +[Website] | [Installation] | [Documentation] | [Support] | [Contributing] | Mastodon + ## Overview -Hugo is a static HTML and CSS website generator written in [Go][]. -It is optimized for speed, ease of use, and configurability. -Hugo takes a directory with content and templates and renders them into a full HTML website. +Hugo is a [static site generator] written in [Go], optimized for speed and designed for flexibility. With its advanced templating system and fast asset pipelines, Hugo renders a complete site in seconds, often less. -Hugo relies on Markdown files with front matter for metadata, and you can run Hugo from any directory. -This works well for shared hosts and other systems where you don’t have a privileged account. +Due to its flexible framework, multilingual support, and powerful taxonomy system, Hugo is widely used to create: -Hugo renders a typical website of moderate size in a fraction of a second. -A good rule of thumb is that each piece of content renders in around 1 millisecond. +- Corporate, government, nonprofit, education, news, event, and project sites +- Documentation sites +- Image portfolios +- Landing pages +- Business, professional, and personal blogs +- Resumes and CVs -Hugo is designed to work well for any kind of website including blogs, tumbles, and docs. +Use Hugo's embedded web server during development to instantly see changes to content, structure, behavior, and presentation. Then deploy the site to your host, or push changes to your Git provider for automated builds and deployment. -#### Supported Architectures +Hugo's fast asset pipelines include: -Currently, we provide pre-built Hugo binaries for Windows, Linux, FreeBSD, NetBSD, DragonFly BSD, Open BSD, macOS (Darwin), and [Android](https://gist.github.com/bep/a0d8a26cf6b4f8bc992729b8e50b480b) for x64, i386 and ARM architectures. +- Image processing – Convert, resize, crop, rotate, adjust colors, apply filters, overlay text and images, and extract EXIF data +- JavaScript bundling – Transpile TypeScript and JSX to JavaScript, bundle, tree shake, minify, create source maps, and perform SRI hashing. +- Sass processing – Transpile Sass to CSS, bundle, tree shake, minify, create source maps, perform SRI hashing, and integrate with PostCSS +- Tailwind CSS processing – Compile Tailwind CSS utility classes into standard CSS, bundle, tree shake, optimize, minify, perform SRI hashing, and integrate with PostCSS -Hugo may also be compiled from source wherever the Go compiler tool chain can run, e.g. for other operating systems including Plan 9 and Solaris. +And with [Hugo Modules], you can share content, assets, data, translations, themes, templates, and configuration with other projects via public or private Git repositories. -**Complete documentation is available at [Hugo Documentation](https://gohugo.io/getting-started/).** +See the [features] section of the documentation for a comprehensive summary of Hugo's capabilities. -## Choose How to Install +## Sponsors -If you want to use Hugo as your site generator, simply install the Hugo binaries. -The Hugo binaries have no external dependencies. +

 

+

+ Linode +    + The complete IDE crafted for professional Go developers. +     + PinMe. +

-To contribute to the Hugo source code or documentation, you should [fork the Hugo GitHub project](https://github.com/gohugoio/hugo#fork-destination-box) and clone it to your local machine. +## Editions -Finally, you can install the Hugo source code with `go`, build the binaries yourself, and run Hugo that way. -Building the binaries is an easy task for an experienced `go` getter. +Hugo is available in three editions: standard, extended, and extended/deploy. While the standard edition provides core functionality, the extended and extended/deploy editions offer advanced features. -### Install Hugo as Your Site Generator (Binary Install) +Feature|extended edition|extended/deploy edition +:--|:-:|:-: +Encode to the WebP format when [processing images]. You can decode WebP images with any edition.|:heavy_check_mark:|:heavy_check_mark: +[Transpile Sass to CSS] using the embedded LibSass transpiler. You can use the [Dart Sass] transpiler with any edition.|:heavy_check_mark:|:heavy_check_mark: +Deploy your site directly to a Google Cloud Storage bucket, an AWS S3 bucket, or an Azure Storage container. See [details].|:x:|:heavy_check_mark: -Use the [installation instructions in the Hugo documentation](https://gohugo.io/getting-started/installing/). +[dart sass]: https://gohugo.io/functions/css/sass/#dart-sass +[processing images]: https://gohugo.io/content-management/image-processing/ +[transpile sass to css]: https://gohugo.io/functions/css/sass/ +[details]: https://gohugo.io/hosting-and-deployment/hugo-deploy/ -### Build and Install the Binaries from Source (Advanced Install) +Unless your specific deployment needs require the extended/deploy edition, we recommend the extended edition. -#### Prerequisite Tools +## Installation -* [Git](https://git-scm.com/) -* [Go (at least Go 1.11)](https://golang.org/dl/) +Install Hugo from a [prebuilt binary], package manager, or package repository. Please see the installation instructions for your operating system: -#### Fetch from GitHub +- [macOS] +- [Linux] +- [Windows] +- [DragonFly BSD, FreeBSD, NetBSD, and OpenBSD] -Since Hugo 0.48, Hugo uses the Go Modules support built into Go 1.11 to build. The easiest is to clone Hugo in a directory outside of `GOPATH`, as in the following example: +## Build from source -```bash -mkdir $HOME/src -cd $HOME/src -git clone https://github.com/gohugoio/hugo.git -cd hugo -go install +Prerequisites to build Hugo from source: + +- Standard edition: Go 1.23.0 or later +- Extended edition: Go 1.23.0 or later, and GCC +- Extended/deploy edition: Go 1.23.0 or later, and GCC + +Build the standard edition: + +```text +go install github.com/gohugoio/hugo@latest ``` -**If you are a Windows user, substitute the `$HOME` environment variable above with `%USERPROFILE%`.** - -## The Hugo Documentation +Build the extended edition: -The Hugo documentation now lives in its own repository, see https://github.com/gohugoio/hugoDocs. But we do keep a version of that documentation as a `git subtree` in this repository. To build the sub folder `/docs` as a Hugo site, you need to clone this repo: - -```bash -git clone git@github.com:gohugoio/hugo.git +```text +CGO_ENABLED=1 go install -tags extended github.com/gohugoio/hugo@latest ``` -## Contributing to Hugo + +Build the extended/deploy edition: + +```text +CGO_ENABLED=1 go install -tags extended,withdeploy github.com/gohugoio/hugo@latest +``` + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=gohugoio/hugo&type=Timeline)](https://star-history.com/#gohugoio/hugo&Timeline) + +## Documentation + +Hugo's [documentation] includes installation instructions, a quick start guide, conceptual explanations, reference information, and examples. + +Please submit documentation issues and pull requests to the [documentation repository]. + +## Support + +Please **do not use the issue queue** for questions or troubleshooting. Unless you are certain that your issue is a software defect, use the [forum]. + +Hugo’s [forum] is an active community of users and developers who answer questions, share knowledge, and provide examples. A quick search of over 20,000 topics will often answer your question. Please be sure to read about [requesting help] before asking your first question. + +## Contributing + +You can contribute to the Hugo project by: + +- Answering questions on the [forum] +- Improving the [documentation] +- Monitoring the [issue queue] +- Creating or improving [themes] +- Squashing [bugs] + +Please submit documentation issues and pull requests to the [documentation repository]. + +If you have an idea for an enhancement or new feature, create a new topic on the [forum] in the "Feature" category. This will help you to: + +- Determine if the capability already exists +- Measure interest +- Refine the concept + +If there is sufficient interest, [create a proposal]. Do not submit a pull request until the project lead accepts the proposal. For a complete guide to contributing to Hugo, see the [Contribution Guide](CONTRIBUTING.md). -We welcome contributions to Hugo of any kind including documentation, themes, -organization, tutorials, blog posts, bug reports, issues, feature requests, -feature implementations, pull requests, answering questions on the forum, -helping to manage issues, etc. - -The Hugo community and maintainers are [very active](https://github.com/gohugoio/hugo/pulse/monthly) and helpful, and the project benefits greatly from this activity. - -### Asking Support Questions - -We have an active [discussion forum](https://discourse.gohugo.io) where users and developers can ask questions. -Please don't use the GitHub issue tracker to ask questions. - -### Reporting Issues - -If you believe you have found a defect in Hugo or its documentation, use -the GitHub issue tracker to report the problem to the Hugo maintainers. -If you're not sure if it's a bug or not, start by asking in the [discussion forum](https://discourse.gohugo.io). -When reporting the issue, please provide the version of Hugo in use (`hugo version`). - -### Submitting Patches - -The Hugo project welcomes all contributors and contributions regardless of skill or experience level. -If you are interested in helping with the project, we will help you with your contribution. -Hugo is a very active project with many contributions happening daily. - -We want to create the best possible product for our users and the best contribution experience for our developers, -we have a set of guidelines which ensure that all contributions are acceptable. -The guidelines are not intended as a filter or barrier to participation. -If you are unfamiliar with the contribution process, the Hugo team will help you and teach you how to bring your contribution in accordance with the guidelines. - -For a complete guide to contributing code to Hugo, see the [Contribution Guide](CONTRIBUTING.md). - -[![Analytics](https://ga-beacon.appspot.com/UA-7131036-6/hugo/readme)](https://github.com/igrigorik/ga-beacon) - -[Go]: https://golang.org/ -[Hugo Documentation]: https://gohugo.io/overview/introduction/ - ## Dependencies -Hugo stands on the shoulder of many great open source libraries, in lexical order: +Hugo stands on the shoulders of great open source libraries. Run `hugo env --logLevel info` to display a list of dependencies. - | Dependency | License | - | :------------- | :------------- | - | [github.com/BurntSushi/locker](https://github.com/BurntSushi/locker) | The Unlicense | - | [github.com/BurntSushi/toml](https://github.com/BurntSushi/toml) | MIT License | - | [github.com/PuerkitoBio/purell](https://github.com/PuerkitoBio/purell) | BSD 3-Clause "New" or "Revised" License | - | [github.com/PuerkitoBio/urlesc](https://github.com/PuerkitoBio/urlesc) | BSD 3-Clause "New" or "Revised" License | - | [github.com/alecthomas/chroma](https://github.com/alecthomas/chroma) | MIT License | - | [github.com/bep/debounce](https://github.com/bep/debounce) | MIT License | - | [github.com/bep/gitmap](https://github.com/bep/gitmap) | MIT License | - | [github.com/bep/go-tocss](https://github.com/bep/go-tocss) | MIT License | - | [github.com/niklasfasching/go-org](https://github.com/niklasfasching/go-org) | MIT License | - | [github.com/cpuguy83/go-md2man](https://github.com/cpuguy83/go-md2man) | MIT License | - | [github.com/danwakefield/fnmatch](https://github.com/danwakefield/fnmatch) | BSD 2-Clause "Simplified" License | - | [github.com/disintegration/imaging](https://github.com/disintegration/imaging) | MIT License | - | [github.com/dlclark/regexp2](https://github.com/dlclark/regexp2) | MIT License | - | [github.com/eknkc/amber](https://github.com/eknkc/amber) | MIT License | - | [github.com/fsnotify/fsnotify](https://github.com/fsnotify/fsnotify) | BSD 3-Clause "New" or "Revised" License | - | [github.com/gobwas/glob](https://github.com/gobwas/glob) | MIT License | - | [github.com/gorilla/websocket](https://github.com/gorilla/websocket) | BSD 2-Clause "Simplified" License | - | [github.com/hashicorp/go-immutable-radix](https://github.com/hashicorp/go-immutable-radix) | Mozilla Public License 2.0 | - | [github.com/hashicorp/golang-lru](https://github.com/hashicorp/golang-lru) | Mozilla Public License 2.0 | - | [github.com/hashicorp/hcl](https://github.com/hashicorp/hcl) | Mozilla Public License 2.0 | - | [github.com/jdkato/prose](https://github.com/jdkato/prose) | MIT License | - | [github.com/kyokomi/emoji](https://github.com/kyokomi/emoji) | MIT License | - | [github.com/magiconair/properties](https://github.com/magiconair/properties) | BSD 2-Clause "Simplified" License | - | [github.com/markbates/inflect](https://github.com/markbates/inflect) | MIT License | - | [github.com/mattn/go-isatty](https://github.com/mattn/go-isatty) | MIT License | - | [github.com/mattn/go-runewidth](https://github.com/mattn/go-runewidth) | MIT License | - | [github.com/miekg/mmark](https://github.com/miekg/mmark) | Simplified BSD License | - | [github.com/mitchellh/hashstructure](https://github.com/mitchellh/hashstructure) | MIT License | - | [github.com/mitchellh/mapstructure](https://github.com/mitchellh/mapstructure) | MIT License | - | [github.com/muesli/smartcrop](https://github.com/muesli/smartcrop) | MIT License | - | [github.com/nicksnyder/go-i18n](https://github.com/nicksnyder/go-i18n) | MIT License | - | [github.com/olekukonko/tablewriter](https://github.com/olekukonko/tablewriter) | MIT License | - | [github.com/pelletier/go-toml](https://github.com/pelletier/go-toml) | MIT License | - | [github.com/pkg/errors](https://github.com/pkg/errors) | BSD 2-Clause "Simplified" License | - | [github.com/russross/blackfriday](https://github.com/russross/blackfriday) | Simplified BSD License | - | [github.com/shurcooL/sanitized_anchor_name](https://github.com/shurcooL/sanitized_anchor_name) | MIT License | - | [github.com/spf13/afero](https://github.com/spf13/afero) | Apache License 2.0 | - | [github.com/spf13/cast](https://github.com/spf13/cast) | MIT License | - | [github.com/spf13/cobra](https://github.com/spf13/cobra) | Apache License 2.0 | - | [github.com/spf13/fsync](https://github.com/spf13/fsync) | MIT License | - | [github.com/spf13/jwalterweatherman](https://github.com/spf13/jwalterweatherman) | MIT License | - | [github.com/spf13/nitro](https://github.com/spf13/nitro) | Apache License 2.0 | - | [github.com/spf13/pflag](https://github.com/spf13/pflag) | BSD 3-Clause "New" or "Revised" License | - | [github.com/spf13/viper](https://github.com/spf13/viper) | MIT License | - | [github.com/tdewolff/minify](https://github.com/tdewolff/minify) | MIT License | - | [github.com/tdewolff/parse](https://github.com/tdewolff/parse) | MIT License | - | [github.com/wellington/go-libsass](https://github.com/wellington/go-libsass) | Apache License 2.0 | - | [github.com/yosssi/ace](https://github.com/yosssi/ace) | MIT License | - | [golang.org/x/image](https://golang.org/x/image) | BSD 3-Clause "New" or "Revised" License | - | [golang.org/x/net](https://golang.org/x/net) | BSD 3-Clause "New" or "Revised" License | - | [golang.org/x/sync](https://golang.org/x/sync) | BSD 3-Clause "New" or "Revised" License | - | [golang.org/x/sys](https://golang.org/x/sys) | BSD 3-Clause "New" or "Revised" License | - | [golang.org/x/text](https://golang.org/x/text) | BSD 3-Clause "New" or "Revised" License - | [gopkg.in/yaml.v2](https://gopkg.in/yaml.v2) | Apache License 2.0 | +
+See current dependencies - - - - - - +```text +github.com/BurntSushi/locker="v0.0.0-20171006230638-a6e239ea1c69" +github.com/PuerkitoBio/goquery="v1.10.1" +github.com/alecthomas/chroma/v2="v2.15.0" +github.com/andybalholm/cascadia="v1.3.3" +github.com/armon/go-radix="v1.0.1-0.20221118154546-54df44f2176c" +github.com/bep/clocks="v0.5.0" +github.com/bep/debounce="v1.2.0" +github.com/bep/gitmap="v1.6.0" +github.com/bep/goat="v0.5.0" +github.com/bep/godartsass/v2="v2.3.2" +github.com/bep/golibsass="v1.2.0" +github.com/bep/gowebp="v0.3.0" +github.com/bep/imagemeta="v0.8.4" +github.com/bep/lazycache="v0.7.0" +github.com/bep/logg="v0.4.0" +github.com/bep/mclib="v1.20400.20402" +github.com/bep/overlayfs="v0.9.2" +github.com/bep/simplecobra="v0.5.0" +github.com/bep/tmc="v0.5.1" +github.com/cespare/xxhash/v2="v2.3.0" +github.com/clbanning/mxj/v2="v2.7.0" +github.com/cpuguy83/go-md2man/v2="v2.0.4" +github.com/disintegration/gift="v1.2.1" +github.com/dlclark/regexp2="v1.11.5" +github.com/dop251/goja="v0.0.0-20250125213203-5ef83b82af17" +github.com/evanw/esbuild="v0.24.2" +github.com/fatih/color="v1.18.0" +github.com/frankban/quicktest="v1.14.6" +github.com/fsnotify/fsnotify="v1.8.0" +github.com/getkin/kin-openapi="v0.129.0" +github.com/ghodss/yaml="v1.0.0" +github.com/go-openapi/jsonpointer="v0.21.0" +github.com/go-openapi/swag="v0.23.0" +github.com/go-sourcemap/sourcemap="v2.1.4+incompatible" +github.com/gobuffalo/flect="v1.0.3" +github.com/gobwas/glob="v0.2.3" +github.com/gohugoio/go-i18n/v2="v2.1.3-0.20230805085216-e63c13218d0e" +github.com/gohugoio/hashstructure="v0.5.0" +github.com/gohugoio/httpcache="v0.7.0" +github.com/gohugoio/hugo-goldmark-extensions/extras="v0.2.0" +github.com/gohugoio/hugo-goldmark-extensions/passthrough="v0.3.0" +github.com/gohugoio/locales="v0.14.0" +github.com/gohugoio/localescompressed="v1.0.1" +github.com/golang/freetype="v0.0.0-20170609003504-e2365dfdc4a0" +github.com/google/go-cmp="v0.6.0" +github.com/google/pprof="v0.0.0-20250208200701-d0013a598941" +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/kyokomi/emoji/v2="v2.2.13" +github.com/lucasb-eyer/go-colorful="v1.2.0" +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-runewidth="v0.0.9" +github.com/mazznoer/csscolorparser="v0.1.5" +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.7.0" +github.com/oasdiff/yaml3="v0.0.0-20241210130736-a94c01f36349" +github.com/oasdiff/yaml="v0.0.0-20241210131133-6b86fb107d80" +github.com/olekukonko/tablewriter="v0.0.5" +github.com/pbnjay/memory="v0.0.0-20210728143218-7b4eea64cf58" +github.com/pelletier/go-toml/v2="v2.2.3" +github.com/perimeterx/marshmallow="v1.1.5" +github.com/pkg/browser="v0.0.0-20240102092130-5ac0b6a4141c" +github.com/pkg/errors="v0.9.1" +github.com/rivo/uniseg="v0.4.7" +github.com/rogpeppe/go-internal="v1.13.1" +github.com/russross/blackfriday/v2="v2.1.0" +github.com/sass/libsass="3.6.6" +github.com/spf13/afero="v1.11.0" +github.com/spf13/cast="v1.7.1" +github.com/spf13/cobra="v1.8.1" +github.com/spf13/fsync="v0.10.1" +github.com/spf13/pflag="v1.0.6" +github.com/tdewolff/minify/v2="v2.20.37" +github.com/tdewolff/parse/v2="v2.7.15" +github.com/tetratelabs/wazero="v1.8.2" +github.com/webmproject/libwebp="v1.3.2" +github.com/yuin/goldmark-emoji="v1.0.4" +github.com/yuin/goldmark="v1.7.8" +go.uber.org/automaxprocs="v1.5.3" +golang.org/x/crypto="v0.33.0" +golang.org/x/exp="v0.0.0-20250210185358-939b2ce775ac" +golang.org/x/image="v0.24.0" +golang.org/x/mod="v0.23.0" +golang.org/x/net="v0.35.0" +golang.org/x/sync="v0.11.0" +golang.org/x/sys="v0.30.0" +golang.org/x/text="v0.22.0" +golang.org/x/tools="v0.30.0" +golang.org/x/xerrors="v0.0.0-20240903120638-7835f813f4da" +gonum.org/v1/plot="v0.15.0" +google.golang.org/protobuf="v1.36.5" +gopkg.in/yaml.v2="v2.4.0" +gopkg.in/yaml.v3="v3.0.1" +oss.terrastruct.com/d2="v0.6.9" +oss.terrastruct.com/util-go="v0.0.0-20241005222610-44c011a04896" +rsc.io/qr="v0.2.0" +software.sslmate.com/src/go-pkcs12="v0.2.0" +``` +
diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..6ac90f072 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,7 @@ +## Security Policy + +### 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. + +Also see [Hugo's Security Model](https://gohugo.io/about/security/). diff --git a/bench.sh b/bench.sh deleted file mode 100755 index c6a20a7e3..000000000 --- a/bench.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash - -# allow user to override go executable by running as GOEXE=xxx make ... -GOEXE="${GOEXE-go}" - -# Convenience script to -# - For a given branch -# - Run benchmark tests for a given package -# - Do the same for master -# - then compare the two runs with benchcmp - -benchFilter=".*" - -if (( $# < 2 )); - then - echo "USAGE: ./bench.sh (and (regexp, optional))" - exit 1 -fi - - - -if [ $# -eq 3 ]; then - benchFilter=$3 -fi - - -BRANCH=$1 -PACKAGE=$2 - -git checkout $BRANCH -"${GOEXE}" test -test.run=NONE -bench="$benchFilter" -test.benchmem=true ./$PACKAGE > /tmp/bench-$PACKAGE-$BRANCH.txt - -git checkout master -"${GOEXE}" test -test.run=NONE -bench="$benchFilter" -test.benchmem=true ./$PACKAGE > /tmp/bench-$PACKAGE-master.txt - - -benchcmp /tmp/bench-$PACKAGE-master.txt /tmp/bench-$PACKAGE-$BRANCH.txt diff --git a/benchSite.sh b/benchSite.sh deleted file mode 100755 index 623086708..000000000 --- a/benchSite.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash - -# allow user to override go executable by running as GOEXE=xxx make ... -GOEXE="${GOEXE-go}" - -# Send in a regexp mathing the benchmarks you want to run, i.e. './benchSite.sh "YAML"'. -# Note the quotes, which will be needed for more complex expressions. -# The above will run all variations, but only for front matter YAML. - -echo "Running with BenchmarkSiteBuilding/${1}" - -"${GOEXE}" test -run="NONE" -bench="BenchmarkSiteBuilding/${1}" -test.benchmem=true ./hugolib -memprofile mem.prof -count 3 -cpuprofile cpu.prof diff --git a/benchbep.sh b/benchbep.sh deleted file mode 100755 index efd616c88..000000000 --- a/benchbep.sh +++ /dev/null @@ -1 +0,0 @@ -gobench -package=./hugolib -bench="BenchmarkSiteNew/Deep_content_tree" \ No newline at end of file diff --git a/bepdock.sh b/bepdock.sh deleted file mode 100755 index a7ac0c639..000000000 --- a/bepdock.sh +++ /dev/null @@ -1 +0,0 @@ -docker run --rm --mount type=bind,source="$(pwd)",target=/hugo -w /hugo -i -t bepsays/ci-goreleaser:1.11-2 /bin/bash \ No newline at end of file diff --git a/bufferpool/bufpool.go b/bufferpool/bufpool.go index c1e4105d0..f05675e3e 100644 --- a/bufferpool/bufpool.go +++ b/bufferpool/bufpool.go @@ -20,7 +20,7 @@ import ( ) var bufferPool = &sync.Pool{ - New: func() interface{} { + New: func() any { return &bytes.Buffer{} }, } diff --git a/cache/docs.go b/cache/docs.go new file mode 100644 index 000000000..b9c49840f --- /dev/null +++ b/cache/docs.go @@ -0,0 +1,2 @@ +// Package cache contains the different cache implementations. +package cache diff --git a/cache/dynacache/dynacache.go b/cache/dynacache/dynacache.go new file mode 100644 index 000000000..25d0f9b29 --- /dev/null +++ b/cache/dynacache/dynacache.go @@ -0,0 +1,647 @@ +// Copyright 2024 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 dynacache + +import ( + "context" + "fmt" + "math" + "path" + "regexp" + "runtime" + "sync" + "time" + + "github.com/bep/lazycache" + "github.com/bep/logg" + "github.com/gohugoio/hugo/common/collections" + "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/common/paths" + "github.com/gohugoio/hugo/common/rungroup" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/helpers" + "github.com/gohugoio/hugo/identity" + "github.com/gohugoio/hugo/resources/resource" +) + +const minMaxSize = 10 + +type KeyIdentity struct { + Key any + Identity identity.Identity +} + +// New creates a new cache. +func New(opts Options) *Cache { + if opts.CheckInterval == 0 { + opts.CheckInterval = time.Second * 2 + } + + if opts.MaxSize == 0 { + opts.MaxSize = 100000 + } + if opts.Log == nil { + panic("nil Log") + } + + if opts.MinMaxSize == 0 { + opts.MinMaxSize = 30 + } + + stats := &stats{ + opts: opts, + adjustmentFactor: 1.0, + currentMaxSize: opts.MaxSize, + availableMemory: config.GetMemoryLimit(), + } + + infol := opts.Log.InfoCommand("dynacache") + + evictedIdentities := collections.NewStack[KeyIdentity]() + + onEvict := func(k, v any) { + if !opts.Watching { + return + } + identity.WalkIdentitiesShallow(v, func(level int, id identity.Identity) bool { + evictedIdentities.Push(KeyIdentity{Key: k, Identity: id}) + return false + }) + resource.MarkStale(v) + } + + c := &Cache{ + partitions: make(map[string]PartitionManager), + onEvict: onEvict, + evictedIdentities: evictedIdentities, + opts: opts, + stats: stats, + infol: infol, + } + + c.stop = c.start() + + return c +} + +// Options for the cache. +type Options struct { + Log loggers.Logger + CheckInterval time.Duration + MaxSize int + MinMaxSize int + Watching bool +} + +// Options for a partition. +type OptionsPartition struct { + // When to clear the this partition. + ClearWhen ClearWhen + + // Weight is a number between 1 and 100 that indicates how, in general, how big this partition may get. + Weight int +} + +func (o OptionsPartition) WeightFraction() float64 { + return float64(o.Weight) / 100 +} + +func (o OptionsPartition) CalculateMaxSize(maxSizePerPartition int) int { + return int(math.Floor(float64(maxSizePerPartition) * o.WeightFraction())) +} + +// A dynamic partitioned cache. +type Cache struct { + mu sync.RWMutex + + partitions map[string]PartitionManager + + onEvict func(k, v any) + evictedIdentities *collections.Stack[KeyIdentity] + + opts Options + infol logg.LevelLogger + + stats *stats + stopOnce sync.Once + stop func() +} + +// DrainEvictedIdentities drains the evicted identities from the cache. +func (c *Cache) DrainEvictedIdentities() []KeyIdentity { + return c.evictedIdentities.Drain() +} + +// DrainEvictedIdentitiesMatching drains the evicted identities from the cache that match the given predicate. +func (c *Cache) DrainEvictedIdentitiesMatching(predicate func(KeyIdentity) bool) []KeyIdentity { + return c.evictedIdentities.DrainMatching(predicate) +} + +// ClearMatching clears all partition for which the predicate returns true. +func (c *Cache) ClearMatching(predicatePartition func(k string, p PartitionManager) bool, predicateValue func(k, v any) bool) { + if predicatePartition == nil { + predicatePartition = func(k string, p PartitionManager) bool { return true } + } + if predicateValue == nil { + panic("nil predicateValue") + } + g := rungroup.Run[PartitionManager](context.Background(), rungroup.Config[PartitionManager]{ + NumWorkers: len(c.partitions), + Handle: func(ctx context.Context, partition PartitionManager) error { + partition.clearMatching(predicateValue) + return nil + }, + }) + + for k, p := range c.partitions { + if !predicatePartition(k, p) { + continue + } + g.Enqueue(p) + } + + g.Wait() +} + +// ClearOnRebuild prepares the cache for a new rebuild taking the given changeset into account. +// predicate is optional and will clear any entry for which it returns true. +func (c *Cache) ClearOnRebuild(predicate func(k, v any) bool, changeset ...identity.Identity) { + g := rungroup.Run[PartitionManager](context.Background(), rungroup.Config[PartitionManager]{ + NumWorkers: len(c.partitions), + Handle: func(ctx context.Context, partition PartitionManager) error { + partition.clearOnRebuild(predicate, changeset...) + return nil + }, + }) + + for _, p := range c.partitions { + g.Enqueue(p) + } + + g.Wait() + + // Clear any entries marked as stale above. + g = rungroup.Run[PartitionManager](context.Background(), rungroup.Config[PartitionManager]{ + NumWorkers: len(c.partitions), + Handle: func(ctx context.Context, partition PartitionManager) error { + partition.clearStale() + return nil + }, + }) + + for _, p := range c.partitions { + g.Enqueue(p) + } + + g.Wait() +} + +type keysProvider interface { + Keys() []string +} + +// Keys returns a list of keys in all partitions. +func (c *Cache) Keys(predicate func(s string) bool) []string { + if predicate == nil { + predicate = func(s string) bool { return true } + } + var keys []string + for pn, g := range c.partitions { + pkeys := g.(keysProvider).Keys() + for _, k := range pkeys { + p := path.Join(pn, k) + if predicate(p) { + keys = append(keys, p) + } + } + + } + return keys +} + +func calculateMaxSizePerPartition(maxItemsTotal, totalWeightQuantity, numPartitions int) int { + if numPartitions == 0 { + panic("numPartitions must be > 0") + } + if totalWeightQuantity == 0 { + panic("totalWeightQuantity must be > 0") + } + + avgWeight := float64(totalWeightQuantity) / float64(numPartitions) + return int(math.Floor(float64(maxItemsTotal) / float64(numPartitions) * (100.0 / avgWeight))) +} + +// Stop stops the cache. +func (c *Cache) Stop() { + c.stopOnce.Do(func() { + c.stop() + }) +} + +func (c *Cache) adjustCurrentMaxSize() { + c.mu.RLock() + defer c.mu.RUnlock() + + if len(c.partitions) == 0 { + return + } + var m runtime.MemStats + runtime.ReadMemStats(&m) + s := c.stats + s.memstatsCurrent = m + // fmt.Printf("\n\nAvailable = %v\nAlloc = %v\nTotalAlloc = %v\nSys = %v\nNumGC = %v\nMaxSize = %d\nAdjustmentFactor=%f\n\n", helpers.FormatByteCount(s.availableMemory), helpers.FormatByteCount(m.Alloc), helpers.FormatByteCount(m.TotalAlloc), helpers.FormatByteCount(m.Sys), m.NumGC, c.stats.currentMaxSize, s.adjustmentFactor) + + if s.availableMemory >= s.memstatsCurrent.Alloc { + if s.adjustmentFactor <= 1.0 { + s.adjustmentFactor += 0.2 + } + } else { + // We're low on memory. + s.adjustmentFactor -= 0.4 + } + + if s.adjustmentFactor <= 0 { + s.adjustmentFactor = 0.05 + } + + if !s.adjustCurrentMaxSize() { + return + } + + totalWeight := 0 + for _, pm := range c.partitions { + totalWeight += pm.getOptions().Weight + } + + maxSizePerPartition := calculateMaxSizePerPartition(c.stats.currentMaxSize, totalWeight, len(c.partitions)) + + evicted := 0 + for _, p := range c.partitions { + evicted += p.adjustMaxSize(p.getOptions().CalculateMaxSize(maxSizePerPartition)) + } + + if evicted > 0 { + c.infol. + WithFields( + logg.Fields{ + {Name: "evicted", Value: evicted}, + {Name: "numGC", Value: m.NumGC}, + {Name: "limit", Value: helpers.FormatByteCount(c.stats.availableMemory)}, + {Name: "alloc", Value: helpers.FormatByteCount(m.Alloc)}, + {Name: "totalAlloc", Value: helpers.FormatByteCount(m.TotalAlloc)}, + }, + ).Logf("adjusted partitions' max size") + } +} + +func (c *Cache) start() func() { + ticker := time.NewTicker(c.opts.CheckInterval) + quit := make(chan struct{}) + + go func() { + for { + select { + case <-ticker.C: + c.adjustCurrentMaxSize() + // Reset the ticker to avoid drift. + ticker.Reset(c.opts.CheckInterval) + case <-quit: + ticker.Stop() + return + } + } + }() + + return func() { + close(quit) + } +} + +var partitionNameRe = regexp.MustCompile(`^\/[a-zA-Z0-9]{4}(\/[a-zA-Z0-9]+)?(\/[a-zA-Z0-9]+)?`) + +// GetOrCreatePartition gets or creates a partition with the given name. +func GetOrCreatePartition[K comparable, V any](c *Cache, name string, opts OptionsPartition) *Partition[K, V] { + if c == nil { + panic("nil Cache") + } + if opts.Weight < 1 || opts.Weight > 100 { + panic("invalid Weight, must be between 1 and 100") + } + + if partitionNameRe.FindString(name) != name { + panic(fmt.Sprintf("invalid partition name %q", name)) + } + + c.mu.RLock() + p, found := c.partitions[name] + c.mu.RUnlock() + if found { + return p.(*Partition[K, V]) + } + + c.mu.Lock() + defer c.mu.Unlock() + + // Double check. + p, found = c.partitions[name] + if found { + return p.(*Partition[K, V]) + } + + // At this point, we don't know the number of partitions or their configuration, but + // this will be re-adjusted later. + const numberOfPartitionsEstimate = 10 + maxSize := opts.CalculateMaxSize(c.opts.MaxSize / numberOfPartitionsEstimate) + + onEvict := func(k K, v V) { + c.onEvict(k, v) + } + + // Create a new partition and cache it. + partition := &Partition[K, V]{ + c: lazycache.New(lazycache.Options[K, V]{MaxEntries: maxSize, OnEvict: onEvict}), + maxSize: maxSize, + trace: c.opts.Log.Logger().WithLevel(logg.LevelTrace).WithField("partition", name), + opts: opts, + } + + c.partitions[name] = partition + + return partition +} + +// Partition is a partition in the cache. +type Partition[K comparable, V any] struct { + c *lazycache.Cache[K, V] + + zero V + + trace logg.LevelLogger + opts OptionsPartition + + maxSize int +} + +// GetOrCreate gets or creates a value for the given key. +func (p *Partition[K, V]) GetOrCreate(key K, create func(key K) (V, error)) (V, error) { + v, err := p.doGetOrCreate(key, create) + if err != nil { + return p.zero, err + } + if resource.StaleVersion(v) > 0 { + p.c.Delete(key) + return p.doGetOrCreate(key, create) + } + return v, err +} + +func (p *Partition[K, V]) doGetOrCreate(key K, create func(key K) (V, error)) (V, error) { + v, _, err := p.c.GetOrCreate(key, create) + return v, err +} + +func (p *Partition[K, V]) GetOrCreateWitTimeout(key K, duration time.Duration, create func(key K) (V, error)) (V, error) { + v, err := p.doGetOrCreateWitTimeout(key, duration, create) + if err != nil { + return p.zero, err + } + if resource.StaleVersion(v) > 0 { + p.c.Delete(key) + return p.doGetOrCreateWitTimeout(key, duration, create) + } + return v, err +} + +// GetOrCreateWitTimeout gets or creates a value for the given key and times out if the create function +// takes too long. +func (p *Partition[K, V]) doGetOrCreateWitTimeout(key K, duration time.Duration, create func(key K) (V, error)) (V, error) { + resultch := make(chan V, 1) + errch := make(chan error, 1) + + go func() { + var ( + v V + err error + ) + defer func() { + if r := recover(); r != nil { + if rerr, ok := r.(error); ok { + err = rerr + } else { + err = fmt.Errorf("panic: %v", r) + } + } + if err != nil { + errch <- err + } else { + resultch <- v + } + }() + v, _, err = p.c.GetOrCreate(key, create) + }() + + select { + case v := <-resultch: + return v, nil + case err := <-errch: + return p.zero, err + case <-time.After(duration): + return p.zero, &herrors.TimeoutError{ + Duration: duration, + } + } +} + +func (p *Partition[K, V]) clearMatching(predicate func(k, v any) bool) { + p.c.DeleteFunc(func(key K, v V) bool { + if predicate(key, v) { + p.trace.Log( + logg.StringFunc( + func() string { + return fmt.Sprintf("clearing cache key %v", key) + }, + ), + ) + return true + } + return false + }) +} + +func (p *Partition[K, V]) clearOnRebuild(predicate func(k, v any) bool, changeset ...identity.Identity) { + if predicate == nil { + predicate = func(k, v any) bool { + return false + } + } + opts := p.getOptions() + if opts.ClearWhen == ClearNever { + return + } + + if opts.ClearWhen == ClearOnRebuild { + // Clear all. + p.Clear() + return + } + + depsFinder := identity.NewFinder(identity.FinderConfig{}) + + shouldDelete := func(key K, v V) bool { + // We always clear elements marked as stale. + if resource.StaleVersion(v) > 0 { + return true + } + + // Now check if this entry has changed based on the changeset + // based on filesystem events. + if len(changeset) == 0 { + // Nothing changed. + return false + } + + var probablyDependent bool + identity.WalkIdentitiesShallow(v, func(level int, id2 identity.Identity) bool { + for _, id := range changeset { + if r := depsFinder.Contains(id, id2, -1); r > 0 { + // It's probably dependent, evict from cache. + probablyDependent = true + return true + } + } + return false + }) + + return probablyDependent + } + + // First pass. + // Second pass needs to be done in a separate loop to catch any + // elements marked as stale in the other partitions. + p.c.DeleteFunc(func(key K, v V) bool { + if predicate(key, v) || shouldDelete(key, v) { + p.trace.Log( + logg.StringFunc( + func() string { + return fmt.Sprintf("first pass: clearing cache key %v", key) + }, + ), + ) + return true + } + return false + }) +} + +func (p *Partition[K, V]) Keys() []K { + var keys []K + p.c.DeleteFunc(func(key K, v V) bool { + keys = append(keys, key) + return false + }) + return keys +} + +func (p *Partition[K, V]) clearStale() { + p.c.DeleteFunc(func(key K, v V) bool { + staleVersion := resource.StaleVersion(v) + if staleVersion > 0 { + p.trace.Log( + logg.StringFunc( + func() string { + return fmt.Sprintf("second pass: clearing cache key %v", key) + }, + ), + ) + } + + return staleVersion > 0 + }) +} + +// adjustMaxSize adjusts the max size of the and returns the number of items evicted. +func (p *Partition[K, V]) adjustMaxSize(newMaxSize int) int { + if newMaxSize < minMaxSize { + newMaxSize = minMaxSize + } + oldMaxSize := p.maxSize + if newMaxSize == oldMaxSize { + return 0 + } + p.maxSize = newMaxSize + // fmt.Println("Adjusting max size of partition from", oldMaxSize, "to", newMaxSize) + return p.c.Resize(newMaxSize) +} + +func (p *Partition[K, V]) getMaxSize() int { + return p.maxSize +} + +func (p *Partition[K, V]) getOptions() OptionsPartition { + return p.opts +} + +func (p *Partition[K, V]) Clear() { + p.c.DeleteFunc(func(key K, v V) bool { + return true + }) +} + +func (p *Partition[K, V]) Get(ctx context.Context, key K) (V, bool) { + return p.c.Get(key) +} + +type PartitionManager interface { + adjustMaxSize(addend int) int + getMaxSize() int + getOptions() OptionsPartition + clearOnRebuild(predicate func(k, v any) bool, changeset ...identity.Identity) + clearMatching(predicate func(k, v any) bool) + clearStale() +} + +const ( + ClearOnRebuild ClearWhen = iota + 1 + ClearOnChange + ClearNever +) + +type ClearWhen int + +type stats struct { + opts Options + memstatsCurrent runtime.MemStats + currentMaxSize int + availableMemory uint64 + + adjustmentFactor float64 +} + +func (s *stats) adjustCurrentMaxSize() bool { + newCurrentMaxSize := int(math.Floor(float64(s.opts.MaxSize) * s.adjustmentFactor)) + + if newCurrentMaxSize < s.opts.MinMaxSize { + newCurrentMaxSize = int(s.opts.MinMaxSize) + } + changed := newCurrentMaxSize != s.currentMaxSize + s.currentMaxSize = newCurrentMaxSize + return changed +} + +// CleanKey turns s into a format suitable for a cache key for this package. +// The key will be a Unix-styled path with a leading slash but no trailing slash. +func CleanKey(s string) string { + return path.Clean(paths.ToSlashPreserveLeading(s)) +} diff --git a/cache/dynacache/dynacache_test.go b/cache/dynacache/dynacache_test.go new file mode 100644 index 000000000..78b2fc82e --- /dev/null +++ b/cache/dynacache/dynacache_test.go @@ -0,0 +1,230 @@ +// Copyright 2024 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 dynacache + +import ( + "errors" + "fmt" + "path/filepath" + "testing" + "time" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/identity" + "github.com/gohugoio/hugo/resources/resource" +) + +var ( + _ resource.StaleInfo = (*testItem)(nil) + _ identity.Identity = (*testItem)(nil) +) + +type testItem struct { + name string + staleVersion uint32 +} + +func (t testItem) StaleVersion() uint32 { + return t.staleVersion +} + +func (t testItem) IdentifierBase() string { + return t.name +} + +func TestCache(t *testing.T) { + t.Parallel() + c := qt.New(t) + + cache := New(Options{ + Log: loggers.NewDefault(), + }) + + c.Cleanup(func() { + cache.Stop() + }) + + opts := OptionsPartition{Weight: 30} + + c.Assert(cache, qt.Not(qt.IsNil)) + + p1 := GetOrCreatePartition[string, testItem](cache, "/aaaa/bbbb", opts) + c.Assert(p1, qt.Not(qt.IsNil)) + + p2 := GetOrCreatePartition[string, testItem](cache, "/aaaa/bbbb", opts) + + c.Assert(func() { GetOrCreatePartition[string, testItem](cache, "foo bar", opts) }, qt.PanicMatches, ".*invalid partition name.*") + c.Assert(func() { GetOrCreatePartition[string, testItem](cache, "/aaaa/cccc", OptionsPartition{Weight: 1234}) }, qt.PanicMatches, ".*invalid Weight.*") + + c.Assert(p2, qt.Equals, p1) + + p3 := GetOrCreatePartition[string, testItem](cache, "/aaaa/cccc", opts) + c.Assert(p3, qt.Not(qt.IsNil)) + c.Assert(p3, qt.Not(qt.Equals), p1) + + c.Assert(func() { New(Options{}) }, qt.PanicMatches, ".*nil Log.*") +} + +func TestCalculateMaxSizePerPartition(t *testing.T) { + t.Parallel() + c := qt.New(t) + + c.Assert(calculateMaxSizePerPartition(1000, 500, 5), qt.Equals, 200) + c.Assert(calculateMaxSizePerPartition(1000, 250, 5), qt.Equals, 400) + c.Assert(func() { calculateMaxSizePerPartition(1000, 250, 0) }, qt.PanicMatches, ".*must be > 0.*") + c.Assert(func() { calculateMaxSizePerPartition(1000, 0, 1) }, qt.PanicMatches, ".*must be > 0.*") +} + +func TestCleanKey(t *testing.T) { + c := qt.New(t) + + c.Assert(CleanKey("a/b/c"), qt.Equals, "/a/b/c") + c.Assert(CleanKey("/a/b/c"), qt.Equals, "/a/b/c") + c.Assert(CleanKey("a/b/c/"), qt.Equals, "/a/b/c") + c.Assert(CleanKey(filepath.FromSlash("/a/b/c/")), qt.Equals, "/a/b/c") +} + +func newTestCache(t *testing.T) *Cache { + cache := New( + Options{ + Log: loggers.NewDefault(), + }, + ) + + p1 := GetOrCreatePartition[string, testItem](cache, "/aaaa/bbbb", OptionsPartition{Weight: 30, ClearWhen: ClearOnRebuild}) + p2 := GetOrCreatePartition[string, testItem](cache, "/aaaa/cccc", OptionsPartition{Weight: 30, ClearWhen: ClearOnChange}) + + p1.GetOrCreate("clearOnRebuild", func(string) (testItem, error) { + return testItem{}, nil + }) + + p2.GetOrCreate("clearBecauseStale", func(string) (testItem, error) { + return testItem{ + staleVersion: 32, + }, nil + }) + + p2.GetOrCreate("clearBecauseIdentityChanged", func(string) (testItem, error) { + return testItem{ + name: "changed", + }, nil + }) + + p2.GetOrCreate("clearNever", func(string) (testItem, error) { + return testItem{ + staleVersion: 0, + }, nil + }) + + t.Cleanup(func() { + cache.Stop() + }) + + return cache +} + +func TestClear(t *testing.T) { + t.Parallel() + c := qt.New(t) + + predicateAll := func(string) bool { + return true + } + + cache := newTestCache(t) + + c.Assert(cache.Keys(predicateAll), qt.HasLen, 4) + + cache.ClearOnRebuild(nil) + + // Stale items are always cleared. + c.Assert(cache.Keys(predicateAll), qt.HasLen, 2) + + cache = newTestCache(t) + cache.ClearOnRebuild(nil, identity.StringIdentity("changed")) + + c.Assert(cache.Keys(nil), qt.HasLen, 1) + + cache = newTestCache(t) + + cache.ClearMatching(nil, func(k, v any) bool { + return k.(string) == "clearOnRebuild" + }) + + c.Assert(cache.Keys(predicateAll), qt.HasLen, 3) + + cache.adjustCurrentMaxSize() +} + +func TestPanicInCreate(t *testing.T) { + t.Parallel() + c := qt.New(t) + cache := newTestCache(t) + + p1 := GetOrCreatePartition[string, testItem](cache, "/aaaa/bbbb", OptionsPartition{Weight: 30, ClearWhen: ClearOnRebuild}) + + willPanic := func(i int) func() { + return func() { + p1.GetOrCreate(fmt.Sprintf("panic-%d", i), func(key string) (testItem, error) { + panic(errors.New(key)) + }) + } + } + + // GetOrCreateWitTimeout needs to recover from panics in the create func. + willErr := func(i int) error { + _, err := p1.GetOrCreateWitTimeout(fmt.Sprintf("error-%d", i), 10*time.Second, func(key string) (testItem, error) { + return testItem{}, errors.New(key) + }) + return err + } + + for i := range 3 { + for range 3 { + c.Assert(willPanic(i), qt.PanicMatches, fmt.Sprintf("panic-%d", i)) + c.Assert(willErr(i), qt.ErrorMatches, fmt.Sprintf("error-%d", i)) + } + } + + // Test the same keys again without the panic. + for i := range 3 { + for range 3 { + v, err := p1.GetOrCreate(fmt.Sprintf("panic-%d", i), func(key string) (testItem, error) { + return testItem{ + name: key, + }, nil + }) + c.Assert(err, qt.IsNil) + c.Assert(v.name, qt.Equals, fmt.Sprintf("panic-%d", i)) + + v, err = p1.GetOrCreateWitTimeout(fmt.Sprintf("error-%d", i), 10*time.Second, func(key string) (testItem, error) { + return testItem{ + name: key, + }, nil + }) + c.Assert(err, qt.IsNil) + c.Assert(v.name, qt.Equals, fmt.Sprintf("error-%d", i)) + } + } +} + +func TestAdjustCurrentMaxSize(t *testing.T) { + t.Parallel() + c := qt.New(t) + cache := newTestCache(t) + alloc := cache.stats.memstatsCurrent.Alloc + cache.adjustCurrentMaxSize() + c.Assert(cache.stats.memstatsCurrent.Alloc, qt.Not(qt.Equals), alloc) +} diff --git a/cache/filecache/filecache.go b/cache/filecache/filecache.go index 37870dd5f..01c466ca6 100644 --- a/cache/filecache/filecache.go +++ b/cache/filecache/filecache.go @@ -1,4 +1,4 @@ -// Copyright 2018 The Hugo Authors. All rights reserved. +// Copyright 2024 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. @@ -17,14 +17,15 @@ import ( "bytes" "errors" "io" - "io/ioutil" "os" "path/filepath" "strings" "sync" "time" + "github.com/gohugoio/httpcache" "github.com/gohugoio/hugo/common/hugio" + "github.com/gohugoio/hugo/hugofs" "github.com/gohugoio/hugo/helpers" @@ -36,7 +37,7 @@ import ( var ErrFatal = errors.New("fatal filecache error") const ( - filecacheRootDirname = "filecache" + FilecacheRootDirname = "filecache" ) // Cache caches a set of files in a directory. This is usually a file on @@ -52,6 +53,9 @@ type Cache struct { pruneAllRootDir string nlocker *lockTracker + + initOnce sync.Once + initErr error } type lockTracker struct { @@ -104,9 +108,23 @@ func (l *lockedFile) Close() error { return l.File.Close() } +func (c *Cache) init() error { + c.initOnce.Do(func() { + // Create the base dir if it does not exist. + if err := c.Fs.MkdirAll("", 0o777); err != nil && !os.IsExist(err) { + c.initErr = err + } + }) + return c.initErr +} + // WriteCloser returns a transactional writer into the cache. // It's important that it's closed when done. func (c *Cache) WriteCloser(id string) (ItemInfo, io.WriteCloser, error) { + if err := c.init(); err != nil { + return ItemInfo{}, nil, err + } + id = cleanID(id) c.nlocker.Lock(id) @@ -130,7 +148,12 @@ func (c *Cache) WriteCloser(id string) (ItemInfo, io.WriteCloser, error) { // it when done. func (c *Cache) ReadOrCreate(id string, read func(info ItemInfo, r io.ReadSeeker) error, - create func(info ItemInfo, w io.WriteCloser) error) (info ItemInfo, err error) { + create func(info ItemInfo, w io.WriteCloser) error, +) (info ItemInfo, err error) { + if err := c.init(); err != nil { + return ItemInfo{}, err + } + id = cleanID(id) c.nlocker.Lock(id) @@ -158,13 +181,24 @@ func (c *Cache) ReadOrCreate(id string, err = create(info, f) return +} +// NamedLock locks the given id. The lock is released when the returned function is called. +func (c *Cache) NamedLock(id string) func() { + id = cleanID(id) + c.nlocker.Lock(id) + return func() { + c.nlocker.Unlock(id) + } } // GetOrCreate tries to get the file with the given id from cache. If not found or expired, create will // be invoked and the result cached. // This method is protected by a named lock using the given id as identifier. func (c *Cache) GetOrCreate(id string, create func() (io.ReadCloser, error)) (ItemInfo, io.ReadCloser, error) { + if err := c.init(); err != nil { + return ItemInfo{}, nil, err + } id = cleanID(id) c.nlocker.Lock(id) @@ -176,7 +210,12 @@ func (c *Cache) GetOrCreate(id string, create func() (io.ReadCloser, error)) (It return info, r, nil } - r, err := create() + var ( + r io.ReadCloser + err error + ) + + r, err = create() if err != nil { return info, nil, err } @@ -189,11 +228,30 @@ func (c *Cache) GetOrCreate(id string, create func() (io.ReadCloser, error)) (It var buff bytes.Buffer return info, hugio.ToReadCloser(&buff), - afero.WriteReader(c.Fs, id, io.TeeReader(r, &buff)) + c.writeReader(id, io.TeeReader(r, &buff)) +} + +func (c *Cache) writeReader(id string, r io.Reader) error { + dir := filepath.Dir(id) + if dir != "" { + _ = c.Fs.MkdirAll(dir, 0o777) + } + f, err := c.Fs.Create(id) + if err != nil { + return err + } + defer f.Close() + + _, _ = io.Copy(f, r) + + return nil } // GetOrCreateBytes is the same as GetOrCreate, but produces a byte slice. func (c *Cache) GetOrCreateBytes(id string, create func() ([]byte, error)) (ItemInfo, []byte, error) { + if err := c.init(); err != nil { + return ItemInfo{}, nil, err + } id = cleanID(id) c.nlocker.Lock(id) @@ -203,11 +261,16 @@ func (c *Cache) GetOrCreateBytes(id string, create func() ([]byte, error)) (Item if r := c.getOrRemove(id); r != nil { defer r.Close() - b, err := ioutil.ReadAll(r) + b, err := io.ReadAll(r) return info, b, err } - b, err := create() + var ( + b []byte + err error + ) + + b, err = create() if err != nil { return info, nil, err } @@ -216,15 +279,18 @@ func (c *Cache) GetOrCreateBytes(id string, create func() ([]byte, error)) (Item return info, b, nil } - if err := afero.WriteReader(c.Fs, id, bytes.NewReader(b)); err != nil { + if err := c.writeReader(id, bytes.NewReader(b)); err != nil { return info, nil, err } - return info, b, nil + return info, b, nil } -// GetBytes gets the file content with the given id from the cahce, nil if none found. +// GetBytes gets the file content with the given id from the cache, nil if none found. func (c *Cache) GetBytes(id string) (ItemInfo, []byte, error) { + if err := c.init(); err != nil { + return ItemInfo{}, nil, err + } id = cleanID(id) c.nlocker.Lock(id) @@ -234,15 +300,18 @@ func (c *Cache) GetBytes(id string) (ItemInfo, []byte, error) { if r := c.getOrRemove(id); r != nil { defer r.Close() - b, err := ioutil.ReadAll(r) + b, err := io.ReadAll(r) return info, b, err } return info, nil, nil } -// Get gets the file with the given id from the cahce, nil if none found. +// Get gets the file with the given id from the cache, nil if none found. func (c *Cache) Get(id string) (ItemInfo, io.ReadCloser, error) { + if err := c.init(); err != nil { + return ItemInfo{}, nil, err + } id = cleanID(id) c.nlocker.Lock(id) @@ -263,20 +332,11 @@ func (c *Cache) getOrRemove(id string) hugio.ReadSeekCloser { return nil } - if c.maxAge > 0 { - fi, err := c.Fs.Stat(id) - if err != nil { - return nil - } - - if c.isExpired(fi.ModTime()) { - c.Fs.Remove(id) - return nil - } + if removed, err := c.removeIfExpired(id); err != nil || removed { + return nil } f, err := c.Fs.Open(id) - if err != nil { return nil } @@ -284,30 +344,74 @@ func (c *Cache) getOrRemove(id string) hugio.ReadSeekCloser { return f } +func (c *Cache) getBytesAndRemoveIfExpired(id string) ([]byte, bool) { + if c.maxAge == 0 { + // No caching. + return nil, false + } + + f, err := c.Fs.Open(id) + if err != nil { + return nil, false + } + defer f.Close() + + b, err := io.ReadAll(f) + if err != nil { + return nil, false + } + + removed, err := c.removeIfExpired(id) + if err != nil { + return nil, false + } + + return b, removed +} + +func (c *Cache) removeIfExpired(id string) (bool, error) { + if c.maxAge <= 0 { + return false, nil + } + + fi, err := c.Fs.Stat(id) + if err != nil { + return false, err + } + + if c.isExpired(fi.ModTime()) { + c.Fs.Remove(id) + return true, nil + } + + return false, nil +} + func (c *Cache) isExpired(modTime time.Time) bool { if c.maxAge < 0 { return false } + + // Note the use of time.Since here. + // We cannot use Hugo's global Clock for this. return c.maxAge == 0 || time.Since(modTime) > c.maxAge } // For testing -func (c *Cache) getString(id string) string { +func (c *Cache) GetString(id string) string { id = cleanID(id) c.nlocker.Lock(id) defer c.nlocker.Unlock(id) f, err := c.Fs.Open(id) - if err != nil { return "" } defer f.Close() - b, _ := ioutil.ReadAll(f) + b, _ := io.ReadAll(f) return string(b) - } // Caches is a named set of caches. @@ -321,47 +425,29 @@ func (f Caches) Get(name string) *Cache { // NewCaches creates a new set of file caches from the given // configuration. func NewCaches(p *helpers.PathSpec) (Caches, error) { - var dcfg Configs - if c, ok := p.Cfg.Get("filecacheConfigs").(Configs); ok { - dcfg = c - } else { - var err error - dcfg, err = DecodeConfig(p.Fs.Source, p.Cfg) - if err != nil { - return nil, err - } - } - + dcfg := p.Cfg.GetConfigSection("caches").(Configs) fs := p.Fs.Source m := make(Caches) for k, v := range dcfg { var cfs afero.Fs - if v.isResourceDir { + if v.IsResourceDir { cfs = p.BaseFs.ResourcesCache } else { cfs = fs } if cfs == nil { - // TODO(bep) we still have some places that do not initialize the - // full dependencies of a site, e.g. the import Jekyll command. - // That command does not need these caches, so let us just continue - // for now. - continue + panic("nil fs") } - baseDir := v.Dir + baseDir := v.DirCompiled - if err := cfs.MkdirAll(baseDir, 0777); err != nil && !os.IsExist(err) { - return nil, err - } - - bfs := afero.NewBasePathFs(cfs, baseDir) + bfs := hugofs.NewBasePathFs(cfs, baseDir) var pruneAllRootDir string - if k == cacheKeyModules { + if k == CacheKeyModules { pruneAllRootDir = "pkg" } @@ -374,3 +460,37 @@ func NewCaches(p *helpers.PathSpec) (Caches, error) { func cleanID(name string) string { return strings.TrimPrefix(filepath.Clean(name), helpers.FilePathSeparator) } + +// AsHTTPCache returns an httpcache.Cache implementation for this file cache. +// Note that none of the methods are protected by named locks, so you need to make sure +// to do that in your own code. +func (c *Cache) AsHTTPCache() httpcache.Cache { + return &httpCache{c: c} +} + +type httpCache struct { + c *Cache +} + +func (h *httpCache) Get(id string) (resp []byte, ok bool) { + id = cleanID(id) + b, removed := h.c.getBytesAndRemoveIfExpired(id) + + return b, !removed +} + +func (h *httpCache) Set(id string, resp []byte) { + if h.c.maxAge == 0 { + return + } + + id = cleanID(id) + + if err := h.c.writeReader(id, bytes.NewReader(resp)); err != nil { + panic(err) + } +} + +func (h *httpCache) Delete(key string) { + h.c.Fs.Remove(key) +} diff --git a/cache/filecache/filecache_config.go b/cache/filecache/filecache_config.go index 0c6b569c1..a71ddb474 100644 --- a/cache/filecache/filecache_config.go +++ b/cache/filecache/filecache_config.go @@ -11,105 +11,131 @@ // See the License for the specific language governing permissions and // limitations under the License. +// Package filecache provides a file based cache for Hugo. package filecache import ( + "errors" + "fmt" "path" "path/filepath" "strings" "time" + "github.com/gohugoio/hugo/common/maps" "github.com/gohugoio/hugo/config" - "github.com/gohugoio/hugo/helpers" - "github.com/mitchellh/mapstructure" - "github.com/pkg/errors" "github.com/spf13/afero" ) const ( - cachesConfigKey = "caches" - resourcesGenDir = ":resourceDir/_gen" + cacheDirProject = ":cacheDir/:project" ) -var defaultCacheConfig = Config{ +var defaultCacheConfig = FileCacheConfig{ MaxAge: -1, // Never expire - Dir: ":cacheDir/:project", + Dir: cacheDirProject, } const ( - cacheKeyGetJSON = "getjson" - cacheKeyGetCSV = "getcsv" - cacheKeyImages = "images" - cacheKeyAssets = "assets" - cacheKeyModules = "modules" + CacheKeyGetJSON = "getjson" + CacheKeyGetCSV = "getcsv" + CacheKeyImages = "images" + CacheKeyAssets = "assets" + CacheKeyModules = "modules" + CacheKeyGetResource = "getresource" + CacheKeyMisc = "misc" ) -type Configs map[string]Config +type Configs map[string]FileCacheConfig +// For internal use. func (c Configs) CacheDirModules() string { - return c[cacheKeyModules].Dir + return c[CacheKeyModules].DirCompiled } var defaultCacheConfigs = Configs{ - cacheKeyModules: { + CacheKeyModules: { MaxAge: -1, Dir: ":cacheDir/modules", }, - cacheKeyGetJSON: defaultCacheConfig, - cacheKeyGetCSV: defaultCacheConfig, - cacheKeyImages: { + CacheKeyGetJSON: defaultCacheConfig, + CacheKeyGetCSV: defaultCacheConfig, + CacheKeyImages: { MaxAge: -1, Dir: resourcesGenDir, }, - cacheKeyAssets: { + CacheKeyAssets: { MaxAge: -1, Dir: resourcesGenDir, }, + CacheKeyGetResource: { + MaxAge: -1, // Never expire + Dir: cacheDirProject, + }, + CacheKeyMisc: { + MaxAge: -1, + Dir: cacheDirProject, + }, } -type Config struct { +type FileCacheConfig struct { // Max age of cache entries in this cache. Any items older than this will // be removed and not returned from the cache. - // a negative value means forever, 0 means cache is disabled. + // A negative value means forever, 0 means cache is disabled. + // Hugo is lenient with what types it accepts here, but we recommend using + // a duration string, a sequence of decimal numbers, each with optional fraction and a unit suffix, + // such as "300ms", "1.5h" or "2h45m". + // Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h". MaxAge time.Duration // The directory where files are stored. - Dir string + Dir string + DirCompiled string `json:"-"` // Will resources/_gen will get its own composite filesystem that // also checks any theme. - isResourceDir bool + IsResourceDir bool `json:"-"` } // GetJSONCache gets the file cache for getJSON. func (f Caches) GetJSONCache() *Cache { - return f[cacheKeyGetJSON] + return f[CacheKeyGetJSON] } // GetCSVCache gets the file cache for getCSV. func (f Caches) GetCSVCache() *Cache { - return f[cacheKeyGetCSV] + return f[CacheKeyGetCSV] } // ImageCache gets the file cache for processed images. func (f Caches) ImageCache() *Cache { - return f[cacheKeyImages] + return f[CacheKeyImages] } // ModulesCache gets the file cache for Hugo Modules. func (f Caches) ModulesCache() *Cache { - return f[cacheKeyModules] + return f[CacheKeyModules] } // AssetsCache gets the file cache for assets (processed resources, SCSS etc.). func (f Caches) AssetsCache() *Cache { - return f[cacheKeyAssets] + return f[CacheKeyAssets] } -func DecodeConfig(fs afero.Fs, cfg config.Provider) (Configs, error) { +// MiscCache gets the file cache for miscellaneous stuff. +func (f Caches) MiscCache() *Cache { + return f[CacheKeyMisc] +} + +// GetResourceCache gets the file cache for remote resources. +func (f Caches) GetResourceCache() *Cache { + return f[CacheKeyGetResource] +} + +func DecodeConfig(fs afero.Fs, bcfg config.BaseConfig, m map[string]any) (Configs, error) { c := make(Configs) valid := make(map[string]bool) // Add defaults @@ -118,11 +144,12 @@ func DecodeConfig(fs afero.Fs, cfg config.Provider) (Configs, error) { valid[k] = true } - m := cfg.GetStringMap(cachesConfigKey) - _, isOsFs := fs.(*afero.OsFs) for k, v := range m { + if _, ok := v.(maps.Params); !ok { + continue + } cc := defaultCacheConfig dc := &mapstructure.DecoderConfig{ @@ -137,7 +164,7 @@ func DecodeConfig(fs afero.Fs, cfg config.Provider) (Configs, error) { } if err := decoder.Decode(v); err != nil { - return nil, err + return nil, fmt.Errorf("failed to decode filecache config: %w", err) } if cc.Dir == "" { @@ -146,15 +173,12 @@ func DecodeConfig(fs afero.Fs, cfg config.Provider) (Configs, error) { name := strings.ToLower(k) if !valid[name] { - return nil, errors.Errorf("%q is not a valid cache name", name) + return nil, fmt.Errorf("%q is not a valid cache name", name) } c[name] = cc } - // This is a very old flag in Hugo, but we need to respect it. - disabled := cfg.GetBool("ignoreCache") - for k, v := range c { dir := filepath.ToSlash(filepath.Clean(v.Dir)) hadSlash := strings.HasPrefix(dir, "/") @@ -162,12 +186,12 @@ func DecodeConfig(fs afero.Fs, cfg config.Provider) (Configs, error) { for i, part := range parts { if strings.HasPrefix(part, ":") { - resolved, isResource, err := resolveDirPlaceholder(fs, cfg, part) + resolved, isResource, err := resolveDirPlaceholder(fs, bcfg, part) if err != nil { return c, err } if isResource { - v.isResourceDir = true + v.IsResourceDir = true } parts[i] = resolved } @@ -177,33 +201,29 @@ func DecodeConfig(fs afero.Fs, cfg config.Provider) (Configs, error) { if hadSlash { dir = "/" + dir } - v.Dir = filepath.Clean(filepath.FromSlash(dir)) + v.DirCompiled = filepath.Clean(filepath.FromSlash(dir)) - if !v.isResourceDir { - if isOsFs && !filepath.IsAbs(v.Dir) { - return c, errors.Errorf("%q must resolve to an absolute directory", v.Dir) + if !v.IsResourceDir { + if isOsFs && !filepath.IsAbs(v.DirCompiled) { + return c, fmt.Errorf("%q must resolve to an absolute directory", v.DirCompiled) } // Avoid cache in root, e.g. / (Unix) or c:\ (Windows) - if len(strings.TrimPrefix(v.Dir, filepath.VolumeName(v.Dir))) == 1 { - return c, errors.Errorf("%q is a root folder and not allowed as cache dir", v.Dir) + if len(strings.TrimPrefix(v.DirCompiled, filepath.VolumeName(v.DirCompiled))) == 1 { + return c, fmt.Errorf("%q is a root folder and not allowed as cache dir", v.DirCompiled) } } - if !strings.HasPrefix(v.Dir, "_gen") { + if !strings.HasPrefix(v.DirCompiled, "_gen") { // We do cache eviction (file removes) and since the user can set // his/hers own cache directory, we really want to make sure // we do not delete any files that do not belong to this cache. // We do add the cache name as the root, but this is an extra safe // guard. We skip the files inside /resources/_gen/ because // that would be breaking. - v.Dir = filepath.Join(v.Dir, filecacheRootDirname, k) + v.DirCompiled = filepath.Join(v.DirCompiled, FilecacheRootDirname, k) } else { - v.Dir = filepath.Join(v.Dir, k) - } - - if disabled { - v.MaxAge = 0 + v.DirCompiled = filepath.Join(v.DirCompiled, k) } c[k] = v @@ -213,18 +233,15 @@ func DecodeConfig(fs afero.Fs, cfg config.Provider) (Configs, error) { } // Resolves :resourceDir => /myproject/resources etc., :cacheDir => ... -func resolveDirPlaceholder(fs afero.Fs, cfg config.Provider, placeholder string) (cacheDir string, isResource bool, err error) { - workingDir := cfg.GetString("workingDir") - +func resolveDirPlaceholder(fs afero.Fs, bcfg config.BaseConfig, placeholder string) (cacheDir string, isResource bool, err error) { switch strings.ToLower(placeholder) { case ":resourcedir": return "", true, nil case ":cachedir": - d, err := helpers.GetCacheDir(fs, cfg) - return d, false, err + return bcfg.CacheDir, false, nil case ":project": - return filepath.Base(workingDir), false, nil + return filepath.Base(bcfg.WorkingDir), false, nil } - return "", false, errors.Errorf("%q is not a valid placeholder (valid values are :cacheDir or :resourceDir)", placeholder) + return "", false, fmt.Errorf("%q is not a valid placeholder (valid values are :cacheDir or :resourceDir)", placeholder) } diff --git a/cache/filecache/filecache_config_test.go b/cache/filecache/filecache_config_test.go index 9f80a4f90..c6d346dfc 100644 --- a/cache/filecache/filecache_config_test.go +++ b/cache/filecache/filecache_config_test.go @@ -11,21 +11,21 @@ // See the License for the specific language governing permissions and // limitations under the License. -package filecache +package filecache_test import ( "path/filepath" "runtime" - "strings" "testing" "time" "github.com/spf13/afero" + "github.com/gohugoio/hugo/cache/filecache" "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/config/testconfig" qt "github.com/frankban/quicktest" - "github.com/spf13/viper" ) func TestDecodeConfig(t *testing.T) { @@ -51,25 +51,27 @@ maxAge = "11h" dir = "/path/to/c2" [caches.images] dir = "/path/to/c3" - +[caches.getResource] +dir = "/path/to/c4" ` cfg, err := config.FromConfigString(configStr, "toml") c.Assert(err, qt.IsNil) fs := afero.NewMemMapFs() - decoded, err := DecodeConfig(fs, cfg) - c.Assert(err, qt.IsNil) - - c.Assert(len(decoded), qt.Equals, 5) + decoded := testconfig.GetTestConfigs(fs, cfg).Base.Caches + c.Assert(len(decoded), qt.Equals, 7) c2 := decoded["getcsv"] c.Assert(c2.MaxAge.String(), qt.Equals, "11h0m0s") - c.Assert(c2.Dir, qt.Equals, filepath.FromSlash("/path/to/c2/filecache/getcsv")) + c.Assert(c2.DirCompiled, qt.Equals, filepath.FromSlash("/path/to/c2/filecache/getcsv")) c3 := decoded["images"] c.Assert(c3.MaxAge, qt.Equals, time.Duration(-1)) - c.Assert(c3.Dir, qt.Equals, filepath.FromSlash("/path/to/c3/filecache/images")) + c.Assert(c3.DirCompiled, qt.Equals, filepath.FromSlash("/path/to/c3/filecache/images")) + c4 := decoded["getresource"] + c.Assert(c4.MaxAge, qt.Equals, time.Duration(-1)) + c.Assert(c4.DirCompiled, qt.Equals, filepath.FromSlash("/path/to/c4/filecache/getresource")) } func TestDecodeConfigIgnoreCache(t *testing.T) { @@ -96,26 +98,24 @@ maxAge = 3456 dir = "/path/to/c2" [caches.images] dir = "/path/to/c3" - +[caches.getResource] +dir = "/path/to/c4" ` cfg, err := config.FromConfigString(configStr, "toml") c.Assert(err, qt.IsNil) fs := afero.NewMemMapFs() - decoded, err := DecodeConfig(fs, cfg) - c.Assert(err, qt.IsNil) - - c.Assert(len(decoded), qt.Equals, 5) + decoded := testconfig.GetTestConfigs(fs, cfg).Base.Caches + c.Assert(len(decoded), qt.Equals, 7) for _, v := range decoded { c.Assert(v.MaxAge, qt.Equals, time.Duration(0)) } - } func TestDecodeConfigDefault(t *testing.T) { c := qt.New(t) - cfg := newTestConfig() + cfg := config.New() if runtime.GOOS == "windows" { cfg.Set("resourceDir", "c:\\cache\\resources") @@ -125,72 +125,22 @@ func TestDecodeConfigDefault(t *testing.T) { cfg.Set("resourceDir", "/cache/resources") cfg.Set("cacheDir", "/cache/thecache") } - - fs := afero.NewMemMapFs() - - decoded, err := DecodeConfig(fs, cfg) - - c.Assert(err, qt.IsNil) - - c.Assert(len(decoded), qt.Equals, 5) - - imgConfig := decoded[cacheKeyImages] - jsonConfig := decoded[cacheKeyGetJSON] - - if runtime.GOOS == "windows" { - c.Assert(imgConfig.Dir, qt.Equals, filepath.FromSlash("_gen/images")) - } else { - c.Assert(imgConfig.Dir, qt.Equals, "_gen/images") - c.Assert(jsonConfig.Dir, qt.Equals, "/cache/thecache/hugoproject/filecache/getjson") - } - - c.Assert(imgConfig.isResourceDir, qt.Equals, true) - c.Assert(jsonConfig.isResourceDir, qt.Equals, false) -} - -func TestDecodeConfigInvalidDir(t *testing.T) { - t.Parallel() - - c := qt.New(t) - - configStr := ` -resourceDir = "myresources" -contentDir = "content" -dataDir = "data" -i18nDir = "i18n" -layoutDir = "layouts" -assetDir = "assets" -archeTypedir = "archetypes" - -[caches] -[caches.getJSON] -maxAge = "10m" -dir = "/" - -` - if runtime.GOOS == "windows" { - configStr = strings.Replace(configStr, "/", "c:\\\\", 1) - } - - cfg, err := config.FromConfigString(configStr, "toml") - c.Assert(err, qt.IsNil) - fs := afero.NewMemMapFs() - - _, err = DecodeConfig(fs, cfg) - c.Assert(err, qt.Not(qt.IsNil)) - -} - -func newTestConfig() *viper.Viper { - cfg := viper.New() cfg.Set("workingDir", filepath.FromSlash("/my/cool/hugoproject")) - cfg.Set("contentDir", "content") - cfg.Set("dataDir", "data") - cfg.Set("resourceDir", "resources") - cfg.Set("i18nDir", "i18n") - cfg.Set("layoutDir", "layouts") - cfg.Set("archetypeDir", "archetypes") - cfg.Set("assetDir", "assets") - return cfg + fs := afero.NewMemMapFs() + decoded := testconfig.GetTestConfigs(fs, cfg).Base.Caches + c.Assert(len(decoded), qt.Equals, 7) + + imgConfig := decoded[filecache.CacheKeyImages] + jsonConfig := decoded[filecache.CacheKeyGetJSON] + + if runtime.GOOS == "windows" { + c.Assert(imgConfig.DirCompiled, qt.Equals, filepath.FromSlash("_gen/images")) + } else { + c.Assert(imgConfig.DirCompiled, qt.Equals, "_gen/images") + c.Assert(jsonConfig.DirCompiled, qt.Equals, "/cache/thecache/hugoproject/filecache/getjson") + } + + c.Assert(imgConfig.IsResourceDir, qt.Equals, true) + c.Assert(jsonConfig.IsResourceDir, qt.Equals, false) } diff --git a/cache/filecache/filecache_integration_test.go b/cache/filecache/filecache_integration_test.go new file mode 100644 index 000000000..1e920c29f --- /dev/null +++ b/cache/filecache/filecache_integration_test.go @@ -0,0 +1,106 @@ +// Copyright 2024 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 filecache_test + +import ( + "path/filepath" + "testing" + "time" + + "github.com/bep/logg" + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/htesting" + "github.com/gohugoio/hugo/hugolib" +) + +// See issue #10781. That issue wouldn't have been triggered if we kept +// the empty root directories (e.g. _resources/gen/images). +// It's still an upstream Go issue that we also need to handle, but +// this is a test for the first part. +func TestPruneShouldPreserveEmptyCacheRoots(t *testing.T) { + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +-- content/_index.md -- +--- +title: "Home" +--- + +` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{T: t, TxtarString: files, RunGC: true, NeedsOsFS: true}, + ).Build() + + _, err := b.H.BaseFs.ResourcesCache.Stat(filepath.Join("_gen", "images")) + + b.Assert(err, qt.IsNil) +} + +func TestPruneImages(t *testing.T) { + if htesting.IsCI() { + // TODO(bep) + t.Skip("skip flaky test on CI server") + } + t.Skip("skip flaky test") + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +[caches] +[caches.images] +maxAge = "200ms" +dir = ":resourceDir/_gen" +-- content/_index.md -- +--- +title: "Home" +--- +-- assets/a/pixel.png -- +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg== +-- layouts/index.html -- +{{ warnf "HOME!" }} +{{ $img := resources.GetMatch "**.png" }} +{{ $img = $img.Resize "3x3" }} +{{ $img.RelPermalink }} + + + +` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{T: t, TxtarString: files, Running: true, RunGC: true, NeedsOsFS: true, LogLevel: logg.LevelInfo}, + ).Build() + + b.Assert(b.GCCount, qt.Equals, 0) + b.Assert(b.H, qt.IsNotNil) + + imagesCacheDir := filepath.Join("_gen", "images") + _, err := b.H.BaseFs.ResourcesCache.Stat(imagesCacheDir) + + b.Assert(err, qt.IsNil) + + // TODO(bep) we need a way to test full rebuilds. + // For now, just sleep a little so the cache elements expires. + time.Sleep(500 * time.Millisecond) + + b.RenameFile("assets/a/pixel.png", "assets/b/pixel2.png").Build() + + b.Assert(b.GCCount, qt.Equals, 1) + // Build it again to GC the empty a dir. + b.Build() + + _, err = b.H.BaseFs.ResourcesCache.Stat(filepath.Join(imagesCacheDir, "a")) + b.Assert(err, qt.Not(qt.IsNil)) + _, err = b.H.BaseFs.ResourcesCache.Stat(imagesCacheDir) + b.Assert(err, qt.IsNil) +} diff --git a/cache/filecache/filecache_pruner.go b/cache/filecache/filecache_pruner.go index 7f68c8b82..6f224cef4 100644 --- a/cache/filecache/filecache_pruner.go +++ b/cache/filecache/filecache_pruner.go @@ -14,10 +14,13 @@ package filecache import ( + "fmt" "io" "os" - "github.com/pkg/errors" + "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/hugofs" + "github.com/spf13/afero" ) @@ -28,16 +31,15 @@ import ( func (c Caches) Prune() (int, error) { counter := 0 for k, cache := range c { - count, err := cache.Prune(false) counter += count if err != nil { - if os.IsNotExist(err) { + if herrors.IsNotExist(err) { continue } - return counter, errors.Wrapf(err, "failed to prune cache %q", k) + return counter, fmt.Errorf("failed to prune cache %q: %w", k, err) } } @@ -51,6 +53,9 @@ func (c *Cache) Prune(force bool) (int, error) { if c.pruneAllRootDir != "" { return c.pruneRootDir(force) } + if err := c.init(); err != nil { + return 0, err + } counter := 0 @@ -67,14 +72,19 @@ func (c *Cache) Prune(force bool) (int, error) { // This cache dir may not exist. return nil } - defer f.Close() _, err = f.Readdirnames(1) + f.Close() if err == io.EOF { // Empty dir. - err = c.Fs.Remove(name) + if name == "." { + // e.g. /_gen/images -- keep it even if empty. + err = nil + } else { + err = c.Fs.Remove(name) + } } - if err != nil && !os.IsNotExist(err) { + if err != nil && !herrors.IsNotExist(err) { return err } @@ -95,7 +105,7 @@ func (c *Cache) Prune(force bool) (int, error) { counter++ } - if err != nil && !os.IsNotExist(err) { + if err != nil && !herrors.IsNotExist(err) { return err } @@ -108,10 +118,12 @@ func (c *Cache) Prune(force bool) (int, error) { } func (c *Cache) pruneRootDir(force bool) (int, error) { - + if err := c.init(); err != nil { + return 0, err + } info, err := c.Fs.Stat(c.pruneAllRootDir) if err != nil { - if os.IsNotExist(err) { + if herrors.IsNotExist(err) { return 0, nil } return 0, err @@ -121,18 +133,5 @@ func (c *Cache) pruneRootDir(force bool) (int, error) { return 0, nil } - counter := 0 - // Module cache has 0555 directories; make them writable in order to remove content. - afero.Walk(c.Fs, c.pruneAllRootDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return nil - } - if info.IsDir() { - counter++ - c.Fs.Chmod(path, 0777) - } - return nil - }) - return 1, c.Fs.RemoveAll(c.pruneAllRootDir) - + return hugofs.MakeReadableAndRemoveAllModulePkgDir(c.Fs, c.pruneAllRootDir) } diff --git a/cache/filecache/filecache_pruner_test.go b/cache/filecache/filecache_pruner_test.go index 48bce723e..b49ba7645 100644 --- a/cache/filecache/filecache_pruner_test.go +++ b/cache/filecache/filecache_pruner_test.go @@ -11,13 +11,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -package filecache +package filecache_test import ( "fmt" "testing" "time" + "github.com/gohugoio/hugo/cache/filecache" "github.com/spf13/afero" qt "github.com/frankban/quicktest" @@ -52,13 +53,13 @@ maxAge = "200ms" dir = ":resourceDir/_gen" ` - for _, name := range []string{cacheKeyGetCSV, cacheKeyGetJSON, cacheKeyAssets, cacheKeyImages} { + for _, name := range []string{filecache.CacheKeyGetCSV, filecache.CacheKeyGetJSON, filecache.CacheKeyAssets, filecache.CacheKeyImages} { msg := qt.Commentf("cache: %s", name) p := newPathsSpec(t, afero.NewMemMapFs(), configStr) - caches, err := NewCaches(p) + caches, err := filecache.NewCaches(p) c.Assert(err, qt.IsNil) cache := caches[name] - for i := 0; i < 10; i++ { + for i := range 10 { id := fmt.Sprintf("i%d", i) cache.GetOrCreateBytes(id, func() ([]byte, error) { return []byte("abc"), nil @@ -73,9 +74,9 @@ dir = ":resourceDir/_gen" c.Assert(err, qt.IsNil) c.Assert(count, qt.Equals, 5, msg) - for i := 0; i < 10; i++ { + for i := range 10 { id := fmt.Sprintf("i%d", i) - v := cache.getString(id) + v := cache.GetString(id) if i < 5 { c.Assert(v, qt.Equals, "") } else { @@ -83,7 +84,7 @@ dir = ":resourceDir/_gen" } } - caches, err = NewCaches(p) + caches, err = filecache.NewCaches(p) c.Assert(err, qt.IsNil) cache = caches[name] // Touch one and then prune. @@ -96,9 +97,9 @@ dir = ":resourceDir/_gen" c.Assert(count, qt.Equals, 4) // Now only the i5 should be left. - for i := 0; i < 10; i++ { + for i := range 10 { id := fmt.Sprintf("i%d", i) - v := cache.getString(id) + v := cache.GetString(id) if i != 5 { c.Assert(v, qt.Equals, "") } else { @@ -107,5 +108,4 @@ dir = ":resourceDir/_gen" } } - } diff --git a/cache/filecache/filecache_test.go b/cache/filecache/filecache_test.go index 5a5dac983..a30aaa50b 100644 --- a/cache/filecache/filecache_test.go +++ b/cache/filecache/filecache_test.go @@ -1,4 +1,4 @@ -// Copyright 2018 The Hugo Authors. All rights reserved. +// Copyright 2024 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. @@ -11,25 +11,21 @@ // See the License for the specific language governing permissions and // limitations under the License. -package filecache +package filecache_test import ( "errors" "fmt" "io" - "io/ioutil" - "os" - "path/filepath" "strings" "sync" "testing" "time" - "github.com/gohugoio/hugo/langs" - "github.com/gohugoio/hugo/modules" - + "github.com/gohugoio/hugo/cache/filecache" "github.com/gohugoio/hugo/common/hugio" "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/config/testconfig" "github.com/gohugoio/hugo/helpers" "github.com/gohugoio/hugo/hugofs" @@ -42,13 +38,8 @@ func TestFileCache(t *testing.T) { t.Parallel() c := qt.New(t) - tempWorkingDir, err := ioutil.TempDir("", "hugo_filecache_test_work") - c.Assert(err, qt.IsNil) - defer os.Remove(tempWorkingDir) - - tempCacheDir, err := ioutil.TempDir("", "hugo_filecache_test_cache") - c.Assert(err, qt.IsNil) - defer os.Remove(tempCacheDir) + tempWorkingDir := t.TempDir() + tempCacheDir := t.TempDir() osfs := afero.NewOsFs() @@ -88,31 +79,14 @@ dir = ":cacheDir/c" p := newPathsSpec(t, osfs, configStr) - caches, err := NewCaches(p) + caches, err := filecache.NewCaches(p) c.Assert(err, qt.IsNil) cache := caches.Get("GetJSON") c.Assert(cache, qt.Not(qt.IsNil)) - c.Assert(cache.maxAge.String(), qt.Equals, "10h0m0s") - - bfs, ok := cache.Fs.(*afero.BasePathFs) - c.Assert(ok, qt.Equals, true) - filename, err := bfs.RealPath("key") - c.Assert(err, qt.IsNil) - if test.cacheDir != "" { - c.Assert(filename, qt.Equals, filepath.Join(test.cacheDir, "c/"+filecacheRootDirname+"/getjson/key")) - } else { - // Temp dir. - c.Assert(filename, qt.Matches, ".*hugo_cache.*"+filecacheRootDirname+".*key") - } cache = caches.Get("Images") c.Assert(cache, qt.Not(qt.IsNil)) - c.Assert(cache.maxAge, qt.Equals, time.Duration(-1)) - bfs, ok = cache.Fs.(*afero.BasePathFs) - c.Assert(ok, qt.Equals, true) - filename, _ = bfs.RealPath("key") - c.Assert(filename, qt.Equals, filepath.FromSlash("_gen/images/key")) rf := func(s string) func() (io.ReadCloser, error) { return func() (io.ReadCloser, error) { @@ -121,7 +95,7 @@ dir = ":cacheDir/c" io.Closer }{ strings.NewReader(s), - ioutil.NopCloser(nil), + io.NopCloser(nil), }, nil } } @@ -130,13 +104,13 @@ dir = ":cacheDir/c" return []byte("bcd"), nil } - for _, ca := range []*Cache{caches.ImageCache(), caches.AssetsCache(), caches.GetJSONCache(), caches.GetCSVCache()} { - for i := 0; i < 2; i++ { + for _, ca := range []*filecache.Cache{caches.ImageCache(), caches.AssetsCache(), caches.GetJSONCache(), caches.GetCSVCache()} { + for range 2 { info, r, err := ca.GetOrCreate("a", rf("abc")) c.Assert(err, qt.IsNil) c.Assert(r, qt.Not(qt.IsNil)) c.Assert(info.Name, qt.Equals, "a") - b, _ := ioutil.ReadAll(r) + b, _ := io.ReadAll(r) r.Close() c.Assert(string(b), qt.Equals, "abc") @@ -152,7 +126,7 @@ dir = ":cacheDir/c" _, r, err = ca.GetOrCreate("a", rf("bcd")) c.Assert(err, qt.IsNil) - b, _ = ioutil.ReadAll(r) + b, _ = io.ReadAll(r) r.Close() c.Assert(string(b), qt.Equals, "abc") } @@ -165,13 +139,13 @@ dir = ":cacheDir/c" c.Assert(info.Name, qt.Equals, "mykey") io.WriteString(w, "Hugo is great!") w.Close() - c.Assert(caches.ImageCache().getString("mykey"), qt.Equals, "Hugo is great!") + c.Assert(caches.ImageCache().GetString("mykey"), qt.Equals, "Hugo is great!") info, r, err := caches.ImageCache().Get("mykey") c.Assert(err, qt.IsNil) c.Assert(r, qt.Not(qt.IsNil)) c.Assert(info.Name, qt.Equals, "mykey") - b, _ := ioutil.ReadAll(r) + b, _ := io.ReadAll(r) r.Close() c.Assert(string(b), qt.Equals, "Hugo is great!") @@ -181,7 +155,6 @@ dir = ":cacheDir/c" c.Assert(string(b), qt.Equals, "Hugo is great!") } - } func TestFileCacheConcurrent(t *testing.T) { @@ -207,7 +180,7 @@ dir = "/cache/c" p := newPathsSpec(t, afero.NewMemMapFs(), configStr) - caches, err := NewCaches(p) + caches, err := filecache.NewCaches(p) c.Assert(err, qt.IsNil) const cacheName = "getjson" @@ -220,11 +193,11 @@ dir = "/cache/c" var wg sync.WaitGroup - for i := 0; i < 50; i++ { + for i := range 50 { wg.Add(1) go func(i int) { defer wg.Done() - for j := 0; j < 20; j++ { + for range 20 { ca := caches.Get(cacheName) c.Assert(ca, qt.Not(qt.IsNil)) filename, data := filenameData(i) @@ -232,7 +205,7 @@ dir = "/cache/c" return hugio.ToReadCloser(strings.NewReader(data)), nil }) c.Assert(err, qt.IsNil) - b, _ := ioutil.ReadAll(r) + b, _ := io.ReadAll(r) r.Close() c.Assert(string(b), qt.Equals, data) // Trigger some expiration. @@ -250,25 +223,24 @@ func TestFileCacheReadOrCreateErrorInRead(t *testing.T) { var result string - rf := func(failLevel int) func(info ItemInfo, r io.ReadSeeker) error { - - return func(info ItemInfo, r io.ReadSeeker) error { + rf := func(failLevel int) func(info filecache.ItemInfo, r io.ReadSeeker) error { + return func(info filecache.ItemInfo, r io.ReadSeeker) error { if failLevel > 0 { if failLevel > 1 { - return ErrFatal + return filecache.ErrFatal } return errors.New("fail") } - b, _ := ioutil.ReadAll(r) + b, _ := io.ReadAll(r) result = string(b) return nil } } - bf := func(s string) func(info ItemInfo, w io.WriteCloser) error { - return func(info ItemInfo, w io.WriteCloser) error { + bf := func(s string) func(info filecache.ItemInfo, w io.WriteCloser) error { + return func(info filecache.ItemInfo, w io.WriteCloser) error { defer w.Close() result = s _, err := w.Write([]byte(s)) @@ -276,7 +248,7 @@ func TestFileCacheReadOrCreateErrorInRead(t *testing.T) { } } - cache := NewCache(afero.NewMemMapFs(), 100*time.Hour, "") + cache := filecache.NewCache(afero.NewMemMapFs(), 100*time.Hour, "") const id = "a32" @@ -290,59 +262,15 @@ func TestFileCacheReadOrCreateErrorInRead(t *testing.T) { c.Assert(err, qt.IsNil) c.Assert(result, qt.Equals, "v3") _, err = cache.ReadOrCreate(id, rf(2), bf("v3")) - c.Assert(err, qt.Equals, ErrFatal) -} - -func TestCleanID(t *testing.T) { - c := qt.New(t) - c.Assert(cleanID(filepath.FromSlash("/a/b//c.txt")), qt.Equals, filepath.FromSlash("a/b/c.txt")) - c.Assert(cleanID(filepath.FromSlash("a/b//c.txt")), qt.Equals, filepath.FromSlash("a/b/c.txt")) -} - -func initConfig(fs afero.Fs, cfg config.Provider) error { - if _, err := langs.LoadLanguageSettings(cfg, nil); err != nil { - return err - } - - modConfig, err := modules.DecodeConfig(cfg) - if err != nil { - return err - } - - workingDir := cfg.GetString("workingDir") - themesDir := cfg.GetString("themesDir") - if !filepath.IsAbs(themesDir) { - themesDir = filepath.Join(workingDir, themesDir) - } - modulesClient := modules.NewClient(modules.ClientConfig{ - Fs: fs, - WorkingDir: workingDir, - ThemesDir: themesDir, - ModuleConfig: modConfig, - IgnoreVendor: true, - }) - - moduleConfig, err := modulesClient.Collect() - if err != nil { - return err - } - - if err := modules.ApplyProjectConfigDefaults(cfg, moduleConfig.ActiveModules[len(moduleConfig.ActiveModules)-1]); err != nil { - return err - } - - cfg.Set("allModules", moduleConfig.ActiveModules) - - return nil + c.Assert(err, qt.Equals, filecache.ErrFatal) } func newPathsSpec(t *testing.T, fs afero.Fs, configStr string) *helpers.PathSpec { c := qt.New(t) cfg, err := config.FromConfigString(configStr, "toml") c.Assert(err, qt.IsNil) - initConfig(fs, cfg) - p, err := helpers.NewPathSpec(hugofs.NewFrom(fs, cfg), cfg, nil) + acfg := testconfig.GetTestConfig(fs, cfg) + p, err := helpers.NewPathSpec(hugofs.NewFrom(fs, acfg.BaseConfig()), acfg, nil) c.Assert(err, qt.IsNil) return p - } diff --git a/cache/httpcache/httpcache.go b/cache/httpcache/httpcache.go new file mode 100644 index 000000000..bd6d4bf7d --- /dev/null +++ b/cache/httpcache/httpcache.go @@ -0,0 +1,229 @@ +// Copyright 2024 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 httpcache + +import ( + "encoding/json" + "time" + + "github.com/gobwas/glob" + "github.com/gohugoio/hugo/common/predicate" + "github.com/gohugoio/hugo/config" + "github.com/mitchellh/mapstructure" +) + +// DefaultConfig holds the default configuration for the HTTP cache. +var DefaultConfig = Config{ + Cache: Cache{ + For: GlobMatcher{ + Excludes: []string{"**"}, + }, + }, + Polls: []PollConfig{ + { + For: GlobMatcher{ + Includes: []string{"**"}, + }, + Disable: true, + }, + }, +} + +// Config holds the configuration for the HTTP cache. +type Config struct { + // Configures the HTTP cache behavior (RFC 9111). + // When this is not enabled for a resource, Hugo will go straight to the file cache. + Cache Cache + + // Polls holds a list of configurations for polling remote resources to detect changes in watch mode. + // This can be disabled for some resources, typically if they are known to not change. + Polls []PollConfig +} + +type Cache struct { + // Enable HTTP cache behavior (RFC 9111) for these resources. + For GlobMatcher +} + +func (c *Config) Compile() (ConfigCompiled, error) { + var cc ConfigCompiled + + p, err := c.Cache.For.CompilePredicate() + if err != nil { + return cc, err + } + + cc.For = p + + for _, pc := range c.Polls { + + p, err := pc.For.CompilePredicate() + if err != nil { + return cc, err + } + + cc.PollConfigs = append(cc.PollConfigs, PollConfigCompiled{ + For: p, + Config: pc, + }) + } + + return cc, nil +} + +// PollConfig holds the configuration for polling remote resources to detect changes in watch mode. +type PollConfig struct { + // What remote resources to apply this configuration to. + For GlobMatcher + + // Disable polling for this configuration. + Disable bool + + // Low is the lower bound for the polling interval. + // This is the starting point when the resource has recently changed, + // if that resource stops changing, the polling interval will gradually increase towards High. + Low time.Duration + + // High is the upper bound for the polling interval. + // This is the interval used when the resource is stable. + High time.Duration +} + +func (c PollConfig) MarshalJSON() (b []byte, err error) { + // Marshal the durations as strings. + type Alias PollConfig + return json.Marshal(&struct { + Low string + High string + Alias + }{ + Low: c.Low.String(), + High: c.High.String(), + Alias: (Alias)(c), + }) +} + +type GlobMatcher struct { + // Excludes holds a list of glob patterns that will be excluded. + Excludes []string + + // Includes holds a list of glob patterns that will be included. + Includes []string +} + +func (gm GlobMatcher) IsZero() bool { + return len(gm.Includes) == 0 && len(gm.Excludes) == 0 +} + +type ConfigCompiled struct { + For predicate.P[string] + PollConfigs []PollConfigCompiled +} + +func (c *ConfigCompiled) PollConfigFor(s string) PollConfigCompiled { + for _, pc := range c.PollConfigs { + if pc.For(s) { + return pc + } + } + return PollConfigCompiled{} +} + +func (c *ConfigCompiled) IsPollingDisabled() bool { + for _, pc := range c.PollConfigs { + if !pc.Config.Disable { + return false + } + } + return true +} + +type PollConfigCompiled struct { + For predicate.P[string] + Config PollConfig +} + +func (p PollConfigCompiled) IsZero() bool { + return p.For == nil +} + +func (gm *GlobMatcher) CompilePredicate() (func(string) bool, error) { + if gm.IsZero() { + panic("no includes or excludes") + } + var p predicate.P[string] + for _, include := range gm.Includes { + g, err := glob.Compile(include, '/') + if err != nil { + return nil, err + } + fn := func(s string) bool { + return g.Match(s) + } + p = p.Or(fn) + } + + for _, exclude := range gm.Excludes { + g, err := glob.Compile(exclude, '/') + if err != nil { + return nil, err + } + fn := func(s string) bool { + return !g.Match(s) + } + p = p.And(fn) + } + + return p, nil +} + +func DecodeConfig(_ config.BaseConfig, m map[string]any) (Config, error) { + if len(m) == 0 { + return DefaultConfig, nil + } + + var c Config + + dc := &mapstructure.DecoderConfig{ + Result: &c, + DecodeHook: mapstructure.StringToTimeDurationHookFunc(), + WeaklyTypedInput: true, + } + + decoder, err := mapstructure.NewDecoder(dc) + if err != nil { + return c, err + } + + if err := decoder.Decode(m); err != nil { + return c, err + } + + if c.Cache.For.IsZero() { + c.Cache.For = DefaultConfig.Cache.For + } + + for pci := range c.Polls { + if c.Polls[pci].For.IsZero() { + c.Polls[pci].For = DefaultConfig.Cache.For + c.Polls[pci].Disable = true + } + } + + if len(c.Polls) == 0 { + c.Polls = DefaultConfig.Polls + } + + return c, nil +} diff --git a/cache/httpcache/httpcache_integration_test.go b/cache/httpcache/httpcache_integration_test.go new file mode 100644 index 000000000..4d6a5f718 --- /dev/null +++ b/cache/httpcache/httpcache_integration_test.go @@ -0,0 +1,95 @@ +// Copyright 2024 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 httpcache_test + +import ( + "testing" + "time" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/hugolib" +) + +func TestConfigCustom(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +[httpcache] +[httpcache.cache.for] +includes = ["**gohugo.io**"] +[[httpcache.polls]] +low = "5s" +high = "32s" +[httpcache.polls.for] +includes = ["**gohugo.io**"] + + +` + + b := hugolib.Test(t, files) + + httpcacheConf := b.H.Configs.Base.HTTPCache + compiled := b.H.Configs.Base.C.HTTPCache + + b.Assert(httpcacheConf.Cache.For.Includes, qt.DeepEquals, []string{"**gohugo.io**"}) + b.Assert(httpcacheConf.Cache.For.Excludes, qt.IsNil) + + pc := compiled.PollConfigFor("https://gohugo.io/foo.jpg") + b.Assert(pc.Config.Low, qt.Equals, 5*time.Second) + b.Assert(pc.Config.High, qt.Equals, 32*time.Second) + b.Assert(compiled.PollConfigFor("https://example.com/foo.jpg").IsZero(), qt.IsTrue) +} + +func TestConfigDefault(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +` + b := hugolib.Test(t, files) + + compiled := b.H.Configs.Base.C.HTTPCache + + b.Assert(compiled.For("https://gohugo.io/posts.json"), qt.IsFalse) + b.Assert(compiled.For("https://gohugo.io/foo.jpg"), qt.IsFalse) + b.Assert(compiled.PollConfigFor("https://gohugo.io/foo.jpg").Config.Disable, qt.IsTrue) +} + +func TestConfigPollsOnly(t *testing.T) { + t.Parallel() + files := ` +-- hugo.toml -- +[httpcache] +[[httpcache.polls]] +low = "5s" +high = "32s" +[httpcache.polls.for] +includes = ["**gohugo.io**"] + + +` + + b := hugolib.Test(t, files) + + compiled := b.H.Configs.Base.C.HTTPCache + + b.Assert(compiled.For("https://gohugo.io/posts.json"), qt.IsFalse) + b.Assert(compiled.For("https://gohugo.io/foo.jpg"), qt.IsFalse) + + pc := compiled.PollConfigFor("https://gohugo.io/foo.jpg") + b.Assert(pc.Config.Low, qt.Equals, 5*time.Second) + b.Assert(pc.Config.High, qt.Equals, 32*time.Second) + b.Assert(compiled.PollConfigFor("https://example.com/foo.jpg").IsZero(), qt.IsTrue) +} diff --git a/cache/httpcache/httpcache_test.go b/cache/httpcache/httpcache_test.go new file mode 100644 index 000000000..60c07d056 --- /dev/null +++ b/cache/httpcache/httpcache_test.go @@ -0,0 +1,73 @@ +// Copyright 2024 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 httpcache + +import ( + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/config" +) + +func TestGlobMatcher(t *testing.T) { + c := qt.New(t) + + g := GlobMatcher{ + Includes: []string{"**/*.jpg", "**.png", "**/bar/**"}, + Excludes: []string{"**/foo.jpg", "**.css"}, + } + + p, err := g.CompilePredicate() + c.Assert(err, qt.IsNil) + + c.Assert(p("foo.jpg"), qt.IsFalse) + c.Assert(p("foo.png"), qt.IsTrue) + c.Assert(p("foo/bar.jpg"), qt.IsTrue) + c.Assert(p("foo/bar.png"), qt.IsTrue) + c.Assert(p("foo/bar/foo.jpg"), qt.IsFalse) + c.Assert(p("foo/bar/foo.css"), qt.IsFalse) + c.Assert(p("foo.css"), qt.IsFalse) + c.Assert(p("foo/bar/foo.css"), qt.IsFalse) + c.Assert(p("foo/bar/foo.xml"), qt.IsTrue) +} + +func TestDefaultConfig(t *testing.T) { + c := qt.New(t) + + _, err := DefaultConfig.Compile() + c.Assert(err, qt.IsNil) +} + +func TestDecodeConfigInjectsDefaultAndCompiles(t *testing.T) { + c := qt.New(t) + + cfg, err := DecodeConfig(config.BaseConfig{}, map[string]interface{}{}) + c.Assert(err, qt.IsNil) + c.Assert(cfg, qt.DeepEquals, DefaultConfig) + + _, err = cfg.Compile() + c.Assert(err, qt.IsNil) + + cfg, err = DecodeConfig(config.BaseConfig{}, map[string]any{ + "cache": map[string]any{ + "polls": []map[string]any{ + {"disable": true}, + }, + }, + }) + c.Assert(err, qt.IsNil) + + _, err = cfg.Compile() + c.Assert(err, qt.IsNil) +} diff --git a/cache/namedmemcache/named_cache.go b/cache/namedmemcache/named_cache.go deleted file mode 100644 index d8c229a01..000000000 --- a/cache/namedmemcache/named_cache.go +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright 2018 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 namedmemcache provides a memory cache with a named lock. This is suitable -// for situations where creating the cached resource can be time consuming or otherwise -// resource hungry, or in situations where a "once only per key" is a requirement. -package namedmemcache - -import ( - "sync" - - "github.com/BurntSushi/locker" -) - -// Cache holds the cached values. -type Cache struct { - nlocker *locker.Locker - cache map[string]cacheEntry - mu sync.RWMutex -} - -type cacheEntry struct { - value interface{} - err error -} - -// New creates a new cache. -func New() *Cache { - return &Cache{ - nlocker: locker.NewLocker(), - cache: make(map[string]cacheEntry), - } -} - -// Clear clears the cache state. -func (c *Cache) Clear() { - c.mu.Lock() - defer c.mu.Unlock() - - c.cache = make(map[string]cacheEntry) - c.nlocker = locker.NewLocker() - -} - -// GetOrCreate tries to get the value with the given cache key, if not found -// create will be called and cached. -// This method is thread safe. It also guarantees that the create func for a given -// key is invoced only once for this cache. -func (c *Cache) GetOrCreate(key string, create func() (interface{}, error)) (interface{}, error) { - c.mu.RLock() - entry, found := c.cache[key] - c.mu.RUnlock() - - if found { - return entry.value, entry.err - } - - c.nlocker.Lock(key) - defer c.nlocker.Unlock(key) - - // Create it. - value, err := create() - - c.mu.Lock() - c.cache[key] = cacheEntry{value: value, err: err} - c.mu.Unlock() - - return value, err -} diff --git a/cache/namedmemcache/named_cache_test.go b/cache/namedmemcache/named_cache_test.go deleted file mode 100644 index 9feddb11f..000000000 --- a/cache/namedmemcache/named_cache_test.go +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright 2018 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 namedmemcache - -import ( - "fmt" - "sync" - "testing" - - qt "github.com/frankban/quicktest" -) - -func TestNamedCache(t *testing.T) { - t.Parallel() - c := qt.New(t) - - cache := New() - - counter := 0 - create := func() (interface{}, error) { - counter++ - return counter, nil - } - - for i := 0; i < 5; i++ { - v1, err := cache.GetOrCreate("a1", create) - c.Assert(err, qt.IsNil) - c.Assert(v1, qt.Equals, 1) - v2, err := cache.GetOrCreate("a2", create) - c.Assert(err, qt.IsNil) - c.Assert(v2, qt.Equals, 2) - } - - cache.Clear() - - v3, err := cache.GetOrCreate("a2", create) - c.Assert(err, qt.IsNil) - c.Assert(v3, qt.Equals, 3) -} - -func TestNamedCacheConcurrent(t *testing.T) { - t.Parallel() - - c := qt.New(t) - - var wg sync.WaitGroup - - cache := New() - - create := func(i int) func() (interface{}, error) { - return func() (interface{}, error) { - return i, nil - } - } - - for i := 0; i < 10; i++ { - wg.Add(1) - go func() { - defer wg.Done() - for j := 0; j < 100; j++ { - id := fmt.Sprintf("id%d", j) - v, err := cache.GetOrCreate(id, create(j)) - c.Assert(err, qt.IsNil) - c.Assert(v, qt.Equals, j) - } - }() - } - wg.Wait() -} diff --git a/cache/partitioned_lazy_cache.go b/cache/partitioned_lazy_cache.go deleted file mode 100644 index 31e66e127..000000000 --- a/cache/partitioned_lazy_cache.go +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright 2017-present 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 cache - -import ( - "sync" -) - -// Partition represents a cache partition where Load is the callback -// for when the partition is needed. -type Partition struct { - Key string - Load func() (map[string]interface{}, error) -} - -// Lazy represents a lazily loaded cache. -type Lazy struct { - initSync sync.Once - initErr error - cache map[string]interface{} - load func() (map[string]interface{}, error) -} - -// NewLazy creates a lazy cache with the given load func. -func NewLazy(load func() (map[string]interface{}, error)) *Lazy { - return &Lazy{load: load} -} - -func (l *Lazy) init() error { - l.initSync.Do(func() { - c, err := l.load() - l.cache = c - l.initErr = err - - }) - - return l.initErr -} - -// Get initializes the cache if not already initialized, then looks up the -// given key. -func (l *Lazy) Get(key string) (interface{}, bool, error) { - l.init() - if l.initErr != nil { - return nil, false, l.initErr - } - v, found := l.cache[key] - return v, found, nil -} - -// PartitionedLazyCache is a lazily loaded cache paritioned by a supplied string key. -type PartitionedLazyCache struct { - partitions map[string]*Lazy -} - -// NewPartitionedLazyCache creates a new NewPartitionedLazyCache with the supplied -// partitions. -func NewPartitionedLazyCache(partitions ...Partition) *PartitionedLazyCache { - lazyPartitions := make(map[string]*Lazy, len(partitions)) - for _, partition := range partitions { - lazyPartitions[partition.Key] = NewLazy(partition.Load) - } - cache := &PartitionedLazyCache{partitions: lazyPartitions} - - return cache -} - -// Get initializes the partition if not already done so, then looks up the given -// key in the given partition, returns nil if no value found. -func (c *PartitionedLazyCache) Get(partition, key string) (interface{}, error) { - p, found := c.partitions[partition] - - if !found { - return nil, nil - } - - v, found, err := p.Get(key) - if err != nil { - return nil, err - } - - if found { - return v, nil - } - - return nil, nil - -} diff --git a/cache/partitioned_lazy_cache_test.go b/cache/partitioned_lazy_cache_test.go deleted file mode 100644 index 2c61a6560..000000000 --- a/cache/partitioned_lazy_cache_test.go +++ /dev/null @@ -1,138 +0,0 @@ -// Copyright 2017-present 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 cache - -import ( - "errors" - "sync" - "testing" - - qt "github.com/frankban/quicktest" -) - -func TestNewPartitionedLazyCache(t *testing.T) { - t.Parallel() - - c := qt.New(t) - - p1 := Partition{ - Key: "p1", - Load: func() (map[string]interface{}, error) { - return map[string]interface{}{ - "p1_1": "p1v1", - "p1_2": "p1v2", - "p1_nil": nil, - }, nil - }, - } - - p2 := Partition{ - Key: "p2", - Load: func() (map[string]interface{}, error) { - return map[string]interface{}{ - "p2_1": "p2v1", - "p2_2": "p2v2", - "p2_3": "p2v3", - }, nil - }, - } - - cache := NewPartitionedLazyCache(p1, p2) - - v, err := cache.Get("p1", "p1_1") - c.Assert(err, qt.IsNil) - c.Assert(v, qt.Equals, "p1v1") - - v, err = cache.Get("p1", "p2_1") - c.Assert(err, qt.IsNil) - c.Assert(v, qt.IsNil) - - v, err = cache.Get("p1", "p1_nil") - c.Assert(err, qt.IsNil) - c.Assert(v, qt.IsNil) - - v, err = cache.Get("p2", "p2_3") - c.Assert(err, qt.IsNil) - c.Assert(v, qt.Equals, "p2v3") - - v, err = cache.Get("doesnotexist", "p1_1") - c.Assert(err, qt.IsNil) - c.Assert(v, qt.IsNil) - - v, err = cache.Get("p1", "doesnotexist") - c.Assert(err, qt.IsNil) - c.Assert(v, qt.IsNil) - - errorP := Partition{ - Key: "p3", - Load: func() (map[string]interface{}, error) { - return nil, errors.New("Failed") - }, - } - - cache = NewPartitionedLazyCache(errorP) - - v, err = cache.Get("p1", "doesnotexist") - c.Assert(err, qt.IsNil) - c.Assert(v, qt.IsNil) - - _, err = cache.Get("p3", "doesnotexist") - c.Assert(err, qt.Not(qt.IsNil)) - -} - -func TestConcurrentPartitionedLazyCache(t *testing.T) { - t.Parallel() - - c := qt.New(t) - - var wg sync.WaitGroup - - p1 := Partition{ - Key: "p1", - Load: func() (map[string]interface{}, error) { - return map[string]interface{}{ - "p1_1": "p1v1", - "p1_2": "p1v2", - "p1_nil": nil, - }, nil - }, - } - - p2 := Partition{ - Key: "p2", - Load: func() (map[string]interface{}, error) { - return map[string]interface{}{ - "p2_1": "p2v1", - "p2_2": "p2v2", - "p2_3": "p2v3", - }, nil - }, - } - - cache := NewPartitionedLazyCache(p1, p2) - - for i := 0; i < 100; i++ { - wg.Add(1) - go func() { - defer wg.Done() - for j := 0; j < 10; j++ { - v, err := cache.Get("p1", "p1_1") - c.Assert(err, qt.IsNil) - c.Assert(v, qt.Equals, "p1v1") - } - }() - } - wg.Wait() -} diff --git a/check_gofmt.sh b/check_gofmt.sh new file mode 100755 index 000000000..c77517d3f --- /dev/null +++ b/check_gofmt.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +diff <(gofmt -d .) <(printf '') \ No newline at end of file diff --git a/codegen/methods.go b/codegen/methods.go index ed8dba923..08ac97b00 100644 --- a/codegen/methods.go +++ b/codegen/methods.go @@ -26,6 +26,7 @@ import ( "path/filepath" "reflect" "regexp" + "slices" "sort" "strings" "sync" @@ -58,7 +59,7 @@ func (c *Inspector) MethodsFromTypes(include []reflect.Type, exclude []reflect.T var methods Methods - var excludes = make(map[string]bool) + excludes := make(map[string]bool) if len(exclude) > 0 { for _, m := range c.MethodsFromTypes(exclude, nil) { @@ -99,12 +100,10 @@ func (c *Inspector) MethodsFromTypes(include []reflect.Type, exclude []reflect.T name = pkgPrefix + name return name, pkg - } for _, t := range include { - - for i := 0; i < t.NumMethod(); i++ { + for i := range t.NumMethod() { m := t.Method(i) if excludes[m.Name] || seen[m.Name] { @@ -124,7 +123,7 @@ func (c *Inspector) MethodsFromTypes(include []reflect.Type, exclude []reflect.T method := Method{Owner: t, OwnerName: ownerName, Name: m.Name} - for i := 0; i < numIn; i++ { + for i := range numIn { in := m.Type.In(i) name, pkg := nameAndPackage(in) @@ -139,7 +138,7 @@ func (c *Inspector) MethodsFromTypes(include []reflect.Type, exclude []reflect.T numOut := m.Type.NumOut() if numOut > 0 { - for i := 0; i < numOut; i++ { + for i := range numOut { out := m.Type.Out(i) name, pkg := nameAndPackage(out) @@ -153,7 +152,6 @@ func (c *Inspector) MethodsFromTypes(include []reflect.Type, exclude []reflect.T methods = append(methods, method) } - } sort.SliceStable(methods, func(i, j int) bool { @@ -167,16 +165,13 @@ func (c *Inspector) MethodsFromTypes(include []reflect.Type, exclude []reflect.T } return wi < wj - }) return methods - } func (c *Inspector) parseSource() { c.init.Do(func() { - if !strings.Contains(c.ProjectRootDir, "hugo") { panic("dir must be set to the Hugo root") } @@ -200,7 +195,6 @@ func (c *Inspector) parseSource() { filenames = append(filenames, path) return nil - }) for _, filename := range filenames { @@ -230,7 +224,6 @@ func (c *Inspector) parseSource() { c.methodWeight[iface] = weights } } - } return true }) @@ -247,7 +240,6 @@ func (c *Inspector) parseSource() { } } } - }) } @@ -313,7 +305,7 @@ func (m Method) inOutStr() string { } args := make([]string, len(m.In)) - for i := 0; i < len(args); i++ { + for i := range args { args[i] = fmt.Sprintf("arg%d", i) } return "(" + strings.Join(args, ", ") + ")" @@ -325,7 +317,7 @@ func (m Method) inStr() string { } args := make([]string, len(m.In)) - for i := 0; i < len(args); i++ { + for i := range args { args[i] = fmt.Sprintf("arg%d %s", i, m.In[i]) } return "(" + strings.Join(args, ", ") + ")" @@ -348,7 +340,7 @@ func (m Method) outStrNamed() string { } outs := make([]string, len(m.Out)) - for i := 0; i < len(outs); i++ { + for i := range outs { outs[i] = fmt.Sprintf("o%d %s", i, m.Out[i]) } @@ -374,7 +366,7 @@ func (m Methods) Imports() []string { } // ToMarshalJSON creates a MarshalJSON method for these methods. Any method name -// matchin any of the regexps in excludes will be ignored. +// matching any of the regexps in excludes will be ignored. func (m Methods) ToMarshalJSON(receiver, pkgPath string, excludes ...string) (string, []string) { var sb strings.Builder @@ -385,7 +377,7 @@ func (m Methods) ToMarshalJSON(receiver, pkgPath string, excludes ...string) (st fmt.Fprintf(&sb, "func Marshal%sToJSON(%s %s) ([]byte, error) {\n", what, r, receiver) var methods Methods - var excludeRes = make([]*regexp.Regexp, len(excludes)) + excludeRes := make([]*regexp.Regexp, len(excludes)) for i, exclude := range excludes { excludeRes[i] = regexp.MustCompile(exclude) @@ -444,13 +436,12 @@ func (m Methods) ToMarshalJSON(receiver, pkgPath string, excludes ...string) (st // Exclude self for i, pkgImp := range pkgImports { if pkgImp == pkgPath { - pkgImports = append(pkgImports[:i], pkgImports[i+1:]...) + pkgImports = slices.Delete(pkgImports, i, i+1) } } } return sb.String(), pkgImports - } func collectMethodsRecursive(pkg string, f []*ast.Field) []string { @@ -462,12 +453,15 @@ func collectMethodsRecursive(pkg string, f []*ast.Field) []string { } if ident, ok := m.Type.(*ast.Ident); ok && ident.Obj != nil { - // Embedded interface - methodNames = append( - methodNames, - collectMethodsRecursive( - pkg, - ident.Obj.Decl.(*ast.TypeSpec).Type.(*ast.InterfaceType).Methods.List)...) + switch tt := ident.Obj.Decl.(*ast.TypeSpec).Type.(type) { + case *ast.InterfaceType: + // Embedded interface + methodNames = append( + methodNames, + collectMethodsRecursive( + pkg, + tt.Methods.List)...) + } } else { // Embedded, but in a different file/package. Return the // package.Name and deal with that later. @@ -481,7 +475,6 @@ func collectMethodsRecursive(pkg string, f []*ast.Field) []string { } return methodNames - } func firstToLower(name string) string { @@ -516,7 +509,7 @@ func typeName(name, pkg string) string { func uniqueNonEmptyStrings(s []string) []string { var unique []string - set := map[string]interface{}{} + set := map[string]any{} for _, val := range s { if val == "" { continue @@ -544,5 +537,4 @@ func varName(name string) string { } return name - } diff --git a/codegen/methods_test.go b/codegen/methods_test.go index 77399f4e4..0aff43d0e 100644 --- a/codegen/methods_test.go +++ b/codegen/methods_test.go @@ -25,7 +25,6 @@ import ( ) func TestMethods(t *testing.T) { - var ( zeroIE = reflect.TypeOf((*IEmbed)(nil)).Elem() zeroIEOnly = reflect.TypeOf((*IEOnly)(nil)).Elem() @@ -58,7 +57,6 @@ func TestMethods(t *testing.T) { methodsStr := fmt.Sprint(methods) c.Assert(methodsStr, qt.Contains, "MethodEmbed3(arg0 string) string") - }) t.Run("ToMarshalJSON", func(t *testing.T) { @@ -76,9 +74,7 @@ func TestMethods(t *testing.T) { c.Assert(pkg, qt.Contains, "encoding/json") fmt.Println(pkg) - }) - } type I interface { @@ -89,7 +85,7 @@ type I interface { Method3(myint int, mystring string) Method5() (string, error) Method6() *net.IP - Method7() interface{} + Method7() any Method8() herrors.ErrorContext method2() method9() os.FileInfo diff --git a/commands/check.go b/commands/check.go deleted file mode 100644 index f36f23969..000000000 --- a/commands/check.go +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright 2018 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. - -// +build !darwin - -package commands - -import ( - "github.com/spf13/cobra" -) - -var _ cmder = (*checkCmd)(nil) - -type checkCmd struct { - *baseCmd -} - -func newCheckCmd() *checkCmd { - return &checkCmd{baseCmd: &baseCmd{cmd: &cobra.Command{ - Use: "check", - Short: "Contains some verification checks", - }, - }} -} diff --git a/commands/check_darwin.go b/commands/check_darwin.go deleted file mode 100644 index 9291be84c..000000000 --- a/commands/check_darwin.go +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright 2018 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 commands - -import ( - "github.com/spf13/cobra" -) - -var _ cmder = (*checkCmd)(nil) - -type checkCmd struct { - *baseCmd -} - -func newCheckCmd() *checkCmd { - cc := &checkCmd{baseCmd: &baseCmd{cmd: &cobra.Command{ - Use: "check", - Short: "Contains some verification checks", - }, - }} - - cc.cmd.AddCommand(newLimitCmd().getCommand()) - - return cc -} diff --git a/commands/commandeer.go b/commands/commandeer.go index 8c8440a7a..bf9655637 100644 --- a/commands/commandeer.go +++ b/commands/commandeer.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Hugo Authors. All rights reserved. +// Copyright 2024 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,402 +14,666 @@ package commands import ( - "bytes" + "context" "errors" - "sync" - - "golang.org/x/sync/semaphore" - - "io/ioutil" - - "github.com/gohugoio/hugo/common/herrors" - "github.com/gohugoio/hugo/common/hugo" - - jww "github.com/spf13/jwalterweatherman" - + "fmt" + "io" + "log" "os" + "os/signal" "path/filepath" - "regexp" + "runtime" + "strings" + "sync" + "sync/atomic" + "syscall" "time" + "go.uber.org/automaxprocs/maxprocs" + + "github.com/bep/clocks" + "github.com/bep/lazycache" + "github.com/bep/logg" + "github.com/bep/overlayfs" + "github.com/bep/simplecobra" + + "github.com/gohugoio/hugo/common/hstrings" + "github.com/gohugoio/hugo/common/htime" "github.com/gohugoio/hugo/common/loggers" - "github.com/gohugoio/hugo/config" - - "github.com/spf13/cobra" - - "github.com/gohugoio/hugo/hugolib" - "github.com/spf13/afero" - - "github.com/bep/debounce" + "github.com/gohugoio/hugo/common/paths" "github.com/gohugoio/hugo/common/types" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/config/allconfig" "github.com/gohugoio/hugo/deps" "github.com/gohugoio/hugo/helpers" "github.com/gohugoio/hugo/hugofs" - "github.com/gohugoio/hugo/langs" + "github.com/gohugoio/hugo/hugolib" + "github.com/gohugoio/hugo/identity" + "github.com/gohugoio/hugo/resources/kinds" + "github.com/spf13/afero" + "github.com/spf13/cobra" ) -type commandeerHugoState struct { - *deps.DepsCfg - hugoSites *hugolib.HugoSites - fsCreate sync.Once - created chan struct{} -} +var errHelp = errors.New("help requested") -type commandeer struct { - *commandeerHugoState - - logger *loggers.Logger - - // Currently only set when in "fast render mode". But it seems to - // be fast enough that we could maybe just add it for all server modes. - changeDetector *fileChangeDetector - - // We need to reuse this on server rebuilds. - destinationFs afero.Fs - - h *hugoBuilderCommon - ftch flagsToConfigHandler - - visitedURLs *types.EvictingStringQueue - - doWithCommandeer func(c *commandeer) error - - // We watch these for changes. - configFiles []string - - // Used in cases where we get flooded with events in server mode. - debounce func(f func()) - - serverPorts []int - languagesConfigured bool - languages langs.Languages - doLiveReload bool - fastRenderMode bool - showErrorInBrowser bool - wasError bool - - configured bool - paused bool - - fullRebuildSem *semaphore.Weighted - - // Any error from the last build. - buildErr error -} - -func newCommandeerHugoState() *commandeerHugoState { - return &commandeerHugoState{ - created: make(chan struct{}), +// Execute executes a command. +func Execute(args []string) error { + // Default GOMAXPROCS to be CPU limit aware, still respecting GOMAXPROCS env. + maxprocs.Set() + x, err := newExec() + if err != nil { + return err } -} - -func (c *commandeerHugoState) hugo() *hugolib.HugoSites { - <-c.created - return c.hugoSites -} - -func (c *commandeer) errCount() int { - return int(c.logger.ErrorCounter.Count()) -} - -func (c *commandeer) getErrorWithContext() interface{} { - errCount := c.errCount() - - if errCount == 0 { - return nil - } - - m := make(map[string]interface{}) - - m["Error"] = errors.New(removeErrorPrefixFromLog(c.logger.Errors())) - m["Version"] = hugo.BuildVersionString() - - fe := herrors.UnwrapErrorWithFileContext(c.buildErr) - if fe != nil { - m["File"] = fe - } - - if c.h.verbose { - var b bytes.Buffer - herrors.FprintStackTraceFromErr(&b, c.buildErr) - m["StackTrace"] = b.String() - } - - return m -} - -func (c *commandeer) Set(key string, value interface{}) { - if c.configured { - panic("commandeer cannot be changed") - } - c.Cfg.Set(key, value) -} - -func (c *commandeer) initFs(fs *hugofs.Fs) error { - c.destinationFs = fs.Destination - c.DepsCfg.Fs = fs - - return nil -} - -func newCommandeer(mustHaveConfigFile, running bool, h *hugoBuilderCommon, f flagsToConfigHandler, doWithCommandeer func(c *commandeer) error, subCmdVs ...*cobra.Command) (*commandeer, error) { - - var rebuildDebouncer func(f func()) - if running { - // The time value used is tested with mass content replacements in a fairly big Hugo site. - // It is better to wait for some seconds in those cases rather than get flooded - // with rebuilds. - rebuildDebouncer = debounce.New(4 * time.Second) - } - - out := ioutil.Discard - if !h.quiet { - out = os.Stdout - } - - c := &commandeer{ - h: h, - ftch: f, - commandeerHugoState: newCommandeerHugoState(), - doWithCommandeer: doWithCommandeer, - visitedURLs: types.NewEvictingStringQueue(10), - debounce: rebuildDebouncer, - fullRebuildSem: semaphore.NewWeighted(1), - // This will be replaced later, but we need something to log to before the configuration is read. - logger: loggers.NewLogger(jww.LevelError, jww.LevelError, out, ioutil.Discard, running), - } - - return c, c.loadConfig(mustHaveConfigFile, running) -} - -type fileChangeDetector struct { - sync.Mutex - current map[string]string - prev map[string]string - - irrelevantRe *regexp.Regexp -} - -func (f *fileChangeDetector) OnFileClose(name, md5sum string) { - f.Lock() - defer f.Unlock() - f.current[name] = md5sum -} - -func (f *fileChangeDetector) changed() []string { - if f == nil { - return nil - } - f.Lock() - defer f.Unlock() - var c []string - for k, v := range f.current { - vv, found := f.prev[k] - if !found || v != vv { - c = append(c, k) + args = mapLegacyArgs(args) + cd, err := x.Execute(context.Background(), args) + if cd != nil { + if closer, ok := cd.Root.Command.(types.Closer); ok { + closer.Close() } } - return f.filterIrrelevant(c) -} - -func (f *fileChangeDetector) filterIrrelevant(in []string) []string { - var filtered []string - for _, v := range in { - if !f.irrelevantRe.MatchString(v) { - filtered = append(filtered, v) - } - } - return filtered -} - -func (f *fileChangeDetector) PrepareNew() { - if f == nil { - return - } - - f.Lock() - defer f.Unlock() - - if f.current == nil { - f.current = make(map[string]string) - f.prev = make(map[string]string) - return - } - - f.prev = make(map[string]string) - for k, v := range f.current { - f.prev[k] = v - } - f.current = make(map[string]string) -} - -func (c *commandeer) loadConfig(mustHaveConfigFile, running bool) error { - - if c.DepsCfg == nil { - c.DepsCfg = &deps.DepsCfg{} - } - - if c.logger != nil { - // Truncate the error log if this is a reload. - c.logger.Reset() - } - - cfg := c.DepsCfg - c.configured = false - cfg.Running = running - - var dir string - if c.h.source != "" { - dir, _ = filepath.Abs(c.h.source) - } else { - dir, _ = os.Getwd() - } - - var sourceFs afero.Fs = hugofs.Os - if c.DepsCfg.Fs != nil { - sourceFs = c.DepsCfg.Fs.Source - } - - environment := c.h.getEnvironment(running) - - doWithConfig := func(cfg config.Provider) error { - - if c.ftch != nil { - c.ftch.flagsToConfig(cfg) - } - - cfg.Set("workingDir", dir) - cfg.Set("environment", environment) - return nil - } - - doWithCommandeer := func(cfg config.Provider) error { - c.Cfg = cfg - if c.doWithCommandeer == nil { + if err != nil { + if err == errHelp { + cd.CobraCommand.Help() + fmt.Println() return nil } - err := c.doWithCommandeer(c) - return err - } - - configPath := c.h.source - if configPath == "" { - configPath = dir - } - config, configFiles, err := hugolib.LoadConfig( - hugolib.ConfigSourceDescriptor{ - Fs: sourceFs, - Logger: c.logger, - Path: configPath, - WorkingDir: dir, - Filename: c.h.cfgFile, - AbsConfigDir: c.h.getConfigDir(dir), - Environ: os.Environ(), - Environment: environment}, - doWithCommandeer, - doWithConfig) - - if err != nil && mustHaveConfigFile { - return err - } else if mustHaveConfigFile && len(configFiles) == 0 { - return hugolib.ErrNoConfigFile - } - - c.configFiles = configFiles - - if l, ok := c.Cfg.Get("languagesSorted").(langs.Languages); ok { - c.languagesConfigured = true - c.languages = l - } - - // Set some commonly used flags - c.doLiveReload = running && !c.Cfg.GetBool("disableLiveReload") - c.fastRenderMode = c.doLiveReload && !c.Cfg.GetBool("disableFastRender") - c.showErrorInBrowser = c.doLiveReload && !c.Cfg.GetBool("disableBrowserError") - - // This is potentially double work, but we need to do this one more time now - // that all the languages have been configured. - if c.doWithCommandeer != nil { - if err := c.doWithCommandeer(c); err != nil { - return err + if simplecobra.IsCommandError(err) { + // Print the help, but also return the error to fail the command. + cd.CobraCommand.Help() + fmt.Println() } } + return err +} - logger, err := c.createLogger(config, running) - if err != nil { - return err - } +type commonConfig struct { + mu *sync.Mutex + configs *allconfig.Configs + cfg config.Provider + fs *hugofs.Fs +} - cfg.Logger = logger - c.logger = logger +type configKey struct { + counter int32 + ignoreModulesDoesNotExists bool +} - createMemFs := config.GetBool("renderToMemory") +// This is the root command. +type rootCommand struct { + Printf func(format string, v ...any) + Println func(a ...any) + StdOut io.Writer + StdErr io.Writer - if createMemFs { - // Rendering to memoryFS, publish to Root regardless of publishDir. - config.Set("publishDir", "/") - } + logger loggers.Logger - c.fsCreate.Do(func() { - fs := hugofs.NewFrom(sourceFs, config) + // The main cache busting key for the caches below. + configVersionID atomic.Int32 - if c.destinationFs != nil { - // Need to reuse the destination on server rebuilds. - fs.Destination = c.destinationFs - } else if createMemFs { - // Hugo writes the output to memory instead of the disk. - fs.Destination = new(afero.MemMapFs) - } + // Some, but not all commands need access to these. + // Some needs more than one, so keep them in a small cache. + commonConfigs *lazycache.Cache[configKey, *commonConfig] + hugoSites *lazycache.Cache[configKey, *hugolib.HugoSites] - if c.fastRenderMode { - // For now, fast render mode only. It should, however, be fast enough - // for the full variant, too. - changeDetector := &fileChangeDetector{ - // We use this detector to decide to do a Hot reload of a single path or not. - // We need to filter out source maps and possibly some other to be able - // to make that decision. - irrelevantRe: regexp.MustCompile(`\.map$`), + // changesFromBuild received from Hugo in watch mode. + changesFromBuild chan []identity.Identity + + commands []simplecobra.Commander + + // Flags + source string + buildWatch bool + environment string + + // Common build flags. + baseURL string + gc bool + poll string + forceSyncStatic bool + + // Profile flags (for debugging of performance problems) + cpuprofile string + memprofile string + mutexprofile string + traceprofile string + printm bool + + logLevel string + + quiet bool + devMode bool // Hidden flag. + + renderToMemory bool + + cfgFile string + cfgDir string +} + +func (r *rootCommand) isVerbose() bool { + return r.logger.Level() <= logg.LevelInfo +} + +func (r *rootCommand) Close() error { + if r.hugoSites != nil { + r.hugoSites.DeleteFunc(func(key configKey, value *hugolib.HugoSites) bool { + if value != nil { + value.Close() } + return false + }) + } + return nil +} - changeDetector.PrepareNew() - fs.Destination = hugofs.NewHashingFs(fs.Destination, changeDetector) - c.changeDetector = changeDetector - } +func (r *rootCommand) Build(cd *simplecobra.Commandeer, bcfg hugolib.BuildCfg, cfg config.Provider) (*hugolib.HugoSites, error) { + h, err := r.Hugo(cfg) + if err != nil { + return nil, err + } + if err := h.Build(bcfg); err != nil { + return nil, err + } - if c.Cfg.GetBool("logPathWarnings") { - fs.Destination = hugofs.NewCreateCountingFs(fs.Destination) - } + return h, nil +} - // To debug hard-to-find path issues. - //fs.Destination = hugofs.NewStacktracerFs(fs.Destination, `fr/fr`) +func (r *rootCommand) Commands() []simplecobra.Commander { + return r.commands +} - err = c.initFs(fs) +func (r *rootCommand) ConfigFromConfig(key configKey, oldConf *commonConfig) (*commonConfig, error) { + cc, _, err := r.commonConfigs.GetOrCreate(key, func(key configKey) (*commonConfig, error) { + fs := oldConf.fs + configs, err := allconfig.LoadConfig( + allconfig.ConfigSourceDescriptor{ + Flags: oldConf.cfg, + Fs: fs.Source, + Filename: r.cfgFile, + ConfigDir: r.cfgDir, + Logger: r.logger, + Environment: r.environment, + IgnoreModuleDoesNotExist: key.ignoreModulesDoesNotExists, + }, + ) if err != nil { - close(c.created) - return + return nil, err } - var h *hugolib.HugoSites - - h, err = hugolib.NewHugoSites(*c.DepsCfg) - c.hugoSites = h - close(c.created) + if !configs.Base.C.Clock.IsZero() { + // TODO(bep) find a better place for this. + htime.Clock = clocks.Start(configs.Base.C.Clock) + } + return &commonConfig{ + mu: oldConf.mu, + configs: configs, + cfg: oldConf.cfg, + fs: fs, + }, nil }) + return cc, err +} + +func (r *rootCommand) ConfigFromProvider(key configKey, cfg config.Provider) (*commonConfig, error) { + if cfg == nil { + panic("cfg must be set") + } + cc, _, err := r.commonConfigs.GetOrCreate(key, func(key configKey) (*commonConfig, error) { + var dir string + if r.source != "" { + dir, _ = filepath.Abs(r.source) + } else { + dir, _ = os.Getwd() + } + + if cfg == nil { + cfg = config.New() + } + + if !cfg.IsSet("workingDir") { + cfg.Set("workingDir", dir) + } else { + if err := os.MkdirAll(cfg.GetString("workingDir"), 0o777); err != nil { + return nil, fmt.Errorf("failed to create workingDir: %w", err) + } + } + + // Load the config first to allow publishDir to be configured in config file. + configs, err := allconfig.LoadConfig( + allconfig.ConfigSourceDescriptor{ + Flags: cfg, + Fs: hugofs.Os, + Filename: r.cfgFile, + ConfigDir: r.cfgDir, + Environment: r.environment, + Logger: r.logger, + IgnoreModuleDoesNotExist: key.ignoreModulesDoesNotExists, + }, + ) + if err != nil { + return nil, err + } + + base := configs.Base + + cfg.Set("publishDir", base.PublishDir) + cfg.Set("publishDirStatic", base.PublishDir) + cfg.Set("publishDirDynamic", base.PublishDir) + + renderStaticToDisk := cfg.GetBool("renderStaticToDisk") + + sourceFs := hugofs.Os + var destinationFs afero.Fs + if cfg.GetBool("renderToMemory") { + destinationFs = afero.NewMemMapFs() + if renderStaticToDisk { + // Hybrid, render dynamic content to Root. + cfg.Set("publishDirDynamic", "/") + } else { + // Rendering to memoryFS, publish to Root regardless of publishDir. + cfg.Set("publishDirDynamic", "/") + cfg.Set("publishDirStatic", "/") + } + } else { + destinationFs = hugofs.Os + } + + fs := hugofs.NewFromSourceAndDestination(sourceFs, destinationFs, cfg) + + if renderStaticToDisk { + dynamicFs := fs.PublishDir + publishDirStatic := cfg.GetString("publishDirStatic") + workingDir := cfg.GetString("workingDir") + absPublishDirStatic := paths.AbsPathify(workingDir, publishDirStatic) + staticFs := hugofs.NewBasePathFs(afero.NewOsFs(), absPublishDirStatic) + + // Serve from both the static and dynamic fs, + // the first will take priority. + // THis is a read-only filesystem, + // we do all the writes to + // fs.Destination and fs.DestinationStatic. + fs.PublishDirServer = overlayfs.New( + overlayfs.Options{ + Fss: []afero.Fs{ + dynamicFs, + staticFs, + }, + }, + ) + fs.PublishDirStatic = staticFs + + } + + if !base.C.Clock.IsZero() { + // TODO(bep) find a better place for this. + htime.Clock = clocks.Start(configs.Base.C.Clock) + } + + if base.PrintPathWarnings { + // Note that we only care about the "dynamic creates" here, + // so skip the static fs. + fs.PublishDir = hugofs.NewCreateCountingFs(fs.PublishDir) + } + + commonConfig := &commonConfig{ + mu: &sync.Mutex{}, + configs: configs, + cfg: cfg, + fs: fs, + } + + return commonConfig, nil + }) + + return cc, err +} + +func (r *rootCommand) HugFromConfig(conf *commonConfig) (*hugolib.HugoSites, error) { + k := configKey{counter: r.configVersionID.Load()} + h, _, err := r.hugoSites.GetOrCreate(k, func(key configKey) (*hugolib.HugoSites, error) { + depsCfg := r.newDepsConfig(conf) + return hugolib.NewHugoSites(depsCfg) + }) + return h, err +} + +func (r *rootCommand) Hugo(cfg config.Provider) (*hugolib.HugoSites, error) { + return r.getOrCreateHugo(cfg, false) +} + +func (r *rootCommand) getOrCreateHugo(cfg config.Provider, ignoreModuleDoesNotExist bool) (*hugolib.HugoSites, error) { + k := configKey{counter: r.configVersionID.Load(), ignoreModulesDoesNotExists: ignoreModuleDoesNotExist} + h, _, err := r.hugoSites.GetOrCreate(k, func(key configKey) (*hugolib.HugoSites, error) { + conf, err := r.ConfigFromProvider(key, cfg) + if err != nil { + return nil, err + } + depsCfg := r.newDepsConfig(conf) + return hugolib.NewHugoSites(depsCfg) + }) + return h, err +} + +func (r *rootCommand) newDepsConfig(conf *commonConfig) deps.DepsCfg { + return deps.DepsCfg{Configs: conf.configs, Fs: conf.fs, StdOut: r.logger.StdOut(), StdErr: r.logger.StdErr(), LogLevel: r.logger.Level(), ChangesFromBuild: r.changesFromBuild} +} + +func (r *rootCommand) Name() string { + return "hugo" +} + +func (r *rootCommand) Run(ctx context.Context, cd *simplecobra.Commandeer, args []string) error { + b := newHugoBuilder(r, nil) + + if !r.buildWatch { + defer b.postBuild("Total", time.Now()) + } + + if err := b.loadConfig(cd, false); err != nil { + return err + } + + err := func() error { + if r.buildWatch { + defer r.timeTrack(time.Now(), "Built") + } + err := b.build() + if err != nil { + return err + } + return nil + }() if err != nil { return err } - cacheDir, err := helpers.GetCacheDir(sourceFs, config) + if !r.buildWatch { + // Done. + return nil + } + + watchDirs, err := b.getDirList() if err != nil { return err } - config.Set("cacheDir", cacheDir) - cfg.Logger.INFO.Println("Using config file:", config.ConfigFileUsed()) + watchGroups := helpers.ExtractAndGroupRootPaths(watchDirs) + + for _, group := range watchGroups { + r.Printf("Watching for changes in %s\n", group) + } + watcher, err := b.newWatcher(r.poll, watchDirs...) + if err != nil { + return err + } + + defer watcher.Close() + + r.Println("Press Ctrl+C to stop") + + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + + <-sigs return nil - +} + +func (r *rootCommand) PreRun(cd, runner *simplecobra.Commandeer) error { + r.StdOut = os.Stdout + r.StdErr = os.Stderr + if r.quiet { + r.StdOut = io.Discard + r.StdErr = io.Discard + } + // Used by mkcert (server). + log.SetOutput(r.StdOut) + + r.Printf = func(format string, v ...any) { + if !r.quiet { + fmt.Fprintf(r.StdOut, format, v...) + } + } + r.Println = func(a ...any) { + if !r.quiet { + fmt.Fprintln(r.StdOut, a...) + } + } + _, running := runner.Command.(*serverCommand) + var err error + r.logger, err = r.createLogger(running) + if err != nil { + return err + } + // Set up the global logger early to allow info deprecations during config load. + loggers.SetGlobalLogger(r.logger) + + r.changesFromBuild = make(chan []identity.Identity, 10) + + r.commonConfigs = lazycache.New(lazycache.Options[configKey, *commonConfig]{MaxEntries: 5}) + // We don't want to keep stale HugoSites in memory longer than needed. + r.hugoSites = lazycache.New(lazycache.Options[configKey, *hugolib.HugoSites]{ + MaxEntries: 1, + OnEvict: func(key configKey, value *hugolib.HugoSites) { + value.Close() + runtime.GC() + }, + }) + + return nil +} + +func (r *rootCommand) createLogger(running bool) (loggers.Logger, error) { + level := logg.LevelWarn + + if r.devMode { + level = logg.LevelTrace + } else { + if r.logLevel != "" { + switch strings.ToLower(r.logLevel) { + case "debug": + level = logg.LevelDebug + case "info": + level = logg.LevelInfo + case "warn", "warning": + level = logg.LevelWarn + case "error": + level = logg.LevelError + default: + return nil, fmt.Errorf("invalid log level: %q, must be one of debug, warn, info or error", r.logLevel) + } + } + } + + optsLogger := loggers.Options{ + DistinctLevel: logg.LevelWarn, + Level: level, + StdOut: r.StdOut, + StdErr: r.StdErr, + StoreErrors: running, + } + + return loggers.New(optsLogger), nil +} + +func (r *rootCommand) resetLogs() { + r.logger.Reset() + loggers.Log().Reset() +} + +// IsTestRun reports whether the command is running as a test. +func (r *rootCommand) IsTestRun() bool { + return os.Getenv("HUGO_TESTRUN") != "" +} + +func (r *rootCommand) Init(cd *simplecobra.Commandeer) error { + return r.initRootCommand("", cd) +} + +func (r *rootCommand) initRootCommand(subCommandName string, cd *simplecobra.Commandeer) error { + cmd := cd.CobraCommand + commandName := "hugo" + if subCommandName != "" { + commandName = subCommandName + } + cmd.Use = fmt.Sprintf("%s [flags]", commandName) + cmd.Short = "Build your site" + cmd.Long = `COMMAND_NAME is the main command, used to build your Hugo site. + +Hugo is a Fast and Flexible Static Site Generator +built with love by spf13 and friends in Go. + +Complete documentation is available at https://gohugo.io/.` + + cmd.Long = strings.ReplaceAll(cmd.Long, "COMMAND_NAME", commandName) + + // Configure persistent flags + cmd.PersistentFlags().StringVarP(&r.source, "source", "s", "", "filesystem path to read files relative from") + _ = cmd.MarkFlagDirname("source") + cmd.PersistentFlags().StringP("destination", "d", "", "filesystem path to write files to") + _ = cmd.MarkFlagDirname("destination") + + cmd.PersistentFlags().StringVarP(&r.environment, "environment", "e", "", "build environment") + _ = cmd.RegisterFlagCompletionFunc("environment", cobra.NoFileCompletions) + cmd.PersistentFlags().StringP("themesDir", "", "", "filesystem path to themes directory") + _ = cmd.MarkFlagDirname("themesDir") + cmd.PersistentFlags().StringP("ignoreVendorPaths", "", "", "ignores any _vendor for module paths matching the given Glob pattern") + cmd.PersistentFlags().BoolP("noBuildLock", "", false, "don't create .hugo_build.lock file") + _ = cmd.RegisterFlagCompletionFunc("ignoreVendorPaths", cobra.NoFileCompletions) + cmd.PersistentFlags().String("clock", "", "set the clock used by Hugo, e.g. --clock 2021-11-06T22:30:00.00+09:00") + _ = cmd.RegisterFlagCompletionFunc("clock", cobra.NoFileCompletions) + + cmd.PersistentFlags().StringVar(&r.cfgFile, "config", "", "config file (default is hugo.yaml|json|toml)") + _ = cmd.MarkFlagFilename("config", config.ValidConfigFileExtensions...) + cmd.PersistentFlags().StringVar(&r.cfgDir, "configDir", "config", "config dir") + _ = cmd.MarkFlagDirname("configDir") + cmd.PersistentFlags().BoolVar(&r.quiet, "quiet", false, "build in quiet mode") + cmd.PersistentFlags().BoolVarP(&r.renderToMemory, "renderToMemory", "M", false, "render to memory (mostly useful when running the server)") + + cmd.PersistentFlags().BoolVarP(&r.devMode, "devMode", "", false, "only used for internal testing, flag hidden.") + cmd.PersistentFlags().StringVar(&r.logLevel, "logLevel", "", "log level (debug|info|warn|error)") + _ = cmd.RegisterFlagCompletionFunc("logLevel", cobra.FixedCompletions([]string{"debug", "info", "warn", "error"}, cobra.ShellCompDirectiveNoFileComp)) + cmd.Flags().BoolVarP(&r.buildWatch, "watch", "w", false, "watch filesystem for changes and recreate as needed") + + cmd.PersistentFlags().MarkHidden("devMode") + + // Configure local flags + applyLocalFlagsBuild(cmd, r) + + return nil +} + +// A sub set of the complete build flags. These flags are used by new and mod. +func applyLocalFlagsBuildConfig(cmd *cobra.Command, r *rootCommand) { + cmd.Flags().StringSliceP("theme", "t", []string{}, "themes to use (located in /themes/THEMENAME/)") + _ = cmd.MarkFlagDirname("theme") + cmd.Flags().StringVarP(&r.baseURL, "baseURL", "b", "", "hostname (and path) to the root, e.g. https://spf13.com/") + cmd.Flags().StringP("cacheDir", "", "", "filesystem path to cache directory") + _ = cmd.MarkFlagDirname("cacheDir") + cmd.Flags().StringP("contentDir", "c", "", "filesystem path to content directory") + cmd.Flags().StringSliceP("renderSegments", "", []string{}, "named segments to render (configured in the segments config)") +} + +// Flags needed to do a build (used by hugo and hugo server commands) +func applyLocalFlagsBuild(cmd *cobra.Command, r *rootCommand) { + applyLocalFlagsBuildConfig(cmd, r) + cmd.Flags().Bool("cleanDestinationDir", false, "remove files from destination not found in static directories") + 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().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") + cmd.Flags().BoolVar(&r.gc, "gc", false, "enable to run some cleanup tasks (remove unused cache files) after the build") + cmd.Flags().StringVar(&r.poll, "poll", "", "set this to a poll interval, e.g --poll 700ms, to use a poll based approach to watch for file system changes") + _ = cmd.RegisterFlagCompletionFunc("poll", cobra.NoFileCompletions) + cmd.Flags().Bool("panicOnWarning", false, "panic on first WARNING log") + cmd.Flags().Bool("templateMetrics", false, "display metrics about template executions") + cmd.Flags().Bool("templateMetricsHints", false, "calculate some improvement hints when combined with --templateMetrics") + cmd.Flags().BoolVar(&r.forceSyncStatic, "forceSyncStatic", false, "copy all files when static is changed.") + cmd.Flags().BoolP("noTimes", "", false, "don't sync modification time of files") + cmd.Flags().BoolP("noChmod", "", false, "don't sync permission mode of files") + cmd.Flags().BoolP("printI18nWarnings", "", false, "print missing translations") + cmd.Flags().BoolP("printPathWarnings", "", false, "print warnings on duplicate target paths etc.") + cmd.Flags().BoolP("printUnusedTemplates", "", false, "print warnings on unused templates.") + cmd.Flags().StringVarP(&r.cpuprofile, "profile-cpu", "", "", "write cpu profile to `file`") + cmd.Flags().StringVarP(&r.memprofile, "profile-mem", "", "", "write memory profile to `file`") + cmd.Flags().BoolVarP(&r.printm, "printMemoryUsage", "", false, "print memory usage to screen at intervals") + cmd.Flags().StringVarP(&r.mutexprofile, "profile-mutex", "", "", "write Mutex profile to `file`") + cmd.Flags().StringVarP(&r.traceprofile, "trace", "", "", "write trace to `file` (not useful in general)") + + // Hide these for now. + cmd.Flags().MarkHidden("profile-cpu") + cmd.Flags().MarkHidden("profile-mem") + cmd.Flags().MarkHidden("profile-mutex") + + cmd.Flags().StringSlice("disableKinds", []string{}, "disable different kind of pages (home, RSS etc.)") + _ = cmd.RegisterFlagCompletionFunc("disableKinds", cobra.FixedCompletions(kinds.AllKinds, cobra.ShellCompDirectiveNoFileComp)) + cmd.Flags().Bool("minify", false, "minify any supported output format (HTML, XML etc.)") +} + +func (r *rootCommand) timeTrack(start time.Time, name string) { + elapsed := time.Since(start) + r.Printf("%s in %v ms\n", name, int(1000*elapsed.Seconds())) +} + +type simpleCommand struct { + use string + name string + short string + long string + run func(ctx context.Context, cd *simplecobra.Commandeer, rootCmd *rootCommand, args []string) error + withc func(cmd *cobra.Command, r *rootCommand) + initc func(cd *simplecobra.Commandeer) error + + commands []simplecobra.Commander + + rootCmd *rootCommand +} + +func (c *simpleCommand) Commands() []simplecobra.Commander { + return c.commands +} + +func (c *simpleCommand) Name() string { + return c.name +} + +func (c *simpleCommand) Run(ctx context.Context, cd *simplecobra.Commandeer, args []string) error { + if c.run == nil { + return nil + } + return c.run(ctx, cd, c.rootCmd, args) +} + +func (c *simpleCommand) Init(cd *simplecobra.Commandeer) error { + c.rootCmd = cd.Root.Command.(*rootCommand) + cmd := cd.CobraCommand + cmd.Short = c.short + cmd.Long = c.long + if c.use != "" { + cmd.Use = c.use + } + if c.withc != nil { + c.withc(cmd, c.rootCmd) + } + return nil +} + +func (c *simpleCommand) PreRun(cd, runner *simplecobra.Commandeer) error { + if c.initc != nil { + return c.initc(cd) + } + return nil +} + +func mapLegacyArgs(args []string) []string { + if len(args) > 1 && args[0] == "new" && !hstrings.EqualAny(args[1], "site", "theme", "content") { + // Insert "content" as the second argument + args = append(args[:1], append([]string{"content"}, args[1:]...)...) + } + return args } diff --git a/commands/commands.go b/commands/commands.go index 66fd9caa4..10ab106e2 100644 --- a/commands/commands.go +++ b/commands/commands.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Hugo Authors. All rights reserved. +// Copyright 2024 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,321 +14,60 @@ package commands import ( - "fmt" - "os" - "time" + "context" - "github.com/gohugoio/hugo/hugolib/paths" - - "github.com/gohugoio/hugo/common/hugo" - "github.com/gohugoio/hugo/common/loggers" - "github.com/gohugoio/hugo/config" - "github.com/gohugoio/hugo/helpers" - "github.com/spf13/cobra" + "github.com/bep/simplecobra" ) -type commandsBuilder struct { - hugoBuilderCommon - - commands []cmder -} - -func newCommandsBuilder() *commandsBuilder { - return &commandsBuilder{} -} - -func (b *commandsBuilder) addCommands(commands ...cmder) *commandsBuilder { - b.commands = append(b.commands, commands...) - return b -} - -func (b *commandsBuilder) addAll() *commandsBuilder { - b.addCommands( - b.newServerCmd(), - newVersionCmd(), - newEnvCmd(), - b.newConfigCmd(), - newCheckCmd(), - b.newDeployCmd(), - b.newConvertCmd(), - b.newNewCmd(), - b.newListCmd(), - newImportCmd(), - newGenCmd(), - createReleaser(), - b.newModCmd(), - ) - - return b -} - -func (b *commandsBuilder) build() *hugoCmd { - h := b.newHugoCmd() - addCommands(h.getCommand(), b.commands...) - return h -} - -func addCommands(root *cobra.Command, commands ...cmder) { - for _, command := range commands { - cmd := command.getCommand() - if cmd == nil { - continue - } - root.AddCommand(cmd) +// newExec wires up all of Hugo's CLI. +func newExec() (*simplecobra.Exec, error) { + rootCmd := &rootCommand{ + commands: []simplecobra.Commander{ + newHugoBuildCmd(), + newVersionCmd(), + newEnvCommand(), + newServerCommand(), + newDeployCommand(), + newConfigCommand(), + newNewCommand(), + newConvertCommand(), + newImportCommand(), + newListCommand(), + newModCommands(), + newGenCommand(), + newReleaseCommand(), + }, } + + return simplecobra.New(rootCmd) } -type baseCmd struct { - cmd *cobra.Command +func newHugoBuildCmd() simplecobra.Commander { + return &hugoBuildCommand{} } -var _ commandsBuilderGetter = (*baseBuilderCmd)(nil) - -// Used in tests. -type commandsBuilderGetter interface { - getCommandsBuilder() *commandsBuilder -} -type baseBuilderCmd struct { - *baseCmd - *commandsBuilder +// hugoBuildCommand just delegates to the rootCommand. +type hugoBuildCommand struct { + rootCmd *rootCommand } -func (b *baseBuilderCmd) getCommandsBuilder() *commandsBuilder { - return b.commandsBuilder -} - -func (c *baseCmd) getCommand() *cobra.Command { - return c.cmd -} - -func newBaseCmd(cmd *cobra.Command) *baseCmd { - return &baseCmd{cmd: cmd} -} - -func (b *commandsBuilder) newBuilderCmd(cmd *cobra.Command) *baseBuilderCmd { - bcmd := &baseBuilderCmd{commandsBuilder: b, baseCmd: &baseCmd{cmd: cmd}} - bcmd.hugoBuilderCommon.handleFlags(cmd) - return bcmd -} - -func (b *commandsBuilder) newBuilderBasicCmd(cmd *cobra.Command) *baseBuilderCmd { - bcmd := &baseBuilderCmd{commandsBuilder: b, baseCmd: &baseCmd{cmd: cmd}} - bcmd.hugoBuilderCommon.handleCommonBuilderFlags(cmd) - return bcmd -} - -func (c *baseCmd) flagsToConfig(cfg config.Provider) { - initializeFlags(c.cmd, cfg) -} - -type hugoCmd struct { - *baseBuilderCmd - - // Need to get the sites once built. - c *commandeer -} - -var _ cmder = (*nilCommand)(nil) - -type nilCommand struct { -} - -func (c *nilCommand) getCommand() *cobra.Command { +func (c *hugoBuildCommand) Commands() []simplecobra.Commander { return nil } -func (c *nilCommand) flagsToConfig(cfg config.Provider) { - +func (c *hugoBuildCommand) Name() string { + return "build" } -func (b *commandsBuilder) newHugoCmd() *hugoCmd { - cc := &hugoCmd{} - - cc.baseBuilderCmd = b.newBuilderCmd(&cobra.Command{ - Use: "hugo", - Short: "hugo builds your site", - Long: `hugo is the main command, used to build your Hugo site. - -Hugo is a Fast and Flexible Static Site Generator -built with love by spf13 and friends in Go. - -Complete documentation is available at http://gohugo.io/.`, - RunE: func(cmd *cobra.Command, args []string) error { - defer cc.timeTrack(time.Now(), "Total") - cfgInit := func(c *commandeer) error { - if cc.buildWatch { - c.Set("disableLiveReload", true) - } - return nil - } - - c, err := initializeConfig(true, cc.buildWatch, &cc.hugoBuilderCommon, cc, cfgInit) - if err != nil { - return err - } - cc.c = c - - return c.build() - }, - }) - - cc.cmd.PersistentFlags().StringVar(&cc.cfgFile, "config", "", "config file (default is path/config.yaml|json|toml)") - cc.cmd.PersistentFlags().StringVar(&cc.cfgDir, "configDir", "config", "config dir") - cc.cmd.PersistentFlags().BoolVar(&cc.quiet, "quiet", false, "build in quiet mode") - - // Set bash-completion - _ = cc.cmd.PersistentFlags().SetAnnotation("config", cobra.BashCompFilenameExt, config.ValidConfigFileExtensions) - - cc.cmd.PersistentFlags().BoolVarP(&cc.verbose, "verbose", "v", false, "verbose output") - cc.cmd.PersistentFlags().BoolVarP(&cc.debug, "debug", "", false, "debug output") - cc.cmd.PersistentFlags().BoolVar(&cc.logging, "log", false, "enable Logging") - cc.cmd.PersistentFlags().StringVar(&cc.logFile, "logFile", "", "log File path (if set, logging enabled automatically)") - cc.cmd.PersistentFlags().BoolVar(&cc.verboseLog, "verboseLog", false, "verbose logging") - - cc.cmd.Flags().BoolVarP(&cc.buildWatch, "watch", "w", false, "watch filesystem for changes and recreate as needed") - - cc.cmd.Flags().Bool("renderToMemory", false, "render to memory (only useful for benchmark testing)") - - // Set bash-completion - _ = cc.cmd.PersistentFlags().SetAnnotation("logFile", cobra.BashCompFilenameExt, []string{}) - - cc.cmd.SetGlobalNormalizationFunc(helpers.NormalizeHugoFlags) - cc.cmd.SilenceUsage = true - - return cc +func (c *hugoBuildCommand) Init(cd *simplecobra.Commandeer) error { + c.rootCmd = cd.Root.Command.(*rootCommand) + return c.rootCmd.initRootCommand("build", cd) } -type hugoBuilderCommon struct { - source string - baseURL string - environment string - - buildWatch bool - - gc bool - - // Profile flags (for debugging of performance problems) - cpuprofile string - memprofile string - mutexprofile string - traceprofile string - - // TODO(bep) var vs string - logging bool - verbose bool - verboseLog bool - debug bool - quiet bool - - cfgFile string - cfgDir string - logFile string +func (c *hugoBuildCommand) PreRun(cd, runner *simplecobra.Commandeer) error { + return c.rootCmd.PreRun(cd, runner) } -func (cc *hugoBuilderCommon) timeTrack(start time.Time, name string) { - if cc.quiet { - return - } - elapsed := time.Since(start) - fmt.Printf("%s in %v ms\n", name, int(1000*elapsed.Seconds())) -} - -func (cc *hugoBuilderCommon) getConfigDir(baseDir string) string { - if cc.cfgDir != "" { - return paths.AbsPathify(baseDir, cc.cfgDir) - } - - if v, found := os.LookupEnv("HUGO_CONFIGDIR"); found { - return paths.AbsPathify(baseDir, v) - } - - return paths.AbsPathify(baseDir, "config") -} - -func (cc *hugoBuilderCommon) getEnvironment(isServer bool) string { - if cc.environment != "" { - return cc.environment - } - - if v, found := os.LookupEnv("HUGO_ENVIRONMENT"); found { - return v - } - - // Used by Netlify and Forestry - if v, found := os.LookupEnv("HUGO_ENV"); found { - return v - } - - if isServer { - return hugo.EnvironmentDevelopment - } - - return hugo.EnvironmentProduction -} - -func (cc *hugoBuilderCommon) handleCommonBuilderFlags(cmd *cobra.Command) { - cmd.PersistentFlags().StringVarP(&cc.source, "source", "s", "", "filesystem path to read files relative from") - cmd.PersistentFlags().SetAnnotation("source", cobra.BashCompSubdirsInDir, []string{}) - cmd.PersistentFlags().StringVarP(&cc.environment, "environment", "e", "", "build environment") - cmd.PersistentFlags().StringP("themesDir", "", "", "filesystem path to themes directory") - cmd.PersistentFlags().BoolP("ignoreVendor", "", false, "ignores any _vendor directory") -} - -func (cc *hugoBuilderCommon) handleFlags(cmd *cobra.Command) { - cc.handleCommonBuilderFlags(cmd) - cmd.Flags().Bool("cleanDestinationDir", false, "remove files from destination not found in static directories") - 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().StringP("contentDir", "c", "", "filesystem path to content directory") - cmd.Flags().StringP("layoutDir", "l", "", "filesystem path to layout directory") - cmd.Flags().StringP("cacheDir", "", "", "filesystem path to cache directory. Defaults: $TMPDIR/hugo_cache/") - cmd.Flags().BoolP("ignoreCache", "", false, "ignores the cache directory") - cmd.Flags().StringP("destination", "d", "", "filesystem path to write files to") - cmd.Flags().StringSliceP("theme", "t", []string{}, "themes to use (located in /themes/THEMENAME/)") - cmd.Flags().StringVarP(&cc.baseURL, "baseURL", "b", "", "hostname (and path) to the root, e.g. http://spf13.com/") - cmd.Flags().Bool("enableGitInfo", false, "add Git revision, date and author info to the pages") - cmd.Flags().BoolVar(&cc.gc, "gc", false, "enable to run some cleanup tasks (remove unused cache files) after the build") - - cmd.Flags().Bool("templateMetrics", false, "display metrics about template executions") - cmd.Flags().Bool("templateMetricsHints", false, "calculate some improvement hints when combined with --templateMetrics") - cmd.Flags().BoolP("forceSyncStatic", "", false, "copy all files when static is changed.") - cmd.Flags().BoolP("noTimes", "", false, "don't sync modification time of files") - cmd.Flags().BoolP("noChmod", "", false, "don't sync permission mode of files") - cmd.Flags().BoolP("i18n-warnings", "", false, "print missing translations") - cmd.Flags().BoolP("path-warnings", "", false, "print warnings on duplicate target paths etc.") - cmd.Flags().StringVarP(&cc.cpuprofile, "profile-cpu", "", "", "write cpu profile to `file`") - cmd.Flags().StringVarP(&cc.memprofile, "profile-mem", "", "", "write memory profile to `file`") - cmd.Flags().StringVarP(&cc.mutexprofile, "profile-mutex", "", "", "write Mutex profile to `file`") - cmd.Flags().StringVarP(&cc.traceprofile, "trace", "", "", "write trace to `file` (not useful in general)") - - // Hide these for now. - cmd.Flags().MarkHidden("profile-cpu") - cmd.Flags().MarkHidden("profile-mem") - cmd.Flags().MarkHidden("profile-mutex") - - cmd.Flags().StringSlice("disableKinds", []string{}, "disable different kind of pages (home, RSS etc.)") - - cmd.Flags().Bool("minify", false, "minify any supported output format (HTML, XML etc.)") - - // Set bash-completion. - // Each flag must first be defined before using the SetAnnotation() call. - _ = cmd.Flags().SetAnnotation("source", cobra.BashCompSubdirsInDir, []string{}) - _ = cmd.Flags().SetAnnotation("cacheDir", cobra.BashCompSubdirsInDir, []string{}) - _ = cmd.Flags().SetAnnotation("destination", cobra.BashCompSubdirsInDir, []string{}) - _ = cmd.Flags().SetAnnotation("theme", cobra.BashCompSubdirsInDir, []string{"themes"}) -} - -func checkErr(logger *loggers.Logger, err error, s ...string) { - if err == nil { - return - } - if len(s) == 0 { - logger.CRITICAL.Println(err) - return - } - for _, message := range s { - logger.ERROR.Println(message) - } - logger.ERROR.Println(err) +func (c *hugoBuildCommand) Run(ctx context.Context, cd *simplecobra.Commandeer, args []string) error { + return c.rootCmd.Run(ctx, cd, args) } diff --git a/commands/commands_test.go b/commands/commands_test.go deleted file mode 100644 index 3b1944891..000000000 --- a/commands/commands_test.go +++ /dev/null @@ -1,401 +0,0 @@ -// Copyright 2019 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 commands - -import ( - "fmt" - "io/ioutil" - "os" - "path/filepath" - "testing" - - "github.com/gohugoio/hugo/htesting" - - "github.com/spf13/afero" - - "github.com/gohugoio/hugo/hugofs" - - "github.com/gohugoio/hugo/common/types" - - "github.com/spf13/cobra" - "github.com/spf13/viper" - - qt "github.com/frankban/quicktest" -) - -func TestExecute(t *testing.T) { - - c := qt.New(t) - - createSite := func(c *qt.C) (string, func()) { - dir, clean, err := createSimpleTestSite(t, testSiteConfig{}) - c.Assert(err, qt.IsNil) - return dir, clean - } - - c.Run("hugo", func(c *qt.C) { - dir, clean := createSite(c) - defer clean() - resp := Execute([]string{"-s=" + dir}) - c.Assert(resp.Err, qt.IsNil) - result := resp.Result - c.Assert(len(result.Sites) == 1, qt.Equals, true) - c.Assert(len(result.Sites[0].RegularPages()) == 1, qt.Equals, true) - c.Assert(result.Sites[0].Info.Params()["myparam"], qt.Equals, "paramproduction") - }) - - c.Run("hugo, set environment", func(c *qt.C) { - dir, clean := createSite(c) - defer clean() - resp := Execute([]string{"-s=" + dir, "-e=staging"}) - c.Assert(resp.Err, qt.IsNil) - result := resp.Result - c.Assert(result.Sites[0].Info.Params()["myparam"], qt.Equals, "paramstaging") - }) - - c.Run("convert toJSON", func(c *qt.C) { - dir, clean := createSite(c) - output := filepath.Join(dir, "myjson") - defer clean() - resp := Execute([]string{"convert", "toJSON", "-s=" + dir, "-e=staging", "-o=" + output}) - c.Assert(resp.Err, qt.IsNil) - converted := readFileFrom(c, filepath.Join(output, "content", "p1.md")) - c.Assert(converted, qt.Equals, "{\n \"title\": \"P1\",\n \"weight\": 1\n}\n\nContent\n\n", qt.Commentf(converted)) - }) - - c.Run("config, set environment", func(c *qt.C) { - dir, clean := createSite(c) - defer clean() - out, err := captureStdout(func() error { - resp := Execute([]string{"config", "-s=" + dir, "-e=staging"}) - return resp.Err - }) - c.Assert(err, qt.IsNil) - c.Assert(out, qt.Contains, "params = map[myparam:paramstaging]", qt.Commentf(out)) - }) - - c.Run("deploy, environment set", func(c *qt.C) { - dir, clean := createSite(c) - defer clean() - resp := Execute([]string{"deploy", "-s=" + dir, "-e=staging", "--target=mydeployment", "--dryRun"}) - c.Assert(resp.Err, qt.Not(qt.IsNil)) - c.Assert(resp.Err.Error(), qt.Contains, `no provider registered for "hugocloud"`) - }) - - c.Run("list", func(c *qt.C) { - dir, clean := createSite(c) - defer clean() - out, err := captureStdout(func() error { - resp := Execute([]string{"list", "all", "-s=" + dir, "-e=staging"}) - return resp.Err - }) - c.Assert(err, qt.IsNil) - c.Assert(out, qt.Contains, "p1.md") - }) - - c.Run("new theme", func(c *qt.C) { - dir, clean := createSite(c) - defer clean() - themesDir := filepath.Join(dir, "mythemes") - resp := Execute([]string{"new", "theme", "mytheme", "-s=" + dir, "-e=staging", "--themesDir=" + themesDir}) - c.Assert(resp.Err, qt.IsNil) - themeTOML := readFileFrom(c, filepath.Join(themesDir, "mytheme", "theme.toml")) - c.Assert(themeTOML, qt.Contains, "name = \"Mytheme\"") - }) - - c.Run("new site", func(c *qt.C) { - dir, clean := createSite(c) - defer clean() - siteDir := filepath.Join(dir, "mysite") - resp := Execute([]string{"new", "site", siteDir, "-e=staging"}) - c.Assert(resp.Err, qt.IsNil) - config := readFileFrom(c, filepath.Join(siteDir, "config.toml")) - c.Assert(config, qt.Contains, "baseURL = \"http://example.org/\"") - checkNewSiteInited(c, siteDir) - }) - -} - -func checkNewSiteInited(c *qt.C, basepath string) { - paths := []string{ - filepath.Join(basepath, "layouts"), - filepath.Join(basepath, "content"), - filepath.Join(basepath, "archetypes"), - filepath.Join(basepath, "static"), - filepath.Join(basepath, "data"), - filepath.Join(basepath, "config.toml"), - } - - for _, path := range paths { - _, err := os.Stat(path) - c.Assert(err, qt.IsNil) - } -} - -func readFileFrom(c *qt.C, filename string) string { - c.Helper() - filename = filepath.Clean(filename) - b, err := afero.ReadFile(hugofs.Os, filename) - c.Assert(err, qt.IsNil) - return string(b) -} - -func TestCommandsPersistentFlags(t *testing.T) { - c := qt.New(t) - - noOpRunE := func(cmd *cobra.Command, args []string) error { - return nil - } - - tests := []struct { - args []string - check func(command []cmder) - }{{[]string{"server", - "--config=myconfig.toml", - "--configDir=myconfigdir", - "--contentDir=mycontent", - "--disableKinds=page,home", - "--environment=testing", - "--configDir=myconfigdir", - "--layoutDir=mylayouts", - "--theme=mytheme", - "--gc", - "--themesDir=mythemes", - "--cleanDestinationDir", - "--navigateToChanged", - "--disableLiveReload", - "--noHTTPCache", - "--i18n-warnings", - "--destination=/tmp/mydestination", - "-b=https://example.com/b/", - "--port=1366", - "--renderToDisk", - "--source=mysource", - "--path-warnings", - }, func(commands []cmder) { - var sc *serverCmd - for _, command := range commands { - if b, ok := command.(commandsBuilderGetter); ok { - v := b.getCommandsBuilder().hugoBuilderCommon - c.Assert(v.cfgFile, qt.Equals, "myconfig.toml") - c.Assert(v.cfgDir, qt.Equals, "myconfigdir") - c.Assert(v.source, qt.Equals, "mysource") - c.Assert(v.baseURL, qt.Equals, "https://example.com/b/") - } - - if srvCmd, ok := command.(*serverCmd); ok { - sc = srvCmd - } - } - - c.Assert(sc, qt.Not(qt.IsNil)) - c.Assert(sc.navigateToChanged, qt.Equals, true) - c.Assert(sc.disableLiveReload, qt.Equals, true) - c.Assert(sc.noHTTPCache, qt.Equals, true) - c.Assert(sc.renderToDisk, qt.Equals, true) - c.Assert(sc.serverPort, qt.Equals, 1366) - c.Assert(sc.environment, qt.Equals, "testing") - - cfg := viper.New() - sc.flagsToConfig(cfg) - c.Assert(cfg.GetString("publishDir"), qt.Equals, "/tmp/mydestination") - c.Assert(cfg.GetString("contentDir"), qt.Equals, "mycontent") - c.Assert(cfg.GetString("layoutDir"), qt.Equals, "mylayouts") - c.Assert(cfg.GetStringSlice("theme"), qt.DeepEquals, []string{"mytheme"}) - c.Assert(cfg.GetString("themesDir"), qt.Equals, "mythemes") - c.Assert(cfg.GetString("baseURL"), qt.Equals, "https://example.com/b/") - - c.Assert(cfg.Get("disableKinds"), qt.DeepEquals, []string{"page", "home"}) - - c.Assert(cfg.GetBool("gc"), qt.Equals, true) - - // The flag is named path-warnings - c.Assert(cfg.GetBool("logPathWarnings"), qt.Equals, true) - - // The flag is named i18n-warnings - c.Assert(cfg.GetBool("logI18nWarnings"), qt.Equals, true) - - }}} - - for _, test := range tests { - b := newCommandsBuilder() - root := b.addAll().build() - - for _, c := range b.commands { - if c.getCommand() == nil { - continue - } - // We are only intereseted in the flag handling here. - c.getCommand().RunE = noOpRunE - } - rootCmd := root.getCommand() - rootCmd.SetArgs(test.args) - c.Assert(rootCmd.Execute(), qt.IsNil) - test.check(b.commands) - } - -} - -func TestCommandsExecute(t *testing.T) { - - c := qt.New(t) - - dir, clean, err := createSimpleTestSite(t, testSiteConfig{}) - c.Assert(err, qt.IsNil) - - dirOut, clean2, err := htesting.CreateTempDir(hugofs.Os, "hugo-cli-out") - c.Assert(err, qt.IsNil) - - defer clean() - defer clean2() - - sourceFlag := fmt.Sprintf("-s=%s", dir) - - tests := []struct { - commands []string - flags []string - expectErrToContain string - }{ - // TODO(bep) permission issue on my OSX? "operation not permitted" {[]string{"check", "ulimit"}, nil, false}, - {[]string{"env"}, nil, ""}, - {[]string{"version"}, nil, ""}, - // no args = hugo build - {nil, []string{sourceFlag}, ""}, - {nil, []string{sourceFlag, "--renderToMemory"}, ""}, - {[]string{"config"}, []string{sourceFlag}, ""}, - {[]string{"convert", "toTOML"}, []string{sourceFlag, "-o=" + filepath.Join(dirOut, "toml")}, ""}, - {[]string{"convert", "toYAML"}, []string{sourceFlag, "-o=" + filepath.Join(dirOut, "yaml")}, ""}, - {[]string{"convert", "toJSON"}, []string{sourceFlag, "-o=" + filepath.Join(dirOut, "json")}, ""}, - {[]string{"gen", "autocomplete"}, []string{"--completionfile=" + filepath.Join(dirOut, "autocomplete.txt")}, ""}, - {[]string{"gen", "chromastyles"}, []string{"--style=manni"}, ""}, - {[]string{"gen", "doc"}, []string{"--dir=" + filepath.Join(dirOut, "doc")}, ""}, - {[]string{"gen", "man"}, []string{"--dir=" + filepath.Join(dirOut, "man")}, ""}, - {[]string{"list", "drafts"}, []string{sourceFlag}, ""}, - {[]string{"list", "expired"}, []string{sourceFlag}, ""}, - {[]string{"list", "future"}, []string{sourceFlag}, ""}, - {[]string{"new", "new-page.md"}, []string{sourceFlag}, ""}, - {[]string{"new", "site", filepath.Join(dirOut, "new-site")}, nil, ""}, - {[]string{"unknowncommand"}, nil, "unknown command"}, - // TODO(bep) cli refactor fix https://github.com/gohugoio/hugo/issues/4450 - //{[]string{"new", "theme", filepath.Join(dirOut, "new-theme")}, nil,false}, - } - - for _, test := range tests { - b := newCommandsBuilder().addAll().build() - hugoCmd := b.getCommand() - test.flags = append(test.flags, "--quiet") - hugoCmd.SetArgs(append(test.commands, test.flags...)) - - // TODO(bep) capture output and add some simple asserts - // TODO(bep) misspelled subcommands does not return an error. We should investigate this - // but before that, check for "Error: unknown command". - - _, err := hugoCmd.ExecuteC() - if test.expectErrToContain != "" { - c.Assert(err, qt.Not(qt.IsNil)) - c.Assert(err.Error(), qt.Contains, test.expectErrToContain) - } else { - c.Assert(err, qt.IsNil) - } - - // Assert that we have not left any development debug artifacts in - // the code. - if b.c != nil { - _, ok := b.c.destinationFs.(types.DevMarker) - c.Assert(ok, qt.Equals, false) - } - - } - -} - -type testSiteConfig struct { - configTOML string - contentDir string -} - -func createSimpleTestSite(t *testing.T, cfg testSiteConfig) (string, func(), error) { - d, clean, e := htesting.CreateTempDir(hugofs.Os, "hugo-cli") - if e != nil { - return "", nil, e - } - - cfgStr := ` - -baseURL = "https://example.org" -title = "Hugo Commands" - - -` - - contentDir := "content" - - if cfg.configTOML != "" { - cfgStr = cfg.configTOML - } - if cfg.contentDir != "" { - contentDir = cfg.contentDir - } - - os.MkdirAll(filepath.Join(d, "public"), 0777) - - // Just the basic. These are for CLI tests, not site testing. - writeFile(t, filepath.Join(d, "config.toml"), cfgStr) - writeFile(t, filepath.Join(d, "config", "staging", "params.toml"), `myparam="paramstaging"`) - writeFile(t, filepath.Join(d, "config", "staging", "deployment.toml"), ` -[[targets]] -name = "mydeployment" -URL = "hugocloud://hugotestbucket" -`) - - writeFile(t, filepath.Join(d, "config", "testing", "params.toml"), `myparam="paramtesting"`) - writeFile(t, filepath.Join(d, "config", "production", "params.toml"), `myparam="paramproduction"`) - - writeFile(t, filepath.Join(d, contentDir, "p1.md"), ` ---- -title: "P1" -weight: 1 ---- - -Content - -`) - - writeFile(t, filepath.Join(d, "layouts", "_default", "single.html"), ` - -Single: {{ .Title }} - -`) - - writeFile(t, filepath.Join(d, "layouts", "_default", "list.html"), ` - -List: {{ .Title }} -Environment: {{ hugo.Environment }} - -`) - - return d, clean, nil - -} - -func writeFile(t *testing.T, filename, content string) { - must(t, os.MkdirAll(filepath.Dir(filename), os.FileMode(0755))) - must(t, ioutil.WriteFile(filename, []byte(content), os.FileMode(0755))) -} - -func must(t *testing.T, err error) { - if err != nil { - t.Fatal(err) - } -} diff --git a/commands/config.go b/commands/config.go index 37bf45e3c..7d166b9b8 100644 --- a/commands/config.go +++ b/commands/config.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Hugo Authors. All rights reserved. +// Copyright 2024 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. @@ -9,138 +9,231 @@ // 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.Print the version number of Hug +// limitations under the License. package commands import ( + "bytes" + "context" "encoding/json" "fmt" "os" - "reflect" - "regexp" - "sort" "strings" + "time" + "github.com/bep/simplecobra" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/config/allconfig" + "github.com/gohugoio/hugo/modules" "github.com/gohugoio/hugo/parser" "github.com/gohugoio/hugo/parser/metadecoders" - - "github.com/gohugoio/hugo/modules" - "github.com/spf13/cobra" - "github.com/spf13/viper" ) -var _ cmder = (*configCmd)(nil) - -type configCmd struct { - *baseBuilderCmd +// newConfigCommand creates a new config command and its subcommands. +func newConfigCommand() *configCommand { + return &configCommand{ + commands: []simplecobra.Commander{ + &configMountsCommand{}, + }, + } } -func (b *commandsBuilder) newConfigCmd() *configCmd { - cc := &configCmd{} - cmd := &cobra.Command{ - Use: "config", - Short: "Print the site configuration", - Long: `Print the site configuration, both default and custom settings.`, - RunE: cc.printConfig, - } +type configCommand struct { + r *rootCommand - printMountsCmd := &cobra.Command{ - Use: "mounts", - Short: "Print the configured file mounts", - RunE: cc.printMounts, - } + format string + lang string + printZero bool - cmd.AddCommand(printMountsCmd) - - cc.baseBuilderCmd = b.newBuilderBasicCmd(cmd) - - return cc + commands []simplecobra.Commander } -func (c *configCmd) printMounts(cmd *cobra.Command, args []string) error { - cfg, err := initializeConfig(true, false, &c.hugoBuilderCommon, c, nil) +func (c *configCommand) Commands() []simplecobra.Commander { + return c.commands +} + +func (c *configCommand) Name() string { + return "config" +} + +func (c *configCommand) Run(ctx context.Context, cd *simplecobra.Commandeer, args []string) error { + conf, err := c.r.ConfigFromProvider(configKey{counter: c.r.configVersionID.Load()}, flagsToCfg(cd, nil)) if err != nil { return err } + var config *allconfig.Config + if c.lang != "" { + var found bool + config, found = conf.configs.LanguageConfigMap[c.lang] + if !found { + return fmt.Errorf("language %q not found", c.lang) + } + } else { + config = conf.configs.LanguageConfigSlice[0] + } - allModules := cfg.Cfg.Get("allmodules").(modules.Modules) + var buf bytes.Buffer + dec := json.NewEncoder(&buf) + dec.SetIndent("", " ") + dec.SetEscapeHTML(false) - for _, m := range allModules { - if err := parser.InterfaceToConfig(&modMounts{m: m}, metadecoders.JSON, os.Stdout); err != nil { + if err := dec.Encode(parser.ReplacingJSONMarshaller{Value: config, KeysToLower: true, OmitEmpty: !c.printZero}); err != nil { + return err + } + + format := strings.ToLower(c.format) + + switch format { + case "json": + os.Stdout.Write(buf.Bytes()) + default: + // Decode the JSON to a map[string]interface{} and then unmarshal it again to the correct format. + var m map[string]any + if err := json.Unmarshal(buf.Bytes(), &m); err != nil { return err } - } - return nil -} - -func (c *configCmd) printConfig(cmd *cobra.Command, args []string) error { - cfg, err := initializeConfig(true, false, &c.hugoBuilderCommon, c, nil) - if err != nil { - return err - } - - allSettings := cfg.Cfg.(*viper.Viper).AllSettings() - - // We need to clean up this, but we store objects in the config that - // isn't really interesting to the end user, so filter these. - ignoreKeysRe := regexp.MustCompile("client|sorted|filecacheconfigs|allmodules|multilingual") - - separator := ": " - - if len(cfg.configFiles) > 0 && strings.HasSuffix(cfg.configFiles[0], ".toml") { - separator = " = " - } - - var keys []string - for k := range allSettings { - if ignoreKeysRe.MatchString(k) { - continue - } - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - kv := reflect.ValueOf(allSettings[k]) - if kv.Kind() == reflect.String { - fmt.Printf("%s%s\"%+v\"\n", k, separator, allSettings[k]) - } else { - fmt.Printf("%s%s%+v\n", k, separator, allSettings[k]) + maps.ConvertFloat64WithNoDecimalsToInt(m) + switch format { + case "yaml": + return parser.InterfaceToConfig(m, metadecoders.YAML, os.Stdout) + case "toml": + return parser.InterfaceToConfig(m, metadecoders.TOML, os.Stdout) + default: + return fmt.Errorf("unsupported format: %q", format) } } return nil } -type modMounts struct { - m modules.Module +func (c *configCommand) Init(cd *simplecobra.Commandeer) error { + c.r = cd.Root.Command.(*rootCommand) + cmd := cd.CobraCommand + cmd.Short = "Display site configuration" + cmd.Long = `Display site 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().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) + + return nil } -type modMount struct { +func (c *configCommand) PreRun(cd, runner *simplecobra.Commandeer) error { + return nil +} + +type configModMount struct { Source string `json:"source"` Target string `json:"target"` Lang string `json:"lang,omitempty"` } -func (m *modMounts) MarshalJSON() ([]byte, error) { - var mounts []modMount +type configModMounts struct { + verbose bool + m modules.Module +} + +// MarshalJSON is for internal use only. +func (m *configModMounts) MarshalJSON() ([]byte, error) { + var mounts []configModMount for _, mount := range m.m.Mounts() { - mounts = append(mounts, modMount{ + mounts = append(mounts, configModMount{ Source: mount.Source, Target: mount.Target, Lang: mount.Lang, }) } + var ownerPath string + if m.m.Owner() != nil { + ownerPath = m.m.Owner().Path() + } + + if m.verbose { + config := m.m.Config() + return json.Marshal(&struct { + Path string `json:"path"` + Version string `json:"version"` + Time time.Time `json:"time"` + Owner string `json:"owner"` + Dir string `json:"dir"` + Meta map[string]any `json:"meta"` + HugoVersion modules.HugoVersion `json:"hugoVersion"` + + Mounts []configModMount `json:"mounts"` + }{ + Path: m.m.Path(), + Version: m.m.Version(), + Time: m.m.Time(), + Owner: ownerPath, + Dir: m.m.Dir(), + Meta: config.Params, + HugoVersion: config.HugoVersion, + Mounts: mounts, + }) + } + return json.Marshal(&struct { - Path string `json:"path"` - Dir string `json:"dir"` - Mounts []modMount `json:"mounts"` + Path string `json:"path"` + Version string `json:"version"` + Time time.Time `json:"time"` + Owner string `json:"owner"` + Dir string `json:"dir"` + Mounts []configModMount `json:"mounts"` }{ - Path: m.m.Path(), - Dir: m.m.Dir(), - Mounts: mounts, + Path: m.m.Path(), + Version: m.m.Version(), + Time: m.m.Time(), + Owner: ownerPath, + Dir: m.m.Dir(), + Mounts: mounts, }) } + +type configMountsCommand struct { + r *rootCommand + configCmd *configCommand +} + +func (c *configMountsCommand) Commands() []simplecobra.Commander { + return nil +} + +func (c *configMountsCommand) Name() string { + return "mounts" +} + +func (c *configMountsCommand) Run(ctx context.Context, cd *simplecobra.Commandeer, args []string) error { + r := c.configCmd.r + conf, err := r.ConfigFromProvider(configKey{counter: c.r.configVersionID.Load()}, flagsToCfg(cd, nil)) + if err != nil { + return err + } + + for _, m := range conf.configs.Modules { + if err := parser.InterfaceToConfig(&configModMounts{m: m, verbose: r.isVerbose()}, metadecoders.JSON, os.Stdout); err != nil { + return err + } + } + return nil +} + +func (c *configMountsCommand) Init(cd *simplecobra.Commandeer) error { + c.r = cd.Root.Command.(*rootCommand) + cmd := cd.CobraCommand + cmd.Short = "Print the configured file mounts" + cmd.ValidArgsFunction = cobra.NoFileCompletions + applyLocalFlagsBuildConfig(cmd, c.r) + return nil +} + +func (c *configMountsCommand) PreRun(cd, runner *simplecobra.Commandeer) error { + c.configCmd = cd.Parent.Command.(*configCommand) + return nil +} diff --git a/commands/convert.go b/commands/convert.go index b9129e594..ebf81cfb3 100644 --- a/commands/convert.go +++ b/commands/convert.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Hugo Authors. All rights reserved. +// Copyright 2024 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. @@ -15,151 +15,149 @@ package commands import ( "bytes" + "context" "fmt" - "io" + "path/filepath" "strings" "time" - "github.com/gohugoio/hugo/resources/page" - - "github.com/gohugoio/hugo/hugofs" - + "github.com/bep/simplecobra" + "github.com/gohugoio/hugo/config" "github.com/gohugoio/hugo/helpers" - + "github.com/gohugoio/hugo/hugofs" + "github.com/gohugoio/hugo/hugolib" "github.com/gohugoio/hugo/parser" "github.com/gohugoio/hugo/parser/metadecoders" "github.com/gohugoio/hugo/parser/pageparser" - - "github.com/pkg/errors" - - "github.com/gohugoio/hugo/hugolib" - - "path/filepath" - + "github.com/gohugoio/hugo/resources/page" "github.com/spf13/cobra" ) -var ( - _ cmder = (*convertCmd)(nil) -) +func newConvertCommand() *convertCommand { + var c *convertCommand + c = &convertCommand{ + commands: []simplecobra.Commander{ + &simpleCommand{ + name: "toJSON", + short: "Convert front matter to JSON", + long: `toJSON converts all front matter in the content directory +to use JSON for the front matter.`, + run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error { + return c.convertContents(metadecoders.JSON) + }, + withc: func(cmd *cobra.Command, r *rootCommand) { + cmd.ValidArgsFunction = cobra.NoFileCompletions + }, + }, + &simpleCommand{ + name: "toTOML", + short: "Convert front matter to TOML", + long: `toTOML converts all front matter in the content directory +to use TOML for the front matter.`, + run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error { + return c.convertContents(metadecoders.TOML) + }, + withc: func(cmd *cobra.Command, r *rootCommand) { + cmd.ValidArgsFunction = cobra.NoFileCompletions + }, + }, + &simpleCommand{ + name: "toYAML", + short: "Convert front matter to YAML", + long: `toYAML converts all front matter in the content directory +to use YAML for the front matter.`, + run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error { + return c.convertContents(metadecoders.YAML) + }, + withc: func(cmd *cobra.Command, r *rootCommand) { + cmd.ValidArgsFunction = cobra.NoFileCompletions + }, + }, + }, + } + return c +} -type convertCmd struct { +type convertCommand struct { + // Flags. outputDir string unsafe bool - *baseBuilderCmd + // Deps. + r *rootCommand + h *hugolib.HugoSites + + // Commands. + commands []simplecobra.Commander } -func (b *commandsBuilder) newConvertCmd() *convertCmd { - cc := &convertCmd{} - - cmd := &cobra.Command{ - Use: "convert", - Short: "Convert your content to different formats", - Long: `Convert your content (e.g. front matter) to different formats. - -See convert's subcommands toJSON, toTOML and toYAML for more information.`, - RunE: nil, - } - - cmd.AddCommand( - &cobra.Command{ - Use: "toJSON", - Short: "Convert front matter to JSON", - Long: `toJSON converts all front matter in the content directory -to use JSON for the front matter.`, - RunE: func(cmd *cobra.Command, args []string) error { - return cc.convertContents(metadecoders.JSON) - }, - }, - &cobra.Command{ - Use: "toTOML", - Short: "Convert front matter to TOML", - Long: `toTOML converts all front matter in the content directory -to use TOML for the front matter.`, - RunE: func(cmd *cobra.Command, args []string) error { - return cc.convertContents(metadecoders.TOML) - }, - }, - &cobra.Command{ - Use: "toYAML", - Short: "Convert front matter to YAML", - Long: `toYAML converts all front matter in the content directory -to use YAML for the front matter.`, - RunE: func(cmd *cobra.Command, args []string) error { - return cc.convertContents(metadecoders.YAML) - }, - }, - ) - - cmd.PersistentFlags().StringVarP(&cc.outputDir, "output", "o", "", "filesystem path to write files to") - cmd.PersistentFlags().BoolVar(&cc.unsafe, "unsafe", false, "enable less safe operations, please backup first") - - cc.baseBuilderCmd = b.newBuilderBasicCmd(cmd) - - return cc +func (c *convertCommand) Commands() []simplecobra.Commander { + return c.commands } -func (cc *convertCmd) convertContents(format metadecoders.Format) error { - if cc.outputDir == "" && !cc.unsafe { - return newUserError("Unsafe operation not allowed, use --unsafe or set a different output path") - } +func (c *convertCommand) Name() string { + return "convert" +} - c, err := initializeConfig(true, false, &cc.hugoBuilderCommon, cc, nil) - if err != nil { - return err - } - - c.Cfg.Set("buildDrafts", true) - - h, err := hugolib.NewHugoSites(*c.DepsCfg) - if err != nil { - return err - } - - if err := h.Build(hugolib.BuildCfg{SkipRender: true}); err != nil { - return err - } - - site := h.Sites[0] - - site.Log.FEEDBACK.Println("processing", len(site.AllPages()), "content files") - for _, p := range site.AllPages() { - if err := cc.convertAndSavePage(p, site, format); err != nil { - return err - } - } +func (c *convertCommand) Run(ctx context.Context, cd *simplecobra.Commandeer, args []string) error { return nil } -func (cc *convertCmd) convertAndSavePage(p page.Page, site *hugolib.Site, targetFormat metadecoders.Format) error { +func (c *convertCommand) Init(cd *simplecobra.Commandeer) error { + cmd := cd.CobraCommand + cmd.Short = "Convert front matter to another format" + cmd.Long = `Convert front matter to another format. + +See convert's subcommands toJSON, toTOML and toYAML for more information.` + + cmd.PersistentFlags().StringVarP(&c.outputDir, "output", "o", "", "filesystem path to write files to") + _ = cmd.MarkFlagDirname("output") + cmd.PersistentFlags().BoolVar(&c.unsafe, "unsafe", false, "enable less safe operations, please backup first") + + cmd.RunE = nil + return nil +} + +func (c *convertCommand) PreRun(cd, runner *simplecobra.Commandeer) error { + c.r = cd.Root.Command.(*rootCommand) + cfg := config.New() + cfg.Set("buildDrafts", true) + h, err := c.r.Hugo(flagsToCfg(cd, cfg)) + if err != nil { + return err + } + c.h = h + return nil +} + +func (c *convertCommand) convertAndSavePage(p page.Page, site *hugolib.Site, targetFormat metadecoders.Format) error { // The resources are not in .Site.AllPages. for _, r := range p.Resources().ByType("page") { - if err := cc.convertAndSavePage(r.(page.Page), site, targetFormat); err != nil { + if err := c.convertAndSavePage(r.(page.Page), site, targetFormat); err != nil { return err } } - if p.File().IsZero() { + if p.File() == nil { // No content file. return nil } - errMsg := fmt.Errorf("Error processing file %q", p.Path()) + errMsg := fmt.Errorf("error processing file %q", p.File().Path()) - site.Log.INFO.Println("Attempting to convert", p.File().Filename()) + site.Log.Infoln("attempting to convert", p.File().Filename()) f := p.File() file, err := f.FileInfo().Meta().Open() if err != nil { - site.Log.ERROR.Println(errMsg) + site.Log.Errorln(errMsg) file.Close() return nil } - pf, err := parseContentFile(file) + pf, err := pageparser.ParseFrontMatterAndContent(file) if err != nil { - site.Log.ERROR.Println(errMsg) + site.Log.Errorln(errMsg) file.Close() return err } @@ -167,82 +165,65 @@ func (cc *convertCmd) convertAndSavePage(p page.Page, site *hugolib.Site, target file.Close() // better handling of dates in formats that don't have support for them - if pf.frontMatterFormat == metadecoders.JSON || pf.frontMatterFormat == metadecoders.YAML || pf.frontMatterFormat == metadecoders.TOML { - for k, v := range pf.frontMatter { + if pf.FrontMatterFormat == metadecoders.JSON || pf.FrontMatterFormat == metadecoders.YAML || pf.FrontMatterFormat == metadecoders.TOML { + for k, v := range pf.FrontMatter { switch vv := v.(type) { case time.Time: - pf.frontMatter[k] = vv.Format(time.RFC3339) + pf.FrontMatter[k] = vv.Format(time.RFC3339) } } } var newContent bytes.Buffer - err = parser.InterfaceToFrontMatter(pf.frontMatter, targetFormat, &newContent) + err = parser.InterfaceToFrontMatter(pf.FrontMatter, targetFormat, &newContent) if err != nil { - site.Log.ERROR.Println(errMsg) + site.Log.Errorln(errMsg) return err } - newContent.Write(pf.content) + newContent.Write(pf.Content) newFilename := p.File().Filename() - if cc.outputDir != "" { - contentDir := strings.TrimSuffix(newFilename, p.Path()) + if c.outputDir != "" { + contentDir := strings.TrimSuffix(newFilename, p.File().Path()) contentDir = filepath.Base(contentDir) - newFilename = filepath.Join(cc.outputDir, contentDir, p.Path()) + newFilename = filepath.Join(c.outputDir, contentDir, p.File().Path()) } fs := hugofs.Os if err := helpers.WriteToDisk(newFilename, &newContent, fs); err != nil { - return errors.Wrapf(err, "Failed to save file %q:", newFilename) + return fmt.Errorf("failed to save file %q:: %w", newFilename, err) } return nil } -type parsedFile struct { - frontMatterFormat metadecoders.Format - frontMatterSource []byte - frontMatter map[string]interface{} - - // Everything after Front Matter - content []byte -} - -func parseContentFile(r io.Reader) (parsedFile, error) { - var pf parsedFile - - psr, err := pageparser.Parse(r, pageparser.Config{}) - if err != nil { - return pf, err +func (c *convertCommand) convertContents(format metadecoders.Format) error { + if c.outputDir == "" && !c.unsafe { + return newUserError("Unsafe operation not allowed, use --unsafe or set a different output path") } - iter := psr.Iterator() + if err := c.h.Build(hugolib.BuildCfg{SkipRender: true}); err != nil { + return err + } - walkFn := func(item pageparser.Item) bool { - if pf.frontMatterSource != nil { - // The rest is content. - pf.content = psr.Input()[item.Pos:] - // Done - return false - } else if item.IsFrontMatter() { - pf.frontMatterFormat = metadecoders.FormatFromFrontMatterType(item.Type) - pf.frontMatterSource = item.Val + site := c.h.Sites[0] + + var pagesBackedByFile page.Pages + for _, p := range site.AllPages() { + if p.File() == nil { + continue } - return true - + pagesBackedByFile = append(pagesBackedByFile, p) } - iter.PeekWalk(walkFn) - - metadata, err := metadecoders.Default.UnmarshalToMap(pf.frontMatterSource, pf.frontMatterFormat) - if err != nil { - return pf, err + site.Log.Println("processing", len(pagesBackedByFile), "content files") + for _, p := range site.AllPages() { + if err := c.convertAndSavePage(p, site, format); err != nil { + return err + } } - pf.frontMatter = metadata - - return pf, nil - + return nil } diff --git a/commands/deploy.go b/commands/deploy.go index ab51c9eb6..3e9d3df20 100644 --- a/commands/deploy.go +++ b/commands/deploy.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Hugo Authors. All rights reserved. +// Copyright 2024 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. @@ -11,68 +11,41 @@ // See the License for the specific language governing permissions and // limitations under the License. +//go:build withdeploy + package commands import ( "context" "github.com/gohugoio/hugo/deploy" + + "github.com/bep/simplecobra" "github.com/spf13/cobra" ) -var _ cmder = (*deployCmd)(nil) - -// deployCmd supports deploying sites to Cloud providers. -type deployCmd struct { - *baseBuilderCmd -} - -// TODO: In addition to the "deploy" command, consider adding a "--deploy" -// flag for the default command; this would build the site and then deploy it. -// It's not obvious how to do this; would all of the deploy-specific flags -// have to exist at the top level as well? - -// TODO: The output files change every time "hugo" is executed, it looks -// like because of map order randomization. This means that you can -// run "hugo && hugo deploy" again and again and upload new stuff every time. Is -// this intended? - -func (b *commandsBuilder) newDeployCmd() *deployCmd { - cc := &deployCmd{} - - cmd := &cobra.Command{ - Use: "deploy", - Short: "Deploy your site to a Cloud provider.", - Long: `Deploy your site to a Cloud provider. +func newDeployCommand() simplecobra.Commander { + return &simpleCommand{ + name: "deploy", + short: "Deploy your site to a cloud provider", + long: `Deploy your site to a cloud provider See https://gohugo.io/hosting-and-deployment/hugo-deploy/ for detailed documentation. `, - - RunE: func(cmd *cobra.Command, args []string) error { - cfgInit := func(c *commandeer) error { - return nil - } - comm, err := initializeConfig(true, false, &cc.hugoBuilderCommon, cc, cfgInit) + run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error { + h, err := r.Hugo(flagsToCfgWithAdditionalConfigBase(cd, nil, "deployment")) if err != nil { return err } - deployer, err := deploy.New(comm.Cfg, comm.hugo().PathSpec.PublishFs) + deployer, err := deploy.New(h.Configs.GetFirstLanguageConfig(), h.Log, h.PathSpec.PublishFs) if err != nil { return err } - return deployer.Deploy(context.Background()) + return deployer.Deploy(ctx) + }, + withc: func(cmd *cobra.Command, r *rootCommand) { + applyDeployFlags(cmd, r) }, } - - cmd.Flags().String("target", "", "target deployment from deployments section in config file; defaults to the first one") - cmd.Flags().Bool("confirm", false, "ask for confirmation before making changes to the target") - cmd.Flags().Bool("dryRun", false, "dry run") - cmd.Flags().Bool("force", false, "force upload of all files") - cmd.Flags().Bool("invalidateCDN", true, "invalidate the CDN cache listed in the deployment target") - cmd.Flags().Int("maxDeletes", 256, "maximum # of files to delete, or -1 to disable") - - cc.baseBuilderCmd = b.newBuilderBasicCmd(cmd) - - return cc } diff --git a/commands/deploy_flags.go b/commands/deploy_flags.go new file mode 100644 index 000000000..d4326547a --- /dev/null +++ b/commands/deploy_flags.go @@ -0,0 +1,33 @@ +// Copyright 2024 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 commands + +import ( + "github.com/gohugoio/hugo/deploy/deployconfig" + "github.com/spf13/cobra" +) + +func applyDeployFlags(cmd *cobra.Command, r *rootCommand) { + cmd.ValidArgsFunction = cobra.NoFileCompletions + cmd.Flags().String("target", "", "target deployment from deployments section in config file; defaults to the first one") + _ = cmd.RegisterFlagCompletionFunc("target", cobra.NoFileCompletions) + cmd.Flags().Bool("confirm", false, "ask for confirmation before making changes to the target") + cmd.Flags().Bool("dryRun", false, "dry run") + cmd.Flags().Bool("force", false, "force upload of all files") + cmd.Flags().Bool("invalidateCDN", deployconfig.DefaultConfig.InvalidateCDN, "invalidate the CDN cache listed in the deployment target") + cmd.Flags().Int("maxDeletes", deployconfig.DefaultConfig.MaxDeletes, "maximum # of files to delete, or -1 to disable") + _ = cmd.RegisterFlagCompletionFunc("maxDeletes", cobra.NoFileCompletions) + cmd.Flags().Int("workers", deployconfig.DefaultConfig.Workers, "number of workers to transfer files. defaults to 10") + _ = cmd.RegisterFlagCompletionFunc("workers", cobra.NoFileCompletions) +} diff --git a/commands/deploy_off.go b/commands/deploy_off.go new file mode 100644 index 000000000..8f5eaa2de --- /dev/null +++ b/commands/deploy_off.go @@ -0,0 +1,50 @@ +// Copyright 2024 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. + +//go:build !withdeploy + +// Copyright 2024 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 commands + +import ( + "context" + "errors" + + "github.com/bep/simplecobra" + "github.com/spf13/cobra" +) + +func newDeployCommand() simplecobra.Commander { + return &simpleCommand{ + name: "deploy", + run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error { + return errors.New("deploy not supported in this version of Hugo; install a release with 'withdeploy' in the archive filename or build yourself with the 'withdeploy' build tag. Also see https://github.com/gohugoio/hugo/pull/12995") + }, + withc: func(cmd *cobra.Command, r *rootCommand) { + applyDeployFlags(cmd, r) + cmd.Hidden = true + }, + } +} diff --git a/commands/env.go b/commands/env.go index 76c16b93b..753522560 100644 --- a/commands/env.go +++ b/commands/env.go @@ -1,4 +1,4 @@ -// Copyright 2016 The Hugo Authors. All rights reserved. +// Copyright 2024 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,31 +14,57 @@ package commands import ( + "context" "runtime" + "github.com/bep/simplecobra" + "github.com/gohugoio/hugo/common/hugo" "github.com/spf13/cobra" - jww "github.com/spf13/jwalterweatherman" ) -var _ cmder = (*envCmd)(nil) - -type envCmd struct { - *baseCmd -} - -func newEnvCmd() *envCmd { - return &envCmd{baseCmd: newBaseCmd(&cobra.Command{ - Use: "env", - Short: "Print Hugo version and environment info", - Long: `Print Hugo version and environment info. This is useful in Hugo bug reports.`, - RunE: func(cmd *cobra.Command, args []string) error { - printHugoVersion() - jww.FEEDBACK.Printf("GOOS=%q\n", runtime.GOOS) - jww.FEEDBACK.Printf("GOARCH=%q\n", runtime.GOARCH) - jww.FEEDBACK.Printf("GOVERSION=%q\n", runtime.Version()) +func newEnvCommand() simplecobra.Commander { + return &simpleCommand{ + name: "env", + short: "Display version and environment info", + long: "Display version and environment info. This is useful in Hugo bug reports", + run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error { + r.Printf("%s\n", hugo.BuildVersionString()) + r.Printf("GOOS=%q\n", runtime.GOOS) + r.Printf("GOARCH=%q\n", runtime.GOARCH) + r.Printf("GOVERSION=%q\n", runtime.Version()) + if r.isVerbose() { + deps := hugo.GetDependencyList() + for _, dep := range deps { + r.Printf("%s\n", dep) + } + } else { + // These are also included in the GetDependencyList above; + // always print these as these are most likely the most useful to know about. + deps := hugo.GetDependencyListNonGo() + for _, dep := range deps { + r.Printf("%s\n", dep) + } + } return nil }, - }), + withc: func(cmd *cobra.Command, r *rootCommand) { + cmd.ValidArgsFunction = cobra.NoFileCompletions + }, + } +} + +func newVersionCmd() simplecobra.Commander { + return &simpleCommand{ + name: "version", + run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error { + r.Println(hugo.BuildVersionString()) + return nil + }, + short: "Display version", + long: "Display version and environment info. This is useful in Hugo bug reports.", + withc: func(cmd *cobra.Command, r *rootCommand) { + cmd.ValidArgsFunction = cobra.NoFileCompletions + }, } } diff --git a/commands/gen.go b/commands/gen.go index 6878cfe70..1c5361840 100644 --- a/commands/gen.go +++ b/commands/gen.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Hugo Authors. All rights reserved. +// Copyright 2024 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,28 +14,290 @@ package commands import ( + "bytes" + "context" + "encoding/json" + "fmt" + "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/gohugoio/hugo/common/hugo" + "github.com/gohugoio/hugo/docshelper" + "github.com/gohugoio/hugo/helpers" + "github.com/gohugoio/hugo/hugofs" + "github.com/gohugoio/hugo/hugolib" + "github.com/gohugoio/hugo/parser" "github.com/spf13/cobra" + "github.com/spf13/cobra/doc" + "gopkg.in/yaml.v2" ) -var _ cmder = (*genCmd)(nil) +func newGenCommand() *genCommand { + var ( + // Flags. + gendocdir string + genmandir string -type genCmd struct { - *baseCmd + // Chroma flags. + style string + highlightStyle string + lineNumbersInlineStyle string + lineNumbersTableStyle string + omitEmpty bool + ) + + newChromaStyles := func() simplecobra.Commander { + return &simpleCommand{ + name: "chromastyles", + short: "Generate CSS stylesheet for the Chroma code highlighter", + long: `Generate CSS stylesheet for the Chroma code highlighter for a given style. This stylesheet is needed if markup.highlight.noClasses is disabled in config. + +See https://xyproto.github.io/splash/docs/all.html 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 + } + + var formatter *html.Formatter + if omitEmpty { + formatter = html.New(html.WithClasses(true)) + } else { + formatter = html.New(html.WithAllClasses(true)) + } + + w := os.Stdout + fmt.Fprintf(w, "/* Generated using: hugo %s */\n\n", strings.Join(os.Args[1:], " ")) + formatter.WriteCSS(w, style) + return nil + }, + withc: func(cmd *cobra.Command, r *rootCommand) { + cmd.ValidArgsFunction = cobra.NoFileCompletions + cmd.PersistentFlags().StringVar(&style, "style", "friendly", "highlighter style (see https://xyproto.github.io/splash/docs/)") + _ = cmd.RegisterFlagCompletionFunc("style", 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"`) + _ = cmd.RegisterFlagCompletionFunc("lineNumbersInlineStyle", cobra.NoFileCompletions) + cmd.PersistentFlags().StringVar(&lineNumbersTableStyle, "lineNumbersTableStyle", "", `foreground and background colors for table line numbers, e.g. --lineNumbersTableStyle "#fff000 bg:#000fff"`) + _ = cmd.RegisterFlagCompletionFunc("lineNumbersTableStyle", cobra.NoFileCompletions) + cmd.PersistentFlags().BoolVar(&omitEmpty, "omitEmpty", false, `omit empty CSS rules`) + _ = cmd.RegisterFlagCompletionFunc("omitEmpty", cobra.NoFileCompletions) + }, + } + } + + newMan := func() simplecobra.Commander { + return &simpleCommand{ + name: "man", + short: "Generate man pages for the Hugo CLI", + long: `This command automatically generates up-to-date man pages of Hugo's + command-line interface. By default, it creates the man page files + in the "man" directory under the current directory.`, + + run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error { + header := &doc.GenManHeader{ + Section: "1", + Manual: "Hugo Manual", + Source: fmt.Sprintf("Hugo %s", hugo.CurrentVersion), + } + if !strings.HasSuffix(genmandir, helpers.FilePathSeparator) { + genmandir += helpers.FilePathSeparator + } + if found, _ := helpers.Exists(genmandir, hugofs.Os); !found { + r.Println("Directory", genmandir, "does not exist, creating...") + if err := hugofs.Os.MkdirAll(genmandir, 0o777); err != nil { + return err + } + } + cd.CobraCommand.Root().DisableAutoGenTag = true + + r.Println("Generating Hugo man pages in", genmandir, "...") + doc.GenManTree(cd.CobraCommand.Root(), header, genmandir) + + r.Println("Done.") + + return nil + }, + withc: func(cmd *cobra.Command, r *rootCommand) { + cmd.ValidArgsFunction = cobra.NoFileCompletions + cmd.PersistentFlags().StringVar(&genmandir, "dir", "man/", "the directory to write the man pages.") + _ = cmd.MarkFlagDirname("dir") + }, + } + } + + newGen := func() simplecobra.Commander { + const gendocFrontmatterTemplate = `--- +title: "%s" +slug: %s +url: %s +--- +` + + return &simpleCommand{ + name: "doc", + short: "Generate Markdown documentation for the Hugo CLI", + long: `Generate Markdown documentation for the Hugo CLI. + This command is, mostly, used to create up-to-date documentation + of Hugo's command-line interface for https://gohugo.io/. + + It creates one Markdown file per command with front matter suitable + for rendering in Hugo.`, + run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error { + cd.CobraCommand.VisitParents(func(c *cobra.Command) { + // Disable the "Auto generated by spf13/cobra on DATE" + // as it creates a lot of diffs. + c.DisableAutoGenTag = true + }) + if !strings.HasSuffix(gendocdir, helpers.FilePathSeparator) { + gendocdir += helpers.FilePathSeparator + } + if found, _ := helpers.Exists(gendocdir, hugofs.Os); !found { + r.Println("Directory", gendocdir, "does not exist, creating...") + if err := hugofs.Os.MkdirAll(gendocdir, 0o777); err != nil { + return err + } + } + prepender := func(filename string) string { + name := filepath.Base(filename) + base := strings.TrimSuffix(name, path.Ext(name)) + url := "/docs/reference/commands/" + strings.ToLower(base) + "/" + return fmt.Sprintf(gendocFrontmatterTemplate, strings.Replace(base, "_", " ", -1), base, url) + } + + linkHandler := func(name string) string { + base := strings.TrimSuffix(name, path.Ext(name)) + return "/docs/reference/commands/" + strings.ToLower(base) + "/" + } + r.Println("Generating Hugo command-line documentation in", gendocdir, "...") + doc.GenMarkdownTreeCustom(cd.CobraCommand.Root(), gendocdir, prepender, linkHandler) + r.Println("Done.") + + return nil + }, + withc: func(cmd *cobra.Command, r *rootCommand) { + cmd.ValidArgsFunction = cobra.NoFileCompletions + cmd.PersistentFlags().StringVar(&gendocdir, "dir", "/tmp/hugodoc/", "the directory to write the doc.") + _ = cmd.MarkFlagDirname("dir") + }, + } + } + + var docsHelperTarget string + + newDocsHelper := func() simplecobra.Commander { + return &simpleCommand{ + name: "docshelper", + short: "Generate some data files for the Hugo docs", + + run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error { + r.Println("Generate docs data to", docsHelperTarget) + + var buf bytes.Buffer + jsonEnc := json.NewEncoder(&buf) + + configProvider := func() docshelper.DocProvider { + conf := hugolib.DefaultConfig() + conf.CacheDir = "" // The default value does not make sense in the docs. + defaultConfig := parser.NullBoolJSONMarshaller{Wrapped: parser.LowerCaseCamelJSONMarshaller{Value: conf}} + return docshelper.DocProvider{"config": defaultConfig} + } + + docshelper.AddDocProviderFunc(configProvider) + if err := jsonEnc.Encode(docshelper.GetDocProvider()); err != nil { + return err + } + + // Decode the JSON to a map[string]interface{} and then unmarshal it again to the correct format. + var m map[string]any + if err := json.Unmarshal(buf.Bytes(), &m); err != nil { + return err + } + + targetFile := filepath.Join(docsHelperTarget, "docs.yaml") + + f, err := os.Create(targetFile) + if err != nil { + return err + } + defer f.Close() + yamlEnc := yaml.NewEncoder(f) + if err := yamlEnc.Encode(m); err != nil { + return err + } + + r.Println("Done!") + return nil + }, + withc: func(cmd *cobra.Command, r *rootCommand) { + cmd.Hidden = true + cmd.ValidArgsFunction = cobra.NoFileCompletions + cmd.PersistentFlags().StringVarP(&docsHelperTarget, "dir", "", "docs/data", "data dir") + }, + } + } + + return &genCommand{ + commands: []simplecobra.Commander{ + newChromaStyles(), + newGen(), + newMan(), + newDocsHelper(), + }, + } } -func newGenCmd() *genCmd { - cc := &genCmd{} - cc.baseCmd = newBaseCmd(&cobra.Command{ - Use: "gen", - Short: "A collection of several useful generators.", - }) +type genCommand struct { + rootCmd *rootCommand - cc.cmd.AddCommand( - newGenautocompleteCmd().getCommand(), - newGenDocCmd().getCommand(), - newGenManCmd().getCommand(), - createGenDocsHelper().getCommand(), - createGenChromaStyles().getCommand()) - - return cc + commands []simplecobra.Commander +} + +func (c *genCommand) Commands() []simplecobra.Commander { + return c.commands +} + +func (c *genCommand) Name() string { + return "gen" +} + +func (c *genCommand) Run(ctx context.Context, cd *simplecobra.Commandeer, args []string) error { + return nil +} + +func (c *genCommand) Init(cd *simplecobra.Commandeer) error { + cmd := cd.CobraCommand + cmd.Short = "Generate documentation and syntax highlighting styles" + cmd.Long = "Generate documentation for your project using Hugo's documentation engine, including syntax highlighting for various programming languages." + + cmd.RunE = nil + return nil +} + +func (c *genCommand) PreRun(cd, runner *simplecobra.Commandeer) error { + c.rootCmd = cd.Root.Command.(*rootCommand) + return nil } diff --git a/commands/genautocomplete.go b/commands/genautocomplete.go deleted file mode 100644 index b0b98abb4..000000000 --- a/commands/genautocomplete.go +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright 2015 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 commands - -import ( - "github.com/spf13/cobra" - jww "github.com/spf13/jwalterweatherman" -) - -var _ cmder = (*genautocompleteCmd)(nil) - -type genautocompleteCmd struct { - autocompleteTarget string - - // bash for now (zsh and others will come) - autocompleteType string - - *baseCmd -} - -func newGenautocompleteCmd() *genautocompleteCmd { - cc := &genautocompleteCmd{} - - cc.baseCmd = newBaseCmd(&cobra.Command{ - Use: "autocomplete", - Short: "Generate shell autocompletion script for Hugo", - Long: `Generates a shell autocompletion script for Hugo. - -NOTE: The current version supports Bash only. - This should work for *nix systems with Bash installed. - -By default, the file is written directly to /etc/bash_completion.d -for convenience, and the command may need superuser rights, e.g.: - - $ sudo hugo gen autocomplete - -Add ` + "`--completionfile=/path/to/file`" + ` flag to set alternative -file-path and name. - -Logout and in again to reload the completion scripts, -or just source them in directly: - - $ . /etc/bash_completion`, - - RunE: func(cmd *cobra.Command, args []string) error { - if cc.autocompleteType != "bash" { - return newUserError("Only Bash is supported for now") - } - - err := cmd.Root().GenBashCompletionFile(cc.autocompleteTarget) - - if err != nil { - return err - } - - jww.FEEDBACK.Println("Bash completion file for Hugo saved to", cc.autocompleteTarget) - - return nil - }, - }) - - cc.cmd.PersistentFlags().StringVarP(&cc.autocompleteTarget, "completionfile", "", "/etc/bash_completion.d/hugo.sh", "autocompletion file") - cc.cmd.PersistentFlags().StringVarP(&cc.autocompleteType, "type", "", "bash", "autocompletion type (currently only bash supported)") - - // For bash-completion - cc.cmd.PersistentFlags().SetAnnotation("completionfile", cobra.BashCompFilenameExt, []string{}) - - return cc -} diff --git a/commands/genchromastyles.go b/commands/genchromastyles.go deleted file mode 100644 index 1f82d764e..000000000 --- a/commands/genchromastyles.go +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright 2017-present 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 commands - -import ( - "os" - - "github.com/alecthomas/chroma" - "github.com/alecthomas/chroma/formatters/html" - "github.com/alecthomas/chroma/styles" - "github.com/spf13/cobra" -) - -var ( - _ cmder = (*genChromaStyles)(nil) -) - -type genChromaStyles struct { - style string - highlightStyle string - linesStyle string - *baseCmd -} - -// TODO(bep) highlight -func createGenChromaStyles() *genChromaStyles { - g := &genChromaStyles{ - baseCmd: newBaseCmd(&cobra.Command{ - Use: "chromastyles", - Short: "Generate CSS stylesheet for the Chroma code highlighter", - Long: `Generate CSS stylesheet for the Chroma code highlighter for a given style. This stylesheet is needed if pygmentsUseClasses is enabled in config. - -See https://help.farbox.com/pygments.html for preview of available styles`, - }), - } - - g.cmd.RunE = func(cmd *cobra.Command, args []string) error { - return g.generate() - } - - g.cmd.PersistentFlags().StringVar(&g.style, "style", "friendly", "highlighter style (see https://help.farbox.com/pygments.html)") - g.cmd.PersistentFlags().StringVar(&g.highlightStyle, "highlightStyle", "bg:#ffffcc", "style used for highlighting lines (see https://github.com/alecthomas/chroma)") - g.cmd.PersistentFlags().StringVar(&g.linesStyle, "linesStyle", "", "style used for line numbers (see https://github.com/alecthomas/chroma)") - - return g -} - -func (g *genChromaStyles) generate() error { - builder := styles.Get(g.style).Builder() - if g.highlightStyle != "" { - builder.Add(chroma.LineHighlight, g.highlightStyle) - } - if g.linesStyle != "" { - builder.Add(chroma.LineNumbers, g.linesStyle) - } - style, err := builder.Build() - if err != nil { - return err - } - formatter := html.New(html.WithClasses(true)) - formatter.WriteCSS(os.Stdout, style) - return nil -} diff --git a/commands/gendoc.go b/commands/gendoc.go deleted file mode 100644 index 8312191f2..000000000 --- a/commands/gendoc.go +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright 2016 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 commands - -import ( - "fmt" - "path" - "path/filepath" - "strings" - "time" - - "github.com/gohugoio/hugo/helpers" - "github.com/gohugoio/hugo/hugofs" - "github.com/spf13/cobra" - "github.com/spf13/cobra/doc" - jww "github.com/spf13/jwalterweatherman" -) - -var _ cmder = (*genDocCmd)(nil) - -type genDocCmd struct { - gendocdir string - *baseCmd -} - -func newGenDocCmd() *genDocCmd { - const gendocFrontmatterTemplate = `--- -date: %s -title: "%s" -slug: %s -url: %s ---- -` - - cc := &genDocCmd{} - - cc.baseCmd = newBaseCmd(&cobra.Command{ - Use: "doc", - Short: "Generate Markdown documentation for the Hugo CLI.", - Long: `Generate Markdown documentation for the Hugo CLI. - -This command is, mostly, used to create up-to-date documentation -of Hugo's command-line interface for http://gohugo.io/. - -It creates one Markdown file per command with front matter suitable -for rendering in Hugo.`, - - RunE: func(cmd *cobra.Command, args []string) error { - if !strings.HasSuffix(cc.gendocdir, helpers.FilePathSeparator) { - cc.gendocdir += helpers.FilePathSeparator - } - if found, _ := helpers.Exists(cc.gendocdir, hugofs.Os); !found { - jww.FEEDBACK.Println("Directory", cc.gendocdir, "does not exist, creating...") - if err := hugofs.Os.MkdirAll(cc.gendocdir, 0777); err != nil { - return err - } - } - now := time.Now().Format("2006-01-02") - prepender := func(filename string) string { - name := filepath.Base(filename) - base := strings.TrimSuffix(name, path.Ext(name)) - url := "/commands/" + strings.ToLower(base) + "/" - return fmt.Sprintf(gendocFrontmatterTemplate, now, strings.Replace(base, "_", " ", -1), base, url) - } - - linkHandler := func(name string) string { - base := strings.TrimSuffix(name, path.Ext(name)) - return "/commands/" + strings.ToLower(base) + "/" - } - - jww.FEEDBACK.Println("Generating Hugo command-line documentation in", cc.gendocdir, "...") - doc.GenMarkdownTreeCustom(cmd.Root(), cc.gendocdir, prepender, linkHandler) - jww.FEEDBACK.Println("Done.") - - return nil - }, - }) - - cc.cmd.PersistentFlags().StringVar(&cc.gendocdir, "dir", "/tmp/hugodoc/", "the directory to write the doc.") - - // For bash-completion - cc.cmd.PersistentFlags().SetAnnotation("dir", cobra.BashCompSubdirsInDir, []string{}) - - return cc -} diff --git a/commands/gendocshelper.go b/commands/gendocshelper.go deleted file mode 100644 index c243581f6..000000000 --- a/commands/gendocshelper.go +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright 2017-present 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 commands - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - - "github.com/gohugoio/hugo/docshelper" - "github.com/spf13/cobra" -) - -var ( - _ cmder = (*genDocsHelper)(nil) -) - -type genDocsHelper struct { - target string - *baseCmd -} - -func createGenDocsHelper() *genDocsHelper { - g := &genDocsHelper{ - baseCmd: newBaseCmd(&cobra.Command{ - Use: "docshelper", - Short: "Generate some data files for the Hugo docs.", - Hidden: true, - }), - } - - g.cmd.RunE = func(cmd *cobra.Command, args []string) error { - return g.generate() - } - - g.cmd.PersistentFlags().StringVarP(&g.target, "dir", "", "docs/data", "data dir") - - return g -} - -func (g *genDocsHelper) generate() error { - fmt.Println("Generate docs data to", g.target) - - targetFile := filepath.Join(g.target, "docs.json") - - f, err := os.Create(targetFile) - if err != nil { - return err - } - defer f.Close() - - enc := json.NewEncoder(f) - enc.SetIndent("", " ") - - if err := enc.Encode(docshelper.DocProviders); err != nil { - return err - } - - fmt.Println("Done!") - return nil - -} diff --git a/commands/genman.go b/commands/genman.go deleted file mode 100644 index 720046289..000000000 --- a/commands/genman.go +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright 2016 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 commands - -import ( - "fmt" - "strings" - - "github.com/gohugoio/hugo/common/hugo" - "github.com/gohugoio/hugo/helpers" - "github.com/gohugoio/hugo/hugofs" - "github.com/spf13/cobra" - "github.com/spf13/cobra/doc" - jww "github.com/spf13/jwalterweatherman" -) - -var _ cmder = (*genManCmd)(nil) - -type genManCmd struct { - genmandir string - *baseCmd -} - -func newGenManCmd() *genManCmd { - cc := &genManCmd{} - - cc.baseCmd = newBaseCmd(&cobra.Command{ - Use: "man", - Short: "Generate man pages for the Hugo CLI", - Long: `This command automatically generates up-to-date man pages of Hugo's -command-line interface. By default, it creates the man page files -in the "man" directory under the current directory.`, - - RunE: func(cmd *cobra.Command, args []string) error { - header := &doc.GenManHeader{ - Section: "1", - Manual: "Hugo Manual", - Source: fmt.Sprintf("Hugo %s", hugo.CurrentVersion), - } - if !strings.HasSuffix(cc.genmandir, helpers.FilePathSeparator) { - cc.genmandir += helpers.FilePathSeparator - } - if found, _ := helpers.Exists(cc.genmandir, hugofs.Os); !found { - jww.FEEDBACK.Println("Directory", cc.genmandir, "does not exist, creating...") - if err := hugofs.Os.MkdirAll(cc.genmandir, 0777); err != nil { - return err - } - } - cmd.Root().DisableAutoGenTag = true - - jww.FEEDBACK.Println("Generating Hugo man pages in", cc.genmandir, "...") - doc.GenManTree(cmd.Root(), header, cc.genmandir) - - jww.FEEDBACK.Println("Done.") - - return nil - }, - }) - - cc.cmd.PersistentFlags().StringVar(&cc.genmandir, "dir", "man/", "the directory to write the man pages.") - - // For bash-completion - cc.cmd.PersistentFlags().SetAnnotation("dir", cobra.BashCompSubdirsInDir, []string{}) - - return cc -} diff --git a/commands/helpers.go b/commands/helpers.go index 1386e425f..a13bdebc2 100644 --- a/commands/helpers.go +++ b/commands/helpers.go @@ -1,4 +1,4 @@ -// Copyright 2018 The Hugo Authors. All rights reserved. +// Copyright 2024 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. @@ -11,16 +11,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package commands defines and implements command-line commands and flags -// used by Hugo. Commands and flags are implemented using Cobra. package commands import ( + "errors" "fmt" - "regexp" + "log" + "os" + "path/filepath" + "strings" + "github.com/bep/simplecobra" "github.com/gohugoio/hugo/config" - "github.com/spf13/cobra" + "github.com/spf13/pflag" ) const ( @@ -30,50 +33,89 @@ const ( showCursor = ansiEsc + "[?25h" ) -type flagsToConfigHandler interface { - flagsToConfig(cfg config.Provider) +func newUserError(a ...any) *simplecobra.CommandError { + return &simplecobra.CommandError{Err: errors.New(fmt.Sprint(a...))} } -type cmder interface { - flagsToConfigHandler - getCommand() *cobra.Command +func setValueFromFlag(flags *pflag.FlagSet, key string, cfg config.Provider, targetKey string, force bool) { + key = strings.TrimSpace(key) + if (force && flags.Lookup(key) != nil) || flags.Changed(key) { + f := flags.Lookup(key) + configKey := key + if targetKey != "" { + configKey = targetKey + } + // Gotta love this API. + switch f.Value.Type() { + case "bool": + bv, _ := flags.GetBool(key) + cfg.Set(configKey, bv) + case "string": + cfg.Set(configKey, f.Value.String()) + case "stringSlice": + bv, _ := flags.GetStringSlice(key) + cfg.Set(configKey, bv) + case "int": + iv, _ := flags.GetInt(key) + cfg.Set(configKey, iv) + default: + panic(fmt.Sprintf("update switch with %s", f.Value.Type())) + } + + } } -// commandError is an error used to signal different error situations in command handling. -type commandError struct { - s string - userError bool +func flagsToCfg(cd *simplecobra.Commandeer, cfg config.Provider) config.Provider { + return flagsToCfgWithAdditionalConfigBase(cd, cfg, "") } -func (c commandError) Error() string { - return c.s -} - -func (c commandError) isUserError() bool { - return c.userError -} - -func newUserError(a ...interface{}) commandError { - return commandError{s: fmt.Sprintln(a...), userError: true} -} - -func newSystemError(a ...interface{}) commandError { - return commandError{s: fmt.Sprintln(a...), userError: false} -} - -func newSystemErrorF(format string, a ...interface{}) commandError { - return commandError{s: fmt.Sprintf(format, a...), userError: false} -} - -// Catch some of the obvious user errors from Cobra. -// We don't want to show the usage message for every error. -// The below may be to generic. Time will show. -var userErrorRegexp = regexp.MustCompile("argument|flag|shorthand") - -func isUserError(err error) bool { - if cErr, ok := err.(commandError); ok && cErr.isUserError() { - return true +func flagsToCfgWithAdditionalConfigBase(cd *simplecobra.Commandeer, cfg config.Provider, additionalConfigBase string) config.Provider { + if cfg == nil { + cfg = config.New() } - return userErrorRegexp.MatchString(err.Error()) + // Flags with a different name in the config. + keyMap := map[string]string{ + "minify": "minifyOutput", + "destination": "publishDir", + "editor": "newContentEditor", + } + + // Flags that we for some reason don't want to expose in the site config. + internalKeySet := map[string]bool{ + "quiet": true, + "verbose": true, + "watch": true, + "liveReloadPort": true, + "renderToMemory": true, + "clock": true, + } + + cmd := cd.CobraCommand + flags := cmd.Flags() + + flags.VisitAll(func(f *pflag.Flag) { + if f.Changed { + targetKey := f.Name + if internalKeySet[targetKey] { + targetKey = "internal." + targetKey + } else if mapped, ok := keyMap[targetKey]; ok { + targetKey = mapped + } + setValueFromFlag(flags, f.Name, cfg, targetKey, false) + if additionalConfigBase != "" { + setValueFromFlag(flags, f.Name, cfg, additionalConfigBase+"."+targetKey, true) + } + } + }) + + return cfg +} + +func mkdir(x ...string) { + p := filepath.Join(x...) + err := os.MkdirAll(p, 0o777) // before umask + if err != nil { + log.Fatal(err) + } } diff --git a/commands/hugo.go b/commands/hugo.go deleted file mode 100644 index b2b0981a9..000000000 --- a/commands/hugo.go +++ /dev/null @@ -1,1178 +0,0 @@ -// Copyright 2019 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 commands defines and implements command-line commands and flags -// used by Hugo. Commands and flags are implemented using Cobra. -package commands - -import ( - "context" - "fmt" - "io/ioutil" - "os/signal" - "runtime/pprof" - "runtime/trace" - "sync/atomic" - - "github.com/gohugoio/hugo/hugofs" - - "github.com/gohugoio/hugo/resources/page" - - "github.com/pkg/errors" - - "github.com/gohugoio/hugo/common/herrors" - "github.com/gohugoio/hugo/common/loggers" - "github.com/gohugoio/hugo/common/terminal" - - "syscall" - - "github.com/gohugoio/hugo/hugolib/filesystems" - - "golang.org/x/sync/errgroup" - - "os" - "path/filepath" - "runtime" - "strings" - "time" - - "github.com/gohugoio/hugo/config" - - flag "github.com/spf13/pflag" - - "github.com/fsnotify/fsnotify" - "github.com/gohugoio/hugo/helpers" - "github.com/gohugoio/hugo/hugolib" - "github.com/gohugoio/hugo/livereload" - "github.com/gohugoio/hugo/watcher" - "github.com/spf13/afero" - "github.com/spf13/cobra" - "github.com/spf13/fsync" - jww "github.com/spf13/jwalterweatherman" -) - -// The Response value from Execute. -type Response struct { - // The build Result will only be set in the hugo build command. - Result *hugolib.HugoSites - - // Err is set when the command failed to execute. - Err error - - // The command that was executed. - Cmd *cobra.Command -} - -// IsUserError returns true is the Response error is a user error rather than a -// system error. -func (r Response) IsUserError() bool { - return r.Err != nil && isUserError(r.Err) -} - -// Execute adds all child commands to the root command HugoCmd and sets flags appropriately. -// The args are usually filled with os.Args[1:]. -func Execute(args []string) Response { - hugoCmd := newCommandsBuilder().addAll().build() - cmd := hugoCmd.getCommand() - cmd.SetArgs(args) - - c, err := cmd.ExecuteC() - - var resp Response - - if c == cmd && hugoCmd.c != nil { - // Root command executed - resp.Result = hugoCmd.c.hugo() - } - - if err == nil { - errCount := int(loggers.GlobalErrorCounter.Count()) - if errCount > 0 { - err = fmt.Errorf("logged %d errors", errCount) - } else if resp.Result != nil { - errCount = resp.Result.NumLogErrors() - if errCount > 0 { - err = fmt.Errorf("logged %d errors", errCount) - } - } - - } - - resp.Err = err - resp.Cmd = c - - return resp -} - -// InitializeConfig initializes a config file with sensible default configuration flags. -func initializeConfig(mustHaveConfigFile, running bool, - h *hugoBuilderCommon, - f flagsToConfigHandler, - doWithCommandeer func(c *commandeer) error) (*commandeer, error) { - - c, err := newCommandeer(mustHaveConfigFile, running, h, f, doWithCommandeer) - if err != nil { - return nil, err - } - - return c, nil - -} - -func (c *commandeer) createLogger(cfg config.Provider, running bool) (*loggers.Logger, error) { - var ( - logHandle = ioutil.Discard - logThreshold = jww.LevelWarn - logFile = cfg.GetString("logFile") - outHandle = ioutil.Discard - stdoutThreshold = jww.LevelWarn - ) - - if !c.h.quiet { - outHandle = os.Stdout - } - - if c.h.verboseLog || c.h.logging || (c.h.logFile != "") { - var err error - if logFile != "" { - logHandle, err = os.OpenFile(logFile, os.O_RDWR|os.O_APPEND|os.O_CREATE, 0666) - if err != nil { - return nil, newSystemError("Failed to open log file:", logFile, err) - } - } else { - logHandle, err = ioutil.TempFile("", "hugo") - if err != nil { - return nil, newSystemError(err) - } - } - } else if !c.h.quiet && cfg.GetBool("verbose") { - stdoutThreshold = jww.LevelInfo - } - - if cfg.GetBool("debug") { - stdoutThreshold = jww.LevelDebug - } - - if c.h.verboseLog { - logThreshold = jww.LevelInfo - if cfg.GetBool("debug") { - logThreshold = jww.LevelDebug - } - } - - loggers.InitGlobalLogger(stdoutThreshold, logThreshold, outHandle, logHandle) - helpers.InitLoggers() - - return loggers.NewLogger(stdoutThreshold, logThreshold, outHandle, logHandle, running), nil -} - -func initializeFlags(cmd *cobra.Command, cfg config.Provider) { - persFlagKeys := []string{ - "debug", - "verbose", - "logFile", - // Moved from vars - } - flagKeys := []string{ - "cleanDestinationDir", - "buildDrafts", - "buildFuture", - "buildExpired", - "uglyURLs", - "canonifyURLs", - "enableRobotsTXT", - "enableGitInfo", - "pluralizeListTitles", - "preserveTaxonomyNames", - "ignoreCache", - "forceSyncStatic", - "noTimes", - "noChmod", - "ignoreVendor", - "templateMetrics", - "templateMetricsHints", - - // Moved from vars. - "baseURL", - "buildWatch", - "cacheDir", - "cfgFile", - "confirm", - "contentDir", - "debug", - "destination", - "disableKinds", - "dryRun", - "force", - "gc", - "i18n-warnings", - "invalidateCDN", - "layoutDir", - "logFile", - "maxDeletes", - "quiet", - "renderToMemory", - "source", - "target", - "theme", - "themesDir", - "verbose", - "verboseLog", - "duplicateTargetPaths", - } - - // Will set a value even if it is the default. - flagKeysForced := []string{ - "minify", - } - - for _, key := range persFlagKeys { - setValueFromFlag(cmd.PersistentFlags(), key, cfg, "", false) - } - for _, key := range flagKeys { - setValueFromFlag(cmd.Flags(), key, cfg, "", false) - } - - for _, key := range flagKeysForced { - setValueFromFlag(cmd.Flags(), key, cfg, "", true) - } - - // Set some "config aliases" - setValueFromFlag(cmd.Flags(), "destination", cfg, "publishDir", false) - setValueFromFlag(cmd.Flags(), "i18n-warnings", cfg, "logI18nWarnings", false) - setValueFromFlag(cmd.Flags(), "path-warnings", cfg, "logPathWarnings", false) - -} - -func setValueFromFlag(flags *flag.FlagSet, key string, cfg config.Provider, targetKey string, force bool) { - key = strings.TrimSpace(key) - if (force && flags.Lookup(key) != nil) || flags.Changed(key) { - f := flags.Lookup(key) - configKey := key - if targetKey != "" { - configKey = targetKey - } - // Gotta love this API. - switch f.Value.Type() { - case "bool": - bv, _ := flags.GetBool(key) - cfg.Set(configKey, bv) - case "string": - cfg.Set(configKey, f.Value.String()) - case "stringSlice": - bv, _ := flags.GetStringSlice(key) - cfg.Set(configKey, bv) - case "int": - iv, _ := flags.GetInt(key) - cfg.Set(configKey, iv) - default: - panic(fmt.Sprintf("update switch with %s", f.Value.Type())) - } - - } -} - -func isTerminal() bool { - return terminal.IsTerminal(os.Stdout) - -} -func ifTerminal(s string) string { - if !isTerminal() { - return "" - } - return s -} - -func (c *commandeer) fullBuild() error { - - var ( - g errgroup.Group - langCount map[string]uint64 - ) - - if !c.h.quiet { - fmt.Print(ifTerminal(hideCursor) + "Building sites … ") - if isTerminal() { - defer func() { - fmt.Print(showCursor + clearLine) - }() - } - } - - copyStaticFunc := func() error { - - cnt, err := c.copyStatic() - if err != nil { - return errors.Wrap(err, "Error copying static files") - } - langCount = cnt - return nil - } - buildSitesFunc := func() error { - if err := c.buildSites(); err != nil { - return errors.Wrap(err, "Error building site") - } - return nil - } - // Do not copy static files and build sites in parallel if cleanDestinationDir is enabled. - // This flag deletes all static resources in /public folder that are missing in /static, - // and it does so at the end of copyStatic() call. - if c.Cfg.GetBool("cleanDestinationDir") { - if err := copyStaticFunc(); err != nil { - return err - } - if err := buildSitesFunc(); err != nil { - return err - } - } else { - g.Go(copyStaticFunc) - g.Go(buildSitesFunc) - if err := g.Wait(); err != nil { - return err - } - } - - for _, s := range c.hugo().Sites { - s.ProcessingStats.Static = langCount[s.Language().Lang] - } - - if c.h.gc { - count, err := c.hugo().GC() - if err != nil { - return err - } - for _, s := range c.hugo().Sites { - // We have no way of knowing what site the garbage belonged to. - s.ProcessingStats.Cleaned = uint64(count) - } - } - - return nil - -} - -func (c *commandeer) initCPUProfile() (func(), error) { - if c.h.cpuprofile == "" { - return nil, nil - } - - f, err := os.Create(c.h.cpuprofile) - if err != nil { - return nil, errors.Wrap(err, "failed to create CPU profile") - } - if err := pprof.StartCPUProfile(f); err != nil { - return nil, errors.Wrap(err, "failed to start CPU profile") - } - return func() { - pprof.StopCPUProfile() - f.Close() - }, nil -} - -func (c *commandeer) initMemProfile() { - if c.h.memprofile == "" { - return - } - - f, err := os.Create(c.h.memprofile) - if err != nil { - c.logger.ERROR.Println("could not create memory profile: ", err) - } - defer f.Close() - runtime.GC() // get up-to-date statistics - if err := pprof.WriteHeapProfile(f); err != nil { - c.logger.ERROR.Println("could not write memory profile: ", err) - } -} - -func (c *commandeer) initTraceProfile() (func(), error) { - if c.h.traceprofile == "" { - return nil, nil - } - - f, err := os.Create(c.h.traceprofile) - if err != nil { - return nil, errors.Wrap(err, "failed to create trace file") - } - - if err := trace.Start(f); err != nil { - return nil, errors.Wrap(err, "failed to start trace") - } - - return func() { - trace.Stop() - f.Close() - }, nil -} - -func (c *commandeer) initMutexProfile() (func(), error) { - if c.h.mutexprofile == "" { - return nil, nil - } - - f, err := os.Create(c.h.mutexprofile) - if err != nil { - return nil, err - } - - runtime.SetMutexProfileFraction(1) - - return func() { - pprof.Lookup("mutex").WriteTo(f, 0) - f.Close() - }, nil - -} - -func (c *commandeer) initProfiling() (func(), error) { - stopCPUProf, err := c.initCPUProfile() - if err != nil { - return nil, err - } - - stopMutexProf, err := c.initMutexProfile() - if err != nil { - return nil, err - } - - stopTraceProf, err := c.initTraceProfile() - if err != nil { - return nil, err - } - - return func() { - c.initMemProfile() - - if stopCPUProf != nil { - stopCPUProf() - } - if stopMutexProf != nil { - stopMutexProf() - } - - if stopTraceProf != nil { - stopTraceProf() - } - }, nil -} - -func (c *commandeer) build() error { - stopProfiling, err := c.initProfiling() - if err != nil { - return err - } - - defer func() { - if stopProfiling != nil { - stopProfiling() - } - }() - - if err := c.fullBuild(); err != nil { - return err - } - - // TODO(bep) Feedback? - if !c.h.quiet { - fmt.Println() - c.hugo().PrintProcessingStats(os.Stdout) - fmt.Println() - - if createCounter, ok := c.destinationFs.(hugofs.DuplicatesReporter); ok { - dupes := createCounter.ReportDuplicates() - if dupes != "" { - c.logger.WARN.Println("Duplicate target paths:", dupes) - } - } - } - - if c.h.buildWatch { - watchDirs, err := c.getDirList() - if err != nil { - return err - } - - baseWatchDir := c.Cfg.GetString("workingDir") - rootWatchDirs := getRootWatchDirsStr(baseWatchDir, watchDirs) - - c.logger.FEEDBACK.Printf("Watching for changes in %s%s{%s}\n", baseWatchDir, helpers.FilePathSeparator, rootWatchDirs) - c.logger.FEEDBACK.Println("Press Ctrl+C to stop") - watcher, err := c.newWatcher(watchDirs...) - checkErr(c.Logger, err) - defer watcher.Close() - - var sigs = make(chan os.Signal, 1) - signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) - - <-sigs - } - - return nil -} - -func (c *commandeer) serverBuild() error { - defer c.timeTrack(time.Now(), "Built") - - stopProfiling, err := c.initProfiling() - if err != nil { - return err - } - - defer func() { - if stopProfiling != nil { - stopProfiling() - } - }() - - if err := c.fullBuild(); err != nil { - return err - } - - // TODO(bep) Feedback? - if !c.h.quiet { - fmt.Println() - c.hugo().PrintProcessingStats(os.Stdout) - fmt.Println() - } - - return nil -} - -func (c *commandeer) copyStatic() (map[string]uint64, error) { - m, err := c.doWithPublishDirs(c.copyStaticTo) - if err == nil || os.IsNotExist(err) { - return m, nil - } - return m, err -} - -func (c *commandeer) doWithPublishDirs(f func(sourceFs *filesystems.SourceFilesystem) (uint64, error)) (map[string]uint64, error) { - - langCount := make(map[string]uint64) - - staticFilesystems := c.hugo().BaseFs.SourceFilesystems.Static - - if len(staticFilesystems) == 0 { - c.logger.INFO.Println("No static directories found to sync") - return langCount, nil - } - - for lang, fs := range staticFilesystems { - cnt, err := f(fs) - if err != nil { - return langCount, err - } - - if lang == "" { - // Not multihost - for _, l := range c.languages { - langCount[l.Lang] = cnt - } - } else { - langCount[lang] = cnt - } - } - - return langCount, nil -} - -type countingStatFs struct { - afero.Fs - statCounter uint64 -} - -func (fs *countingStatFs) Stat(name string) (os.FileInfo, error) { - f, err := fs.Fs.Stat(name) - if err == nil { - if !f.IsDir() { - atomic.AddUint64(&fs.statCounter, 1) - } - } - return f, err -} - -func chmodFilter(dst, src os.FileInfo) bool { - // Hugo publishes data from multiple sources, potentially - // with overlapping directory structures. We cannot sync permissions - // for directories as that would mean that we might end up with write-protected - // directories inside /public. - // One example of this would be syncing from the Go Module cache, - // which have 0555 directories. - return src.IsDir() -} - -func (c *commandeer) copyStaticTo(sourceFs *filesystems.SourceFilesystem) (uint64, error) { - publishDir := c.hugo().PathSpec.PublishDir - // If root, remove the second '/' - if publishDir == "//" { - publishDir = helpers.FilePathSeparator - } - - if sourceFs.PublishFolder != "" { - publishDir = filepath.Join(publishDir, sourceFs.PublishFolder) - } - - fs := &countingStatFs{Fs: sourceFs.Fs} - - syncer := fsync.NewSyncer() - syncer.NoTimes = c.Cfg.GetBool("noTimes") - syncer.NoChmod = c.Cfg.GetBool("noChmod") - syncer.ChmodFilter = chmodFilter - syncer.SrcFs = fs - syncer.DestFs = c.Fs.Destination - // Now that we are using a unionFs for the static directories - // We can effectively clean the publishDir on initial sync - syncer.Delete = c.Cfg.GetBool("cleanDestinationDir") - - if syncer.Delete { - c.logger.INFO.Println("removing all files from destination that don't exist in static dirs") - - syncer.DeleteFilter = func(f os.FileInfo) bool { - return f.IsDir() && strings.HasPrefix(f.Name(), ".") - } - } - c.logger.INFO.Println("syncing static files to", publishDir) - - // because we are using a baseFs (to get the union right). - // set sync src to root - err := syncer.Sync(publishDir, helpers.FilePathSeparator) - if err != nil { - return 0, err - } - - // Sync runs Stat 3 times for every source file (which sounds much) - numFiles := fs.statCounter / 3 - - return numFiles, err -} - -func (c *commandeer) firstPathSpec() *helpers.PathSpec { - return c.hugo().Sites[0].PathSpec -} - -func (c *commandeer) timeTrack(start time.Time, name string) { - elapsed := time.Since(start) - c.logger.FEEDBACK.Printf("%s in %v ms", name, int(1000*elapsed.Seconds())) -} - -// getDirList provides NewWatcher() with a list of directories to watch for changes. -func (c *commandeer) getDirList() ([]string, error) { - var filenames []string - - walkFn := func(path string, fi hugofs.FileMetaInfo, err error) error { - if err != nil { - c.logger.ERROR.Println("walker: ", err) - return nil - } - - if fi.IsDir() { - if fi.Name() == ".git" || - fi.Name() == "node_modules" || fi.Name() == "bower_components" { - return filepath.SkipDir - } - - filenames = append(filenames, fi.Meta().Filename()) - } - - return nil - - } - - watchFiles := c.hugo().PathSpec.BaseFs.WatchDirs() - for _, fi := range watchFiles { - if !fi.IsDir() { - filenames = append(filenames, fi.Meta().Filename()) - continue - } - - w := hugofs.NewWalkway(hugofs.WalkwayConfig{Logger: c.logger, Info: fi, WalkFn: walkFn}) - if err := w.Walk(); err != nil { - c.logger.ERROR.Println("walker: ", err) - } - } - - filenames = helpers.UniqueStringsSorted(filenames) - - return filenames, nil -} - -func (c *commandeer) buildSites() (err error) { - return c.hugo().Build(hugolib.BuildCfg{}) -} - -func (c *commandeer) handleBuildErr(err error, msg string) { - c.buildErr = err - - c.logger.ERROR.Print(msg + ":\n\n") - c.logger.ERROR.Println(helpers.FirstUpper(err.Error())) - if !c.h.quiet && c.h.verbose { - herrors.PrintStackTraceFromErr(err) - } -} - -func (c *commandeer) rebuildSites(events []fsnotify.Event) error { - defer c.timeTrack(time.Now(), "Total") - defer func() { - c.wasError = false - }() - - c.buildErr = nil - visited := c.visitedURLs.PeekAllSet() - if c.fastRenderMode { - - // Make sure we always render the home pages - for _, l := range c.languages { - langPath := c.hugo().PathSpec.GetLangSubDir(l.Lang) - if langPath != "" { - langPath = langPath + "/" - } - home := c.hugo().PathSpec.PrependBasePath("/"+langPath, false) - visited[home] = true - } - - } - return c.hugo().Build(hugolib.BuildCfg{RecentlyVisited: visited, ErrRecovery: c.wasError}, events...) -} - -func (c *commandeer) partialReRender(urls ...string) error { - defer func() { - c.wasError = false - }() - c.buildErr = nil - visited := make(map[string]bool) - for _, url := range urls { - visited[url] = true - } - return c.hugo().Build(hugolib.BuildCfg{RecentlyVisited: visited, PartialReRender: true, ErrRecovery: c.wasError}) -} - -func (c *commandeer) fullRebuild(changeType string) { - if changeType == configChangeGoMod { - // go.mod may be changed during the build itself, and - // we really want to prevent superfluous builds. - if !c.fullRebuildSem.TryAcquire(1) { - return - } - c.fullRebuildSem.Release(1) - } - - c.fullRebuildSem.Acquire(context.Background(), 1) - - go func() { - - defer c.fullRebuildSem.Release(1) - - c.printChangeDetected(changeType) - - defer func() { - - // Allow any file system events to arrive back. - // This will block any rebuild on config changes for the - // duration of the sleep. - time.Sleep(2 * time.Second) - }() - - defer c.timeTrack(time.Now(), "Rebuilt") - - c.commandeerHugoState = newCommandeerHugoState() - err := c.loadConfig(true, true) - if err != nil { - // Set the processing on pause until the state is recovered. - c.paused = true - c.handleBuildErr(err, "Failed to reload config") - - } else { - c.paused = false - } - - if !c.paused { - _, err := c.copyStatic() - if err != nil { - c.logger.ERROR.Println(err) - return - } - - err = c.buildSites() - if err != nil { - c.logger.ERROR.Println(err) - } else if !c.h.buildWatch && !c.Cfg.GetBool("disableLiveReload") { - livereload.ForceRefresh() - } - } - }() -} - -// newWatcher creates a new watcher to watch filesystem events. -func (c *commandeer) newWatcher(dirList ...string) (*watcher.Batcher, error) { - if runtime.GOOS == "darwin" { - tweakLimit() - } - - staticSyncer, err := newStaticSyncer(c) - if err != nil { - return nil, err - } - - watcher, err := watcher.New(1 * time.Second) - - if err != nil { - return nil, err - } - - for _, d := range dirList { - if d != "" { - _ = watcher.Add(d) - } - } - - // Identifies changes to config (config.toml) files. - configSet := make(map[string]bool) - - c.logger.FEEDBACK.Println("Watching for config changes in", strings.Join(c.configFiles, ", ")) - for _, configFile := range c.configFiles { - watcher.Add(configFile) - configSet[configFile] = true - } - - go func() { - for { - select { - case evs := <-watcher.Events: - c.handleEvents(watcher, staticSyncer, evs, configSet) - if c.showErrorInBrowser && c.errCount() > 0 { - // Need to reload browser to show the error - livereload.ForceRefresh() - } - case err := <-watcher.Errors: - if err != nil { - c.logger.ERROR.Println("Error while watching:", err) - } - } - } - }() - - return watcher, nil -} - -func (c *commandeer) printChangeDetected(typ string) { - msg := "\nChange" - if typ != "" { - msg += " of " + typ - } - msg += " detected, rebuilding site." - - c.logger.FEEDBACK.Println(msg) - const layout = "2006-01-02 15:04:05.000 -0700" - c.logger.FEEDBACK.Println(time.Now().Format(layout)) -} - -const ( - configChangeConfig = "config file" - configChangeGoMod = "go.mod file" -) - -func (c *commandeer) handleEvents(watcher *watcher.Batcher, - staticSyncer *staticSyncer, - evs []fsnotify.Event, - configSet map[string]bool) { - - var isHandled bool - - for _, ev := range evs { - isConfig := configSet[ev.Name] - configChangeType := configChangeConfig - if isConfig { - if strings.Contains(ev.Name, "go.mod") { - configChangeType = configChangeGoMod - } - } - if !isConfig { - // It may be one of the /config folders - dirname := filepath.Dir(ev.Name) - if dirname != "." && configSet[dirname] { - isConfig = true - } - } - - if isConfig { - isHandled = true - - if ev.Op&fsnotify.Chmod == fsnotify.Chmod { - continue - } - - if ev.Op&fsnotify.Remove == fsnotify.Remove || ev.Op&fsnotify.Rename == fsnotify.Rename { - for _, configFile := range c.configFiles { - counter := 0 - for watcher.Add(configFile) != nil { - counter++ - if counter >= 100 { - break - } - time.Sleep(100 * time.Millisecond) - } - } - - } - - // Config file(s) changed. Need full rebuild. - c.fullRebuild(configChangeType) - - return - } - } - - if isHandled { - return - } - - if c.paused { - // Wait for the server to get into a consistent state before - // we continue with processing. - return - } - - if len(evs) > 50 { - // This is probably a mass edit of the content dir. - // Schedule a full rebuild for when it slows down. - c.debounce(func() { - c.fullRebuild("") - }) - return - } - - c.logger.INFO.Println("Received System Events:", evs) - - staticEvents := []fsnotify.Event{} - dynamicEvents := []fsnotify.Event{} - - // Special handling for symbolic links inside /content. - filtered := []fsnotify.Event{} - for _, ev := range evs { - // Check the most specific first, i.e. files. - contentMapped := c.hugo().ContentChanges.GetSymbolicLinkMappings(ev.Name) - if len(contentMapped) > 0 { - for _, mapped := range contentMapped { - filtered = append(filtered, fsnotify.Event{Name: mapped, Op: ev.Op}) - } - continue - } - - // Check for any symbolic directory mapping. - - dir, name := filepath.Split(ev.Name) - - contentMapped = c.hugo().ContentChanges.GetSymbolicLinkMappings(dir) - - if len(contentMapped) == 0 { - filtered = append(filtered, ev) - continue - } - - for _, mapped := range contentMapped { - mappedFilename := filepath.Join(mapped, name) - filtered = append(filtered, fsnotify.Event{Name: mappedFilename, Op: ev.Op}) - } - } - - evs = filtered - - for _, ev := range evs { - ext := filepath.Ext(ev.Name) - baseName := filepath.Base(ev.Name) - istemp := strings.HasSuffix(ext, "~") || - (ext == ".swp") || // vim - (ext == ".swx") || // vim - (ext == ".tmp") || // generic temp file - (ext == ".DS_Store") || // OSX Thumbnail - baseName == "4913" || // vim - strings.HasPrefix(ext, ".goutputstream") || // gnome - strings.HasSuffix(ext, "jb_old___") || // intelliJ - strings.HasSuffix(ext, "jb_tmp___") || // intelliJ - strings.HasSuffix(ext, "jb_bak___") || // intelliJ - strings.HasPrefix(ext, ".sb-") || // byword - strings.HasPrefix(baseName, ".#") || // emacs - strings.HasPrefix(baseName, "#") // emacs - if istemp { - continue - } - if c.hugo().Deps.SourceSpec.IgnoreFile(ev.Name) { - continue - } - // Sometimes during rm -rf operations a '"": REMOVE' is triggered. Just ignore these - if ev.Name == "" { - continue - } - - // Write and rename operations are often followed by CHMOD. - // There may be valid use cases for rebuilding the site on CHMOD, - // but that will require more complex logic than this simple conditional. - // On OS X this seems to be related to Spotlight, see: - // https://github.com/go-fsnotify/fsnotify/issues/15 - // A workaround is to put your site(s) on the Spotlight exception list, - // but that may be a little mysterious for most end users. - // So, for now, we skip reload on CHMOD. - // We do have to check for WRITE though. On slower laptops a Chmod - // could be aggregated with other important events, and we still want - // to rebuild on those - if ev.Op&(fsnotify.Chmod|fsnotify.Write|fsnotify.Create) == fsnotify.Chmod { - continue - } - - walkAdder := func(path string, f hugofs.FileMetaInfo, err error) error { - if f.IsDir() { - c.logger.FEEDBACK.Println("adding created directory to watchlist", path) - if err := watcher.Add(path); err != nil { - return err - } - } else if !staticSyncer.isStatic(path) { - // Hugo's rebuilding logic is entirely file based. When you drop a new folder into - // /content on OSX, the above logic will handle future watching of those files, - // but the initial CREATE is lost. - dynamicEvents = append(dynamicEvents, fsnotify.Event{Name: path, Op: fsnotify.Create}) - } - return nil - } - - // recursively add new directories to watch list - // When mkdir -p is used, only the top directory triggers an event (at least on OSX) - if ev.Op&fsnotify.Create == fsnotify.Create { - if s, err := c.Fs.Source.Stat(ev.Name); err == nil && s.Mode().IsDir() { - _ = helpers.SymbolicWalk(c.Fs.Source, ev.Name, walkAdder) - } - } - - if staticSyncer.isStatic(ev.Name) { - staticEvents = append(staticEvents, ev) - } else { - dynamicEvents = append(dynamicEvents, ev) - } - } - - if len(staticEvents) > 0 { - c.printChangeDetected("Static files") - - if c.Cfg.GetBool("forceSyncStatic") { - c.logger.FEEDBACK.Printf("Syncing all static files\n") - _, err := c.copyStatic() - if err != nil { - c.logger.ERROR.Println("Error copying static files to publish dir:", err) - return - } - } else { - if err := staticSyncer.syncsStaticEvents(staticEvents); err != nil { - c.logger.ERROR.Println("Error syncing static files to publish dir:", err) - return - } - } - - if !c.h.buildWatch && !c.Cfg.GetBool("disableLiveReload") { - // Will block forever trying to write to a channel that nobody is reading if livereload isn't initialized - - // force refresh when more than one file - if len(staticEvents) == 1 { - ev := staticEvents[0] - path := c.hugo().BaseFs.SourceFilesystems.MakeStaticPathRelative(ev.Name) - path = c.firstPathSpec().RelURL(helpers.ToSlashTrimLeading(path), false) - livereload.RefreshPath(path) - } else { - livereload.ForceRefresh() - } - } - } - - if len(dynamicEvents) > 0 { - partitionedEvents := partitionDynamicEvents( - c.firstPathSpec().BaseFs.SourceFilesystems, - dynamicEvents) - - doLiveReload := !c.h.buildWatch && !c.Cfg.GetBool("disableLiveReload") - onePageName := pickOneWriteOrCreatePath(partitionedEvents.ContentEvents) - - c.printChangeDetected("") - c.changeDetector.PrepareNew() - if err := c.rebuildSites(dynamicEvents); err != nil { - c.handleBuildErr(err, "Rebuild failed") - } - - if doLiveReload { - if len(partitionedEvents.ContentEvents) == 0 && len(partitionedEvents.AssetEvents) > 0 { - changed := c.changeDetector.changed() - if c.changeDetector != nil && len(changed) == 0 { - // Nothing has changed. - return - } else if len(changed) == 1 { - pathToRefresh := c.firstPathSpec().RelURL(helpers.ToSlashTrimLeading(changed[0]), false) - livereload.RefreshPath(pathToRefresh) - } else { - livereload.ForceRefresh() - } - } - - if len(partitionedEvents.ContentEvents) > 0 { - - navigate := c.Cfg.GetBool("navigateToChanged") - // We have fetched the same page above, but it may have - // changed. - var p page.Page - - if navigate { - if onePageName != "" { - p = c.hugo().GetContentPage(onePageName) - } - } - - if p != nil { - livereload.NavigateToPathForPort(p.RelPermalink(), p.Site().ServerPort()) - } else { - livereload.ForceRefresh() - } - } - } - } -} - -// dynamicEvents contains events that is considered dynamic, as in "not static". -// Both of these categories will trigger a new build, but the asset events -// does not fit into the "navigate to changed" logic. -type dynamicEvents struct { - ContentEvents []fsnotify.Event - AssetEvents []fsnotify.Event -} - -func partitionDynamicEvents(sourceFs *filesystems.SourceFilesystems, events []fsnotify.Event) (de dynamicEvents) { - for _, e := range events { - if sourceFs.IsAsset(e.Name) { - de.AssetEvents = append(de.AssetEvents, e) - } else { - de.ContentEvents = append(de.ContentEvents, e) - } - } - return - -} - -func pickOneWriteOrCreatePath(events []fsnotify.Event) string { - name := "" - - // Some editors (for example notepad.exe on Windows) triggers a change - // both for directory and file. So we pick the longest path, which should - // be the file itself. - for _, ev := range events { - if (ev.Op&fsnotify.Write == fsnotify.Write || ev.Op&fsnotify.Create == fsnotify.Create) && len(ev.Name) > len(name) { - name = ev.Name - } - } - - return name -} diff --git a/commands/hugo_test.go b/commands/hugo_test.go deleted file mode 100644 index 65a0416c7..000000000 --- a/commands/hugo_test.go +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright 2019 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 commands - -import ( - "testing" - - qt "github.com/frankban/quicktest" -) - -// Issue #5662 -func TestHugoWithContentDirOverride(t *testing.T) { - c := qt.New(t) - - hugoCmd := newCommandsBuilder().addAll().build() - cmd := hugoCmd.getCommand() - - contentDir := "contentOverride" - - cfgStr := ` - -baseURL = "https://example.org" -title = "Hugo Commands" - -contentDir = "thisdoesnotexist" - -` - dir, clean, err := createSimpleTestSite(t, testSiteConfig{configTOML: cfgStr, contentDir: contentDir}) - c.Assert(err, qt.IsNil) - defer clean() - - cmd.SetArgs([]string{"-s=" + dir, "-c=" + contentDir}) - - _, err = cmd.ExecuteC() - c.Assert(err, qt.IsNil) - -} diff --git a/commands/hugo_windows.go b/commands/hugo_windows.go index 106c0cc71..c354e889d 100644 --- a/commands/hugo_windows.go +++ b/commands/hugo_windows.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Hugo Authors. All rights reserved. +// Copyright 2024 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. @@ -13,15 +13,21 @@ package commands -import "github.com/spf13/cobra" +import ( + // For time zone lookups on Windows without Go installed. + // See #8892 + _ "time/tzdata" + + "github.com/spf13/cobra" +) func init() { // This message to show to Windows users if Hugo is opened from explorer.exe cobra.MousetrapHelpText = ` - Hugo is a command-line tool for generating static website. + Hugo is a command-line tool for generating static websites. + + You need to open PowerShell and run Hugo from there. - You need to open cmd.exe and run Hugo from there. - Visit https://gohugo.io/ for more information.` } diff --git a/commands/hugobuilder.go b/commands/hugobuilder.go new file mode 100644 index 000000000..3b57ac5e9 --- /dev/null +++ b/commands/hugobuilder.go @@ -0,0 +1,1157 @@ +// Copyright 2024 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 commands + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "runtime/pprof" + "runtime/trace" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/bep/simplecobra" + "github.com/fsnotify/fsnotify" + "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/common/htime" + "github.com/gohugoio/hugo/common/hugo" + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/common/paths" + "github.com/gohugoio/hugo/common/terminal" + "github.com/gohugoio/hugo/common/types" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/helpers" + "github.com/gohugoio/hugo/hugofs" + "github.com/gohugoio/hugo/hugolib" + "github.com/gohugoio/hugo/hugolib/filesystems" + "github.com/gohugoio/hugo/identity" + "github.com/gohugoio/hugo/livereload" + "github.com/gohugoio/hugo/resources/page" + "github.com/gohugoio/hugo/watcher" + "github.com/spf13/fsync" + "golang.org/x/sync/errgroup" + "golang.org/x/sync/semaphore" +) + +type hugoBuilder struct { + r *rootCommand + + confmu sync.Mutex + conf *commonConfig + + // May be nil. + s *serverCommand + + // Currently only set when in "fast render mode". + changeDetector *fileChangeDetector + visitedURLs *types.EvictingQueue[string] + + fullRebuildSem *semaphore.Weighted + debounce func(f func()) + + onConfigLoaded func(reloaded bool) error + + fastRenderMode bool + showErrorInBrowser bool + + errState hugoBuilderErrState +} + +var errConfigNotSet = errors.New("config not set") + +func (c *hugoBuilder) withConfE(fn func(conf *commonConfig) error) error { + c.confmu.Lock() + defer c.confmu.Unlock() + if c.conf == nil { + return errConfigNotSet + } + return fn(c.conf) +} + +func (c *hugoBuilder) withConf(fn func(conf *commonConfig)) { + c.confmu.Lock() + defer c.confmu.Unlock() + fn(c.conf) +} + +type hugoBuilderErrState struct { + mu sync.Mutex + paused bool + builderr error + waserr bool +} + +func (e *hugoBuilderErrState) setPaused(p bool) { + e.mu.Lock() + defer e.mu.Unlock() + e.paused = p +} + +func (e *hugoBuilderErrState) isPaused() bool { + e.mu.Lock() + defer e.mu.Unlock() + return e.paused +} + +func (e *hugoBuilderErrState) setBuildErr(err error) { + e.mu.Lock() + defer e.mu.Unlock() + e.builderr = err +} + +func (e *hugoBuilderErrState) buildErr() error { + e.mu.Lock() + defer e.mu.Unlock() + return e.builderr +} + +func (e *hugoBuilderErrState) setWasErr(w bool) { + e.mu.Lock() + defer e.mu.Unlock() + e.waserr = w +} + +func (e *hugoBuilderErrState) wasErr() bool { + e.mu.Lock() + defer e.mu.Unlock() + return e.waserr +} + +// getDirList provides NewWatcher() with a list of directories to watch for changes. +func (c *hugoBuilder) getDirList() ([]string, error) { + h, err := c.hugo() + if err != nil { + return nil, err + } + + return helpers.UniqueStringsSorted(h.PathSpec.BaseFs.WatchFilenames()), nil +} + +func (c *hugoBuilder) initCPUProfile() (func(), error) { + if c.r.cpuprofile == "" { + return nil, nil + } + + f, err := os.Create(c.r.cpuprofile) + if err != nil { + return nil, fmt.Errorf("failed to create CPU profile: %w", err) + } + if err := pprof.StartCPUProfile(f); err != nil { + return nil, fmt.Errorf("failed to start CPU profile: %w", err) + } + return func() { + pprof.StopCPUProfile() + f.Close() + }, nil +} + +func (c *hugoBuilder) initMemProfile() { + if c.r.memprofile == "" { + return + } + + f, err := os.Create(c.r.memprofile) + if err != nil { + c.r.logger.Errorf("could not create memory profile: ", err) + } + defer f.Close() + runtime.GC() // get up-to-date statistics + if err := pprof.WriteHeapProfile(f); err != nil { + c.r.logger.Errorf("could not write memory profile: ", err) + } +} + +func (c *hugoBuilder) initMemTicker() func() { + memticker := time.NewTicker(5 * time.Second) + quit := make(chan struct{}) + printMem := func() { + var m runtime.MemStats + runtime.ReadMemStats(&m) + fmt.Printf("\n\nAlloc = %v\nTotalAlloc = %v\nSys = %v\nNumGC = %v\n\n", formatByteCount(m.Alloc), formatByteCount(m.TotalAlloc), formatByteCount(m.Sys), m.NumGC) + } + + go func() { + for { + select { + case <-memticker.C: + printMem() + case <-quit: + memticker.Stop() + printMem() + return + } + } + }() + + return func() { + close(quit) + } +} + +func (c *hugoBuilder) initMutexProfile() (func(), error) { + if c.r.mutexprofile == "" { + return nil, nil + } + + f, err := os.Create(c.r.mutexprofile) + if err != nil { + return nil, err + } + + runtime.SetMutexProfileFraction(1) + + return func() { + pprof.Lookup("mutex").WriteTo(f, 0) + f.Close() + }, nil +} + +func (c *hugoBuilder) initProfiling() (func(), error) { + stopCPUProf, err := c.initCPUProfile() + if err != nil { + return nil, err + } + + stopMutexProf, err := c.initMutexProfile() + if err != nil { + return nil, err + } + + stopTraceProf, err := c.initTraceProfile() + if err != nil { + return nil, err + } + + var stopMemTicker func() + if c.r.printm { + stopMemTicker = c.initMemTicker() + } + + return func() { + c.initMemProfile() + + if stopCPUProf != nil { + stopCPUProf() + } + if stopMutexProf != nil { + stopMutexProf() + } + + if stopTraceProf != nil { + stopTraceProf() + } + + if stopMemTicker != nil { + stopMemTicker() + } + }, nil +} + +func (c *hugoBuilder) initTraceProfile() (func(), error) { + if c.r.traceprofile == "" { + return nil, nil + } + + f, err := os.Create(c.r.traceprofile) + if err != nil { + return nil, fmt.Errorf("failed to create trace file: %w", err) + } + + if err := trace.Start(f); err != nil { + return nil, fmt.Errorf("failed to start trace: %w", err) + } + + return func() { + trace.Stop() + f.Close() + }, nil +} + +// newWatcher creates a new watcher to watch filesystem events. +func (c *hugoBuilder) newWatcher(pollIntervalStr string, dirList ...string) (*watcher.Batcher, error) { + staticSyncer := &staticSyncer{c: c} + + var pollInterval time.Duration + poll := pollIntervalStr != "" + if poll { + pollInterval, err := types.ToDurationE(pollIntervalStr) + if err != nil { + return nil, fmt.Errorf("invalid value for flag poll: %s", err) + } + c.r.logger.Printf("Use watcher with poll interval %v", pollInterval) + } + + if pollInterval == 0 { + pollInterval = 500 * time.Millisecond + } + + watcher, err := watcher.New(500*time.Millisecond, pollInterval, poll) + if err != nil { + return nil, err + } + + h, err := c.hugo() + if err != nil { + return nil, err + } + spec := h.Deps.SourceSpec + + for _, d := range dirList { + if d != "" { + if spec.IgnoreFile(d) { + continue + } + _ = watcher.Add(d) + } + } + + // Identifies changes to config (config.toml) files. + configSet := make(map[string]bool) + var configFiles []string + c.withConf(func(conf *commonConfig) { + configFiles = conf.configs.LoadingInfo.ConfigFiles + }) + + c.r.Println("Watching for config changes in", strings.Join(configFiles, ", ")) + for _, configFile := range configFiles { + watcher.Add(configFile) + configSet[configFile] = true + } + + go func() { + for { + select { + case changes := <-c.r.changesFromBuild: + unlock, err := h.LockBuild() + if err != nil { + c.r.logger.Errorln("Failed to acquire a build lock: %s", err) + return + } + c.changeDetector.PrepareNew() + err = c.rebuildSitesForChanges(changes) + if err != nil { + c.r.logger.Errorln("Error while watching:", err) + } + if c.s != nil && c.s.doLiveReload { + doReload := c.changeDetector == nil || len(c.changeDetector.changed()) > 0 + doReload = doReload || c.showErrorInBrowser && c.errState.buildErr() != nil + if doReload { + livereload.ForceRefresh() + } + } + unlock() + + case evs := <-watcher.Events: + unlock, err := h.LockBuild() + if err != nil { + c.r.logger.Errorln("Failed to acquire a build lock: %s", err) + return + } + c.handleEvents(watcher, staticSyncer, evs, configSet) + if c.showErrorInBrowser && c.errState.buildErr() != nil { + // Need to reload browser to show the error + livereload.ForceRefresh() + } + unlock() + case err := <-watcher.Errors(): + if err != nil && !herrors.IsNotExist(err) { + c.r.logger.Errorln("Error while watching:", err) + } + } + } + }() + + return watcher, nil +} + +func (c *hugoBuilder) build() error { + stopProfiling, err := c.initProfiling() + if err != nil { + return err + } + + defer func() { + if stopProfiling != nil { + stopProfiling() + } + }() + + if err := c.fullBuild(false); err != nil { + return err + } + + if !c.r.quiet { + c.r.Println() + h, err := c.hugo() + if err != nil { + return err + } + + h.PrintProcessingStats(os.Stdout) + c.r.Println() + } + + return nil +} + +func (c *hugoBuilder) buildSites(noBuildLock bool) (err error) { + defer func() { + c.errState.setBuildErr(err) + }() + + var h *hugolib.HugoSites + h, err = c.hugo() + if err != nil { + return + } + err = h.Build(hugolib.BuildCfg{NoBuildLock: noBuildLock}) + return +} + +func (c *hugoBuilder) copyStatic() (map[string]uint64, error) { + m, err := c.doWithPublishDirs(c.copyStaticTo) + if err == nil || herrors.IsNotExist(err) { + return m, nil + } + return m, err +} + +func (c *hugoBuilder) copyStaticTo(sourceFs *filesystems.SourceFilesystem) (uint64, error) { + infol := c.r.logger.InfoCommand("static") + publishDir := helpers.FilePathSeparator + + if sourceFs.PublishFolder != "" { + publishDir = filepath.Join(publishDir, sourceFs.PublishFolder) + } + + fs := &countingStatFs{Fs: sourceFs.Fs} + + syncer := fsync.NewSyncer() + c.withConf(func(conf *commonConfig) { + syncer.NoTimes = conf.configs.Base.NoTimes + syncer.NoChmod = conf.configs.Base.NoChmod + syncer.ChmodFilter = chmodFilter + + syncer.DestFs = conf.fs.PublishDirStatic + // Now that we are using a unionFs for the static directories + // We can effectively clean the publishDir on initial sync + syncer.Delete = conf.configs.Base.CleanDestinationDir + }) + + syncer.SrcFs = fs + + if syncer.Delete { + infol.Logf("removing all files from destination that don't exist in static dirs") + + syncer.DeleteFilter = func(f fsync.FileInfo) bool { + return f.IsDir() && strings.HasPrefix(f.Name(), ".") + } + } + start := time.Now() + + // because we are using a baseFs (to get the union right). + // set sync src to root + err := syncer.Sync(publishDir, helpers.FilePathSeparator) + if err != nil { + return 0, err + } + loggers.TimeTrackf(infol, start, nil, "syncing static files to %s", publishDir) + + // Sync runs Stat 2 times for every source file. + numFiles := fs.statCounter / 2 + + return numFiles, err +} + +func (c *hugoBuilder) doWithPublishDirs(f func(sourceFs *filesystems.SourceFilesystem) (uint64, error)) (map[string]uint64, error) { + langCount := make(map[string]uint64) + + h, err := c.hugo() + if err != nil { + return nil, err + } + staticFilesystems := h.BaseFs.SourceFilesystems.Static + + if len(staticFilesystems) == 0 { + c.r.logger.Infoln("No static directories found to sync") + return langCount, nil + } + + for lang, fs := range staticFilesystems { + cnt, err := f(fs) + if err != nil { + return langCount, err + } + if lang == "" { + // Not multihost + c.withConf(func(conf *commonConfig) { + for _, l := range conf.configs.Languages { + langCount[l.Lang] = cnt + } + }) + } else { + langCount[lang] = cnt + } + } + + return langCount, nil +} + +func (c *hugoBuilder) fullBuild(noBuildLock bool) error { + var ( + g errgroup.Group + langCount map[string]uint64 + ) + + c.r.logger.Println("Start building sites … ") + c.r.logger.Println(hugo.BuildVersionString()) + c.r.logger.Println() + if terminal.IsTerminal(os.Stdout) { + defer func() { + fmt.Print(showCursor + clearLine) + }() + } + + copyStaticFunc := func() error { + cnt, err := c.copyStatic() + if err != nil { + return fmt.Errorf("error copying static files: %w", err) + } + langCount = cnt + return nil + } + buildSitesFunc := func() error { + if err := c.buildSites(noBuildLock); err != nil { + return fmt.Errorf("error building site: %w", err) + } + return nil + } + // Do not copy static files and build sites in parallel if cleanDestinationDir is enabled. + // This flag deletes all static resources in /public folder that are missing in /static, + // and it does so at the end of copyStatic() call. + var cleanDestinationDir bool + c.withConf(func(conf *commonConfig) { + cleanDestinationDir = conf.configs.Base.CleanDestinationDir + }) + if cleanDestinationDir { + if err := copyStaticFunc(); err != nil { + return err + } + if err := buildSitesFunc(); err != nil { + return err + } + } else { + g.Go(copyStaticFunc) + g.Go(buildSitesFunc) + if err := g.Wait(); err != nil { + return err + } + } + + h, err := c.hugo() + if err != nil { + return err + } + for _, s := range h.Sites { + s.ProcessingStats.Static = langCount[s.Language().Lang] + } + + if c.r.gc { + count, err := h.GC() + if err != nil { + return err + } + for _, s := range h.Sites { + // We have no way of knowing what site the garbage belonged to. + s.ProcessingStats.Cleaned = uint64(count) + } + } + + return nil +} + +func (c *hugoBuilder) fullRebuild(changeType string) { + if changeType == configChangeGoMod { + // go.mod may be changed during the build itself, and + // we really want to prevent superfluous builds. + if !c.fullRebuildSem.TryAcquire(1) { + return + } + c.fullRebuildSem.Release(1) + } + + c.fullRebuildSem.Acquire(context.Background(), 1) + + go func() { + defer c.fullRebuildSem.Release(1) + + c.printChangeDetected(changeType) + + defer func() { + // Allow any file system events to arrive basimplecobra. + // This will block any rebuild on config changes for the + // duration of the sleep. + time.Sleep(2 * time.Second) + }() + + defer c.postBuild("Rebuilt", time.Now()) + + err := c.reloadConfig() + if err != nil { + // Set the processing on pause until the state is recovered. + c.errState.setPaused(true) + c.handleBuildErr(err, "Failed to reload config") + if c.s.doLiveReload { + livereload.ForceRefresh() + } + } else { + c.errState.setPaused(false) + } + + if !c.errState.isPaused() { + _, err := c.copyStatic() + if err != nil { + c.r.logger.Errorln(err) + return + } + err = c.buildSites(false) + if err != nil { + c.r.logger.Errorln(err) + } else if c.s != nil && c.s.doLiveReload { + livereload.ForceRefresh() + } + } + }() +} + +func (c *hugoBuilder) handleBuildErr(err error, msg string) { + c.errState.setBuildErr(err) + c.r.logger.Errorln(msg + ": " + cleanErrorLog(err.Error())) +} + +func (c *hugoBuilder) handleEvents(watcher *watcher.Batcher, + staticSyncer *staticSyncer, + evs []fsnotify.Event, + configSet map[string]bool, +) { + defer func() { + c.errState.setWasErr(false) + }() + + var isHandled bool + + // Filter out ghost events (from deleted, renamed directories). + // This seems to be a bug in fsnotify, or possibly MacOS. + var n int + for _, ev := range evs { + keep := true + // Write and rename operations are often followed by CHMOD. + // There may be valid use cases for rebuilding the site on CHMOD, + // but that will require more complex logic than this simple conditional. + // On OS X this seems to be related to Spotlight, see: + // https://github.com/go-fsnotify/fsnotify/issues/15 + // A workaround is to put your site(s) on the Spotlight exception list, + // but that may be a little mysterious for most end users. + // So, for now, we skip reload on CHMOD. + // We do have to check for WRITE though. On slower laptops a Chmod + // could be aggregated with other important events, and we still want + // to rebuild on those + if ev.Op == fsnotify.Chmod { + keep = false + } else if ev.Has(fsnotify.Create) || ev.Has(fsnotify.Write) { + if _, err := os.Stat(ev.Name); err != nil { + keep = false + } + } + if keep { + evs[n] = ev + n++ + } + } + evs = evs[:n] + + for _, ev := range evs { + isConfig := configSet[ev.Name] + configChangeType := configChangeConfig + if isConfig { + if strings.Contains(ev.Name, "go.mod") { + configChangeType = configChangeGoMod + } + if strings.Contains(ev.Name, ".work") { + configChangeType = configChangeGoWork + } + } + if !isConfig { + // It may be one of the /config folders + dirname := filepath.Dir(ev.Name) + if dirname != "." && configSet[dirname] { + isConfig = true + } + } + + if isConfig { + isHandled = true + + if ev.Op&fsnotify.Chmod == fsnotify.Chmod { + continue + } + + if ev.Op&fsnotify.Remove == fsnotify.Remove || ev.Op&fsnotify.Rename == fsnotify.Rename { + c.withConf(func(conf *commonConfig) { + for _, configFile := range conf.configs.LoadingInfo.ConfigFiles { + counter := 0 + for watcher.Add(configFile) != nil { + counter++ + if counter >= 100 { + break + } + time.Sleep(100 * time.Millisecond) + } + } + }) + } + + // Config file(s) changed. Need full rebuild. + c.fullRebuild(configChangeType) + + return + } + } + + if isHandled { + return + } + + if c.errState.isPaused() { + // Wait for the server to get into a consistent state before + // we continue with processing. + return + } + + if len(evs) > 50 { + // This is probably a mass edit of the content dir. + // Schedule a full rebuild for when it slows down. + c.debounce(func() { + c.fullRebuild("") + }) + return + } + + c.r.logger.Debugln("Received System Events:", evs) + + staticEvents := []fsnotify.Event{} + dynamicEvents := []fsnotify.Event{} + + filterDuplicateEvents := func(evs []fsnotify.Event) []fsnotify.Event { + seen := make(map[string]bool) + var n int + for _, ev := range evs { + if seen[ev.Name] { + continue + } + seen[ev.Name] = true + evs[n] = ev + n++ + } + return evs[:n] + } + + h, err := c.hugo() + if err != nil { + c.r.logger.Errorln("Error getting the Hugo object:", err) + return + } + n = 0 + for _, ev := range evs { + if h.ShouldSkipFileChangeEvent(ev) { + continue + } + evs[n] = ev + n++ + } + evs = evs[:n] + + for _, ev := range evs { + ext := filepath.Ext(ev.Name) + baseName := filepath.Base(ev.Name) + istemp := strings.HasSuffix(ext, "~") || + (ext == ".swp") || // vim + (ext == ".swx") || // vim + (ext == ".bck") || // helix + (ext == ".tmp") || // generic temp file + (ext == ".DS_Store") || // OSX Thumbnail + baseName == "4913" || // vim + strings.HasPrefix(ext, ".goutputstream") || // gnome + strings.HasSuffix(ext, "jb_old___") || // intelliJ + strings.HasSuffix(ext, "jb_tmp___") || // intelliJ + strings.HasSuffix(ext, "jb_bak___") || // intelliJ + strings.HasPrefix(ext, ".sb-") || // byword + strings.HasPrefix(baseName, ".#") || // emacs + strings.HasPrefix(baseName, "#") // emacs + if istemp { + continue + } + + if h.Deps.SourceSpec.IgnoreFile(ev.Name) { + continue + } + // Sometimes during rm -rf operations a '"": REMOVE' is triggered. Just ignore these + if ev.Name == "" { + continue + } + + walkAdder := func(path string, f hugofs.FileMetaInfo) error { + if f.IsDir() { + c.r.logger.Println("adding created directory to watchlist", path) + if err := watcher.Add(path); err != nil { + return err + } + } else if !staticSyncer.isStatic(h, path) { + // Hugo's rebuilding logic is entirely file based. When you drop a new folder into + // /content on OSX, the above logic will handle future watching of those files, + // but the initial CREATE is lost. + dynamicEvents = append(dynamicEvents, fsnotify.Event{Name: path, Op: fsnotify.Create}) + } + return nil + } + + // recursively add new directories to watch list + if ev.Has(fsnotify.Create) || ev.Has(fsnotify.Rename) { + c.withConf(func(conf *commonConfig) { + if s, err := conf.fs.Source.Stat(ev.Name); err == nil && s.Mode().IsDir() { + _ = helpers.Walk(conf.fs.Source, ev.Name, walkAdder) + } + }) + } + + if staticSyncer.isStatic(h, ev.Name) { + staticEvents = append(staticEvents, ev) + } else { + dynamicEvents = append(dynamicEvents, ev) + } + } + + lrl := c.r.logger.InfoCommand("livereload") + + staticEvents = filterDuplicateEvents(staticEvents) + dynamicEvents = filterDuplicateEvents(dynamicEvents) + + if len(staticEvents) > 0 { + c.printChangeDetected("Static files") + + if c.r.forceSyncStatic { + c.r.logger.Printf("Syncing all static files\n") + _, err := c.copyStatic() + if err != nil { + c.r.logger.Errorln("Error copying static files to publish dir:", err) + return + } + } else { + if err := staticSyncer.syncsStaticEvents(staticEvents); err != nil { + c.r.logger.Errorln("Error syncing static files to publish dir:", err) + return + } + } + + if c.s != nil && c.s.doLiveReload { + // Will block forever trying to write to a channel that nobody is reading if livereload isn't initialized + + if !c.errState.wasErr() && len(staticEvents) == 1 { + h, err := c.hugo() + if err != nil { + c.r.logger.Errorln("Error getting the Hugo object:", err) + return + } + + path := h.BaseFs.SourceFilesystems.MakeStaticPathRelative(staticEvents[0].Name) + path = h.RelURL(paths.ToSlashTrimLeading(path), false) + + lrl.Logf("refreshing static file %q", path) + livereload.RefreshPath(path) + } else { + lrl.Logf("got %d static file change events, force refresh", len(staticEvents)) + livereload.ForceRefresh() + } + } + } + + if len(dynamicEvents) > 0 { + partitionedEvents := partitionDynamicEvents( + h.BaseFs.SourceFilesystems, + dynamicEvents) + + onePageName := pickOneWriteOrCreatePath(h.Conf.ContentTypes(), partitionedEvents.ContentEvents) + + c.printChangeDetected("") + c.changeDetector.PrepareNew() + + func() { + defer c.postBuild("Total", time.Now()) + if err := c.rebuildSites(dynamicEvents); err != nil { + c.handleBuildErr(err, "Rebuild failed") + } + }() + + if c.s != nil && c.s.doLiveReload { + if c.errState.wasErr() { + livereload.ForceRefresh() + return + } + + changed := c.changeDetector.changed() + if c.changeDetector != nil { + if len(changed) >= 10 { + lrl.Logf("build changed %d files", len(changed)) + } else { + lrl.Logf("build changed %d files: %q", len(changed), changed) + } + if len(changed) == 0 { + // Nothing has changed. + return + } + } + + // If this change set also contains one or more CSS files, we need to + // refresh these as well. + var cssChanges []string + var otherChanges []string + + for _, ev := range changed { + if strings.HasSuffix(ev, ".css") { + cssChanges = append(cssChanges, ev) + } else { + otherChanges = append(otherChanges, ev) + } + } + + if len(partitionedEvents.ContentEvents) > 0 { + navigate := c.s != nil && c.s.navigateToChanged + // We have fetched the same page above, but it may have + // changed. + var p page.Page + + if navigate { + if onePageName != "" { + p = h.GetContentPage(onePageName) + } + } + + if p != nil && p.RelPermalink() != "" { + link, port := p.RelPermalink(), p.Site().ServerPort() + lrl.Logf("navigating to %q using port %d", link, port) + livereload.NavigateToPathForPort(link, port) + } else { + lrl.Logf("no page to navigate to, force refresh") + livereload.ForceRefresh() + } + } else if len(otherChanges) > 0 { + if len(otherChanges) == 1 { + // Allow single changes to be refreshed without a full page reload. + pathToRefresh := h.PathSpec.RelURL(paths.ToSlashTrimLeading(otherChanges[0]), false) + lrl.Logf("refreshing %q", pathToRefresh) + livereload.RefreshPath(pathToRefresh) + } else if len(cssChanges) == 0 || len(otherChanges) > 1 { + lrl.Logf("force refresh") + livereload.ForceRefresh() + } + } else { + lrl.Logf("force refresh") + livereload.ForceRefresh() + } + + if len(cssChanges) > 0 { + // Allow some time for the live reload script to get reconnected. + if len(otherChanges) > 0 { + time.Sleep(200 * time.Millisecond) + } + for _, ev := range cssChanges { + pathToRefresh := h.PathSpec.RelURL(paths.ToSlashTrimLeading(ev), false) + lrl.Logf("refreshing CSS %q", pathToRefresh) + livereload.RefreshPath(pathToRefresh) + } + } + } + } +} + +func (c *hugoBuilder) postBuild(what string, start time.Time) { + if h, err := c.hugo(); err == nil && h.Conf.Running() { + h.LogServerAddresses() + } + c.r.timeTrack(start, what) +} + +func (c *hugoBuilder) hugo() (*hugolib.HugoSites, error) { + var h *hugolib.HugoSites + if err := c.withConfE(func(conf *commonConfig) error { + var err error + h, err = c.r.HugFromConfig(conf) + return err + }); err != nil { + return nil, err + } + + if c.s != nil { + // A running server, register the media types. + for _, s := range h.Sites { + s.RegisterMediaTypes() + } + } + return h, nil +} + +func (c *hugoBuilder) hugoTry() *hugolib.HugoSites { + var h *hugolib.HugoSites + c.withConf(func(conf *commonConfig) { + h, _ = c.r.HugFromConfig(conf) + }) + return h +} + +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 + } + } + } + cfg.Set("environment", c.r.environment) + + cfg.Set("internal", maps.Params{ + "running": running, + "watch": watch, + "verbose": c.r.isVerbose(), + "fastRenderMode": c.fastRenderMode, + }) + + conf, err := c.r.ConfigFromProvider(configKey{counter: c.r.configVersionID.Load()}, flagsToCfg(cd, cfg)) + if err != nil { + return err + } + + if len(conf.configs.LoadingInfo.ConfigFiles) == 0 { + //lint:ignore ST1005 end user message. + return errors.New("Unable to locate config file or config directory. Perhaps you need to create a new site.\nRun `hugo help new` for details.") + } + + c.conf = conf + if c.onConfigLoaded != nil { + if err := c.onConfigLoaded(false); err != nil { + return err + } + } + + return nil +} + +var rebuildCounter atomic.Uint64 + +func (c *hugoBuilder) printChangeDetected(typ string) { + msg := "\nChange" + if typ != "" { + msg += " of " + typ + } + msg += fmt.Sprintf(" detected, rebuilding site (#%d).", rebuildCounter.Add(1)) + + c.r.logger.Println(msg) + const layout = "2006-01-02 15:04:05.000 -0700" + c.r.logger.Println(htime.Now().Format(layout)) +} + +func (c *hugoBuilder) rebuildSites(events []fsnotify.Event) (err error) { + defer func() { + c.errState.setBuildErr(err) + }() + if err := c.errState.buildErr(); err != nil { + ferrs := herrors.UnwrapFileErrorsWithErrorContext(err) + for _, err := range ferrs { + events = append(events, fsnotify.Event{Name: err.Position().Filename, Op: fsnotify.Write}) + } + } + var h *hugolib.HugoSites + h, err = c.hugo() + if err != nil { + return + } + err = h.Build(hugolib.BuildCfg{NoBuildLock: true, RecentlyTouched: c.visitedURLs, ErrRecovery: c.errState.wasErr()}, events...) + return +} + +func (c *hugoBuilder) rebuildSitesForChanges(ids []identity.Identity) (err error) { + defer func() { + c.errState.setBuildErr(err) + }() + + var h *hugolib.HugoSites + h, err = c.hugo() + if err != nil { + return + } + whatChanged := &hugolib.WhatChanged{} + whatChanged.Add(ids...) + err = h.Build(hugolib.BuildCfg{NoBuildLock: true, WhatChanged: whatChanged, RecentlyTouched: c.visitedURLs, ErrRecovery: c.errState.wasErr()}) + + return +} + +func (c *hugoBuilder) reloadConfig() error { + c.r.resetLogs() + c.r.configVersionID.Add(1) + + if err := c.withConfE(func(conf *commonConfig) error { + oldConf := conf + newConf, err := c.r.ConfigFromConfig(configKey{counter: c.r.configVersionID.Load()}, conf) + if err != nil { + return err + } + sameLen := len(oldConf.configs.Languages) == len(newConf.configs.Languages) + if !sameLen { + if oldConf.configs.IsMultihost || newConf.configs.IsMultihost { + return errors.New("multihost change detected, please restart server") + } + } + c.conf = newConf + return nil + }); err != nil { + return err + } + + if c.onConfigLoaded != nil { + if err := c.onConfigLoaded(true); err != nil { + return err + } + } + + return nil +} diff --git a/commands/import.go b/commands/import.go new file mode 100644 index 000000000..37a6b0dbf --- /dev/null +++ b/commands/import.go @@ -0,0 +1,618 @@ +// Copyright 2024 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 commands + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + "unicode" + + "github.com/bep/simplecobra" + "github.com/gohugoio/hugo/common/htime" + "github.com/gohugoio/hugo/common/hugio" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/helpers" + "github.com/gohugoio/hugo/hugofs" + "github.com/gohugoio/hugo/parser" + "github.com/gohugoio/hugo/parser/metadecoders" + "github.com/gohugoio/hugo/parser/pageparser" + "github.com/spf13/afero" + "github.com/spf13/cobra" +) + +func newImportCommand() *importCommand { + var c *importCommand + c = &importCommand{ + commands: []simplecobra.Commander{ + &simpleCommand{ + name: "jekyll", + short: "hugo import from Jekyll", + long: `hugo import from Jekyll. + +Import from Jekyll requires two paths, e.g. ` + "`hugo import jekyll jekyll_root_path target_path`.", + run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error { + if len(args) < 2 { + return newUserError(`import from jekyll requires two paths, e.g. ` + "`hugo import jekyll jekyll_root_path target_path`.") + } + return c.importFromJekyll(args) + }, + withc: func(cmd *cobra.Command, r *rootCommand) { + cmd.ValidArgsFunction = cobra.NoFileCompletions + cmd.Flags().BoolVar(&c.force, "force", false, "allow import into non-empty target directory") + }, + }, + }, + } + + return c +} + +type importCommand struct { + r *rootCommand + + force bool + + commands []simplecobra.Commander +} + +func (c *importCommand) Commands() []simplecobra.Commander { + return c.commands +} + +func (c *importCommand) Name() string { + return "import" +} + +func (c *importCommand) Run(ctx context.Context, cd *simplecobra.Commandeer, args []string) error { + return nil +} + +func (c *importCommand) Init(cd *simplecobra.Commandeer) error { + cmd := cd.CobraCommand + cmd.Short = "Import a site from another system" + cmd.Long = `Import a site from another system. + +Import requires a subcommand, e.g. ` + "`hugo import jekyll jekyll_root_path target_path`." + + cmd.RunE = nil + return nil +} + +func (c *importCommand) PreRun(cd, runner *simplecobra.Commandeer) error { + c.r = cd.Root.Command.(*rootCommand) + return nil +} + +func (i *importCommand) createConfigFromJekyll(fs afero.Fs, inpath string, kind metadecoders.Format, jekyllConfig map[string]any) (err error) { + title := "My New Hugo Site" + baseURL := "http://example.org/" + + for key, value := range jekyllConfig { + lowerKey := strings.ToLower(key) + + switch lowerKey { + case "title": + if str, ok := value.(string); ok { + title = str + } + + case "url": + if str, ok := value.(string); ok { + baseURL = str + } + } + } + + in := map[string]any{ + "baseURL": baseURL, + "title": title, + "languageCode": "en-us", + "disablePathToLower": true, + } + + var buf bytes.Buffer + err = parser.InterfaceToConfig(in, kind, &buf) + if err != nil { + return err + } + + return helpers.WriteToDisk(filepath.Join(inpath, "hugo."+string(kind)), &buf, fs) +} + +func (c *importCommand) getJekyllDirInfo(fs afero.Fs, jekyllRoot string) (map[string]bool, bool) { + postDirs := make(map[string]bool) + hasAnyPost := false + if entries, err := os.ReadDir(jekyllRoot); err == nil { + for _, entry := range entries { + if entry.IsDir() { + subDir := filepath.Join(jekyllRoot, entry.Name()) + if isPostDir, hasAnyPostInDir := c.retrieveJekyllPostDir(fs, subDir); isPostDir { + postDirs[entry.Name()] = hasAnyPostInDir + if hasAnyPostInDir { + hasAnyPost = true + } + } + } + } + } + return postDirs, hasAnyPost +} + +func (c *importCommand) createSiteFromJekyll(jekyllRoot, targetDir string, jekyllPostDirs map[string]bool) error { + fs := &afero.OsFs{} + if exists, _ := helpers.Exists(targetDir, fs); exists { + if isDir, _ := helpers.IsDir(targetDir, fs); !isDir { + return errors.New("target path \"" + targetDir + "\" exists but is not a directory") + } + + isEmpty, _ := helpers.IsEmpty(targetDir, fs) + + if !isEmpty && !c.force { + return errors.New("target path \"" + targetDir + "\" exists and is not empty") + } + } + + jekyllConfig := c.loadJekyllConfig(fs, jekyllRoot) + + mkdir(targetDir, "layouts") + mkdir(targetDir, "content") + mkdir(targetDir, "archetypes") + mkdir(targetDir, "static") + mkdir(targetDir, "data") + mkdir(targetDir, "themes") + + c.createConfigFromJekyll(fs, targetDir, "yaml", jekyllConfig) + + c.copyJekyllFilesAndFolders(jekyllRoot, filepath.Join(targetDir, "static"), jekyllPostDirs) + + return nil +} + +func (c *importCommand) convertJekyllContent(m any, content string) (string, error) { + metadata, _ := maps.ToStringMapE(m) + + lines := strings.Split(content, "\n") + var resultLines []string + for _, line := range lines { + resultLines = append(resultLines, strings.Trim(line, "\r\n")) + } + + content = strings.Join(resultLines, "\n") + + excerptSep := "" + if value, ok := metadata["excerpt_separator"]; ok { + if str, strOk := value.(string); strOk { + content = strings.Replace(content, strings.TrimSpace(str), excerptSep, -1) + } + } + + replaceList := []struct { + re *regexp.Regexp + replace string + }{ + {regexp.MustCompile("(?i)"), ""}, + {regexp.MustCompile(`\{%\s*raw\s*%\}\s*(.*?)\s*\{%\s*endraw\s*%\}`), "$1"}, + {regexp.MustCompile(`{%\s*endhighlight\s*%}`), "{{< / highlight >}}"}, + } + + for _, replace := range replaceList { + content = replace.re.ReplaceAllString(content, replace.replace) + } + + replaceListFunc := []struct { + re *regexp.Regexp + replace func(string) string + }{ + // Octopress image tag: http://octopress.org/docs/plugins/image-tag/ + {regexp.MustCompile(`{%\s+img\s*(.*?)\s*%}`), c.replaceImageTag}, + {regexp.MustCompile(`{%\s*highlight\s*(.*?)\s*%}`), c.replaceHighlightTag}, + } + + for _, replace := range replaceListFunc { + content = replace.re.ReplaceAllStringFunc(content, replace.replace) + } + + var buf bytes.Buffer + if len(metadata) != 0 { + err := parser.InterfaceToFrontMatter(m, metadecoders.YAML, &buf) + if err != nil { + return "", err + } + } + buf.WriteString(content) + + return buf.String(), nil +} + +func (c *importCommand) convertJekyllMetaData(m any, postName string, postDate time.Time, draft bool) (any, error) { + metadata, err := maps.ToStringMapE(m) + if err != nil { + return nil, err + } + + if draft { + metadata["draft"] = true + } + + for key, value := range metadata { + lowerKey := strings.ToLower(key) + + switch lowerKey { + case "layout": + delete(metadata, key) + case "permalink": + if str, ok := value.(string); ok { + metadata["url"] = str + } + delete(metadata, key) + case "category": + if str, ok := value.(string); ok { + metadata["categories"] = []string{str} + } + delete(metadata, key) + case "excerpt_separator": + if key != lowerKey { + delete(metadata, key) + metadata[lowerKey] = value + } + case "date": + if str, ok := value.(string); ok { + re := regexp.MustCompile(`(\d+):(\d+):(\d+)`) + r := re.FindAllStringSubmatch(str, -1) + if len(r) > 0 { + hour, _ := strconv.Atoi(r[0][1]) + minute, _ := strconv.Atoi(r[0][2]) + second, _ := strconv.Atoi(r[0][3]) + postDate = time.Date(postDate.Year(), postDate.Month(), postDate.Day(), hour, minute, second, 0, time.UTC) + } + } + delete(metadata, key) + } + + } + + metadata["date"] = postDate.Format(time.RFC3339) + + return metadata, nil +} + +func (c *importCommand) convertJekyllPost(path, relPath, targetDir string, draft bool) error { + log.Println("Converting", path) + + filename := filepath.Base(path) + postDate, postName, err := c.parseJekyllFilename(filename) + if err != nil { + c.r.Printf("Failed to parse filename '%s': %s. Skipping.", filename, err) + return nil + } + + log.Println(filename, postDate, postName) + + targetFile := filepath.Join(targetDir, relPath) + targetParentDir := filepath.Dir(targetFile) + os.MkdirAll(targetParentDir, 0o777) + + contentBytes, err := os.ReadFile(path) + if err != nil { + c.r.logger.Errorln("Read file error:", path) + return err + } + pf, err := pageparser.ParseFrontMatterAndContent(bytes.NewReader(contentBytes)) + if err != nil { + return fmt.Errorf("failed to parse file %q: %s", filename, err) + } + newmetadata, err := c.convertJekyllMetaData(pf.FrontMatter, postName, postDate, draft) + if err != nil { + return fmt.Errorf("failed to convert metadata for file %q: %s", filename, err) + } + + content, err := c.convertJekyllContent(newmetadata, string(pf.Content)) + if err != nil { + return fmt.Errorf("failed to convert content for file %q: %s", filename, err) + } + + fs := hugofs.Os + if err := helpers.WriteToDisk(targetFile, strings.NewReader(content), fs); err != nil { + return fmt.Errorf("failed to save file %q: %s", filename, err) + } + return nil +} + +func (c *importCommand) copyJekyllFilesAndFolders(jekyllRoot, dest string, jekyllPostDirs map[string]bool) (err error) { + fs := hugofs.Os + + fi, err := fs.Stat(jekyllRoot) + if err != nil { + return err + } + if !fi.IsDir() { + return errors.New(jekyllRoot + " is not a directory") + } + err = os.MkdirAll(dest, fi.Mode()) + if err != nil { + return err + } + entries, err := os.ReadDir(jekyllRoot) + if err != nil { + return err + } + + for _, entry := range entries { + sfp := filepath.Join(jekyllRoot, entry.Name()) + dfp := filepath.Join(dest, entry.Name()) + if entry.IsDir() { + if entry.Name()[0] != '_' && entry.Name()[0] != '.' { + if _, ok := jekyllPostDirs[entry.Name()]; !ok { + err = hugio.CopyDir(fs, sfp, dfp, nil) + if err != nil { + c.r.logger.Errorln(err) + } + } + } + } else { + lowerEntryName := strings.ToLower(entry.Name()) + exceptSuffix := []string{ + ".md", ".markdown", ".html", ".htm", + ".xml", ".textile", "rakefile", "gemfile", ".lock", + } + isExcept := false + for _, suffix := range exceptSuffix { + if strings.HasSuffix(lowerEntryName, suffix) { + isExcept = true + break + } + } + + if !isExcept && entry.Name()[0] != '.' && entry.Name()[0] != '_' { + err = hugio.CopyFile(fs, sfp, dfp) + if err != nil { + c.r.logger.Errorln(err) + } + } + } + + } + return nil +} + +func (c *importCommand) importFromJekyll(args []string) error { + jekyllRoot, err := filepath.Abs(filepath.Clean(args[0])) + if err != nil { + return newUserError("path error:", args[0]) + } + + targetDir, err := filepath.Abs(filepath.Clean(args[1])) + if err != nil { + return newUserError("path error:", args[1]) + } + + c.r.Println("Import Jekyll from:", jekyllRoot, "to:", targetDir) + + if strings.HasPrefix(filepath.Dir(targetDir), jekyllRoot) { + return newUserError("abort: target path should not be inside the Jekyll root") + } + + fs := afero.NewOsFs() + jekyllPostDirs, hasAnyPost := c.getJekyllDirInfo(fs, jekyllRoot) + if !hasAnyPost { + return errors.New("abort: jekyll root contains neither posts nor drafts") + } + + err = c.createSiteFromJekyll(jekyllRoot, targetDir, jekyllPostDirs) + if err != nil { + return newUserError(err) + } + + c.r.Println("Importing...") + + fileCount := 0 + callback := func(path string, fi hugofs.FileMetaInfo) error { + if fi.IsDir() { + return nil + } + + relPath, err := filepath.Rel(jekyllRoot, path) + if err != nil { + return newUserError("get rel path error:", path) + } + + relPath = filepath.ToSlash(relPath) + draft := false + + switch { + case strings.Contains(relPath, "_posts/"): + relPath = filepath.Join("content/post", strings.Replace(relPath, "_posts/", "", -1)) + case strings.Contains(relPath, "_drafts/"): + relPath = filepath.Join("content/draft", strings.Replace(relPath, "_drafts/", "", -1)) + draft = true + default: + return nil + } + + fileCount++ + return c.convertJekyllPost(path, relPath, targetDir, draft) + } + + for jekyllPostDir, hasAnyPostInDir := range jekyllPostDirs { + if hasAnyPostInDir { + if err = helpers.Walk(hugofs.Os, filepath.Join(jekyllRoot, jekyllPostDir), callback); err != nil { + return err + } + } + } + + c.r.Println("Congratulations!", fileCount, "post(s) imported!") + c.r.Println("Now, start Hugo by yourself:\n") + 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("hugo server") + + return nil +} + +func (c *importCommand) loadJekyllConfig(fs afero.Fs, jekyllRoot string) map[string]any { + path := filepath.Join(jekyllRoot, "_config.yml") + + exists, err := helpers.Exists(path, fs) + + if err != nil || !exists { + c.r.Println("_config.yaml not found: Is the specified Jekyll root correct?") + return nil + } + + 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 +} + +func (c *importCommand) parseJekyllFilename(filename string) (time.Time, string, error) { + re := regexp.MustCompile(`(\d+-\d+-\d+)-(.+)\..*`) + r := re.FindAllStringSubmatch(filename, -1) + if len(r) == 0 { + return htime.Now(), "", errors.New("filename not match") + } + + postDate, err := time.Parse("2006-1-2", r[0][1]) + if err != nil { + return htime.Now(), "", err + } + + postName := r[0][2] + + return postDate, postName, nil +} + +func (c *importCommand) replaceHighlightTag(match string) string { + r := regexp.MustCompile(`{%\s*highlight\s*(.*?)\s*%}`) + parts := r.FindStringSubmatch(match) + lastQuote := rune(0) + f := func(c rune) bool { + switch { + case c == lastQuote: + lastQuote = rune(0) + return false + case lastQuote != rune(0): + return false + case unicode.In(c, unicode.Quotation_Mark): + lastQuote = c + return false + default: + return unicode.IsSpace(c) + } + } + // splitting string by space but considering quoted section + items := strings.FieldsFunc(parts[1], f) + + result := bytes.NewBufferString("{{< highlight ") + result.WriteString(items[0]) // language + options := items[1:] + for i, opt := range options { + opt = strings.Replace(opt, "\"", "", -1) + if opt == "linenos" { + opt = "linenos=table" + } + if i == 0 { + opt = " \"" + opt + } + if i < len(options)-1 { + opt += "," + } else if i == len(options)-1 { + opt += "\"" + } + result.WriteString(opt) + } + + result.WriteString(" >}}") + return result.String() +} + +func (c *importCommand) replaceImageTag(match string) string { + r := regexp.MustCompile(`{%\s+img\s*(\p{L}*)\s+([\S]*/[\S]+)\s+(\d*)\s*(\d*)\s*(.*?)\s*%}`) + result := bytes.NewBufferString("{{< figure ") + parts := r.FindStringSubmatch(match) + // Index 0 is the entire string, ignore + c.replaceOptionalPart(result, "class", parts[1]) + c.replaceOptionalPart(result, "src", parts[2]) + c.replaceOptionalPart(result, "width", parts[3]) + c.replaceOptionalPart(result, "height", parts[4]) + // title + alt + part := parts[5] + if len(part) > 0 { + splits := strings.Split(part, "'") + lenSplits := len(splits) + if lenSplits == 1 { + c.replaceOptionalPart(result, "title", splits[0]) + } else if lenSplits == 3 { + c.replaceOptionalPart(result, "title", splits[1]) + } else if lenSplits == 5 { + c.replaceOptionalPart(result, "title", splits[1]) + c.replaceOptionalPart(result, "alt", splits[3]) + } + } + result.WriteString(">}}") + return result.String() +} + +func (c *importCommand) replaceOptionalPart(buffer *bytes.Buffer, partName string, part string) { + if len(part) > 0 { + buffer.WriteString(partName + "=\"" + part + "\" ") + } +} + +func (c *importCommand) retrieveJekyllPostDir(fs afero.Fs, dir string) (bool, bool) { + if strings.HasSuffix(dir, "_posts") || strings.HasSuffix(dir, "_drafts") { + isEmpty, _ := helpers.IsEmpty(dir, fs) + return true, !isEmpty + } + + if entries, err := os.ReadDir(dir); err == nil { + for _, entry := range entries { + if entry.IsDir() { + subDir := filepath.Join(dir, entry.Name()) + if isPostDir, hasAnyPost := c.retrieveJekyllPostDir(fs, subDir); isPostDir { + return isPostDir, hasAnyPost + } + } + } + } + + return false, true +} diff --git a/commands/import_jekyll.go b/commands/import_jekyll.go deleted file mode 100644 index d2e7b27da..000000000 --- a/commands/import_jekyll.go +++ /dev/null @@ -1,609 +0,0 @@ -// Copyright 2019 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 commands - -import ( - "bytes" - "errors" - "fmt" - "io/ioutil" - "os" - "path/filepath" - "regexp" - "strconv" - "strings" - "time" - "unicode" - - "github.com/gohugoio/hugo/common/hugio" - - "github.com/gohugoio/hugo/parser/metadecoders" - - "github.com/gohugoio/hugo/common/maps" - "github.com/gohugoio/hugo/helpers" - "github.com/gohugoio/hugo/hugofs" - "github.com/gohugoio/hugo/hugolib" - "github.com/gohugoio/hugo/parser" - "github.com/spf13/afero" - "github.com/spf13/cobra" - jww "github.com/spf13/jwalterweatherman" -) - -var _ cmder = (*importCmd)(nil) - -type importCmd struct { - *baseCmd -} - -func newImportCmd() *importCmd { - cc := &importCmd{} - - cc.baseCmd = newBaseCmd(&cobra.Command{ - Use: "import", - Short: "Import your site from others.", - Long: `Import your site from other web site generators like Jekyll. - -Import requires a subcommand, e.g. ` + "`hugo import jekyll jekyll_root_path target_path`.", - RunE: nil, - }) - - importJekyllCmd := &cobra.Command{ - Use: "jekyll", - Short: "hugo import from Jekyll", - Long: `hugo import from Jekyll. - -Import from Jekyll requires two paths, e.g. ` + "`hugo import jekyll jekyll_root_path target_path`.", - RunE: cc.importFromJekyll, - } - - importJekyllCmd.Flags().Bool("force", false, "allow import into non-empty target directory") - - cc.cmd.AddCommand(importJekyllCmd) - - return cc - -} - -func (i *importCmd) importFromJekyll(cmd *cobra.Command, args []string) error { - - if len(args) < 2 { - return newUserError(`import from jekyll requires two paths, e.g. ` + "`hugo import jekyll jekyll_root_path target_path`.") - } - - jekyllRoot, err := filepath.Abs(filepath.Clean(args[0])) - if err != nil { - return newUserError("path error:", args[0]) - } - - targetDir, err := filepath.Abs(filepath.Clean(args[1])) - if err != nil { - return newUserError("path error:", args[1]) - } - - jww.INFO.Println("Import Jekyll from:", jekyllRoot, "to:", targetDir) - - if strings.HasPrefix(filepath.Dir(targetDir), jekyllRoot) { - return newUserError("abort: target path should not be inside the Jekyll root") - } - - forceImport, _ := cmd.Flags().GetBool("force") - - fs := afero.NewOsFs() - jekyllPostDirs, hasAnyPost := i.getJekyllDirInfo(fs, jekyllRoot) - if !hasAnyPost { - return errors.New("abort: jekyll root contains neither posts nor drafts") - } - - err = i.createSiteFromJekyll(jekyllRoot, targetDir, jekyllPostDirs, forceImport) - - if err != nil { - return newUserError(err) - } - - jww.FEEDBACK.Println("Importing...") - - fileCount := 0 - callback := func(path string, fi hugofs.FileMetaInfo, err error) error { - if err != nil { - return err - } - - if fi.IsDir() { - return nil - } - - relPath, err := filepath.Rel(jekyllRoot, path) - if err != nil { - return newUserError("get rel path error:", path) - } - - relPath = filepath.ToSlash(relPath) - draft := false - - switch { - case strings.Contains(relPath, "_posts/"): - relPath = filepath.Join("content/post", strings.Replace(relPath, "_posts/", "", -1)) - case strings.Contains(relPath, "_drafts/"): - relPath = filepath.Join("content/draft", strings.Replace(relPath, "_drafts/", "", -1)) - draft = true - default: - return nil - } - - fileCount++ - return convertJekyllPost(path, relPath, targetDir, draft) - } - - for jekyllPostDir, hasAnyPostInDir := range jekyllPostDirs { - if hasAnyPostInDir { - if err = helpers.SymbolicWalk(hugofs.Os, filepath.Join(jekyllRoot, jekyllPostDir), callback); err != nil { - return err - } - } - } - - jww.FEEDBACK.Println("Congratulations!", fileCount, "post(s) imported!") - jww.FEEDBACK.Println("Now, start Hugo by yourself:\n" + - "$ git clone https://github.com/spf13/herring-cove.git " + args[1] + "/themes/herring-cove") - jww.FEEDBACK.Println("$ cd " + args[1] + "\n$ hugo server --theme=herring-cove") - - return nil -} - -func (i *importCmd) getJekyllDirInfo(fs afero.Fs, jekyllRoot string) (map[string]bool, bool) { - postDirs := make(map[string]bool) - hasAnyPost := false - if entries, err := ioutil.ReadDir(jekyllRoot); err == nil { - for _, entry := range entries { - if entry.IsDir() { - subDir := filepath.Join(jekyllRoot, entry.Name()) - if isPostDir, hasAnyPostInDir := i.retrieveJekyllPostDir(fs, subDir); isPostDir { - postDirs[entry.Name()] = hasAnyPostInDir - if hasAnyPostInDir { - hasAnyPost = true - } - } - } - } - } - return postDirs, hasAnyPost -} - -func (i *importCmd) retrieveJekyllPostDir(fs afero.Fs, dir string) (bool, bool) { - if strings.HasSuffix(dir, "_posts") || strings.HasSuffix(dir, "_drafts") { - isEmpty, _ := helpers.IsEmpty(dir, fs) - return true, !isEmpty - } - - if entries, err := ioutil.ReadDir(dir); err == nil { - for _, entry := range entries { - if entry.IsDir() { - subDir := filepath.Join(dir, entry.Name()) - if isPostDir, hasAnyPost := i.retrieveJekyllPostDir(fs, subDir); isPostDir { - return isPostDir, hasAnyPost - } - } - } - } - - return false, true -} - -func (i *importCmd) createSiteFromJekyll(jekyllRoot, targetDir string, jekyllPostDirs map[string]bool, force bool) error { - s, err := hugolib.NewSiteDefaultLang() - if err != nil { - return err - } - - fs := s.Fs.Source - if exists, _ := helpers.Exists(targetDir, fs); exists { - if isDir, _ := helpers.IsDir(targetDir, fs); !isDir { - return errors.New("target path \"" + targetDir + "\" exists but is not a directory") - } - - isEmpty, _ := helpers.IsEmpty(targetDir, fs) - - if !isEmpty && !force { - return errors.New("target path \"" + targetDir + "\" exists and is not empty") - } - } - - jekyllConfig := i.loadJekyllConfig(fs, jekyllRoot) - - mkdir(targetDir, "layouts") - mkdir(targetDir, "content") - mkdir(targetDir, "archetypes") - mkdir(targetDir, "static") - mkdir(targetDir, "data") - mkdir(targetDir, "themes") - - i.createConfigFromJekyll(fs, targetDir, "yaml", jekyllConfig) - - i.copyJekyllFilesAndFolders(jekyllRoot, filepath.Join(targetDir, "static"), jekyllPostDirs) - - return nil -} - -func (i *importCmd) loadJekyllConfig(fs afero.Fs, jekyllRoot string) map[string]interface{} { - path := filepath.Join(jekyllRoot, "_config.yml") - - exists, err := helpers.Exists(path, fs) - - if err != nil || !exists { - jww.WARN.Println("_config.yaml not found: Is the specified Jekyll root correct?") - return nil - } - - f, err := fs.Open(path) - if err != nil { - return nil - } - - defer f.Close() - - b, err := ioutil.ReadAll(f) - - if err != nil { - return nil - } - - c, err := metadecoders.Default.UnmarshalToMap(b, metadecoders.YAML) - - if err != nil { - return nil - } - - return c -} - -func (i *importCmd) createConfigFromJekyll(fs afero.Fs, inpath string, kind metadecoders.Format, jekyllConfig map[string]interface{}) (err error) { - title := "My New Hugo Site" - baseURL := "http://example.org/" - - for key, value := range jekyllConfig { - lowerKey := strings.ToLower(key) - - switch lowerKey { - case "title": - if str, ok := value.(string); ok { - title = str - } - - case "url": - if str, ok := value.(string); ok { - baseURL = str - } - } - } - - in := map[string]interface{}{ - "baseURL": baseURL, - "title": title, - "languageCode": "en-us", - "disablePathToLower": true, - } - - var buf bytes.Buffer - err = parser.InterfaceToConfig(in, kind, &buf) - if err != nil { - return err - } - - return helpers.WriteToDisk(filepath.Join(inpath, "config."+string(kind)), &buf, fs) -} - -func (i *importCmd) copyJekyllFilesAndFolders(jekyllRoot, dest string, jekyllPostDirs map[string]bool) (err error) { - fs := hugofs.Os - - fi, err := fs.Stat(jekyllRoot) - if err != nil { - return err - } - if !fi.IsDir() { - return errors.New(jekyllRoot + " is not a directory") - } - err = os.MkdirAll(dest, fi.Mode()) - if err != nil { - return err - } - entries, err := ioutil.ReadDir(jekyllRoot) - if err != nil { - return err - } - - for _, entry := range entries { - sfp := filepath.Join(jekyllRoot, entry.Name()) - dfp := filepath.Join(dest, entry.Name()) - if entry.IsDir() { - if entry.Name()[0] != '_' && entry.Name()[0] != '.' { - if _, ok := jekyllPostDirs[entry.Name()]; !ok { - err = hugio.CopyDir(fs, sfp, dfp, nil) - if err != nil { - jww.ERROR.Println(err) - } - } - } - } else { - lowerEntryName := strings.ToLower(entry.Name()) - exceptSuffix := []string{".md", ".markdown", ".html", ".htm", - ".xml", ".textile", "rakefile", "gemfile", ".lock"} - isExcept := false - for _, suffix := range exceptSuffix { - if strings.HasSuffix(lowerEntryName, suffix) { - isExcept = true - break - } - } - - if !isExcept && entry.Name()[0] != '.' && entry.Name()[0] != '_' { - err = hugio.CopyFile(fs, sfp, dfp) - if err != nil { - jww.ERROR.Println(err) - } - } - } - - } - return nil -} - -func parseJekyllFilename(filename string) (time.Time, string, error) { - re := regexp.MustCompile(`(\d+-\d+-\d+)-(.+)\..*`) - r := re.FindAllStringSubmatch(filename, -1) - if len(r) == 0 { - return time.Now(), "", errors.New("filename not match") - } - - postDate, err := time.Parse("2006-1-2", r[0][1]) - if err != nil { - return time.Now(), "", err - } - - postName := r[0][2] - - return postDate, postName, nil -} - -func convertJekyllPost(path, relPath, targetDir string, draft bool) error { - jww.TRACE.Println("Converting", path) - - filename := filepath.Base(path) - postDate, postName, err := parseJekyllFilename(filename) - if err != nil { - jww.WARN.Printf("Failed to parse filename '%s': %s. Skipping.", filename, err) - return nil - } - - jww.TRACE.Println(filename, postDate, postName) - - targetFile := filepath.Join(targetDir, relPath) - targetParentDir := filepath.Dir(targetFile) - os.MkdirAll(targetParentDir, 0777) - - contentBytes, err := ioutil.ReadFile(path) - if err != nil { - jww.ERROR.Println("Read file error:", path) - return err - } - - pf, err := parseContentFile(bytes.NewReader(contentBytes)) - if err != nil { - jww.ERROR.Println("Parse file error:", path) - return err - } - - newmetadata, err := convertJekyllMetaData(pf.frontMatter, postName, postDate, draft) - if err != nil { - jww.ERROR.Println("Convert metadata error:", path) - return err - } - - content, err := convertJekyllContent(newmetadata, string(pf.content)) - if err != nil { - jww.ERROR.Println("Converting Jekyll error:", path) - return err - } - - fs := hugofs.Os - if err := helpers.WriteToDisk(targetFile, strings.NewReader(content), fs); err != nil { - return fmt.Errorf("failed to save file %q: %s", filename, err) - } - - return nil -} - -func convertJekyllMetaData(m interface{}, postName string, postDate time.Time, draft bool) (interface{}, error) { - metadata, err := maps.ToStringMapE(m) - if err != nil { - return nil, err - } - - if draft { - metadata["draft"] = true - } - - for key, value := range metadata { - lowerKey := strings.ToLower(key) - - switch lowerKey { - case "layout": - delete(metadata, key) - case "permalink": - if str, ok := value.(string); ok { - metadata["url"] = str - } - delete(metadata, key) - case "category": - if str, ok := value.(string); ok { - metadata["categories"] = []string{str} - } - delete(metadata, key) - case "excerpt_separator": - if key != lowerKey { - delete(metadata, key) - metadata[lowerKey] = value - } - case "date": - if str, ok := value.(string); ok { - re := regexp.MustCompile(`(\d+):(\d+):(\d+)`) - r := re.FindAllStringSubmatch(str, -1) - if len(r) > 0 { - hour, _ := strconv.Atoi(r[0][1]) - minute, _ := strconv.Atoi(r[0][2]) - second, _ := strconv.Atoi(r[0][3]) - postDate = time.Date(postDate.Year(), postDate.Month(), postDate.Day(), hour, minute, second, 0, time.UTC) - } - } - delete(metadata, key) - } - - } - - metadata["date"] = postDate.Format(time.RFC3339) - - return metadata, nil -} - -func convertJekyllContent(m interface{}, content string) (string, error) { - metadata, _ := maps.ToStringMapE(m) - - lines := strings.Split(content, "\n") - var resultLines []string - for _, line := range lines { - resultLines = append(resultLines, strings.Trim(line, "\r\n")) - } - - content = strings.Join(resultLines, "\n") - - excerptSep := "" - if value, ok := metadata["excerpt_separator"]; ok { - if str, strOk := value.(string); strOk { - content = strings.Replace(content, strings.TrimSpace(str), excerptSep, -1) - } - } - - replaceList := []struct { - re *regexp.Regexp - replace string - }{ - {regexp.MustCompile("(?i)"), ""}, - {regexp.MustCompile(`\{%\s*raw\s*%\}\s*(.*?)\s*\{%\s*endraw\s*%\}`), "$1"}, - {regexp.MustCompile(`{%\s*endhighlight\s*%}`), "{{< / highlight >}}"}, - } - - for _, replace := range replaceList { - content = replace.re.ReplaceAllString(content, replace.replace) - } - - replaceListFunc := []struct { - re *regexp.Regexp - replace func(string) string - }{ - // Octopress image tag: http://octopress.org/docs/plugins/image-tag/ - {regexp.MustCompile(`{%\s+img\s*(.*?)\s*%}`), replaceImageTag}, - {regexp.MustCompile(`{%\s*highlight\s*(.*?)\s*%}`), replaceHighlightTag}, - } - - for _, replace := range replaceListFunc { - content = replace.re.ReplaceAllStringFunc(content, replace.replace) - } - - var buf bytes.Buffer - if len(metadata) != 0 { - err := parser.InterfaceToFrontMatter(m, metadecoders.YAML, &buf) - if err != nil { - return "", err - } - } - buf.WriteString(content) - - return buf.String(), nil -} - -func replaceHighlightTag(match string) string { - r := regexp.MustCompile(`{%\s*highlight\s*(.*?)\s*%}`) - parts := r.FindStringSubmatch(match) - lastQuote := rune(0) - f := func(c rune) bool { - switch { - case c == lastQuote: - lastQuote = rune(0) - return false - case lastQuote != rune(0): - return false - case unicode.In(c, unicode.Quotation_Mark): - lastQuote = c - return false - default: - return unicode.IsSpace(c) - } - } - // splitting string by space but considering quoted section - items := strings.FieldsFunc(parts[1], f) - - result := bytes.NewBufferString("{{< highlight ") - result.WriteString(items[0]) // language - options := items[1:] - for i, opt := range options { - opt = strings.Replace(opt, "\"", "", -1) - if opt == "linenos" { - opt = "linenos=table" - } - if i == 0 { - opt = " \"" + opt - } - if i < len(options)-1 { - opt += "," - } else if i == len(options)-1 { - opt += "\"" - } - result.WriteString(opt) - } - - result.WriteString(" >}}") - return result.String() -} - -func replaceImageTag(match string) string { - r := regexp.MustCompile(`{%\s+img\s*(\p{L}*)\s+([\S]*/[\S]+)\s+(\d*)\s*(\d*)\s*(.*?)\s*%}`) - result := bytes.NewBufferString("{{< figure ") - parts := r.FindStringSubmatch(match) - // Index 0 is the entire string, ignore - replaceOptionalPart(result, "class", parts[1]) - replaceOptionalPart(result, "src", parts[2]) - replaceOptionalPart(result, "width", parts[3]) - replaceOptionalPart(result, "height", parts[4]) - // title + alt - part := parts[5] - if len(part) > 0 { - splits := strings.Split(part, "'") - lenSplits := len(splits) - if lenSplits == 1 { - replaceOptionalPart(result, "title", splits[0]) - } else if lenSplits == 3 { - replaceOptionalPart(result, "title", splits[1]) - } else if lenSplits == 5 { - replaceOptionalPart(result, "title", splits[1]) - replaceOptionalPart(result, "alt", splits[3]) - } - } - result.WriteString(">}}") - return result.String() - -} -func replaceOptionalPart(buffer *bytes.Buffer, partName string, part string) { - if len(part) > 0 { - buffer.WriteString(partName + "=\"" + part + "\" ") - } -} diff --git a/commands/import_jekyll_test.go b/commands/import_jekyll_test.go deleted file mode 100644 index c87c224b2..000000000 --- a/commands/import_jekyll_test.go +++ /dev/null @@ -1,137 +0,0 @@ -// Copyright 2015 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 commands - -import ( - "encoding/json" - "testing" - "time" - - qt "github.com/frankban/quicktest" -) - -func TestParseJekyllFilename(t *testing.T) { - c := qt.New(t) - filenameArray := []string{ - "2015-01-02-test.md", - "2012-03-15-中文.markup", - } - - expectResult := []struct { - postDate time.Time - postName string - }{ - {time.Date(2015, time.January, 2, 0, 0, 0, 0, time.UTC), "test"}, - {time.Date(2012, time.March, 15, 0, 0, 0, 0, time.UTC), "中文"}, - } - - for i, filename := range filenameArray { - postDate, postName, err := parseJekyllFilename(filename) - c.Assert(err, qt.IsNil) - c.Assert(expectResult[i].postDate.Format("2006-01-02"), qt.Equals, postDate.Format("2006-01-02")) - c.Assert(expectResult[i].postName, qt.Equals, postName) - } -} - -func TestConvertJekyllMetadata(t *testing.T) { - c := qt.New(t) - testDataList := []struct { - metadata interface{} - postName string - postDate time.Time - draft bool - expect string - }{ - {map[interface{}]interface{}{}, "testPost", time.Date(2015, 10, 1, 0, 0, 0, 0, time.UTC), false, - `{"date":"2015-10-01T00:00:00Z"}`}, - {map[interface{}]interface{}{}, "testPost", time.Date(2015, 10, 1, 0, 0, 0, 0, time.UTC), true, - `{"date":"2015-10-01T00:00:00Z","draft":true}`}, - {map[interface{}]interface{}{"Permalink": "/permalink.html", "layout": "post"}, - "testPost", time.Date(2015, 10, 1, 0, 0, 0, 0, time.UTC), false, - `{"date":"2015-10-01T00:00:00Z","url":"/permalink.html"}`}, - {map[interface{}]interface{}{"permalink": "/permalink.html"}, - "testPost", time.Date(2015, 10, 1, 0, 0, 0, 0, time.UTC), false, - `{"date":"2015-10-01T00:00:00Z","url":"/permalink.html"}`}, - {map[interface{}]interface{}{"category": nil, "permalink": 123}, - "testPost", time.Date(2015, 10, 1, 0, 0, 0, 0, time.UTC), false, - `{"date":"2015-10-01T00:00:00Z"}`}, - {map[interface{}]interface{}{"Excerpt_Separator": "sep"}, - "testPost", time.Date(2015, 10, 1, 0, 0, 0, 0, time.UTC), false, - `{"date":"2015-10-01T00:00:00Z","excerpt_separator":"sep"}`}, - {map[interface{}]interface{}{"category": "book", "layout": "post", "Others": "Goods", "Date": "2015-10-01 12:13:11"}, - "testPost", time.Date(2015, 10, 1, 0, 0, 0, 0, time.UTC), false, - `{"Others":"Goods","categories":["book"],"date":"2015-10-01T12:13:11Z"}`}, - } - - for _, data := range testDataList { - result, err := convertJekyllMetaData(data.metadata, data.postName, data.postDate, data.draft) - c.Assert(err, qt.IsNil) - jsonResult, err := json.Marshal(result) - c.Assert(err, qt.IsNil) - c.Assert(string(jsonResult), qt.Equals, data.expect) - } -} - -func TestConvertJekyllContent(t *testing.T) { - c := qt.New(t) - testDataList := []struct { - metadata interface{} - content string - expect string - }{ - {map[interface{}]interface{}{}, - "Test content\r\n\npart2 content", "Test content\n\npart2 content"}, - {map[interface{}]interface{}{}, - "Test content\n\npart2 content", "Test content\n\npart2 content"}, - {map[interface{}]interface{}{"excerpt_separator": ""}, - "Test content\n\npart2 content", - "---\nexcerpt_separator: \n---\nTest content\n\npart2 content"}, - {map[interface{}]interface{}{}, "{% raw %}text{% endraw %}", "text"}, - {map[interface{}]interface{}{}, "{%raw%} text2 {%endraw %}", "text2"}, - {map[interface{}]interface{}{}, - "{% highlight go %}\nvar s int\n{% endhighlight %}", - "{{< highlight go >}}\nvar s int\n{{< / highlight >}}"}, - {map[interface{}]interface{}{}, - "{% highlight go linenos hl_lines=\"1 2\" %}\nvar s string\nvar i int\n{% endhighlight %}", - "{{< highlight go \"linenos=table,hl_lines=1 2\" >}}\nvar s string\nvar i int\n{{< / highlight >}}"}, - - // Octopress image tag - {map[interface{}]interface{}{}, - "{% img http://placekitten.com/890/280 %}", - "{{< figure src=\"http://placekitten.com/890/280\" >}}"}, - {map[interface{}]interface{}{}, - "{% img left http://placekitten.com/320/250 Place Kitten #2 %}", - "{{< figure class=\"left\" src=\"http://placekitten.com/320/250\" title=\"Place Kitten #2\" >}}"}, - {map[interface{}]interface{}{}, - "{% img right http://placekitten.com/300/500 150 250 'Place Kitten #3' %}", - "{{< figure class=\"right\" src=\"http://placekitten.com/300/500\" width=\"150\" height=\"250\" title=\"Place Kitten #3\" >}}"}, - {map[interface{}]interface{}{}, - "{% img right http://placekitten.com/300/500 150 250 'Place Kitten #4' 'An image of a very cute kitten' %}", - "{{< figure class=\"right\" src=\"http://placekitten.com/300/500\" width=\"150\" height=\"250\" title=\"Place Kitten #4\" alt=\"An image of a very cute kitten\" >}}"}, - {map[interface{}]interface{}{}, - "{% img http://placekitten.com/300/500 150 250 'Place Kitten #4' 'An image of a very cute kitten' %}", - "{{< figure src=\"http://placekitten.com/300/500\" width=\"150\" height=\"250\" title=\"Place Kitten #4\" alt=\"An image of a very cute kitten\" >}}"}, - {map[interface{}]interface{}{}, - "{% img right /placekitten/300/500 'Place Kitten #4' 'An image of a very cute kitten' %}", - "{{< figure class=\"right\" src=\"/placekitten/300/500\" title=\"Place Kitten #4\" alt=\"An image of a very cute kitten\" >}}"}, - {map[interface{}]interface{}{"category": "book", "layout": "post", "Date": "2015-10-01 12:13:11"}, - "somecontent", - "---\nDate: \"2015-10-01 12:13:11\"\ncategory: book\nlayout: post\n---\nsomecontent"}, - } - for _, data := range testDataList { - result, err := convertJekyllContent(data.metadata, data.content) - c.Assert(result, qt.Equals, data.expect) - c.Assert(err, qt.IsNil) - } -} diff --git a/commands/limit_darwin.go b/commands/limit_darwin.go deleted file mode 100644 index 6799f37b1..000000000 --- a/commands/limit_darwin.go +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright 2018 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 commands - -import ( - "syscall" - - "github.com/spf13/cobra" - jww "github.com/spf13/jwalterweatherman" -) - -var _ cmder = (*limitCmd)(nil) - -type limitCmd struct { - *baseCmd -} - -func newLimitCmd() *limitCmd { - ccmd := &cobra.Command{ - Use: "ulimit", - Short: "Check system ulimit settings", - Long: `Hugo will inspect the current ulimit settings on the system. -This is primarily to ensure that Hugo can watch enough files on some OSs`, - RunE: func(cmd *cobra.Command, args []string) error { - var rLimit syscall.Rlimit - err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rLimit) - if err != nil { - return newSystemError("Error Getting rlimit ", err) - } - - jww.FEEDBACK.Println("Current rLimit:", rLimit) - - if rLimit.Cur >= newRlimit { - return nil - } - - jww.FEEDBACK.Println("Attempting to increase limit") - rLimit.Cur = newRlimit - err = syscall.Setrlimit(syscall.RLIMIT_NOFILE, &rLimit) - if err != nil { - return newSystemError("Error Setting rLimit ", err) - } - err = syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rLimit) - if err != nil { - return newSystemError("Error Getting rLimit ", err) - } - jww.FEEDBACK.Println("rLimit after change:", rLimit) - - return nil - }, - } - - return &limitCmd{baseCmd: newBaseCmd(ccmd)} -} - -const newRlimit = 10240 - -func tweakLimit() { - var rLimit syscall.Rlimit - err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rLimit) - if err != nil { - jww.WARN.Println("Unable to get rlimit:", err) - return - } - if rLimit.Cur < newRlimit { - rLimit.Cur = newRlimit - err = syscall.Setrlimit(syscall.RLIMIT_NOFILE, &rLimit) - if err != nil { - // This may not succeed, see https://github.com/golang/go/issues/30401 - jww.INFO.Println("Unable to increase number of open files limit:", err) - } - } -} diff --git a/commands/limit_others.go b/commands/limit_others.go deleted file mode 100644 index 8d3e6ad70..000000000 --- a/commands/limit_others.go +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright 2018 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. - -// +build !darwin - -package commands - -func tweakLimit() { - // nothing to do -} diff --git a/commands/list.go b/commands/list.go index 0b7c18797..42f3408ba 100644 --- a/commands/list.go +++ b/commands/list.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Hugo Authors. All rights reserved. +// Copyright 2024 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,194 +14,200 @@ package commands import ( + "context" "encoding/csv" "os" + "path/filepath" "strconv" "strings" "time" + "github.com/bep/simplecobra" "github.com/gohugoio/hugo/hugolib" + "github.com/gohugoio/hugo/resources/page" "github.com/gohugoio/hugo/resources/resource" "github.com/spf13/cobra" - jww "github.com/spf13/jwalterweatherman" ) -var _ cmder = (*listCmd)(nil) - -type listCmd struct { - *baseBuilderCmd -} - -func (lc *listCmd) buildSites(config map[string]interface{}) (*hugolib.HugoSites, error) { - cfgInit := func(c *commandeer) error { - for key, value := range config { - c.Set(key, value) +// newListCommand creates a new list command and its subcommands. +func newListCommand() *listCommand { + createRecord := func(workingDir string, p page.Page) []string { + return []string{ + filepath.ToSlash(strings.TrimPrefix(p.File().Filename(), workingDir+string(os.PathSeparator))), + p.Slug(), + p.Title(), + p.Date().Format(time.RFC3339), + p.ExpiryDate().Format(time.RFC3339), + p.PublishDate().Format(time.RFC3339), + strconv.FormatBool(p.Draft()), + p.Permalink(), + p.Kind(), + p.Section(), } + } + + list := func(cd *simplecobra.Commandeer, r *rootCommand, shouldInclude func(page.Page) bool, opts ...any) error { + bcfg := hugolib.BuildCfg{SkipRender: true} + cfg := flagsToCfg(cd, nil) + for i := 0; i < len(opts); i += 2 { + cfg.Set(opts[i].(string), opts[i+1]) + } + h, err := r.Build(cd, bcfg, cfg) + if err != nil { + return err + } + + writer := csv.NewWriter(r.StdOut) + defer writer.Flush() + + writer.Write([]string{ + "path", + "slug", + "title", + "date", + "expiryDate", + "publishDate", + "draft", + "permalink", + "kind", + "section", + }) + + for _, p := range h.Pages() { + if shouldInclude(p) { + record := createRecord(h.Conf.BaseConfig().WorkingDir, p) + if err := writer.Write(record); err != nil { + return err + } + } + } + return nil } - c, err := initializeConfig(true, false, &lc.hugoBuilderCommon, lc, cfgInit) - if err != nil { - return nil, err + return &listCommand{ + commands: []simplecobra.Commander{ + &simpleCommand{ + name: "drafts", + short: "List draft content", + long: `List draft content.`, + run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error { + shouldInclude := func(p page.Page) bool { + if !p.Draft() || p.File() == nil { + return false + } + return true + } + return list(cd, r, shouldInclude, + "buildDrafts", true, + "buildFuture", true, + "buildExpired", true, + ) + }, + withc: func(cmd *cobra.Command, r *rootCommand) { + cmd.ValidArgsFunction = cobra.NoFileCompletions + }, + }, + &simpleCommand{ + name: "future", + short: "List future content", + long: `List content with a future publication date.`, + run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error { + shouldInclude := func(p page.Page) bool { + if !resource.IsFuture(p) || p.File() == nil { + return false + } + return true + } + return list(cd, r, shouldInclude, + "buildFuture", true, + "buildDrafts", true, + ) + }, + withc: func(cmd *cobra.Command, r *rootCommand) { + cmd.ValidArgsFunction = cobra.NoFileCompletions + }, + }, + &simpleCommand{ + name: "expired", + short: "List expired content", + long: `List content with a past expiration date.`, + run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error { + shouldInclude := func(p page.Page) bool { + if !resource.IsExpired(p) || p.File() == nil { + return false + } + return true + } + return list(cd, r, shouldInclude, + "buildExpired", true, + "buildDrafts", true, + ) + }, + withc: func(cmd *cobra.Command, r *rootCommand) { + cmd.ValidArgsFunction = cobra.NoFileCompletions + }, + }, + &simpleCommand{ + name: "all", + short: "List all content", + long: `List all content including draft, future, and expired.`, + run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error { + shouldInclude := func(p page.Page) bool { + return p.File() != nil + } + return list(cd, r, shouldInclude, "buildDrafts", true, "buildFuture", true, "buildExpired", true) + }, + withc: func(cmd *cobra.Command, r *rootCommand) { + cmd.ValidArgsFunction = cobra.NoFileCompletions + }, + }, + &simpleCommand{ + name: "published", + short: "List published content", + long: `List content that is not draft, future, or expired.`, + run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error { + shouldInclude := func(p page.Page) bool { + return !p.Draft() && !resource.IsFuture(p) && !resource.IsExpired(p) && p.File() != nil + } + return list(cd, r, shouldInclude) + }, + withc: func(cmd *cobra.Command, r *rootCommand) { + cmd.ValidArgsFunction = cobra.NoFileCompletions + }, + }, + }, } - - sites, err := hugolib.NewHugoSites(*c.DepsCfg) - - if err != nil { - return nil, newSystemError("Error creating sites", err) - } - - if err := sites.Build(hugolib.BuildCfg{SkipRender: true}); err != nil { - return nil, newSystemError("Error Processing Source Content", err) - } - - return sites, nil } -func (b *commandsBuilder) newListCmd() *listCmd { - cc := &listCmd{} - - cmd := &cobra.Command{ - Use: "list", - Short: "Listing out various types of content", - Long: `Listing out various types of content. - -List requires a subcommand, e.g. ` + "`hugo list drafts`.", - RunE: nil, - } - - cmd.AddCommand( - &cobra.Command{ - Use: "drafts", - Short: "List all drafts", - Long: `List all of the drafts in your content directory.`, - RunE: func(cmd *cobra.Command, args []string) error { - sites, err := cc.buildSites(map[string]interface{}{"buildDrafts": true}) - - if err != nil { - return newSystemError("Error building sites", err) - } - - for _, p := range sites.Pages() { - if p.Draft() { - jww.FEEDBACK.Println(strings.TrimPrefix(p.File().Filename(), sites.WorkingDir+string(os.PathSeparator))) - } - } - - return nil - }, - }, - &cobra.Command{ - Use: "future", - Short: "List all posts dated in the future", - Long: `List all of the posts in your content directory which will be posted in the future.`, - RunE: func(cmd *cobra.Command, args []string) error { - sites, err := cc.buildSites(map[string]interface{}{"buildFuture": true}) - - if err != nil { - return newSystemError("Error building sites", err) - } - - writer := csv.NewWriter(os.Stdout) - defer writer.Flush() - - for _, p := range sites.Pages() { - if resource.IsFuture(p) { - err := writer.Write([]string{ - strings.TrimPrefix(p.File().Filename(), sites.WorkingDir+string(os.PathSeparator)), - p.PublishDate().Format(time.RFC3339), - }) - if err != nil { - return newSystemError("Error writing future posts to stdout", err) - } - } - } - - return nil - }, - }, - &cobra.Command{ - Use: "expired", - Short: "List all posts already expired", - Long: `List all of the posts in your content directory which has already expired.`, - RunE: func(cmd *cobra.Command, args []string) error { - sites, err := cc.buildSites(map[string]interface{}{"buildExpired": true}) - - if err != nil { - return newSystemError("Error building sites", err) - } - - writer := csv.NewWriter(os.Stdout) - defer writer.Flush() - - for _, p := range sites.Pages() { - if resource.IsExpired(p) { - err := writer.Write([]string{ - strings.TrimPrefix(p.File().Filename(), sites.WorkingDir+string(os.PathSeparator)), - p.ExpiryDate().Format(time.RFC3339), - }) - if err != nil { - return newSystemError("Error writing expired posts to stdout", err) - } - } - } - - return nil - }, - }, - &cobra.Command{ - Use: "all", - Short: "List all posts", - Long: `List all of the posts in your content directory, include drafts, future and expired pages.`, - RunE: func(cmd *cobra.Command, args []string) error { - sites, err := cc.buildSites(map[string]interface{}{ - "buildExpired": true, - "buildDrafts": true, - "buildFuture": true, - }) - - if err != nil { - return newSystemError("Error building sites", err) - } - - writer := csv.NewWriter(os.Stdout) - defer writer.Flush() - - writer.Write([]string{ - "path", - "slug", - "title", - "date", - "expiryDate", - "publishDate", - "draft", - "permalink", - }) - for _, p := range sites.Pages() { - if !p.IsPage() { - continue - } - err := writer.Write([]string{ - strings.TrimPrefix(p.File().Filename(), sites.WorkingDir+string(os.PathSeparator)), - p.Slug(), - p.Title(), - p.Date().Format(time.RFC3339), - p.ExpiryDate().Format(time.RFC3339), - p.PublishDate().Format(time.RFC3339), - strconv.FormatBool(p.Draft()), - p.Permalink(), - }) - if err != nil { - return newSystemError("Error writing posts to stdout", err) - } - } - - return nil - }, - }, - ) - - cc.baseBuilderCmd = b.newBuilderBasicCmd(cmd) - - return cc +type listCommand struct { + commands []simplecobra.Commander +} + +func (c *listCommand) Commands() []simplecobra.Commander { + return c.commands +} + +func (c *listCommand) Name() string { + return "list" +} + +func (c *listCommand) Run(ctx context.Context, cd *simplecobra.Commandeer, args []string) error { + // Do nothing. + return nil +} + +func (c *listCommand) Init(cd *simplecobra.Commandeer) error { + cmd := cd.CobraCommand + cmd.Short = "List content" + cmd.Long = `List content. + +List requires a subcommand, e.g. hugo list drafts` + + cmd.RunE = nil + return nil +} + +func (c *listCommand) PreRun(cd, runner *simplecobra.Commandeer) error { + return nil } diff --git a/commands/list_test.go b/commands/list_test.go deleted file mode 100644 index 6f3d6c74d..000000000 --- a/commands/list_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package commands - -import ( - "bytes" - "encoding/csv" - "io" - "os" - "path/filepath" - "strings" - "testing" - - qt "github.com/frankban/quicktest" -) - -func captureStdout(f func() error) (string, error) { - old := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - err := f() - - w.Close() - os.Stdout = old - - var buf bytes.Buffer - io.Copy(&buf, r) - return buf.String(), err -} - -func TestListAll(t *testing.T) { - c := qt.New(t) - dir, clean, err := createSimpleTestSite(t, testSiteConfig{}) - defer clean() - - c.Assert(err, qt.IsNil) - - hugoCmd := newCommandsBuilder().addAll().build() - cmd := hugoCmd.getCommand() - - defer func() { - os.RemoveAll(dir) - }() - - cmd.SetArgs([]string{"-s=" + dir, "list", "all"}) - - out, err := captureStdout(func() error { - _, err := cmd.ExecuteC() - return err - }) - c.Assert(err, qt.IsNil) - - r := csv.NewReader(strings.NewReader(out)) - - header, err := r.Read() - - c.Assert(err, qt.IsNil) - c.Assert(header, qt.DeepEquals, []string{ - "path", "slug", "title", - "date", "expiryDate", "publishDate", - "draft", "permalink", - }) - - record, err := r.Read() - - c.Assert(err, qt.IsNil) - c.Assert(record, qt.DeepEquals, []string{ - filepath.Join("content", "p1.md"), "", "P1", - "0001-01-01T00:00:00Z", "0001-01-01T00:00:00Z", "0001-01-01T00:00:00Z", - "false", "https://example.org/p1/", - }) -} diff --git a/commands/mod.go b/commands/mod.go index 0b3e193b9..58155f9be 100644 --- a/commands/mod.go +++ b/commands/mod.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Hugo Authors. All rights reserved. +// Copyright 2024 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,176 +14,331 @@ package commands import ( + "context" + "errors" "os" + "path/filepath" - "github.com/gohugoio/hugo/modules" + "github.com/bep/simplecobra" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/modules/npm" "github.com/spf13/cobra" ) -var _ cmder = (*modCmd)(nil) - -type modCmd struct { - *baseBuilderCmd -} - -func (b *commandsBuilder) newModCmd() *modCmd { - c := &modCmd{} - - const commonUsage = ` +const commonUsageMod = ` Note that Hugo will always start out by resolving the components defined in the site -configuration, provided by a _vendor directory (if no --ignoreVendor flag provided), +configuration, provided by a _vendor directory (if no --ignoreVendorPaths flag provided), Go Modules, or a folder inside the themes directory, in that order. See https://gohugo.io/hugo-modules/ for more information. ` - cmd := &cobra.Command{ - Use: "mod", - Short: "Various Hugo Modules helpers.", - Long: `Various helpers to help manage the modules in your project's dependency graph. +// buildConfigCommands creates a new config command and its subcommands. +func newModCommands() *modCommands { + var ( + clean bool + pattern string + all bool + ) -Most operations here requires a Go version installed on your system (>= Go 1.12) and the relevant VCS client (typically Git). -This is not needed if you only operate on modules inside /themes or if you have vendored them via "hugo mod vendor". + npmCommand := &simpleCommand{ + name: "npm", + short: "Various npm helpers", + long: `Various npm (Node package manager) helpers.`, + commands: []simplecobra.Commander{ + &simpleCommand{ + name: "pack", + short: "Experimental: Prepares and writes a composite package.json file for your project", + long: `Prepares and writes a composite package.json file for your project. -` + commonUsage, +On first run it creates a "package.hugo.json" in the project root if not already there. This file will be used as a template file +with the base dependency set. - RunE: nil, +This set will be merged with all "package.hugo.json" files found in the dependency tree, picking the version closest to the project. + +This command is marked as 'Experimental'. We think it's a great idea, so it's not likely to be +removed from Hugo, but we need to test this out in "real life" to get a feel of it, +so this may/will change in future versions of Hugo. +`, + withc: func(cmd *cobra.Command, r *rootCommand) { + cmd.ValidArgsFunction = cobra.NoFileCompletions + applyLocalFlagsBuildConfig(cmd, r) + }, + run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error { + h, err := r.Hugo(flagsToCfg(cd, nil)) + if err != nil { + return err + } + return npm.Pack(h.BaseFs.ProjectSourceFs, h.BaseFs.AssetsWithDuplicatesPreserved.Fs) + }, + }, + }, } - cmd.AddCommand( - &cobra.Command{ - Use: "get", - DisableFlagParsing: true, - Short: "Resolves dependencies in your current Hugo Project.", - Long: ` -Resolves dependencies in your current Hugo Project. + return &modCommands{ + commands: []simplecobra.Commander{ + &simpleCommand{ + name: "init", + short: "Initialize this project as a Hugo Module", + long: `Initialize this project as a Hugo Module. + It will try to guess the module path, but you may help by passing it as an argument, e.g: + + hugo mod init github.com/gohugoio/testshortcodes + + Note that Hugo Modules supports multi-module projects, so you can initialize a Hugo Module + inside a subfolder on GitHub, as one example. + `, + withc: func(cmd *cobra.Command, r *rootCommand) { + cmd.ValidArgsFunction = cobra.NoFileCompletions + applyLocalFlagsBuildConfig(cmd, r) + }, + run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error { + h, err := r.getOrCreateHugo(flagsToCfg(cd, nil), true) + if err != nil { + return err + } + var initPath string + if len(args) >= 1 { + initPath = args[0] + } + c := h.Configs.ModulesClient + if err := c.Init(initPath); err != nil { + return err + } + return nil + }, + }, + &simpleCommand{ + name: "verify", + short: "Verify dependencies", + long: `Verify checks that the dependencies of the current module, which are stored in a local downloaded source cache, have not been modified since being downloaded.`, + withc: func(cmd *cobra.Command, r *rootCommand) { + cmd.ValidArgsFunction = cobra.NoFileCompletions + applyLocalFlagsBuildConfig(cmd, r) + cmd.Flags().BoolVarP(&clean, "clean", "", false, "delete module cache for dependencies that fail verification") + }, + run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error { + conf, err := r.ConfigFromProvider(configKey{counter: r.configVersionID.Load()}, flagsToCfg(cd, nil)) + if err != nil { + return err + } + client := conf.configs.ModulesClient + return client.Verify(clean) + }, + }, + &simpleCommand{ + name: "graph", + short: "Print a module dependency graph", + long: `Print a module dependency graph with information about module status (disabled, vendored). +Note that for vendored modules, that is the version listed and not the one from go.mod. +`, + withc: func(cmd *cobra.Command, r *rootCommand) { + cmd.ValidArgsFunction = cobra.NoFileCompletions + applyLocalFlagsBuildConfig(cmd, r) + cmd.Flags().BoolVarP(&clean, "clean", "", false, "delete module cache for dependencies that fail verification") + }, + run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error { + conf, err := r.ConfigFromProvider(configKey{counter: r.configVersionID.Load()}, flagsToCfg(cd, nil)) + if err != nil { + return err + } + client := conf.configs.ModulesClient + return client.Graph(os.Stdout) + }, + }, + &simpleCommand{ + name: "clean", + short: "Delete the Hugo Module cache for the current project", + long: `Delete the Hugo Module cache for the current project.`, + withc: func(cmd *cobra.Command, r *rootCommand) { + cmd.ValidArgsFunction = cobra.NoFileCompletions + applyLocalFlagsBuildConfig(cmd, r) + cmd.Flags().StringVarP(&pattern, "pattern", "", "", `pattern matching module paths to clean (all if not set), e.g. "**hugo*"`) + _ = cmd.RegisterFlagCompletionFunc("pattern", cobra.NoFileCompletions) + cmd.Flags().BoolVarP(&all, "all", "", false, "clean entire module cache") + }, + run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error { + h, err := r.Hugo(flagsToCfg(cd, nil)) + if err != nil { + return err + } + if all { + modCache := h.ResourceSpec.FileCaches.ModulesCache() + count, err := modCache.Prune(true) + r.Printf("Deleted %d files from module cache.", count) + return err + } + + return h.Configs.ModulesClient.Clean(pattern) + }, + }, + &simpleCommand{ + name: "tidy", + short: "Remove unused entries in go.mod and go.sum", + withc: func(cmd *cobra.Command, r *rootCommand) { + cmd.ValidArgsFunction = cobra.NoFileCompletions + applyLocalFlagsBuildConfig(cmd, r) + }, + run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error { + h, err := r.Hugo(flagsToCfg(cd, nil)) + if err != nil { + return err + } + return h.Configs.ModulesClient.Tidy() + }, + }, + &simpleCommand{ + name: "vendor", + short: "Vendor all module dependencies into the _vendor directory", + long: `Vendor all module dependencies into the _vendor directory. + If a module is vendored, that is where Hugo will look for it's dependencies. + `, + withc: func(cmd *cobra.Command, r *rootCommand) { + cmd.ValidArgsFunction = cobra.NoFileCompletions + applyLocalFlagsBuildConfig(cmd, r) + }, + run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error { + h, err := r.Hugo(flagsToCfg(cd, nil)) + if err != nil { + return err + } + return h.Configs.ModulesClient.Vendor() + }, + }, + + &simpleCommand{ + name: "get", + short: "Resolves dependencies in your current Hugo project", + long: ` +Resolves dependencies in your current Hugo project. Some examples: Install the latest version possible for a given module: hugo mod get github.com/gohugoio/testshortcodes - + Install a specific version: hugo mod get github.com/gohugoio/testshortcodes@v0.3.0 -Install the latest versions of all module dependencies: +Install the latest versions of all direct module dependencies: + + hugo mod get + hugo mod get ./... (recursive) + +Install the latest versions of all module dependencies (direct and indirect): hugo mod get -u + hugo mod get -u ./... (recursive) Run "go help get" for more information. All flags available for "go get" is also relevant here. -` + commonUsage, - RunE: func(cmd *cobra.Command, args []string) error { - return c.withModsClient(false, func(c *modules.Client) error { - +` + commonUsageMod, + withc: func(cmd *cobra.Command, r *rootCommand) { + cmd.DisableFlagParsing = true + cmd.ValidArgsFunction = cobra.NoFileCompletions + }, + run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error { // We currently just pass on the flags we get to Go and // need to do the flag handling manually. - if len(args) == 1 && args[0] == "-h" { - return cmd.Help() + if len(args) == 1 && (args[0] == "-h" || args[0] == "--help") { + return errHelp } - return c.Get(args...) - }) + + var lastArg string + if len(args) != 0 { + lastArg = args[len(args)-1] + } + + if lastArg == "./..." { + args = args[:len(args)-1] + // Do a recursive update. + dirname, err := os.Getwd() + if err != nil { + return err + } + + // Sanity chesimplecobra. We do recursive walking and want to avoid + // accidents. + if len(dirname) < 5 { + return errors.New("must not be run from the file system root") + } + + filepath.Walk(dirname, func(path string, info os.FileInfo, err error) error { + if info.IsDir() { + return nil + } + if info.Name() == "go.mod" { + // Found a module. + dir := filepath.Dir(path) + + cfg := config.New() + cfg.Set("workingDir", dir) + conf, err := r.ConfigFromProvider(configKey{counter: r.configVersionID.Add(1)}, flagsToCfg(cd, cfg)) + if err != nil { + return err + } + r.Println("Update module in", conf.configs.Base.WorkingDir) + client := conf.configs.ModulesClient + return client.Get(args...) + + } + return nil + }) + return nil + } else { + conf, err := r.ConfigFromProvider(configKey{counter: r.configVersionID.Load()}, flagsToCfg(cd, nil)) + if err != nil { + return err + } + client := conf.configs.ModulesClient + return client.Get(args...) + } + }, }, + npmCommand, }, - &cobra.Command{ - Use: "graph", - Short: "Print a module dependency graph.", - Long: `Print a module dependency graph with information about module status (disabled, vendored). -Note that for vendored modules, that is the version listed and not the one from go.mod. -`, - RunE: func(cmd *cobra.Command, args []string) error { - return c.withModsClient(true, func(c *modules.Client) error { - return c.Graph(os.Stdout) - }) - }, - }, - &cobra.Command{ - Use: "init", - Short: "Initialize this project as a Hugo Module.", - Long: `Initialize this project as a Hugo Module. -It will try to guess the module path, but you may help by passing it as an argument, e.g: - - hugo mod init github.com/gohugoio/testshortcodes - -Note that Hugo Modules supports multi-module projects, so you can initialize a Hugo Module -inside a subfolder on GitHub, as one example. -`, - RunE: func(cmd *cobra.Command, args []string) error { - var path string - if len(args) >= 1 { - path = args[0] - } - return c.withModsClient(false, func(c *modules.Client) error { - return c.Init(path) - }) - }, - }, - &cobra.Command{ - Use: "vendor", - Short: "Vendor all module dependencies into the _vendor directory.", - Long: `Vendor all module dependencies into the _vendor directory. - -If a module is vendored, that is where Hugo will look for it's dependencies. -`, - RunE: func(cmd *cobra.Command, args []string) error { - return c.withModsClient(true, func(c *modules.Client) error { - return c.Vendor() - }) - }, - }, - &cobra.Command{ - Use: "tidy", - Short: "Remove unused entries in go.mod and go.sum.", - RunE: func(cmd *cobra.Command, args []string) error { - return c.withModsClient(true, func(c *modules.Client) error { - return c.Tidy() - }) - }, - }, - &cobra.Command{ - Use: "clean", - Short: "Delete the entire Hugo Module cache.", - Long: `Delete the entire Hugo Module cache. - -Note that after you run this command, all of your dependencies will be re-downloaded next time you run "hugo". - -Also note that if you configure a positive maxAge for the "modules" file cache, it will also be cleaned as part of "hugo --gc". - -`, - RunE: func(cmd *cobra.Command, args []string) error { - com, err := c.initConfig(true) - if err != nil { - return err - } - - _, err = com.hugo().FileCaches.ModulesCache().Prune(true) - return err - - }, - }, - ) - - c.baseBuilderCmd = b.newBuilderCmd(cmd) - - return c - + } } -func (c *modCmd) withModsClient(failOnMissingConfig bool, f func(*modules.Client) error) error { - com, err := c.initConfig(failOnMissingConfig) +type modCommands struct { + r *rootCommand + + commands []simplecobra.Commander +} + +func (c *modCommands) Commands() []simplecobra.Commander { + return c.commands +} + +func (c *modCommands) Name() string { + return "mod" +} + +func (c *modCommands) Run(ctx context.Context, cd *simplecobra.Commandeer, args []string) error { + _, err := c.r.ConfigFromProvider(configKey{counter: c.r.configVersionID.Load()}, nil) if err != nil { return err } + // config := conf.configs.Base - return f(com.hugo().ModulesClient) + return nil } -func (c *modCmd) initConfig(failOnNoConfig bool) (*commandeer, error) { - com, err := initializeConfig(failOnNoConfig, false, &c.hugoBuilderCommon, c, nil) - if err != nil { - return nil, err - } - return com, nil +func (c *modCommands) Init(cd *simplecobra.Commandeer) error { + cmd := cd.CobraCommand + cmd.Short = "Manage modules" + cmd.Long = `Various helpers to help manage the modules in your project's dependency graph. +Most operations here requires a Go version installed on your system (>= Go 1.12) and the relevant VCS client (typically Git). +This is not needed if you only operate on modules inside /themes or if you have vendored them via "hugo mod vendor". + +` + commonUsageMod + cmd.RunE = nil + return nil +} + +func (c *modCommands) PreRun(cd, runner *simplecobra.Commandeer) error { + c.r = cd.Root.Command.(*rootCommand) + return nil } diff --git a/commands/new.go b/commands/new.go index 576976e8e..81e1c65a4 100644 --- a/commands/new.go +++ b/commands/new.go @@ -1,4 +1,4 @@ -// Copyright 2018 The Hugo Authors. All rights reserved. +// Copyright 2024 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. @@ -15,32 +15,33 @@ package commands import ( "bytes" - "os" + "context" "path/filepath" "strings" + "github.com/bep/simplecobra" + "github.com/gohugoio/hugo/common/paths" + "github.com/gohugoio/hugo/config" "github.com/gohugoio/hugo/create" - "github.com/gohugoio/hugo/helpers" - "github.com/gohugoio/hugo/hugolib" - "github.com/spf13/afero" + "github.com/gohugoio/hugo/create/skeletons" "github.com/spf13/cobra" - jww "github.com/spf13/jwalterweatherman" ) -var _ cmder = (*newCmd)(nil) +func newNewCommand() *newCommand { + var ( + force bool + contentType string + format string + ) -type newCmd struct { - contentEditor string - contentType string - - *baseBuilderCmd -} - -func (b *commandsBuilder) newNewCmd() *newCmd { - cmd := &cobra.Command{ - Use: "new [path]", - Short: "Create new content for your site", - Long: `Create a new content file and automatically set the date and title. + var c *newCommand + c = &newCommand{ + commands: []simplecobra.Commander{ + &simpleCommand{ + name: "content", + use: "content [path]", + short: "Create new content", + long: `Create a new content file and automatically set the date and title. It will guess which kind of file to create based on the path provided. You can also specify the kind with ` + "`-k KIND`" + `. @@ -48,90 +49,179 @@ You can also specify the kind with ` + "`-k KIND`" + `. If archetypes are provided in your theme or site, they will be used. Ensure you run this within the root directory of your site.`, + run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error { + if len(args) < 1 { + return newUserError("path needs to be provided") + } + h, err := r.Hugo(flagsToCfg(cd, nil)) + if err != nil { + return err + } + return create.NewContent(h, contentType, args[0], force) + }, + withc: func(cmd *cobra.Command, r *rootCommand) { + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return []string{}, cobra.ShellCompDirectiveNoFileComp + } + return []string{}, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveFilterDirs + } + cmd.Flags().StringVarP(&contentType, "kind", "k", "", "content type to create") + cmd.Flags().String("editor", "", "edit new content with this editor, if provided") + _ = cmd.RegisterFlagCompletionFunc("editor", cobra.NoFileCompletions) + cmd.Flags().BoolVarP(&force, "force", "f", false, "overwrite file if it already exists") + applyLocalFlagsBuildConfig(cmd, r) + }, + }, + &simpleCommand{ + name: "site", + use: "site [path]", + short: "Create a new site", + long: `Create a new site at the specified path.`, + run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error { + if len(args) < 1 { + return newUserError("path needs to be provided") + } + createpath, err := filepath.Abs(filepath.Clean(args[0])) + if err != nil { + return err + } + + cfg := config.New() + cfg.Set("workingDir", createpath) + cfg.Set("publishDir", "public") + + conf, err := r.ConfigFromProvider(configKey{counter: r.configVersionID.Load()}, flagsToCfg(cd, cfg)) + if err != nil { + return err + } + sourceFs := conf.fs.Source + + err = skeletons.CreateSite(createpath, sourceFs, force, format) + if err != nil { + return err + } + + r.Printf("Congratulations! Your new Hugo site was created in %s.\n\n", createpath) + r.Println(c.newSiteNextStepsText(createpath, format)) + + return nil + }, + withc: func(cmd *cobra.Command, r *rootCommand) { + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return []string{}, cobra.ShellCompDirectiveNoFileComp + } + return []string{}, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveFilterDirs + } + cmd.Flags().BoolVarP(&force, "force", "f", false, "init inside non-empty directory") + cmd.Flags().StringVar(&format, "format", "toml", "preferred file format (toml, yaml or json)") + _ = cmd.RegisterFlagCompletionFunc("format", cobra.FixedCompletions([]string{"toml", "yaml", "json"}, cobra.ShellCompDirectiveNoFileComp)) + }, + }, + &simpleCommand{ + name: "theme", + use: "theme [name]", + short: "Create a new theme", + long: `Create a new theme with the specified name in the ./themes directory. +This generates a functional theme including template examples and sample content.`, + run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error { + if len(args) < 1 { + return newUserError("theme name needs to be provided") + } + cfg := config.New() + cfg.Set("publishDir", "public") + + conf, err := r.ConfigFromProvider(configKey{counter: r.configVersionID.Load()}, flagsToCfg(cd, cfg)) + if err != nil { + return err + } + sourceFs := conf.fs.Source + createpath := paths.AbsPathify(conf.configs.Base.WorkingDir, filepath.Join(conf.configs.Base.ThemesDir, args[0])) + r.Println("Creating new theme in", createpath) + + err = skeletons.CreateTheme(createpath, sourceFs, format) + if err != nil { + return err + } + + return nil + }, + withc: func(cmd *cobra.Command, r *rootCommand) { + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return []string{}, cobra.ShellCompDirectiveNoFileComp + } + return []string{}, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveFilterDirs + } + cmd.Flags().StringVar(&format, "format", "toml", "preferred file format (toml, yaml or json)") + _ = cmd.RegisterFlagCompletionFunc("format", cobra.FixedCompletions([]string{"toml", "yaml", "json"}, cobra.ShellCompDirectiveNoFileComp)) + }, + }, + }, } - cc := &newCmd{baseBuilderCmd: b.newBuilderCmd(cmd)} - - cmd.Flags().StringVarP(&cc.contentType, "kind", "k", "", "content type to create") - cmd.Flags().StringVar(&cc.contentEditor, "editor", "", "edit new content with this editor, if provided") - - cmd.AddCommand(b.newNewSiteCmd().getCommand()) - cmd.AddCommand(b.newNewThemeCmd().getCommand()) - - cmd.RunE = cc.newContent - - return cc + return c } -func (n *newCmd) newContent(cmd *cobra.Command, args []string) error { - cfgInit := func(c *commandeer) error { - if cmd.Flags().Changed("editor") { - c.Set("newContentEditor", n.contentEditor) - } - return nil - } +type newCommand struct { + rootCmd *rootCommand - c, err := initializeConfig(true, false, &n.hugoBuilderCommon, n, cfgInit) - - if err != nil { - return err - } - - if len(args) < 1 { - return newUserError("path needs to be provided") - } - - createPath := args[0] - - var kind string - - createPath, kind = newContentPathSection(c.hugo(), createPath) - - if n.contentType != "" { - kind = n.contentType - } - - return create.NewContent(c.hugo(), kind, createPath) + commands []simplecobra.Commander } -func mkdir(x ...string) { - p := filepath.Join(x...) - - err := os.MkdirAll(p, 0777) // before umask - if err != nil { - jww.FATAL.Fatalln(err) - } +func (c *newCommand) Commands() []simplecobra.Commander { + return c.commands } -func touchFile(fs afero.Fs, x ...string) { - inpath := filepath.Join(x...) - mkdir(filepath.Dir(inpath)) - err := helpers.WriteToDisk(inpath, bytes.NewReader([]byte{}), fs) - if err != nil { - jww.FATAL.Fatalln(err) - } +func (c *newCommand) Name() string { + return "new" } -func newContentPathSection(h *hugolib.HugoSites, path string) (string, string) { - // Forward slashes is used in all examples. Convert if needed. - // Issue #1133 - createpath := filepath.FromSlash(path) - - if h != nil { - for _, dir := range h.BaseFs.Content.Dirs { - createpath = strings.TrimPrefix(createpath, dir.Meta().Filename()) - } - } - - var section string - // assume the first directory is the section (kind) - if strings.Contains(createpath[1:], helpers.FilePathSeparator) { - parts := strings.Split(strings.TrimPrefix(createpath, helpers.FilePathSeparator), helpers.FilePathSeparator) - if len(parts) > 0 { - section = parts[0] - } - - } - - return createpath, section +func (c *newCommand) Run(ctx context.Context, cd *simplecobra.Commandeer, args []string) error { + return nil +} + +func (c *newCommand) Init(cd *simplecobra.Commandeer) error { + cmd := cd.CobraCommand + cmd.Short = "Create new content" + cmd.Long = `Create a new content file and automatically set the date and title. +It will guess which kind of file to create based on the path provided. + +You can also specify the kind with ` + "`-k KIND`" + `. + +If archetypes are provided in your theme or site, they will be used. + +Ensure you run this within the root directory of your site.` + + cmd.RunE = nil + return nil +} + +func (c *newCommand) PreRun(cd, runner *simplecobra.Commandeer) error { + c.rootCmd = cd.Root.Command.(*rootCommand) + return nil +} + +func (c *newCommand) newSiteNextStepsText(path string, format string) string { + format = strings.ToLower(format) + var nextStepsText bytes.Buffer + + nextStepsText.WriteString(`Just a few more steps... + +1. Change the current directory to ` + path + `. +2. Create or install a theme: + - Create a new theme with the command "hugo new theme " + - Or, install a theme from https://themes.gohugo.io/ +3. Edit hugo.` + format + `, setting the "theme" property to the theme name. +4. Create new content with the command "hugo new content `) + + nextStepsText.WriteString(filepath.Join("", ".")) + + nextStepsText.WriteString(`". +5. Start the embedded web server with the command "hugo server --buildDrafts". + +See documentation at https://gohugo.io/.`) + + return nextStepsText.String() } diff --git a/commands/new_content_test.go b/commands/new_content_test.go deleted file mode 100644 index 42a7c968c..000000000 --- a/commands/new_content_test.go +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright 2019 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 commands - -import ( - "path/filepath" - "testing" - - qt "github.com/frankban/quicktest" -) - -// Issue #1133 -func TestNewContentPathSectionWithForwardSlashes(t *testing.T) { - c := qt.New(t) - p, s := newContentPathSection(nil, "/post/new.md") - c.Assert(p, qt.Equals, filepath.FromSlash("/post/new.md")) - c.Assert(s, qt.Equals, "post") -} diff --git a/commands/new_site.go b/commands/new_site.go deleted file mode 100644 index 9fb47096a..000000000 --- a/commands/new_site.go +++ /dev/null @@ -1,165 +0,0 @@ -// Copyright 2018 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 commands - -import ( - "bytes" - "errors" - "path/filepath" - "strings" - - "github.com/gohugoio/hugo/parser/metadecoders" - - _errors "github.com/pkg/errors" - - "github.com/gohugoio/hugo/create" - "github.com/gohugoio/hugo/helpers" - "github.com/gohugoio/hugo/hugofs" - "github.com/gohugoio/hugo/parser" - "github.com/spf13/cobra" - jww "github.com/spf13/jwalterweatherman" - "github.com/spf13/viper" -) - -var _ cmder = (*newSiteCmd)(nil) - -type newSiteCmd struct { - configFormat string - - *baseBuilderCmd -} - -func (b *commandsBuilder) newNewSiteCmd() *newSiteCmd { - cc := &newSiteCmd{} - - cmd := &cobra.Command{ - Use: "site [path]", - Short: "Create a new site (skeleton)", - Long: `Create a new site in the provided directory. -The new site will have the correct structure, but no content or theme yet. -Use ` + "`hugo new [contentPath]`" + ` to create new content.`, - RunE: cc.newSite, - } - - cmd.Flags().StringVarP(&cc.configFormat, "format", "f", "toml", "config & frontmatter format") - cmd.Flags().Bool("force", false, "init inside non-empty directory") - - cc.baseBuilderCmd = b.newBuilderBasicCmd(cmd) - - return cc - -} - -func (n *newSiteCmd) doNewSite(fs *hugofs.Fs, basepath string, force bool) error { - archeTypePath := filepath.Join(basepath, "archetypes") - dirs := []string{ - filepath.Join(basepath, "layouts"), - filepath.Join(basepath, "content"), - archeTypePath, - filepath.Join(basepath, "static"), - filepath.Join(basepath, "data"), - filepath.Join(basepath, "themes"), - } - - if exists, _ := helpers.Exists(basepath, fs.Source); exists { - if isDir, _ := helpers.IsDir(basepath, fs.Source); !isDir { - return errors.New(basepath + " already exists but not a directory") - } - - isEmpty, _ := helpers.IsEmpty(basepath, fs.Source) - - switch { - case !isEmpty && !force: - return errors.New(basepath + " already exists and is not empty. See --force.") - - case !isEmpty && force: - all := append(dirs, filepath.Join(basepath, "config."+n.configFormat)) - for _, path := range all { - if exists, _ := helpers.Exists(path, fs.Source); exists { - return errors.New(path + " already exists") - } - } - } - } - - for _, dir := range dirs { - if err := fs.Source.MkdirAll(dir, 0777); err != nil { - return _errors.Wrap(err, "Failed to create dir") - } - } - - createConfig(fs, basepath, n.configFormat) - - // Create a default archetype file. - helpers.SafeWriteToDisk(filepath.Join(archeTypePath, "default.md"), - strings.NewReader(create.ArchetypeTemplateTemplate), fs.Source) - - jww.FEEDBACK.Printf("Congratulations! Your new Hugo site is created in %s.\n\n", basepath) - jww.FEEDBACK.Println(nextStepsText()) - - return nil -} - -// newSite creates a new Hugo site and initializes a structured Hugo directory. -func (n *newSiteCmd) newSite(cmd *cobra.Command, args []string) error { - if len(args) < 1 { - return newUserError("path needs to be provided") - } - - createpath, err := filepath.Abs(filepath.Clean(args[0])) - if err != nil { - return newUserError(err) - } - - forceNew, _ := cmd.Flags().GetBool("force") - - return n.doNewSite(hugofs.NewDefault(viper.New()), createpath, forceNew) -} - -func createConfig(fs *hugofs.Fs, inpath string, kind string) (err error) { - in := map[string]string{ - "baseURL": "http://example.org/", - "title": "My New Hugo Site", - "languageCode": "en-us", - } - - var buf bytes.Buffer - err = parser.InterfaceToConfig(in, metadecoders.FormatFromString(kind), &buf) - if err != nil { - return err - } - - return helpers.WriteToDisk(filepath.Join(inpath, "config."+kind), &buf, fs.Source) -} - -func nextStepsText() string { - var nextStepsText bytes.Buffer - - nextStepsText.WriteString(`Just a few more steps and you're ready to go: - -1. Download a theme into the same-named folder. - Choose a theme from https://themes.gohugo.io/ or - create your own with the "hugo new theme " command. -2. Perhaps you want to add some content. You can add single files - with "hugo new `) - - nextStepsText.WriteString(filepath.Join("", ".")) - - nextStepsText.WriteString(`". -3. Start the built-in live server via "hugo server". - -Visit https://gohugo.io/ for quickstart guide and full documentation.`) - - return nextStepsText.String() -} diff --git a/commands/new_theme.go b/commands/new_theme.go deleted file mode 100644 index ee3437360..000000000 --- a/commands/new_theme.go +++ /dev/null @@ -1,178 +0,0 @@ -// Copyright 2018 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 commands - -import ( - "bytes" - "errors" - "path/filepath" - "strings" - "time" - - "github.com/gohugoio/hugo/helpers" - "github.com/gohugoio/hugo/hugofs" - "github.com/spf13/cobra" - jww "github.com/spf13/jwalterweatherman" -) - -var _ cmder = (*newThemeCmd)(nil) - -type newThemeCmd struct { - *baseBuilderCmd -} - -func (b *commandsBuilder) newNewThemeCmd() *newThemeCmd { - cc := &newThemeCmd{} - - cmd := &cobra.Command{ - Use: "theme [name]", - Short: "Create a new theme", - Long: `Create a new theme (skeleton) called [name] in the current directory. -New theme is a skeleton. Please add content to the touched files. Add your -name to the copyright line in the license and adjust the theme.toml file -as you see fit.`, - RunE: cc.newTheme, - } - - cc.baseBuilderCmd = b.newBuilderBasicCmd(cmd) - - return cc -} - -// newTheme creates a new Hugo theme template -func (n *newThemeCmd) newTheme(cmd *cobra.Command, args []string) error { - c, err := initializeConfig(false, false, &n.hugoBuilderCommon, n, nil) - - if err != nil { - return err - } - - if len(args) < 1 { - return newUserError("theme name needs to be provided") - } - - createpath := c.hugo().PathSpec.AbsPathify(filepath.Join(c.Cfg.GetString("themesDir"), args[0])) - jww.FEEDBACK.Println("Creating theme at", createpath) - - cfg := c.DepsCfg - - if x, _ := helpers.Exists(createpath, cfg.Fs.Source); x { - return errors.New(createpath + " already exists") - } - - mkdir(createpath, "layouts", "_default") - mkdir(createpath, "layouts", "partials") - - touchFile(cfg.Fs.Source, createpath, "layouts", "index.html") - touchFile(cfg.Fs.Source, createpath, "layouts", "404.html") - touchFile(cfg.Fs.Source, createpath, "layouts", "_default", "list.html") - touchFile(cfg.Fs.Source, createpath, "layouts", "_default", "single.html") - - baseofDefault := []byte(` - - {{- partial "head.html" . -}} - - {{- partial "header.html" . -}} -
- {{- block "main" . }}{{- end }} -
- {{- partial "footer.html" . -}} - - -`) - err = helpers.WriteToDisk(filepath.Join(createpath, "layouts", "_default", "baseof.html"), bytes.NewReader(baseofDefault), cfg.Fs.Source) - if err != nil { - return err - } - - touchFile(cfg.Fs.Source, createpath, "layouts", "partials", "head.html") - touchFile(cfg.Fs.Source, createpath, "layouts", "partials", "header.html") - touchFile(cfg.Fs.Source, createpath, "layouts", "partials", "footer.html") - - mkdir(createpath, "archetypes") - - archDefault := []byte("+++\n+++\n") - - err = helpers.WriteToDisk(filepath.Join(createpath, "archetypes", "default.md"), bytes.NewReader(archDefault), cfg.Fs.Source) - if err != nil { - return err - } - - mkdir(createpath, "static", "js") - mkdir(createpath, "static", "css") - - by := []byte(`The MIT License (MIT) - -Copyright (c) ` + time.Now().Format("2006") + ` YOUR_NAME_HERE - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -`) - - err = helpers.WriteToDisk(filepath.Join(createpath, "LICENSE"), bytes.NewReader(by), cfg.Fs.Source) - if err != nil { - return err - } - - n.createThemeMD(cfg.Fs, createpath) - - return nil -} - -func (n *newThemeCmd) createThemeMD(fs *hugofs.Fs, inpath string) (err error) { - - by := []byte(`# theme.toml template for a Hugo theme -# See https://github.com/gohugoio/hugoThemes#themetoml for an example - -name = "` + strings.Title(helpers.MakeTitle(filepath.Base(inpath))) + `" -license = "MIT" -licenselink = "https://github.com/yourname/yourtheme/blob/master/LICENSE" -description = "" -homepage = "http://example.com/" -tags = [] -features = [] -min_version = "0.41" - -[author] - name = "" - homepage = "" - -# If porting an existing theme -[original] - name = "" - homepage = "" - repo = "" -`) - - err = helpers.WriteToDisk(filepath.Join(inpath, "theme.toml"), bytes.NewReader(by), fs.Source) - if err != nil { - return - } - - return nil -} diff --git a/commands/release.go b/commands/release.go index 4de165f35..059f04eb8 100644 --- a/commands/release.go +++ b/commands/release.go @@ -1,6 +1,4 @@ -// +build release - -// Copyright 2017-present The Hugo Authors. All rights reserved. +// Copyright 2024 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. @@ -16,57 +14,40 @@ package commands import ( - "errors" + "context" - "github.com/gohugoio/hugo/config" + "github.com/bep/simplecobra" "github.com/gohugoio/hugo/releaser" "github.com/spf13/cobra" ) -var _ cmder = (*releaseCommandeer)(nil) +// Note: This is a command only meant for internal use and must be run +// via "go run -tags release main.go release" on the actual code base that is in the release. +func newReleaseCommand() simplecobra.Commander { + var ( + step int + skipPush bool + try bool + ) -type releaseCommandeer struct { - cmd *cobra.Command + return &simpleCommand{ + name: "release", + short: "Release a new version of Hugo", + run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error { + rel, err := releaser.New(skipPush, try, step) + if err != nil { + return err + } - version string - - skipPublish bool - try bool -} - -func createReleaser() cmder { - // Note: This is a command only meant for internal use and must be run - // via "go run -tags release main.go release" on the actual code base that is in the release. - r := &releaseCommandeer{ - cmd: &cobra.Command{ - Use: "release", - Short: "Release a new version of Hugo.", - Hidden: true, + return rel.Run() + }, + withc: func(cmd *cobra.Command, r *rootCommand) { + cmd.Hidden = true + cmd.ValidArgsFunction = cobra.NoFileCompletions + cmd.PersistentFlags().BoolVarP(&skipPush, "skip-push", "", false, "skip pushing to remote") + cmd.PersistentFlags().BoolVarP(&try, "try", "", false, "no changes") + cmd.PersistentFlags().IntVarP(&step, "step", "", 0, "step to run (1: set new version 2: prepare next dev version)") + _ = cmd.RegisterFlagCompletionFunc("step", cobra.FixedCompletions([]string{"1", "2"}, cobra.ShellCompDirectiveNoFileComp)) }, } - - r.cmd.RunE = func(cmd *cobra.Command, args []string) error { - return r.release() - } - - r.cmd.PersistentFlags().StringVarP(&r.version, "rel", "r", "", "new release version, i.e. 0.25.1") - r.cmd.PersistentFlags().BoolVarP(&r.skipPublish, "skip-publish", "", false, "skip all publishing pipes of the release") - r.cmd.PersistentFlags().BoolVarP(&r.try, "try", "", false, "simulate a release, i.e. no changes") - - return r -} - -func (c *releaseCommandeer) getCommand() *cobra.Command { - return c.cmd -} - -func (c *releaseCommandeer) flagsToConfig(cfg config.Provider) { - -} - -func (r *releaseCommandeer) release() error { - if r.version == "" { - return errors.New("must set the --rel flag to the relevant version number") - } - return releaser.New(r.version, r.skipPublish, r.try).Run() } diff --git a/commands/release_noop.go b/commands/release_noop.go deleted file mode 100644 index ccf34b68e..000000000 --- a/commands/release_noop.go +++ /dev/null @@ -1,20 +0,0 @@ -// +build !release - -// Copyright 2018 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 commands - -func createReleaser() cmder { - return &nilCommand{} -} diff --git a/commands/server.go b/commands/server.go index 728847492..c8895b9a1 100644 --- a/commands/server.go +++ b/commands/server.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Hugo Authors. All rights reserved. +// Copyright 2024 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. @@ -15,105 +15,416 @@ package commands import ( "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" "fmt" "io" + "maps" "net" "net/http" + _ "net/http/pprof" "net/url" "os" "os/signal" + "path" "path/filepath" "regexp" - "runtime" + "sort" "strconv" "strings" "sync" + "sync/atomic" "syscall" "time" - "github.com/pkg/errors" + "github.com/bep/mclib" + "github.com/pkg/browser" - "github.com/gohugoio/hugo/livereload" + "github.com/bep/debounce" + "github.com/bep/simplecobra" + "github.com/fsnotify/fsnotify" + "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/common/hugo" + "github.com/gohugoio/hugo/tpl/tplimpl" + "github.com/gohugoio/hugo/common/types" + "github.com/gohugoio/hugo/common/urls" "github.com/gohugoio/hugo/config" "github.com/gohugoio/hugo/helpers" + "github.com/gohugoio/hugo/hugofs" + "github.com/gohugoio/hugo/hugolib" + "github.com/gohugoio/hugo/hugolib/filesystems" + "github.com/gohugoio/hugo/livereload" + "github.com/gohugoio/hugo/transform" + "github.com/gohugoio/hugo/transform/livereloadinject" "github.com/spf13/afero" "github.com/spf13/cobra" - jww "github.com/spf13/jwalterweatherman" + "github.com/spf13/fsync" + "golang.org/x/sync/errgroup" + "golang.org/x/sync/semaphore" ) -type serverCmd struct { - // Can be used to stop the server. Useful in tests - stop <-chan bool +var ( + logDuplicateTemplateExecuteRe = regexp.MustCompile(`: template: .*?:\d+:\d+: executing ".*?"`) + logDuplicateTemplateParseRe = regexp.MustCompile(`: template: .*?:\d+:\d*`) +) - disableLiveReload bool - navigateToChanged bool - renderToDisk bool - serverAppend bool - serverInterface string - serverPort int - liveReloadPort int - serverWatch bool - noHTTPCache bool +var logReplacer = strings.NewReplacer( + "can't", "can’t", // Chroma lexer doesn't do well with "can't" + "*hugolib.pageState", "page.Page", // Page is the public interface. + "Rebuild failed:", "", +) - disableFastRender bool - disableBrowserError bool +const ( + configChangeConfig = "config file" + configChangeGoMod = "go.mod file" + configChangeGoWork = "go work file" +) - *baseBuilderCmd +const ( + hugoHeaderRedirect = "X-Hugo-Redirect" +) + +func newHugoBuilder(r *rootCommand, s *serverCommand, onConfigLoaded ...func(reloaded bool) error) *hugoBuilder { + var visitedURLs *types.EvictingQueue[string] + if s != nil && !s.disableFastRender { + visitedURLs = types.NewEvictingQueue[string](20) + } + return &hugoBuilder{ + r: r, + s: s, + visitedURLs: visitedURLs, + fullRebuildSem: semaphore.NewWeighted(1), + debounce: debounce.New(4 * time.Second), + onConfigLoaded: func(reloaded bool) error { + for _, wc := range onConfigLoaded { + if err := wc(reloaded); err != nil { + return err + } + } + return nil + }, + } } -func (b *commandsBuilder) newServerCmd() *serverCmd { - return b.newServerCmdSignaled(nil) +func newServerCommand() *serverCommand { + // Flags. + var uninstall bool + + c := &serverCommand{ + quit: make(chan bool), + commands: []simplecobra.Commander{ + &simpleCommand{ + name: "trust", + short: "Install the local CA in the system trust store", + run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error { + action := "-install" + if uninstall { + action = "-uninstall" + } + os.Args = []string{action} + return mclib.RunMain() + }, + withc: func(cmd *cobra.Command, r *rootCommand) { + cmd.ValidArgsFunction = cobra.NoFileCompletions + cmd.Flags().BoolVar(&uninstall, "uninstall", false, "Uninstall the local CA (but do not delete it).") + }, + }, + }, + } + + return c } -func (b *commandsBuilder) newServerCmdSignaled(stop <-chan bool) *serverCmd { - cc := &serverCmd{stop: stop} +func (c *serverCommand) Commands() []simplecobra.Commander { + return c.commands +} - cc.baseBuilderCmd = b.newBuilderCmd(&cobra.Command{ - Use: "server", - Aliases: []string{"serve"}, - Short: "A high performance webserver", - Long: `Hugo provides its own webserver which builds and serves the site. -While hugo server is high performance, it is a webserver with limited options. -Many run it in production, but the standard behavior is for people to use it -in development and use a more full featured server such as Nginx or Caddy. +type countingStatFs struct { + afero.Fs + statCounter uint64 +} -'hugo server' will avoid writing the rendered and served content to disk, -preferring to store it in memory. +func (fs *countingStatFs) Stat(name string) (os.FileInfo, error) { + f, err := fs.Fs.Stat(name) + if err == nil { + if !f.IsDir() { + atomic.AddUint64(&fs.statCounter, 1) + } + } + return f, err +} -By default hugo will also watch your files for any changes you make and -automatically rebuild the site. It will then live reload any open browser pages -and push the latest content to them. As most Hugo sites are built in a fraction -of a second, you will be able to save and see your changes nearly instantly.`, - RunE: cc.server, +// dynamicEvents contains events that is considered dynamic, as in "not static". +// Both of these categories will trigger a new build, but the asset events +// does not fit into the "navigate to changed" logic. +type dynamicEvents struct { + ContentEvents []fsnotify.Event + AssetEvents []fsnotify.Event +} + +type fileChangeDetector struct { + sync.Mutex + current map[string]uint64 + prev map[string]uint64 + + irrelevantRe *regexp.Regexp +} + +func (f *fileChangeDetector) OnFileClose(name string, checksum uint64) { + f.Lock() + defer f.Unlock() + f.current[name] = checksum +} + +func (f *fileChangeDetector) PrepareNew() { + if f == nil { + return + } + + f.Lock() + defer f.Unlock() + + if f.current == nil { + f.current = make(map[string]uint64) + f.prev = make(map[string]uint64) + return + } + + f.prev = make(map[string]uint64) + maps.Copy(f.prev, f.current) + f.current = make(map[string]uint64) +} + +func (f *fileChangeDetector) changed() []string { + if f == nil { + return nil + } + f.Lock() + defer f.Unlock() + var c []string + for k, v := range f.current { + vv, found := f.prev[k] + if !found || v != vv { + c = append(c, k) + } + } + + return f.filterIrrelevantAndSort(c) +} + +func (f *fileChangeDetector) filterIrrelevantAndSort(in []string) []string { + var filtered []string + for _, v := range in { + if !f.irrelevantRe.MatchString(v) { + filtered = append(filtered, v) + } + } + sort.Strings(filtered) + return filtered +} + +type fileServer struct { + baseURLs []urls.BaseURL + roots []string + errorTemplate func(err any) (io.Reader, error) + c *serverCommand +} + +func (f *fileServer) createEndpoint(i int) (*http.ServeMux, net.Listener, string, string, error) { + r := f.c.r + baseURL := f.baseURLs[i] + root := f.roots[i] + port := f.c.serverPorts[i].p + listener := f.c.serverPorts[i].ln + logger := f.c.r.logger + + if i == 0 { + r.Printf("Environment: %q\n", f.c.hugoTry().Deps.Site.Hugo().Environment) + mainTarget := "disk" + if f.c.r.renderToMemory { + mainTarget = "memory" + } + if f.c.renderStaticToDisk { + r.Printf("Serving pages from %s and static files from disk\n", mainTarget) + } else { + r.Printf("Serving pages from %s\n", mainTarget) + } + } + + var httpFs *afero.HttpFs + f.c.withConf(func(conf *commonConfig) { + httpFs = afero.NewHttpFs(conf.fs.PublishDirServer) }) - cc.cmd.Flags().IntVarP(&cc.serverPort, "port", "p", 1313, "port on which the server will listen") - cc.cmd.Flags().IntVar(&cc.liveReloadPort, "liveReloadPort", -1, "port for live reloading (i.e. 443 in HTTPS proxy situations)") - cc.cmd.Flags().StringVarP(&cc.serverInterface, "bind", "", "127.0.0.1", "interface to which the server will bind") - cc.cmd.Flags().BoolVarP(&cc.serverWatch, "watch", "w", true, "watch filesystem for changes and recreate as needed") - cc.cmd.Flags().BoolVar(&cc.noHTTPCache, "noHTTPCache", false, "prevent HTTP caching") - cc.cmd.Flags().BoolVarP(&cc.serverAppend, "appendPort", "", true, "append port to baseURL") - cc.cmd.Flags().BoolVar(&cc.disableLiveReload, "disableLiveReload", false, "watch without enabling live browser reload on rebuild") - cc.cmd.Flags().BoolVar(&cc.navigateToChanged, "navigateToChanged", false, "navigate to changed content file on live browser reload") - cc.cmd.Flags().BoolVar(&cc.renderToDisk, "renderToDisk", false, "render to Destination path (default is render to memory & serve from there)") - cc.cmd.Flags().BoolVar(&cc.disableFastRender, "disableFastRender", false, "enables full re-renders on changes") - cc.cmd.Flags().BoolVar(&cc.disableBrowserError, "disableBrowserError", false, "do not show build errors in the browser") + fs := filesOnlyFs{httpFs.Dir(path.Join("/", root))} + if i == 0 && f.c.fastRenderMode { + r.Println("Running in Fast Render Mode. For full rebuilds on change: hugo server --disableFastRender") + } - cc.cmd.Flags().String("memstats", "", "log memory usage to this file") - cc.cmd.Flags().String("meminterval", "100ms", "interval to poll memory usage (requires --memstats), valid time units are \"ns\", \"us\" (or \"µs\"), \"ms\", \"s\", \"m\", \"h\".") + decorate := func(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if f.c.showErrorInBrowser { + // First check the error state + err := f.c.getErrorWithContext() + if err != nil { + f.c.errState.setWasErr(true) + w.WriteHeader(500) + r, err := f.errorTemplate(err) + if err != nil { + logger.Errorln(err) + } - return cc + port = 1313 + f.c.withConf(func(conf *commonConfig) { + if lrport := conf.configs.GetFirstLanguageConfig().BaseURLLiveReload().Port(); lrport != 0 { + port = lrport + } + }) + lr := baseURL.URL() + lr.Host = fmt.Sprintf("%s:%d", lr.Hostname(), port) + fmt.Fprint(w, injectLiveReloadScript(r, lr)) + + return + } + } + + if f.c.noHTTPCache { + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") + w.Header().Set("Pragma", "no-cache") + } + + var serverConfig config.Server + f.c.withConf(func(conf *commonConfig) { + serverConfig = conf.configs.Base.Server + }) + + // Ignore any query params for the operations below. + requestURI, _ := url.PathUnescape(strings.TrimSuffix(r.RequestURI, "?"+r.URL.RawQuery)) + + for _, header := range serverConfig.MatchHeaders(requestURI) { + w.Header().Set(header.Key, header.Value) + } + + if canRedirect(requestURI, r) { + if redirect := serverConfig.MatchRedirect(requestURI, r.Header); !redirect.IsZero() { + doRedirect := true + // This matches Netlify's behavior and is needed for SPA behavior. + // See https://docs.netlify.com/routing/redirects/rewrites-proxies/ + if !redirect.Force { + path := filepath.Clean(strings.TrimPrefix(requestURI, baseURL.Path())) + if root != "" { + path = filepath.Join(root, path) + } + var fs afero.Fs + f.c.withConf(func(conf *commonConfig) { + fs = conf.fs.PublishDirServer + }) + + fi, err := fs.Stat(path) + + if err == nil { + if fi.IsDir() { + // There will be overlapping directories, so we + // need to check for a file. + _, err = fs.Stat(filepath.Join(path, "index.html")) + doRedirect = err != nil + } else { + doRedirect = false + } + } + } + + if doRedirect { + w.Header().Set(hugoHeaderRedirect, "true") + switch redirect.Status { + case 404: + w.WriteHeader(404) + file, err := fs.Open(strings.TrimPrefix(redirect.To, baseURL.Path())) + if err == nil { + defer file.Close() + io.Copy(w, file) + } else { + fmt.Fprintln(w, "

Page Not Found

") + } + return + case 200: + if r2 := f.rewriteRequest(r, strings.TrimPrefix(redirect.To, baseURL.Path())); r2 != nil { + requestURI = redirect.To + r = r2 + } + default: + w.Header().Set("Content-Type", "") + http.Redirect(w, r, redirect.To, redirect.Status) + return + + } + } + } + } + + if f.c.fastRenderMode && f.c.errState.buildErr() == nil { + if isNavigation(requestURI, r) { + if !f.c.visitedURLs.Contains(requestURI) { + // If not already on stack, re-render that single page. + if err := f.c.partialReRender(requestURI); err != nil { + f.c.handleBuildErr(err, fmt.Sprintf("Failed to render %q", requestURI)) + if f.c.showErrorInBrowser { + http.Redirect(w, r, requestURI, http.StatusMovedPermanently) + return + } + } + } + + f.c.visitedURLs.Add(requestURI) + + } + } + + h.ServeHTTP(w, r) + }) + } + + fileserver := decorate(http.FileServer(fs)) + mu := http.NewServeMux() + if baseURL.Path() == "" || baseURL.Path() == "/" { + mu.Handle("/", fileserver) + } else { + mu.Handle(baseURL.Path(), http.StripPrefix(baseURL.Path(), fileserver)) + } + if r.IsTestRun() { + var shutDownOnce sync.Once + mu.HandleFunc("/__stop", func(w http.ResponseWriter, r *http.Request) { + shutDownOnce.Do(func() { + close(f.c.quit) + }) + }) + } + + endpoint := net.JoinHostPort(f.c.serverInterface, strconv.Itoa(port)) + + return mu, listener, baseURL.String(), endpoint, nil +} + +func (f *fileServer) rewriteRequest(r *http.Request, toPath string) *http.Request { + r2 := new(http.Request) + *r2 = *r + r2.URL = new(url.URL) + *r2.URL = *r.URL + r2.URL.Path = toPath + r2.Header.Set("X-Rewrite-Original-URI", r.URL.RequestURI()) + + return r2 } type filesOnlyFs struct { fs http.FileSystem } -type noDirFile struct { - http.File -} - func (fs filesOnlyFs) Open(name string) (http.File, error) { f, err := fs.fs.Open(name) if err != nil { @@ -122,134 +433,56 @@ func (fs filesOnlyFs) Open(name string) (http.File, error) { return noDirFile{f}, nil } +type noDirFile struct { + http.File +} + func (f noDirFile) Readdir(count int) ([]os.FileInfo, error) { return nil, nil } -var serverPorts []int +type serverCommand struct { + r *rootCommand -func (sc *serverCmd) server(cmd *cobra.Command, args []string) error { - // If a Destination is provided via flag write to disk - destination, _ := cmd.Flags().GetString("destination") - if destination != "" { - sc.renderToDisk = true + commands []simplecobra.Commander + + *hugoBuilder + + quit chan bool // Closed when the server should shut down. Used in tests only. + serverPorts []serverPortListener + doLiveReload bool + + // Flags. + renderStaticToDisk bool + navigateToChanged bool + openBrowser bool + serverAppend bool + serverInterface string + tlsCertFile string + tlsKeyFile string + tlsAuto bool + pprof bool + serverPort int + liveReloadPort int + serverWatch bool + noHTTPCache bool + disableLiveReload bool + disableFastRender bool + disableBrowserError bool +} + +func (c *serverCommand) Name() string { + return "server" +} + +func (c *serverCommand) Run(ctx context.Context, cd *simplecobra.Commandeer, args []string) error { + if c.pprof { + go func() { + http.ListenAndServe("localhost:8080", nil) + }() } - - var serverCfgInit sync.Once - - cfgInit := func(c *commandeer) error { - c.Set("renderToMemory", !sc.renderToDisk) - if cmd.Flags().Changed("navigateToChanged") { - c.Set("navigateToChanged", sc.navigateToChanged) - } - if cmd.Flags().Changed("disableLiveReload") { - c.Set("disableLiveReload", sc.disableLiveReload) - } - if cmd.Flags().Changed("disableFastRender") { - c.Set("disableFastRender", sc.disableFastRender) - } - if cmd.Flags().Changed("disableBrowserError") { - c.Set("disableBrowserError", sc.disableBrowserError) - } - if sc.serverWatch { - c.Set("watch", true) - } - - // TODO(bep) yes, we should fix. - if !c.languagesConfigured { - return nil - } - - var err error - - // We can only do this once. - serverCfgInit.Do(func() { - serverPorts = make([]int, 1) - - if c.languages.IsMultihost() { - if !sc.serverAppend { - err = newSystemError("--appendPort=false not supported when in multihost mode") - } - serverPorts = make([]int, len(c.languages)) - } - - currentServerPort := sc.serverPort - - for i := 0; i < len(serverPorts); i++ { - l, err := net.Listen("tcp", net.JoinHostPort(sc.serverInterface, strconv.Itoa(currentServerPort))) - if err == nil { - l.Close() - serverPorts[i] = currentServerPort - } else { - if i == 0 && sc.cmd.Flags().Changed("port") { - // port set explicitly by user -- he/she probably meant it! - err = newSystemErrorF("Server startup failed: %s", err) - } - c.logger.FEEDBACK.Println("port", sc.serverPort, "already in use, attempting to use an available port") - sp, err := helpers.FindAvailablePort() - if err != nil { - err = newSystemError("Unable to find alternative port to use:", err) - } - serverPorts[i] = sp.Port - } - - currentServerPort = serverPorts[i] + 1 - } - }) - - c.serverPorts = serverPorts - - c.Set("port", sc.serverPort) - if sc.liveReloadPort != -1 { - c.Set("liveReloadPort", sc.liveReloadPort) - } else { - c.Set("liveReloadPort", serverPorts[0]) - } - - isMultiHost := c.languages.IsMultihost() - for i, language := range c.languages { - var serverPort int - if isMultiHost { - serverPort = serverPorts[i] - } else { - serverPort = serverPorts[0] - } - - baseURL, err := sc.fixURL(language, sc.baseURL, serverPort) - if err != nil { - return nil - } - if isMultiHost { - language.Set("baseURL", baseURL) - } - if i == 0 { - c.Set("baseURL", baseURL) - } - } - - return err - - } - - if err := memStats(); err != nil { - jww.WARN.Println("memstats error:", err) - } - - c, err := initializeConfig(true, true, &sc.hugoBuilderCommon, sc, cfgInit) - if err != nil { - return err - } - - if err := c.serverBuild(); err != nil { - return err - } - - for _, s := range c.hugo().Sites { - s.RegisterMediaTypes() - } - // Watch runs its own server as part of the routine - if sc.serverWatch { + if c.serverWatch { watchDirs, err := c.getDirList() if err != nil { @@ -259,10 +492,9 @@ func (sc *serverCmd) server(cmd *cobra.Command, args []string) error { watchGroups := helpers.ExtractAndGroupRootPaths(watchDirs) for _, group := range watchGroups { - jww.FEEDBACK.Printf("Watching for changes in %s\n", group) + c.r.Printf("Watching for changes in %s\n", group) } - watcher, err := c.newWatcher(watchDirs...) - + watcher, err := c.newWatcher(c.r.poll, watchDirs...) if err != nil { return err } @@ -271,250 +503,333 @@ func (sc *serverCmd) server(cmd *cobra.Command, args []string) error { } - return c.serve(sc) - -} - -func getRootWatchDirsStr(baseDir string, watchDirs []string) string { - relWatchDirs := make([]string, len(watchDirs)) - for i, dir := range watchDirs { - relWatchDirs[i], _ = helpers.GetRelativePath(dir, baseDir) - } - - return strings.Join(helpers.UniqueStringsSorted(helpers.ExtractRootPaths(relWatchDirs)), ",") -} - -type fileServer struct { - baseURLs []string - roots []string - errorTemplate func(err interface{}) (io.Reader, error) - c *commandeer - s *serverCmd -} - -func (f *fileServer) createEndpoint(i int) (*http.ServeMux, string, string, error) { - baseURL := f.baseURLs[i] - root := f.roots[i] - port := f.c.serverPorts[i] - - publishDir := f.c.Cfg.GetString("publishDir") - - if root != "" { - publishDir = filepath.Join(publishDir, root) - } - - absPublishDir := f.c.hugo().PathSpec.AbsPathify(publishDir) - - jww.FEEDBACK.Printf("Environment: %q", f.c.hugo().Deps.Site.Hugo().Environment) - - if i == 0 { - if f.s.renderToDisk { - jww.FEEDBACK.Println("Serving pages from " + absPublishDir) - } else { - jww.FEEDBACK.Println("Serving pages from memory") - } - } - - httpFs := afero.NewHttpFs(f.c.destinationFs) - fs := filesOnlyFs{httpFs.Dir(absPublishDir)} - - if i == 0 && f.c.fastRenderMode { - jww.FEEDBACK.Println("Running in Fast Render Mode. For full rebuilds on change: hugo server --disableFastRender") - } - - // We're only interested in the path - u, err := url.Parse(baseURL) - if err != nil { - return nil, "", "", errors.Wrap(err, "Invalid baseURL") - } - - decorate := func(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if f.c.showErrorInBrowser { - // First check the error state - err := f.c.getErrorWithContext() - if err != nil { - f.c.wasError = true - w.WriteHeader(500) - r, err := f.errorTemplate(err) - if err != nil { - f.c.logger.ERROR.Println(err) - } - port = 1313 - if !f.c.paused { - port = f.c.Cfg.GetInt("liveReloadPort") - } - fmt.Fprint(w, injectLiveReloadScript(r, port)) - - return - } - } - - if f.s.noHTTPCache { - w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") - w.Header().Set("Pragma", "no-cache") - } - - if f.c.fastRenderMode && f.c.buildErr == nil { - p := r.RequestURI - if strings.HasSuffix(p, "/") || strings.HasSuffix(p, "html") || strings.HasSuffix(p, "htm") { - if !f.c.visitedURLs.Contains(p) { - // If not already on stack, re-render that single page. - if err := f.c.partialReRender(p); err != nil { - f.c.handleBuildErr(err, fmt.Sprintf("Failed to render %q", p)) - if f.c.showErrorInBrowser { - http.Redirect(w, r, p, http.StatusMovedPermanently) - return - } - } - } - - f.c.visitedURLs.Add(p) - - } - } - h.ServeHTTP(w, r) - }) - } - - fileserver := decorate(http.FileServer(fs)) - mu := http.NewServeMux() - - if u.Path == "" || u.Path == "/" { - mu.Handle("/", fileserver) - } else { - mu.Handle(u.Path, http.StripPrefix(u.Path, fileserver)) - } - - endpoint := net.JoinHostPort(f.s.serverInterface, strconv.Itoa(port)) - - return mu, u.String(), endpoint, nil -} - -var logErrorRe = regexp.MustCompile(`(?s)ERROR \d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2} `) - -func removeErrorPrefixFromLog(content string) string { - return logErrorRe.ReplaceAllLiteralString(content, "") -} -func (c *commandeer) serve(s *serverCmd) error { - - isMultiHost := c.hugo().IsMultihost() - - var ( - baseURLs []string - roots []string - ) - - if isMultiHost { - for _, s := range c.hugo().Sites { - baseURLs = append(baseURLs, s.BaseURL.String()) - roots = append(roots, s.Language().Lang) - } - } else { - s := c.hugo().Sites[0] - baseURLs = []string{s.BaseURL.String()} - roots = []string{""} - } - - templ, err := c.hugo().TextTmpl().Parse("__default_server_error", buildErrorTemplate) + err := func() error { + defer c.r.timeTrack(time.Now(), "Built") + return c.build() + }() if err != nil { return err } - srv := &fileServer{ - baseURLs: baseURLs, - roots: roots, - c: c, - s: s, - errorTemplate: func(ctx interface{}) (io.Reader, error) { - b := &bytes.Buffer{} - err := c.hugo().Tmpl().Execute(templ, b, ctx) - return b, err - }, - } + return c.serve() +} - doLiveReload := !c.Cfg.GetBool("disableLiveReload") +func (c *serverCommand) Init(cd *simplecobra.Commandeer) error { + cmd := cd.CobraCommand + cmd.Short = "Start the embedded web server" + cmd.Long = `Hugo provides its own webserver which builds and serves the site. +While hugo server is high performance, it is a webserver with limited options. - if doLiveReload { - livereload.Initialize() - } +The ` + "`" + `hugo server` + "`" + ` command will by default write and serve files from disk, but +you can render to memory by using the ` + "`" + `--renderToMemory` + "`" + ` flag. This can be +faster in some cases, but it will consume more memory. - var sigs = make(chan os.Signal, 1) - signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) +By default hugo will also watch your files for any changes you make and +automatically rebuild the site. It will then live reload any open browser pages +and push the latest content to them. As most Hugo sites are built in a fraction +of a second, you will be able to save and see your changes nearly instantly.` + cmd.Aliases = []string{"serve"} - for i := range baseURLs { - mu, serverURL, endpoint, err := srv.createEndpoint(i) + cmd.Flags().IntVarP(&c.serverPort, "port", "p", 1313, "port on which the server will listen") + _ = cmd.RegisterFlagCompletionFunc("port", cobra.NoFileCompletions) + cmd.Flags().IntVar(&c.liveReloadPort, "liveReloadPort", -1, "port for live reloading (i.e. 443 in HTTPS proxy situations)") + _ = cmd.RegisterFlagCompletionFunc("liveReloadPort", cobra.NoFileCompletions) + cmd.Flags().StringVarP(&c.serverInterface, "bind", "", "127.0.0.1", "interface to which the server will bind") + _ = cmd.RegisterFlagCompletionFunc("bind", cobra.NoFileCompletions) + cmd.Flags().StringVarP(&c.tlsCertFile, "tlsCertFile", "", "", "path to TLS certificate file") + _ = cmd.MarkFlagFilename("tlsCertFile", "pem") + cmd.Flags().StringVarP(&c.tlsKeyFile, "tlsKeyFile", "", "", "path to TLS key file") + _ = cmd.MarkFlagFilename("tlsKeyFile", "pem") + 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().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") + cmd.Flags().BoolVarP(&c.openBrowser, "openBrowser", "O", false, "open the site in a browser after server startup") + cmd.Flags().BoolVar(&c.renderStaticToDisk, "renderStaticToDisk", false, "serve static files from disk and dynamic files from memory") + cmd.Flags().BoolVar(&c.disableFastRender, "disableFastRender", false, "enables full re-renders on changes") + cmd.Flags().BoolVar(&c.disableBrowserError, "disableBrowserError", false, "do not show build errors in the browser") - if doLiveReload { - mu.HandleFunc("/livereload.js", livereload.ServeJS) - mu.HandleFunc("/livereload", livereload.Handler) - } - jww.FEEDBACK.Printf("Web Server is available at %s (bind address %s)\n", serverURL, s.serverInterface) - go func() { - err = http.ListenAndServe(endpoint, mu) - if err != nil { - c.logger.ERROR.Printf("Error: %s\n", err.Error()) - os.Exit(1) + r := cd.Root.Command.(*rootCommand) + applyLocalFlagsBuild(cmd, r) + + return nil +} + +func (c *serverCommand) PreRun(cd, runner *simplecobra.Commandeer) error { + c.r = cd.Root.Command.(*rootCommand) + + c.hugoBuilder = newHugoBuilder( + c.r, + c, + func(reloaded bool) error { + if !reloaded { + if err := c.createServerPorts(cd); err != nil { + return err + } + + if (c.tlsCertFile == "" || c.tlsKeyFile == "") && c.tlsAuto { + c.withConfE(func(conf *commonConfig) error { + return c.createCertificates(conf) + }) + } } - }() + + if err := c.setServerInfoInConfig(); err != nil { + return err + } + + if !reloaded && c.fastRenderMode { + c.withConf(func(conf *commonConfig) { + conf.fs.PublishDir = hugofs.NewHashingFs(conf.fs.PublishDir, c.changeDetector) + conf.fs.PublishDirStatic = hugofs.NewHashingFs(conf.fs.PublishDirStatic, c.changeDetector) + }) + } + + return nil + }, + ) + + destinationFlag := cd.CobraCommand.Flags().Lookup("destination") + if c.r.renderToMemory && (destinationFlag != nil && destinationFlag.Changed) { + return fmt.Errorf("cannot use --renderToMemory with --destination") + } + c.doLiveReload = !c.disableLiveReload + c.fastRenderMode = !c.disableFastRender + c.showErrorInBrowser = c.doLiveReload && !c.disableBrowserError + + if c.fastRenderMode { + // For now, fast render mode only. It should, however, be fast enough + // for the full variant, too. + c.changeDetector = &fileChangeDetector{ + // We use this detector to decide to do a Hot reload of a single path or not. + // We need to filter out source maps and possibly some other to be able + // to make that decision. + irrelevantRe: regexp.MustCompile(`\.map$`), + } + + c.changeDetector.PrepareNew() + } - jww.FEEDBACK.Println("Press Ctrl+C to stop") - - if s.stop != nil { - select { - case <-sigs: - case <-s.stop: - } - } else { - <-sigs + err := c.loadConfig(cd, true) + if err != nil { + return err } return nil } +func (c *serverCommand) setServerInfoInConfig() error { + if len(c.serverPorts) == 0 { + panic("no server ports set") + } + return c.withConfE(func(conf *commonConfig) error { + for i, language := range conf.configs.LanguagesDefaultFirst { + isMultihost := conf.configs.IsMultihost + var serverPort int + if isMultihost { + serverPort = c.serverPorts[i].p + } else { + serverPort = c.serverPorts[0].p + } + langConfig := conf.configs.LanguageConfigMap[language.Lang] + baseURLStr, err := c.fixURL(langConfig.BaseURL, c.r.baseURL, serverPort) + if err != nil { + return err + } + baseURL, err := urls.NewBaseURLFromString(baseURLStr) + if err != nil { + return fmt.Errorf("failed to create baseURL from %q: %s", baseURLStr, err) + } + + baseURLLiveReload := baseURL + if c.liveReloadPort != -1 { + baseURLLiveReload, _ = baseURLLiveReload.WithPort(c.liveReloadPort) + } + langConfig.C.SetServerInfo(baseURL, baseURLLiveReload, c.serverInterface) + + } + return nil + }) +} + +func (c *serverCommand) getErrorWithContext() any { + buildErr := c.errState.buildErr() + if buildErr == nil { + return nil + } + + m := make(map[string]any) + + m["Error"] = cleanErrorLog(c.r.logger.Errors()) + + m["Version"] = hugo.BuildVersionString() + ferrors := herrors.UnwrapFileErrorsWithErrorContext(buildErr) + m["Files"] = ferrors + + return m +} + +func (c *serverCommand) createCertificates(conf *commonConfig) error { + hostname := "localhost" + if c.r.baseURL != "" { + u, err := url.Parse(c.r.baseURL) + if err != nil { + return err + } + hostname = u.Hostname() + } + + // For now, store these in the Hugo cache dir. + // Hugo should probably introduce some concept of a less temporary application directory. + keyDir := filepath.Join(conf.configs.LoadingInfo.BaseConfig.CacheDir, "_mkcerts") + + // Create the directory if it doesn't exist. + if _, err := os.Stat(keyDir); os.IsNotExist(err) { + if err := os.MkdirAll(keyDir, 0o777); err != nil { + return err + } + } + + c.tlsCertFile = filepath.Join(keyDir, fmt.Sprintf("%s.pem", hostname)) + c.tlsKeyFile = filepath.Join(keyDir, fmt.Sprintf("%s-key.pem", hostname)) + + // Check if the certificate already exists and is valid. + certPEM, err := os.ReadFile(c.tlsCertFile) + if err == nil { + rootPem, err := os.ReadFile(filepath.Join(mclib.GetCAROOT(), "rootCA.pem")) + if err == nil { + if err := c.verifyCert(rootPem, certPEM, hostname); err == nil { + c.r.Println("Using existing", c.tlsCertFile, "and", c.tlsKeyFile) + return nil + } + } + } + + c.r.Println("Creating TLS certificates in", keyDir) + + // Yes, this is unfortunate, but it's currently the only way to use Mkcert as a library. + os.Args = []string{"-cert-file", c.tlsCertFile, "-key-file", c.tlsKeyFile, hostname} + return mclib.RunMain() +} + +func (c *serverCommand) verifyCert(rootPEM, certPEM []byte, name string) error { + roots := x509.NewCertPool() + ok := roots.AppendCertsFromPEM(rootPEM) + if !ok { + return fmt.Errorf("failed to parse root certificate") + } + + block, _ := pem.Decode(certPEM) + if block == nil { + return fmt.Errorf("failed to parse certificate PEM") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return fmt.Errorf("failed to parse certificate: %v", err.Error()) + } + + opts := x509.VerifyOptions{ + DNSName: name, + Roots: roots, + } + + if _, err := cert.Verify(opts); err != nil { + return fmt.Errorf("failed to verify certificate: %v", err.Error()) + } + + return nil +} + +func (c *serverCommand) createServerPorts(cd *simplecobra.Commandeer) error { + flags := cd.CobraCommand.Flags() + var cerr error + c.withConf(func(conf *commonConfig) { + isMultihost := conf.configs.IsMultihost + c.serverPorts = make([]serverPortListener, 1) + if isMultihost { + if !c.serverAppend { + cerr = errors.New("--appendPort=false not supported when in multihost mode") + return + } + c.serverPorts = make([]serverPortListener, len(conf.configs.Languages)) + } + currentServerPort := c.serverPort + for i := range c.serverPorts { + l, err := net.Listen("tcp", net.JoinHostPort(c.serverInterface, strconv.Itoa(currentServerPort))) + if err == nil { + c.serverPorts[i] = serverPortListener{ln: l, p: currentServerPort} + } else { + if i == 0 && flags.Changed("port") { + // port set explicitly by user -- he/she probably meant it! + cerr = fmt.Errorf("server startup failed: %s", err) + return + } + c.r.Println("port", currentServerPort, "already in use, attempting to use an available port") + l, sp, err := helpers.TCPListen() + if err != nil { + cerr = fmt.Errorf("unable to find alternative port to use: %s", err) + return + } + c.serverPorts[i] = serverPortListener{ln: l, p: sp.Port} + } + + currentServerPort = c.serverPorts[i].p + 1 + } + }) + + return cerr +} + // fixURL massages the baseURL into a form needed for serving // all pages correctly. -func (sc *serverCmd) fixURL(cfg config.Provider, s string, port int) (string, error) { +func (c *serverCommand) fixURL(baseURLFromConfig, baseURLFromFlag string, port int) (string, error) { + certsSet := (c.tlsCertFile != "" && c.tlsKeyFile != "") || c.tlsAuto useLocalhost := false - if s == "" { - s = cfg.GetString("baseURL") + baseURL := baseURLFromFlag + if baseURL == "" { + baseURL = baseURLFromConfig useLocalhost = true } - if !strings.HasSuffix(s, "/") { - s = s + "/" + if !strings.HasSuffix(baseURL, "/") { + baseURL = baseURL + "/" } // do an initial parse of the input string - u, err := url.Parse(s) + u, err := url.Parse(baseURL) if err != nil { return "", err } // if no Host is defined, then assume that no schema or double-slash were // present in the url. Add a double-slash and make a best effort attempt. - if u.Host == "" && s != "/" { - s = "//" + s + if u.Host == "" && baseURL != "/" { + baseURL = "//" + baseURL - u, err = url.Parse(s) + u, err = url.Parse(baseURL) if err != nil { return "", err } } if useLocalhost { - if u.Scheme == "https" { + if certsSet { + u.Scheme = "https" + } else if u.Scheme == "https" { u.Scheme = "http" } u.Host = "localhost" } - if sc.serverAppend { + if c.serverAppend { if strings.Contains(u.Host, ":") { u.Host, _, err = net.SplitHostPort(u.Host) if err != nil { - return "", errors.Wrap(err, "Failed to split baseURL hostpost") + return "", fmt.Errorf("failed to split baseURL hostport: %w", err) } } u.Host += fmt.Sprintf(":%d", port) @@ -523,39 +838,420 @@ func (sc *serverCmd) fixURL(cfg config.Provider, s string, port int) (string, er return u.String(), nil } -func memStats() error { - b := newCommandsBuilder() - sc := b.newServerCmd().getCommand() - memstats := sc.Flags().Lookup("memstats").Value.String() - if memstats != "" { - interval, err := time.ParseDuration(sc.Flags().Lookup("meminterval").Value.String()) - if err != nil { - interval, _ = time.ParseDuration("100ms") - } +func (c *serverCommand) partialReRender(urls ...string) (err error) { + defer func() { + c.errState.setWasErr(false) + }() + visited := types.NewEvictingQueue[string](len(urls)) + for _, url := range urls { + visited.Add(url) + } - fileMemStats, err := os.Create(memstats) + var h *hugolib.HugoSites + h, err = c.hugo() + if err != nil { + return + } + + // Note: We do not set NoBuildLock as the file lock is not acquired at this stage. + err = h.Build(hugolib.BuildCfg{NoBuildLock: false, RecentlyTouched: visited, PartialReRender: true, ErrRecovery: c.errState.wasErr()}) + + return +} + +func (c *serverCommand) serve() error { + var ( + baseURLs []urls.BaseURL + roots []string + h *hugolib.HugoSites + ) + err := c.withConfE(func(conf *commonConfig) error { + isMultihost := conf.configs.IsMultihost + var err error + h, err = c.r.HugFromConfig(conf) if err != nil { return err } - fileMemStats.WriteString("# Time\tHeapSys\tHeapAlloc\tHeapIdle\tHeapReleased\n") + // We need the server to share the same logger as the Hugo build (for error counts etc.) + c.r.logger = h.Log - go func() { - var stats runtime.MemStats - - start := time.Now().UnixNano() - - for { - runtime.ReadMemStats(&stats) - if fileMemStats != nil { - fileMemStats.WriteString(fmt.Sprintf("%d\t%d\t%d\t%d\t%d\n", - (time.Now().UnixNano()-start)/1000000, stats.HeapSys, stats.HeapAlloc, stats.HeapIdle, stats.HeapReleased)) - time.Sleep(interval) - } else { - break - } + if isMultihost { + for _, l := range conf.configs.ConfigLangs() { + baseURLs = append(baseURLs, l.BaseURL()) + roots = append(roots, l.Language().Lang) } - }() + } else { + l := conf.configs.GetFirstLanguageConfig() + baseURLs = []urls.BaseURL{l.BaseURL()} + roots = []string{""} + } + + return nil + }) + if err != nil { + return err } - return nil + + // Cache it here. The HugoSites object may be unavailable later on due to intermittent configuration errors. + // To allow the en user to change the error template while the server is running, we use + // the freshest template we can provide. + var ( + errTempl *tplimpl.TemplInfo + templHandler *tplimpl.TemplateStore + ) + getErrorTemplateAndHandler := func(h *hugolib.HugoSites) (*tplimpl.TemplInfo, *tplimpl.TemplateStore) { + if h == nil { + return errTempl, templHandler + } + templHandler := h.GetTemplateStore() + errTempl := templHandler.LookupByPath("/_server/error.html") + if errTempl == nil { + panic("template server/error.html not found") + } + return errTempl, templHandler + } + errTempl, templHandler = getErrorTemplateAndHandler(h) + + srv := &fileServer{ + baseURLs: baseURLs, + roots: roots, + c: c, + errorTemplate: func(ctx any) (io.Reader, error) { + // hugoTry does not block, getErrorTemplateAndHandler will fall back + // to cached values if nil. + templ, handler := getErrorTemplateAndHandler(c.hugoTry()) + b := &bytes.Buffer{} + err := handler.ExecuteWithContext(context.Background(), templ, b, ctx) + return b, err + }, + } + + doLiveReload := !c.disableLiveReload + + if doLiveReload { + livereload.Initialize() + } + + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + var servers []*http.Server + + wg1, ctx := errgroup.WithContext(context.Background()) + + for i := range baseURLs { + mu, listener, serverURL, endpoint, err := srv.createEndpoint(i) + var srv *http.Server + if c.tlsCertFile != "" && c.tlsKeyFile != "" { + srv = &http.Server{ + Addr: endpoint, + Handler: mu, + TLSConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + }, + } + } else { + srv = &http.Server{ + Addr: endpoint, + Handler: mu, + } + } + + servers = append(servers, srv) + + if doLiveReload { + baseURL := baseURLs[i] + mu.HandleFunc(baseURL.Path()+"livereload.js", livereload.ServeJS) + mu.HandleFunc(baseURL.Path()+"livereload", livereload.Handler) + } + c.r.Printf("Web Server is available at %s (bind address %s) %s\n", serverURL, c.serverInterface, roots[i]) + wg1.Go(func() error { + if c.tlsCertFile != "" && c.tlsKeyFile != "" { + err = srv.ServeTLS(listener, c.tlsCertFile, c.tlsKeyFile) + } else { + err = srv.Serve(listener) + } + if err != nil && err != http.ErrServerClosed { + return err + } + return nil + }) + } + + if c.r.IsTestRun() { + // Write a .ready file to disk to signal ready status. + // This is where the test is run from. + var baseURLs []string + for _, baseURL := range srv.baseURLs { + baseURLs = append(baseURLs, baseURL.String()) + } + testInfo := map[string]any{ + "baseURLs": baseURLs, + } + + dir := os.Getenv("WORK") + if dir != "" { + readyFile := filepath.Join(dir, ".ready") + // encode the test info as JSON into the .ready file. + b, err := json.Marshal(testInfo) + if err != nil { + return err + } + err = os.WriteFile(readyFile, b, 0o777) + if err != nil { + return err + } + } + + } + + c.r.Println("Press Ctrl+C to stop") + + if c.openBrowser { + // There may be more than one baseURL in multihost mode, open the first. + if err := browser.OpenURL(baseURLs[0].String()); err != nil { + c.r.logger.Warnf("Failed to open browser: %s", err) + } + } + + err = func() error { + for { + select { + case <-c.quit: + return nil + case <-sigs: + return nil + case <-ctx.Done(): + return ctx.Err() + } + } + }() + if err != nil { + c.r.Println("Error:", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + wg2, ctx := errgroup.WithContext(ctx) + for _, srv := range servers { + srv := srv + wg2.Go(func() error { + return srv.Shutdown(ctx) + }) + } + + err1, err2 := wg1.Wait(), wg2.Wait() + if err1 != nil { + return err1 + } + return err2 +} + +type serverPortListener struct { + p int + ln net.Listener +} + +type staticSyncer struct { + c *hugoBuilder +} + +func (s *staticSyncer) isStatic(h *hugolib.HugoSites, filename string) bool { + return h.BaseFs.SourceFilesystems.IsStatic(filename) +} + +func (s *staticSyncer) syncsStaticEvents(staticEvents []fsnotify.Event) error { + c := s.c + + syncFn := func(sourceFs *filesystems.SourceFilesystem) (uint64, error) { + publishDir := helpers.FilePathSeparator + + if sourceFs.PublishFolder != "" { + publishDir = filepath.Join(publishDir, sourceFs.PublishFolder) + } + + syncer := fsync.NewSyncer() + c.withConf(func(conf *commonConfig) { + syncer.NoTimes = conf.configs.Base.NoTimes + syncer.NoChmod = conf.configs.Base.NoChmod + syncer.ChmodFilter = chmodFilter + syncer.SrcFs = sourceFs.Fs + syncer.DestFs = conf.fs.PublishDir + if c.s != nil && c.s.renderStaticToDisk { + syncer.DestFs = conf.fs.PublishDirStatic + } + }) + + logger := s.c.r.logger + + for _, ev := range staticEvents { + // Due to our approach of layering both directories and the content's rendered output + // into one we can't accurately remove a file not in one of the source directories. + // If a file is in the local static dir and also in the theme static dir and we remove + // it from one of those locations we expect it to still exist in the destination + // + // If Hugo generates a file (from the content dir) over a static file + // the content generated file should take precedence. + // + // Because we are now watching and handling individual events it is possible that a static + // event that occupies the same path as a content generated file will take precedence + // until a regeneration of the content takes places. + // + // Hugo assumes that these cases are very rare and will permit this bad behavior + // The alternative is to track every single file and which pipeline rendered it + // and then to handle conflict resolution on every event. + + fromPath := ev.Name + + relPath, found := sourceFs.MakePathRelative(fromPath, true) + + if !found { + // Not member of this virtual host. + continue + } + + // Remove || rename is harder and will require an assumption. + // Hugo takes the following approach: + // If the static file exists in any of the static source directories after this event + // Hugo will re-sync it. + // If it does not exist in all of the static directories Hugo will remove it. + // + // This assumes that Hugo has not generated content on top of a static file and then removed + // the source of that static file. In this case Hugo will incorrectly remove that file + // from the published directory. + if ev.Op&fsnotify.Rename == fsnotify.Rename || ev.Op&fsnotify.Remove == fsnotify.Remove { + if _, err := sourceFs.Fs.Stat(relPath); herrors.IsNotExist(err) { + // If file doesn't exist in any static dir, remove it + logger.Println("File no longer exists in static dir, removing", relPath) + c.withConf(func(conf *commonConfig) { + _ = conf.fs.PublishDirStatic.RemoveAll(relPath) + }) + + } else if err == nil { + // If file still exists, sync it + logger.Println("Syncing", relPath, "to", publishDir) + + if err := syncer.Sync(relPath, relPath); err != nil { + c.r.logger.Errorln(err) + } + } else { + c.r.logger.Errorln(err) + } + + continue + } + + // For all other event operations Hugo will sync static. + logger.Println("Syncing", relPath, "to", publishDir) + if err := syncer.Sync(filepath.Join(publishDir, relPath), relPath); err != nil { + c.r.logger.Errorln(err) + } + } + + return 0, nil + } + + _, err := c.doWithPublishDirs(syncFn) + return err +} + +func chmodFilter(dst, src os.FileInfo) bool { + // Hugo publishes data from multiple sources, potentially + // with overlapping directory structures. We cannot sync permissions + // for directories as that would mean that we might end up with write-protected + // directories inside /public. + // One example of this would be syncing from the Go Module cache, + // which have 0555 directories. + return src.IsDir() +} + +func cleanErrorLog(content string) string { + content = strings.ReplaceAll(content, "\n", " ") + content = logReplacer.Replace(content) + content = logDuplicateTemplateExecuteRe.ReplaceAllString(content, "") + content = logDuplicateTemplateParseRe.ReplaceAllString(content, "") + seen := make(map[string]bool) + parts := strings.Split(content, ": ") + keep := make([]string, 0, len(parts)) + for _, part := range parts { + if seen[part] { + continue + } + seen[part] = true + keep = append(keep, part) + } + return strings.Join(keep, ": ") +} + +func injectLiveReloadScript(src io.Reader, baseURL *url.URL) string { + var b bytes.Buffer + chain := transform.Chain{livereloadinject.New(baseURL)} + chain.Apply(&b, src) + + return b.String() +} + +func partitionDynamicEvents(sourceFs *filesystems.SourceFilesystems, events []fsnotify.Event) (de dynamicEvents) { + for _, e := range events { + if !sourceFs.IsContent(e.Name) { + de.AssetEvents = append(de.AssetEvents, e) + } else { + de.ContentEvents = append(de.ContentEvents, e) + } + } + return +} + +func pickOneWriteOrCreatePath(contentTypes config.ContentTypesProvider, events []fsnotify.Event) string { + name := "" + + for _, ev := range events { + if ev.Op&fsnotify.Write == fsnotify.Write || ev.Op&fsnotify.Create == fsnotify.Create { + if contentTypes.IsIndexContentFile(ev.Name) { + return ev.Name + } + + if contentTypes.IsContentFile(ev.Name) { + name = ev.Name + } + + } + } + + return name +} + +func formatByteCount(b uint64) string { + const unit = 1000 + if b < unit { + return fmt.Sprintf("%d B", b) + } + div, exp := int64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", + float64(b)/float64(div), "kMGTPE"[exp]) +} + +func canRedirect(requestURIWithoutQuery string, r *http.Request) bool { + if r.Header.Get(hugoHeaderRedirect) != "" { + return false + } + return isNavigation(requestURIWithoutQuery, r) +} + +// Sec-Fetch-Mode should be sent by all recent browser versions, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Mode#navigate +// Fall back to the file extension if not set. +// The main take here is that we don't want to have CSS/JS files etc. partake in this logic. +func isNavigation(requestURIWithoutQuery string, r *http.Request) bool { + return r.Header.Get("Sec-Fetch-Mode") == "navigate" || isPropablyHTMLRequest(requestURIWithoutQuery) +} + +func isPropablyHTMLRequest(requestURIWithoutQuery string) bool { + if strings.HasSuffix(requestURIWithoutQuery, "/") || strings.HasSuffix(requestURIWithoutQuery, "html") || strings.HasSuffix(requestURIWithoutQuery, "htm") { + return true + } + return !strings.Contains(requestURIWithoutQuery, ".") } diff --git a/commands/server_errors.go b/commands/server_errors.go deleted file mode 100644 index 9f13c9d8c..000000000 --- a/commands/server_errors.go +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright 2018 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 commands - -import ( - "bytes" - "io" - - "github.com/gohugoio/hugo/transform" - "github.com/gohugoio/hugo/transform/livereloadinject" -) - -var buildErrorTemplate = ` - - - - Hugo Server: Error - - - -
- {{ highlight .Error "apl" "noclasses=true,style=monokai" }} - {{ with .File }} - {{ $params := printf "noclasses=true,style=monokai,linenos=table,hl_lines=%d,linenostart=%d" (add .LinesPos 1) (sub .Position.LineNumber .LinesPos) }} - {{ $lexer := .ChromaLexer | default "go-html-template" }} - {{ highlight (delimit .Lines "\n") $lexer $params }} - {{ end }} - {{ with .StackTrace }} - {{ highlight . "apl" "noclasses=true,style=monokai" }} - {{ end }} -

{{ .Version }}

- Reload Page -
- - -` - -func injectLiveReloadScript(src io.Reader, port int) string { - var b bytes.Buffer - chain := transform.Chain{livereloadinject.New(port)} - chain.Apply(&b, src) - - return b.String() -} diff --git a/commands/server_test.go b/commands/server_test.go deleted file mode 100644 index 04e874f94..000000000 --- a/commands/server_test.go +++ /dev/null @@ -1,135 +0,0 @@ -// Copyright 2015 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 commands - -import ( - "fmt" - "net/http" - "os" - "runtime" - "strings" - "testing" - "time" - - "github.com/gohugoio/hugo/helpers" - - qt "github.com/frankban/quicktest" - "github.com/spf13/viper" -) - -func TestServer(t *testing.T) { - if isWindowsCI() { - // TODO(bep) not sure why server tests have started to fail on the Windows CI server. - t.Skip("Skip server test on appveyor") - } - c := qt.New(t) - dir, clean, err := createSimpleTestSite(t, testSiteConfig{}) - defer clean() - c.Assert(err, qt.IsNil) - - // Let us hope that this port is available on all systems ... - port := 1331 - - defer func() { - os.RemoveAll(dir) - }() - - stop := make(chan bool) - - b := newCommandsBuilder() - scmd := b.newServerCmdSignaled(stop) - - cmd := scmd.getCommand() - cmd.SetArgs([]string{"-s=" + dir, fmt.Sprintf("-p=%d", port)}) - - go func() { - _, err = cmd.ExecuteC() - c.Assert(err, qt.IsNil) - }() - - // There is no way to know exactly when the server is ready for connections. - // We could improve by something like https://golang.org/pkg/net/http/httptest/#Server - // But for now, let us sleep and pray! - time.Sleep(2 * time.Second) - - resp, err := http.Get("http://localhost:1331/") - c.Assert(err, qt.IsNil) - defer resp.Body.Close() - homeContent := helpers.ReaderToString(resp.Body) - - c.Assert(homeContent, qt.Contains, "List: Hugo Commands") - c.Assert(homeContent, qt.Contains, "Environment: development") - - // Stop the server. - stop <- true - -} - -func TestFixURL(t *testing.T) { - type data struct { - TestName string - CLIBaseURL string - CfgBaseURL string - AppendPort bool - Port int - Result string - } - tests := []data{ - {"Basic http localhost", "", "http://foo.com", true, 1313, "http://localhost:1313/"}, - {"Basic https production, http localhost", "", "https://foo.com", true, 1313, "http://localhost:1313/"}, - {"Basic subdir", "", "http://foo.com/bar", true, 1313, "http://localhost:1313/bar/"}, - {"Basic production", "http://foo.com", "http://foo.com", false, 80, "http://foo.com/"}, - {"Production subdir", "http://foo.com/bar", "http://foo.com/bar", false, 80, "http://foo.com/bar/"}, - {"No http", "", "foo.com", true, 1313, "//localhost:1313/"}, - {"Override configured port", "", "foo.com:2020", true, 1313, "//localhost:1313/"}, - {"No http production", "foo.com", "foo.com", false, 80, "//foo.com/"}, - {"No http production with port", "foo.com", "foo.com", true, 2020, "//foo.com:2020/"}, - {"No config", "", "", true, 1313, "//localhost:1313/"}, - } - - for _, test := range tests { - t.Run(test.TestName, func(t *testing.T) { - b := newCommandsBuilder() - s := b.newServerCmd() - v := viper.New() - baseURL := test.CLIBaseURL - v.Set("baseURL", test.CfgBaseURL) - s.serverAppend = test.AppendPort - s.serverPort = test.Port - result, err := s.fixURL(v, baseURL, s.serverPort) - if err != nil { - t.Errorf("Unexpected error %s", err) - } - if result != test.Result { - t.Errorf("Expected %q, got %q", test.Result, result) - } - }) - } -} - -func TestRemoveErrorPrefixFromLog(t *testing.T) { - c := qt.New(t) - content := `ERROR 2018/10/07 13:11:12 Error while rendering "home": template: _default/baseof.html:4:3: executing "main" at : error calling partial: template: partials/logo.html:5:84: executing "partials/logo.html" at <$resized.AHeight>: can't evaluate field AHeight in type *resource.Image -ERROR 2018/10/07 13:11:12 Rebuild failed: logged 1 error(s) -` - - withoutError := removeErrorPrefixFromLog(content) - - c.Assert(strings.Contains(withoutError, "ERROR"), qt.Equals, false) - -} - -func isWindowsCI() bool { - return runtime.GOOS == "windows" && os.Getenv("CI") != "" -} diff --git a/commands/static_syncer.go b/commands/static_syncer.go deleted file mode 100644 index 62ef28b2c..000000000 --- a/commands/static_syncer.go +++ /dev/null @@ -1,132 +0,0 @@ -// Copyright 2017 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 commands - -import ( - "os" - "path/filepath" - - "github.com/gohugoio/hugo/hugolib/filesystems" - - "github.com/fsnotify/fsnotify" - "github.com/gohugoio/hugo/helpers" - "github.com/spf13/fsync" -) - -type staticSyncer struct { - c *commandeer -} - -func newStaticSyncer(c *commandeer) (*staticSyncer, error) { - return &staticSyncer{c: c}, nil -} - -func (s *staticSyncer) isStatic(filename string) bool { - return s.c.hugo().BaseFs.SourceFilesystems.IsStatic(filename) -} - -func (s *staticSyncer) syncsStaticEvents(staticEvents []fsnotify.Event) error { - c := s.c - - syncFn := func(sourceFs *filesystems.SourceFilesystem) (uint64, error) { - publishDir := c.hugo().PathSpec.PublishDir - // If root, remove the second '/' - if publishDir == "//" { - publishDir = helpers.FilePathSeparator - } - - if sourceFs.PublishFolder != "" { - publishDir = filepath.Join(publishDir, sourceFs.PublishFolder) - } - - syncer := fsync.NewSyncer() - syncer.NoTimes = c.Cfg.GetBool("noTimes") - syncer.NoChmod = c.Cfg.GetBool("noChmod") - syncer.ChmodFilter = chmodFilter - syncer.SrcFs = sourceFs.Fs - syncer.DestFs = c.Fs.Destination - - // prevent spamming the log on changes - logger := helpers.NewDistinctFeedbackLogger() - - for _, ev := range staticEvents { - // Due to our approach of layering both directories and the content's rendered output - // into one we can't accurately remove a file not in one of the source directories. - // If a file is in the local static dir and also in the theme static dir and we remove - // it from one of those locations we expect it to still exist in the destination - // - // If Hugo generates a file (from the content dir) over a static file - // the content generated file should take precedence. - // - // Because we are now watching and handling individual events it is possible that a static - // event that occupies the same path as a content generated file will take precedence - // until a regeneration of the content takes places. - // - // Hugo assumes that these cases are very rare and will permit this bad behavior - // The alternative is to track every single file and which pipeline rendered it - // and then to handle conflict resolution on every event. - - fromPath := ev.Name - - relPath := sourceFs.MakePathRelative(fromPath) - - if relPath == "" { - // Not member of this virtual host. - continue - } - - // Remove || rename is harder and will require an assumption. - // Hugo takes the following approach: - // If the static file exists in any of the static source directories after this event - // Hugo will re-sync it. - // If it does not exist in all of the static directories Hugo will remove it. - // - // This assumes that Hugo has not generated content on top of a static file and then removed - // the source of that static file. In this case Hugo will incorrectly remove that file - // from the published directory. - if ev.Op&fsnotify.Rename == fsnotify.Rename || ev.Op&fsnotify.Remove == fsnotify.Remove { - if _, err := sourceFs.Fs.Stat(relPath); os.IsNotExist(err) { - // If file doesn't exist in any static dir, remove it - toRemove := filepath.Join(publishDir, relPath) - - logger.Println("File no longer exists in static dir, removing", toRemove) - _ = c.Fs.Destination.RemoveAll(toRemove) - } else if err == nil { - // If file still exists, sync it - logger.Println("Syncing", relPath, "to", publishDir) - - if err := syncer.Sync(filepath.Join(publishDir, relPath), relPath); err != nil { - c.logger.ERROR.Println(err) - } - } else { - c.logger.ERROR.Println(err) - } - - continue - } - - // For all other event operations Hugo will sync static. - logger.Println("Syncing", relPath, "to", publishDir) - if err := syncer.Sync(filepath.Join(publishDir, relPath), relPath); err != nil { - c.logger.ERROR.Println(err) - } - } - - return 0, nil - } - - _, err := c.doWithPublishDirs(syncFn) - return err - -} diff --git a/commands/version.go b/commands/version.go deleted file mode 100644 index 287950a2d..000000000 --- a/commands/version.go +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright 2015 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 commands - -import ( - "github.com/gohugoio/hugo/common/hugo" - "github.com/spf13/cobra" - jww "github.com/spf13/jwalterweatherman" -) - -var _ cmder = (*versionCmd)(nil) - -type versionCmd struct { - *baseCmd -} - -func newVersionCmd() *versionCmd { - return &versionCmd{ - newBaseCmd(&cobra.Command{ - Use: "version", - Short: "Print the version number of Hugo", - Long: `All software has versions. This is Hugo's.`, - RunE: func(cmd *cobra.Command, args []string) error { - printHugoVersion() - return nil - }, - }), - } -} - -func printHugoVersion() { - jww.FEEDBACK.Println(hugo.BuildVersionString()) -} diff --git a/common/collections/append.go b/common/collections/append.go index b56455bc9..db9db8bf3 100644 --- a/common/collections/append.go +++ b/common/collections/append.go @@ -21,38 +21,73 @@ import ( // Append appends from to a slice to and returns the resulting slice. // If length of from is one and the only element is a slice of same type as to, // it will be appended. -func Append(to interface{}, from ...interface{}) (interface{}, error) { +func Append(to any, from ...any) (any, error) { + if len(from) == 0 { + return to, nil + } tov, toIsNil := indirect(reflect.ValueOf(to)) toIsNil = toIsNil || to == nil var tot reflect.Type if !toIsNil { + if tov.Kind() == reflect.Slice { + // Create a copy of tov, so we don't modify the original. + c := reflect.MakeSlice(tov.Type(), tov.Len(), tov.Len()+len(from)) + reflect.Copy(c, tov) + tov = c + } + if tov.Kind() != reflect.Slice { return nil, fmt.Errorf("expected a slice, got %T", to) } tot = tov.Type().Elem() + if tot.Kind() == reflect.Slice { + totvt := tot.Elem() + fromvs := make([]reflect.Value, len(from)) + for i, f := range from { + fromv := reflect.ValueOf(f) + fromt := fromv.Type() + if fromt.Kind() == reflect.Slice { + fromt = fromt.Elem() + } + if totvt != fromt { + return nil, fmt.Errorf("cannot append slice of %s to slice of %s", fromt, totvt) + } else { + fromvs[i] = fromv + } + } + return reflect.Append(tov, fromvs...).Interface(), nil + + } + toIsNil = tov.Len() == 0 if len(from) == 1 { fromv := reflect.ValueOf(from[0]) + if !fromv.IsValid() { + // from[0] is nil + return appendToInterfaceSliceFromValues(tov, fromv) + } + fromt := fromv.Type() + if fromt.Kind() == reflect.Slice { + fromt = fromt.Elem() + } if fromv.Kind() == reflect.Slice { if toIsNil { // If we get nil []string, we just return the []string return from[0], nil } - fromt := reflect.TypeOf(from[0]).Elem() - // If we get []string []string, we append the from slice to to if tot == fromt { return reflect.AppendSlice(tov, fromv).Interface(), nil } else if !fromt.AssignableTo(tot) { // Fall back to a []interface{} slice. return appendToInterfaceSliceFromValues(tov, fromv) - } + } } } @@ -63,7 +98,7 @@ func Append(to interface{}, from ...interface{}) (interface{}, error) { for _, f := range from { fv := reflect.ValueOf(f) - if !fv.Type().AssignableTo(tot) { + if !fv.IsValid() || !fv.Type().AssignableTo(tot) { // Fall back to a []interface{} slice. tov, _ := indirect(reflect.ValueOf(to)) return appendToInterfaceSlice(tov, from...) @@ -74,11 +109,15 @@ func Append(to interface{}, from ...interface{}) (interface{}, error) { return tov.Interface(), nil } -func appendToInterfaceSliceFromValues(slice1, slice2 reflect.Value) ([]interface{}, error) { - var tos []interface{} +func appendToInterfaceSliceFromValues(slice1, slice2 reflect.Value) ([]any, error) { + var tos []any for _, slice := range []reflect.Value{slice1, slice2} { - for i := 0; i < slice.Len(); i++ { + if !slice.IsValid() { + tos = append(tos, nil) + continue + } + for i := range slice.Len() { tos = append(tos, slice.Index(i).Interface()) } } @@ -86,10 +125,10 @@ func appendToInterfaceSliceFromValues(slice1, slice2 reflect.Value) ([]interface return tos, nil } -func appendToInterfaceSlice(tov reflect.Value, from ...interface{}) ([]interface{}, error) { - var tos []interface{} +func appendToInterfaceSlice(tov reflect.Value, from ...any) ([]any, error) { + var tos []any - for i := 0; i < tov.Len(); i++ { + for i := range tov.Len() { tos = append(tos, tov.Index(i).Interface()) } diff --git a/common/collections/append_test.go b/common/collections/append_test.go index 4086570b8..62d9015ce 100644 --- a/common/collections/append_test.go +++ b/common/collections/append_test.go @@ -15,6 +15,7 @@ package collections import ( "html/template" + "reflect" "testing" qt "github.com/frankban/quicktest" @@ -24,40 +25,60 @@ func TestAppend(t *testing.T) { t.Parallel() c := qt.New(t) - for _, test := range []struct { - start interface{} - addend []interface{} - expected interface{} + for i, test := range []struct { + start any + addend []any + expected any }{ - {[]string{"a", "b"}, []interface{}{"c"}, []string{"a", "b", "c"}}, - {[]string{"a", "b"}, []interface{}{"c", "d", "e"}, []string{"a", "b", "c", "d", "e"}}, - {[]string{"a", "b"}, []interface{}{[]string{"c", "d", "e"}}, []string{"a", "b", "c", "d", "e"}}, - {[]string{"a"}, []interface{}{"b", template.HTML("c")}, []interface{}{"a", "b", template.HTML("c")}}, - {nil, []interface{}{"a", "b"}, []string{"a", "b"}}, - {nil, []interface{}{nil}, []interface{}{nil}}, - {[]interface{}{}, []interface{}{[]string{"c", "d", "e"}}, []string{"c", "d", "e"}}, - {tstSlicers{&tstSlicer{"a"}, &tstSlicer{"b"}}, - []interface{}{&tstSlicer{"c"}}, - tstSlicers{&tstSlicer{"a"}, &tstSlicer{"b"}, &tstSlicer{"c"}}}, - {&tstSlicers{&tstSlicer{"a"}, &tstSlicer{"b"}}, - []interface{}{&tstSlicer{"c"}}, - tstSlicers{&tstSlicer{"a"}, + {[]string{"a", "b"}, []any{"c"}, []string{"a", "b", "c"}}, + {[]string{"a", "b"}, []any{"c", "d", "e"}, []string{"a", "b", "c", "d", "e"}}, + {[]string{"a", "b"}, []any{[]string{"c", "d", "e"}}, []string{"a", "b", "c", "d", "e"}}, + {[]string{"a"}, []any{"b", template.HTML("c")}, []any{"a", "b", template.HTML("c")}}, + {nil, []any{"a", "b"}, []string{"a", "b"}}, + {nil, []any{nil}, []any{nil}}, + {[]any{}, []any{[]string{"c", "d", "e"}}, []string{"c", "d", "e"}}, + { + tstSlicers{&tstSlicer{"a"}, &tstSlicer{"b"}}, + []any{&tstSlicer{"c"}}, + tstSlicers{&tstSlicer{"a"}, &tstSlicer{"b"}, &tstSlicer{"c"}}, + }, + { + &tstSlicers{&tstSlicer{"a"}, &tstSlicer{"b"}}, + []any{&tstSlicer{"c"}}, + tstSlicers{ + &tstSlicer{"a"}, &tstSlicer{"b"}, - &tstSlicer{"c"}}}, - {testSlicerInterfaces{&tstSlicerIn1{"a"}, &tstSlicerIn1{"b"}}, - []interface{}{&tstSlicerIn1{"c"}}, - testSlicerInterfaces{&tstSlicerIn1{"a"}, &tstSlicerIn1{"b"}, &tstSlicerIn1{"c"}}}, - //https://github.com/gohugoio/hugo/issues/5361 - {[]string{"a", "b"}, []interface{}{tstSlicers{&tstSlicer{"a"}, &tstSlicer{"b"}}}, - []interface{}{"a", "b", &tstSlicer{"a"}, &tstSlicer{"b"}}}, - {[]string{"a", "b"}, []interface{}{&tstSlicer{"a"}}, - []interface{}{"a", "b", &tstSlicer{"a"}}}, + &tstSlicer{"c"}, + }, + }, + { + testSlicerInterfaces{&tstSlicerIn1{"a"}, &tstSlicerIn1{"b"}}, + []any{&tstSlicerIn1{"c"}}, + testSlicerInterfaces{&tstSlicerIn1{"a"}, &tstSlicerIn1{"b"}, &tstSlicerIn1{"c"}}, + }, + // https://github.com/gohugoio/hugo/issues/5361 + { + []string{"a", "b"}, + []any{tstSlicers{&tstSlicer{"a"}, &tstSlicer{"b"}}}, + []any{"a", "b", &tstSlicer{"a"}, &tstSlicer{"b"}}, + }, + { + []string{"a", "b"}, + []any{&tstSlicer{"a"}}, + []any{"a", "b", &tstSlicer{"a"}}, + }, // Errors - {"", []interface{}{[]string{"a", "b"}}, false}, + {"", []any{[]string{"a", "b"}}, false}, // No string concatenation. - {"ab", - []interface{}{"c"}, - false}, + { + "ab", + []any{"c"}, + false, + }, + {[]string{"a", "b"}, []any{nil}, []any{"a", "b", nil}}, + {[]string{"a", "b"}, []any{nil, "d", nil}, []any{"a", "b", nil, "d", nil}}, + {[]any{"a", nil, "c"}, []any{"d", nil, "f"}, []any{"a", nil, "c", "d", nil, "f"}}, + {[]string{"a", "b"}, []any{}, []string{"a", "b"}}, } { result, err := Append(test.start, test.addend...) @@ -69,7 +90,124 @@ func TestAppend(t *testing.T) { } c.Assert(err, qt.IsNil) - c.Assert(result, qt.DeepEquals, test.expected) + c.Assert(result, qt.DeepEquals, test.expected, qt.Commentf("test: [%d] %v", i, test)) + } +} + +// #11093 +func TestAppendToMultiDimensionalSlice(t *testing.T) { + t.Parallel() + c := qt.New(t) + + for _, test := range []struct { + to any + from []any + expected any + }{ + { + [][]string{{"a", "b"}}, + []any{[]string{"c", "d"}}, + [][]string{ + {"a", "b"}, + {"c", "d"}, + }, + }, + { + [][]string{{"a", "b"}}, + []any{[]string{"c", "d"}, []string{"e", "f"}}, + [][]string{ + {"a", "b"}, + {"c", "d"}, + {"e", "f"}, + }, + }, + { + [][]string{{"a", "b"}}, + []any{[]int{1, 2}}, + false, + }, + } { + result, err := Append(test.to, test.from...) + if b, ok := test.expected.(bool); ok && !b { + c.Assert(err, qt.Not(qt.IsNil)) + } else { + c.Assert(err, qt.IsNil) + c.Assert(result, qt.DeepEquals, test.expected) + } + } +} + +func TestAppendShouldMakeACopyOfTheInputSlice(t *testing.T) { + t.Parallel() + c := qt.New(t) + slice := make([]string, 0, 100) + slice = append(slice, "a", "b") + result, err := Append(slice, "c") + c.Assert(err, qt.IsNil) + slice[0] = "d" + c.Assert(result, qt.DeepEquals, []string{"a", "b", "c"}) + c.Assert(slice, qt.DeepEquals, []string{"d", "b"}) +} + +func TestIndirect(t *testing.T) { + t.Parallel() + c := qt.New(t) + + type testStruct struct { + Field string } + var ( + nilPtr *testStruct + nilIface interface{} = nil + nonNilIface interface{} = &testStruct{Field: "hello"} + ) + + tests := []struct { + name string + input any + wantKind reflect.Kind + wantNil bool + }{ + { + name: "nil pointer", + input: nilPtr, + wantKind: reflect.Ptr, + wantNil: true, + }, + { + name: "nil interface", + input: nilIface, + wantKind: reflect.Invalid, + wantNil: false, + }, + { + name: "non-nil pointer to struct", + input: &testStruct{Field: "abc"}, + wantKind: reflect.Struct, + wantNil: false, + }, + { + name: "non-nil interface holding pointer", + input: nonNilIface, + wantKind: reflect.Struct, + wantNil: false, + }, + { + name: "plain value", + input: testStruct{Field: "xyz"}, + wantKind: reflect.Struct, + wantNil: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v := reflect.ValueOf(tt.input) + got, isNil := indirect(v) + + c.Assert(got.Kind(), qt.Equals, tt.wantKind) + c.Assert(isNil, qt.Equals, tt.wantNil) + }) + } } diff --git a/common/collections/collections.go b/common/collections/collections.go index bb47c8acc..0b46abee9 100644 --- a/common/collections/collections.go +++ b/common/collections/collections.go @@ -17,5 +17,5 @@ package collections // Grouper defines a very generic way to group items by a given key. type Grouper interface { - Group(key interface{}, items interface{}) (interface{}, error) + Group(key any, items any) (any, error) } diff --git a/common/collections/order.go b/common/collections/order.go new file mode 100644 index 000000000..4bdc3b4ac --- /dev/null +++ b/common/collections/order.go @@ -0,0 +1,20 @@ +// Copyright 2020 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 collections + +type Order interface { + // Ordinal is a zero-based ordinal that represents the order of an object + // in a collection. + Ordinal() int +} diff --git a/common/collections/slice.go b/common/collections/slice.go index 38ca86b08..731f489f9 100644 --- a/common/collections/slice.go +++ b/common/collections/slice.go @@ -15,17 +15,18 @@ package collections import ( "reflect" + "sort" ) // Slicer defines a very generic way to create a typed slice. This is used // in collections.Slice template func to get types such as Pages, PageGroups etc. // instead of the less useful []interface{}. type Slicer interface { - Slice(items interface{}) (interface{}, error) + Slice(items any) (any, error) } // Slice returns a slice of all passed arguments. -func Slice(args ...interface{}) interface{} { +func Slice(args ...any) any { if len(args) == 0 { return args } @@ -64,3 +65,31 @@ func Slice(args ...interface{}) interface{} { } return slice.Interface() } + +// StringSliceToInterfaceSlice converts ss to []interface{}. +func StringSliceToInterfaceSlice(ss []string) []any { + result := make([]any, len(ss)) + for i, s := range ss { + result[i] = s + } + return result +} + +type SortedStringSlice []string + +// Contains returns true if s is in ss. +func (ss SortedStringSlice) Contains(s string) bool { + i := sort.SearchStrings(ss, s) + return i < len(ss) && ss[i] == s +} + +// Count returns the number of times s is in ss. +func (ss SortedStringSlice) Count(s string) int { + var count int + i := sort.SearchStrings(ss, s) + for i < len(ss) && ss[i] == s { + count++ + i++ + } + return count +} diff --git a/common/collections/slice_test.go b/common/collections/slice_test.go index 3ebfe6d11..4008a5e6c 100644 --- a/common/collections/slice_test.go +++ b/common/collections/slice_test.go @@ -20,11 +20,13 @@ import ( qt "github.com/frankban/quicktest" ) -var _ Slicer = (*tstSlicer)(nil) -var _ Slicer = (*tstSlicerIn1)(nil) -var _ Slicer = (*tstSlicerIn2)(nil) -var _ testSlicerInterface = (*tstSlicerIn1)(nil) -var _ testSlicerInterface = (*tstSlicerIn1)(nil) +var ( + _ Slicer = (*tstSlicer)(nil) + _ Slicer = (*tstSlicerIn1)(nil) + _ Slicer = (*tstSlicerIn2)(nil) + _ testSlicerInterface = (*tstSlicerIn1)(nil) + _ testSlicerInterface = (*tstSlicerIn1)(nil) +) type testSlicerInterface interface { Name() string @@ -44,8 +46,8 @@ type tstSlicer struct { TheName string } -func (p *tstSlicerIn1) Slice(in interface{}) (interface{}, error) { - items := in.([]interface{}) +func (p *tstSlicerIn1) Slice(in any) (any, error) { + items := in.([]any) result := make(testSlicerInterfaces, len(items)) for i, v := range items { switch vv := v.(type) { @@ -54,13 +56,12 @@ func (p *tstSlicerIn1) Slice(in interface{}) (interface{}, error) { default: return nil, errors.New("invalid type") } - } return result, nil } -func (p *tstSlicerIn2) Slice(in interface{}) (interface{}, error) { - items := in.([]interface{}) +func (p *tstSlicerIn2) Slice(in any) (any, error) { + items := in.([]any) result := make(testSlicerInterfaces, len(items)) for i, v := range items { switch vv := v.(type) { @@ -81,8 +82,8 @@ func (p *tstSlicerIn2) Name() string { return p.TheName } -func (p *tstSlicer) Slice(in interface{}) (interface{}, error) { - items := in.([]interface{}) +func (p *tstSlicer) Slice(in any) (any, error) { + items := in.([]any) result := make(tstSlicers, len(items)) for i, v := range items { switch vv := v.(type) { @@ -102,17 +103,17 @@ func TestSlice(t *testing.T) { c := qt.New(t) for i, test := range []struct { - args []interface{} - expected interface{} + args []any + expected any }{ - {[]interface{}{"a", "b"}, []string{"a", "b"}}, - {[]interface{}{&tstSlicer{"a"}, &tstSlicer{"b"}}, tstSlicers{&tstSlicer{"a"}, &tstSlicer{"b"}}}, - {[]interface{}{&tstSlicer{"a"}, "b"}, []interface{}{&tstSlicer{"a"}, "b"}}, - {[]interface{}{}, []interface{}{}}, - {[]interface{}{nil}, []interface{}{nil}}, - {[]interface{}{5, "b"}, []interface{}{5, "b"}}, - {[]interface{}{&tstSlicerIn1{"a"}, &tstSlicerIn2{"b"}}, testSlicerInterfaces{&tstSlicerIn1{"a"}, &tstSlicerIn2{"b"}}}, - {[]interface{}{&tstSlicerIn1{"a"}, &tstSlicer{"b"}}, []interface{}{&tstSlicerIn1{"a"}, &tstSlicer{"b"}}}, + {[]any{"a", "b"}, []string{"a", "b"}}, + {[]any{&tstSlicer{"a"}, &tstSlicer{"b"}}, tstSlicers{&tstSlicer{"a"}, &tstSlicer{"b"}}}, + {[]any{&tstSlicer{"a"}, "b"}, []any{&tstSlicer{"a"}, "b"}}, + {[]any{}, []any{}}, + {[]any{nil}, []any{nil}}, + {[]any{5, "b"}, []any{5, "b"}}, + {[]any{&tstSlicerIn1{"a"}, &tstSlicerIn2{"b"}}, testSlicerInterfaces{&tstSlicerIn1{"a"}, &tstSlicerIn2{"b"}}}, + {[]any{&tstSlicerIn1{"a"}, &tstSlicer{"b"}}, []any{&tstSlicerIn1{"a"}, &tstSlicer{"b"}}}, } { errMsg := qt.Commentf("[%d] %v", i, test.args) @@ -120,5 +121,52 @@ func TestSlice(t *testing.T) { c.Assert(test.expected, qt.DeepEquals, result, errMsg) } - +} + +func TestSortedStringSlice(t *testing.T) { + t.Parallel() + c := qt.New(t) + + var s SortedStringSlice = []string{"a", "b", "b", "b", "c", "d"} + + c.Assert(s.Contains("a"), qt.IsTrue) + c.Assert(s.Contains("b"), qt.IsTrue) + c.Assert(s.Contains("z"), qt.IsFalse) + c.Assert(s.Count("b"), qt.Equals, 3) + c.Assert(s.Count("z"), qt.Equals, 0) + c.Assert(s.Count("a"), qt.Equals, 1) +} + +func TestStringSliceToInterfaceSlice(t *testing.T) { + t.Parallel() + c := qt.New(t) + + tests := []struct { + name string + in []string + want []any + }{ + { + name: "empty slice", + in: []string{}, + want: []any{}, + }, + { + name: "single element", + in: []string{"hello"}, + want: []any{"hello"}, + }, + { + name: "multiple elements", + in: []string{"a", "b", "c"}, + want: []any{"a", "b", "c"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := StringSliceToInterfaceSlice(tt.in) + c.Assert(got, qt.DeepEquals, tt.want) + }) + } } diff --git a/common/collections/stack.go b/common/collections/stack.go new file mode 100644 index 000000000..ff0db2f02 --- /dev/null +++ b/common/collections/stack.go @@ -0,0 +1,82 @@ +// Copyright 2024 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 collections + +import "slices" + +import "sync" + +// Stack is a simple LIFO stack that is safe for concurrent use. +type Stack[T any] struct { + items []T + zero T + mu sync.RWMutex +} + +func NewStack[T any]() *Stack[T] { + return &Stack[T]{} +} + +func (s *Stack[T]) Push(item T) { + s.mu.Lock() + defer s.mu.Unlock() + s.items = append(s.items, item) +} + +func (s *Stack[T]) Pop() (T, bool) { + s.mu.Lock() + defer s.mu.Unlock() + if len(s.items) == 0 { + return s.zero, false + } + item := s.items[len(s.items)-1] + s.items = s.items[:len(s.items)-1] + return item, true +} + +func (s *Stack[T]) Peek() (T, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + if len(s.items) == 0 { + return s.zero, false + } + return s.items[len(s.items)-1], true +} + +func (s *Stack[T]) Len() int { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.items) +} + +func (s *Stack[T]) Drain() []T { + s.mu.Lock() + defer s.mu.Unlock() + items := s.items + s.items = nil + return items +} + +func (s *Stack[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]) + s.items = slices.Delete(s.items, i, i+1) + } + } + return items +} diff --git a/common/collections/stack_test.go b/common/collections/stack_test.go new file mode 100644 index 000000000..965d4dbc8 --- /dev/null +++ b/common/collections/stack_test.go @@ -0,0 +1,77 @@ +package collections + +import ( + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestNewStack(t *testing.T) { + t.Parallel() + c := qt.New(t) + + s := NewStack[int]() + + c.Assert(s, qt.IsNotNil) +} + +func TestStackBasic(t *testing.T) { + t.Parallel() + c := qt.New(t) + + s := NewStack[int]() + + c.Assert(s.Len(), qt.Equals, 0) + + s.Push(1) + s.Push(2) + s.Push(3) + + c.Assert(s.Len(), qt.Equals, 3) + + top, ok := s.Peek() + c.Assert(ok, qt.Equals, true) + c.Assert(top, qt.Equals, 3) + + popped, ok := s.Pop() + c.Assert(ok, qt.Equals, true) + c.Assert(popped, qt.Equals, 3) + + c.Assert(s.Len(), qt.Equals, 2) + + _, _ = s.Pop() + _, _ = s.Pop() + _, ok = s.Pop() + + c.Assert(ok, qt.Equals, false) +} + +func TestStackDrain(t *testing.T) { + t.Parallel() + c := qt.New(t) + + s := NewStack[string]() + s.Push("a") + s.Push("b") + + got := s.Drain() + + c.Assert(got, qt.DeepEquals, []string{"a", "b"}) + c.Assert(s.Len(), qt.Equals, 0) +} + +func TestStackDrainMatching(t *testing.T) { + t.Parallel() + c := qt.New(t) + + s := NewStack[int]() + s.Push(1) + s.Push(2) + s.Push(3) + s.Push(4) + + got := s.DrainMatching(func(v int) bool { return v%2 == 0 }) + + c.Assert(got, qt.DeepEquals, []int{4, 2}) + c.Assert(s.Drain(), qt.DeepEquals, []int{1, 3}) +} diff --git a/common/constants/constants.go b/common/constants/constants.go new file mode 100644 index 000000000..c7bbaa541 --- /dev/null +++ b/common/constants/constants.go @@ -0,0 +1,49 @@ +// Copyright 2020 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 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" +) + +// Field/method names with special meaning. +const ( + FieldRelPermalink = "RelPermalink" + FieldPermalink = "Permalink" +) + +// IsFieldRelOrPermalink returns whether the given name is a RelPermalink or Permalink. +func IsFieldRelOrPermalink(name string) bool { + return name == FieldRelPermalink || name == FieldPermalink +} + +// Resource transformations. +const ( + ResourceTransformationFingerprint = "fingerprint" +) + +// IsResourceTransformationPermalinkHash returns whether the given name is a resource transformation that changes the permalink based on the content. +func IsResourceTransformationPermalinkHash(name string) bool { + return name == ResourceTransformationFingerprint +} diff --git a/common/docs.go b/common/docs.go new file mode 100644 index 000000000..041a62a01 --- /dev/null +++ b/common/docs.go @@ -0,0 +1,2 @@ +// Package common provides common helper functionality for Hugo. +package common diff --git a/common/hashing/hashing.go b/common/hashing/hashing.go new file mode 100644 index 000000000..e45356758 --- /dev/null +++ b/common/hashing/hashing.go @@ -0,0 +1,194 @@ +// Copyright 2024 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 hashing provides common hashing utilities. +package hashing + +import ( + "crypto/md5" + "encoding/hex" + "io" + "strconv" + "sync" + + "github.com/cespare/xxhash/v2" + "github.com/gohugoio/hashstructure" + "github.com/gohugoio/hugo/identity" +) + +// XXHashFromReader calculates the xxHash for the given reader. +func XXHashFromReader(r io.Reader) (uint64, int64, error) { + h := getXxHashReadFrom() + defer putXxHashReadFrom(h) + + size, err := io.Copy(h, r) + if err != nil { + return 0, 0, err + } + return h.Sum64(), size, nil +} + +// XxHashFromReaderHexEncoded calculates the xxHash for the given reader +// and returns the hash as a hex encoded string. +func XxHashFromReaderHexEncoded(r io.Reader) (string, error) { + h := getXxHashReadFrom() + defer putXxHashReadFrom(h) + _, err := io.Copy(h, r) + if err != nil { + return "", err + } + hash := h.Sum(nil) + return hex.EncodeToString(hash), nil +} + +// XXHashFromString calculates the xxHash for the given string. +func XXHashFromString(s string) (uint64, error) { + h := xxhash.New() + h.WriteString(s) + return h.Sum64(), nil +} + +// XxHashFromStringHexEncoded calculates the xxHash for the given string +// and returns the hash as a hex encoded string. +func XxHashFromStringHexEncoded(f string) string { + h := xxhash.New() + h.WriteString(f) + hash := h.Sum(nil) + return hex.EncodeToString(hash) +} + +// MD5FromStringHexEncoded returns the MD5 hash of the given string. +func MD5FromStringHexEncoded(f string) string { + h := md5.New() + h.Write([]byte(f)) + 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 +// it in the more complex values (e.g. Page) will not hash the full content. +func HashString(vs ...any) string { + hash := HashUint64(vs...) + return strconv.FormatUint(hash, 10) +} + +// HashStringHex returns a hash from the given elements as a hex encoded string. +// See HashString for more information. +func HashStringHex(vs ...any) string { + hash := HashUint64(vs...) + return strconv.FormatUint(hash, 16) +} + +var hashOptsPool = sync.Pool{ + New: func() any { + return &hashstructure.HashOptions{ + Hasher: xxhash.New(), + } + }, +} + +func getHashOpts() *hashstructure.HashOptions { + return hashOptsPool.Get().(*hashstructure.HashOptions) +} + +func putHashOpts(opts *hashstructure.HashOptions) { + opts.Hasher.Reset() + hashOptsPool.Put(opts) +} + +// HashUint64 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 +// it in the more complex values (e.g. Page) will not hash the full content. +func HashUint64(vs ...any) uint64 { + var o any + if len(vs) == 1 { + o = toHashable(vs[0]) + } else { + elements := make([]any, len(vs)) + for i, e := range vs { + elements[i] = toHashable(e) + } + o = elements + } + + hash, err := Hash(o) + if err != nil { + panic(err) + } + return hash +} + +// Hash returns a hash from vs. +func Hash(vs ...any) (uint64, error) { + hashOpts := getHashOpts() + defer putHashOpts(hashOpts) + var v any = vs + if len(vs) == 1 { + v = vs[0] + } + return hashstructure.Hash(v, hashOpts) +} + +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 +} + +func (x *xxhashReadFrom) ReadFrom(r io.Reader) (int64, error) { + for { + n, err := r.Read(x.buff) + if n > 0 { + x.Digest.Write(x.buff[:n]) + } + if err != nil { + if err == io.EOF { + err = nil + } + return int64(n), err + } + } +} + +var xXhashReadFromPool = sync.Pool{ + New: func() any { + return &xxhashReadFrom{Digest: xxhash.New(), buff: make([]byte, 48*1024)} + }, +} + +func getXxHashReadFrom() *xxhashReadFrom { + return xXhashReadFromPool.Get().(*xxhashReadFrom) +} + +func putXxHashReadFrom(h *xxhashReadFrom) { + h.Reset() + xXhashReadFromPool.Put(h) +} diff --git a/common/hashing/hashing_test.go b/common/hashing/hashing_test.go new file mode 100644 index 000000000..105b6d8b5 --- /dev/null +++ b/common/hashing/hashing_test.go @@ -0,0 +1,157 @@ +// Copyright 2024 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 hashing + +import ( + "fmt" + "math" + "strings" + "sync" + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestXxHashFromReader(t *testing.T) { + c := qt.New(t) + s := "Hello World" + r := strings.NewReader(s) + got, size, err := XXHashFromReader(r) + c.Assert(err, qt.IsNil) + c.Assert(size, qt.Equals, int64(len(s))) + c.Assert(got, qt.Equals, uint64(7148569436472236994)) +} + +func TestXxHashFromReaderPara(t *testing.T) { + c := qt.New(t) + + var wg sync.WaitGroup + for i := range 10 { + i := i + wg.Add(1) + go func() { + defer wg.Done() + for j := range 100 { + s := strings.Repeat("Hello ", i+j+1*42) + r := strings.NewReader(s) + got, size, err := XXHashFromReader(r) + c.Assert(size, qt.Equals, int64(len(s))) + c.Assert(err, qt.IsNil) + expect, _ := XXHashFromString(s) + c.Assert(got, qt.Equals, expect) + } + }() + } + + wg.Wait() +} + +func TestXxHashFromString(t *testing.T) { + c := qt.New(t) + s := "Hello World" + got, err := XXHashFromString(s) + c.Assert(err, qt.IsNil) + c.Assert(got, qt.Equals, uint64(7148569436472236994)) +} + +func TestXxHashFromStringHexEncoded(t *testing.T) { + c := qt.New(t) + s := "The quick brown fox jumps over the lazy dog" + got := XxHashFromStringHexEncoded(s) + // Facit: https://asecuritysite.com/encryption/xxhash?val=The%20quick%20brown%20fox%20jumps%20over%20the%20lazy%20dog + c.Assert(got, qt.Equals, "0b242d361fda71bc") +} + +func BenchmarkXXHashFromReader(b *testing.B) { + r := strings.NewReader("Hello World") + b.ResetTimer() + for i := 0; i < b.N; i++ { + XXHashFromReader(r) + r.Seek(0, 0) + } +} + +func BenchmarkXXHashFromString(b *testing.B) { + s := "Hello World" + b.ResetTimer() + for i := 0; i < b.N; i++ { + XXHashFromString(s) + } +} + +func BenchmarkXXHashFromStringHexEncoded(b *testing.B) { + s := "The quick brown fox jumps over the lazy dog" + b.ResetTimer() + for i := 0; i < b.N; i++ { + XxHashFromStringHexEncoded(s) + } +} + +func TestHashString(t *testing.T) { + c := qt.New(t) + + c.Assert(HashString("a", "b"), qt.Equals, "3176555414984061461") + c.Assert(HashString("ab"), qt.Equals, "7347350983217793633") + + var vals []any = []any{"a", "b", tstKeyer{"c"}} + + c.Assert(HashString(vals...), qt.Equals, "4438730547989914315") + c.Assert(vals[2], qt.Equals, tstKeyer{"c"}) +} + +type tstKeyer struct { + key string +} + +func (t tstKeyer) Key() string { + return t.key +} + +func (t tstKeyer) String() string { + return "key: " + t.key +} + +func BenchmarkHashString(b *testing.B) { + word := " hello " + + var tests []string + + for i := 1; i <= 5; i++ { + sentence := strings.Repeat(word, int(math.Pow(4, float64(i)))) + tests = append(tests, sentence) + } + + b.ResetTimer() + + for _, test := range tests { + b.Run(fmt.Sprintf("n%d", len(test)), func(b *testing.B) { + for i := 0; i < b.N; i++ { + HashString(test) + } + }) + } +} + +func BenchmarkHashMap(b *testing.B) { + m := map[string]any{} + for i := range 1000 { + m[fmt.Sprintf("key%d", i)] = i + } + + b.ResetTimer() + + for i := 0; i < b.N; i++ { + HashString(m) + } +} diff --git a/common/hcontext/context.go b/common/hcontext/context.go new file mode 100644 index 000000000..9524ef284 --- /dev/null +++ b/common/hcontext/context.go @@ -0,0 +1,46 @@ +// Copyright 2024 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 hcontext + +import "context" + +// ContextDispatcher is a generic interface for setting and getting values from a context. +type ContextDispatcher[T any] interface { + Set(ctx context.Context, value T) context.Context + Get(ctx context.Context) T +} + +// NewContextDispatcher creates a new ContextDispatcher with the given key. +func NewContextDispatcher[T any, R comparable](key R) ContextDispatcher[T] { + return keyInContext[T, R]{ + id: key, + } +} + +type keyInContext[T any, R comparable] struct { + zero T + id R +} + +func (f keyInContext[T, R]) Get(ctx context.Context) T { + v := ctx.Value(f.id) + if v == nil { + return f.zero + } + return v.(T) +} + +func (f keyInContext[T, R]) Set(ctx context.Context, value T) context.Context { + return context.WithValue(ctx, f.id, value) +} diff --git a/common/herrors/error_locator.go b/common/herrors/error_locator.go index 3778a3729..acaebb4bc 100644 --- a/common/herrors/error_locator.go +++ b/common/herrors/error_locator.go @@ -1,4 +1,4 @@ -// Copyright 2018 The Hugo Authors. All rights reserved. +// Copyright 2024 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. @@ -11,18 +11,15 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package errors contains common Hugo errors and error related utilities. +// Package herrors contains common Hugo errors and error related utilities. package herrors import ( "io" - "io/ioutil" "path/filepath" "strings" "github.com/gohugoio/hugo/common/text" - - "github.com/spf13/afero" ) // LineMatcher contains the elements used to match an error to a line @@ -36,19 +33,47 @@ type LineMatcher struct { } // LineMatcherFn is used to match a line with an error. -type LineMatcherFn func(m LineMatcher) bool +// It returns the column number or 0 if the line was found, but column could not be determined. Returns -1 if no line match. +type LineMatcherFn func(m LineMatcher) int // SimpleLineMatcher simply matches by line number. -var SimpleLineMatcher = func(m LineMatcher) bool { - return m.Position.LineNumber == m.LineNumber +var SimpleLineMatcher = func(m LineMatcher) int { + if m.Position.LineNumber == m.LineNumber { + // We found the line, but don't know the column. + return 0 + } + return -1 } -var _ text.Positioner = ErrorContext{} +// NopLineMatcher is a matcher that always returns 1. +// This will effectively give line 1, column 1. +var NopLineMatcher = func(m LineMatcher) int { + return 1 +} + +// OffsetMatcher is a line matcher that matches by offset. +var OffsetMatcher = func(m LineMatcher) int { + if m.Offset+len(m.Line) >= m.Position.Offset { + // We found the line, but return 0 to signal that we want to determine + // the column from the error. + return 0 + } + return -1 +} + +// ContainsMatcher is a line matcher that matches by line content. +func ContainsMatcher(text string) func(m LineMatcher) int { + return func(m LineMatcher) int { + if idx := strings.Index(m.Line, text); idx != -1 { + return idx + 1 + } + return -1 + } +} // ErrorContext contains contextual information about an error. This will // typically be the lines surrounding some problem in a file. type ErrorContext struct { - // If a match will contain the matched line and up to 2 lines before and after. // Will be empty if no match. Lines []string @@ -56,114 +81,15 @@ type ErrorContext struct { // The position of the error in the Lines above. 0 based. LinesPos int - position text.Position + // The position of the content in the file. Note that this may be different from the error's position set + // in FileError. + Position text.Position // The lexer to use for syntax highlighting. // https://gohugo.io/content-management/syntax-highlighting/#list-of-chroma-highlighting-languages ChromaLexer string } -// Position returns the text position of this error. -func (e ErrorContext) Position() text.Position { - return e.position -} - -var _ causer = (*ErrorWithFileContext)(nil) - -// ErrorWithFileContext is an error with some additional file context related -// to that error. -type ErrorWithFileContext struct { - cause error - ErrorContext -} - -func (e *ErrorWithFileContext) Error() string { - pos := e.Position() - if pos.IsValid() { - return pos.String() + ": " + e.cause.Error() - } - return e.cause.Error() -} - -func (e *ErrorWithFileContext) Cause() error { - return e.cause -} - -// WithFileContextForFile will try to add a file context with lines matching the given matcher. -// If no match could be found, the original error is returned with false as the second return value. -func WithFileContextForFile(e error, realFilename, filename string, fs afero.Fs, matcher LineMatcherFn) (error, bool) { - f, err := fs.Open(filename) - if err != nil { - return e, false - } - defer f.Close() - return WithFileContext(e, realFilename, f, matcher) -} - -// WithFileContextForFile will try to add a file context with lines matching the given matcher. -// If no match could be found, the original error is returned with false as the second return value. -func WithFileContext(e error, realFilename string, r io.Reader, matcher LineMatcherFn) (error, bool) { - if e == nil { - panic("error missing") - } - le := UnwrapFileError(e) - - if le == nil { - var ok bool - if le, ok = ToFileError("", e).(FileError); !ok { - return e, false - } - } - - var errCtx ErrorContext - - posle := le.Position() - - if posle.Offset != -1 { - errCtx = locateError(r, le, func(m LineMatcher) bool { - if posle.Offset >= m.Offset && posle.Offset < m.Offset+len(m.Line) { - lno := posle.LineNumber - m.Position.LineNumber + m.LineNumber - m.Position = text.Position{LineNumber: lno} - } - return matcher(m) - }) - } else { - errCtx = locateError(r, le, matcher) - } - - pos := &errCtx.position - - if pos.LineNumber == -1 { - return e, false - } - - pos.Filename = realFilename - - if le.Type() != "" { - errCtx.ChromaLexer = chromaLexerFromType(le.Type()) - } else { - errCtx.ChromaLexer = chromaLexerFromFilename(realFilename) - } - - return &ErrorWithFileContext{cause: e, ErrorContext: errCtx}, true -} - -// UnwrapErrorWithFileContext tries to unwrap an ErrorWithFileContext from err. -// It returns nil if this is not possible. -func UnwrapErrorWithFileContext(err error) *ErrorWithFileContext { - for err != nil { - switch v := err.(type) { - case *ErrorWithFileContext: - return v - case causer: - err = v.Cause() - default: - return nil - } - } - return nil -} - func chromaLexerFromType(fileType string) string { switch fileType { case "html", "htm": @@ -185,31 +111,24 @@ func chromaLexerFromFilename(filename string) string { return chromaLexerFromType(ext) } -func locateErrorInString(src string, matcher LineMatcherFn) ErrorContext { +func locateErrorInString(src string, matcher LineMatcherFn) *ErrorContext { return locateError(strings.NewReader(src), &fileError{}, matcher) } -func locateError(r io.Reader, le FileError, matches LineMatcherFn) ErrorContext { +func locateError(r io.Reader, le FileError, matches LineMatcherFn) *ErrorContext { if le == nil { panic("must provide an error") } - errCtx := ErrorContext{position: text.Position{LineNumber: -1, ColumnNumber: 1, Offset: -1}, LinesPos: -1} + ectx := &ErrorContext{LinesPos: -1, Position: text.Position{Offset: -1}} - b, err := ioutil.ReadAll(r) + b, err := io.ReadAll(r) if err != nil { - return errCtx + return ectx } - pos := &errCtx.position - lepos := le.Position() - lines := strings.Split(string(b), "\n") - if lepos.ColumnNumber >= 0 { - pos.ColumnNumber = lepos.ColumnNumber - } - lineNo := 0 posBytes := 0 @@ -222,34 +141,30 @@ func locateError(r io.Reader, le FileError, matches LineMatcherFn) ErrorContext Offset: posBytes, Line: line, } - if errCtx.LinesPos == -1 && matches(m) { - pos.LineNumber = lineNo + v := matches(m) + if ectx.LinesPos == -1 && v != -1 { + ectx.Position.LineNumber = lineNo + ectx.Position.ColumnNumber = v break } posBytes += len(line) } - if pos.LineNumber != -1 { - low := pos.LineNumber - 3 - if low < 0 { - low = 0 - } + if ectx.Position.LineNumber > 0 { + low := max(ectx.Position.LineNumber-3, 0) - if pos.LineNumber > 2 { - errCtx.LinesPos = 2 + if ectx.Position.LineNumber > 2 { + ectx.LinesPos = 2 } else { - errCtx.LinesPos = pos.LineNumber - 1 + ectx.LinesPos = ectx.Position.LineNumber - 1 } - high := pos.LineNumber + 2 - if high > len(lines) { - high = len(lines) - } + high := min(ectx.Position.LineNumber+2, len(lines)) - errCtx.Lines = lines[low:high] + ectx.Lines = lines[low:high] } - return errCtx + return ectx } diff --git a/common/herrors/error_locator_test.go b/common/herrors/error_locator_test.go index cce94166f..62f15213d 100644 --- a/common/herrors/error_locator_test.go +++ b/common/herrors/error_locator_test.go @@ -1,4 +1,4 @@ -// Copyright 2018 The Hugo Authors. All rights reserved. +// Copyright 2024 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. @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package errors contains common Hugo errors and error related utilities. +// Package herrors contains common Hugo errors and error related utilities. package herrors import ( @@ -24,8 +24,11 @@ import ( func TestErrorLocator(t *testing.T) { c := qt.New(t) - lineMatcher := func(m LineMatcher) bool { - return strings.Contains(m.Line, "THEONE") + lineMatcher := func(m LineMatcher) int { + if strings.Contains(m.Line, "THEONE") { + return 1 + } + return -1 } lines := `LINE 1 @@ -39,35 +42,41 @@ LINE 8 ` location := locateErrorInString(lines, lineMatcher) + pos := location.Position c.Assert(location.Lines, qt.DeepEquals, []string{"LINE 3", "LINE 4", "This is THEONE", "LINE 6", "LINE 7"}) - pos := location.Position() c.Assert(pos.LineNumber, qt.Equals, 5) c.Assert(location.LinesPos, qt.Equals, 2) - c.Assert(locateErrorInString(`This is THEONE`, lineMatcher).Lines, qt.DeepEquals, []string{"This is THEONE"}) + locate := func(s string, m LineMatcherFn) *ErrorContext { + ctx := locateErrorInString(s, m) + return ctx + } + + c.Assert(locate(`This is THEONE`, lineMatcher).Lines, qt.DeepEquals, []string{"This is THEONE"}) location = locateErrorInString(`L1 This is THEONE L2 `, lineMatcher) - c.Assert(location.Position().LineNumber, qt.Equals, 2) + pos = location.Position + c.Assert(pos.LineNumber, qt.Equals, 2) c.Assert(location.LinesPos, qt.Equals, 1) c.Assert(location.Lines, qt.DeepEquals, []string{"L1", "This is THEONE", "L2", ""}) - location = locateErrorInString(`This is THEONE + location = locate(`This is THEONE L2 `, lineMatcher) c.Assert(location.LinesPos, qt.Equals, 0) c.Assert(location.Lines, qt.DeepEquals, []string{"This is THEONE", "L2", ""}) - location = locateErrorInString(`L1 + location = locate(`L1 This THEONE `, lineMatcher) c.Assert(location.Lines, qt.DeepEquals, []string{"L1", "This THEONE", ""}) c.Assert(location.LinesPos, qt.Equals, 1) - location = locateErrorInString(`L1 + location = locate(`L1 L2 This THEONE `, lineMatcher) @@ -75,12 +84,16 @@ This THEONE c.Assert(location.LinesPos, qt.Equals, 2) location = locateErrorInString("NO MATCH", lineMatcher) - c.Assert(location.Position().LineNumber, qt.Equals, -1) + pos = location.Position + c.Assert(pos.LineNumber, qt.Equals, 0) c.Assert(location.LinesPos, qt.Equals, -1) c.Assert(len(location.Lines), qt.Equals, 0) - lineMatcher = func(m LineMatcher) bool { - return m.LineNumber == 6 + lineMatcher = func(m LineMatcher) int { + if m.LineNumber == 6 { + return 1 + } + return -1 } location = locateErrorInString(`A @@ -93,14 +106,18 @@ G H I J`, lineMatcher) + pos = location.Position c.Assert(location.Lines, qt.DeepEquals, []string{"D", "E", "F", "G", "H"}) - c.Assert(location.Position().LineNumber, qt.Equals, 6) + c.Assert(pos.LineNumber, qt.Equals, 6) c.Assert(location.LinesPos, qt.Equals, 2) // Test match EOF - lineMatcher = func(m LineMatcher) bool { - return m.LineNumber == 4 + lineMatcher = func(m LineMatcher) int { + if m.LineNumber == 4 { + return 1 + } + return -1 } location = locateErrorInString(`A @@ -108,12 +125,17 @@ B C `, lineMatcher) + pos = location.Position + c.Assert(location.Lines, qt.DeepEquals, []string{"B", "C", ""}) - c.Assert(location.Position().LineNumber, qt.Equals, 4) + c.Assert(pos.LineNumber, qt.Equals, 4) c.Assert(location.LinesPos, qt.Equals, 2) - offsetMatcher := func(m LineMatcher) bool { - return m.Offset == 1 + offsetMatcher := func(m LineMatcher) int { + if m.Offset == 1 { + return 1 + } + return -1 } location = locateErrorInString(`A @@ -122,8 +144,9 @@ C D E`, offsetMatcher) - c.Assert(location.Lines, qt.DeepEquals, []string{"A", "B", "C", "D"}) - c.Assert(location.Position().LineNumber, qt.Equals, 2) - c.Assert(location.LinesPos, qt.Equals, 1) + pos = location.Position + c.Assert(location.Lines, qt.DeepEquals, []string{"A", "B", "C", "D"}) + c.Assert(pos.LineNumber, qt.Equals, 2) + c.Assert(location.LinesPos, qt.Equals, 1) } diff --git a/common/herrors/errors.go b/common/herrors/errors.go index 5fae6fcae..c7ee90dd0 100644 --- a/common/herrors/errors.go +++ b/common/herrors/errors.go @@ -1,4 +1,4 @@ -// Copyright 2018 The Hugo Authors. All rights reserved. +// Copyright 2024 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. @@ -15,41 +15,17 @@ package herrors import ( - "bytes" "errors" "fmt" "io" "os" + "regexp" "runtime" "runtime/debug" - "strconv" - - _errors "github.com/pkg/errors" + "strings" + "time" ) -// As defined in https://godoc.org/github.com/pkg/errors -type causer interface { - Cause() error -} - -type stackTracer interface { - StackTrace() _errors.StackTrace -} - -// PrintStackTraceFromErr prints the error's stack trace to stdoud. -func PrintStackTraceFromErr(err error) { - FprintStackTraceFromErr(os.Stdout, err) -} - -// FprintStackTraceFromErr prints the error's stack trace to w. -func FprintStackTraceFromErr(w io.Writer, err error) { - if err, ok := err.(stackTracer); ok { - for _, f := range err.StackTrace() { - fmt.Fprintf(w, "%+s:%d\n", f, f) - } - } -} - // PrintStackTrace prints the current stacktrace to w. func PrintStackTrace(w io.Writer) { buf := make([]byte, 1<<16) @@ -57,10 +33,16 @@ func PrintStackTrace(w io.Writer) { fmt.Fprintf(w, "%s", buf) } +// ErrorSender is a, typically, non-blocking error handler. +type ErrorSender interface { + SendError(err error) +} + // Recover is a helper function that can be used to capture panics. // Put this at the top of a method/function that crashes in a template: -// defer herrors.Recover() -func Recover(args ...interface{}) { +// +// defer herrors.Recover() +func Recover(args ...any) { if r := recover(); r != nil { fmt.Println("ERR:", r) args = append(args, "stacktrace from panic: \n"+string(debug.Stack()), "\n") @@ -68,18 +50,138 @@ func Recover(args ...interface{}) { } } -// Get the current goroutine id. Used only for debugging. -func GetGID() uint64 { - b := make([]byte, 64) - b = b[:runtime.Stack(b, false)] - b = bytes.TrimPrefix(b, []byte("goroutine ")) - b = b[:bytes.IndexByte(b, ' ')] - n, _ := strconv.ParseUint(string(b), 10, 64) - return n +// IsTimeoutError returns true if the given error is or contains a TimeoutError. +func IsTimeoutError(err error) bool { + return errors.Is(err, &TimeoutError{}) +} + +type TimeoutError struct { + Duration time.Duration +} + +func (e *TimeoutError) Error() string { + return fmt.Sprintf("timeout after %s", e.Duration) +} + +func (e *TimeoutError) Is(target error) bool { + _, ok := target.(*TimeoutError) + return ok +} + +// errMessage wraps an error with a message. +type errMessage struct { + msg string + err error +} + +func (e *errMessage) Error() string { + return e.msg +} + +func (e *errMessage) Unwrap() error { + return e.err +} + +// IsFeatureNotAvailableError returns true if the given error is or contains a FeatureNotAvailableError. +func IsFeatureNotAvailableError(err error) bool { + return errors.Is(err, &FeatureNotAvailableError{}) } // ErrFeatureNotAvailable denotes that a feature is unavailable. // // We will, at least to begin with, make some Hugo features (SCSS with libsass) optional, // and this error is used to signal those situations. -var ErrFeatureNotAvailable = errors.New("this feature is not available in your current Hugo version, see https://goo.gl/YMrWcn for more information") +var ErrFeatureNotAvailable = &FeatureNotAvailableError{Cause: errors.New("this feature is not available in your current Hugo version, see https://goo.gl/YMrWcn for more information")} + +// FeatureNotAvailableError is an error type used to signal that a feature is not available. +type FeatureNotAvailableError struct { + Cause error +} + +func (e *FeatureNotAvailableError) Unwrap() error { + return e.Cause +} + +func (e *FeatureNotAvailableError) Error() string { + return e.Cause.Error() +} + +func (e *FeatureNotAvailableError) Is(target error) bool { + _, ok := target.(*FeatureNotAvailableError) + return ok +} + +// Must panics if err != nil. +func Must(err error) { + if err != nil { + panic(err) + } +} + +// IsNotExist returns true if the error is a file not found error. +// Unlike os.IsNotExist, this also considers wrapped errors. +func IsNotExist(err error) bool { + if os.IsNotExist(err) { + return true + } + + // os.IsNotExist does not consider wrapped errors. + if os.IsNotExist(errors.Unwrap(err)) { + return true + } + + return false +} + +// IsExist returns true if the error is a file exists error. +// Unlike os.IsExist, this also considers wrapped errors. +func IsExist(err error) bool { + if os.IsExist(err) { + return true + } + + // os.IsExist does not consider wrapped errors. + if os.IsExist(errors.Unwrap(err)) { + return true + } + + return false +} + +var nilPointerErrRe = regexp.MustCompile(`at <(.*)>: error calling (.*?): runtime error: invalid memory address or nil pointer dereference`) + +const deferredPrefix = "__hdeferred/" + +var deferredStringToRemove = regexp.MustCompile(`executing "__hdeferred/.*?" `) + +// ImproveRenderErr improves the error message for rendering errors. +func ImproveRenderErr(inErr error) (outErr error) { + outErr = inErr + msg := improveIfNilPointerMsg(inErr) + if msg != "" { + outErr = &errMessage{msg: msg, err: outErr} + } + + if strings.Contains(inErr.Error(), deferredPrefix) { + msg := deferredStringToRemove.ReplaceAllString(inErr.Error(), "executing ") + outErr = &errMessage{msg: msg, err: outErr} + } + return +} + +func improveIfNilPointerMsg(inErr error) string { + m := nilPointerErrRe.FindStringSubmatch(inErr.Error()) + if len(m) == 0 { + return "" + } + call := m[1] + field := m[2] + parts := strings.Split(call, ".") + if len(parts) < 2 { + return "" + } + receiverName := parts[len(parts)-2] + receiver := strings.Join(parts[:len(parts)-1], ".") + 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) +} diff --git a/common/herrors/errors_test.go b/common/herrors/errors_test.go new file mode 100644 index 000000000..2f53a1e89 --- /dev/null +++ b/common/herrors/errors_test.go @@ -0,0 +1,45 @@ +// Copyright 2024 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 herrors + +import ( + "errors" + "fmt" + "testing" + + qt "github.com/frankban/quicktest" + "github.com/spf13/afero" +) + +func TestIsNotExist(t *testing.T) { + c := qt.New(t) + + c.Assert(IsNotExist(afero.ErrFileNotFound), qt.Equals, true) + c.Assert(IsNotExist(afero.ErrFileExists), qt.Equals, false) + c.Assert(IsNotExist(afero.ErrDestinationExists), qt.Equals, false) + c.Assert(IsNotExist(nil), qt.Equals, false) + + c.Assert(IsNotExist(fmt.Errorf("foo")), qt.Equals, false) + + // os.IsNotExist returns false for wrapped errors. + c.Assert(IsNotExist(fmt.Errorf("foo: %w", afero.ErrFileNotFound)), qt.Equals, true) +} + +func TestIsFeatureNotAvailableError(t *testing.T) { + c := qt.New(t) + + c.Assert(IsFeatureNotAvailableError(ErrFeatureNotAvailable), qt.Equals, true) + c.Assert(IsFeatureNotAvailableError(&FeatureNotAvailableError{}), qt.Equals, true) + c.Assert(IsFeatureNotAvailableError(errors.New("asdf")), qt.Equals, false) +} diff --git a/common/herrors/file_error.go b/common/herrors/file_error.go index 5af84adf5..38b198656 100644 --- a/common/herrors/file_error.go +++ b/common/herrors/file_error.go @@ -1,28 +1,32 @@ -// Copyright 2018 The Hugo Authors. All rights reserved. +// Copyright 2024 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 +// Unless required by applicable lfmtaw 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 -// limitatio ns under the License. +// limitations under the License. package herrors import ( "encoding/json" + "errors" + "fmt" + "io" + "path/filepath" + "github.com/bep/godartsass/v2" + "github.com/bep/golibsass/libsass/libsasserrors" + "github.com/gohugoio/hugo/common/paths" "github.com/gohugoio/hugo/common/text" - - "github.com/pkg/errors" -) - -var ( - _ causer = (*fileError)(nil) + "github.com/pelletier/go-toml/v2" + "github.com/spf13/afero" + "github.com/tdewolff/parse/v2" ) // FileError represents an error when handling a file: Parsing a config file, @@ -30,48 +34,309 @@ var ( type FileError interface { error + // ErrorContext holds some context information about the error. + ErrorContext() *ErrorContext + text.Positioner - // A string identifying the type of file, e.g. JSON, TOML, markdown etc. - Type() string + // UpdatePosition updates the position of the error. + UpdatePosition(pos text.Position) FileError + + // UpdateContent updates the error with a new ErrorContext from the content of the file. + UpdateContent(r io.Reader, linematcher LineMatcherFn) FileError + + // SetFilename sets the filename of the error. + SetFilename(filename string) FileError } -var _ FileError = (*fileError)(nil) +// Unwrapper can unwrap errors created with fmt.Errorf. +type Unwrapper interface { + Unwrap() error +} + +var ( + _ FileError = (*fileError)(nil) + _ Unwrapper = (*fileError)(nil) +) + +func (fe *fileError) SetFilename(filename string) FileError { + fe.position.Filename = filename + return fe +} + +func (fe *fileError) UpdatePosition(pos text.Position) FileError { + oldFilename := fe.Position().Filename + if pos.Filename != "" && fe.fileType == "" { + _, fe.fileType = paths.FileAndExtNoDelimiter(filepath.Clean(pos.Filename)) + } + if pos.Filename == "" { + pos.Filename = oldFilename + } + fe.position = pos + return fe +} + +func (fe *fileError) UpdateContent(r io.Reader, linematcher LineMatcherFn) FileError { + if linematcher == nil { + linematcher = SimpleLineMatcher + } + + var ( + posle = fe.position + ectx *ErrorContext + ) + + if posle.LineNumber <= 1 && posle.Offset > 0 { + // Try to locate the line number from the content if offset is set. + ectx = locateError(r, fe, func(m LineMatcher) int { + if posle.Offset >= m.Offset && posle.Offset < m.Offset+len(m.Line) { + lno := posle.LineNumber - m.Position.LineNumber + m.LineNumber + m.Position = text.Position{LineNumber: lno} + return linematcher(m) + } + return -1 + }) + } else { + ectx = locateError(r, fe, linematcher) + } + + if ectx.ChromaLexer == "" { + if fe.fileType != "" { + ectx.ChromaLexer = chromaLexerFromType(fe.fileType) + } else { + ectx.ChromaLexer = chromaLexerFromFilename(fe.Position().Filename) + } + } + + fe.errorContext = ectx + + if ectx.Position.LineNumber > 0 { + fe.position.LineNumber = ectx.Position.LineNumber + } + + if ectx.Position.ColumnNumber > 0 { + fe.position.ColumnNumber = ectx.Position.ColumnNumber + } + + return fe +} type fileError struct { - position text.Position + position text.Position + errorContext *ErrorContext fileType string cause error } +func (e *fileError) ErrorContext() *ErrorContext { + return e.errorContext +} + // Position returns the text position of this error. func (e fileError) Position() text.Position { return e.position } -func (e *fileError) Type() string { - return e.fileType +func (e *fileError) Error() string { + return fmt.Sprintf("%s: %s", e.position, e.causeString()) } -func (e *fileError) Error() string { +func (e *fileError) causeString() string { if e.cause == nil { return "" } - return e.cause.Error() + switch v := e.cause.(type) { + // Avoid repeating the file info in the error message. + case godartsass.SassError: + return v.Message + case libsasserrors.Error: + return v.Message + default: + return v.Error() + } } -func (f *fileError) Cause() error { - return f.cause +func (e *fileError) Unwrap() error { + return e.cause } -// NewFileError creates a new FileError. -func NewFileError(fileType string, offset, lineNumber, columnNumber int, err error) FileError { - pos := text.Position{Offset: offset, LineNumber: lineNumber, ColumnNumber: columnNumber} +// NewFileError creates a new FileError that wraps err. +// It will try to extract the filename and line number from err. +func NewFileError(err error) FileError { + // Filetype is used to determine the Chroma lexer to use. + fileType, pos := extractFileTypePos(err) return &fileError{cause: err, fileType: fileType, position: pos} } +// NewFileErrorFromName creates a new FileError that wraps err. +// The value for name should identify the file, the best +// being the full filename to the file on disk. +func NewFileErrorFromName(err error, name string) FileError { + // Filetype is used to determine the Chroma lexer to use. + fileType, pos := extractFileTypePos(err) + pos.Filename = name + if fileType == "" { + _, fileType = paths.FileAndExtNoDelimiter(filepath.Clean(name)) + } + + return &fileError{cause: err, fileType: fileType, position: pos} +} + +// NewFileErrorFromPos will use the filename and line number from pos to create a new FileError, wrapping err. +func NewFileErrorFromPos(err error, pos text.Position) FileError { + // Filetype is used to determine the Chroma lexer to use. + fileType, _ := extractFileTypePos(err) + if fileType == "" { + _, fileType = paths.FileAndExtNoDelimiter(filepath.Clean(pos.Filename)) + } + return &fileError{cause: err, fileType: fileType, position: pos} +} + +func NewFileErrorFromFileInErr(err error, fs afero.Fs, linematcher LineMatcherFn) FileError { + fe := NewFileError(err) + pos := fe.Position() + if pos.Filename == "" { + return fe + } + + f, realFilename, err2 := openFile(pos.Filename, fs) + if err2 != nil { + return fe + } + + pos.Filename = realFilename + defer f.Close() + return fe.UpdateContent(f, linematcher) +} + +func NewFileErrorFromFileInPos(err error, pos text.Position, fs afero.Fs, linematcher LineMatcherFn) FileError { + if err == nil { + panic("err is nil") + } + f, realFilename, err2 := openFile(pos.Filename, fs) + if err2 != nil { + return NewFileErrorFromPos(err, pos) + } + pos.Filename = realFilename + defer f.Close() + return NewFileErrorFromPos(err, pos).UpdateContent(f, linematcher) +} + +// NewFileErrorFromFile is a convenience method to create a new FileError from a file. +func NewFileErrorFromFile(err error, filename string, fs afero.Fs, linematcher LineMatcherFn) FileError { + if err == nil { + panic("err is nil") + } + f, realFilename, err2 := openFile(filename, fs) + if err2 != nil { + return NewFileErrorFromName(err, realFilename) + } + defer f.Close() + return NewFileErrorFromName(err, realFilename).UpdateContent(f, linematcher) +} + +func openFile(filename string, fs afero.Fs) (afero.File, string, error) { + realFilename := filename + + // We want the most specific filename possible in the error message. + fi, err2 := fs.Stat(filename) + if err2 == nil { + if s, ok := fi.(interface { + Filename() string + }); ok { + realFilename = s.Filename() + } + } + + f, err2 := fs.Open(filename) + if err2 != nil { + return nil, realFilename, err2 + } + + return f, realFilename, nil +} + +// Cause returns the underlying error, that is, +// it unwraps errors until it finds one that does not implement +// the Unwrap method. +// For a shallow variant, see Unwrap. +func Cause(err error) error { + type unwrapper interface { + Unwrap() error + } + + for err != nil { + cause, ok := err.(unwrapper) + if !ok { + break + } + err = cause.Unwrap() + } + return err +} + +// Unwrap returns the underlying error or itself if it does not implement Unwrap. +func Unwrap(err error) error { + if u := errors.Unwrap(err); u != nil { + return u + } + return err +} + +func extractFileTypePos(err error) (string, text.Position) { + err = Unwrap(err) + + var fileType string + + // LibSass, DartSass + if pos := extractPosition(err); pos.LineNumber > 0 || pos.Offset > 0 { + _, fileType = paths.FileAndExtNoDelimiter(pos.Filename) + return fileType, pos + } + + // Default to line 1 col 1 if we don't find any better. + pos := text.Position{ + Offset: -1, + LineNumber: 1, + ColumnNumber: 1, + } + + // JSON errors. + offset, typ := extractOffsetAndType(err) + if fileType == "" { + fileType = typ + } + + if offset >= 0 { + pos.Offset = offset + } + + // The error type from the minifier contains line number and column number. + if line, col := extractLineNumberAndColumnNumber(err); line >= 0 { + pos.LineNumber = line + pos.ColumnNumber = col + return fileType, pos + } + + // Look in the error message for the line number. + for _, handle := range lineNumberExtractors { + lno, col := handle(err) + if lno > 0 { + pos.ColumnNumber = col + pos.LineNumber = lno + break + } + } + + if fileType == "" && pos.Filename != "" { + _, fileType = paths.FileAndExtNoDelimiter(pos.Filename) + } + + return fileType, pos +} + // UnwrapFileError tries to unwrap a FileError from err. // It returns nil if this is not possible. func UnwrapFileError(err error) FileError { @@ -79,49 +344,38 @@ func UnwrapFileError(err error) FileError { switch v := err.(type) { case FileError: return v - case causer: - err = v.Cause() default: - return nil + err = errors.Unwrap(err) } } return nil } -// ToFileErrorWithOffset will return a new FileError with a line number -// with the given offset from the original. -func ToFileErrorWithOffset(fe FileError, offset int) FileError { - pos := fe.Position() - return ToFileErrorWithLineNumber(fe, pos.LineNumber+offset) -} - -// ToFileErrorWithOffset will return a new FileError with the given line number. -func ToFileErrorWithLineNumber(fe FileError, lineNumber int) FileError { - pos := fe.Position() - pos.LineNumber = lineNumber - return &fileError{cause: fe, fileType: fe.Type(), position: pos} -} - -// ToFileError will convert the given error to an error supporting -// the FileError interface. -func ToFileError(fileType string, err error) FileError { - for _, handle := range lineNumberExtractors { - lno, col := handle(err) - offset, typ := extractOffsetAndType(err) - if fileType == "" { - fileType = typ - } - - if lno > 0 || offset != -1 { - return NewFileError(fileType, offset, lno, col, err) +// UnwrapFileErrors tries to unwrap all FileError. +func UnwrapFileErrors(err error) []FileError { + var errs []FileError + for err != nil { + if v, ok := err.(FileError); ok { + errs = append(errs, v) } + err = errors.Unwrap(err) } - // Fall back to the pointing to line number 1. - return NewFileError(fileType, -1, 1, 1, err) + return errs +} + +// UnwrapFileErrorsWithErrorContext tries to unwrap all FileError in err that has an ErrorContext. +func UnwrapFileErrorsWithErrorContext(err error) []FileError { + var errs []FileError + for err != nil { + if v, ok := err.(FileError); ok && v.ErrorContext() != nil { + errs = append(errs, v) + } + err = errors.Unwrap(err) + } + return errs } func extractOffsetAndType(e error) (int, string) { - e = errors.Cause(e) switch v := e.(type) { case *json.UnmarshalTypeError: return int(v.Offset), "json" @@ -131,3 +385,46 @@ func extractOffsetAndType(e error) (int, string) { return -1, "" } } + +func extractLineNumberAndColumnNumber(e error) (int, int) { + switch v := e.(type) { + case *parse.Error: + return v.Line, v.Column + case *toml.DecodeError: + return v.Position() + + } + + return -1, -1 +} + +func extractPosition(e error) (pos text.Position) { + switch v := e.(type) { + case godartsass.SassError: + span := v.Span + start := span.Start + filename, _ := paths.UrlStringToFilename(span.Url) + pos.Filename = filename + pos.Offset = start.Offset + pos.ColumnNumber = start.Column + case libsasserrors.Error: + pos.Filename = v.File + pos.LineNumber = v.Line + pos.ColumnNumber = v.Column + } + return +} + +// TextSegmentError is an error with a text segment attached. +type TextSegmentError struct { + Segment string + Err error +} + +func (e TextSegmentError) Unwrap() error { + return e.Err +} + +func (e TextSegmentError) Error() string { + return e.Err.Error() +} diff --git a/common/herrors/file_error_test.go b/common/herrors/file_error_test.go index b1b5c5a02..7aca08405 100644 --- a/common/herrors/file_error_test.go +++ b/common/herrors/file_error_test.go @@ -1,4 +1,4 @@ -// Copyright 2018 The Hugo Authors. All rights reserved. +// Copyright 2024 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,14 +14,42 @@ package herrors import ( + "errors" + "fmt" + "strings" "testing" - "github.com/pkg/errors" + "github.com/gohugoio/hugo/common/text" qt "github.com/frankban/quicktest" ) -func TestToLineNumberError(t *testing.T) { +func TestNewFileError(t *testing.T) { + t.Parallel() + + c := qt.New(t) + + fe := NewFileErrorFromName(errors.New("bar"), "foo.html") + c.Assert(fe.Error(), qt.Equals, `"foo.html:1:1": bar`) + + lines := "" + for i := 1; i <= 100; i++ { + lines += fmt.Sprintf("line %d\n", i) + } + + fe.UpdatePosition(text.Position{LineNumber: 32, ColumnNumber: 2}) + c.Assert(fe.Error(), qt.Equals, `"foo.html:32:2": bar`) + fe.UpdatePosition(text.Position{LineNumber: 0, ColumnNumber: 0, Offset: 212}) + fe.UpdateContent(strings.NewReader(lines), nil) + c.Assert(fe.Error(), qt.Equals, `"foo.html:32:0": bar`) + errorContext := fe.ErrorContext() + c.Assert(errorContext, qt.IsNotNil) + c.Assert(errorContext.Lines, qt.DeepEquals, []string{"line 30", "line 31", "line 32", "line 33", "line 34"}) + c.Assert(errorContext.LinesPos, qt.Equals, 2) + c.Assert(errorContext.ChromaLexer, qt.Equals, "go-html-template") +} + +func TestNewFileErrorExtractFromMessage(t *testing.T) { t.Parallel() c := qt.New(t) @@ -36,21 +64,17 @@ func TestToLineNumberError(t *testing.T) { {errors.New(`template: _default/single.html:4:15: executing "_default/single.html" at <.Titles>: can't evaluate field Titles in type *hugolib.PageOutput`), 0, 4, 15}, {errors.New("parse failed: template: _default/bundle-resource-meta.html:11: unexpected in operand"), 0, 11, 1}, {errors.New(`failed:: template: _default/bundle-resource-meta.html:2:7: executing "main" at <.Titles>`), 0, 2, 7}, - {errors.New("error in front matter: Near line 32 (last key parsed 'title')"), 0, 32, 1}, {errors.New(`failed to load translations: (6, 7): was expecting token =, but got "g" instead`), 0, 6, 7}, + {errors.New(`execute of template failed: template: index.html:2:5: executing "index.html" at : error calling partial: "/layouts/partials/foo.html:3:6": execute of template failed: template: partials/foo.html:3:6: executing "partials/foo.html" at <.ThisDoesNotExist>: can't evaluate field ThisDoesNotExist in type *hugolib.pageStat`), 0, 2, 5}, } { - got := ToFileError("template", test.in) + got := NewFileErrorFromName(test.in, "test.txt") errMsg := qt.Commentf("[%d][%T]", i, got) - le, ok := got.(FileError) - c.Assert(ok, qt.Equals, true) - c.Assert(ok, qt.Equals, true, errMsg) - pos := le.Position() + pos := got.Position() c.Assert(pos.LineNumber, qt.Equals, test.lineNumber, errMsg) c.Assert(pos.ColumnNumber, qt.Equals, test.columnNumber, errMsg) - c.Assert(errors.Cause(got), qt.Not(qt.IsNil)) + c.Assert(errors.Unwrap(got), qt.Not(qt.IsNil)) } - } diff --git a/common/herrors/line_number_extractors.go b/common/herrors/line_number_extractors.go index 93969b967..f70a2691f 100644 --- a/common/herrors/line_number_extractors.go +++ b/common/herrors/line_number_extractors.go @@ -9,7 +9,7 @@ // 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 -// limitatio ns under the License. +// limitations under the License. package herrors @@ -20,17 +20,14 @@ import ( var lineNumberExtractors = []lineNumberExtractor{ // Template/shortcode parse errors - newLineNumberErrHandlerFromRegexp(".*:(\\d+):(\\d*):"), - newLineNumberErrHandlerFromRegexp(".*:(\\d+):"), - - // TOML parse errors - newLineNumberErrHandlerFromRegexp(".*Near line (\\d+)(\\s.*)"), + newLineNumberErrHandlerFromRegexp(`:(\d+):(\d*):`), + newLineNumberErrHandlerFromRegexp(`:(\d+):`), // YAML parse errors - newLineNumberErrHandlerFromRegexp("line (\\d+):"), + newLineNumberErrHandlerFromRegexp(`line (\d+):`), // i18n bundle errors - newLineNumberErrHandlerFromRegexp("\\((\\d+),\\s(\\d*)"), + newLineNumberErrHandlerFromRegexp(`\((\d+),\s(\d*)`), } type lineNumberExtractor func(e error) (int, int) @@ -61,6 +58,6 @@ func extractLineNo(re *regexp.Regexp) lineNumberExtractor { return lno, col } - return -1, col + return 0, col } } diff --git a/common/hexec/exec.go b/common/hexec/exec.go new file mode 100644 index 000000000..c3a6ebf57 --- /dev/null +++ b/common/hexec/exec.go @@ -0,0 +1,389 @@ +// Copyright 2020 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 ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "sync" + + "github.com/bep/logg" + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/config/security" +) + +var WithDir = func(dir string) func(c *commandeer) { + return func(c *commandeer) { + c.dir = dir + } +} + +var WithContext = func(ctx context.Context) func(c *commandeer) { + return func(c *commandeer) { + c.ctx = ctx + } +} + +var WithStdout = func(w io.Writer) func(c *commandeer) { + return func(c *commandeer) { + c.stdout = w + } +} + +var WithStderr = func(w io.Writer) func(c *commandeer) { + return func(c *commandeer) { + c.stderr = w + } +} + +var WithStdin = func(r io.Reader) func(c *commandeer) { + return func(c *commandeer) { + c.stdin = r + } +} + +var WithEnviron = func(env []string) func(c *commandeer) { + return func(c *commandeer) { + setOrAppend := func(s string) { + k1, _ := config.SplitEnvVar(s) + var found bool + for i, v := range c.env { + k2, _ := config.SplitEnvVar(v) + if k1 == k2 { + found = true + c.env[i] = s + } + } + + if !found { + c.env = append(c.env, s) + } + } + + for _, s := range env { + setOrAppend(s) + } + } +} + +// New creates a new Exec using the provided security config. +func New(cfg security.Config, workingDir string, log loggers.Logger) *Exec { + var baseEnviron []string + for _, v := range os.Environ() { + k, _ := config.SplitEnvVar(v) + if cfg.Exec.OsEnv.Accept(k) { + baseEnviron = append(baseEnviron, v) + } + } + + return &Exec{ + sc: cfg, + workingDir: workingDir, + infol: log.InfoCommand("exec"), + baseEnviron: baseEnviron, + newNPXRunnerCache: maps.NewCache[string, func(arg ...any) (Runner, error)](), + } +} + +// IsNotFound reports whether this is an error about a binary not found. +func IsNotFound(err error) bool { + var notFoundErr *NotFoundError + return errors.As(err, ¬FoundErr) +} + +// Exec enforces a security policy for commands run via os/exec. +type Exec struct { + sc security.Config + workingDir string + infol logg.LevelLogger + + // os.Environ filtered by the Exec.OsEnviron whitelist filter. + baseEnviron []string + + newNPXRunnerCache *maps.Cache[string, func(arg ...any) (Runner, error)] + npxInit sync.Once + npxAvailable bool +} + +func (e *Exec) New(name string, arg ...any) (Runner, error) { + return e.new(name, "", arg...) +} + +// New will fail if name is not allowed according to the configured security policy. +// Else a configured Runner will be returned ready to be Run. +func (e *Exec) new(name string, fullyQualifiedName string, arg ...any) (Runner, error) { + if err := e.sc.CheckAllowedExec(name); err != nil { + return nil, err + } + + env := make([]string, len(e.baseEnviron)) + copy(env, e.baseEnviron) + + cm := &commandeer{ + name: name, + fullyQualifiedName: fullyQualifiedName, + env: env, + } + + return cm.command(arg...) +} + +type binaryLocation int + +func (b binaryLocation) String() string { + switch b { + case binaryLocationNodeModules: + return "node_modules/.bin" + case binaryLocationNpx: + return "npx" + case binaryLocationPath: + return "PATH" + } + return "unknown" +} + +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 . +// 3. Fall back to the PATH. +// If name is "tailwindcss", we will try the PATH as the second option. +func (e *Exec) Npx(name string, arg ...any) (Runner, error) { + if err := e.sc.CheckAllowedExec(name); err != 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...) + } + }, + } + + locations := []binaryLocation{binaryLocationNodeModules, binaryLocationNpx, binaryLocationPath} + if name == "tailwindcss" { + // See https://github.com/gohugoio/hugo/issues/13221#issuecomment-2574801253 + locations = []binaryLocation{binaryLocationNodeModules, binaryLocationPath, binaryLocationNpx} + } + 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])} + }) + if err != nil { + return nil, err + } + + return newRunner(arg...) +} + +const ( + npxNoInstall = "--no-install" + npxBinary = "npx" + nodeModulesBinPath = "node_modules/.bin" +) + +func (e *Exec) checkNpx() { + e.npxInit.Do(func() { + e.npxAvailable = InPath(npxBinary) + }) +} + +// npx is a convenience method to create a Runner running npx --no-install 0 { + if tp.NumIn() > 1 { + panic("not supported") + } + first := tp.In(0) + if IsContextType(first) { + args = append(args, reflect.ValueOf(cxt)) + } + } + + return fn.Call(args) +} + // Based on: https://github.com/golang/go/blob/178a2c42254166cffed1b25fb1d3c7a5727cada6/src/text/template/exec.go#L931 func indirectInterface(v reflect.Value) reflect.Value { if v.Kind() != reflect.Interface { @@ -89,3 +270,26 @@ func indirectInterface(v reflect.Value) reflect.Value { } return v.Elem() } + +var contextInterface = reflect.TypeOf((*context.Context)(nil)).Elem() + +var isContextCache = maps.NewCache[reflect.Type, bool]() + +type k string + +var contextTypeValue = reflect.TypeOf(context.WithValue(context.Background(), k("key"), 32)) + +// IsContextType returns whether tp is a context.Context type. +func IsContextType(tp reflect.Type) bool { + if tp == contextTypeValue { + return true + } + if tp == contextInterface { + return true + } + + isContext, _ := isContextCache.GetOrCreate(tp, func() (bool, error) { + return tp.Implements(contextInterface), nil + }) + return isContext +} diff --git a/common/hreflect/helpers_test.go b/common/hreflect/helpers_test.go index 480ccb27a..cbcad0f22 100644 --- a/common/hreflect/helpers_test.go +++ b/common/hreflect/helpers_test.go @@ -14,6 +14,7 @@ package hreflect import ( + "context" "reflect" "testing" "time" @@ -30,6 +31,65 @@ func TestIsTruthful(t *testing.T) { c.Assert(IsTruthful(time.Time{}), qt.Equals, false) } +func TestGetMethodByName(t *testing.T) { + c := qt.New(t) + v := reflect.ValueOf(&testStruct{}) + tp := v.Type() + + c.Assert(GetMethodIndexByName(tp, "Method1"), qt.Equals, 0) + c.Assert(GetMethodIndexByName(tp, "Method3"), qt.Equals, 2) + c.Assert(GetMethodIndexByName(tp, "Foo"), qt.Equals, -1) +} + +func TestIsContextType(t *testing.T) { + c := qt.New(t) + type k string + ctx := context.Background() + valueCtx := context.WithValue(ctx, k("key"), 32) + c.Assert(IsContextType(reflect.TypeOf(ctx)), qt.IsTrue) + c.Assert(IsContextType(reflect.TypeOf(valueCtx)), qt.IsTrue) +} + +func TestToSliceAny(t *testing.T) { + c := qt.New(t) + + checkOK := func(in any, expected []any) { + out, ok := ToSliceAny(in) + c.Assert(ok, qt.Equals, true) + c.Assert(out, qt.DeepEquals, expected) + } + + checkOK([]any{1, 2, 3}, []any{1, 2, 3}) + checkOK([]int{1, 2, 3}, []any{1, 2, 3}) +} + +func BenchmarkIsContextType(b *testing.B) { + type k string + b.Run("value", func(b *testing.B) { + ctx := context.Background() + ctxs := make([]reflect.Type, b.N) + for i := 0; i < b.N; i++ { + ctxs[i] = reflect.TypeOf(context.WithValue(ctx, k("key"), i)) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + if !IsContextType(ctxs[i]) { + b.Fatal("not context") + } + } + }) + + b.Run("background", func(b *testing.B) { + var ctxt reflect.Type = reflect.TypeOf(context.Background()) + for i := 0; i < b.N; i++ { + if !IsContextType(ctxt) { + b.Fatal("not context") + } + } + }) +} + func BenchmarkIsTruthFul(b *testing.B) { v := reflect.ValueOf("Hugo") @@ -40,3 +100,51 @@ func BenchmarkIsTruthFul(b *testing.B) { } } } + +type testStruct struct{} + +func (t *testStruct) Method1() string { + return "Hugo" +} + +func (t *testStruct) Method2() string { + return "Hugo" +} + +func (t *testStruct) Method3() string { + return "Hugo" +} + +func (t *testStruct) Method4() string { + return "Hugo" +} + +func (t *testStruct) Method5() string { + return "Hugo" +} + +func BenchmarkGetMethodByName(b *testing.B) { + v := reflect.ValueOf(&testStruct{}) + methods := []string{"Method1", "Method2", "Method3", "Method4", "Method5"} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + for _, method := range methods { + _ = GetMethodByName(v, method) + } + } +} + +func BenchmarkGetMethodByNamePara(b *testing.B) { + v := reflect.ValueOf(&testStruct{}) + methods := []string{"Method1", "Method2", "Method3", "Method4", "Method5"} + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + for _, method := range methods { + _ = GetMethodByName(v, method) + } + } + }) +} diff --git a/common/hstrings/strings.go b/common/hstrings/strings.go new file mode 100644 index 000000000..1de38678f --- /dev/null +++ b/common/hstrings/strings.go @@ -0,0 +1,134 @@ +// Copyright 2024 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 hstrings + +import ( + "fmt" + "regexp" + "slices" + "strings" + "sync" + + "github.com/gohugoio/hugo/compare" +) + +var _ compare.Eqer = StringEqualFold("") + +// StringEqualFold is a string that implements the compare.Eqer interface and considers +// two strings equal if they are equal when folded to lower case. +// The compare.Eqer interface is used in Hugo to compare values in templates (e.g. using the eq template function). +type StringEqualFold string + +func (s StringEqualFold) EqualFold(s2 string) bool { + return strings.EqualFold(string(s), s2) +} + +func (s StringEqualFold) String() string { + return string(s) +} + +func (s StringEqualFold) Eq(s2 any) bool { + switch ss := s2.(type) { + case string: + return s.EqualFold(ss) + case fmt.Stringer: + return s.EqualFold(ss.String()) + } + + return false +} + +// EqualAny returns whether a string is equal to any of the given strings. +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)} + +// 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) +} + +// 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 { + return slices.Contains(arr, el) +} + +// InSlicEqualFold checks if a string is an element of a slice of strings +// and returns a boolean value. +// It uses strings.EqualFold to compare. +func InSlicEqualFold(arr []string, el string) bool { + for _, v := range arr { + if strings.EqualFold(v, el) { + return true + } + } + return false +} + +// ToString converts the given value to a string. +// Note that this is a more strict version compared to cast.ToString, +// as it will not try to convert numeric values to strings, +// but only accept strings or fmt.Stringer. +func ToString(v any) (string, bool) { + switch vv := v.(type) { + case string: + return vv, true + case fmt.Stringer: + return vv.String(), true + } + return "", false +} + +type ( + Strings2 [2]string + Strings3 [3]string +) diff --git a/common/hstrings/strings_test.go b/common/hstrings/strings_test.go new file mode 100644 index 000000000..d8e9e204a --- /dev/null +++ b/common/hstrings/strings_test.go @@ -0,0 +1,56 @@ +// Copyright 2024 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 hstrings + +import ( + "regexp" + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestStringEqualFold(t *testing.T) { + c := qt.New(t) + + s1 := "A" + s2 := "a" + + c.Assert(StringEqualFold(s1).EqualFold(s2), qt.Equals, true) + c.Assert(StringEqualFold(s1).EqualFold(s1), qt.Equals, true) + c.Assert(StringEqualFold(s2).EqualFold(s1), qt.Equals, true) + c.Assert(StringEqualFold(s2).EqualFold(s2), qt.Equals, true) + c.Assert(StringEqualFold(s1).EqualFold("b"), qt.Equals, false) + c.Assert(StringEqualFold(s1).Eq(s2), qt.Equals, true) + c.Assert(StringEqualFold(s1).Eq("b"), qt.Equals, false) +} + +func TestGetOrCompileRegexp(t *testing.T) { + c := qt.New(t) + + re, err := GetOrCompileRegexp(`\d+`) + c.Assert(err, qt.IsNil) + c.Assert(re.MatchString("123"), qt.Equals, true) +} + +func BenchmarkGetOrCompileRegexp(b *testing.B) { + for i := 0; i < b.N; i++ { + GetOrCompileRegexp(`\d+`) + } +} + +func BenchmarkCompileRegexp(b *testing.B) { + for i := 0; i < b.N; i++ { + regexp.MustCompile(`\d+`) + } +} diff --git a/common/htime/htime_integration_test.go b/common/htime/htime_integration_test.go new file mode 100644 index 000000000..8090add12 --- /dev/null +++ b/common/htime/htime_integration_test.go @@ -0,0 +1,78 @@ +// Copyright 2024 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 htime_test + +import ( + "testing" + + "github.com/gohugoio/hugo/hugolib" +) + +// Issue #11267 +func TestApplyWithContext(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +defaultContentLanguage = 'it' +-- layouts/index.html -- +{{ $dates := slice + "2022-01-03" + "2022-02-01" + "2022-03-02" + "2022-04-07" + "2022-05-06" + "2022-06-04" + "2022-07-03" + "2022-08-01" + "2022-09-06" + "2022-10-05" + "2022-11-03" + "2022-12-02" +}} +{{ range $dates }} + {{ . | time.Format "month: _January_ weekday: _Monday_" }} + {{ . | time.Format "month: _Jan_ weekday: _Mon_" }} +{{ end }} + ` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", ` +month: _gennaio_ weekday: _lunedì_ +month: _gen_ weekday: _lun_ +month: _febbraio_ weekday: _martedì_ +month: _feb_ weekday: _mar_ +month: _marzo_ weekday: _mercoledì_ +month: _mar_ weekday: _mer_ +month: _aprile_ weekday: _giovedì_ +month: _apr_ weekday: _gio_ +month: _maggio_ weekday: _venerdì_ +month: _mag_ weekday: _ven_ +month: _giugno_ weekday: _sabato_ +month: _giu_ weekday: _sab_ +month: _luglio_ weekday: _domenica_ +month: _lug_ weekday: _dom_ +month: _agosto_ weekday: _lunedì_ +month: _ago_ weekday: _lun_ +month: _settembre_ weekday: _martedì_ +month: _set_ weekday: _mar_ +month: _ottobre_ weekday: _mercoledì_ +month: _ott_ weekday: _mer_ +month: _novembre_ weekday: _giovedì_ +month: _nov_ weekday: _gio_ +month: _dicembre_ weekday: _venerdì_ +month: _dic_ weekday: _ven_ +`) +} diff --git a/common/htime/time.go b/common/htime/time.go new file mode 100644 index 000000000..c71e39ee4 --- /dev/null +++ b/common/htime/time.go @@ -0,0 +1,177 @@ +// Copyright 2021 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 htime + +import ( + "log" + "strings" + "time" + + "github.com/bep/clocks" + "github.com/spf13/cast" + + "github.com/gohugoio/locales" +) + +var ( + longDayNames = []string{ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + } + + shortDayNames = []string{ + "Sun", + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + } + + shortMonthNames = []string{ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + } + + longMonthNames = []string{ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + } + + Clock = clocks.System() +) + +func NewTimeFormatter(ltr locales.Translator) TimeFormatter { + if ltr == nil { + panic("must provide a locales.Translator") + } + return TimeFormatter{ + ltr: ltr, + } +} + +// TimeFormatter is locale aware. +type TimeFormatter struct { + ltr locales.Translator +} + +func (f TimeFormatter) Format(t time.Time, layout string) string { + if layout == "" { + return "" + } + + if layout[0] == ':' { + // It may be one of Hugo's custom layouts. + switch strings.ToLower(layout[1:]) { + case "date_full": + return f.ltr.FmtDateFull(t) + case "date_long": + return f.ltr.FmtDateLong(t) + case "date_medium": + return f.ltr.FmtDateMedium(t) + case "date_short": + return f.ltr.FmtDateShort(t) + case "time_full": + return f.ltr.FmtTimeFull(t) + case "time_long": + return f.ltr.FmtTimeLong(t) + case "time_medium": + return f.ltr.FmtTimeMedium(t) + case "time_short": + return f.ltr.FmtTimeShort(t) + } + } + + s := t.Format(layout) + + monthIdx := t.Month() - 1 // Month() starts at 1. + dayIdx := t.Weekday() + + if strings.Contains(layout, "January") { + s = strings.ReplaceAll(s, longMonthNames[monthIdx], f.ltr.MonthWide(t.Month())) + } else if strings.Contains(layout, "Jan") { + s = strings.ReplaceAll(s, shortMonthNames[monthIdx], f.ltr.MonthAbbreviated(t.Month())) + } + + if strings.Contains(layout, "Monday") { + s = strings.ReplaceAll(s, longDayNames[dayIdx], f.ltr.WeekdayWide(t.Weekday())) + } else if strings.Contains(layout, "Mon") { + s = strings.ReplaceAll(s, shortDayNames[dayIdx], f.ltr.WeekdayAbbreviated(t.Weekday())) + } + + return s +} + +func ToTimeInDefaultLocationE(i any, location *time.Location) (tim time.Time, err error) { + switch vv := i.(type) { + case AsTimeProvider: + return vv.AsTime(location), nil + // issue #8895 + // datetimes parsed by `go-toml` have empty zone name + // convert back them into string and use `cast` + // TODO(bep) add tests, make sure we really need this. + case time.Time: + i = vv.Format(time.RFC3339) + } + return cast.ToTimeInDefaultLocationE(i, location) +} + +// Now returns time.Now() or time value based on the `clock` flag. +// Use this function to fake time inside hugo. +func Now() time.Time { + return Clock.Now() +} + +func Since(t time.Time) time.Duration { + return Clock.Since(t) +} + +// AsTimeProvider is implemented by go-toml's LocalDate and LocalDateTime. +type AsTimeProvider interface { + AsTime(zone *time.Location) time.Time +} + +// StopWatch is a simple helper to measure time during development. +func StopWatch(name string) func() { + start := time.Now() + return func() { + log.Printf("StopWatch %q took %s", name, time.Since(start)) + } +} diff --git a/common/htime/time_test.go b/common/htime/time_test.go new file mode 100644 index 000000000..78954887e --- /dev/null +++ b/common/htime/time_test.go @@ -0,0 +1,144 @@ +// Copyright 2021 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 htime + +import ( + "testing" + "time" + + qt "github.com/frankban/quicktest" + translators "github.com/gohugoio/localescompressed" +) + +func TestTimeFormatter(t *testing.T) { + c := qt.New(t) + + june06, _ := time.Parse("2006-Jan-02", "2018-Jun-06") + june06 = june06.Add(7777 * time.Second) + + jan06, _ := time.Parse("2006-Jan-02", "2018-Jan-06") + jan06 = jan06.Add(32 * time.Second) + + mondayNovemberFirst, _ := time.Parse("2006-Jan-02", "2021-11-01") + mondayNovemberFirst = mondayNovemberFirst.Add(33 * time.Second) + + c.Run("Norsk nynorsk", func(c *qt.C) { + f := NewTimeFormatter(translators.GetTranslator("nn")) + + c.Assert(f.Format(june06, "Monday Jan 2 2006"), qt.Equals, "onsdag juni 6 2018") + c.Assert(f.Format(june06, "Mon January 2 2006"), qt.Equals, "on. juni 6 2018") + c.Assert(f.Format(june06, "Mon Mon"), qt.Equals, "on. on.") + }) + + c.Run("Custom layouts Norsk nynorsk", func(c *qt.C) { + f := NewTimeFormatter(translators.GetTranslator("nn")) + + c.Assert(f.Format(june06, ":date_full"), qt.Equals, "onsdag 6. juni 2018") + c.Assert(f.Format(june06, ":date_long"), qt.Equals, "6. juni 2018") + c.Assert(f.Format(june06, ":date_medium"), qt.Equals, "6. juni 2018") + c.Assert(f.Format(june06, ":date_short"), qt.Equals, "06.06.2018") + + c.Assert(f.Format(june06, ":time_full"), qt.Equals, "kl. 02:09:37 UTC") + c.Assert(f.Format(june06, ":time_long"), qt.Equals, "02:09:37 UTC") + c.Assert(f.Format(june06, ":time_medium"), qt.Equals, "02:09:37") + c.Assert(f.Format(june06, ":time_short"), qt.Equals, "02:09") + }) + + c.Run("Custom layouts English", func(c *qt.C) { + f := NewTimeFormatter(translators.GetTranslator("en")) + + c.Assert(f.Format(june06, ":date_full"), qt.Equals, "Wednesday, June 6, 2018") + c.Assert(f.Format(june06, ":date_long"), qt.Equals, "June 6, 2018") + c.Assert(f.Format(june06, ":date_medium"), qt.Equals, "Jun 6, 2018") + c.Assert(f.Format(june06, ":date_short"), qt.Equals, "6/6/18") + + c.Assert(f.Format(june06, ":time_full"), qt.Equals, "2:09:37 am UTC") + c.Assert(f.Format(june06, ":time_long"), qt.Equals, "2:09:37 am UTC") + c.Assert(f.Format(june06, ":time_medium"), qt.Equals, "2:09:37 am") + c.Assert(f.Format(june06, ":time_short"), qt.Equals, "2:09 am") + }) + + c.Run("English", func(c *qt.C) { + f := NewTimeFormatter(translators.GetTranslator("en")) + + c.Assert(f.Format(june06, "Monday Jan 2 2006"), qt.Equals, "Wednesday Jun 6 2018") + c.Assert(f.Format(june06, "Mon January 2 2006"), qt.Equals, "Wed June 6 2018") + c.Assert(f.Format(june06, "Mon Mon"), qt.Equals, "Wed Wed") + }) + + c.Run("Weekdays German", func(c *qt.C) { + tr := translators.GetTranslator("de") + f := NewTimeFormatter(tr) + + // Issue #9107 + for i, weekDayWideGerman := range []string{"Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"} { + date := mondayNovemberFirst.Add(time.Duration(i*24) * time.Hour) + c.Assert(tr.WeekdayWide(date.Weekday()), qt.Equals, weekDayWideGerman) + c.Assert(f.Format(date, "Monday"), qt.Equals, weekDayWideGerman) + } + + for i, weekDayAbbreviatedGerman := range []string{"Mo.", "Di.", "Mi.", "Do.", "Fr.", "Sa.", "So."} { + date := mondayNovemberFirst.Add(time.Duration(i*24) * time.Hour) + c.Assert(tr.WeekdayAbbreviated(date.Weekday()), qt.Equals, weekDayAbbreviatedGerman) + c.Assert(f.Format(date, "Mon"), qt.Equals, weekDayAbbreviatedGerman) + } + }) + + c.Run("Months German", func(c *qt.C) { + tr := translators.GetTranslator("de") + f := NewTimeFormatter(tr) + + // Issue #9107 + for i, monthWideNorway := range []string{"Januar", "Februar", "März", "April", "Mai", "Juni", "Juli"} { + date := jan06.Add(time.Duration(i*24*31) * time.Hour) + c.Assert(tr.MonthWide(date.Month()), qt.Equals, monthWideNorway) + c.Assert(f.Format(date, "January"), qt.Equals, monthWideNorway) + } + }) +} + +func BenchmarkTimeFormatter(b *testing.B) { + june06, _ := time.Parse("2006-Jan-02", "2018-Jun-06") + + b.Run("Native", func(b *testing.B) { + for i := 0; i < b.N; i++ { + got := june06.Format("Monday Jan 2 2006") + if got != "Wednesday Jun 6 2018" { + b.Fatalf("invalid format, got %q", got) + } + } + }) + + b.Run("Localized", func(b *testing.B) { + f := NewTimeFormatter(translators.GetTranslator("nn")) + b.ResetTimer() + for i := 0; i < b.N; i++ { + got := f.Format(june06, "Monday Jan 2 2006") + if got != "onsdag juni 6 2018" { + b.Fatalf("invalid format, got %q", got) + } + } + }) + + b.Run("Localized Custom", func(b *testing.B) { + f := NewTimeFormatter(translators.GetTranslator("nn")) + b.ResetTimer() + for i := 0; i < b.N; i++ { + got := f.Format(june06, ":date_medium") + if got != "6. juni 2018" { + b.Fatalf("invalid format, got %q", got) + } + } + }) +} diff --git a/common/hugio/copy.go b/common/hugio/copy.go index 2b756cb44..31d679dfc 100644 --- a/common/hugio/copy.go +++ b/common/hugio/copy.go @@ -14,60 +14,63 @@ package hugio import ( + "fmt" "io" - "io/ioutil" - "os" + iofs "io/fs" "path/filepath" - "github.com/pkg/errors" - "github.com/spf13/afero" ) // CopyFile copies a file. func CopyFile(fs afero.Fs, from, to string) error { - sf, err := os.Open(from) + sf, err := fs.Open(from) if err != nil { return err } defer sf.Close() - df, err := os.Create(to) + df, err := fs.Create(to) if err != nil { return err } defer df.Close() _, err = io.Copy(df, sf) - if err == nil { - si, err := os.Stat(from) - if err != nil { - err = os.Chmod(to, si.Mode()) - - if err != nil { - return err - } - } - + if err != nil { + return err } + si, err := fs.Stat(from) + if err != nil { + err = fs.Chmod(to, si.Mode()) + + if err != nil { + return err + } + } + return nil } // CopyDir copies a directory. func CopyDir(fs afero.Fs, from, to string, shouldCopy func(filename string) bool) error { - fi, err := os.Stat(from) + fi, err := fs.Stat(from) if err != nil { return err } if !fi.IsDir() { - return errors.Errorf("%q is not a directory", from) + return fmt.Errorf("%q is not a directory", from) } - err = fs.MkdirAll(to, 0777) // before umask + err = fs.MkdirAll(to, 0o777) // before umask if err != nil { return err } - entries, _ := ioutil.ReadDir(from) + d, err := fs.Open(from) + if err != nil { + return err + } + entries, _ := d.(iofs.ReadDirFile).ReadDir(-1) for _, entry := range entries { fromFilename := filepath.Join(from, entry.Name()) toFilename := filepath.Join(to, entry.Name()) diff --git a/common/hugio/hasBytesWriter.go b/common/hugio/hasBytesWriter.go new file mode 100644 index 000000000..d2bcd1bb4 --- /dev/null +++ b/common/hugio/hasBytesWriter.go @@ -0,0 +1,80 @@ +// Copyright 2024 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 hugio + +import ( + "bytes" +) + +// HasBytesWriter is a writer will match against a slice of patterns. +type HasBytesWriter struct { + Patterns []*HasBytesPattern + + i int + done bool + buff []byte +} + +type HasBytesPattern struct { + Match bool + Pattern []byte +} + +func (h *HasBytesWriter) patternLen() int { + l := 0 + for _, p := range h.Patterns { + l += len(p.Pattern) + } + return l +} + +func (h *HasBytesWriter) Write(p []byte) (n int, err error) { + if h.done { + return len(p), nil + } + + if len(h.buff) == 0 { + h.buff = make([]byte, h.patternLen()*2) + } + + 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 + } + + 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 + } + } + + } + + return len(p), nil +} diff --git a/common/hugio/hasBytesWriter_test.go b/common/hugio/hasBytesWriter_test.go new file mode 100644 index 000000000..9e689a112 --- /dev/null +++ b/common/hugio/hasBytesWriter_test.go @@ -0,0 +1,67 @@ +// Copyright 2024 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 hugio + +import ( + "bytes" + "fmt" + "io" + "math/rand" + "strings" + "testing" + "time" + + qt "github.com/frankban/quicktest" +) + +func TestHasBytesWriter(t *testing.T) { + r := rand.New(rand.NewSource(time.Now().UnixNano())) + + c := qt.New((t)) + + neww := func() (*HasBytesWriter, io.Writer) { + var b bytes.Buffer + + h := &HasBytesWriter{ + Patterns: []*HasBytesPattern{ + {Pattern: []byte("__foo")}, + }, + } + + return h, io.MultiWriter(&b, h) + } + + rndStr := func() string { + return strings.Repeat("ab cfo", r.Intn(33)) + } + + for range 22 { + h, w := neww() + fmt.Fprint(w, rndStr()+"abc __foobar"+rndStr()) + c.Assert(h.Patterns[0].Match, qt.Equals, true) + + h, w = neww() + fmt.Fprint(w, rndStr()+"abc __f") + fmt.Fprint(w, "oo bar"+rndStr()) + c.Assert(h.Patterns[0].Match, qt.Equals, true) + + h, w = neww() + fmt.Fprint(w, rndStr()+"abc __moo bar") + c.Assert(h.Patterns[0].Match, qt.Equals, false) + } + + h, w := neww() + fmt.Fprintf(w, "__foo") + c.Assert(h.Patterns[0].Match, qt.Equals, true) +} diff --git a/common/hugio/readers.go b/common/hugio/readers.go index 8c901dd24..c4304c84e 100644 --- a/common/hugio/readers.go +++ b/common/hugio/readers.go @@ -14,6 +14,7 @@ package hugio import ( + "bytes" "io" "strings" ) @@ -31,24 +32,75 @@ type ReadSeekCloser interface { io.Closer } -// ReadSeekerNoOpCloser implements ReadSeekCloser by doing nothing in Close. -// TODO(bep) rename this and simila to ReadSeekerNopCloser, naming used in stdlib, which kind of makes sense. -type ReadSeekerNoOpCloser struct { +// ReadSeekCloserProvider provides a ReadSeekCloser. +type ReadSeekCloserProvider interface { + ReadSeekCloser() (ReadSeekCloser, error) +} + +// readSeekerNopCloser implements ReadSeekCloser by doing nothing in Close. +type readSeekerNopCloser struct { ReadSeeker } // Close does nothing. -func (r ReadSeekerNoOpCloser) Close() error { +func (r readSeekerNopCloser) Close() error { return nil } // NewReadSeekerNoOpCloser creates a new ReadSeekerNoOpCloser with the given ReadSeeker. -func NewReadSeekerNoOpCloser(r ReadSeeker) ReadSeekerNoOpCloser { - return ReadSeekerNoOpCloser{r} +func NewReadSeekerNoOpCloser(r ReadSeeker) ReadSeekCloser { + return readSeekerNopCloser{r} } // NewReadSeekerNoOpCloserFromString uses strings.NewReader to create a new ReadSeekerNoOpCloser // from the given string. -func NewReadSeekerNoOpCloserFromString(content string) ReadSeekerNoOpCloser { - return ReadSeekerNoOpCloser{strings.NewReader(content)} +func NewReadSeekerNoOpCloserFromString(content string) ReadSeekCloser { + return stringReadSeeker{s: content, readSeekerNopCloser: readSeekerNopCloser{strings.NewReader(content)}} +} + +var _ StringReader = (*stringReadSeeker)(nil) + +type stringReadSeeker struct { + s string + readSeekerNopCloser +} + +func (s *stringReadSeeker) ReadString() string { + return s.s +} + +// StringReader provides a way to read a string. +type StringReader interface { + ReadString() string +} + +// NewReadSeekerNoOpCloserFromBytes uses bytes.NewReader to create a new ReadSeekerNoOpCloser +// from the given bytes slice. +func NewReadSeekerNoOpCloserFromBytes(content []byte) readSeekerNopCloser { + return readSeekerNopCloser{bytes.NewReader(content)} +} + +// NewOpenReadSeekCloser creates a new ReadSeekCloser from the given ReadSeeker. +// The ReadSeeker will be seeked to the beginning before returned. +func NewOpenReadSeekCloser(r ReadSeekCloser) OpenReadSeekCloser { + return func() (ReadSeekCloser, error) { + r.Seek(0, io.SeekStart) + return r, nil + } +} + +// OpenReadSeekCloser allows setting some other way (than reading from a filesystem) +// to open or create a ReadSeekCloser. +type OpenReadSeekCloser func() (ReadSeekCloser, error) + +// ReadString reads from the given reader and returns the content as a string. +func ReadString(r io.Reader) (string, error) { + if sr, ok := r.(StringReader); ok { + return sr.ReadString(), nil + } + b, err := io.ReadAll(r) + if err != nil { + return "", err + } + return string(b), nil } diff --git a/common/hugio/writers.go b/common/hugio/writers.go index 82c4dca52..6f439cc8b 100644 --- a/common/hugio/writers.go +++ b/common/hugio/writers.go @@ -15,9 +15,16 @@ package hugio import ( "io" - "io/ioutil" ) +// As implemented by strings.Builder. +type FlexiWriter interface { + io.Writer + io.ByteWriter + WriteString(s string) (int, error) + WriteRune(r rune) (int, error) +} + type multiWriteCloser struct { io.Writer closers []io.WriteCloser @@ -26,7 +33,7 @@ type multiWriteCloser struct { func (m multiWriteCloser) Close() error { var err error for _, c := range m.closers { - if closeErr := c.Close(); err != nil { + if closeErr := c.Close(); closeErr != nil { err = closeErr } } @@ -55,7 +62,7 @@ func ToWriteCloser(w io.Writer) io.WriteCloser { io.Closer }{ w, - ioutil.NopCloser(nil), + io.NopCloser(nil), } } @@ -71,6 +78,36 @@ func ToReadCloser(r io.Reader) io.ReadCloser { io.Closer }{ r, - ioutil.NopCloser(nil), + io.NopCloser(nil), } } + +type ReadWriteCloser interface { + io.Reader + io.Writer + io.Closer +} + +// PipeReadWriteCloser is a convenience type to create a pipe with a ReadCloser and a WriteCloser. +type PipeReadWriteCloser struct { + *io.PipeReader + *io.PipeWriter +} + +// NewPipeReadWriteCloser creates a new PipeReadWriteCloser. +func NewPipeReadWriteCloser() PipeReadWriteCloser { + pr, pw := io.Pipe() + return PipeReadWriteCloser{pr, pw} +} + +func (c PipeReadWriteCloser) Close() (err error) { + if err = c.PipeReader.Close(); err != nil { + return + } + err = c.PipeWriter.Close() + return +} + +func (c PipeReadWriteCloser) WriteString(s string) (int, error) { + return c.PipeWriter.Write([]byte(s)) +} diff --git a/common/hugo/hugo.go b/common/hugo/hugo.go index 62d923bf0..764a86a97 100644 --- a/common/hugo/hugo.go +++ b/common/hugo/hugo.go @@ -14,8 +14,32 @@ package hugo import ( + "context" "fmt" "html/template" + "os" + "path/filepath" + "runtime/debug" + "sort" + "strings" + "sync" + "time" + + "github.com/bep/logg" + + "github.com/bep/godartsass/v2" + "github.com/gohugoio/hugo/common/hcontext" + "github.com/gohugoio/hugo/common/hexec" + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/hugofs/files" + + "github.com/spf13/afero" + + iofs "io/fs" + + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/hugofs" ) const ( @@ -24,16 +48,16 @@ const ( ) var ( - // commitHash contains the current Git revision. Use make to build to make - // sure this gets set. - commitHash string - - // buildDate contains the date of the current build. + // buildDate allows vendor-specified build date when .git/ is unavailable. buildDate string + // vendorInfo contains vendor notes about the current build. + vendorInfo string ) -// Info contains information about the current Hugo environment -type Info struct { +var _ maps.StoreProvider = (*HugoInfo)(nil) + +// HugoInfo contains information about the current Hugo environment +type HugoInfo struct { CommitHash string BuildDate string @@ -42,26 +66,402 @@ type Info struct { // This can also be set by the user. // It can be any string, but it will be all lower case. Environment string + + // version of go that the Hugo binary was built with + GoVersion string + + conf ConfigProvider + deps []*Dependency + + store *maps.Scratch + + // Context gives access to some of the context scoped variables. + Context Context } // Version returns the current version as a comparable version string. -func (i Info) Version() VersionString { +func (i HugoInfo) Version() VersionString { return CurrentVersion.Version() } // Generator a Hugo meta generator HTML tag. -func (i Info) Generator() template.HTML { - return template.HTML(fmt.Sprintf(``, CurrentVersion.String())) +func (i HugoInfo) Generator() template.HTML { + return template.HTML(fmt.Sprintf(``, CurrentVersion.String())) +} + +// IsDevelopment reports whether the current running environment is "development". +func (i HugoInfo) IsDevelopment() bool { + return i.Environment == EnvironmentDevelopment +} + +// IsProduction reports whether the current running environment is "production". +func (i HugoInfo) IsProduction() bool { + return i.Environment == EnvironmentProduction +} + +// IsServer reports whether the built-in server is running. +func (i HugoInfo) IsServer() bool { + return i.conf.Running() +} + +// IsExtended reports whether the Hugo binary is the extended version. +func (i HugoInfo) IsExtended() bool { + return IsExtended +} + +// WorkingDir returns the project working directory. +func (i HugoInfo) WorkingDir() string { + return i.conf.WorkingDir() +} + +// Deps gets a list of dependencies for this Hugo build. +func (i HugoInfo) Deps() []*Dependency { + return i.deps +} + +func (i HugoInfo) Store() *maps.Scratch { + return i.store +} + +// Deprecated: Use hugo.IsMultihost instead. +func (i HugoInfo) IsMultiHost() bool { + Deprecate("hugo.IsMultiHost", "Use hugo.IsMultihost instead.", "v0.124.0") + return i.conf.IsMultihost() +} + +// IsMultihost reports whether each configured language has a unique baseURL. +func (i HugoInfo) IsMultihost() bool { + return i.conf.IsMultihost() +} + +// IsMultilingual reports whether there are two or more configured languages. +func (i HugoInfo) IsMultilingual() bool { + return i.conf.IsMultilingual() +} + +type contextKey uint8 + +const ( + contextKeyMarkupScope contextKey = iota +) + +var markupScope = hcontext.NewContextDispatcher[string](contextKeyMarkupScope) + +type Context struct{} + +func (c Context) MarkupScope(ctx context.Context) string { + return GetMarkupScope(ctx) +} + +// SetMarkupScope sets the markup scope in the context. +func SetMarkupScope(ctx context.Context, s string) context.Context { + return markupScope.Set(ctx, s) +} + +// GetMarkupScope gets the markup scope from the context. +func GetMarkupScope(ctx context.Context) string { + return markupScope.Get(ctx) +} + +// ConfigProvider represents the config options that are relevant for HugoInfo. +type ConfigProvider interface { + Environment() string + Running() bool + WorkingDir() string + IsMultihost() bool + IsMultilingual() bool } // NewInfo creates a new Hugo Info object. -func NewInfo(environment string) Info { - if environment == "" { - environment = EnvironmentProduction +func NewInfo(conf ConfigProvider, deps []*Dependency) HugoInfo { + if conf.Environment() == "" { + panic("environment not set") } - return Info{ + var ( + commitHash string + buildDate string + goVersion string + ) + + bi := getBuildInfo() + if bi != nil { + commitHash = bi.Revision + buildDate = bi.RevisionTime + goVersion = bi.GoVersion + } + + return HugoInfo{ CommitHash: commitHash, BuildDate: buildDate, - Environment: environment, + Environment: conf.Environment(), + conf: conf, + deps: deps, + store: maps.NewScratch(), + GoVersion: goVersion, + } +} + +// GetExecEnviron creates and gets the common os/exec environment used in the +// external programs we interact with via os/exec, e.g. postcss. +func GetExecEnviron(workDir string, cfg config.AllProvider, fs afero.Fs) []string { + var env []string + nodepath := filepath.Join(workDir, "node_modules") + if np := os.Getenv("NODE_PATH"); np != "" { + nodepath = workDir + string(os.PathListSeparator) + np + } + config.SetEnvVars(&env, "NODE_PATH", nodepath) + config.SetEnvVars(&env, "PWD", workDir) + config.SetEnvVars(&env, "HUGO_ENVIRONMENT", cfg.Environment()) + config.SetEnvVars(&env, "HUGO_ENV", cfg.Environment()) + config.SetEnvVars(&env, "HUGO_PUBLISHDIR", filepath.Join(workDir, cfg.BaseConfig().PublishDir)) + + if fs != nil { + var fis []iofs.DirEntry + d, err := fs.Open(files.FolderJSConfig) + if err == nil { + fis, err = d.(iofs.ReadDirFile).ReadDir(-1) + } + + if err == nil { + for _, fi := range fis { + key := fmt.Sprintf("HUGO_FILE_%s", strings.ReplaceAll(strings.ToUpper(fi.Name()), ".", "_")) + value := fi.(hugofs.FileMetaInfo).Meta().Filename + config.SetEnvVars(&env, key, value) + } + } + } + + return env +} + +type buildInfo struct { + VersionControlSystem string + Revision string + RevisionTime string + Modified bool + + GoOS string + GoArch string + + *debug.BuildInfo +} + +var ( + bInfo *buildInfo + bInfoInit sync.Once +) + +func getBuildInfo() *buildInfo { + bInfoInit.Do(func() { + bi, ok := debug.ReadBuildInfo() + if !ok { + return + } + + bInfo = &buildInfo{BuildInfo: bi} + + for _, s := range bInfo.Settings { + switch s.Key { + case "vcs": + bInfo.VersionControlSystem = s.Value + case "vcs.revision": + bInfo.Revision = s.Value + case "vcs.time": + bInfo.RevisionTime = s.Value + case "vcs.modified": + bInfo.Modified = s.Value == "true" + case "GOOS": + bInfo.GoOS = s.Value + case "GOARCH": + bInfo.GoArch = s.Value + } + } + }) + + return bInfo +} + +func formatDep(path, version string) string { + return fmt.Sprintf("%s=%q", path, version) +} + +// GetDependencyList returns a sorted dependency list on the format package="version". +// It includes both Go dependencies and (a manually maintained) list of C(++) dependencies. +func GetDependencyList() []string { + var deps []string + + bi := getBuildInfo() + if bi == nil { + return deps + } + + for _, dep := range bi.Deps { + deps = append(deps, formatDep(dep.Path, dep.Version)) + } + + deps = append(deps, GetDependencyListNonGo()...) + + sort.Strings(deps) + + return deps +} + +// GetDependencyListNonGo returns a list of non-Go dependencies. +func GetDependencyListNonGo() []string { + var deps []string + + if IsExtended { + deps = append( + deps, + formatDep("github.com/sass/libsass", "3.6.6"), + formatDep("github.com/webmproject/libwebp", "v1.3.2"), + ) + } + + if dartSass := dartSassVersion(); dartSass.ProtocolVersion != "" { + dartSassPath := "github.com/sass/dart-sass-embedded" + if IsDartSassGeV2() { + dartSassPath = "github.com/sass/dart-sass" + } + deps = append(deps, + formatDep(dartSassPath+"/protocol", dartSass.ProtocolVersion), + formatDep(dartSassPath+"/compiler", dartSass.CompilerVersion), + formatDep(dartSassPath+"/implementation", dartSass.ImplementationVersion), + ) + } + return deps +} + +// IsRunningAsTest reports whether we are running as a test. +func IsRunningAsTest() bool { + for _, arg := range os.Args { + if strings.HasPrefix(arg, "-test") { + return true + } + } + return false +} + +// Dependency is a single dependency, which can be either a Hugo Module or a local theme. +type Dependency struct { + // Returns the path to this module. + // This will either be the module path, e.g. "github.com/gohugoio/myshortcodes", + // or the path below your /theme folder, e.g. "mytheme". + Path string + + // The module version. + Version string + + // Whether this dependency is vendored. + Vendor bool + + // Time version was created. + Time time.Time + + // In the dependency tree, this is the first module that defines this module + // as a dependency. + Owner *Dependency + + // Replaced by this dependency. + Replace *Dependency +} + +func dartSassVersion() godartsass.DartSassVersion { + if DartSassBinaryName == "" || !IsDartSassGeV2() { + return godartsass.DartSassVersion{} + } + v, _ := godartsass.Version(DartSassBinaryName) + return v +} + +// DartSassBinaryName is the name of the Dart Sass binary to use. +// TODO(bep) find a better place for this. +var DartSassBinaryName string + +func init() { + DartSassBinaryName = os.Getenv("DART_SASS_BINARY") + if DartSassBinaryName == "" { + for _, name := range dartSassBinaryNamesV2 { + if hexec.InPath(name) { + DartSassBinaryName = name + break + } + } + if DartSassBinaryName == "" { + if hexec.InPath(dartSassBinaryNameV1) { + DartSassBinaryName = dartSassBinaryNameV1 + } + } + } +} + +var ( + dartSassBinaryNameV1 = "dart-sass-embedded" + dartSassBinaryNamesV2 = []string{"dart-sass", "sass"} +) + +// TODO(bep) we eventually want to remove this, but keep it for a while to throw an informative error. +// We stopped supporting the old binary in Hugo 0.139.0. +func IsDartSassGeV2() bool { + // dart-sass-embedded was the first version of the embedded Dart Sass before it was moved into the main project. + return !strings.Contains(DartSassBinaryName, "embedded") +} + +// Deprecate informs about a deprecation starting at the given version. +// +// A deprecation typically needs a simple change in the template, but doing so will make the template incompatible with older versions. +// Theme maintainers generally want +// 1. No warnings or errors in the console when building a Hugo site. +// 2. Their theme to work for at least the last few Hugo versions. +func Deprecate(item, alternative string, version string) { + level := deprecationLogLevelFromVersion(version) + deprecateLevel(item, alternative, version, level) +} + +// See Deprecate for details. +func DeprecateWithLogger(item, alternative string, version string, log logg.Logger) { + level := deprecationLogLevelFromVersion(version) + deprecateLevelWithLogger(item, alternative, version, level, log) +} + +// DeprecateLevelMin informs about a deprecation starting at the given version, but with a minimum log level. +func DeprecateLevelMin(item, alternative string, version string, minLevel logg.Level) { + level := max(deprecationLogLevelFromVersion(version), minLevel) + deprecateLevel(item, alternative, version, level) +} + +// deprecateLevel informs about a deprecation logging at the given level. +func deprecateLevel(item, alternative, version string, level logg.Level) { + deprecateLevelWithLogger(item, alternative, version, level, loggers.Log().Logger()) +} + +// DeprecateLevel informs about a deprecation logging at the given level. +func deprecateLevelWithLogger(item, alternative, version string, level logg.Level, log logg.Logger) { + var msg string + if level == logg.LevelError { + msg = fmt.Sprintf("%s was deprecated in Hugo %s and subsequently removed. %s", item, version, alternative) + } else { + msg = fmt.Sprintf("%s was deprecated in Hugo %s and will be removed in a future release. %s", item, version, alternative) + } + + log.WithLevel(level).WithField(loggers.FieldNameCmd, "deprecated").Logf("%s", msg) +} + +// We usually do about one minor version a month. +// We want people to run at least the current and previous version without any warnings. +// We want people who don't update Hugo that often to see the warnings and errors before we remove the feature. +func deprecationLogLevelFromVersion(ver string) logg.Level { + from := MustParseVersion(ver) + to := CurrentVersion + minorDiff := to.Minor - from.Minor + switch { + case minorDiff >= 15: + // Start failing the build after about 15 months. + return logg.LevelError + case minorDiff >= 3: + // Start printing warnings after about 3 months. + return logg.LevelWarn + default: + return logg.LevelInfo } } diff --git a/common/hugo/hugo_integration_test.go b/common/hugo/hugo_integration_test.go new file mode 100644 index 000000000..77dbb5c91 --- /dev/null +++ b/common/hugo/hugo_integration_test.go @@ -0,0 +1,77 @@ +// Copyright 2024 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 hugo_test + +import ( + "strings" + "testing" + + "github.com/gohugoio/hugo/hugolib" +) + +func TestIsMultilingualAndIsMultihost(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['page','rss','section','sitemap','taxonomy','term'] +defaultContentLanguageInSubdir = true +[languages.de] +baseURL = 'https://de.example.org/' +[languages.en] +baseURL = 'https://en.example.org/' +-- content/_index.md -- +--- +title: home +--- +-- layouts/index.html -- +multilingual={{ hugo.IsMultilingual }} +multihost={{ hugo.IsMultihost }} + ` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/de/index.html", + "multilingual=true", + "multihost=true", + ) + b.AssertFileContent("public/en/index.html", + "multilingual=true", + "multihost=true", + ) + + files = strings.ReplaceAll(files, "baseURL = 'https://de.example.org/'", "") + files = strings.ReplaceAll(files, "baseURL = 'https://en.example.org/'", "") + + b = hugolib.Test(t, files) + + b.AssertFileContent("public/de/index.html", + "multilingual=true", + "multihost=false", + ) + b.AssertFileContent("public/en/index.html", + "multilingual=true", + "multihost=false", + ) + + files = strings.ReplaceAll(files, "[languages.de]", "") + files = strings.ReplaceAll(files, "[languages.en]", "") + + b = hugolib.Test(t, files) + + b.AssertFileContent("public/en/index.html", + "multilingual=false", + "multihost=false", + ) +} diff --git a/common/hugo/hugo_test.go b/common/hugo/hugo_test.go index 5be575b62..f938073da 100644 --- a/common/hugo/hugo_test.go +++ b/common/hugo/hugo_test.go @@ -14,22 +14,98 @@ package hugo import ( + "context" "fmt" "testing" + "github.com/bep/logg" qt "github.com/frankban/quicktest" ) func TestHugoInfo(t *testing.T) { c := qt.New(t) - hugoInfo := NewInfo("") + conf := testConfig{environment: "production", workingDir: "/mywork", running: false} + hugoInfo := NewInfo(conf, nil) c.Assert(hugoInfo.Version(), qt.Equals, CurrentVersion.Version()) c.Assert(fmt.Sprintf("%T", VersionString("")), qt.Equals, fmt.Sprintf("%T", hugoInfo.Version())) - c.Assert(hugoInfo.CommitHash, qt.Equals, commitHash) - c.Assert(hugoInfo.BuildDate, qt.Equals, buildDate) + c.Assert(hugoInfo.WorkingDir(), qt.Equals, "/mywork") + + bi := getBuildInfo() + if bi != nil { + c.Assert(hugoInfo.CommitHash, qt.Equals, bi.Revision) + c.Assert(hugoInfo.BuildDate, qt.Equals, bi.RevisionTime) + c.Assert(hugoInfo.GoVersion, qt.Equals, bi.GoVersion) + } c.Assert(hugoInfo.Environment, qt.Equals, "production") c.Assert(string(hugoInfo.Generator()), qt.Contains, fmt.Sprintf("Hugo %s", hugoInfo.Version())) + c.Assert(hugoInfo.IsDevelopment(), qt.Equals, false) + c.Assert(hugoInfo.IsProduction(), qt.Equals, true) + c.Assert(hugoInfo.IsExtended(), qt.Equals, IsExtended) + c.Assert(hugoInfo.IsServer(), qt.Equals, false) + devHugoInfo := NewInfo(testConfig{environment: "development", running: true}, nil) + c.Assert(devHugoInfo.IsDevelopment(), qt.Equals, true) + c.Assert(devHugoInfo.IsProduction(), qt.Equals, false) + c.Assert(devHugoInfo.IsServer(), qt.Equals, true) +} + +func TestDeprecationLogLevelFromVersion(t *testing.T) { + c := qt.New(t) + + c.Assert(deprecationLogLevelFromVersion("0.55.0"), qt.Equals, logg.LevelError) + ver := CurrentVersion + c.Assert(deprecationLogLevelFromVersion(ver.String()), qt.Equals, logg.LevelInfo) + ver.Minor -= 3 + c.Assert(deprecationLogLevelFromVersion(ver.String()), qt.Equals, logg.LevelWarn) + ver.Minor -= 4 + c.Assert(deprecationLogLevelFromVersion(ver.String()), qt.Equals, logg.LevelWarn) + ver.Minor -= 13 + 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.122.0. + c.Assert(deprecationLogLevelFromVersion("0.127.0"), qt.Equals, logg.LevelError) +} + +func TestMarkupScope(t *testing.T) { + c := qt.New(t) + + conf := testConfig{environment: "production", workingDir: "/mywork", running: false} + info := NewInfo(conf, nil) + + ctx := context.Background() + + ctx = SetMarkupScope(ctx, "foo") + + c.Assert(info.Context.MarkupScope(ctx), qt.Equals, "foo") +} + +type testConfig struct { + environment string + running bool + workingDir string + multihost bool + multilingual bool +} + +func (c testConfig) Environment() string { + return c.environment +} + +func (c testConfig) Running() bool { + return c.running +} + +func (c testConfig) WorkingDir() string { + return c.workingDir +} + +func (c testConfig) IsMultihost() bool { + return c.multihost +} + +func (c testConfig) IsMultilingual() bool { + return c.multilingual } diff --git a/common/hugo/vars_extended.go b/common/hugo/vars_extended.go index bb96bade6..ab01e2647 100644 --- a/common/hugo/vars_extended.go +++ b/common/hugo/vars_extended.go @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -// +build extended +//go:build extended package hugo diff --git a/common/hugo/vars_regular.go b/common/hugo/vars_regular.go index fae18df14..a78aeb0b6 100644 --- a/common/hugo/vars_regular.go +++ b/common/hugo/vars_regular.go @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -// +build !extended +//go:build !extended package hugo diff --git a/common/hugo/vars_withdeploy.go b/common/hugo/vars_withdeploy.go new file mode 100644 index 000000000..4e0c3efbb --- /dev/null +++ b/common/hugo/vars_withdeploy.go @@ -0,0 +1,18 @@ +// Copyright 2024 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. + +//go:build withdeploy + +package hugo + +var IsWithdeploy = true diff --git a/common/hugo/vars_withdeploy_off.go b/common/hugo/vars_withdeploy_off.go new file mode 100644 index 000000000..36e9bd874 --- /dev/null +++ b/common/hugo/vars_withdeploy_off.go @@ -0,0 +1,18 @@ +// Copyright 2024 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. + +//go:build !withdeploy + +package hugo + +var IsWithdeploy = false diff --git a/common/hugo/version.go b/common/hugo/version.go index 848393f97..cf5988840 100644 --- a/common/hugo/version.go +++ b/common/hugo/version.go @@ -15,9 +15,10 @@ package hugo import ( "fmt" - "strconv" - + "io" + "math" "runtime" + "strconv" "strings" "github.com/gohugoio/hugo/compare" @@ -26,8 +27,9 @@ import ( // Version represents the Hugo build version. type Version struct { - // Major and minor version. - Number float32 + Major int + + Minor int // Increment this for bug releases PatchLevel int @@ -43,7 +45,7 @@ var ( ) func (v Version) String() string { - return version(v.Number, v.PatchLevel, v.Suffix) + return version(v.Major, v.Minor, v.PatchLevel, v.Suffix) } // Version returns the Hugo version. @@ -51,6 +53,11 @@ func (v Version) Version() VersionString { return VersionString(v.String()) } +// Compare implements the compare.Comparer interface. +func (h Version) Compare(other any) int { + return compareVersions(h, other) +} + // VersionString represents a Hugo version string. type VersionString string @@ -59,13 +66,16 @@ func (h VersionString) String() string { } // Compare implements the compare.Comparer interface. -func (h VersionString) Compare(other interface{}) int { - v := MustParseVersion(h.String()) - return compareVersionsWithSuffix(v.Number, v.PatchLevel, v.Suffix, other) +func (h VersionString) Compare(other any) int { + return compareVersions(h.Version(), other) +} + +func (h VersionString) Version() Version { + return MustParseVersion(h.String()) } // Eq implements the compare.Eqer interface. -func (h VersionString) Eq(other interface{}) bool { +func (h VersionString) Eq(other any) bool { s, err := cast.ToStringE(other) if err != nil { return false @@ -85,10 +95,7 @@ func ParseVersion(s string) (Version, error) { } } - v, p := parseVersion(s) - - vv.Number = v - vv.PatchLevel = p + vv.Major, vv.Minor, vv.PatchLevel = parseVersion(s) return vv, nil } @@ -111,76 +118,112 @@ func (v Version) ReleaseVersion() Version { // Next returns the next Hugo release version. func (v Version) Next() Version { - return Version{Number: v.Number + 0.01} + return Version{Major: v.Major, Minor: v.Minor + 1} } // Prev returns the previous Hugo release version. func (v Version) Prev() Version { - return Version{Number: v.Number - 0.01} + return Version{Major: v.Major, Minor: v.Minor - 1} } // NextPatchLevel returns the next patch/bugfix Hugo version. // This will be a patch increment on the previous Hugo version. func (v Version) NextPatchLevel(level int) Version { - return Version{Number: v.Number - 0.01, PatchLevel: level} + prev := v.Prev() + prev.PatchLevel = level + return prev } // BuildVersionString creates a version string. This is what you see when // running "hugo version". func BuildVersionString() string { - program := "Hugo Static Site Generator" + // program := "Hugo Static Site Generator" + program := "hugo" version := "v" + CurrentVersion.String() - if commitHash != "" { - version += "-" + strings.ToUpper(commitHash) + + bi := getBuildInfo() + if bi == nil { + return version + } + if bi.Revision != "" { + version += "-" + bi.Revision } if IsExtended { - version += "/extended" + version += "+extended" + } + if IsWithdeploy { + version += "+withdeploy" } - osArch := runtime.GOOS + "/" + runtime.GOARCH + osArch := bi.GoOS + "/" + bi.GoArch - date := buildDate + date := bi.RevisionTime + if date == "" { + // Accept vendor-specified build date if .git/ is unavailable. + date = buildDate + } if date == "" { date = "unknown" } - return fmt.Sprintf("%s %s %s BuildDate: %s", program, version, osArch, date) + versionString := fmt.Sprintf("%s %s %s BuildDate=%s", + program, version, osArch, date) + if vendorInfo != "" { + versionString += " VendorInfo=" + vendorInfo + } + + return versionString } -func version(version float32, patchVersion int, suffix string) string { - if patchVersion > 0 || version > 0.53 { - return fmt.Sprintf("%.2f.%d%s", version, patchVersion, suffix) +func version(major, minor, patch int, suffix string) string { + if patch > 0 || minor > 53 { + return fmt.Sprintf("%d.%d.%d%s", major, minor, patch, suffix) } - return fmt.Sprintf("%.2f%s", version, suffix) + return fmt.Sprintf("%d.%d%s", major, minor, suffix) } // CompareVersion compares the given version string or number against the // running Hugo version. // It returns -1 if the given version is less than, 0 if equal and 1 if greater than // the running version. -func CompareVersion(version interface{}) int { - return compareVersionsWithSuffix(CurrentVersion.Number, CurrentVersion.PatchLevel, CurrentVersion.Suffix, version) +func CompareVersion(version any) int { + return compareVersions(CurrentVersion, version) } -func compareVersions(inVersion float32, inPatchVersion int, in interface{}) int { - return compareVersionsWithSuffix(inVersion, inPatchVersion, "", in) -} - -func compareVersionsWithSuffix(inVersion float32, inPatchVersion int, suffix string, in interface{}) int { +func compareVersions(inVersion Version, in any) int { var c int switch d := in.(type) { case float64: - c = compareFloatVersions(inVersion, float32(d)) + c = compareFloatWithVersion(d, inVersion) case float32: - c = compareFloatVersions(inVersion, d) + c = compareFloatWithVersion(float64(d), inVersion) case int: - c = compareFloatVersions(inVersion, float32(d)) + c = compareFloatWithVersion(float64(d), inVersion) case int32: - c = compareFloatVersions(inVersion, float32(d)) + c = compareFloatWithVersion(float64(d), inVersion) case int64: - c = compareFloatVersions(inVersion, float32(d)) + c = compareFloatWithVersion(float64(d), inVersion) + case Version: + if d.Major == inVersion.Major && d.Minor == inVersion.Minor && d.PatchLevel == inVersion.PatchLevel { + return strings.Compare(inVersion.Suffix, d.Suffix) + } + if d.Major > inVersion.Major { + return 1 + } else if d.Major < inVersion.Major { + return -1 + } + if d.Minor > inVersion.Minor { + return 1 + } else if d.Minor < inVersion.Minor { + return -1 + } + if d.PatchLevel > inVersion.PatchLevel { + return 1 + } else if d.PatchLevel < inVersion.PatchLevel { + return -1 + } default: s, err := cast.ToStringE(in) if err != nil { @@ -191,50 +234,53 @@ func compareVersionsWithSuffix(inVersion float32, inPatchVersion int, suffix str if err != nil { return -1 } + return inVersion.Compare(v) - if v.Number == inVersion && v.PatchLevel == inPatchVersion { - return strings.Compare(suffix, v.Suffix) - } - - if v.Number < inVersion || (v.Number == inVersion && v.PatchLevel < inPatchVersion) { - return -1 - } - - return 1 - } - - if c == 0 && suffix != "" { - return 1 } return c } -func parseVersion(s string) (float32, int) { - var ( - v float32 - p int - ) - - if strings.Count(s, ".") == 2 { - li := strings.LastIndex(s, ".") - p = cast.ToInt(s[li+1:]) - s = s[:li] +func parseVersion(s string) (int, int, int) { + var major, minor, patch int + parts := strings.Split(s, ".") + if len(parts) > 0 { + major, _ = strconv.Atoi(parts[0]) + } + if len(parts) > 1 { + minor, _ = strconv.Atoi(parts[1]) + } + if len(parts) > 2 { + patch, _ = strconv.Atoi(parts[2]) } - v = float32(cast.ToFloat64(s)) - - return v, p + return major, minor, patch } -func compareFloatVersions(version float32, v float32) int { - if v == version { +// compareFloatWithVersion compares v1 with v2. +// It returns -1 if v1 is less than v2, 0 if v1 is equal to v2 and 1 if v1 is greater than v2. +func compareFloatWithVersion(v1 float64, v2 Version) int { + mf, minf := math.Modf(v1) + v1maj := int(mf) + v1min := int(minf * 100) + + if v2.Major == v1maj && v2.Minor == v1min { return 0 } - if v < version { + + if v1maj > v2.Major { + return 1 + } + + if v1maj < v2.Major { return -1 } - return 1 + + if v1min > v2.Minor { + return 1 + } + + return -1 } func GoMinorVersion() int { @@ -245,7 +291,15 @@ func goMinorVersion(version string) int { if strings.HasPrefix(version, "devel") { return 9999 // magic } - i, _ := strconv.Atoi(strings.Split(version, ".")[1]) - return i - + var major, minor int + var trailing string + n, err := fmt.Sscanf(version, "go%d.%d%s", &major, &minor, &trailing) + if n == 2 && err == io.EOF { + // Means there were no trailing characters (i.e., not an alpha/beta) + err = nil + } + if err != nil { + return 0 + } + return minor } diff --git a/common/hugo/version_current.go b/common/hugo/version_current.go index f1d1c7b97..ba367ceb5 100644 --- a/common/hugo/version_current.go +++ b/common/hugo/version_current.go @@ -16,7 +16,8 @@ package hugo // CurrentVersion represents the current build version. // This should be the only one. var CurrentVersion = Version{ - Number: 0.64, + Major: 0, + Minor: 148, PatchLevel: 0, - Suffix: "", + Suffix: "-DEV", } diff --git a/common/hugo/version_test.go b/common/hugo/version_test.go index e0cd0e6e8..33e50ebf5 100644 --- a/common/hugo/version_test.go +++ b/common/hugo/version_test.go @@ -22,10 +22,10 @@ import ( func TestHugoVersion(t *testing.T) { c := qt.New(t) - c.Assert(version(0.15, 0, "-DEV"), qt.Equals, "0.15-DEV") - c.Assert(version(0.15, 2, "-DEV"), qt.Equals, "0.15.2-DEV") + c.Assert(version(0, 15, 0, "-DEV"), qt.Equals, "0.15-DEV") + c.Assert(version(0, 15, 2, "-DEV"), qt.Equals, "0.15.2-DEV") - v := Version{Number: 0.21, PatchLevel: 0, Suffix: "-DEV"} + v := Version{Minor: 21, Suffix: "-DEV"} c.Assert(v.ReleaseVersion().String(), qt.Equals, "0.21") c.Assert(v.String(), qt.Equals, "0.21-DEV") @@ -39,37 +39,36 @@ func TestHugoVersion(t *testing.T) { // We started to use full semver versions even for main // releases in v0.54.0 - v = Version{Number: 0.53, PatchLevel: 0} + v = Version{Minor: 53, PatchLevel: 0} c.Assert(v.String(), qt.Equals, "0.53") c.Assert(v.Next().String(), qt.Equals, "0.54.0") c.Assert(v.Next().Next().String(), qt.Equals, "0.55.0") - v = Version{Number: 0.54, PatchLevel: 0, Suffix: "-DEV"} + v = Version{Minor: 54, PatchLevel: 0, Suffix: "-DEV"} c.Assert(v.String(), qt.Equals, "0.54.0-DEV") } func TestCompareVersions(t *testing.T) { c := qt.New(t) - c.Assert(compareVersions(0.20, 0, 0.20), qt.Equals, 0) - c.Assert(compareVersions(0.20, 0, float32(0.20)), qt.Equals, 0) - c.Assert(compareVersions(0.20, 0, float64(0.20)), qt.Equals, 0) - c.Assert(compareVersions(0.19, 1, 0.20), qt.Equals, 1) - c.Assert(compareVersions(0.19, 3, "0.20.2"), qt.Equals, 1) - c.Assert(compareVersions(0.19, 1, 0.01), qt.Equals, -1) - c.Assert(compareVersions(0, 1, 3), qt.Equals, 1) - c.Assert(compareVersions(0, 1, int32(3)), qt.Equals, 1) - c.Assert(compareVersions(0, 1, int64(3)), qt.Equals, 1) - c.Assert(compareVersions(0.20, 0, "0.20"), qt.Equals, 0) - c.Assert(compareVersions(0.20, 1, "0.20.1"), qt.Equals, 0) - c.Assert(compareVersions(0.20, 1, "0.20"), qt.Equals, -1) - c.Assert(compareVersions(0.20, 0, "0.20.1"), qt.Equals, 1) - c.Assert(compareVersions(0.20, 1, "0.20.2"), qt.Equals, 1) - c.Assert(compareVersions(0.21, 1, "0.22.1"), qt.Equals, 1) - c.Assert(compareVersions(0.22, 0, "0.22-DEV"), qt.Equals, -1) - c.Assert(compareVersions(0.22, 0, "0.22.1-DEV"), qt.Equals, 1) - c.Assert(compareVersionsWithSuffix(0.22, 0, "-DEV", "0.22"), qt.Equals, 1) - c.Assert(compareVersionsWithSuffix(0.22, 1, "-DEV", "0.22"), qt.Equals, -1) - c.Assert(compareVersionsWithSuffix(0.22, 1, "-DEV", "0.22.1-DEV"), qt.Equals, 0) + c.Assert(compareVersions(MustParseVersion("0.20.0"), 0.20), qt.Equals, 0) + c.Assert(compareVersions(MustParseVersion("0.20.0"), float32(0.20)), qt.Equals, 0) + c.Assert(compareVersions(MustParseVersion("0.20.0"), float64(0.20)), qt.Equals, 0) + c.Assert(compareVersions(MustParseVersion("0.19.1"), 0.20), qt.Equals, 1) + c.Assert(compareVersions(MustParseVersion("0.19.3"), "0.20.2"), qt.Equals, 1) + c.Assert(compareVersions(MustParseVersion("0.1"), 3), qt.Equals, 1) + c.Assert(compareVersions(MustParseVersion("0.1"), int32(3)), qt.Equals, 1) + c.Assert(compareVersions(MustParseVersion("0.1"), int64(3)), qt.Equals, 1) + c.Assert(compareVersions(MustParseVersion("0.20"), "0.20"), qt.Equals, 0) + c.Assert(compareVersions(MustParseVersion("0.20.1"), "0.20.1"), qt.Equals, 0) + c.Assert(compareVersions(MustParseVersion("0.20.1"), "0.20"), qt.Equals, -1) + c.Assert(compareVersions(MustParseVersion("0.20.0"), "0.20.1"), qt.Equals, 1) + c.Assert(compareVersions(MustParseVersion("0.20.1"), "0.20.2"), qt.Equals, 1) + c.Assert(compareVersions(MustParseVersion("0.21.1"), "0.22.1"), qt.Equals, 1) + c.Assert(compareVersions(MustParseVersion("0.22.0"), "0.22-DEV"), qt.Equals, -1) + c.Assert(compareVersions(MustParseVersion("0.22.0"), "0.22.1-DEV"), qt.Equals, 1) + c.Assert(compareVersions(MustParseVersion("0.22.0-DEV"), "0.22"), qt.Equals, 1) + c.Assert(compareVersions(MustParseVersion("0.22.1-DEV"), "0.22"), qt.Equals, -1) + c.Assert(compareVersions(MustParseVersion("0.22.1-DEV"), "0.22.1-DEV"), qt.Equals, 0) } func TestParseHugoVersion(t *testing.T) { @@ -84,5 +83,6 @@ func TestParseHugoVersion(t *testing.T) { func TestGoMinorVersion(t *testing.T) { c := qt.New(t) c.Assert(goMinorVersion("go1.12.5"), qt.Equals, 12) + c.Assert(goMinorVersion("go1.14rc1"), qt.Equals, 14) c.Assert(GoMinorVersion() >= 11, qt.Equals, true) } diff --git a/common/loggers/handlerdefault.go b/common/loggers/handlerdefault.go new file mode 100644 index 000000000..bc3c7eec2 --- /dev/null +++ b/common/loggers/handlerdefault.go @@ -0,0 +1,106 @@ +// Copyright 2024 The Hugo Authors. All rights reserved. +// Some functions in this file (see comments) is based on the Go source code, +// copyright The Go Authors and governed by a BSD-style license. +// +// 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 loggers contains some basic logging setup. +package loggers + +import ( + "fmt" + "io" + "strings" + "sync" + + "github.com/bep/logg" + + "github.com/fatih/color" +) + +// levelColor mapping. +var levelColor = [...]*color.Color{ + logg.LevelTrace: color.New(color.FgWhite), + logg.LevelDebug: color.New(color.FgWhite), + logg.LevelInfo: color.New(color.FgBlue), + logg.LevelWarn: color.New(color.FgYellow), + logg.LevelError: color.New(color.FgRed), +} + +// levelString mapping. +var levelString = [...]string{ + logg.LevelTrace: "TRACE", + logg.LevelDebug: "DEBUG", + logg.LevelInfo: "INFO ", + logg.LevelWarn: "WARN ", + logg.LevelError: "ERROR", +} + +// newDefaultHandler handler. +func newDefaultHandler(outWriter, errWriter io.Writer) logg.Handler { + return &defaultHandler{ + outWriter: outWriter, + errWriter: errWriter, + Padding: 0, + } +} + +// Default Handler implementation. +// Based on https://github.com/apex/log/blob/master/handlers/cli/cli.go +type defaultHandler struct { + mu sync.Mutex + outWriter io.Writer // Defaults to os.Stdout. + errWriter io.Writer // Defaults to os.Stderr. + + Padding int +} + +// HandleLog implements logg.Handler. +func (h *defaultHandler) HandleLog(e *logg.Entry) error { + color := levelColor[e.Level] + level := levelString[e.Level] + + h.mu.Lock() + defer h.mu.Unlock() + + var w io.Writer + if e.Level > logg.LevelInfo { + w = h.errWriter + } else { + w = h.outWriter + } + + var prefix string + for _, field := range e.Fields { + if field.Name == FieldNameCmd { + prefix = fmt.Sprint(field.Value) + break + } + } + + if prefix != "" { + prefix = prefix + ": " + } + + color.Fprintf(w, "%s %s%s", fmt.Sprintf("%*s", h.Padding+1, level), color.Sprint(prefix), e.Message) + + for _, field := range e.Fields { + if strings.HasPrefix(field.Name, reservedFieldNamePrefix) { + continue + } + fmt.Fprintf(w, " %s %v", color.Sprint(field.Name), field.Value) + } + + fmt.Fprintln(w) + + return nil +} diff --git a/common/loggers/handlersmisc.go b/common/loggers/handlersmisc.go new file mode 100644 index 000000000..2ae6300f7 --- /dev/null +++ b/common/loggers/handlersmisc.go @@ -0,0 +1,145 @@ +// Copyright 2024 The Hugo Authors. All rights reserved. +// Some functions in this file (see comments) is based on the Go source code, +// copyright The Go Authors and governed by a BSD-style license. +// +// 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 loggers + +import ( + "fmt" + "strings" + "sync" + + "github.com/bep/logg" + "github.com/gohugoio/hugo/common/hashing" +) + +// PanicOnWarningHook panics on warnings. +var PanicOnWarningHook = func(e *logg.Entry) error { + if e.Level != logg.LevelWarn { + return nil + } + panic(e.Message) +} + +func newLogLevelCounter() *logLevelCounter { + return &logLevelCounter{ + counters: make(map[logg.Level]int), + } +} + +func newLogOnceHandler(threshold logg.Level) *logOnceHandler { + return &logOnceHandler{ + threshold: threshold, + seen: make(map[uint64]bool), + } +} + +func newStopHandler(h ...logg.Handler) *stopHandler { + return &stopHandler{ + handlers: h, + } +} + +func newSuppressStatementsHandler(statements map[string]bool) *suppressStatementsHandler { + return &suppressStatementsHandler{ + statements: statements, + } +} + +type logLevelCounter struct { + mu sync.RWMutex + counters map[logg.Level]int +} + +func (h *logLevelCounter) HandleLog(e *logg.Entry) error { + h.mu.Lock() + defer h.mu.Unlock() + h.counters[e.Level]++ + return nil +} + +var errStop = fmt.Errorf("stop") + +type logOnceHandler struct { + threshold logg.Level + mu sync.Mutex + seen map[uint64]bool +} + +func (h *logOnceHandler) HandleLog(e *logg.Entry) error { + if e.Level < h.threshold { + // We typically only want to enable this for warnings and above. + // The common use case is that many go routines may log the same error. + return nil + } + h.mu.Lock() + defer h.mu.Unlock() + hash := hashing.HashUint64(e.Level, e.Message, e.Fields) + if h.seen[hash] { + return errStop + } + h.seen[hash] = true + return nil +} + +func (h *logOnceHandler) reset() { + h.mu.Lock() + defer h.mu.Unlock() + h.seen = make(map[uint64]bool) +} + +type stopHandler struct { + handlers []logg.Handler +} + +// HandleLog implements logg.Handler. +func (h *stopHandler) HandleLog(e *logg.Entry) error { + for _, handler := range h.handlers { + if err := handler.HandleLog(e); err != nil { + if err == errStop { + return nil + } + return err + } + } + return nil +} + +type suppressStatementsHandler struct { + statements map[string]bool +} + +func (h *suppressStatementsHandler) HandleLog(e *logg.Entry) error { + for _, field := range e.Fields { + if field.Name == FieldNameStatementID { + if h.statements[field.Value.(string)] { + return errStop + } + } + } + return nil +} + +// whiteSpaceTrimmer creates a new log handler that trims whitespace from log messages and string fields. +func whiteSpaceTrimmer() logg.Handler { + return logg.HandlerFunc(func(e *logg.Entry) error { + e.Message = strings.TrimSpace(e.Message) + for i, field := range e.Fields { + if s, ok := field.Value.(string); ok { + e.Fields[i].Value = strings.TrimSpace(s) + } + } + return nil + }) +} diff --git a/common/loggers/handlerterminal.go b/common/loggers/handlerterminal.go new file mode 100644 index 000000000..c6a86d3a2 --- /dev/null +++ b/common/loggers/handlerterminal.go @@ -0,0 +1,100 @@ +// Copyright 2024 The Hugo Authors. All rights reserved. +// Some functions in this file (see comments) is based on the Go source code, +// copyright The Go Authors and governed by a BSD-style license. +// +// 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 loggers + +import ( + "fmt" + "io" + "regexp" + "strings" + "sync" + + "github.com/bep/logg" +) + +// newNoAnsiEscapeHandler creates a new noAnsiEscapeHandler +func newNoAnsiEscapeHandler(outWriter, errWriter io.Writer, noLevelPrefix bool, predicate func(*logg.Entry) bool) *noAnsiEscapeHandler { + if predicate == nil { + predicate = func(e *logg.Entry) bool { return true } + } + return &noAnsiEscapeHandler{ + noLevelPrefix: noLevelPrefix, + outWriter: outWriter, + errWriter: errWriter, + predicate: predicate, + } +} + +type noAnsiEscapeHandler struct { + mu sync.Mutex + outWriter io.Writer + errWriter io.Writer + predicate func(*logg.Entry) bool + noLevelPrefix bool +} + +func (h *noAnsiEscapeHandler) HandleLog(e *logg.Entry) error { + if !h.predicate(e) { + return nil + } + h.mu.Lock() + defer h.mu.Unlock() + + var w io.Writer + if e.Level > logg.LevelInfo { + w = h.errWriter + } else { + w = h.outWriter + } + + var prefix string + for _, field := range e.Fields { + if field.Name == FieldNameCmd { + prefix = fmt.Sprint(field.Value) + break + } + } + + if prefix != "" { + prefix = prefix + ": " + } + + msg := stripANSI(e.Message) + + if h.noLevelPrefix { + fmt.Fprintf(w, "%s%s", prefix, msg) + } else { + fmt.Fprintf(w, "%s %s%s", levelString[e.Level], prefix, msg) + } + + for _, field := range e.Fields { + if strings.HasPrefix(field.Name, reservedFieldNamePrefix) { + continue + } + fmt.Fprintf(w, " %s %v", field.Name, field.Value) + + } + fmt.Fprintln(w) + + return nil +} + +var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +// stripANSI removes ANSI escape codes from s. +func stripANSI(s string) string { + return ansiRe.ReplaceAllString(s, "") +} diff --git a/common/loggers/handlerterminal_test.go b/common/loggers/handlerterminal_test.go new file mode 100644 index 000000000..f45ce80df --- /dev/null +++ b/common/loggers/handlerterminal_test.go @@ -0,0 +1,40 @@ +// Copyright 2024 The Hugo Authors. All rights reserved. +// Some functions in this file (see comments) is based on the Go source code, +// copyright The Go Authors and governed by a BSD-style license. +// +// 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 loggers + +import ( + "bytes" + "testing" + + "github.com/bep/logg" + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/common/terminal" +) + +func TestNoAnsiEscapeHandler(t *testing.T) { + c := qt.New(t) + + test := func(s string) { + c.Assert(stripANSI(terminal.Notice(s)), qt.Equals, s) + } + test(`error in "file.md:1:2"`) + + var buf bytes.Buffer + h := newNoAnsiEscapeHandler(&buf, &buf, false, nil) + h.HandleLog(&logg.Entry{Message: terminal.Notice(`error in "file.md:1:2"`), Level: logg.LevelInfo}) + + c.Assert(buf.String(), qt.Equals, "INFO error in \"file.md:1:2\"\n") +} diff --git a/common/loggers/logger.go b/common/loggers/logger.go new file mode 100644 index 000000000..a013049f7 --- /dev/null +++ b/common/loggers/logger.go @@ -0,0 +1,385 @@ +// Copyright 2024 The Hugo Authors. All rights reserved. +// Some functions in this file (see comments) is based on the Go source code, +// copyright The Go Authors and governed by a BSD-style license. +// +// 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 loggers + +import ( + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/bep/logg" + "github.com/bep/logg/handlers/multi" + "github.com/gohugoio/hugo/common/terminal" +) + +var ( + reservedFieldNamePrefix = "__h_field_" + // FieldNameCmd is the name of the field that holds the command name. + FieldNameCmd = reservedFieldNamePrefix + "_cmd" + // Used to suppress statements. + FieldNameStatementID = reservedFieldNamePrefix + "__h_field_statement_id" +) + +// Options defines options for the logger. +type Options struct { + Level logg.Level + StdOut io.Writer + StdErr io.Writer + DistinctLevel logg.Level + StoreErrors bool + HandlerPost func(e *logg.Entry) error + SuppressStatements map[string]bool +} + +// New creates a new logger with the given options. +func New(opts Options) Logger { + if opts.StdOut == nil { + opts.StdOut = os.Stdout + } + if opts.StdErr == nil { + opts.StdErr = os.Stderr + } + + if opts.Level == 0 { + opts.Level = logg.LevelWarn + } + + var logHandler logg.Handler + if terminal.PrintANSIColors(os.Stderr) { + logHandler = newDefaultHandler(opts.StdErr, opts.StdErr) + } else { + logHandler = newNoAnsiEscapeHandler(opts.StdErr, opts.StdErr, false, nil) + } + + errorsw := &strings.Builder{} + logCounters := newLogLevelCounter() + handlers := []logg.Handler{ + logCounters, + } + + if opts.Level == logg.LevelTrace { + // Trace is used during development only, and it's useful to + // only see the trace messages. + handlers = append(handlers, + logg.HandlerFunc(func(e *logg.Entry) error { + if e.Level != logg.LevelTrace { + return logg.ErrStopLogEntry + } + return nil + }), + ) + } + + handlers = append(handlers, whiteSpaceTrimmer(), logHandler) + + if opts.HandlerPost != nil { + var hookHandler logg.HandlerFunc = func(e *logg.Entry) error { + opts.HandlerPost(e) + return nil + } + handlers = append(handlers, hookHandler) + } + + if opts.StoreErrors { + h := newNoAnsiEscapeHandler(io.Discard, errorsw, true, func(e *logg.Entry) bool { + return e.Level >= logg.LevelError + }) + + handlers = append(handlers, h) + } + + logHandler = multi.New(handlers...) + + var logOnce *logOnceHandler + if opts.DistinctLevel != 0 { + logOnce = newLogOnceHandler(opts.DistinctLevel) + logHandler = newStopHandler(logOnce, logHandler) + } + + if len(opts.SuppressStatements) > 0 { + logHandler = newStopHandler(newSuppressStatementsHandler(opts.SuppressStatements), logHandler) + } + + logger := logg.New( + logg.Options{ + Level: opts.Level, + Handler: logHandler, + }, + ) + + l := logger.WithLevel(opts.Level) + + reset := func() { + logCounters.mu.Lock() + defer logCounters.mu.Unlock() + logCounters.counters = make(map[logg.Level]int) + errorsw.Reset() + if logOnce != nil { + logOnce.reset() + } + } + + return &logAdapter{ + logCounters: logCounters, + errors: errorsw, + reset: reset, + stdOut: opts.StdOut, + stdErr: opts.StdErr, + level: opts.Level, + logger: logger, + tracel: l.WithLevel(logg.LevelTrace), + debugl: l.WithLevel(logg.LevelDebug), + infol: l.WithLevel(logg.LevelInfo), + warnl: l.WithLevel(logg.LevelWarn), + errorl: l.WithLevel(logg.LevelError), + } +} + +// NewDefault creates a new logger with the default options. +func NewDefault() Logger { + opts := Options{ + DistinctLevel: logg.LevelWarn, + Level: logg.LevelWarn, + } + return New(opts) +} + +func NewTrace() Logger { + opts := Options{ + DistinctLevel: logg.LevelWarn, + Level: logg.LevelTrace, + } + return New(opts) +} + +func LevelLoggerToWriter(l logg.LevelLogger) io.Writer { + return logWriter{l: l} +} + +type Logger interface { + Debug() logg.LevelLogger + Debugf(format string, v ...any) + Debugln(v ...any) + Error() logg.LevelLogger + Errorf(format string, v ...any) + Erroridf(id, format string, v ...any) + Errorln(v ...any) + Errors() string + Info() logg.LevelLogger + InfoCommand(command string) logg.LevelLogger + Infof(format string, v ...any) + Infoln(v ...any) + Level() logg.Level + LoggCount(logg.Level) int + Logger() logg.Logger + StdOut() io.Writer + StdErr() io.Writer + Printf(format string, v ...any) + Println(v ...any) + PrintTimerIfDelayed(start time.Time, name string) + Reset() + Warn() logg.LevelLogger + WarnCommand(command string) logg.LevelLogger + Warnf(format string, v ...any) + Warnidf(id, format string, v ...any) + Warnln(v ...any) + Deprecatef(fail bool, format string, v ...any) + Trace(s logg.StringFunc) +} + +type logAdapter struct { + logCounters *logLevelCounter + errors *strings.Builder + reset func() + stdOut io.Writer + stdErr io.Writer + level logg.Level + logger logg.Logger + tracel logg.LevelLogger + debugl logg.LevelLogger + infol logg.LevelLogger + warnl logg.LevelLogger + errorl logg.LevelLogger +} + +func (l *logAdapter) Debug() logg.LevelLogger { + return l.debugl +} + +func (l *logAdapter) Debugf(format string, v ...any) { + l.debugl.Logf(format, v...) +} + +func (l *logAdapter) Debugln(v ...any) { + l.debugl.Logf(l.sprint(v...)) +} + +func (l *logAdapter) Info() logg.LevelLogger { + return l.infol +} + +func (l *logAdapter) InfoCommand(command string) logg.LevelLogger { + return l.infol.WithField(FieldNameCmd, command) +} + +func (l *logAdapter) Infof(format string, v ...any) { + l.infol.Logf(format, v...) +} + +func (l *logAdapter) Infoln(v ...any) { + l.infol.Logf(l.sprint(v...)) +} + +func (l *logAdapter) Level() logg.Level { + return l.level +} + +func (l *logAdapter) LoggCount(level logg.Level) int { + l.logCounters.mu.RLock() + defer l.logCounters.mu.RUnlock() + return l.logCounters.counters[level] +} + +func (l *logAdapter) Logger() logg.Logger { + return l.logger +} + +func (l *logAdapter) StdOut() io.Writer { + return l.stdOut +} + +func (l *logAdapter) StdErr() io.Writer { + return l.stdErr +} + +// PrintTimerIfDelayed prints a time statement to the FEEDBACK logger +// if considerable time is spent. +func (l *logAdapter) PrintTimerIfDelayed(start time.Time, name string) { + elapsed := time.Since(start) + milli := int(1000 * elapsed.Seconds()) + if milli < 500 { + return + } + fmt.Fprintf(l.stdErr, "%s in %v ms", name, milli) +} + +func (l *logAdapter) Printf(format string, v ...any) { + // Add trailing newline if not present. + if !strings.HasSuffix(format, "\n") { + format += "\n" + } + fmt.Fprintf(l.stdOut, format, v...) +} + +func (l *logAdapter) Println(v ...any) { + fmt.Fprintln(l.stdOut, v...) +} + +func (l *logAdapter) Reset() { + l.reset() +} + +func (l *logAdapter) Warn() logg.LevelLogger { + return l.warnl +} + +func (l *logAdapter) Warnf(format string, v ...any) { + l.warnl.Logf(format, v...) +} + +func (l *logAdapter) WarnCommand(command string) logg.LevelLogger { + return l.warnl.WithField(FieldNameCmd, command) +} + +func (l *logAdapter) Warnln(v ...any) { + l.warnl.Logf(l.sprint(v...)) +} + +func (l *logAdapter) Error() logg.LevelLogger { + return l.errorl +} + +func (l *logAdapter) Errorf(format string, v ...any) { + l.errorl.Logf(format, v...) +} + +func (l *logAdapter) Errorln(v ...any) { + l.errorl.Logf(l.sprint(v...)) +} + +func (l *logAdapter) Errors() string { + return l.errors.String() +} + +func (l *logAdapter) Erroridf(id, format string, v ...any) { + id = strings.ToLower(id) + format += l.idfInfoStatement("error", id, format) + l.errorl.WithField(FieldNameStatementID, id).Logf(format, v...) +} + +func (l *logAdapter) Warnidf(id, format string, v ...any) { + id = strings.ToLower(id) + format += l.idfInfoStatement("warning", id, format) + l.warnl.WithField(FieldNameStatementID, id).Logf(format, v...) +} + +func (l *logAdapter) idfInfoStatement(what, id, format string) string { + return fmt.Sprintf("\nYou can suppress this %s by adding the following to your site configuration:\nignoreLogs = ['%s']", what, id) +} + +func (l *logAdapter) Trace(s logg.StringFunc) { + l.tracel.Log(s) +} + +func (l *logAdapter) sprint(v ...any) string { + return strings.TrimRight(fmt.Sprintln(v...), "\n") +} + +func (l *logAdapter) Deprecatef(fail bool, format string, v ...any) { + format = "DEPRECATED: " + format + if fail { + l.errorl.Logf(format, v...) + } else { + l.warnl.Logf(format, v...) + } +} + +type logWriter struct { + l logg.LevelLogger +} + +func (w logWriter) Write(p []byte) (n int, err error) { + w.l.Log(logg.String(string(p))) + return len(p), nil +} + +func TimeTrackf(l logg.LevelLogger, start time.Time, fields logg.Fields, format string, a ...any) { + elapsed := time.Since(start) + if fields != nil { + l = l.WithFields(fields) + } + l.WithField("duration", elapsed).Logf(format, a...) +} + +func TimeTrackfn(fn func() (logg.LevelLogger, error)) error { + start := time.Now() + l, err := fn() + elapsed := time.Since(start) + l.WithField("duration", elapsed).Logf("") + return err +} diff --git a/common/loggers/logger_test.go b/common/loggers/logger_test.go new file mode 100644 index 000000000..bc8975b06 --- /dev/null +++ b/common/loggers/logger_test.go @@ -0,0 +1,154 @@ +// Copyright 2024 The Hugo Authors. All rights reserved. +// Some functions in this file (see comments) is based on the Go source code, +// copyright The Go Authors and governed by a BSD-style license. +// +// 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 loggers_test + +import ( + "io" + "strings" + "testing" + + "github.com/bep/logg" + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/common/loggers" +) + +func TestLogDistinct(t *testing.T) { + c := qt.New(t) + + opts := loggers.Options{ + DistinctLevel: logg.LevelWarn, + StoreErrors: true, + StdOut: io.Discard, + StdErr: io.Discard, + } + + l := loggers.New(opts) + + for range 10 { + l.Errorln("error 1") + l.Errorln("error 2") + l.Warnln("warn 1") + } + c.Assert(strings.Count(l.Errors(), "error 1"), qt.Equals, 1) + c.Assert(l.LoggCount(logg.LevelError), qt.Equals, 2) + c.Assert(l.LoggCount(logg.LevelWarn), qt.Equals, 1) +} + +func TestHookLast(t *testing.T) { + c := qt.New(t) + + opts := loggers.Options{ + HandlerPost: func(e *logg.Entry) error { + panic(e.Message) + }, + StdOut: io.Discard, + StdErr: io.Discard, + } + + l := loggers.New(opts) + + c.Assert(func() { l.Warnln("warn 1") }, qt.PanicMatches, "warn 1") +} + +func TestOptionStoreErrors(t *testing.T) { + c := qt.New(t) + + var sb strings.Builder + + opts := loggers.Options{ + StoreErrors: true, + StdErr: &sb, + StdOut: &sb, + } + + l := loggers.New(opts) + l.Errorln("error 1") + l.Errorln("error 2") + + errorsStr := l.Errors() + + c.Assert(errorsStr, qt.Contains, "error 1") + c.Assert(errorsStr, qt.Not(qt.Contains), "ERROR") + + c.Assert(sb.String(), qt.Contains, "error 1") + c.Assert(sb.String(), qt.Contains, "ERROR") +} + +func TestLogCount(t *testing.T) { + c := qt.New(t) + + opts := loggers.Options{ + StoreErrors: true, + } + + l := loggers.New(opts) + l.Errorln("error 1") + l.Errorln("error 2") + l.Warnln("warn 1") + + c.Assert(l.LoggCount(logg.LevelError), qt.Equals, 2) + c.Assert(l.LoggCount(logg.LevelWarn), qt.Equals, 1) + c.Assert(l.LoggCount(logg.LevelInfo), qt.Equals, 0) +} + +func TestSuppressStatements(t *testing.T) { + c := qt.New(t) + + opts := loggers.Options{ + StoreErrors: true, + SuppressStatements: map[string]bool{ + "error-1": true, + }, + } + + l := loggers.New(opts) + l.Error().WithField(loggers.FieldNameStatementID, "error-1").Logf("error 1") + l.Errorln("error 2") + + errorsStr := l.Errors() + + c.Assert(errorsStr, qt.Not(qt.Contains), "error 1") + c.Assert(errorsStr, qt.Contains, "error 2") + c.Assert(l.LoggCount(logg.LevelError), qt.Equals, 1) +} + +func TestReset(t *testing.T) { + c := qt.New(t) + + opts := loggers.Options{ + StoreErrors: true, + DistinctLevel: logg.LevelWarn, + StdOut: io.Discard, + StdErr: io.Discard, + } + + l := loggers.New(opts) + + for range 3 { + l.Errorln("error 1") + l.Errorln("error 2") + l.Errorln("error 1") + c.Assert(l.LoggCount(logg.LevelError), qt.Equals, 2) + + l.Reset() + + errorsStr := l.Errors() + + c.Assert(errorsStr, qt.Equals, "") + c.Assert(l.LoggCount(logg.LevelError), qt.Equals, 0) + + } +} diff --git a/common/loggers/loggerglobal.go b/common/loggers/loggerglobal.go new file mode 100644 index 000000000..b8c9a6931 --- /dev/null +++ b/common/loggers/loggerglobal.go @@ -0,0 +1,62 @@ +// Copyright 2024 The Hugo Authors. All rights reserved. +// Some functions in this file (see comments) is based on the Go source code, +// copyright The Go Authors and governed by a BSD-style license. +// +// 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 loggers + +import ( + "sync" + + "github.com/bep/logg" +) + +// SetGlobalLogger sets the global logger. +// This is used in a few places in Hugo, e.g. deprecated functions. +func SetGlobalLogger(logger Logger) { + logMu.Lock() + defer logMu.Unlock() + log = logger +} + +func initGlobalLogger(level logg.Level, panicOnWarnings bool) { + logMu.Lock() + defer logMu.Unlock() + var logHookLast func(e *logg.Entry) error + if panicOnWarnings { + logHookLast = PanicOnWarningHook + } + + log = New( + Options{ + Level: level, + DistinctLevel: logg.LevelInfo, + HandlerPost: logHookLast, + }, + ) +} + +var logMu sync.Mutex + +func Log() Logger { + logMu.Lock() + defer logMu.Unlock() + return log +} + +// The global logger. +var log Logger + +func init() { + initGlobalLogger(logg.LevelWarn, false) +} diff --git a/common/loggers/loggers.go b/common/loggers/loggers.go deleted file mode 100644 index 2b2ddb4d1..000000000 --- a/common/loggers/loggers.go +++ /dev/null @@ -1,221 +0,0 @@ -// Copyright 2018 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 loggers - -import ( - "bytes" - "fmt" - "io" - "io/ioutil" - "log" - "os" - "regexp" - "runtime" - "time" - - "github.com/gohugoio/hugo/common/terminal" - - jww "github.com/spf13/jwalterweatherman" -) - -var ( - // Counts ERROR logs to the global jww logger. - GlobalErrorCounter *jww.Counter -) - -func init() { - GlobalErrorCounter = &jww.Counter{} - jww.SetLogListeners(jww.LogCounter(GlobalErrorCounter, jww.LevelError)) -} - -// Logger wraps a *loggers.Logger and some other related logging state. -type Logger struct { - *jww.Notepad - - // The writer that represents stdout. - // Will be ioutil.Discard when in quiet mode. - Out io.Writer - - ErrorCounter *jww.Counter - WarnCounter *jww.Counter - - // This is only set in server mode. - errors *bytes.Buffer -} - -// PrintTimerIfDelayed prints a time statement to the FEEDBACK logger -// if considerable time is spent. -func (l *Logger) PrintTimerIfDelayed(start time.Time, name string) { - elapsed := time.Since(start) - milli := int(1000 * elapsed.Seconds()) - if milli < 500 { - return - } - l.FEEDBACK.Printf("%s in %v ms", name, milli) -} - -func (l *Logger) PrintTimer(start time.Time, name string) { - elapsed := time.Since(start) - milli := int(1000 * elapsed.Seconds()) - l.FEEDBACK.Printf("%s in %v ms", name, milli) -} - -func (l *Logger) Errors() string { - if l.errors == nil { - return "" - } - return ansiColorRe.ReplaceAllString(l.errors.String(), "") -} - -// Reset resets the logger's internal state. -func (l *Logger) Reset() { - l.ErrorCounter.Reset() - if l.errors != nil { - l.errors.Reset() - } -} - -// NewLogger creates a new Logger for the given thresholds -func NewLogger(stdoutThreshold, logThreshold jww.Threshold, outHandle, logHandle io.Writer, saveErrors bool) *Logger { - return newLogger(stdoutThreshold, logThreshold, outHandle, logHandle, saveErrors) -} - -// NewDebugLogger is a convenience function to create a debug logger. -func NewDebugLogger() *Logger { - return newBasicLogger(jww.LevelDebug) -} - -// NewWarningLogger is a convenience function to create a warning logger. -func NewWarningLogger() *Logger { - return newBasicLogger(jww.LevelWarn) -} - -// NewErrorLogger is a convenience function to create an error logger. -func NewErrorLogger() *Logger { - return newBasicLogger(jww.LevelError) -} - -var ( - ansiColorRe = regexp.MustCompile("(?s)\\033\\[\\d*(;\\d*)*m") - errorRe = regexp.MustCompile("^(ERROR|FATAL|WARN)") -) - -type ansiCleaner struct { - w io.Writer -} - -func (a ansiCleaner) Write(p []byte) (n int, err error) { - return a.w.Write(ansiColorRe.ReplaceAll(p, []byte(""))) -} - -type labelColorizer struct { - w io.Writer -} - -func (a labelColorizer) Write(p []byte) (n int, err error) { - replaced := errorRe.ReplaceAllStringFunc(string(p), func(m string) string { - switch m { - case "ERROR", "FATAL": - return terminal.Error(m) - case "WARN": - return terminal.Warning(m) - default: - return m - } - }) - // io.MultiWriter will abort if we return a bigger write count than input - // bytes, so we lie a little. - _, err = a.w.Write([]byte(replaced)) - return len(p), err - -} - -// InitGlobalLogger initializes the global logger, used in some rare cases. -func InitGlobalLogger(stdoutThreshold, logThreshold jww.Threshold, outHandle, logHandle io.Writer) { - outHandle, logHandle = getLogWriters(outHandle, logHandle) - - jww.SetStdoutOutput(outHandle) - jww.SetLogOutput(logHandle) - jww.SetLogThreshold(logThreshold) - jww.SetStdoutThreshold(stdoutThreshold) - -} - -func getLogWriters(outHandle, logHandle io.Writer) (io.Writer, io.Writer) { - isTerm := terminal.IsTerminal(os.Stdout) - if logHandle != ioutil.Discard && isTerm { - // Remove any Ansi coloring from log output - logHandle = ansiCleaner{w: logHandle} - } - - if isTerm { - outHandle = labelColorizer{w: outHandle} - } - - return outHandle, logHandle - -} - -type fatalLogWriter int - -func (s fatalLogWriter) Write(p []byte) (n int, err error) { - trace := make([]byte, 1500) - runtime.Stack(trace, true) - fmt.Printf("\n===========\n\n%s\n", trace) - os.Exit(-1) - - return 0, nil -} - -var fatalLogListener = func(t jww.Threshold) io.Writer { - if t != jww.LevelError { - // Only interested in ERROR - return nil - } - - return new(fatalLogWriter) -} - -func newLogger(stdoutThreshold, logThreshold jww.Threshold, outHandle, logHandle io.Writer, saveErrors bool) *Logger { - errorCounter := &jww.Counter{} - warnCounter := &jww.Counter{} - outHandle, logHandle = getLogWriters(outHandle, logHandle) - - listeners := []jww.LogListener{jww.LogCounter(errorCounter, jww.LevelError), jww.LogCounter(warnCounter, jww.LevelWarn)} - var errorBuff *bytes.Buffer - if saveErrors { - errorBuff = new(bytes.Buffer) - errorCapture := func(t jww.Threshold) io.Writer { - if t != jww.LevelError { - // Only interested in ERROR - return nil - } - return errorBuff - } - - listeners = append(listeners, errorCapture) - } - - return &Logger{ - Notepad: jww.NewNotepad(stdoutThreshold, logThreshold, outHandle, logHandle, "", log.Ldate|log.Ltime, listeners...), - Out: outHandle, - ErrorCounter: errorCounter, - WarnCounter: warnCounter, - errors: errorBuff, - } -} - -func newBasicLogger(t jww.Threshold) *Logger { - return newLogger(t, jww.LevelError, os.Stdout, ioutil.Discard, false) -} diff --git a/common/loggers/loggers_test.go b/common/loggers/loggers_test.go deleted file mode 100644 index f572ba170..000000000 --- a/common/loggers/loggers_test.go +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright 2018 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 loggers - -import ( - "testing" - - qt "github.com/frankban/quicktest" -) - -func TestLogger(t *testing.T) { - c := qt.New(t) - l := NewWarningLogger() - - l.ERROR.Println("One error") - l.ERROR.Println("Two error") - l.WARN.Println("A warning") - - c.Assert(l.ErrorCounter.Count(), qt.Equals, uint64(2)) - -} diff --git a/common/maps/cache.go b/common/maps/cache.go new file mode 100644 index 000000000..de1535994 --- /dev/null +++ b/common/maps/cache.go @@ -0,0 +1,195 @@ +// Copyright 2024 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 maps + +import ( + "sync" +) + +// Cache is a simple thread safe cache backed by a map. +type Cache[K comparable, T any] struct { + m map[K]T + hasBeenInitialized bool + sync.RWMutex +} + +// NewCache creates a new Cache. +func NewCache[K comparable, T any]() *Cache[K, T] { + return &Cache[K, T]{m: make(map[K]T)} +} + +// Delete deletes the given key from the cache. +// If c is nil, this method is a no-op. +func (c *Cache[K, T]) Get(key K) (T, bool) { + if c == nil { + var zero T + return zero, false + } + c.RLock() + v, found := c.get(key) + c.RUnlock() + return v, found +} + +func (c *Cache[K, T]) get(key K) (T, bool) { + v, found := c.m[key] + return v, found +} + +// GetOrCreate gets the value for the given key if it exists, or creates it if not. +func (c *Cache[K, T]) GetOrCreate(key K, create func() (T, error)) (T, error) { + c.RLock() + v, found := c.m[key] + c.RUnlock() + if found { + return v, nil + } + c.Lock() + defer c.Unlock() + v, found = c.m[key] + if found { + return v, nil + } + v, err := create() + if err != nil { + return v, err + } + c.m[key] = v + return v, nil +} + +// Contains returns whether the given key exists in the cache. +func (c *Cache[K, T]) Contains(key K) bool { + c.RLock() + _, found := c.m[key] + c.RUnlock() + return found +} + +// InitAndGet initializes the cache if not already done and returns the value for the given key. +// The init state will be reset on Reset or Drain. +func (c *Cache[K, T]) InitAndGet(key K, init func(get func(key K) (T, bool), set func(key K, value T)) error) (T, error) { + var v T + c.RLock() + if !c.hasBeenInitialized { + c.RUnlock() + if err := func() error { + c.Lock() + defer c.Unlock() + // Double check in case another goroutine has initialized it in the meantime. + if !c.hasBeenInitialized { + err := init(c.get, c.set) + if err != nil { + return err + } + c.hasBeenInitialized = true + } + return nil + }(); err != nil { + return v, err + } + // Reacquire the read lock. + c.RLock() + } + + v = c.m[key] + c.RUnlock() + + return v, nil +} + +// Set sets the given key to the given value. +func (c *Cache[K, T]) Set(key K, value T) { + c.Lock() + c.set(key, value) + c.Unlock() +} + +// SetIfAbsent sets the given key to the given value if the key does not already exist in the cache. +func (c *Cache[K, T]) SetIfAbsent(key K, value T) { + c.RLock() + if _, found := c.get(key); !found { + c.RUnlock() + c.Set(key, value) + } else { + c.RUnlock() + } +} + +func (c *Cache[K, T]) set(key K, value T) { + c.m[key] = value +} + +// ForEeach calls the given function for each key/value pair in the cache. +// If the function returns false, the iteration stops. +func (c *Cache[K, T]) ForEeach(f func(K, T) bool) { + c.RLock() + defer c.RUnlock() + for k, v := range c.m { + if !f(k, v) { + return + } + } +} + +func (c *Cache[K, T]) Drain() map[K]T { + c.Lock() + m := c.m + c.m = make(map[K]T) + c.hasBeenInitialized = false + c.Unlock() + return m +} + +func (c *Cache[K, T]) Len() int { + c.RLock() + defer c.RUnlock() + return len(c.m) +} + +func (c *Cache[K, T]) Reset() { + c.Lock() + clear(c.m) + c.hasBeenInitialized = false + c.Unlock() +} + +// SliceCache is a simple thread safe cache backed by a map. +type SliceCache[T any] struct { + m map[string][]T + sync.RWMutex +} + +func NewSliceCache[T any]() *SliceCache[T] { + return &SliceCache[T]{m: make(map[string][]T)} +} + +func (c *SliceCache[T]) Get(key string) ([]T, bool) { + c.RLock() + v, found := c.m[key] + c.RUnlock() + return v, found +} + +func (c *SliceCache[T]) Append(key string, values ...T) { + c.Lock() + c.m[key] = append(c.m[key], values...) + c.Unlock() +} + +func (c *SliceCache[T]) Reset() { + c.Lock() + c.m = make(map[string][]T) + c.Unlock() +} diff --git a/common/maps/maps.go b/common/maps/maps.go index 8b42ca764..f9171ebf2 100644 --- a/common/maps/maps.go +++ b/common/maps/maps.go @@ -14,56 +14,134 @@ package maps import ( + "fmt" "strings" - "github.com/gobwas/glob" + "github.com/gohugoio/hugo/common/types" + "github.com/gobwas/glob" "github.com/spf13/cast" ) -// ToLower makes all the keys in the given map lower cased and will do so -// recursively. -// Notes: -// * This will modify the map given. -// * Any nested map[interface{}]interface{} will be converted to Params. -func ToLower(m Params) { - for k, v := range m { - var retyped bool - switch v.(type) { - case map[interface{}]interface{}: - var p Params = cast.ToStringMap(v) - v = p - ToLower(p) - retyped = true - case map[string]interface{}: - var p Params = v.(map[string]interface{}) - v = p - ToLower(p) - retyped = true - } - - lKey := strings.ToLower(k) - if retyped || k != lKey { - delete(m, k) - m[lKey] = v - } - } -} - -func ToStringMapE(in interface{}) (map[string]interface{}, error) { - switch in.(type) { +// ToStringMapE converts in to map[string]interface{}. +func ToStringMapE(in any) (map[string]any, error) { + switch vv := in.(type) { case Params: - return in.(Params), nil + return vv, nil + case map[string]string: + m := map[string]any{} + for k, v := range vv { + m[k] = v + } + return m, nil + default: return cast.ToStringMapE(in) } } -func ToStringMap(in interface{}) map[string]interface{} { +// ToParamsAndPrepare converts in to Params and prepares it for use. +// If in is nil, an empty map is returned. +// See PrepareParams. +func ToParamsAndPrepare(in any) (Params, error) { + if types.IsNil(in) { + return Params{}, nil + } + m, err := ToStringMapE(in) + if err != nil { + return nil, err + } + PrepareParams(m) + return m, nil +} + +// MustToParamsAndPrepare calls ToParamsAndPrepare and panics if it fails. +func MustToParamsAndPrepare(in any) Params { + p, err := ToParamsAndPrepare(in) + if err != nil { + panic(fmt.Sprintf("cannot convert %T to maps.Params: %s", in, err)) + } + return p +} + +// ToStringMap converts in to map[string]interface{}. +func ToStringMap(in any) map[string]any { m, _ := ToStringMapE(in) return m } +// ToStringMapStringE converts in to map[string]string. +func ToStringMapStringE(in any) (map[string]string, error) { + m, err := ToStringMapE(in) + if err != nil { + return nil, err + } + return cast.ToStringMapStringE(m) +} + +// ToStringMapString converts in to map[string]string. +func ToStringMapString(in any) map[string]string { + m, _ := ToStringMapStringE(in) + return m +} + +// ToStringMapBool converts in to bool. +func ToStringMapBool(in any) map[string]bool { + m, _ := ToStringMapE(in) + return cast.ToStringMapBool(m) +} + +// ToSliceStringMap converts in to []map[string]interface{}. +func ToSliceStringMap(in any) ([]map[string]any, error) { + switch v := in.(type) { + case []map[string]any: + return v, nil + case Params: + return []map[string]any{v}, nil + case []any: + var s []map[string]any + for _, entry := range v { + if vv, ok := entry.(map[string]any); ok { + s = append(s, vv) + } + } + return s, nil + default: + return nil, fmt.Errorf("unable to cast %#v of type %T to []map[string]interface{}", in, in) + } +} + +// LookupEqualFold finds key in m with case insensitive equality checks. +func LookupEqualFold[T any | string](m map[string]T, key string) (T, string, bool) { + if v, found := m[key]; found { + return v, key, true + } + for k, v := range m { + if strings.EqualFold(k, key) { + return v, k, true + } + } + var s T + return s, "", false +} + +// MergeShallow merges src into dst, but only if the key does not already exist in dst. +// The keys are compared case insensitively. +func MergeShallow(dst, src map[string]any) { + for k, v := range src { + found := false + for dk := range dst { + if strings.EqualFold(dk, k) { + found = true + break + } + } + if !found { + dst[k] = v + } + } +} + type keyRename struct { pattern glob.Glob newKey string @@ -101,7 +179,7 @@ func (r KeyRenamer) getNewKey(keyPath string) string { // Rename renames the keys in the given map according // to the patterns in the current KeyRenamer. -func (r KeyRenamer) Rename(m map[string]interface{}) { +func (r KeyRenamer) Rename(m map[string]any) { r.renamePath("", m) } @@ -109,27 +187,50 @@ func (KeyRenamer) keyPath(k1, k2 string) string { k1, k2 = strings.ToLower(k1), strings.ToLower(k2) if k1 == "" { return k2 - } else { - return k1 + "/" + k2 } + return k1 + "/" + k2 } -func (r KeyRenamer) renamePath(parentKeyPath string, m map[string]interface{}) { - for key, val := range m { - keyPath := r.keyPath(parentKeyPath, key) - switch val.(type) { - case map[interface{}]interface{}: - val = cast.ToStringMap(val) - r.renamePath(keyPath, val.(map[string]interface{})) - case map[string]interface{}: - r.renamePath(keyPath, val.(map[string]interface{})) +func (r KeyRenamer) renamePath(parentKeyPath string, m map[string]any) { + for k, v := range m { + keyPath := r.keyPath(parentKeyPath, k) + switch vv := v.(type) { + case map[any]any: + r.renamePath(keyPath, cast.ToStringMap(vv)) + case map[string]any: + r.renamePath(keyPath, vv) } newKey := r.getNewKey(keyPath) if newKey != "" { - delete(m, key) - m[newKey] = val + delete(m, k) + m[newKey] = v + } + } +} + +// ConvertFloat64WithNoDecimalsToInt converts float64 values with no decimals to int recursively. +func ConvertFloat64WithNoDecimalsToInt(m map[string]any) { + for k, v := range m { + switch vv := v.(type) { + case float64: + if v == float64(int64(vv)) { + m[k] = int64(vv) + } + case map[string]any: + ConvertFloat64WithNoDecimalsToInt(vv) + case []any: + for i, vvv := range vv { + switch vvvv := vvv.(type) { + case float64: + if vvv == float64(int64(vvvv)) { + vv[i] = int64(vvvv) + } + case map[string]any: + ConvertFloat64WithNoDecimalsToInt(vvvv) + } + } } } } diff --git a/common/maps/maps_get.go b/common/maps/maps_get.go deleted file mode 100644 index 9289991ae..000000000 --- a/common/maps/maps_get.go +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright 2019 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 maps - -import ( - "github.com/spf13/cast" -) - -// GetString tries to get a value with key from map m and convert it to a string. -// It will return an empty string if not found or if it cannot be convertd to a string. -func GetString(m map[string]interface{}, key string) string { - if m == nil { - return "" - } - v, found := m[key] - if !found { - return "" - } - return cast.ToString(v) -} diff --git a/common/maps/maps_test.go b/common/maps/maps_test.go index 6e4947adb..40c8ac824 100644 --- a/common/maps/maps_test.go +++ b/common/maps/maps_test.go @@ -21,13 +21,13 @@ import ( qt "github.com/frankban/quicktest" ) -func TestToLower(t *testing.T) { +func TestPrepareParams(t *testing.T) { tests := []struct { - input map[string]interface{} - expected map[string]interface{} + input Params + expected Params }{ { - map[string]interface{}{ + map[string]any{ "abC": 32, }, Params{ @@ -35,18 +35,21 @@ func TestToLower(t *testing.T) { }, }, { - map[string]interface{}{ + map[string]any{ "abC": 32, - "deF": map[interface{}]interface{}{ + "deF": map[any]any{ 23: "A value", - 24: map[string]interface{}{ + 24: map[string]any{ "AbCDe": "A value", "eFgHi": "Another value", }, }, - "gHi": map[string]interface{}{ + "gHi": map[string]any{ "J": 25, }, + "jKl": map[string]string{ + "M": "26", + }, }, Params{ "abc": 32, @@ -60,51 +63,101 @@ func TestToLower(t *testing.T) { "ghi": Params{ "j": 25, }, + "jkl": Params{ + "m": "26", + }, }, }, } for i, test := range tests { t.Run(fmt.Sprint(i), func(t *testing.T) { - // ToLower modifies input. - ToLower(test.input) + // PrepareParams modifies input. + prepareClone := PrepareParamsClone(test.input) + PrepareParams(test.input) if !reflect.DeepEqual(test.expected, test.input) { t.Errorf("[%d] Expected\n%#v, got\n%#v\n", i, test.expected, test.input) } + if !reflect.DeepEqual(test.expected, prepareClone) { + t.Errorf("[%d] Expected\n%#v, got\n%#v\n", i, test.expected, prepareClone) + } }) } } +func TestToSliceStringMap(t *testing.T) { + c := qt.New(t) + + tests := []struct { + input any + expected []map[string]any + }{ + { + input: []map[string]any{ + {"abc": 123}, + }, + expected: []map[string]any{ + {"abc": 123}, + }, + }, { + input: []any{ + map[string]any{ + "def": 456, + }, + }, + expected: []map[string]any{ + {"def": 456}, + }, + }, + } + + for _, test := range tests { + v, err := ToSliceStringMap(test.input) + c.Assert(err, qt.IsNil) + c.Assert(v, qt.DeepEquals, test.expected) + } +} + +func TestToParamsAndPrepare(t *testing.T) { + c := qt.New(t) + _, err := ToParamsAndPrepare(map[string]any{"A": "av"}) + c.Assert(err, qt.IsNil) + + params, err := ToParamsAndPrepare(nil) + c.Assert(err, qt.IsNil) + c.Assert(params, qt.DeepEquals, Params{}) +} + func TestRenameKeys(t *testing.T) { c := qt.New(t) - m := map[string]interface{}{ + m := map[string]any{ "a": 32, "ren1": "m1", "ren2": "m1_2", - "sub": map[string]interface{}{ - "subsub": map[string]interface{}{ + "sub": map[string]any{ + "subsub": map[string]any{ "REN1": "m2", "ren2": "m2_2", }, }, - "no": map[string]interface{}{ + "no": map[string]any{ "ren1": "m2", "ren2": "m2_2", }, } - expected := map[string]interface{}{ + expected := map[string]any{ "a": 32, "new1": "m1", "new2": "m1_2", - "sub": map[string]interface{}{ - "subsub": map[string]interface{}{ + "sub": map[string]any{ + "subsub": map[string]any{ "new1": "m2", "ren2": "m2_2", }, }, - "no": map[string]interface{}{ + "no": map[string]any{ "ren1": "m2", "ren2": "m2_2", }, @@ -121,5 +174,28 @@ func TestRenameKeys(t *testing.T) { if !reflect.DeepEqual(expected, m) { t.Errorf("Expected\n%#v, got\n%#v\n", expected, m) } - +} + +func TestLookupEqualFold(t *testing.T) { + c := qt.New(t) + + m1 := map[string]any{ + "a": "av", + "B": "bv", + } + + v, k, found := LookupEqualFold(m1, "b") + c.Assert(found, qt.IsTrue) + c.Assert(v, qt.Equals, "bv") + c.Assert(k, qt.Equals, "B") + + m2 := map[string]string{ + "a": "av", + "B": "bv", + } + + v, k, found = LookupEqualFold(m2, "b") + c.Assert(found, qt.IsTrue) + c.Assert(k, qt.Equals, "B") + c.Assert(v, qt.Equals, "bv") } diff --git a/common/maps/ordered.go b/common/maps/ordered.go new file mode 100644 index 000000000..0da9d239d --- /dev/null +++ b/common/maps/ordered.go @@ -0,0 +1,144 @@ +// Copyright 2024 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 maps + +import ( + "slices" + + "github.com/gohugoio/hugo/common/hashing" +) + +// Ordered is a map that can be iterated in the order of insertion. +// Note that insertion order is not affected if a key is re-inserted into the map. +// In a nil map, all operations are no-ops. +// This is not thread safe. +type Ordered[K comparable, T any] struct { + // The keys in the order they were added. + keys []K + // The values. + values map[K]T +} + +// NewOrdered creates a new Ordered map. +func NewOrdered[K comparable, T any]() *Ordered[K, T] { + return &Ordered[K, T]{values: make(map[K]T)} +} + +// Set sets the value for the given key. +// Note that insertion order is not affected if a key is re-inserted into the map. +func (m *Ordered[K, T]) Set(key K, value T) { + if m == nil { + return + } + // Check if key already exists. + if _, found := m.values[key]; !found { + m.keys = append(m.keys, key) + } + m.values[key] = value +} + +// Get gets the value for the given key. +func (m *Ordered[K, T]) Get(key K) (T, bool) { + if m == nil { + var v T + return v, false + } + value, found := m.values[key] + return value, found +} + +// Has returns whether the given key exists in the map. +func (m *Ordered[K, T]) Has(key K) bool { + if m == nil { + return false + } + _, found := m.values[key] + return found +} + +// Delete deletes the value for the given key. +func (m *Ordered[K, T]) Delete(key K) { + if m == nil { + return + } + delete(m.values, key) + for i, k := range m.keys { + if k == key { + m.keys = slices.Delete(m.keys, i, i+1) + break + } + } +} + +// Clone creates a shallow copy of the map. +func (m *Ordered[K, T]) Clone() *Ordered[K, T] { + if m == nil { + return nil + } + clone := NewOrdered[K, T]() + for _, k := range m.keys { + clone.Set(k, m.values[k]) + } + return clone +} + +// Keys returns the keys in the order they were added. +func (m *Ordered[K, T]) Keys() []K { + if m == nil { + return nil + } + return m.keys +} + +// Values returns the values in the order they were added. +func (m *Ordered[K, T]) Values() []T { + if m == nil { + return nil + } + var values []T + for _, k := range m.keys { + values = append(values, m.values[k]) + } + return values +} + +// Len returns the number of items in the map. +func (m *Ordered[K, T]) Len() int { + if m == nil { + return 0 + } + return len(m.keys) +} + +// Range calls f sequentially for each key and value present in the map. +// If f returns false, range stops the iteration. +// TODO(bep) replace with iter.Seq2 when we bump go Go 1.24. +func (m *Ordered[K, T]) Range(f func(key K, value T) bool) { + if m == nil { + return + } + for _, k := range m.keys { + if !f(k, m.values[k]) { + return + } + } +} + +// Hash calculates a hash from the values. +func (m *Ordered[K, T]) Hash() (uint64, error) { + if m == nil { + return 0, nil + } + return hashing.Hash(m.values) +} diff --git a/common/maps/ordered_test.go b/common/maps/ordered_test.go new file mode 100644 index 000000000..65a827810 --- /dev/null +++ b/common/maps/ordered_test.go @@ -0,0 +1,99 @@ +// Copyright 2024 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 maps + +import ( + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestOrdered(t *testing.T) { + c := qt.New(t) + + m := NewOrdered[string, int]() + m.Set("a", 1) + m.Set("b", 2) + m.Set("c", 3) + + c.Assert(m.Keys(), qt.DeepEquals, []string{"a", "b", "c"}) + c.Assert(m.Values(), qt.DeepEquals, []int{1, 2, 3}) + + v, found := m.Get("b") + c.Assert(found, qt.Equals, true) + c.Assert(v, qt.Equals, 2) + + m.Set("b", 22) + c.Assert(m.Keys(), qt.DeepEquals, []string{"a", "b", "c"}) + c.Assert(m.Values(), qt.DeepEquals, []int{1, 22, 3}) + + m.Delete("b") + + c.Assert(m.Keys(), qt.DeepEquals, []string{"a", "c"}) + c.Assert(m.Values(), qt.DeepEquals, []int{1, 3}) +} + +func TestOrderedHash(t *testing.T) { + c := qt.New(t) + + m := NewOrdered[string, int]() + m.Set("a", 1) + m.Set("b", 2) + m.Set("c", 3) + + h1, err := m.Hash() + c.Assert(err, qt.IsNil) + + m.Set("d", 4) + + h2, err := m.Hash() + c.Assert(err, qt.IsNil) + + c.Assert(h1, qt.Not(qt.Equals), h2) + + m = NewOrdered[string, int]() + m.Set("b", 2) + m.Set("a", 1) + m.Set("c", 3) + + h3, err := m.Hash() + c.Assert(err, qt.IsNil) + // Order does not matter. + c.Assert(h1, qt.Equals, h3) +} + +func TestOrderedNil(t *testing.T) { + c := qt.New(t) + + var m *Ordered[string, int] + + m.Set("a", 1) + c.Assert(m.Keys(), qt.IsNil) + c.Assert(m.Values(), qt.IsNil) + v, found := m.Get("a") + c.Assert(found, qt.Equals, false) + c.Assert(v, qt.Equals, 0) + m.Delete("a") + var b bool + m.Range(func(k string, v int) bool { + b = true + return true + }) + c.Assert(b, qt.Equals, false) + c.Assert(m.Len(), qt.Equals, 0) + c.Assert(m.Clone(), qt.IsNil) + h, err := m.Hash() + c.Assert(err, qt.IsNil) + c.Assert(h, qt.Equals, uint64(0)) +} diff --git a/common/maps/params.go b/common/maps/params.go index ecb63d7a5..819f796e4 100644 --- a/common/maps/params.go +++ b/common/maps/params.go @@ -14,22 +14,143 @@ package maps import ( + "fmt" "strings" "github.com/spf13/cast" ) // Params is a map where all keys are lower case. -type Params map[string]interface{} +type Params map[string]any -// Get does a lower case and nested search in this map. +// KeyParams is an utility struct for the WalkParams method. +type KeyParams struct { + Key string + Params Params +} + +// GetNested does a lower case and nested search in this map. // It will return nil if none found. -func (p Params) Get(indices ...string) interface{} { +// Make all of these methods internal somehow. +func (p Params) GetNested(indices ...string) any { v, _, _ := getNested(p, indices) return v } -func getNested(m map[string]interface{}, indices []string) (interface{}, string, map[string]interface{}) { +// SetParams overwrites values in dst with values in src for common or new keys. +// This is done recursively. +func SetParams(dst, src Params) { + for k, v := range src { + vv, found := dst[k] + if !found { + dst[k] = v + } else { + switch vvv := vv.(type) { + case Params: + if pv, ok := v.(Params); ok { + SetParams(vvv, pv) + } else { + dst[k] = v + } + default: + dst[k] = v + } + } + } +} + +// IsZero returns true if p is considered empty. +func (p Params) IsZero() bool { + if len(p) == 0 { + return true + } + + if len(p) > 1 { + return false + } + + for k := range p { + return k == MergeStrategyKey + } + + return false +} + +// MergeParamsWithStrategy transfers values from src to dst for new keys using the merge strategy given. +// This is done recursively. +func MergeParamsWithStrategy(strategy string, dst, src Params) { + dst.merge(ParamsMergeStrategy(strategy), src) +} + +// MergeParams transfers values from src to dst for new keys using the merge encoded in dst. +// This is done recursively. +func MergeParams(dst, src Params) { + ms, _ := dst.GetMergeStrategy() + dst.merge(ms, src) +} + +func (p Params) merge(ps ParamsMergeStrategy, pp Params) { + ns, found := p.GetMergeStrategy() + + ms := ns + if !found && ps != "" { + ms = ps + } + + noUpdate := ms == ParamsMergeStrategyNone + noUpdate = noUpdate || (ps != "" && ps == ParamsMergeStrategyShallow) + + for k, v := range pp { + + if k == MergeStrategyKey { + continue + } + vv, found := p[k] + + if found { + // Key matches, if both sides are Params, we try to merge. + if vvv, ok := vv.(Params); ok { + if pv, ok := v.(Params); ok { + vvv.merge(ms, pv) + } + } + } else if !noUpdate { + p[k] = v + } + + } +} + +// For internal use. +func (p Params) GetMergeStrategy() (ParamsMergeStrategy, bool) { + if v, found := p[MergeStrategyKey]; found { + if s, ok := v.(ParamsMergeStrategy); ok { + return s, true + } + } + return ParamsMergeStrategyShallow, false +} + +// For internal use. +func (p Params) DeleteMergeStrategy() bool { + if _, found := p[MergeStrategyKey]; found { + delete(p, MergeStrategyKey) + return true + } + return false +} + +// For internal use. +func (p Params) SetMergeStrategy(s ParamsMergeStrategy) { + switch s { + case ParamsMergeStrategyDeep, ParamsMergeStrategyNone, ParamsMergeStrategyShallow: + default: + panic(fmt.Sprintf("invalid merge strategy %q", s)) + } + p[MergeStrategyKey] = s +} + +func getNested(m map[string]any, indices []string) (any, string, map[string]any) { if len(indices) == 0 { return nil, "", nil } @@ -37,7 +158,11 @@ func getNested(m map[string]interface{}, indices []string) (interface{}, string, first := indices[0] v, found := m[strings.ToLower(cast.ToString(first))] if !found { + if len(indices) == 1 { + return nil, first, m + } return nil, "", nil + } if len(indices) == 1 { @@ -47,7 +172,7 @@ func getNested(m map[string]interface{}, indices []string) (interface{}, string, switch m2 := v.(type) { case Params: return getNested(m2, indices[1:]) - case map[string]interface{}: + case map[string]any: return getNested(m2, indices[1:]) default: return nil, "", nil @@ -58,7 +183,7 @@ func getNested(m map[string]interface{}, indices []string) (interface{}, string, // It will first try the exact match and then try to find it as a nested map value, // using the given separator, e.g. "mymap.name". // It assumes that all the maps given have lower cased keys. -func GetNestedParam(keyStr, separator string, candidates ...Params) (interface{}, error) { +func GetNestedParam(keyStr, separator string, candidates ...Params) (any, error) { keyStr = strings.ToLower(keyStr) // Try exact match first @@ -70,17 +195,16 @@ func GetNestedParam(keyStr, separator string, candidates ...Params) (interface{} keySegments := strings.Split(keyStr, separator) for _, m := range candidates { - if v := m.Get(keySegments...); v != nil { + if v := m.GetNested(keySegments...); v != nil { return v, nil } } return nil, nil - } -func GetNestedParamFn(keyStr, separator string, lookupFn func(key string) interface{}) (interface{}, string, map[string]interface{}, error) { - keySegments := strings.Split(strings.ToLower(keyStr), separator) +func GetNestedParamFn(keyStr, separator string, lookupFn func(key string) any) (any, string, map[string]any, error) { + keySegments := strings.Split(keyStr, separator) if len(keySegments) == 0 { return nil, "", nil, nil } @@ -95,7 +219,7 @@ func GetNestedParamFn(keyStr, separator string, lookupFn func(key string) interf } switch m := first.(type) { - case map[string]interface{}: + case map[string]any: v, key, owner := getNested(m, keySegments[1:]) return v, key, owner, nil case Params: @@ -105,3 +229,156 @@ func GetNestedParamFn(keyStr, separator string, lookupFn func(key string) interf return nil, "", nil, nil } + +// ParamsMergeStrategy tells what strategy to use in Params.Merge. +type ParamsMergeStrategy string + +const ( + // Do not merge. + ParamsMergeStrategyNone ParamsMergeStrategy = "none" + // Only add new keys. + ParamsMergeStrategyShallow ParamsMergeStrategy = "shallow" + // Add new keys, merge existing. + ParamsMergeStrategyDeep ParamsMergeStrategy = "deep" + + MergeStrategyKey = "_merge" +) + +// CleanConfigStringMapString removes any processing instructions from m, +// m will never be modified. +func CleanConfigStringMapString(m map[string]string) map[string]string { + if len(m) == 0 { + return m + } + if _, found := m[MergeStrategyKey]; !found { + return m + } + // Create a new map and copy all the keys except the merge strategy key. + m2 := make(map[string]string, len(m)-1) + for k, v := range m { + if k != MergeStrategyKey { + m2[k] = v + } + } + return m2 +} + +// CleanConfigStringMap is the same as CleanConfigStringMapString but for +// map[string]any. +func CleanConfigStringMap(m map[string]any) map[string]any { + if len(m) == 0 { + return m + } + if _, found := m[MergeStrategyKey]; !found { + return m + } + // Create a new map and copy all the keys except the merge strategy key. + m2 := make(map[string]any, len(m)-1) + for k, v := range m { + if k != MergeStrategyKey { + m2[k] = v + } + switch v2 := v.(type) { + case map[string]any: + m2[k] = CleanConfigStringMap(v2) + case Params: + var p Params = CleanConfigStringMap(v2) + m2[k] = p + case map[string]string: + m2[k] = CleanConfigStringMapString(v2) + } + + } + return m2 +} + +func toMergeStrategy(v any) ParamsMergeStrategy { + s := ParamsMergeStrategy(cast.ToString(v)) + switch s { + case ParamsMergeStrategyDeep, ParamsMergeStrategyNone, ParamsMergeStrategyShallow: + return s + default: + return ParamsMergeStrategyDeep + } +} + +// PrepareParams +// * makes all the keys in the given map lower cased and will do so recursively. +// * This will modify the map given. +// * Any nested map[interface{}]interface{}, map[string]interface{},map[string]string will be converted to Params. +// * Any _merge value will be converted to proper type and value. +func PrepareParams(m Params) { + for k, v := range m { + var retyped bool + lKey := strings.ToLower(k) + if lKey == MergeStrategyKey { + v = toMergeStrategy(v) + retyped = true + } else { + switch vv := v.(type) { + case map[any]any: + var p Params = cast.ToStringMap(v) + v = p + PrepareParams(p) + retyped = true + case map[string]any: + var p Params = v.(map[string]any) + v = p + PrepareParams(p) + retyped = true + case map[string]string: + p := make(Params) + for k, v := range vv { + p[k] = v + } + v = p + PrepareParams(p) + retyped = true + } + } + + if retyped || k != lKey { + delete(m, k) + m[lKey] = v + } + } +} + +// PrepareParamsClone is like PrepareParams, but it does not modify the input. +func PrepareParamsClone(m Params) Params { + m2 := make(Params) + for k, v := range m { + var retyped bool + lKey := strings.ToLower(k) + if lKey == MergeStrategyKey { + v = toMergeStrategy(v) + retyped = true + } else { + switch vv := v.(type) { + case map[any]any: + var p Params = cast.ToStringMap(v) + v = PrepareParamsClone(p) + retyped = true + case map[string]any: + var p Params = v.(map[string]any) + v = PrepareParamsClone(p) + retyped = true + case map[string]string: + p := make(Params) + for k, v := range vv { + p[k] = v + } + v = p + PrepareParams(p) + retyped = true + } + } + + if retyped || k != lKey { + m2[lKey] = v + } else { + m2[k] = v + } + } + return m2 +} diff --git a/common/maps/params_test.go b/common/maps/params_test.go index 8016a8bd6..892c77175 100644 --- a/common/maps/params_test.go +++ b/common/maps/params_test.go @@ -20,14 +20,13 @@ import ( ) func TestGetNestedParam(t *testing.T) { - - m := map[string]interface{}{ + m := map[string]any{ "string": "value", "first": 1, "with_underscore": 2, - "nested": map[string]interface{}{ + "nested": map[string]any{ "color": "blue", - "nestednested": map[string]interface{}{ + "nestednested": map[string]any{ "color": "green", }, }, @@ -35,7 +34,7 @@ func TestGetNestedParam(t *testing.T) { c := qt.New(t) - must := func(keyStr, separator string, candidates ...Params) interface{} { + must := func(keyStr, separator string, candidates ...Params) any { v, err := GetNestedParam(keyStr, separator, candidates...) c.Assert(err, qt.IsNil) return v @@ -47,5 +46,124 @@ func TestGetNestedParam(t *testing.T) { c.Assert(must("nested_color", "_", m), qt.Equals, "blue") c.Assert(must("nested.nestednested.color", ".", m), qt.Equals, "green") c.Assert(must("string.name", ".", m), qt.IsNil) - + c.Assert(must("nested.foo", ".", m), qt.IsNil) +} + +// https://github.com/gohugoio/hugo/issues/7903 +func TestGetNestedParamFnNestedNewKey(t *testing.T) { + c := qt.New(t) + + nested := map[string]any{ + "color": "blue", + } + m := map[string]any{ + "nested": nested, + } + + existing, nestedKey, owner, err := GetNestedParamFn("nested.new", ".", func(key string) any { + return m[key] + }) + + c.Assert(err, qt.IsNil) + c.Assert(existing, qt.IsNil) + c.Assert(nestedKey, qt.Equals, "new") + c.Assert(owner, qt.DeepEquals, nested) +} + +func TestParamsSetAndMerge(t *testing.T) { + c := qt.New(t) + + createParamsPair := func() (Params, Params) { + p1 := Params{"a": "av", "c": "cv", "nested": Params{"al2": "al2v", "cl2": "cl2v"}} + p2 := Params{"b": "bv", "a": "abv", "nested": Params{"bl2": "bl2v", "al2": "al2bv"}, MergeStrategyKey: ParamsMergeStrategyDeep} + return p1, p2 + } + + p1, p2 := createParamsPair() + + SetParams(p1, p2) + + c.Assert(p1, qt.DeepEquals, Params{ + "a": "abv", + "c": "cv", + "nested": Params{ + "al2": "al2bv", + "cl2": "cl2v", + "bl2": "bl2v", + }, + "b": "bv", + MergeStrategyKey: ParamsMergeStrategyDeep, + }) + + p1, p2 = createParamsPair() + + MergeParamsWithStrategy("", p1, p2) + + // Default is to do a shallow merge. + c.Assert(p1, qt.DeepEquals, Params{ + "c": "cv", + "nested": Params{ + "al2": "al2v", + "cl2": "cl2v", + }, + "b": "bv", + "a": "av", + }) + + p1, p2 = createParamsPair() + p1.SetMergeStrategy(ParamsMergeStrategyNone) + MergeParamsWithStrategy("", p1, p2) + p1.DeleteMergeStrategy() + + c.Assert(p1, qt.DeepEquals, Params{ + "a": "av", + "c": "cv", + "nested": Params{ + "al2": "al2v", + "cl2": "cl2v", + }, + }) + + p1, p2 = createParamsPair() + p1.SetMergeStrategy(ParamsMergeStrategyShallow) + MergeParamsWithStrategy("", p1, p2) + p1.DeleteMergeStrategy() + + c.Assert(p1, qt.DeepEquals, Params{ + "a": "av", + "c": "cv", + "nested": Params{ + "al2": "al2v", + "cl2": "cl2v", + }, + "b": "bv", + }) + + p1, p2 = createParamsPair() + p1.SetMergeStrategy(ParamsMergeStrategyDeep) + MergeParamsWithStrategy("", p1, p2) + p1.DeleteMergeStrategy() + + c.Assert(p1, qt.DeepEquals, Params{ + "nested": Params{ + "al2": "al2v", + "cl2": "cl2v", + "bl2": "bl2v", + }, + "b": "bv", + "a": "av", + "c": "cv", + }) +} + +func TestParamsIsZero(t *testing.T) { + c := qt.New(t) + + var nilParams Params + + c.Assert(Params{}.IsZero(), qt.IsTrue) + c.Assert(nilParams.IsZero(), qt.IsTrue) + c.Assert(Params{"foo": "bar"}.IsZero(), qt.IsFalse) + c.Assert(Params{"_merge": "foo", "foo": "bar"}.IsZero(), qt.IsFalse) + c.Assert(Params{"_merge": "foo"}.IsZero(), qt.IsTrue) } diff --git a/common/maps/scratch.go b/common/maps/scratch.go index 4acd10c6c..cf5231783 100644 --- a/common/maps/scratch.go +++ b/common/maps/scratch.go @@ -22,37 +22,24 @@ import ( "github.com/gohugoio/hugo/common/math" ) -// Scratch is a writable context used for stateful operations in Page/Node rendering. +type StoreProvider interface { + // Store returns a Scratch that can be used to store temporary state. + // Store is not reset on server rebuilds. + Store() *Scratch +} + +// Scratch is a writable context used for stateful build operations type Scratch struct { - values map[string]interface{} + values map[string]any mu sync.RWMutex } -// Scratcher provides a scratching service. -type Scratcher interface { - Scratch() *Scratch -} - -type scratcher struct { - s *Scratch -} - -func (s scratcher) Scratch() *Scratch { - return s.s -} - -// NewScratcher creates a new Scratcher. -func NewScratcher() Scratcher { - return scratcher{s: NewScratch()} -} - // Add will, for single values, add (using the + operator) the addend to the existing addend (if found). // Supports numeric values and strings. // // If the first add for a key is an array or slice, then the next value(s) will be appended. -func (c *Scratch) Add(key string, newAddend interface{}) (string, error) { - - var newVal interface{} +func (c *Scratch) Add(key string, newAddend any) (string, error) { + var newVal any c.mu.RLock() existingAddend, found := c.values[key] c.mu.RUnlock() @@ -83,7 +70,7 @@ func (c *Scratch) Add(key string, newAddend interface{}) (string, error) { // Set stores a value with the given key in the Node context. // This value can later be retrieved with Get. -func (c *Scratch) Set(key string, value interface{}) string { +func (c *Scratch) Set(key string, value any) string { c.mu.Lock() c.values[key] = value c.mu.Unlock() @@ -99,7 +86,7 @@ func (c *Scratch) Delete(key string) string { } // Get returns a value previously set by Add or Set. -func (c *Scratch) Get(key string) interface{} { +func (c *Scratch) Get(key string) any { c.mu.RLock() val := c.values[key] c.mu.RUnlock() @@ -107,22 +94,42 @@ func (c *Scratch) Get(key string) interface{} { return val } +// Values returns the raw backing map. Note that you should just use +// this method on the locally scoped Scratch instances you obtain via newScratch, not +// .Page.Scratch etc., as that will lead to concurrency issues. +func (c *Scratch) Values() map[string]any { + c.mu.RLock() + defer c.mu.RUnlock() + return c.values +} + // SetInMap stores a value to a map with the given key in the Node context. // This map can later be retrieved with GetSortedMapValues. -func (c *Scratch) SetInMap(key string, mapKey string, value interface{}) string { +func (c *Scratch) SetInMap(key string, mapKey string, value any) string { c.mu.Lock() _, found := c.values[key] if !found { - c.values[key] = make(map[string]interface{}) + c.values[key] = make(map[string]any) } - c.values[key].(map[string]interface{})[mapKey] = value + c.values[key].(map[string]any)[mapKey] = value + c.mu.Unlock() + return "" +} + +// DeleteInMap deletes a value to a map with the given key in the Node context. +func (c *Scratch) DeleteInMap(key string, mapKey string) string { + c.mu.Lock() + _, found := c.values[key] + if found { + delete(c.values[key].(map[string]any), mapKey) + } c.mu.Unlock() return "" } // GetSortedMapValues returns a sorted map previously filled with SetInMap. -func (c *Scratch) GetSortedMapValues(key string) interface{} { +func (c *Scratch) GetSortedMapValues(key string) any { c.mu.RLock() if c.values[key] == nil { @@ -130,7 +137,7 @@ func (c *Scratch) GetSortedMapValues(key string) interface{} { return nil } - unsortedMap := c.values[key].(map[string]interface{}) + unsortedMap := c.values[key].(map[string]any) c.mu.RUnlock() var keys []string for mapKey := range unsortedMap { @@ -139,7 +146,7 @@ func (c *Scratch) GetSortedMapValues(key string) interface{} { sort.Strings(keys) - sortedArray := make([]interface{}, len(unsortedMap)) + sortedArray := make([]any, len(unsortedMap)) for i, mapKey := range keys { sortedArray[i] = unsortedMap[mapKey] } @@ -147,7 +154,7 @@ func (c *Scratch) GetSortedMapValues(key string) interface{} { return sortedArray } -// NewScratch returns a new instance Scratch. +// NewScratch returns a new instance of Scratch. func NewScratch() *Scratch { - return &Scratch{values: make(map[string]interface{})} + return &Scratch{values: make(map[string]any)} } diff --git a/common/maps/scratch_test.go b/common/maps/scratch_test.go index c2c436e40..f07169e61 100644 --- a/common/maps/scratch_test.go +++ b/common/maps/scratch_test.go @@ -47,10 +47,12 @@ func TestScratchAdd(t *testing.T) { scratch.Add("scratch", scratch) _, err := scratch.Add("scratch", scratch) + m := scratch.Values() + c.Assert(m, qt.HasLen, 5) + if err == nil { t.Errorf("Expected error from invalid arithmetic") } - } func TestScratchAddSlice(t *testing.T) { @@ -88,12 +90,11 @@ func TestScratchAddTypedSliceToInterfaceSlice(t *testing.T) { c := qt.New(t) scratch := NewScratch() - scratch.Set("slice", []interface{}{}) + scratch.Set("slice", []any{}) _, err := scratch.Add("slice", []int{1, 2}) c.Assert(err, qt.IsNil) c.Assert(scratch.Get("slice"), qt.DeepEquals, []int{1, 2}) - } // https://github.com/gohugoio/hugo/issues/5361 @@ -106,8 +107,7 @@ func TestScratchAddDifferentTypedSliceToInterfaceSlice(t *testing.T) { _, err := scratch.Add("slice", []int{1, 2}) c.Assert(err, qt.IsNil) - c.Assert(scratch.Get("slice"), qt.DeepEquals, []interface{}{"foo", 1, 2}) - + c.Assert(scratch.Get("slice"), qt.DeepEquals, []any{"foo", 1, 2}) } func TestScratchSet(t *testing.T) { @@ -140,7 +140,7 @@ func TestScratchInParallel(t *testing.T) { for i := 1; i <= 10; i++ { wg.Add(1) go func(j int) { - for k := 0; k < 10; k++ { + for k := range 10 { newVal := int64(k + j) _, err := scratch.Add(key, newVal) @@ -185,7 +185,21 @@ func TestScratchSetInMap(t *testing.T) { scratch.SetInMap("key", "zyx", "Zyx") scratch.SetInMap("key", "abc", "Abc (updated)") scratch.SetInMap("key", "def", "Def") - c.Assert(scratch.GetSortedMapValues("key"), qt.DeepEquals, []interface{}{0: "Abc (updated)", 1: "Def", 2: "Lux", 3: "Zyx"}) + c.Assert(scratch.GetSortedMapValues("key"), qt.DeepEquals, any([]any{"Abc (updated)", "Def", "Lux", "Zyx"})) +} + +func TestScratchDeleteInMap(t *testing.T) { + t.Parallel() + c := qt.New(t) + + scratch := NewScratch() + scratch.SetInMap("key", "lux", "Lux") + scratch.SetInMap("key", "abc", "Abc") + scratch.SetInMap("key", "zyx", "Zyx") + scratch.DeleteInMap("key", "abc") + scratch.SetInMap("key", "def", "Def") + scratch.DeleteInMap("key", "lmn") // Do nothing + c.Assert(scratch.GetSortedMapValues("key"), qt.DeepEquals, any([]any{"Def", "Lux", "Zyx"})) } func TestScratchGetSortedMapValues(t *testing.T) { diff --git a/common/math/math.go b/common/math/math.go index cd06379aa..f88fbcd9c 100644 --- a/common/math/math.go +++ b/common/math/math.go @@ -20,35 +20,38 @@ import ( // DoArithmetic performs arithmetic operations (+,-,*,/) using reflection to // determine the type of the two terms. -func DoArithmetic(a, b interface{}, op rune) (interface{}, error) { +func DoArithmetic(a, b any, op rune) (any, error) { av := reflect.ValueOf(a) bv := reflect.ValueOf(b) var ai, bi int64 var af, bf float64 var au, bu uint64 + var isInt, isFloat, isUint bool switch av.Kind() { case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: ai = av.Int() switch bv.Kind() { case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + isInt = true bi = bv.Int() case reflect.Float32, reflect.Float64: + isFloat = true af = float64(ai) // may overflow - ai = 0 bf = bv.Float() case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: bu = bv.Uint() if ai >= 0 { + isUint = true au = uint64(ai) - ai = 0 } else { + isInt = true bi = int64(bu) // may overflow - bu = 0 } default: return nil, errors.New("can't apply the operator to the values") } case reflect.Float32, reflect.Float64: + isFloat = true af = av.Float() switch bv.Kind() { case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: @@ -66,17 +69,18 @@ func DoArithmetic(a, b interface{}, op rune) (interface{}, error) { case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: bi = bv.Int() if bi >= 0 { + isUint = true bu = uint64(bi) - bi = 0 } else { + isInt = true ai = int64(au) // may overflow - au = 0 } case reflect.Float32, reflect.Float64: + isFloat = true af = float64(au) // may overflow - au = 0 bf = bv.Float() case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + isUint = true bu = bv.Uint() default: return nil, errors.New("can't apply the operator to the values") @@ -94,38 +98,32 @@ func DoArithmetic(a, b interface{}, op rune) (interface{}, error) { switch op { case '+': - if ai != 0 || bi != 0 { + if isInt { return ai + bi, nil - } else if af != 0 || bf != 0 { + } else if isFloat { return af + bf, nil - } else if au != 0 || bu != 0 { - return au + bu, nil } - return 0, nil + return au + bu, nil case '-': - if ai != 0 || bi != 0 { + if isInt { return ai - bi, nil - } else if af != 0 || bf != 0 { + } else if isFloat { return af - bf, nil - } else if au != 0 || bu != 0 { - return au - bu, nil } - return 0, nil + return au - bu, nil case '*': - if ai != 0 || bi != 0 { + if isInt { return ai * bi, nil - } else if af != 0 || bf != 0 { + } else if isFloat { return af * bf, nil - } else if au != 0 || bu != 0 { - return au * bu, nil } - return 0, nil + return au * bu, nil case '/': - if bi != 0 { + if isInt && bi != 0 { return ai / bi, nil - } else if bf != 0 { + } else if isFloat && bf != 0 { return af / bf, nil - } else if bu != 0 { + } else if isUint && bu != 0 { return au / bu, nil } return nil, errors.New("can't divide the value by 0") diff --git a/common/math/math_test.go b/common/math/math_test.go index a11701862..d75d30a69 100644 --- a/common/math/math_test.go +++ b/common/math/math_test.go @@ -24,16 +24,18 @@ func TestDoArithmetic(t *testing.T) { c := qt.New(t) for _, test := range []struct { - a interface{} - b interface{} + a any + b any op rune - expect interface{} + expect any }{ {3, 2, '+', int64(5)}, + {0, 0, '+', int64(0)}, {3, 2, '-', int64(1)}, {3, 2, '*', int64(6)}, {3, 2, '/', int64(1)}, {3.0, 2, '+', float64(5)}, + {0.0, 0, '+', float64(0.0)}, {3.0, 2, '-', float64(1)}, {3.0, 2, '*', float64(6)}, {3.0, 2, '/', float64(1.5)}, @@ -42,18 +44,22 @@ func TestDoArithmetic(t *testing.T) { {3, 2.0, '*', float64(6)}, {3, 2.0, '/', float64(1.5)}, {3.0, 2.0, '+', float64(5)}, + {0.0, 0.0, '+', float64(0.0)}, {3.0, 2.0, '-', float64(1)}, {3.0, 2.0, '*', float64(6)}, {3.0, 2.0, '/', float64(1.5)}, {uint(3), uint(2), '+', uint64(5)}, + {uint(0), uint(0), '+', uint64(0)}, {uint(3), uint(2), '-', uint64(1)}, {uint(3), uint(2), '*', uint64(6)}, {uint(3), uint(2), '/', uint64(1)}, {uint(3), 2, '+', uint64(5)}, + {uint(0), 0, '+', uint64(0)}, {uint(3), 2, '-', uint64(1)}, {uint(3), 2, '*', uint64(6)}, {uint(3), 2, '/', uint64(1)}, {3, uint(2), '+', uint64(5)}, + {0, uint(0), '+', uint64(0)}, {3, uint(2), '-', uint64(1)}, {3, uint(2), '*', uint64(6)}, {3, uint(2), '/', uint64(1)}, @@ -66,16 +72,15 @@ func TestDoArithmetic(t *testing.T) { {-3, uint(2), '*', int64(-6)}, {-3, uint(2), '/', int64(-1)}, {uint(3), 2.0, '+', float64(5)}, + {uint(0), 0.0, '+', float64(0)}, {uint(3), 2.0, '-', float64(1)}, {uint(3), 2.0, '*', float64(6)}, {uint(3), 2.0, '/', float64(1.5)}, {3.0, uint(2), '+', float64(5)}, + {0.0, uint(0), '+', float64(0)}, {3.0, uint(2), '-', float64(1)}, {3.0, uint(2), '*', float64(6)}, {3.0, uint(2), '/', float64(1.5)}, - {0, 0, '+', 0}, - {0, 0, '-', 0}, - {0, 0, '*', 0}, {"foo", "bar", '+', "foobar"}, {3, 0, '/', false}, {3.0, 0, '/', false}, diff --git a/common/para/para.go b/common/para/para.go index 69bfc205b..c323a3073 100644 --- a/common/para/para.go +++ b/common/para/para.go @@ -27,7 +27,7 @@ type Workers struct { // Runner wraps the lifecycle methods of a new task set. // -// Run wil block until a worker is available or the context is cancelled, +// Run will block until a worker is available or the context is cancelled, // and then run the given func in a new goroutine. // Wait will wait for all the running goroutines to finish. type Runner interface { diff --git a/common/para/para_test.go b/common/para/para_test.go index bda7f5d27..cf24a4e37 100644 --- a/common/para/para_test.go +++ b/common/para/para_test.go @@ -22,6 +22,8 @@ import ( "testing" "time" + "github.com/gohugoio/hugo/htesting" + qt "github.com/frankban/quicktest" ) @@ -30,12 +32,17 @@ func TestPara(t *testing.T) { 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) { n := 500 ints := make([]int, n) - for i := 0; i < n; i++ { + for i := range n { ints[i] = i } @@ -44,7 +51,7 @@ func TestPara(t *testing.T) { var result []int var mu sync.Mutex - for i := 0; i < n; i++ { + for i := range n { i := i r.Run(func() error { mu.Lock() @@ -59,7 +66,6 @@ func TestPara(t *testing.T) { c.Assert(sort.IntsAreSorted(result), qt.Equals, false, qt.Commentf("Para does not seem to be parallel")) sort.Ints(result) c.Assert(result, qt.DeepEquals, ints) - }) c.Run("Time", func(c *qt.C) { @@ -72,7 +78,7 @@ func TestPara(t *testing.T) { var counter int64 - for i := 0; i < n; i++ { + for range n { r.Run(func() error { atomic.AddInt64(&counter, 1) time.Sleep(1 * time.Millisecond) @@ -82,8 +88,9 @@ func TestPara(t *testing.T) { c.Assert(r.Wait(), qt.IsNil) c.Assert(counter, qt.Equals, int64(n)) - c.Assert(time.Since(start) < n/2*time.Millisecond, qt.Equals, true) + since := time.Since(start) + limit := n / 2 * time.Millisecond + c.Assert(since < limit, qt.Equals, true, qt.Commentf("%s >= %s", since, limit)) }) - } diff --git a/common/paths/path.go b/common/paths/path.go new file mode 100644 index 000000000..de91d6a2f --- /dev/null +++ b/common/paths/path.go @@ -0,0 +1,430 @@ +// Copyright 2021 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 paths + +import ( + "errors" + "fmt" + "net/url" + "path" + "path/filepath" + "strings" + "unicode" +) + +// FilePathSeparator as defined by os.Separator. +const ( + FilePathSeparator = string(filepath.Separator) + slash = "/" +) + +// filepathPathBridge is a bridge for common functionality in filepath vs path +type filepathPathBridge interface { + Base(in string) string + Clean(in string) string + Dir(in string) string + Ext(in string) string + Join(elem ...string) string + Separator() string +} + +type filepathBridge struct{} + +func (filepathBridge) Base(in string) string { + return filepath.Base(in) +} + +func (filepathBridge) Clean(in string) string { + return filepath.Clean(in) +} + +func (filepathBridge) Dir(in string) string { + return filepath.Dir(in) +} + +func (filepathBridge) Ext(in string) string { + return filepath.Ext(in) +} + +func (filepathBridge) Join(elem ...string) string { + return filepath.Join(elem...) +} + +func (filepathBridge) Separator() string { + return FilePathSeparator +} + +var fpb filepathBridge + +// AbsPathify creates an absolute path if given a working dir and a relative path. +// If already absolute, the path is just cleaned. +func AbsPathify(workingDir, inPath string) string { + if filepath.IsAbs(inPath) { + return filepath.Clean(inPath) + } + return filepath.Join(workingDir, inPath) +} + +// AddTrailingSlash adds a trailing Unix styled slash (/) if not already +// there. +func AddTrailingSlash(path string) string { + if !strings.HasSuffix(path, "/") { + path += "/" + } + return path +} + +// AddLeadingSlash adds a leading Unix styled slash (/) if not already +// there. +func AddLeadingSlash(path string) string { + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return path +} + +// AddTrailingAndLeadingSlash adds a leading and trailing Unix styled slash (/) if not already +// there. +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 strings.HasPrefix(inPath, currentPath) { + return strings.TrimPrefix(inPath, currentPath), nil + } + } + return inPath, errors.New("can't extract relative path, unknown prefix") +} + +// ExtNoDelimiter takes a path and returns the extension, excluding the delimiter, i.e. "md". +func ExtNoDelimiter(in string) string { + return strings.TrimPrefix(Ext(in), ".") +} + +// Ext takes a path and returns the extension, including the delimiter, i.e. ".md". +func Ext(in string) string { + _, ext := fileAndExt(in, fpb) + return ext +} + +// PathAndExt is the same as FileAndExt, but it uses the path package. +func PathAndExt(in string) (string, string) { + return fileAndExt(in, pb) +} + +// FileAndExt takes a path and returns the file and extension separated, +// the extension including the delimiter, i.e. ".md". +func FileAndExt(in string) (string, string) { + return fileAndExt(in, fpb) +} + +// FileAndExtNoDelimiter takes a path and returns the file and extension separated, +// the extension excluding the delimiter, e.g "md". +func FileAndExtNoDelimiter(in string) (string, string) { + file, ext := fileAndExt(in, fpb) + return file, strings.TrimPrefix(ext, ".") +} + +// Filename takes a file path, strips out the extension, +// and returns the name of the file. +func Filename(in string) (name string) { + name, _ = fileAndExt(in, fpb) + return +} + +// FileAndExt returns the filename and any extension of a file path as +// two separate strings. +// +// If the path, in, contains a directory name ending in a slash, +// then both name and ext will be empty strings. +// +// If the path, in, is either the current directory, the parent +// directory or the root directory, or an empty string, +// then both name and ext will be empty strings. +// +// If the path, in, represents the path of a file without an extension, +// then name will be the name of the file and ext will be an empty string. +// +// If the path, in, represents a filename with an extension, +// then name will be the filename minus any extension - including the dot +// and ext will contain the extension - minus the dot. +func fileAndExt(in string, b filepathPathBridge) (name string, ext string) { + ext = b.Ext(in) + base := b.Base(in) + + return extractFilename(in, ext, base, b.Separator()), ext +} + +func extractFilename(in, ext, base, pathSeparator string) (name string) { + // No file name cases. These are defined as: + // 1. any "in" path that ends in a pathSeparator + // 2. any "base" consisting of just an pathSeparator + // 3. any "base" consisting of just an empty string + // 4. any "base" consisting of just the current directory i.e. "." + // 5. any "base" consisting of just the parent directory i.e. ".." + if (strings.LastIndex(in, pathSeparator) == len(in)-1) || base == "" || base == "." || base == ".." || base == pathSeparator { + name = "" // there is NO filename + } else if ext != "" { // there was an Extension + // return the filename minus the extension (and the ".") + name = base[:strings.LastIndex(base, ".")] + } else { + // no extension case so just return base, which will + // be the filename + name = base + } + 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. +// +// Spaces will be replaced with a single hyphen. +// +// This function is the core function used to normalize paths in Hugo. +// +// Note that this is the first common step for URL/path sanitation, +// the final URL/path may end up looking differently if the user has stricter rules defined (e.g. removePathAccents=true). +func Sanitize(s string) string { + var willChange bool + for i, r := range s { + willChange = !isAllowedPathCharacter(s, i, r) + if willChange { + break + } + } + + if !willChange { + // Prevent allocation when nothing changes. + return s + } + + target := make([]rune, 0, len(s)) + var ( + prependHyphen bool + wasHyphen bool + ) + + for i, r := range s { + isAllowed := isAllowedPathCharacter(s, i, r) + + if isAllowed { + // track explicit hyphen in input; no need to add a new hyphen if + // we just saw one. + wasHyphen = r == '-' + + if prependHyphen { + // if currently have a hyphen, don't prepend an extra one + if !wasHyphen { + target = append(target, '-') + } + prependHyphen = false + } + target = append(target, r) + } else if len(target) > 0 && !wasHyphen && unicode.IsSpace(r) { + prependHyphen = true + } + } + + return string(target) +} + +func isAllowedPathCharacter(s string, i int, r rune) bool { + if r == ' ' { + return false + } + // Check for the most likely first (faster). + isAllowed := unicode.IsLetter(r) || unicode.IsDigit(r) + isAllowed = isAllowed || r == '.' || r == '/' || r == '\\' || r == '_' || r == '#' || r == '+' || r == '~' || r == '-' || r == '@' + isAllowed = isAllowed || unicode.IsMark(r) + isAllowed = isAllowed || (r == '%' && i+2 < len(s) && ishex(s[i+1]) && ishex(s[i+2])) + return isAllowed +} + +// From https://golang.org/src/net/url/url.go +func ishex(c byte) bool { + switch { + case '0' <= c && c <= '9': + return true + case 'a' <= c && c <= 'f': + return true + case 'A' <= c && c <= 'F': + return true + } + return false +} + +var slashFunc = func(r rune) bool { + return r == '/' +} + +// Dir behaves like path.Dir without the path.Clean step. +// +// The returned path ends in a slash only if it is the root "/". +func Dir(s string) string { + dir, _ := path.Split(s) + if len(dir) > 1 && dir[len(dir)-1] == '/' { + return dir[:len(dir)-1] + } + return dir +} + +// FieldsSlash cuts s into fields separated with '/'. +func FieldsSlash(s string) []string { + f := strings.FieldsFunc(s, slashFunc) + return f +} + +// DirFile holds the result from path.Split. +type DirFile struct { + Dir string + File string +} + +// Used in test. +func (df DirFile) String() string { + return fmt.Sprintf("%s|%s", df.Dir, df.File) +} + +// PathEscape escapes unicode letters in pth. +// Use URLEscape to escape full URLs including scheme, query etc. +// This is slightly faster for the common case. +// Note, there is a url.PathEscape function, but that also +// escapes /. +func PathEscape(pth string) string { + u, err := url.Parse(pth) + if err != nil { + panic(err) + } + return u.EscapedPath() +} + +// ToSlashTrimLeading is just a filepath.ToSlash with an added / prefix trimmer. +func ToSlashTrimLeading(s string) string { + return TrimLeading(filepath.ToSlash(s)) +} + +// TrimLeading trims the leading slash from the given string. +func TrimLeading(s string) string { + return strings.TrimPrefix(s, "/") +} + +// ToSlashTrimTrailing is just a filepath.ToSlash with an added / suffix trimmer. +func ToSlashTrimTrailing(s string) string { + return TrimTrailing(filepath.ToSlash(s)) +} + +// TrimTrailing trims the trailing slash from the given string. +func TrimTrailing(s string) string { + return strings.TrimSuffix(s, "/") +} + +// ToSlashTrim trims any leading and trailing slashes from the given string and converts it to a forward slash separated path. +func ToSlashTrim(s string) string { + return strings.Trim(filepath.ToSlash(s), "/") +} + +// ToSlashPreserveLeading converts the path given to a forward slash separated path +// and preserves the leading slash if present trimming any trailing slash. +func ToSlashPreserveLeading(s string) string { + return "/" + strings.Trim(filepath.ToSlash(s), "/") +} + +// IsSameFilePath checks if s1 and s2 are the same file path. +func IsSameFilePath(s1, s2 string) bool { + return path.Clean(ToSlashTrim(s1)) == path.Clean(ToSlashTrim(s2)) +} diff --git a/common/paths/path_test.go b/common/paths/path_test.go new file mode 100644 index 000000000..bc27df6c6 --- /dev/null +++ b/common/paths/path_test.go @@ -0,0 +1,313 @@ +// Copyright 2024 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 paths + +import ( + "path/filepath" + "testing" + + 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 + } + + data := []test{ + {"/abc/bcd/ab.css", "/abc/bcd", "/bbc/bcd", "/ab.css"}, + {"/abc/bcd/ab.css", "/abcd/bcd", "/abc/bcd", "/ab.css"}, + } + + for i, d := range data { + output, _ := makePathRelative(d.inPath, d.path1, d.path2) + if d.output != output { + t.Errorf("Test #%d failed. Expected %q got %q", i, d.output, output) + } + } + _, error := makePathRelative("a/b/c.ss", "/a/c", "/d/c", "/e/f") + + if error == nil { + t.Errorf("Test failed, expected error") + } +} + +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") +} + +func TestFilename(t *testing.T) { + type test struct { + input, expected string + } + data := []test{ + {"index.html", "index"}, + {"./index.html", "index"}, + {"/index.html", "index"}, + {"index", "index"}, + {"/tmp/index.html", "index"}, + {"./filename-no-ext", "filename-no-ext"}, + {"/filename-no-ext", "filename-no-ext"}, + {"filename-no-ext", "filename-no-ext"}, + {"directory/", ""}, // no filename case?? + {"directory/.hidden.ext", ".hidden"}, + {"./directory/../~/banana/gold.fish", "gold"}, + {"../directory/banana.man", "banana"}, + {"~/mydir/filename.ext", "filename"}, + {"./directory//tmp/filename.ext", "filename"}, + } + + for i, d := range data { + output := Filename(filepath.FromSlash(d.input)) + if d.expected != output { + t.Errorf("Test %d failed. Expected %q got %q", i, d.expected, output) + } + } +} + +func TestFileAndExt(t *testing.T) { + type test struct { + input, expectedFile, expectedExt string + } + data := []test{ + {"index.html", "index", ".html"}, + {"./index.html", "index", ".html"}, + {"/index.html", "index", ".html"}, + {"index", "index", ""}, + {"/tmp/index.html", "index", ".html"}, + {"./filename-no-ext", "filename-no-ext", ""}, + {"/filename-no-ext", "filename-no-ext", ""}, + {"filename-no-ext", "filename-no-ext", ""}, + {"directory/", "", ""}, // no filename case?? + {"directory/.hidden.ext", ".hidden", ".ext"}, + {"./directory/../~/banana/gold.fish", "gold", ".fish"}, + {"../directory/banana.man", "banana", ".man"}, + {"~/mydir/filename.ext", "filename", ".ext"}, + {"./directory//tmp/filename.ext", "filename", ".ext"}, + } + + for i, d := range data { + file, ext := fileAndExt(filepath.FromSlash(d.input), fpb) + if d.expectedFile != file { + t.Errorf("Test %d failed. Expected filename %q got %q.", i, d.expectedFile, file) + } + if d.expectedExt != ext { + t.Errorf("Test %d failed. Expected extension %q got %q.", i, d.expectedExt, ext) + } + } +} + +func TestSanitize(t *testing.T) { + c := qt.New(t) + tests := []struct { + input string + expected string + }{ + {" Foo bar ", "Foo-bar"}, + {"Foo.Bar/foo_Bar-Foo", "Foo.Bar/foo_Bar-Foo"}, + {"fOO,bar:foobAR", "fOObarfoobAR"}, + {"FOo/BaR.html", "FOo/BaR.html"}, + {"FOo/Ba---R.html", "FOo/Ba---R.html"}, /// See #10104 + {"FOo/Ba R.html", "FOo/Ba-R.html"}, + {"трям/трям", "трям/трям"}, + {"은행", "은행"}, + {"Банковский кассир", "Банковский-кассир"}, + // Issue #1488 + {"संस्कृत", "संस्कृत"}, + {"a%C3%B1ame", "a%C3%B1ame"}, // Issue #1292 + {"this+is+a+test", "this+is+a+test"}, // Issue #1290 + {"~foo", "~foo"}, // Issue #2177 + + } + + for _, test := range tests { + c.Assert(Sanitize(test.input), qt.Equals, test.expected) + } +} + +func BenchmarkSanitize(b *testing.B) { + const ( + allAlowedPath = "foo/bar" + spacePath = "foo bar" + ) + + // This should not allocate any memory. + b.Run("All allowed", func(b *testing.B) { + for i := 0; i < b.N; i++ { + got := Sanitize(allAlowedPath) + if got != allAlowedPath { + b.Fatal(got) + } + } + }) + + // This will allocate some memory. + b.Run("Spaces", func(b *testing.B) { + for i := 0; i < b.N; i++ { + got := Sanitize(spacePath) + if got != "foo-bar" { + b.Fatal(got) + } + } + }) +} + +func TestDir(t *testing.T) { + c := qt.New(t) + c.Assert(Dir("/a/b/c/d"), qt.Equals, "/a/b/c") + c.Assert(Dir("/a"), qt.Equals, "/") + c.Assert(Dir("/"), qt.Equals, "/") + c.Assert(Dir(""), qt.Equals, "") +} + +func TestFieldsSlash(t *testing.T) { + c := qt.New(t) + + c.Assert(FieldsSlash("a/b/c"), qt.DeepEquals, []string{"a", "b", "c"}) + c.Assert(FieldsSlash("/a/b/c"), qt.DeepEquals, []string{"a", "b", "c"}) + c.Assert(FieldsSlash("/a/b/c/"), qt.DeepEquals, []string{"a", "b", "c"}) + c.Assert(FieldsSlash("a/b/c/"), qt.DeepEquals, []string{"a", "b", "c"}) + c.Assert(FieldsSlash("/"), qt.DeepEquals, []string{}) + 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) + + for _, this := range []struct { + a, b string + expected bool + }{ + {"/a/b/c", "/a/b/c", true}, + {"/a/b/c", "/a/b/c/", true}, + {"/a/b/c", "/a/b/d", false}, + {"/a/b/c", "/a/b", false}, + {"/a/b/c", "/a/b/c/d", false}, + {"/a/b/c", "/a/b/cd", false}, + {"/a/b/c", "/a/b/cc", false}, + {"/a/b/c", "/a/b/c/", true}, + {"/a/b/c", "/a/b/c//", true}, + {"/a/b/c", "/a/b/c/.", true}, + {"/a/b/c", "/a/b/c/./", true}, + {"/a/b/c", "/a/b/c/./.", true}, + {"/a/b/c", "/a/b/c/././", true}, + {"/a/b/c", "/a/b/c/././.", true}, + {"/a/b/c", "/a/b/c/./././", true}, + {"/a/b/c", "/a/b/c/./././.", true}, + {"/a/b/c", "/a/b/c/././././", true}, + } { + c.Assert(IsSameFilePath(filepath.FromSlash(this.a), filepath.FromSlash(this.b)), qt.Equals, this.expected, qt.Commentf("a: %s b: %s", this.a, this.b)) + } +} diff --git a/common/paths/pathparser.go b/common/paths/pathparser.go new file mode 100644 index 000000000..8b9259bf7 --- /dev/null +++ b/common/paths/pathparser.go @@ -0,0 +1,788 @@ +// Copyright 2024 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 paths + +import ( + "path" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/gohugoio/hugo/common/types" + "github.com/gohugoio/hugo/hugofs/files" + "github.com/gohugoio/hugo/identity" + "github.com/gohugoio/hugo/resources/kinds" +) + +const ( + identifierBaseof = "baseof" +) + +// PathParser parses a path into a Path. +type PathParser struct { + // Maps the language code to its index in the languages/sites slice. + LanguageIndex map[string]int + + // Reports whether the given language is disabled. + IsLangDisabled func(string) bool + + // IsOutputFormat reports whether the given name is a valid output format. + // The second argument is optional. + IsOutputFormat func(name, ext string) bool + + // Reports whether the given ext is a content file. + IsContentExt func(string) bool +} + +// NormalizePathString returns a normalized path string using the very basic Hugo rules. +func NormalizePathStringBasic(s string) string { + // All lower case. + s = strings.ToLower(s) + + // Replace spaces with hyphens. + s = strings.ReplaceAll(s, " ", "-") + + return s +} + +// 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) + defer putPath(p) + return p.Base(), p.BaseNameNoIdentifier() +} + +func (pp *PathParser) parsePooled(c, s string) *Path { + s = NormalizePathStringBasic(s) + p := getPath() + p.component = c + p, err := pp.doParse(c, s, p) + if err != nil { + panic(err) + } + return p +} + +// Parse parses component c with path s into Path using Hugo's content path rules. +func (pp *PathParser) Parse(c, s string) *Path { + p, err := pp.parse(c, s) + if err != nil { + panic(err) + } + return p +} + +func (pp *PathParser) newPath(component string) *Path { + p := &Path{} + p.reset() + p.component = component + return p +} + +func (pp *PathParser) parse(component, s string) (*Path, error) { + ss := NormalizePathStringBasic(s) + + p, err := pp.doParse(component, ss, pp.newPath(component)) + if err != nil { + return nil, err + } + + if s != ss { + var err error + // Preserve the original case for titles etc. + p.unnormalized, err = pp.doParse(component, s, pp.newPath(component)) + if err != nil { + return nil, err + } + } else { + p.unnormalized = p + } + + return p, nil +} + +func (pp *PathParser) parseIdentifier(component, s string, p *Path, i, lastDot, numDots int, isLast bool) { + if p.posContainerHigh != -1 { + return + } + mayHaveLang := numDots > 1 && p.posIdentifierLanguage == -1 && pp.LanguageIndex != nil + mayHaveLang = mayHaveLang && (component == files.ComponentFolderContent || component == files.ComponentFolderLayouts) + mayHaveOutputFormat := component == files.ComponentFolderLayouts + mayHaveKind := p.posIdentifierKind == -1 && mayHaveOutputFormat + var mayHaveLayout bool + if p.pathType == TypeShortcode { + mayHaveLayout = !isLast && component == files.ComponentFolderLayouts + } else { + mayHaveLayout = component == files.ComponentFolderLayouts + } + + var found bool + var high int + if len(p.identifiersKnown) > 0 { + high = lastDot + } else { + high = len(p.s) + } + id := types.LowHigh[string]{Low: i + 1, High: high} + sid := p.s[id.Low:id.High] + + if len(p.identifiersKnown) == 0 { + // The first is always the extension. + p.identifiersKnown = append(p.identifiersKnown, id) + found = true + + // May also be the output format. + if mayHaveOutputFormat && pp.IsOutputFormat(sid, "") { + p.posIdentifierOutputFormat = 0 + } + } else { + + var langFound bool + + if mayHaveLang { + var disabled bool + _, langFound = pp.LanguageIndex[sid] + if !langFound { + disabled = pp.IsLangDisabled != nil && pp.IsLangDisabled(sid) + if disabled { + p.disabled = true + langFound = true + } + } + found = langFound + if langFound { + p.identifiersKnown = append(p.identifiersKnown, id) + p.posIdentifierLanguage = len(p.identifiersKnown) - 1 + } + } + + if !found && mayHaveOutputFormat { + // At this point we may already have resolved an output format, + // but we need to keep looking for a more specific one, e.g. amp before html. + // Use both name and extension to prevent + // false positives on the form css.html. + if pp.IsOutputFormat(sid, p.Ext()) { + found = true + p.identifiersKnown = append(p.identifiersKnown, id) + p.posIdentifierOutputFormat = len(p.identifiersKnown) - 1 + } + } + + if !found && mayHaveKind { + if kinds.GetKindMain(sid) != "" { + found = true + p.identifiersKnown = append(p.identifiersKnown, id) + p.posIdentifierKind = len(p.identifiersKnown) - 1 + } + } + + if !found && sid == identifierBaseof { + found = true + p.identifiersKnown = append(p.identifiersKnown, id) + p.posIdentifierBaseof = len(p.identifiersKnown) - 1 + } + + if !found && mayHaveLayout { + p.identifiersKnown = append(p.identifiersKnown, id) + p.posIdentifierLayout = len(p.identifiersKnown) - 1 + found = true + } + + if !found { + p.identifiersUnknown = append(p.identifiersUnknown, id) + } + + } +} + +func (pp *PathParser) doParse(component, s string, p *Path) (*Path, error) { + if runtime.GOOS == "windows" { + s = path.Clean(filepath.ToSlash(s)) + if s == "." { + s = "" + } + } + + if s == "" { + s = "/" + } + + // Leading slash, no trailing slash. + if !strings.HasPrefix(s, "/") { + s = "/" + s + } + + if s != "/" && s[len(s)-1] == '/' { + s = s[:len(s)-1] + } + + p.s = s + slashCount := 0 + lastDot := 0 + lastSlashIdx := strings.LastIndex(s, "/") + numDots := strings.Count(s[lastSlashIdx+1:], ".") + if strings.Contains(s, "/_shortcodes/") { + p.pathType = TypeShortcode + } + + for i := len(s) - 1; i >= 0; i-- { + c := s[i] + + switch c { + case '.': + pp.parseIdentifier(component, s, p, i, lastDot, numDots, false) + lastDot = i + case '/': + slashCount++ + if p.posContainerHigh == -1 { + if lastDot > 0 { + pp.parseIdentifier(component, s, p, i, lastDot, numDots, true) + } + p.posContainerHigh = i + 1 + } else if p.posContainerLow == -1 { + p.posContainerLow = i + 1 + } + if i > 0 { + p.posSectionHigh = i + } + } + } + + 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 isContent { + switch b { + case "index": + p.pathType = TypeLeaf + case "_index": + p.pathType = TypeBranch + default: + p.pathType = TypeContentSingle + } + + if slashCount == 2 && p.IsLeafBundle() { + p.posSectionHigh = 0 + } + } else if b == files.NameContentData && files.IsContentDataExt(p.Ext()) { + p.pathType = TypeContentData + } + } + } + + if p.pathType < TypeMarkup && component == files.ComponentFolderLayouts { + if p.posIdentifierBaseof != -1 { + p.pathType = TypeBaseof + } else { + pth := p.Path() + if strings.Contains(pth, "/_shortcodes/") { + p.pathType = TypeShortcode + } else if strings.Contains(pth, "/_markup/") { + p.pathType = TypeMarkup + } else if strings.HasPrefix(pth, "/_partials/") { + p.pathType = TypePartial + } + } + } + + if p.pathType == TypeShortcode && p.posIdentifierLayout != -1 { + id := p.identifiersKnown[p.posIdentifierLayout] + if id.Low == p.posContainerHigh { + // First identifier is shortcode name. + p.posIdentifierLayout = -1 + } + } + + return p, nil +} + +func ModifyPathBundleTypeResource(p *Path) { + if p.IsContent() { + p.pathType = TypeContentResource + } else { + p.pathType = TypeFile + } +} + +//go:generate stringer -type Type + +type Type int + +const ( + + // A generic resource, e.g. a JSON file. + TypeFile Type = iota + + // All below are content files. + // A resource of a content type with front matter. + TypeContentResource + + // E.g. /blog/my-post.md + TypeContentSingle + + // All below are bundled content files. + + // Leaf bundles, e.g. /blog/my-post/index.md + TypeLeaf + + // Branch bundles, e.g. /blog/_index.md + TypeBranch + + // Content data file, _content.gotmpl. + TypeContentData + + // Layout types. + TypeMarkup + TypeShortcode + TypePartial + TypeBaseof +) + +type Path struct { + // Note: Any additions to this struct should also be added to the pathPool. + s string + + posContainerLow int + posContainerHigh int + posSectionHigh int + + component string + pathType Type + + identifiersKnown []types.LowHigh[string] + identifiersUnknown []types.LowHigh[string] + + posIdentifierLanguage int + posIdentifierOutputFormat int + posIdentifierKind int + posIdentifierLayout int + posIdentifierBaseof int + disabled bool + + trimLeadingSlash bool + + unnormalized *Path +} + +var pathPool = &sync.Pool{ + New: func() any { + p := &Path{} + p.reset() + return p + }, +} + +func getPath() *Path { + return pathPool.Get().(*Path) +} + +func putPath(p *Path) { + p.reset() + pathPool.Put(p) +} + +func (p *Path) reset() { + p.s = "" + p.posContainerLow = -1 + p.posContainerHigh = -1 + p.posSectionHigh = -1 + p.component = "" + p.pathType = 0 + p.identifiersKnown = p.identifiersKnown[:0] + p.posIdentifierLanguage = -1 + p.posIdentifierOutputFormat = -1 + p.posIdentifierKind = -1 + p.posIdentifierLayout = -1 + p.posIdentifierBaseof = -1 + p.disabled = false + p.trimLeadingSlash = false + p.unnormalized = nil +} + +// TrimLeadingSlash returns a copy of the Path with the leading slash removed. +func (p Path) TrimLeadingSlash() *Path { + p.trimLeadingSlash = true + return &p +} + +func (p *Path) norm(s string) string { + if p.trimLeadingSlash { + s = strings.TrimPrefix(s, "/") + } + return s +} + +// IdentifierBase satisfies identity.Identity. +func (p *Path) IdentifierBase() string { + if p.Component() == files.ComponentFolderLayouts { + return p.Path() + } + return p.Base() +} + +// Component returns the component for this path (e.g. "content"). +func (p *Path) Component() string { + return p.component +} + +// Container returns the base name of the container directory for this path. +func (p *Path) Container() string { + if p.posContainerLow == -1 { + return "" + } + return p.norm(p.s[p.posContainerLow : p.posContainerHigh-1]) +} + +func (p *Path) String() string { + if p == nil { + return "" + } + return p.Path() +} + +// ContainerDir returns the container directory for this path. +// For content bundles this will be the parent directory. +func (p *Path) ContainerDir() string { + if p.posContainerLow == -1 || !p.IsBundle() { + return p.Dir() + } + return p.norm(p.s[:p.posContainerLow-1]) +} + +// Section returns the first path element (section). +func (p *Path) Section() string { + if p.posSectionHigh <= 0 { + return "" + } + return p.norm(p.s[1:p.posSectionHigh]) +} + +// IsContent returns true if the path is a content file (e.g. mypost.md). +// Note that this will also return true for content files in a bundle. +func (p *Path) IsContent() bool { + return p.Type() >= TypeContentResource && p.Type() <= TypeContentData +} + +// isContentPage returns true if the path is a content file (e.g. mypost.md), +// but nof if inside a leaf bundle. +func (p *Path) isContentPage() bool { + return p.Type() >= TypeContentSingle && p.Type() <= TypeContentData +} + +// Name returns the last element of path. +func (p *Path) Name() string { + if p.posContainerHigh > 0 { + return p.s[p.posContainerHigh:] + } + return p.s +} + +// Name returns the last element of path without any extension. +func (p *Path) NameNoExt() string { + if i := p.identifierIndex(0); i != -1 { + return p.s[p.posContainerHigh : p.identifiersKnown[i].Low-1] + } + 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 { + if p.IsBundle() { + return p.Container() + } + return p.NameNoIdentifier() +} + +// NameNoIdentifier returns the last element of path without any identifier (e.g. no extension). +func (p *Path) NameNoIdentifier() string { + lowHigh := p.nameLowHigh() + return p.s[lowHigh.Low:lowHigh.High] +} + +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), + } +} + +// Dir returns all but the last element of path, typically the path's directory. +func (p *Path) Dir() (d string) { + if p.posContainerHigh > 0 { + d = p.s[:p.posContainerHigh-1] + } + if d == "" { + d = "/" + } + d = p.norm(d) + return +} + +// Path returns the full path. +func (p *Path) Path() (d string) { + return p.norm(p.s) +} + +// PathNoLeadingSlash returns the full path without the leading slash. +func (p *Path) PathNoLeadingSlash() string { + return p.Path()[1:] +} + +// Unnormalized returns the Path with the original case preserved. +func (p *Path) Unnormalized() *Path { + return p.unnormalized +} + +// PathNoLang returns the Path but with any language identifier removed. +func (p *Path) PathNoLang() string { + return p.base(true, false) +} + +// PathNoIdentifier returns the Path but with any identifier (ext, lang) removed. +func (p *Path) PathNoIdentifier() string { + return p.base(false, false) +} + +// PathBeforeLangAndOutputFormatAndExt returns the path up to the first identifier that is not a language or output format. +func (p *Path) PathBeforeLangAndOutputFormatAndExt() string { + if len(p.identifiersKnown) == 0 { + return p.norm(p.s) + } + i := p.identifierIndex(0) + + if j := p.posIdentifierOutputFormat; i == -1 || (j != -1 && j < i) { + i = j + } + if j := p.posIdentifierLanguage; i == -1 || (j != -1 && j < i) { + i = j + } + + if i == -1 { + return p.norm(p.s) + } + + id := p.identifiersKnown[i] + return p.norm(p.s[:id.Low-1]) +} + +// PathRel returns the path relative to the given owner. +func (p *Path) PathRel(owner *Path) string { + ob := owner.Base() + if !strings.HasSuffix(ob, "/") { + ob += "/" + } + return strings.TrimPrefix(p.Path(), ob) +} + +// BaseRel returns the base path relative to the given owner. +func (p *Path) BaseRel(owner *Path) string { + ob := owner.Base() + if ob == "/" { + ob = "" + } + return p.Base()[len(ob)+1:] +} + +// For content files, Base returns the path without any identifiers (extension, language code etc.). +// Any 'index' as the last path element is ignored. +// +// For other files (Resources), any extension is kept. +func (p *Path) Base() string { + return p.base(!p.isContentPage(), p.IsBundle()) +} + +// Used in template lookups. +// For pages with Type set, we treat that as the section. +func (p *Path) BaseReTyped(typ string) (d string) { + base := p.Base() + if p.Section() == typ { + return base + } + d = "/" + typ + if p.posSectionHigh != -1 { + d += base[p.posSectionHigh:] + } + d = p.norm(d) + 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) + } + + if preserveExt && len(p.identifiersKnown) == 1 { + // Preserve extension. + return p.norm(p.s) + } + + var high int + + if isBundle { + high = p.posContainerHigh - 1 + } else { + high = p.nameLowHigh().High + } + + if high == 0 { + high++ + } + + if !preserveExt { + return p.norm(p.s[:high]) + } + + // For txt files etc. we want to preserve the extension. + id := p.identifiersKnown[0] + + return p.norm(p.s[:high] + p.s[id.Low-1:id.High]) +} + +func (p *Path) Ext() string { + return p.identifierAsString(0) +} + +func (p *Path) OutputFormat() string { + return p.identifierAsString(p.posIdentifierOutputFormat) +} + +func (p *Path) Kind() string { + return p.identifierAsString(p.posIdentifierKind) +} + +func (p *Path) Layout() string { + return p.identifierAsString(p.posIdentifierLayout) +} + +func (p *Path) Lang() string { + return p.identifierAsString(p.posIdentifierLanguage) +} + +func (p *Path) Identifier(i int) string { + return p.identifierAsString(i) +} + +func (p *Path) Disabled() bool { + return p.disabled +} + +func (p *Path) Identifiers() []string { + ids := make([]string, len(p.identifiersKnown)) + for i, id := range p.identifiersKnown { + ids[i] = p.s[id.Low:id.High] + } + return ids +} + +func (p *Path) IdentifiersUnknown() []string { + ids := make([]string, len(p.identifiersUnknown)) + for i, id := range p.identifiersUnknown { + ids[i] = p.s[id.Low:id.High] + } + return ids +} + +func (p *Path) Type() Type { + return p.pathType +} + +func (p *Path) IsBundle() bool { + return p.pathType >= TypeLeaf && p.pathType <= TypeContentData +} + +func (p *Path) IsBranchBundle() bool { + return p.pathType == TypeBranch +} + +func (p *Path) IsLeafBundle() bool { + return p.pathType == TypeLeaf +} + +func (p *Path) IsContentData() bool { + return p.pathType == TypeContentData +} + +func (p Path) ForType(t Type) *Path { + p.pathType = t + return &p +} + +func (p *Path) identifierAsString(i int) string { + i = p.identifierIndex(i) + if i == -1 { + return "" + } + + id := p.identifiersKnown[i] + return p.s[id.Low:id.High] +} + +func (p *Path) identifierIndex(i int) int { + if i < 0 || i >= len(p.identifiersKnown) { + return -1 + } + return i +} + +// HasExt returns true if the Unix styled path has an extension. +func HasExt(p string) bool { + for i := len(p) - 1; i >= 0; i-- { + if p[i] == '.' { + return true + } + if p[i] == '/' { + return false + } + } + return false +} diff --git a/common/paths/pathparser_test.go b/common/paths/pathparser_test.go new file mode 100644 index 000000000..b1734aef2 --- /dev/null +++ b/common/paths/pathparser_test.go @@ -0,0 +1,611 @@ +// Copyright 2024 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 paths + +import ( + "path/filepath" + "testing" + + "github.com/gohugoio/hugo/hugofs/files" + "github.com/gohugoio/hugo/resources/kinds" + + qt "github.com/frankban/quicktest" +) + +var testParser = &PathParser{ + LanguageIndex: map[string]int{ + "no": 0, + "en": 1, + "fr": 2, + }, + IsContentExt: func(ext string) bool { + return ext == "md" + }, + IsOutputFormat: func(name, ext string) bool { + switch name { + case "html", "amp", "csv", "rss": + return true + } + return false + }, +} + +func TestParse(t *testing.T) { + c := qt.New(t) + + tests := []struct { + name string + path string + assert func(c *qt.C, p *Path) + }{ + { + "Basic text file", + "/a/b.txt", + func(c *qt.C, p *Path) { + c.Assert(p.Name(), qt.Equals, "b.txt") + c.Assert(p.Base(), qt.Equals, "/a/b.txt") + c.Assert(p.Container(), qt.Equals, "a") + c.Assert(p.Dir(), qt.Equals, "/a") + c.Assert(p.Ext(), qt.Equals, "txt") + c.Assert(p.IsContent(), qt.IsFalse) + }, + }, + { + "Basic text file, upper case", + "/A/B.txt", + func(c *qt.C, p *Path) { + c.Assert(p.Name(), qt.Equals, "b.txt") + c.Assert(p.NameNoExt(), qt.Equals, "b") + c.Assert(p.NameNoIdentifier(), qt.Equals, "b") + c.Assert(p.BaseNameNoIdentifier(), qt.Equals, "b") + c.Assert(p.Base(), qt.Equals, "/a/b.txt") + c.Assert(p.Ext(), qt.Equals, "txt") + }, + }, + { + "Basic text file, 1 space in dir", + "/a b/c.txt", + func(c *qt.C, p *Path) { + c.Assert(p.Base(), qt.Equals, "/a-b/c.txt") + }, + }, + { + "Basic text file, 2 spaces in dir", + "/a b/c.txt", + func(c *qt.C, p *Path) { + c.Assert(p.Base(), qt.Equals, "/a--b/c.txt") + }, + }, + { + "Basic text file, 1 space in filename", + "/a/b c.txt", + func(c *qt.C, p *Path) { + c.Assert(p.Base(), qt.Equals, "/a/b-c.txt") + }, + }, + { + "Basic text file, 2 spaces in filename", + "/a/b c.txt", + func(c *qt.C, p *Path) { + c.Assert(p.Base(), qt.Equals, "/a/b--c.txt") + }, + }, + { + "Basic text file, mixed case and spaces, unnormalized", + "/a/Foo BAR.txt", + func(c *qt.C, p *Path) { + pp := p.Unnormalized() + c.Assert(pp, qt.IsNotNil) + c.Assert(pp.BaseNameNoIdentifier(), qt.Equals, "Foo BAR") + }, + }, + { + "Basic Markdown file", + "/a/b/c.md", + func(c *qt.C, p *Path) { + c.Assert(p.Ext(), qt.Equals, "md") + c.Assert(p.Type(), qt.Equals, TypeContentSingle) + c.Assert(p.IsContent(), qt.IsTrue) + c.Assert(p.IsLeafBundle(), qt.IsFalse) + c.Assert(p.Name(), qt.Equals, "c.md") + c.Assert(p.Base(), qt.Equals, "/a/b/c") + c.Assert(p.BaseReTyped("foo"), qt.Equals, "/foo/b/c") + c.Assert(p.Section(), qt.Equals, "a") + c.Assert(p.BaseNameNoIdentifier(), qt.Equals, "c") + c.Assert(p.Path(), qt.Equals, "/a/b/c.md") + c.Assert(p.Dir(), qt.Equals, "/a/b") + c.Assert(p.Container(), qt.Equals, "b") + c.Assert(p.ContainerDir(), qt.Equals, "/a/b") + }, + }, + { + "Content resource", + "/a/b.md", + 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") + + // Reclassify it as a content resource. + ModifyPathBundleTypeResource(p) + c.Assert(p.Type(), qt.Equals, TypeContentResource) + c.Assert(p.IsContent(), qt.IsTrue) + c.Assert(p.Name(), qt.Equals, "b.md") + c.Assert(p.Base(), qt.Equals, "/a/b.md") + }, + }, + { + "No ext", + "/a/b", + func(c *qt.C, p *Path) { + c.Assert(p.Name(), qt.Equals, "b") + c.Assert(p.NameNoExt(), qt.Equals, "b") + c.Assert(p.Base(), qt.Equals, "/a/b") + c.Assert(p.Ext(), qt.Equals, "") + }, + }, + { + "No ext, trailing slash", + "/a/b/", + func(c *qt.C, p *Path) { + c.Assert(p.Name(), qt.Equals, "b") + c.Assert(p.Base(), qt.Equals, "/a/b") + c.Assert(p.Ext(), qt.Equals, "") + }, + }, + { + "Identifiers", + "/a/b.a.b.no.txt", + 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") + c.Assert(p.PathNoIdentifier(), qt.Equals, "/a/b.a.b") + }, + }, + { + "Home branch cundle", + "/_index.md", + func(c *qt.C, p *Path) { + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"md"}) + c.Assert(p.IsBranchBundle(), qt.IsTrue) + c.Assert(p.IsBundle(), qt.IsTrue) + c.Assert(p.Base(), qt.Equals, "/") + c.Assert(p.BaseReTyped("foo"), qt.Equals, "/foo") + c.Assert(p.Path(), qt.Equals, "/_index.md") + c.Assert(p.Container(), qt.Equals, "") + c.Assert(p.ContainerDir(), qt.Equals, "/") + }, + }, + { + "Index content file in root", + "/a/index.md", + func(c *qt.C, p *Path) { + c.Assert(p.Base(), qt.Equals, "/a") + c.Assert(p.BaseReTyped("foo"), qt.Equals, "/foo/a") + c.Assert(p.BaseNameNoIdentifier(), qt.Equals, "a") + c.Assert(p.Container(), qt.Equals, "a") + c.Assert(p.Container(), qt.Equals, "a") + c.Assert(p.ContainerDir(), qt.Equals, "") + c.Assert(p.Dir(), qt.Equals, "/a") + c.Assert(p.Ext(), qt.Equals, "md") + c.Assert(p.IdentifiersUnknown(), qt.DeepEquals, []string{"index"}) + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"md"}) + c.Assert(p.IsBranchBundle(), qt.IsFalse) + c.Assert(p.IsBundle(), qt.IsTrue) + c.Assert(p.IsLeafBundle(), qt.IsTrue) + 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, "") + }, + }, + { + "Index content file with lang", + "/a/b/index.no.md", + func(c *qt.C, p *Path) { + c.Assert(p.Base(), qt.Equals, "/a/b") + c.Assert(p.BaseNameNoIdentifier(), qt.Equals, "b") + c.Assert(p.BaseReTyped("foo"), qt.Equals, "/foo/b") + c.Assert(p.Container(), qt.Equals, "b") + c.Assert(p.ContainerDir(), qt.Equals, "/a") + c.Assert(p.Dir(), qt.Equals, "/a/b") + c.Assert(p.Ext(), qt.Equals, "md") + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"md", "no"}) + c.Assert(p.IsBranchBundle(), qt.IsFalse) + c.Assert(p.IsBundle(), qt.IsTrue) + c.Assert(p.IsLeafBundle(), qt.IsTrue) + 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") + }, + }, + { + "Index branch content file", + "/a/b/_index.no.md", + func(c *qt.C, p *Path) { + c.Assert(p.Base(), qt.Equals, "/a/b") + c.Assert(p.BaseNameNoIdentifier(), qt.Equals, "b") + c.Assert(p.Container(), qt.Equals, "b") + c.Assert(p.ContainerDir(), qt.Equals, "/a") + c.Assert(p.Ext(), qt.Equals, "md") + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"md", "no"}) + c.Assert(p.IsBranchBundle(), qt.IsTrue) + 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") + }, + }, + { + "Index root no slash", + "_index.md", + func(c *qt.C, p *Path) { + c.Assert(p.Base(), qt.Equals, "/") + c.Assert(p.Ext(), qt.Equals, "md") + c.Assert(p.Name(), qt.Equals, "_index.md") + }, + }, + { + "Index root", + "/_index.md", + func(c *qt.C, p *Path) { + c.Assert(p.Base(), qt.Equals, "/") + c.Assert(p.Ext(), qt.Equals, "md") + c.Assert(p.Name(), qt.Equals, "_index.md") + }, + }, + { + "Index first", + "/a/_index.md", + func(c *qt.C, p *Path) { + c.Assert(p.Section(), qt.Equals, "a") + }, + }, + { + "Index text file", + "/a/b/index.no.txt", + func(c *qt.C, p *Path) { + c.Assert(p.Base(), qt.Equals, "/a/b/index.txt") + c.Assert(p.Ext(), qt.Equals, "txt") + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"txt", "no"}) + c.Assert(p.IsLeafBundle(), qt.IsFalse) + c.Assert(p.PathNoIdentifier(), qt.Equals, "/a/b/index") + }, + }, + { + "Empty", + "", + func(c *qt.C, p *Path) { + c.Assert(p.Base(), qt.Equals, "/") + c.Assert(p.Ext(), qt.Equals, "") + c.Assert(p.Name(), qt.Equals, "") + c.Assert(p.Path(), qt.Equals, "/") + }, + }, + { + "Slash", + "/", + func(c *qt.C, p *Path) { + c.Assert(p.Base(), qt.Equals, "/") + c.Assert(p.Ext(), qt.Equals, "") + c.Assert(p.Name(), qt.Equals, "") + }, + }, + { + "Trim Leading Slash bundle", + "foo/bar/index.no.md", + func(c *qt.C, p *Path) { + c.Assert(p.Path(), qt.Equals, "/foo/bar/index.no.md") + pp := p.TrimLeadingSlash() + c.Assert(pp.Path(), qt.Equals, "foo/bar/index.no.md") + c.Assert(pp.PathNoLang(), qt.Equals, "foo/bar/index.md") + c.Assert(pp.Base(), qt.Equals, "foo/bar") + c.Assert(pp.Dir(), qt.Equals, "foo/bar") + c.Assert(pp.ContainerDir(), qt.Equals, "foo") + c.Assert(pp.Container(), qt.Equals, "bar") + c.Assert(pp.BaseNameNoIdentifier(), qt.Equals, "bar") + }, + }, + { + "Trim Leading Slash file", + "foo/bar.txt", + func(c *qt.C, p *Path) { + c.Assert(p.Path(), qt.Equals, "/foo/bar.txt") + pp := p.TrimLeadingSlash() + c.Assert(pp.Path(), qt.Equals, "foo/bar.txt") + c.Assert(pp.PathNoLang(), qt.Equals, "foo/bar.txt") + c.Assert(pp.Base(), qt.Equals, "foo/bar.txt") + c.Assert(pp.Dir(), qt.Equals, "foo") + c.Assert(pp.ContainerDir(), qt.Equals, "foo") + c.Assert(pp.Container(), qt.Equals, "foo") + c.Assert(pp.BaseNameNoIdentifier(), qt.Equals, "bar") + }, + }, + { + "File separator", + filepath.FromSlash("/a/b/c.txt"), + func(c *qt.C, p *Path) { + c.Assert(p.Base(), qt.Equals, "/a/b/c.txt") + c.Assert(p.Ext(), qt.Equals, "txt") + c.Assert(p.Name(), qt.Equals, "c.txt") + c.Assert(p.Path(), qt.Equals, "/a/b/c.txt") + }, + }, + { + "Content data file gotmpl", + "/a/b/_content.gotmpl", + func(c *qt.C, p *Path) { + c.Assert(p.Path(), qt.Equals, "/a/b/_content.gotmpl") + c.Assert(p.Ext(), qt.Equals, "gotmpl") + c.Assert(p.IsContentData(), qt.IsTrue) + }, + }, + { + "Content data file yaml", + "/a/b/_content.yaml", + func(c *qt.C, p *Path) { + c.Assert(p.IsContentData(), qt.IsFalse) + }, + }, + } + for _, test := range tests { + c.Run(test.name, func(c *qt.C) { + if test.name != "Home branch cundle" { + // return + } + test.assert(c, testParser.Parse(files.ComponentFolderContent, test.path)) + }) + } +} + +func TestParseLayouts(t *testing.T) { + c := qt.New(t) + + tests := []struct { + name string + path string + assert func(c *qt.C, p *Path) + }{ + { + "Basic", + "/list.html", + func(c *qt.C, p *Path) { + c.Assert(p.Base(), qt.Equals, "/list.html") + c.Assert(p.OutputFormat(), qt.Equals, "html") + }, + }, + { + "Lang", + "/list.no.html", + func(c *qt.C, p *Path) { + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"html", "no", "list"}) + c.Assert(p.IdentifiersUnknown(), qt.DeepEquals, []string{}) + c.Assert(p.Base(), qt.Equals, "/list.html") + c.Assert(p.Lang(), qt.Equals, "no") + }, + }, + { + "Kind", + "/section.no.html", + func(c *qt.C, p *Path) { + c.Assert(p.Kind(), qt.Equals, kinds.KindSection) + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"html", "no", "section"}) + c.Assert(p.IdentifiersUnknown(), qt.DeepEquals, []string{}) + c.Assert(p.Base(), qt.Equals, "/section.html") + c.Assert(p.Lang(), qt.Equals, "no") + }, + }, + { + "Layout", + "/list.section.no.html", + func(c *qt.C, p *Path) { + c.Assert(p.Layout(), qt.Equals, "list") + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"html", "no", "section", "list"}) + c.Assert(p.IdentifiersUnknown(), qt.DeepEquals, []string{}) + c.Assert(p.Base(), qt.Equals, "/list.html") + c.Assert(p.Lang(), qt.Equals, "no") + }, + }, + { + "Layout multiple", + "/mylayout.list.section.no.html", + func(c *qt.C, p *Path) { + c.Assert(p.Layout(), qt.Equals, "mylayout") + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"html", "no", "section", "list", "mylayout"}) + c.Assert(p.IdentifiersUnknown(), qt.DeepEquals, []string{}) + c.Assert(p.Base(), qt.Equals, "/mylayout.html") + c.Assert(p.Lang(), qt.Equals, "no") + }, + }, + { + "Layout shortcode", + "/_shortcodes/myshort.list.no.html", + func(c *qt.C, p *Path) { + c.Assert(p.Layout(), qt.Equals, "list") + }, + }, + { + "Layout baseof", + "/baseof.list.no.html", + func(c *qt.C, p *Path) { + c.Assert(p.Layout(), qt.Equals, "list") + }, + }, + { + "Lang and output format", + "/list.no.amp.not.html", + func(c *qt.C, p *Path) { + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"html", "not", "amp", "no", "list"}) + c.Assert(p.OutputFormat(), qt.Equals, "amp") + c.Assert(p.Ext(), qt.Equals, "html") + c.Assert(p.Lang(), qt.Equals, "no") + c.Assert(p.Base(), qt.Equals, "/list.html") + }, + }, + { + "Term", + "/term.html", + func(c *qt.C, p *Path) { + c.Assert(p.Base(), qt.Equals, "/term.html") + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"html", "term"}) + c.Assert(p.PathNoIdentifier(), qt.Equals, "/term") + c.Assert(p.PathBeforeLangAndOutputFormatAndExt(), qt.Equals, "/term") + c.Assert(p.Lang(), qt.Equals, "") + c.Assert(p.Kind(), qt.Equals, "term") + c.Assert(p.OutputFormat(), qt.Equals, "html") + }, + }, + { + "Shortcode with layout", + "/_shortcodes/myshortcode.list.html", + func(c *qt.C, p *Path) { + c.Assert(p.Base(), qt.Equals, "/_shortcodes/myshortcode.html") + c.Assert(p.Type(), qt.Equals, TypeShortcode) + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"html", "list"}) + c.Assert(p.Layout(), qt.Equals, "list") + c.Assert(p.PathNoIdentifier(), qt.Equals, "/_shortcodes/myshortcode") + c.Assert(p.PathBeforeLangAndOutputFormatAndExt(), qt.Equals, "/_shortcodes/myshortcode.list") + c.Assert(p.Lang(), qt.Equals, "") + c.Assert(p.Kind(), qt.Equals, "") + c.Assert(p.OutputFormat(), qt.Equals, "html") + }, + }, + { + "Sub dir", + "/pages/home.html", + func(c *qt.C, p *Path) { + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"html", "home"}) + c.Assert(p.Lang(), qt.Equals, "") + c.Assert(p.Kind(), qt.Equals, "home") + c.Assert(p.OutputFormat(), qt.Equals, "html") + c.Assert(p.Dir(), qt.Equals, "/pages") + }, + }, + { + "Baseof", + "/pages/baseof.list.section.fr.amp.html", + func(c *qt.C, p *Path) { + c.Assert(p.Identifiers(), qt.DeepEquals, []string{"html", "amp", "fr", "section", "list", "baseof"}) + c.Assert(p.IdentifiersUnknown(), qt.DeepEquals, []string{}) + c.Assert(p.Kind(), qt.Equals, kinds.KindSection) + c.Assert(p.Lang(), qt.Equals, "fr") + c.Assert(p.OutputFormat(), qt.Equals, "amp") + c.Assert(p.Dir(), qt.Equals, "/pages") + c.Assert(p.NameNoIdentifier(), qt.Equals, "baseof") + c.Assert(p.Type(), qt.Equals, TypeBaseof) + c.Assert(p.IdentifierBase(), qt.Equals, "/pages/baseof.list.section.fr.amp.html") + }, + }, + { + "Markup", + "/_markup/render-link.html", + func(c *qt.C, p *Path) { + c.Assert(p.Type(), qt.Equals, TypeMarkup) + }, + }, + { + "Markup nested", + "/foo/_markup/render-link.html", + func(c *qt.C, p *Path) { + c.Assert(p.Type(), qt.Equals, TypeMarkup) + }, + }, + { + "Shortcode", + "/_shortcodes/myshortcode.html", + func(c *qt.C, p *Path) { + c.Assert(p.Type(), qt.Equals, TypeShortcode) + }, + }, + { + "Shortcode nested", + "/foo/_shortcodes/myshortcode.html", + func(c *qt.C, p *Path) { + c.Assert(p.Type(), qt.Equals, TypeShortcode) + }, + }, + { + "Shortcode nested sub", + "/foo/_shortcodes/foo/myshortcode.html", + func(c *qt.C, p *Path) { + c.Assert(p.Type(), qt.Equals, TypeShortcode) + }, + }, + { + "Partials", + "/_partials/foo.bar", + func(c *qt.C, p *Path) { + c.Assert(p.Type(), qt.Equals, TypePartial) + }, + }, + { + "Shortcode lang in root", + "/_shortcodes/no.html", + func(c *qt.C, p *Path) { + c.Assert(p.Type(), qt.Equals, TypeShortcode) + c.Assert(p.Lang(), qt.Equals, "") + c.Assert(p.NameNoIdentifier(), qt.Equals, "no") + }, + }, + { + "Shortcode lang layout", + "/_shortcodes/myshortcode.no.html", + func(c *qt.C, p *Path) { + c.Assert(p.Type(), qt.Equals, TypeShortcode) + c.Assert(p.Lang(), qt.Equals, "no") + c.Assert(p.Layout(), qt.Equals, "") + c.Assert(p.NameNoIdentifier(), qt.Equals, "myshortcode") + }, + }, + } + + for _, test := range tests { + c.Run(test.name, func(c *qt.C) { + if test.name != "Shortcode lang layout" { + // return + } + test.assert(c, testParser.Parse(files.ComponentFolderLayouts, test.path)) + }) + } +} + +func TestHasExt(t *testing.T) { + c := qt.New(t) + + c.Assert(HasExt("/a/b/c.txt"), qt.IsTrue) + c.Assert(HasExt("/a/b.c/d.txt"), qt.IsTrue) + c.Assert(HasExt("/a/b/c"), qt.IsFalse) + c.Assert(HasExt("/a/b.c/d"), qt.IsFalse) +} + +func BenchmarkParseIdentity(b *testing.B) { + for i := 0; i < b.N; i++ { + testParser.ParseIdentity(files.ComponentFolderAssets, "/a/b.css") + } +} diff --git a/common/paths/paths_integration_test.go b/common/paths/paths_integration_test.go new file mode 100644 index 000000000..f5ea3066a --- /dev/null +++ b/common/paths/paths_integration_test.go @@ -0,0 +1,103 @@ +// Copyright 2024 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 paths_test + +import ( + "testing" + + "github.com/gohugoio/hugo/hugolib" +) + +func TestRemovePathAccents(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term"] +defaultContentLanguage = "en" +defaultContentLanguageInSubdir = true +[languages] +[languages.en] +weight = 1 +[languages.fr] +weight = 2 +removePathAccents = true +-- content/διακριτικός.md -- +-- content/διακριτικός.fr.md -- +-- layouts/_default/single.html -- +{{ .Language.Lang }}|Single. +-- layouts/_default/list.html -- +List +` + b := hugolib.Test(t, files) + + b.AssertFileContent("public/en/διακριτικός/index.html", "en|Single") + b.AssertFileContent("public/fr/διακριτικος/index.html", "fr|Single") +} + +func TestDisablePathToLower(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term"] +defaultContentLanguage = "en" +defaultContentLanguageInSubdir = true +[languages] +[languages.en] +weight = 1 +[languages.fr] +weight = 2 +disablePathToLower = true +-- content/MySection/MyPage.md -- +-- content/MySection/MyPage.fr.md -- +-- content/MySection/MyBundle/index.md -- +-- content/MySection/MyBundle/index.fr.md -- +-- layouts/_default/single.html -- +{{ .Language.Lang }}|Single. +-- layouts/_default/list.html -- +{{ .Language.Lang }}|List. +` + b := hugolib.Test(t, files) + + b.AssertFileContent("public/en/mysection/index.html", "en|List") + b.AssertFileContent("public/en/mysection/mypage/index.html", "en|Single") + b.AssertFileContent("public/fr/MySection/index.html", "fr|List") + b.AssertFileContent("public/fr/MySection/MyPage/index.html", "fr|Single") + b.AssertFileContent("public/en/mysection/mybundle/index.html", "en|Single") + b.AssertFileContent("public/fr/MySection/MyBundle/index.html", "fr|Single") +} + +func TestIssue13596(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['home','rss','section','sitemap','taxonomy','term'] +-- content/p1/index.md -- +--- +title: p1 +--- +-- content/p1/a.1.txt -- +-- content/p1/a.2.txt -- +-- layouts/all.html -- +{{ range .Resources.Match "*" }}{{ .Name }}|{{ end }} +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/p1/index.html", "a.1.txt|a.2.txt|") + b.AssertFileExists("public/p1/a.1.txt", true) + b.AssertFileExists("public/p1/a.2.txt", true) // fails +} diff --git a/common/paths/type_string.go b/common/paths/type_string.go new file mode 100644 index 000000000..08fbcc835 --- /dev/null +++ b/common/paths/type_string.go @@ -0,0 +1,32 @@ +// Code generated by "stringer -type Type"; DO NOT EDIT. + +package paths + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[TypeFile-0] + _ = x[TypeContentResource-1] + _ = x[TypeContentSingle-2] + _ = x[TypeLeaf-3] + _ = x[TypeBranch-4] + _ = x[TypeContentData-5] + _ = x[TypeMarkup-6] + _ = x[TypeShortcode-7] + _ = x[TypePartial-8] + _ = x[TypeBaseof-9] +} + +const _Type_name = "TypeFileTypeContentResourceTypeContentSingleTypeLeafTypeBranchTypeContentDataTypeMarkupTypeShortcodeTypePartialTypeBaseof" + +var _Type_index = [...]uint8{0, 8, 27, 44, 52, 62, 77, 87, 100, 111, 121} + +func (i Type) String() string { + if i < 0 || i >= Type(len(_Type_index)-1) { + return "Type(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _Type_name[_Type_index[i]:_Type_index[i+1]] +} diff --git a/common/paths/url.go b/common/paths/url.go new file mode 100644 index 000000000..1d1408b51 --- /dev/null +++ b/common/paths/url.go @@ -0,0 +1,273 @@ +// Copyright 2024 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 paths + +import ( + "fmt" + "net/url" + "path" + "path/filepath" + "runtime" + "strings" +) + +type pathBridge struct{} + +func (pathBridge) Base(in string) string { + return path.Base(in) +} + +func (pathBridge) Clean(in string) string { + return path.Clean(in) +} + +func (pathBridge) Dir(in string) string { + return path.Dir(in) +} + +func (pathBridge) Ext(in string) string { + return path.Ext(in) +} + +func (pathBridge) Join(elem ...string) string { + return path.Join(elem...) +} + +func (pathBridge) Separator() string { + return "/" +} + +var pb pathBridge + +// MakePermalink combines base URL with content path to create full URL paths. +// Example +// +// base: http://spf13.com/ +// path: post/how-i-blog +// result: http://spf13.com/post/how-i-blog +func MakePermalink(host, plink string) *url.URL { + base, err := url.Parse(host) + if err != nil { + panic(err) + } + + p, err := url.Parse(plink) + if err != nil { + panic(err) + } + + if p.Host != "" { + panic(fmt.Errorf("can't make permalink from absolute link %q", plink)) + } + + base.Path = path.Join(base.Path, p.Path) + base.Fragment = p.Fragment + base.RawQuery = p.RawQuery + + // path.Join will strip off the last /, so put it back if it was there. + hadTrailingSlash := (plink == "" && strings.HasSuffix(host, "/")) || strings.HasSuffix(p.Path, "/") + if hadTrailingSlash && !strings.HasSuffix(base.Path, "/") { + base.Path = base.Path + "/" + } + + return base +} + +// AddContextRoot adds the context root to an URL if it's not already set. +// For relative URL entries on sites with a base url with a context root set (i.e. http://example.com/mysite), +// relative URLs must not include the context root if canonifyURLs is enabled. But if it's disabled, it must be set. +func AddContextRoot(baseURL, relativePath string) string { + url, err := url.Parse(baseURL) + if err != nil { + panic(err) + } + + newPath := path.Join(url.Path, relativePath) + + // path strips trailing slash, ignore root path. + if newPath != "/" && strings.HasSuffix(relativePath, "/") { + newPath += "/" + } + 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 + u, err := url.Parse(uri) + if err != nil { + panic(err) + } + return u.String() +} + +// TrimExt trims the extension from a path.. +func TrimExt(in string) string { + return strings.TrimSuffix(in, path.Ext(in)) +} + +// From https://github.com/golang/go/blob/e0c76d95abfc1621259864adb3d101cf6f1f90fc/src/cmd/go/internal/web/url.go#L45 +func UrlFromFilename(filename string) (*url.URL, error) { + if !filepath.IsAbs(filename) { + return nil, fmt.Errorf("filepath must be absolute") + } + + // If filename has a Windows volume name, convert the volume to a host and prefix + // per https://blogs.msdn.microsoft.com/ie/2006/12/06/file-uris-in-windows/. + if vol := filepath.VolumeName(filename); vol != "" { + if strings.HasPrefix(vol, `\\`) { + filename = filepath.ToSlash(filename[2:]) + i := strings.IndexByte(filename, '/') + + if i < 0 { + // A degenerate case. + // \\host.example.com (without a share name) + // becomes + // file://host.example.com/ + return &url.URL{ + Scheme: "file", + Host: filename, + Path: "/", + }, nil + } + + // \\host.example.com\Share\path\to\file + // becomes + // file://host.example.com/Share/path/to/file + return &url.URL{ + Scheme: "file", + Host: filename[:i], + Path: filepath.ToSlash(filename[i:]), + }, nil + } + + // C:\path\to\file + // becomes + // file:///C:/path/to/file + return &url.URL{ + Scheme: "file", + Path: "/" + filepath.ToSlash(filename), + }, nil + } + + // /path/to/file + // becomes + // file:///path/to/file + return &url.URL{ + Scheme: "file", + Path: filepath.ToSlash(filename), + }, nil +} + +// UrlStringToFilename converts the URL s to a filename. +// If ParseRequestURI fails, the input is just converted to OS specific slashes and returned. +func UrlStringToFilename(s string) (string, bool) { + u, err := url.ParseRequestURI(s) + if err != nil { + return filepath.FromSlash(s), false + } + + p := u.Path + + if p == "" { + p, _ = url.QueryUnescape(u.Opaque) + return filepath.FromSlash(p), false + } + + if runtime.GOOS != "windows" { + return p, true + } + + if len(p) == 0 || p[0] != '/' { + return filepath.FromSlash(p), false + } + + p = filepath.FromSlash(p) + + if len(u.Host) == 1 { + // file://c/Users/... + return strings.ToUpper(u.Host) + ":" + p, true + } + + if u.Host != "" && u.Host != "localhost" { + if filepath.VolumeName(u.Host) != "" { + return "", false + } + return `\\` + u.Host + p, true + } + + if vol := filepath.VolumeName(p[1:]); vol == "" || strings.HasPrefix(vol, `\\`) { + return "", false + } + + return p[1:], true +} diff --git a/common/paths/url_test.go b/common/paths/url_test.go new file mode 100644 index 000000000..5a9233c26 --- /dev/null +++ b/common/paths/url_test.go @@ -0,0 +1,100 @@ +// Copyright 2021 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 paths + +import ( + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestMakePermalink(t *testing.T) { + type test struct { + host, link, output string + } + + data := []test{ + {"http://abc.com/foo", "post/bar", "http://abc.com/foo/post/bar"}, + {"http://abc.com/foo/", "post/bar", "http://abc.com/foo/post/bar"}, + {"http://abc.com", "post/bar", "http://abc.com/post/bar"}, + {"http://abc.com", "bar", "http://abc.com/bar"}, + {"http://abc.com/foo/bar", "post/bar", "http://abc.com/foo/bar/post/bar"}, + {"http://abc.com/foo/bar", "post/bar/", "http://abc.com/foo/bar/post/bar/"}, + {"http://abc.com/foo", "post/bar?a=b#c", "http://abc.com/foo/post/bar?a=b#c"}, + } + + for i, d := range data { + output := MakePermalink(d.host, d.link).String() + if d.output != output { + t.Errorf("Test #%d failed. Expected %q got %q", i, d.output, output) + } + } +} + +func TestAddContextRoot(t *testing.T) { + tests := []struct { + baseURL string + url string + expected string + }{ + {"http://example.com/sub/", "/foo", "/sub/foo"}, + {"http://example.com/sub/", "/foo/index.html", "/sub/foo/index.html"}, + {"http://example.com/sub1/sub2", "/foo", "/sub1/sub2/foo"}, + {"http://example.com", "/foo", "/foo"}, + // cannot guess that the context root is already added int the example below + {"http://example.com/sub/", "/sub/foo", "/sub/sub/foo"}, + {"http://example.com/тря", "/трям/", "/тря/трям/"}, + {"http://example.com", "/", "/"}, + {"http://example.com/bar", "//", "/bar/"}, + } + + for _, test := range tests { + output := AddContextRoot(test.baseURL, test.url) + if output != test.expected { + t.Errorf("Expected %#v, got %#v\n", test.expected, output) + } + } +} + +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("")) +} diff --git a/common/predicate/predicate.go b/common/predicate/predicate.go new file mode 100644 index 000000000..f71536474 --- /dev/null +++ b/common/predicate/predicate.go @@ -0,0 +1,78 @@ +// Copyright 2024 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 predicate + +// P is a predicate function that tests whether a value of type T satisfies some condition. +type P[T any] func(T) bool + +// And returns a predicate that is a short-circuiting logical AND of this and the given predicates. +func (p P[T]) And(ps ...P[T]) P[T] { + return func(v T) bool { + for _, pp := range ps { + if !pp(v) { + return false + } + } + if p == nil { + return true + } + return p(v) + } +} + +// Or returns a predicate that is a short-circuiting logical OR of this and the given predicates. +func (p P[T]) Or(ps ...P[T]) P[T] { + return func(v T) bool { + for _, pp := range ps { + if pp(v) { + return true + } + } + if p == nil { + return false + } + return p(v) + } +} + +// Negate returns a predicate that is a logical negation of this predicate. +func (p P[T]) Negate() P[T] { + return func(v T) bool { + return !p(v) + } +} + +// Filter returns a new slice holding only the elements of s that satisfy p. +// Filter modifies the contents of the slice s and returns the modified slice, which may have a smaller length. +func (p P[T]) Filter(s []T) []T { + var n int + for _, v := range s { + if p(v) { + s[n] = v + n++ + } + } + return s[:n] +} + +// FilterCopy returns a new slice holding only the elements of s that satisfy p. +func (p P[T]) FilterCopy(s []T) []T { + var result []T + for _, v := range s { + if p(v) { + result = append(result, v) + } + } + return result +} diff --git a/common/predicate/predicate_test.go b/common/predicate/predicate_test.go new file mode 100644 index 000000000..1e1ec004b --- /dev/null +++ b/common/predicate/predicate_test.go @@ -0,0 +1,83 @@ +// Copyright 2024 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 predicate_test + +import ( + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/common/predicate" +) + +func TestAdd(t *testing.T) { + c := qt.New(t) + + var p predicate.P[int] = intP1 + + c.Assert(p(1), qt.IsTrue) + c.Assert(p(2), qt.IsFalse) + + neg := p.Negate() + c.Assert(neg(1), qt.IsFalse) + c.Assert(neg(2), qt.IsTrue) + + and := p.And(intP2) + c.Assert(and(1), qt.IsFalse) + c.Assert(and(2), qt.IsFalse) + c.Assert(and(10), qt.IsTrue) + + or := p.Or(intP2) + c.Assert(or(1), qt.IsTrue) + c.Assert(or(2), qt.IsTrue) + c.Assert(or(10), qt.IsTrue) + c.Assert(or(11), qt.IsFalse) +} + +func TestFilter(t *testing.T) { + c := qt.New(t) + + var p predicate.P[int] = intP1 + p = p.Or(intP2) + + ints := []int{1, 2, 3, 4, 1, 6, 7, 8, 2} + + c.Assert(p.Filter(ints), qt.DeepEquals, []int{1, 2, 1, 2}) + c.Assert(ints, qt.DeepEquals, []int{1, 2, 1, 2, 1, 6, 7, 8, 2}) +} + +func TestFilterCopy(t *testing.T) { + c := qt.New(t) + + var p predicate.P[int] = intP1 + p = p.Or(intP2) + + ints := []int{1, 2, 3, 4, 1, 6, 7, 8, 2} + + c.Assert(p.FilterCopy(ints), qt.DeepEquals, []int{1, 2, 1, 2}) + c.Assert(ints, qt.DeepEquals, []int{1, 2, 3, 4, 1, 6, 7, 8, 2}) +} + +var intP1 = func(i int) bool { + if i == 10 { + return true + } + return i == 1 +} + +var intP2 = func(i int) bool { + if i == 10 { + return true + } + return i == 2 +} diff --git a/common/rungroup/rungroup.go b/common/rungroup/rungroup.go new file mode 100644 index 000000000..80a730ca9 --- /dev/null +++ b/common/rungroup/rungroup.go @@ -0,0 +1,93 @@ +// Copyright 2024 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 rungroup + +import ( + "context" + + "golang.org/x/sync/errgroup" +) + +// Group is a group of workers that can be used to enqueue work and wait for +// them to finish. +type Group[T any] interface { + Enqueue(T) error + Wait() error +} + +type runGroup[T any] struct { + ctx context.Context + g *errgroup.Group + ch chan T +} + +// Config is the configuration for a new Group. +type Config[T any] struct { + NumWorkers int + Handle func(context.Context, T) error +} + +// Run creates a new Group with the given configuration. +func Run[T any](ctx context.Context, cfg Config[T]) Group[T] { + if cfg.NumWorkers <= 0 { + cfg.NumWorkers = 1 + } + if cfg.Handle == nil { + panic("Handle must be set") + } + + g, ctx := errgroup.WithContext(ctx) + // Buffered for performance. + ch := make(chan T, cfg.NumWorkers) + + for range cfg.NumWorkers { + g.Go(func() error { + for { + select { + case <-ctx.Done(): + return nil + case v, ok := <-ch: + if !ok { + return nil + } + if err := cfg.Handle(ctx, v); err != nil { + return err + } + } + } + }) + } + + return &runGroup[T]{ + ctx: ctx, + g: g, + ch: ch, + } +} + +// Enqueue enqueues a new item to be handled by the workers. +func (r *runGroup[T]) Enqueue(t T) error { + select { + case <-r.ctx.Done(): + return nil + case r.ch <- t: + } + return nil +} + +// Wait waits for all workers to finish and returns the first error. +func (r *runGroup[T]) Wait() error { + close(r.ch) + return r.g.Wait() +} diff --git a/common/rungroup/rungroup_test.go b/common/rungroup/rungroup_test.go new file mode 100644 index 000000000..ac902079e --- /dev/null +++ b/common/rungroup/rungroup_test.go @@ -0,0 +1,44 @@ +// Copyright 2024 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 rungroup + +import ( + "context" + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestNew(t *testing.T) { + c := qt.New(t) + + var result int + adder := func(ctx context.Context, i int) error { + result += i + return nil + } + + g := Run[int]( + context.Background(), + Config[int]{ + Handle: adder, + }, + ) + + c.Assert(g, qt.IsNotNil) + g.Enqueue(32) + g.Enqueue(33) + c.Assert(g.Wait(), qt.IsNil) + c.Assert(result, qt.Equals, 65) +} diff --git a/common/tasks/tasks.go b/common/tasks/tasks.go new file mode 100644 index 000000000..3f8a754e9 --- /dev/null +++ b/common/tasks/tasks.go @@ -0,0 +1,150 @@ +// Copyright 2024 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 tasks + +import ( + "sync" + "time" +) + +// RunEvery runs a function at intervals defined by the function itself. +// Functions can be added and removed while running. +type RunEvery struct { + // Any error returned from the function will be passed to this function. + HandleError func(string, error) + + // If set, the function will be run immediately. + RunImmediately bool + + // The named functions to run. + funcs map[string]*Func + + mu sync.Mutex + started bool + closed bool + quit chan struct{} +} + +type Func struct { + // The shortest interval between each run. + IntervalLow time.Duration + + // The longest interval between each run. + IntervalHigh time.Duration + + // The function to run. + F func(interval time.Duration) (time.Duration, error) + + interval time.Duration + last time.Time +} + +func (r *RunEvery) Start() error { + if r.started { + return nil + } + + r.started = true + r.quit = make(chan struct{}) + + go func() { + if r.RunImmediately { + r.run() + } + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-r.quit: + return + case <-ticker.C: + r.run() + } + } + }() + + return nil +} + +// Close stops the RunEvery from running. +func (r *RunEvery) Close() error { + if r.closed { + return nil + } + r.closed = true + if r.quit != nil { + close(r.quit) + } + return nil +} + +// Add adds a function to the RunEvery. +func (r *RunEvery) Add(name string, f Func) { + r.mu.Lock() + defer r.mu.Unlock() + if r.funcs == nil { + r.funcs = make(map[string]*Func) + } + if f.IntervalLow == 0 { + f.IntervalLow = 500 * time.Millisecond + } + if f.IntervalHigh <= f.IntervalLow { + f.IntervalHigh = 20 * time.Second + } + + start := max(f.IntervalHigh/3, f.IntervalLow) + f.interval = start + f.last = time.Now() + + r.funcs[name] = &f +} + +// Remove removes a function from the RunEvery. +func (r *RunEvery) Remove(name string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.funcs, name) +} + +// Has returns whether the RunEvery has a function with the given name. +func (r *RunEvery) Has(name string) bool { + r.mu.Lock() + defer r.mu.Unlock() + _, found := r.funcs[name] + return found +} + +func (r *RunEvery) run() { + r.mu.Lock() + defer r.mu.Unlock() + for name, f := range r.funcs { + if time.Now().Before(f.last.Add(f.interval)) { + continue + } + f.last = time.Now() + interval, err := f.F(f.interval) + if err != nil && r.HandleError != nil { + r.HandleError(name, err) + } + + if interval < f.IntervalLow { + interval = f.IntervalLow + } + + if interval > f.IntervalHigh { + interval = f.IntervalHigh + } + f.interval = interval + } +} diff --git a/common/terminal/colors.go b/common/terminal/colors.go index 334b82fae..fef6efce8 100644 --- a/common/terminal/colors.go +++ b/common/terminal/colors.go @@ -1,4 +1,4 @@ -// Copyright 2018 The Hugo Authors. All rights reserved. +// Copyright 2024 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. @@ -17,7 +17,6 @@ package terminal import ( "fmt" "os" - "runtime" "strings" isatty "github.com/mattn/go-isatty" @@ -29,13 +28,18 @@ const ( noticeColor = "\033[1;36m%s\033[0m" ) +// PrintANSIColors returns false if NO_COLOR env variable is set, +// else IsTerminal(f). +func PrintANSIColors(f *os.File) bool { + if os.Getenv("NO_COLOR") != "" { + return false + } + return IsTerminal(f) +} + // IsTerminal return true if the file descriptor is terminal and the TERM // environment variable isn't a dumb one. func IsTerminal(f *os.File) bool { - if runtime.GOOS == "windows" { - return false - } - fd := f.Fd() return os.Getenv("TERM") != "dumb" && (isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd)) } diff --git a/common/text/position.go b/common/text/position.go index 0c43c5ae7..eb9de5624 100644 --- a/common/text/position.go +++ b/common/text/position.go @@ -24,6 +24,8 @@ import ( // Positioner represents a thing that knows its position in a text file or stream, // typically an error. type Positioner interface { + // Position returns the current position. + // Useful in error logging, e.g. {{ errorf "error in code block: %s" .Position }}. Position() Position } @@ -50,12 +52,11 @@ func (pos Position) IsValid() bool { var positionStringFormatfunc func(p Position) string func createPositionStringFormatter(formatStr string) func(p Position) string { - if formatStr == "" { formatStr = "\":file::line::col\"" } - var identifiers = []string{":file", ":line", ":col"} + identifiers := []string{":file", ":line", ":col"} var identifiersFound []string for i := range formatStr { @@ -70,7 +71,7 @@ func createPositionStringFormatter(formatStr string) func(p Position) string { format := replacer.Replace(formatStr) f := func(pos Position) string { - args := make([]interface{}, len(identifiersFound)) + args := make([]any, len(identifiersFound)) for i, id := range identifiersFound { switch id { case ":file": @@ -84,7 +85,7 @@ func createPositionStringFormatter(formatStr string) func(p Position) string { msg := fmt.Sprintf(format, args...) - if terminal.IsTerminal(os.Stdout) { + if terminal.PrintANSIColors(os.Stdout) { return terminal.Notice(msg) } diff --git a/common/text/position_test.go b/common/text/position_test.go index ba4824344..a1f43c5d4 100644 --- a/common/text/position_test.go +++ b/common/text/position_test.go @@ -29,5 +29,4 @@ func TestPositionStringFormatter(t *testing.T) { c.Assert(createPositionStringFormatter("好::col")(pos), qt.Equals, "好:13") c.Assert(createPositionStringFormatter("")(pos), qt.Equals, "\"/my/file.txt:12:13\"") c.Assert(pos.String(), qt.Equals, "\"/my/file.txt:12:13\"") - } diff --git a/common/text/transform.go b/common/text/transform.go index f59577803..de093af0d 100644 --- a/common/text/transform.go +++ b/common/text/transform.go @@ -14,6 +14,7 @@ package text import ( + "strings" "sync" "unicode" @@ -23,7 +24,7 @@ import ( ) var accentTransformerPool = &sync.Pool{ - New: func() interface{} { + New: func() any { return transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC) }, } @@ -45,3 +46,33 @@ func RemoveAccentsString(s string) string { accentTransformerPool.Put(t) return s } + +// Chomp removes trailing newline characters from s. +func Chomp(s string) string { + return strings.TrimRightFunc(s, func(r rune) bool { + return r == '\n' || r == '\r' + }) +} + +// Puts adds a trailing \n none found. +func Puts(s string) string { + if s == "" || s[len(s)-1] == '\n' { + return s + } + return s + "\n" +} + +// VisitLinesAfter calls the given function for each line, including newlines, in the given string. +func VisitLinesAfter(s string, fn func(line string)) { + high := strings.IndexRune(s, '\n') + for high != -1 { + fn(s[:high+1]) + s = s[high+1:] + + high = strings.IndexRune(s, '\n') + } + + if s != "" { + fn(s) + } +} diff --git a/common/text/transform_test.go b/common/text/transform_test.go index 70b10d149..74bb37783 100644 --- a/common/text/transform_test.go +++ b/common/text/transform_test.go @@ -25,5 +25,48 @@ func TestRemoveAccents(t *testing.T) { c.Assert(string(RemoveAccents([]byte("Resumé"))), qt.Equals, "Resume") c.Assert(string(RemoveAccents([]byte("Hugo Rocks!"))), qt.Equals, "Hugo Rocks!") c.Assert(string(RemoveAccentsString("Resumé")), qt.Equals, "Resume") - +} + +func TestChomp(t *testing.T) { + c := qt.New(t) + + c.Assert(Chomp("\nA\n"), qt.Equals, "\nA") + c.Assert(Chomp("A\r\n"), qt.Equals, "A") +} + +func TestPuts(t *testing.T) { + c := qt.New(t) + + c.Assert(Puts("A"), qt.Equals, "A\n") + c.Assert(Puts("\nA\n"), qt.Equals, "\nA\n") + c.Assert(Puts(""), qt.Equals, "") +} + +func TestVisitLinesAfter(t *testing.T) { + const lines = `line 1 +line 2 + +line 3` + + var collected []string + + VisitLinesAfter(lines, func(s string) { + collected = append(collected, s) + }) + + c := qt.New(t) + + c.Assert(collected, qt.DeepEquals, []string{"line 1\n", "line 2\n", "\n", "line 3"}) +} + +func BenchmarkVisitLinesAfter(b *testing.B) { + const lines = `line 1 + line 2 + + line 3` + + for i := 0; i < b.N; i++ { + VisitLinesAfter(lines, func(s string) { + }) + } } diff --git a/common/types/closer.go b/common/types/closer.go new file mode 100644 index 000000000..9f8875a8a --- /dev/null +++ b/common/types/closer.go @@ -0,0 +1,54 @@ +// Copyright 2024 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 types + +import "sync" + +type Closer interface { + Close() error +} + +// CloserFunc is a convenience type to create a Closer from a function. +type CloserFunc func() error + +func (f CloserFunc) Close() error { + return f() +} + +type CloseAdder interface { + Add(Closer) +} + +type Closers struct { + mu sync.Mutex + cs []Closer +} + +func (cs *Closers) Add(c Closer) { + cs.mu.Lock() + defer cs.mu.Unlock() + cs.cs = append(cs.cs, c) +} + +func (cs *Closers) Close() error { + cs.mu.Lock() + defer cs.mu.Unlock() + for _, c := range cs.cs { + c.Close() + } + + cs.cs = cs.cs[:0] + + return nil +} diff --git a/common/types/convert.go b/common/types/convert.go new file mode 100644 index 000000000..6b1750376 --- /dev/null +++ b/common/types/convert.go @@ -0,0 +1,129 @@ +// Copyright 2019 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 types + +import ( + "encoding/json" + "fmt" + "html/template" + "reflect" + "time" + + "github.com/spf13/cast" +) + +// ToDuration converts v to time.Duration. +// See ToDurationE if you need to handle errors. +func ToDuration(v any) time.Duration { + d, _ := ToDurationE(v) + return d +} + +// ToDurationE converts v to time.Duration. +func ToDurationE(v any) (time.Duration, error) { + if n := cast.ToInt(v); n > 0 { + return time.Duration(n) * time.Millisecond, nil + } + d, err := time.ParseDuration(cast.ToString(v)) + if err != nil { + return 0, fmt.Errorf("cannot convert %v to time.Duration", v) + } + return d, nil +} + +// ToStringSlicePreserveString is the same as ToStringSlicePreserveStringE, +// but it never fails. +func ToStringSlicePreserveString(v any) []string { + vv, _ := ToStringSlicePreserveStringE(v) + return vv +} + +// ToStringSlicePreserveStringE converts v to a string slice. +// If v is a string, it will be wrapped in a string slice. +func ToStringSlicePreserveStringE(v any) ([]string, error) { + if v == nil { + return nil, nil + } + if sds, ok := v.(string); ok { + return []string{sds}, nil + } + result, err := cast.ToStringSliceE(v) + if err == nil { + return result, nil + } + + // Probably []int or similar. Fall back to reflect. + vv := reflect.ValueOf(v) + + switch vv.Kind() { + case reflect.Slice, reflect.Array: + result = make([]string, vv.Len()) + for i := range vv.Len() { + s, err := cast.ToStringE(vv.Index(i).Interface()) + if err != nil { + return nil, err + } + result[i] = s + } + return result, nil + default: + return nil, fmt.Errorf("failed to convert %T to a string slice", v) + } +} + +// TypeToString converts v to a string if it's a valid string type. +// Note that this will not try to convert numeric values etc., +// use ToString for that. +func TypeToString(v any) (string, bool) { + switch s := v.(type) { + case string: + return s, true + case template.HTML: + return string(s), true + case template.CSS: + return string(s), true + case template.HTMLAttr: + return string(s), true + case template.JS: + return string(s), true + case template.JSStr: + return string(s), true + case template.URL: + return string(s), true + case template.Srcset: + return string(s), true + } + + return "", false +} + +// ToString converts v to a string. +func ToString(v any) string { + s, _ := ToStringE(v) + return s +} + +// ToStringE converts v to a string. +func ToStringE(v any) (string, error) { + if s, ok := TypeToString(v); ok { + return s, nil + } + + switch s := v.(type) { + case json.RawMessage: + return string(s), nil + default: + return cast.ToStringE(v) + } +} diff --git a/common/types/convert_test.go b/common/types/convert_test.go new file mode 100644 index 000000000..13059285d --- /dev/null +++ b/common/types/convert_test.go @@ -0,0 +1,48 @@ +// Copyright 2019 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 types + +import ( + "encoding/json" + "testing" + "time" + + qt "github.com/frankban/quicktest" +) + +func TestToStringSlicePreserveString(t *testing.T) { + c := qt.New(t) + + c.Assert(ToStringSlicePreserveString("Hugo"), qt.DeepEquals, []string{"Hugo"}) + c.Assert(ToStringSlicePreserveString(qt.Commentf("Hugo")), qt.DeepEquals, []string{"Hugo"}) + c.Assert(ToStringSlicePreserveString([]any{"A", "B"}), qt.DeepEquals, []string{"A", "B"}) + c.Assert(ToStringSlicePreserveString([]int{1, 3}), qt.DeepEquals, []string{"1", "3"}) + c.Assert(ToStringSlicePreserveString(nil), qt.IsNil) +} + +func TestToString(t *testing.T) { + c := qt.New(t) + + c.Assert(ToString([]byte("Hugo")), qt.Equals, "Hugo") + c.Assert(ToString(json.RawMessage("Hugo")), qt.Equals, "Hugo") +} + +func TestToDuration(t *testing.T) { + c := qt.New(t) + + c.Assert(ToDuration("200ms"), qt.Equals, 200*time.Millisecond) + c.Assert(ToDuration("200"), qt.Equals, 200*time.Millisecond) + c.Assert(ToDuration("4m"), qt.Equals, 4*time.Minute) + c.Assert(ToDuration("asdfadf"), qt.Equals, time.Duration(0)) +} diff --git a/common/types/css/csstypes.go b/common/types/css/csstypes.go new file mode 100644 index 000000000..061acfe64 --- /dev/null +++ b/common/types/css/csstypes.go @@ -0,0 +1,20 @@ +// Copyright 2024 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 css + +// QuotedString is a string that needs to be quoted in CSS. +type QuotedString string + +// UnquotedString is a string that does not need to be quoted in CSS. +type UnquotedString string diff --git a/common/types/evictingqueue.go b/common/types/evictingqueue.go index 884762426..a335be3b2 100644 --- a/common/types/evictingqueue.go +++ b/common/types/evictingqueue.go @@ -15,57 +15,72 @@ package types import ( + "slices" "sync" ) -// EvictingStringQueue is a queue which automatically evicts elements from the head of +// EvictingQueue is a queue which automatically evicts elements from the head of // the queue when attempting to add new elements onto the queue and it is full. // This queue orders elements LIFO (last-in-first-out). It throws away duplicates. -// Note: This queue currently does not contain any remove (poll etc.) methods. -type EvictingStringQueue struct { +type EvictingQueue[T comparable] struct { size int - vals []string - set map[string]bool + vals []T + set map[T]bool mu sync.Mutex + zero T } -// NewEvictingStringQueue creates a new queue with the given size. -func NewEvictingStringQueue(size int) *EvictingStringQueue { - return &EvictingStringQueue{size: size, set: make(map[string]bool)} +// NewEvictingQueue creates a new queue with the given size. +func NewEvictingQueue[T comparable](size int) *EvictingQueue[T] { + return &EvictingQueue[T]{size: size, set: make(map[T]bool)} } // Add adds a new string to the tail of the queue if it's not already there. -func (q *EvictingStringQueue) Add(v string) { +func (q *EvictingQueue[T]) Add(v T) *EvictingQueue[T] { q.mu.Lock() if q.set[v] { q.mu.Unlock() - return + return q } if len(q.set) == q.size { // Full delete(q.set, q.vals[0]) - q.vals = append(q.vals[:0], q.vals[1:]...) + q.vals = slices.Delete(q.vals, 0, 1) } q.set[v] = true q.vals = append(q.vals, v) q.mu.Unlock() + + return q +} + +func (q *EvictingQueue[T]) Len() int { + if q == nil { + return 0 + } + q.mu.Lock() + defer q.mu.Unlock() + return len(q.vals) } // Contains returns whether the queue contains v. -func (q *EvictingStringQueue) Contains(v string) bool { +func (q *EvictingQueue[T]) Contains(v T) bool { + if q == nil { + return false + } q.mu.Lock() defer q.mu.Unlock() return q.set[v] } // Peek looks at the last element added to the queue. -func (q *EvictingStringQueue) Peek() string { +func (q *EvictingQueue[T]) Peek() T { q.mu.Lock() l := len(q.vals) if l == 0 { q.mu.Unlock() - return "" + return q.zero } elem := q.vals[l-1] q.mu.Unlock() @@ -73,9 +88,12 @@ func (q *EvictingStringQueue) Peek() string { } // PeekAll looks at all the elements in the queue, with the newest first. -func (q *EvictingStringQueue) PeekAll() []string { +func (q *EvictingQueue[T]) PeekAll() []T { + if q == nil { + return nil + } q.mu.Lock() - vals := make([]string, len(q.vals)) + vals := make([]T, len(q.vals)) copy(vals, q.vals) q.mu.Unlock() for i, j := 0, len(vals)-1; i < j; i, j = i+1, j-1 { @@ -85,9 +103,9 @@ func (q *EvictingStringQueue) PeekAll() []string { } // PeekAllSet returns PeekAll as a set. -func (q *EvictingStringQueue) PeekAllSet() map[string]bool { +func (q *EvictingQueue[T]) PeekAllSet() map[T]bool { all := q.PeekAll() - set := make(map[string]bool) + set := make(map[T]bool) for _, v := range all { set[v] = true } diff --git a/common/types/evictingqueue_test.go b/common/types/evictingqueue_test.go index 7489ba88d..b93243f3c 100644 --- a/common/types/evictingqueue_test.go +++ b/common/types/evictingqueue_test.go @@ -23,7 +23,7 @@ import ( func TestEvictingStringQueue(t *testing.T) { c := qt.New(t) - queue := NewEvictingStringQueue(3) + queue := NewEvictingQueue[string](3) c.Assert(queue.Peek(), qt.Equals, "") queue.Add("a") @@ -53,9 +53,9 @@ func TestEvictingStringQueueConcurrent(t *testing.T) { var wg sync.WaitGroup val := "someval" - queue := NewEvictingStringQueue(3) + queue := NewEvictingQueue[string](3) - for j := 0; j < 100; j++ { + for range 100 { wg.Add(1) go func() { defer wg.Done() diff --git a/common/types/hstring/stringtypes.go b/common/types/hstring/stringtypes.go new file mode 100644 index 000000000..53ce2068f --- /dev/null +++ b/common/types/hstring/stringtypes.go @@ -0,0 +1,36 @@ +// Copyright 2024 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 hstring + +import ( + "html/template" + + "github.com/gohugoio/hugo/common/types" +) + +var _ types.PrintableValueProvider = HTML("") + +// HTML is a string that represents rendered HTML. +// When printed in templates it will be rendered as template.HTML and considered safe so no need to pipe it into `safeHTML`. +// This type was introduced as a wasy to prevent a common case of inifinite recursion in the template rendering +// when the `linkify` option is enabled with a common (wrong) construct like `{{ .Text | .Page.RenderString }}` in a hook template. +type HTML string + +func (s HTML) String() string { + return string(s) +} + +func (s HTML) PrintableValue() any { + return template.HTML(s) +} diff --git a/common/types/hstring/stringtypes_test.go b/common/types/hstring/stringtypes_test.go new file mode 100644 index 000000000..05e2c22b9 --- /dev/null +++ b/common/types/hstring/stringtypes_test.go @@ -0,0 +1,30 @@ +// Copyright 2024 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 hstring + +import ( + "html/template" + "testing" + + qt "github.com/frankban/quicktest" + "github.com/spf13/cast" +) + +func TestRenderedString(t *testing.T) { + c := qt.New(t) + + // Validate that it will behave like a string in Hugo settings. + c.Assert(cast.ToString(HTML("Hugo")), qt.Equals, "Hugo") + c.Assert(template.HTML(HTML("Hugo")), qt.Equals, template.HTML("Hugo")) +} diff --git a/common/types/types.go b/common/types/types.go index 04a27766e..7e94c1eea 100644 --- a/common/types/types.go +++ b/common/types/types.go @@ -17,6 +17,7 @@ package types import ( "fmt" "reflect" + "sync/atomic" "github.com/spf13/cast" ) @@ -27,6 +28,22 @@ type RLocker interface { RUnlock() } +type Locker interface { + Lock() + Unlock() +} + +type RWLocker interface { + RLocker + Locker +} + +// KeyValue is a interface{} tuple. +type KeyValue struct { + Key any + Value any +} + // KeyValueStr is a string tuple. type KeyValueStr struct { Key string @@ -35,8 +52,8 @@ type KeyValueStr struct { // KeyValues holds an key and a slice of values. type KeyValues struct { - Key interface{} - Values []interface{} + Key any + Values []any } // KeyString returns the key as a string, an empty string if conversion fails. @@ -51,8 +68,8 @@ func (k KeyValues) String() string { // NewKeyValuesStrings takes a given key and slice of values and returns a new // KeyValues struct. func NewKeyValuesStrings(key string, values ...string) KeyValues { - iv := make([]interface{}, len(values)) - for i := 0; i < len(values); i++ { + iv := make([]any, len(values)) + for i := range values { iv[i] = values[i] } return KeyValues{Key: key, Values: iv} @@ -65,7 +82,7 @@ type Zeroer interface { } // IsNil reports whether v is nil. -func IsNil(v interface{}) bool { +func IsNil(v any) bool { if v == nil { return true } @@ -84,3 +101,45 @@ func IsNil(v interface{}) bool { type DevMarker interface { DevOnly() } + +// Unwrapper is implemented by types that can unwrap themselves. +type Unwrapper interface { + // Unwrapv is for internal use only. + // It got its slightly odd name to prevent collisions with user types. + Unwrapv() any +} + +// Unwrap returns the underlying value of v if it implements Unwrapper, otherwise v is returned. +func Unwrapv(v any) any { + if u, ok := v.(Unwrapper); ok { + return u.Unwrapv() + } + return v +} + +// LowHigh represents a byte or slice boundary. +type LowHigh[S ~[]byte | string] struct { + Low int + High int +} + +func (l LowHigh[S]) IsZero() bool { + return l.Low < 0 || (l.Low == 0 && l.High == 0) +} + +func (l LowHigh[S]) Value(source S) S { + return source[l.Low:l.High] +} + +// This is only used for debugging purposes. +var InvocationCounter atomic.Int64 + +// NewTrue returns a pointer to b. +func NewBool(b bool) *bool { + return &b +} + +// PrintableValueProvider is implemented by types that can provide a printable value. +type PrintableValueProvider interface { + PrintableValue() any +} diff --git a/common/types/types_test.go b/common/types/types_test.go index 7c5cba659..795733047 100644 --- a/common/types/types_test.go +++ b/common/types/types_test.go @@ -25,5 +25,27 @@ func TestKeyValues(t *testing.T) { kv := NewKeyValuesStrings("key", "a1", "a2") c.Assert(kv.KeyString(), qt.Equals, "key") - c.Assert(kv.Values, qt.DeepEquals, []interface{}{"a1", "a2"}) + c.Assert(kv.Values, qt.DeepEquals, []any{"a1", "a2"}) +} + +func TestLowHigh(t *testing.T) { + c := qt.New(t) + + lh := LowHigh[string]{ + Low: 2, + High: 10, + } + + s := "abcdefghijklmnopqrstuvwxyz" + c.Assert(lh.IsZero(), qt.IsFalse) + c.Assert(lh.Value(s), qt.Equals, "cdefghij") + + lhb := LowHigh[[]byte]{ + Low: 2, + High: 10, + } + + sb := []byte(s) + c.Assert(lhb.IsZero(), qt.IsFalse) + c.Assert(lhb.Value(sb), qt.DeepEquals, []byte("cdefghij")) } diff --git a/common/urls/baseURL.go b/common/urls/baseURL.go new file mode 100644 index 000000000..2958a2a04 --- /dev/null +++ b/common/urls/baseURL.go @@ -0,0 +1,112 @@ +// Copyright 2024 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 urls + +import ( + "fmt" + "net/url" + "strconv" + "strings" +) + +// A BaseURL in Hugo is normally on the form scheme://path, but the +// form scheme: is also valid (mailto:hugo@rules.com). +type BaseURL struct { + url *url.URL + WithPath string + WithPathNoTrailingSlash string + WithoutPath string + BasePath string + BasePathNoTrailingSlash string +} + +func (b BaseURL) String() string { + return b.WithPath +} + +func (b BaseURL) Path() string { + return b.url.Path +} + +func (b BaseURL) Port() int { + p, _ := strconv.Atoi(b.url.Port()) + return p +} + +// HostURL returns the URL to the host root without any path elements. +func (b BaseURL) HostURL() string { + return strings.TrimSuffix(b.String(), b.Path()) +} + +// WithProtocol returns the BaseURL prefixed with the given protocol. +// The Protocol is normally of the form "scheme://", i.e. "webcal://". +func (b BaseURL) WithProtocol(protocol string) (BaseURL, error) { + u := b.URL() + + scheme := protocol + isFullProtocol := strings.HasSuffix(scheme, "://") + isOpaqueProtocol := strings.HasSuffix(scheme, ":") + + if isFullProtocol { + scheme = strings.TrimSuffix(scheme, "://") + } else if isOpaqueProtocol { + scheme = strings.TrimSuffix(scheme, ":") + } + + u.Scheme = scheme + + if isFullProtocol && u.Opaque != "" { + u.Opaque = "//" + u.Opaque + } else if isOpaqueProtocol && u.Opaque == "" { + return BaseURL{}, fmt.Errorf("cannot determine BaseURL for protocol %q", protocol) + } + + return newBaseURLFromURL(u) +} + +func (b BaseURL) WithPort(port int) (BaseURL, error) { + u := b.URL() + u.Host = u.Hostname() + ":" + strconv.Itoa(port) + return newBaseURLFromURL(u) +} + +// URL returns a copy of the internal URL. +// The copy can be safely used and modified. +func (b BaseURL) URL() *url.URL { + c := *b.url + return &c +} + +func NewBaseURLFromString(b string) (BaseURL, error) { + u, err := url.Parse(b) + if err != nil { + return BaseURL{}, err + } + return newBaseURLFromURL(u) +} + +func newBaseURLFromURL(u *url.URL) (BaseURL, error) { + // A baseURL should always have a trailing slash, see #11669. + if !strings.HasSuffix(u.Path, "/") { + u.Path += "/" + } + baseURL := BaseURL{url: u, WithPath: u.String(), WithPathNoTrailingSlash: strings.TrimSuffix(u.String(), "/")} + baseURLNoPath := baseURL.URL() + baseURLNoPath.Path = "" + baseURL.WithoutPath = baseURLNoPath.String() + baseURL.BasePath = u.Path + baseURL.BasePathNoTrailingSlash = strings.TrimSuffix(u.Path, "/") + + return baseURL, nil +} diff --git a/common/urls/baseURL_test.go b/common/urls/baseURL_test.go new file mode 100644 index 000000000..ba337aac8 --- /dev/null +++ b/common/urls/baseURL_test.go @@ -0,0 +1,81 @@ +// Copyright 2024 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 urls + +import ( + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestBaseURL(t *testing.T) { + c := qt.New(t) + + b, err := NewBaseURLFromString("http://example.com/") + c.Assert(err, qt.IsNil) + c.Assert(b.String(), qt.Equals, "http://example.com/") + + b, err = NewBaseURLFromString("http://example.com") + c.Assert(err, qt.IsNil) + c.Assert(b.String(), qt.Equals, "http://example.com/") + c.Assert(b.WithPathNoTrailingSlash, qt.Equals, "http://example.com") + c.Assert(b.BasePath, qt.Equals, "/") + + p, err := b.WithProtocol("webcal://") + c.Assert(err, qt.IsNil) + c.Assert(p.String(), qt.Equals, "webcal://example.com/") + + p, err = b.WithProtocol("webcal") + c.Assert(err, qt.IsNil) + c.Assert(p.String(), qt.Equals, "webcal://example.com/") + + _, err = b.WithProtocol("mailto:") + c.Assert(err, qt.Not(qt.IsNil)) + + b, err = NewBaseURLFromString("mailto:hugo@rules.com") + c.Assert(err, qt.IsNil) + c.Assert(b.String(), qt.Equals, "mailto:hugo@rules.com") + + // These are pretty constructed + p, err = b.WithProtocol("webcal") + c.Assert(err, qt.IsNil) + c.Assert(p.String(), qt.Equals, "webcal:hugo@rules.com") + + p, err = b.WithProtocol("webcal://") + c.Assert(err, qt.IsNil) + c.Assert(p.String(), qt.Equals, "webcal://hugo@rules.com") + + // Test with "non-URLs". Some people will try to use these as a way to get + // relative URLs working etc. + b, err = NewBaseURLFromString("/") + c.Assert(err, qt.IsNil) + c.Assert(b.String(), qt.Equals, "/") + + b, err = NewBaseURLFromString("") + c.Assert(err, qt.IsNil) + c.Assert(b.String(), qt.Equals, "/") + + // BaseURL with sub path + b, err = NewBaseURLFromString("http://example.com/sub") + c.Assert(err, qt.IsNil) + c.Assert(b.String(), qt.Equals, "http://example.com/sub/") + c.Assert(b.WithPathNoTrailingSlash, qt.Equals, "http://example.com/sub") + c.Assert(b.BasePath, qt.Equals, "/sub/") + c.Assert(b.BasePathNoTrailingSlash, qt.Equals, "/sub") + + b, err = NewBaseURLFromString("http://example.com/sub/") + c.Assert(err, qt.IsNil) + c.Assert(b.String(), qt.Equals, "http://example.com/sub/") + c.Assert(b.HostURL(), qt.Equals, "http://example.com") +} diff --git a/common/urls/ref.go b/common/urls/ref.go index 71b00b71d..e5804a279 100644 --- a/common/urls/ref.go +++ b/common/urls/ref.go @@ -17,6 +17,6 @@ package urls // args must contain a path, but can also point to the target // language or output format. type RefLinker interface { - Ref(args map[string]interface{}) (string, error) - RelRef(args map[string]interface{}) (string, error) + Ref(args map[string]any) (string, error) + RelRef(args map[string]any) (string, error) } diff --git a/compare/compare.go b/compare/compare.go index 537a66676..fd15bd087 100644 --- a/compare/compare.go +++ b/compare/compare.go @@ -17,13 +17,16 @@ package compare // The semantics of equals is that the two value are interchangeable // in the Hugo templates. type Eqer interface { - Eq(other interface{}) bool + // Eq returns whether this value is equal to the other. + // This is for internal use. + Eq(other any) bool } // ProbablyEqer is an equal check that may return false positives, but never // a false negative. type ProbablyEqer interface { - ProbablyEq(other interface{}) bool + // For internal use. + ProbablyEq(other any) bool } // Comparer can be used to compare two values. @@ -31,5 +34,34 @@ type ProbablyEqer interface { // Compare returns -1 if the given version is less than, 0 if equal and 1 if greater than // the running version. type Comparer interface { - Compare(other interface{}) int + Compare(other any) int +} + +// Eq returns whether v1 is equal to v2. +// It will use the Eqer interface if implemented, which +// defines equals when two value are interchangeable +// in the Hugo templates. +func Eq(v1, v2 any) bool { + if v1 == nil || v2 == nil { + return v1 == v2 + } + + if eqer, ok := v1.(Eqer); ok { + return eqer.Eq(v2) + } + + return v1 == v2 +} + +// ProbablyEq returns whether v1 is probably equal to v2. +func ProbablyEq(v1, v2 any) bool { + if Eq(v1, v2) { + return true + } + + if peqer, ok := v1.(ProbablyEqer); ok { + return peqer.ProbablyEq(v2) + } + + return false } diff --git a/compare/compare_strings_test.go b/compare/compare_strings_test.go index db286c2c5..1a5bb0b1a 100644 --- a/compare/compare_strings_test.go +++ b/compare/compare_strings_test.go @@ -61,5 +61,22 @@ func TestLexicographicSort(t *testing.T) { }) c.Assert(s, qt.DeepEquals, []string{"A", "b", "Ba", "ba", "ba", "Bz"}) - +} + +func BenchmarkStringSort(b *testing.B) { + prototype := []string{"b", "Bz", "zz", "ba", "αβδ αβδ αβδ", "A", "Ba", "ba", "nnnnasdfnnn", "AAgæåz", "αβδC"} + b.Run("LessStrings", func(b *testing.B) { + ss := make([][]string, b.N) + for i := 0; i < b.N; i++ { + ss[i] = make([]string, len(prototype)) + copy(ss[i], prototype) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + sss := ss[i] + sort.Slice(sss, func(i, j int) bool { + return LessStrings(sss[i], sss[j]) + }) + } + }) } diff --git a/config/allconfig/allconfig.go b/config/allconfig/allconfig.go new file mode 100644 index 000000000..0db0be1d8 --- /dev/null +++ b/config/allconfig/allconfig.go @@ -0,0 +1,1182 @@ +// Copyright 2024 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 allconfig contains the full configuration for Hugo. +// { "name": "Configuration", "description": "This section holds all configuration options in Hugo." } +package allconfig + +import ( + "errors" + "fmt" + "reflect" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/gohugoio/hugo/cache/filecache" + "github.com/gohugoio/hugo/cache/httpcache" + "github.com/gohugoio/hugo/common/hugo" + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/common/paths" + "github.com/gohugoio/hugo/common/types" + "github.com/gohugoio/hugo/common/urls" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/config/privacy" + "github.com/gohugoio/hugo/config/security" + "github.com/gohugoio/hugo/config/services" + "github.com/gohugoio/hugo/deploy/deployconfig" + "github.com/gohugoio/hugo/helpers" + "github.com/gohugoio/hugo/hugolib/segments" + "github.com/gohugoio/hugo/langs" + "github.com/gohugoio/hugo/markup/markup_config" + "github.com/gohugoio/hugo/media" + "github.com/gohugoio/hugo/minifiers" + "github.com/gohugoio/hugo/modules" + "github.com/gohugoio/hugo/navigation" + "github.com/gohugoio/hugo/output" + "github.com/gohugoio/hugo/related" + "github.com/gohugoio/hugo/resources/images" + "github.com/gohugoio/hugo/resources/kinds" + "github.com/gohugoio/hugo/resources/page" + "github.com/gohugoio/hugo/resources/page/pagemeta" + "github.com/spf13/afero" + + xmaps "golang.org/x/exp/maps" +) + +// InternalConfig is the internal configuration for Hugo, not read from any user provided config file. +type InternalConfig struct { + // Server mode? + Running bool + + Quiet bool + Verbose bool + Clock string + Watch bool + FastRenderMode bool + LiveReloadPort int +} + +// All non-params config keys for language. +var configLanguageKeys map[string]bool + +func init() { + skip := map[string]bool{ + "internal": true, + "c": true, + "rootconfig": true, + } + configLanguageKeys = make(map[string]bool) + addKeys := func(v reflect.Value) { + for i := range v.NumField() { + name := strings.ToLower(v.Type().Field(i).Name) + if skip[name] { + continue + } + configLanguageKeys[name] = true + } + } + addKeys(reflect.ValueOf(Config{})) + addKeys(reflect.ValueOf(RootConfig{})) + addKeys(reflect.ValueOf(config.CommonDirs{})) + addKeys(reflect.ValueOf(langs.LanguageConfig{})) +} + +type Config struct { + // For internal use only. + Internal InternalConfig `mapstructure:"-" json:"-"` + // For internal use only. + C *ConfigCompiled `mapstructure:"-" json:"-"` + + RootConfig + + // Author information. + // Deprecated: Use taxonomies instead. + Author map[string]any + + // Social links. + // Deprecated: Use .Site.Params instead. + Social map[string]string + + // The build configuration section contains build-related configuration options. + // {"identifiers": ["build"] } + Build config.BuildConfig `mapstructure:"-"` + + // The caches configuration section contains cache-related configuration options. + // {"identifiers": ["caches"] } + Caches filecache.Configs `mapstructure:"-"` + + // The httpcache configuration section contains HTTP-cache-related configuration options. + // {"identifiers": ["httpcache"] } + HTTPCache httpcache.Config `mapstructure:"-"` + + // The markup configuration section contains markup-related configuration options. + // {"identifiers": ["markup"] } + Markup markup_config.Config `mapstructure:"-"` + + // ContentTypes are the media types that's considered content in Hugo. + ContentTypes *config.ConfigNamespace[map[string]media.ContentTypeConfig, media.ContentTypes] `mapstructure:"-"` + + // The mediatypes configuration section maps the MIME type (a string) to a configuration object for that type. + // {"identifiers": ["mediatypes"], "refs": ["types:media:type"] } + MediaTypes *config.ConfigNamespace[map[string]media.MediaTypeConfig, media.Types] `mapstructure:"-"` + + Imaging *config.ConfigNamespace[images.ImagingConfig, images.ImagingConfigInternal] `mapstructure:"-"` + + // The outputformats configuration sections maps a format name (a string) to a configuration object for that format. + OutputFormats *config.ConfigNamespace[map[string]output.OutputFormatConfig, output.Formats] `mapstructure:"-"` + + // The outputs configuration section maps a Page Kind (a string) to a slice of output formats. + // This can be overridden in the front matter. + Outputs map[string][]string `mapstructure:"-"` + + // The cascade configuration section contains the top level front matter cascade configuration options, + // a slice of page matcher and params to apply to those pages. + Cascade *config.ConfigNamespace[[]page.PageMatcherParamsConfig, *maps.Ordered[page.PageMatcher, page.PageMatcherParamsConfig]] `mapstructure:"-"` + + // The segments defines segments for the site. Used for partial/segmented builds. + Segments *config.ConfigNamespace[map[string]segments.SegmentConfig, segments.Segments] `mapstructure:"-"` + + // Menu configuration. + // {"refs": ["config:languages:menus"] } + Menus *config.ConfigNamespace[map[string]navigation.MenuConfig, navigation.Menus] `mapstructure:"-"` + + // The deployment configuration section contains for hugo deployconfig. + Deployment deployconfig.DeployConfig `mapstructure:"-"` + + // Module configuration. + Module modules.Config `mapstructure:"-"` + + // Front matter configuration. + Frontmatter pagemeta.FrontmatterConfig `mapstructure:"-"` + + // Minification configuration. + Minify minifiers.MinifyConfig `mapstructure:"-"` + + // Permalink configuration. + Permalinks map[string]map[string]string `mapstructure:"-"` + + // Taxonomy configuration. + Taxonomies map[string]string `mapstructure:"-"` + + // Sitemap configuration. + Sitemap config.SitemapConfig `mapstructure:"-"` + + // Related content configuration. + Related related.Config `mapstructure:"-"` + + // Server configuration. + Server config.Server `mapstructure:"-"` + + // Pagination configuration. + Pagination config.Pagination `mapstructure:"-"` + + // Page configuration. + Page config.PageConfig `mapstructure:"-"` + + // Privacy configuration. + Privacy privacy.Config `mapstructure:"-"` + + // Security configuration. + Security security.Config `mapstructure:"-"` + + // Services configuration. + Services services.Config `mapstructure:"-"` + + // User provided parameters. + // {"refs": ["config:languages:params"] } + Params maps.Params `mapstructure:"-"` + + // The languages configuration sections maps a language code (a string) to a configuration object for that language. + Languages map[string]langs.LanguageConfig `mapstructure:"-"` + + // UglyURLs configuration. Either a boolean or a sections map. + UglyURLs any `mapstructure:"-"` +} + +type configCompiler interface { + CompileConfig(logger loggers.Logger) error +} + +func (c Config) cloneForLang() *Config { + x := c + x.C = nil + copyStringSlice := func(in []string) []string { + if in == nil { + return nil + } + out := make([]string, len(in)) + copy(out, in) + return out + } + + // Copy all the slices to avoid sharing. + x.DisableKinds = copyStringSlice(x.DisableKinds) + x.DisableLanguages = copyStringSlice(x.DisableLanguages) + x.MainSections = copyStringSlice(x.MainSections) + x.IgnoreLogs = copyStringSlice(x.IgnoreLogs) + x.IgnoreFiles = copyStringSlice(x.IgnoreFiles) + x.Theme = copyStringSlice(x.Theme) + + // Collapse all static dirs to one. + x.StaticDir = x.staticDirs() + // These will go away soon ... + x.StaticDir0 = nil + x.StaticDir1 = nil + x.StaticDir2 = nil + x.StaticDir3 = nil + x.StaticDir4 = nil + x.StaticDir5 = nil + x.StaticDir6 = nil + x.StaticDir7 = nil + x.StaticDir8 = nil + x.StaticDir9 = nil + x.StaticDir10 = nil + + return &x +} + +func (c *Config) CompileConfig(logger loggers.Logger) error { + var transientErr error + s := c.Timeout + if _, err := strconv.Atoi(s); err == nil { + // A number, assume seconds. + s = s + "s" + } + timeout, err := time.ParseDuration(s) + if err != nil { + return fmt.Errorf("failed to parse timeout: %s", err) + } + disabledKinds := make(map[string]bool) + for _, kind := range c.DisableKinds { + kind = strings.ToLower(kind) + if newKind := kinds.IsDeprecatedAndReplacedWith(kind); newKind != "" { + logger.Deprecatef(false, "Kind %q used in disableKinds is deprecated, use %q instead.", kind, newKind) + // Legacy config. + kind = newKind + } + if kinds.GetKindAny(kind) == "" { + logger.Warnf("Unknown kind %q in disableKinds configuration.", kind) + continue + } + disabledKinds[kind] = true + } + kindOutputFormats := make(map[string]output.Formats) + isRssDisabled := disabledKinds["rss"] + outputFormats := c.OutputFormats.Config + for kind, formats := range c.Outputs { + if newKind := kinds.IsDeprecatedAndReplacedWith(kind); newKind != "" { + logger.Deprecatef(false, "Kind %q used in outputs configuration is deprecated, use %q instead.", kind, newKind) + kind = newKind + } + if disabledKinds[kind] { + continue + } + if kinds.GetKindAny(kind) == "" { + logger.Warnf("Unknown kind %q in outputs configuration.", kind) + continue + } + for _, format := range formats { + if isRssDisabled && format == "rss" { + // Legacy config. + continue + } + f, found := outputFormats.GetByName(format) + if !found { + transientErr = fmt.Errorf("unknown output format %q for kind %q", format, kind) + continue + } + kindOutputFormats[kind] = append(kindOutputFormats[kind], f) + } + } + + defaultOutputFormat := outputFormats[0] + c.DefaultOutputFormat = strings.ToLower(c.DefaultOutputFormat) + if c.DefaultOutputFormat != "" { + f, found := outputFormats.GetByName(c.DefaultOutputFormat) + if !found { + return fmt.Errorf("unknown default output format %q", c.DefaultOutputFormat) + } + defaultOutputFormat = f + } else { + c.DefaultOutputFormat = defaultOutputFormat.Name + } + + disabledLangs := make(map[string]bool) + for _, lang := range c.DisableLanguages { + disabledLangs[lang] = true + } + for lang, language := range c.Languages { + if !language.Disabled && disabledLangs[lang] { + language.Disabled = true + c.Languages[lang] = language + } + if language.Disabled { + disabledLangs[lang] = true + if lang == c.DefaultContentLanguage { + return fmt.Errorf("cannot disable default content language %q", lang) + } + } + } + + for i, s := range c.IgnoreLogs { + c.IgnoreLogs[i] = strings.ToLower(s) + } + + ignoredLogIDs := make(map[string]bool) + for _, err := range c.IgnoreLogs { + ignoredLogIDs[err] = true + } + + baseURL, err := urls.NewBaseURLFromString(c.BaseURL) + if err != nil { + return err + } + + isUglyURL := func(section string) bool { + switch v := c.UglyURLs.(type) { + case bool: + return v + case map[string]bool: + return v[section] + default: + return false + } + } + + ignoreFile := func(s string) bool { + return false + } + if len(c.IgnoreFiles) > 0 { + regexps := make([]*regexp.Regexp, len(c.IgnoreFiles)) + for i, pattern := range c.IgnoreFiles { + var err error + regexps[i], err = regexp.Compile(pattern) + if err != nil { + return fmt.Errorf("failed to compile ignoreFiles pattern %q: %s", pattern, err) + } + } + ignoreFile = func(s string) bool { + for _, r := range regexps { + if r.MatchString(s) { + return true + } + } + return false + } + } + + var clock time.Time + if c.Internal.Clock != "" { + var err error + clock, err = time.Parse(time.RFC3339, c.Internal.Clock) + if err != nil { + return fmt.Errorf("failed to parse clock: %s", err) + } + } + + httpCache, err := c.HTTPCache.Compile() + if err != nil { + return err + } + + // Legacy paginate values. + if c.Paginate != 0 { + hugo.DeprecateWithLogger("site config key paginate", "Use pagination.pagerSize instead.", "v0.128.0", logger.Logger()) + c.Pagination.PagerSize = c.Paginate + } + + if c.PaginatePath != "" { + hugo.DeprecateWithLogger("site config key paginatePath", "Use pagination.path instead.", "v0.128.0", logger.Logger()) + c.Pagination.Path = c.PaginatePath + } + + // Legacy privacy values. + if c.Privacy.Twitter.Disable { + hugo.DeprecateWithLogger("site 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("site 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("site 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("site 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()) + } + + c.C = &ConfigCompiled{ + Timeout: timeout, + BaseURL: baseURL, + BaseURLLiveReload: baseURL, + DisabledKinds: disabledKinds, + DisabledLanguages: disabledLangs, + IgnoredLogs: ignoredLogIDs, + KindOutputFormats: kindOutputFormats, + DefaultOutputFormat: defaultOutputFormat, + CreateTitle: helpers.GetTitleFunc(c.TitleCaseStyle), + IsUglyURLSection: isUglyURL, + IgnoreFile: ignoreFile, + SegmentFilter: c.Segments.Config.Get(func(s string) { logger.Warnf("Render segment %q not found in configuration", s) }, c.RootConfig.RenderSegments...), + MainSections: c.MainSections, + Clock: clock, + HTTPCache: httpCache, + transientErr: transientErr, + } + + for _, s := range allDecoderSetups { + if getCompiler := s.getCompiler; getCompiler != nil { + if err := getCompiler(c).CompileConfig(logger); err != nil { + return err + } + } + } + + return nil +} + +func (c *Config) IsKindEnabled(kind string) bool { + return !c.C.DisabledKinds[kind] +} + +func (c *Config) IsLangDisabled(lang string) bool { + return c.C.DisabledLanguages[lang] +} + +// ConfigCompiled holds values and functions that are derived from the config. +type ConfigCompiled struct { + Timeout time.Duration + BaseURL urls.BaseURL + BaseURLLiveReload urls.BaseURL + ServerInterface string + KindOutputFormats map[string]output.Formats + DefaultOutputFormat output.Format + DisabledKinds map[string]bool + DisabledLanguages map[string]bool + IgnoredLogs map[string]bool + CreateTitle func(s string) string + IsUglyURLSection func(section string) bool + IgnoreFile func(filename string) bool + SegmentFilter segments.SegmentFilter + MainSections []string + Clock time.Time + HTTPCache httpcache.ConfigCompiled + + // This is set to the last transient error found during config compilation. + // With themes/modules we compute the configuration in multiple passes, and + // errors with missing output format definitions may resolve itself. + transientErr error + + mu sync.Mutex +} + +// This may be set after the config is compiled. +func (c *ConfigCompiled) SetMainSections(sections []string) { + c.mu.Lock() + defer c.mu.Unlock() + c.MainSections = sections +} + +// IsMainSectionsSet returns whether the main sections have been set. +func (c *ConfigCompiled) IsMainSectionsSet() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.MainSections != nil +} + +// This is set after the config is compiled by the server command. +func (c *ConfigCompiled) SetServerInfo(baseURL, baseURLLiveReload urls.BaseURL, serverInterface string) { + c.BaseURL = baseURL + c.BaseURLLiveReload = baseURLLiveReload + c.ServerInterface = serverInterface +} + +// RootConfig holds all the top-level configuration options in Hugo +type RootConfig struct { + // The base URL of the site. + // Note that the default value is empty, but Hugo requires a valid URL (e.g. "https://example.com/") to work properly. + // {"identifiers": ["URL"] } + BaseURL string + + // Whether to build content marked as draft.X + // {"identifiers": ["draft"] } + BuildDrafts bool + + // Whether to build content with expiryDate in the past. + // {"identifiers": ["expiryDate"] } + BuildExpired bool + + // Whether to build content with publishDate in the future. + // {"identifiers": ["publishDate"] } + BuildFuture bool + + // Copyright information. + Copyright string + + // The language to apply to content without any language indicator. + DefaultContentLanguage string + + // By default, we put the default content language in the root and the others below their language ID, e.g. /no/. + // Set this to true to put all languages below their language ID. + DefaultContentLanguageInSubdir bool + + // The default output format to use for the site. + // If not set, we will use the first output format. + DefaultOutputFormat string + + // Disable generation of redirect to the default language when DefaultContentLanguageInSubdir is enabled. + DisableDefaultLanguageRedirect bool + + // Disable creation of alias redirect pages. + DisableAliases bool + + // Disable lower casing of path segments. + DisablePathToLower bool + + // Disable page kinds from build. + DisableKinds []string + + // A list of languages to disable. + DisableLanguages []string + + // The named segments to render. + // This needs to match the name of the segment in the segments configuration. + RenderSegments []string + + // Disable the injection of the Hugo generator tag on the home page. + DisableHugoGeneratorInject bool + + // Disable live reloading in server mode. + DisableLiveReload bool + + // Enable replacement in Pages' Content of Emoji shortcodes with their equivalent Unicode characters. + // {"identifiers": ["Content", "Unicode"] } + EnableEmoji bool + + // THe main section(s) of the site. + // If not set, Hugo will try to guess this from the content. + MainSections []string + + // Enable robots.txt generation. + EnableRobotsTXT bool + + // When enabled, Hugo will apply Git version information to each Page if possible, which + // can be used to keep lastUpdated in synch and to print version information. + // {"identifiers": ["Page"] } + EnableGitInfo bool + + // Enable to track, calculate and print metrics. + TemplateMetrics bool + + // Enable to track, print and calculate metric hints. + TemplateMetricsHints bool + + // Enable to disable the build lock file. + NoBuildLock bool + + // A list of log IDs to ignore. + IgnoreLogs []string + + // A list of regexps that match paths to ignore. + // Deprecated: Use the settings on module imports. + IgnoreFiles []string + + // Ignore cache. + IgnoreCache bool + + // Enable to print greppable placeholders (on the form "[i18n] TRANSLATIONID") for missing translation strings. + EnableMissingTranslationPlaceholders bool + + // Enable to panic on warning log entries. This may make it easier to detect the source. + PanicOnWarning bool + + // The configured environment. Default is "development" for server and "production" for build. + Environment string + + // The default language code. + LanguageCode string + + // Enable if the site content has CJK language (Chinese, Japanese, or Korean). This affects how Hugo counts words. + HasCJKLanguage bool + + // The default number of pages per page when paginating. + // Deprecated: Use the Pagination struct. + Paginate int + + // The path to use when creating pagination URLs, e.g. "page" in /page/2/. + // Deprecated: Use the Pagination struct. + PaginatePath string + + // Whether to pluralize default list titles. + // Note that this currently only works for English, but you can provide your own title in the content file's front matter. + PluralizeListTitles bool + + // Whether to capitalize automatic page titles, applicable to section, taxonomy, and term pages. + CapitalizeListTitles bool + + // Make all relative URLs absolute using the baseURL. + // {"identifiers": ["baseURL"] } + CanonifyURLs bool + + // Enable this to make all relative URLs relative to content root. Note that this does not affect absolute URLs. + RelativeURLs bool + + // Removes non-spacing marks from composite characters in content paths. + RemovePathAccents bool + + // Whether to track and print unused templates during the build. + PrintUnusedTemplates bool + + // Enable to print warnings for missing translation strings. + PrintI18nWarnings bool + + // ENable to print warnings for multiple files published to the same destination. + PrintPathWarnings bool + + // URL to be used as a placeholder when a page reference cannot be found in ref or relref. Is used as-is. + RefLinksNotFoundURL string + + // When using ref or relref to resolve page links and a link cannot be resolved, it will be logged with this log level. + // Valid values are ERROR (default) or WARNING. Any ERROR will fail the build (exit -1). + RefLinksErrorLevel string + + // This will create a menu with all the sections as menu items and all the sections’ pages as “shadow-members”. + SectionPagesMenu string + + // The length of text in words to show in a .Summary. + SummaryLength int + + // The site title. + Title string + + // The theme(s) to use. + // See Modules for more a more flexible way to load themes. + Theme []string + + // Timeout for generating page contents, specified as a duration or in seconds. + Timeout string + + // The time zone (or location), e.g. Europe/Oslo, used to parse front matter dates without such information and in the time function. + TimeZone string + + // Set titleCaseStyle to specify the title style used by the title template function and the automatic section titles in Hugo. + // It defaults to AP Stylebook for title casing, but you can also set it to Chicago or Go (every word starts with a capital letter). + TitleCaseStyle string + + // The editor used for opening up new content. + NewContentEditor string + + // Don't sync modification time of files for the static mounts. + NoTimes bool + + // Don't sync modification time of files for the static mounts. + NoChmod bool + + // Clean the destination folder before a new build. + // This currently only handles static files. + CleanDestinationDir bool + + // A Glob pattern of module paths to ignore in the _vendor folder. + IgnoreVendorPaths string + + config.CommonDirs `mapstructure:",squash"` + + // The odd constructs below are kept for backwards compatibility. + // Deprecated: Use module mount config instead. + StaticDir []string + // Deprecated: Use module mount config instead. + StaticDir0 []string + // Deprecated: Use module mount config instead. + StaticDir1 []string + // Deprecated: Use module mount config instead. + StaticDir2 []string + // Deprecated: Use module mount config instead. + StaticDir3 []string + // Deprecated: Use module mount config instead. + StaticDir4 []string + // Deprecated: Use module mount config instead. + StaticDir5 []string + // Deprecated: Use module mount config instead. + StaticDir6 []string + // Deprecated: Use module mount config instead. + StaticDir7 []string + // Deprecated: Use module mount config instead. + StaticDir8 []string + // Deprecated: Use module mount config instead. + StaticDir9 []string + // Deprecated: Use module mount config instead. + StaticDir10 []string +} + +func (c RootConfig) staticDirs() []string { + var dirs []string + dirs = append(dirs, c.StaticDir...) + dirs = append(dirs, c.StaticDir0...) + dirs = append(dirs, c.StaticDir1...) + dirs = append(dirs, c.StaticDir2...) + dirs = append(dirs, c.StaticDir3...) + dirs = append(dirs, c.StaticDir4...) + dirs = append(dirs, c.StaticDir5...) + dirs = append(dirs, c.StaticDir6...) + dirs = append(dirs, c.StaticDir7...) + dirs = append(dirs, c.StaticDir8...) + dirs = append(dirs, c.StaticDir9...) + dirs = append(dirs, c.StaticDir10...) + return helpers.UniqueStringsReuse(dirs) +} + +type Configs struct { + Base *Config + LoadingInfo config.LoadConfigResult + LanguageConfigMap map[string]*Config + LanguageConfigSlice []*Config + + IsMultihost bool + + Modules modules.Modules + ModulesClient *modules.Client + + // All below is set in Init. + Languages langs.Languages + LanguagesDefaultFirst langs.Languages + ContentPathParser *paths.PathParser + + configLangs []config.AllProvider +} + +func (c *Configs) Validate(logger loggers.Logger) error { + c.Base.Cascade.Config.Range(func(p page.PageMatcher, cfg page.PageMatcherParamsConfig) bool { + page.CheckCascadePattern(logger, p) + return true + }) + return nil +} + +// transientErr returns the last transient error found during config compilation. +func (c *Configs) transientErr() error { + for _, l := range c.LanguageConfigMap { + if l.C.transientErr != nil { + return l.C.transientErr + } + } + return nil +} + +func (c *Configs) IsZero() bool { + // A config always has at least one language. + return c == nil || len(c.Languages) == 0 +} + +func (c *Configs) Init() error { + var languages langs.Languages + + var langKeys []string + var hasEn bool + + const en = "en" + + for k := range c.LanguageConfigMap { + langKeys = append(langKeys, k) + if k == en { + hasEn = true + } + } + + // Sort the LanguageConfigSlice by language weight (if set) or lang. + sort.Slice(langKeys, func(i, j int) bool { + ki := langKeys[i] + kj := langKeys[j] + lki := c.LanguageConfigMap[ki] + lkj := c.LanguageConfigMap[kj] + li := lki.Languages[ki] + lj := lkj.Languages[kj] + if li.Weight != lj.Weight { + return li.Weight < lj.Weight + } + return ki < kj + }) + + // See issue #13646. + defaultConfigLanguageFallback := en + if !hasEn { + // Pick the first one. + defaultConfigLanguageFallback = langKeys[0] + } + + if c.Base.DefaultContentLanguage == "" { + c.Base.DefaultContentLanguage = defaultConfigLanguageFallback + } + + for _, k := range langKeys { + v := c.LanguageConfigMap[k] + if v.DefaultContentLanguage == "" { + v.DefaultContentLanguage = defaultConfigLanguageFallback + } + c.LanguageConfigSlice = append(c.LanguageConfigSlice, v) + languageConf := v.Languages[k] + language, err := langs.NewLanguage(k, c.Base.DefaultContentLanguage, v.TimeZone, languageConf) + if err != nil { + return err + } + languages = append(languages, language) + } + + // Filter out disabled languages. + var n int + for _, l := range languages { + if !l.Disabled { + languages[n] = l + n++ + } + } + languages = languages[:n] + + var languagesDefaultFirst langs.Languages + for _, l := range languages { + if l.Lang == c.Base.DefaultContentLanguage { + languagesDefaultFirst = append(languagesDefaultFirst, l) + } + } + for _, l := range languages { + if l.Lang != c.Base.DefaultContentLanguage { + languagesDefaultFirst = append(languagesDefaultFirst, l) + } + } + + c.Languages = languages + c.LanguagesDefaultFirst = languagesDefaultFirst + + c.ContentPathParser = &paths.PathParser{ + LanguageIndex: languagesDefaultFirst.AsIndexSet(), + IsLangDisabled: c.Base.IsLangDisabled, + IsContentExt: c.Base.ContentTypes.Config.IsContentSuffix, + IsOutputFormat: func(name, ext string) bool { + if name == "" { + return false + } + + if of, ok := c.Base.OutputFormats.Config.GetByName(name); ok { + if ext != "" && !of.MediaType.HasSuffix(ext) { + return false + } + return true + } + return false + }, + } + + c.configLangs = make([]config.AllProvider, len(c.Languages)) + for i, l := range c.LanguagesDefaultFirst { + c.configLangs[i] = ConfigLanguage{ + m: c, + config: c.LanguageConfigMap[l.Lang], + baseConfig: c.LoadingInfo.BaseConfig, + language: l, + } + } + + if len(c.Modules) == 0 { + return errors.New("no modules loaded (need at least the main module)") + } + + // Apply default project mounts. + if err := modules.ApplyProjectConfigDefaults(c.Modules[0], c.configLangs...); err != nil { + return err + } + + // We should consolidate this, but to get a full view of the mounts in e.g. "hugo config" we need to + // transfer any default mounts added above to the config used to print the config. + for _, m := range c.Modules[0].Mounts() { + var found bool + for _, cm := range c.Base.Module.Mounts { + if cm.Source == m.Source && cm.Target == m.Target && cm.Lang == m.Lang { + found = true + break + } + } + if !found { + c.Base.Module.Mounts = append(c.Base.Module.Mounts, m) + } + } + + // Transfer the changed mounts to the language versions (all share the same mount set, but can be displayed in different languages). + for _, l := range c.LanguageConfigSlice { + l.Module.Mounts = c.Base.Module.Mounts + } + + return nil +} + +func (c Configs) ConfigLangs() []config.AllProvider { + return c.configLangs +} + +func (c Configs) GetFirstLanguageConfig() config.AllProvider { + return c.configLangs[0] +} + +func (c Configs) GetByLang(lang string) config.AllProvider { + for _, l := range c.configLangs { + if l.Language().Lang == lang { + return l + } + } + return nil +} + +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", + + CommonDirs: config.CommonDirs{ + ArcheTypeDir: "archetypes", + ContentDir: "content", + ResourceDir: "resources", + PublishDir: "public", + ThemesDir: "themes", + AssetDir: "assets", + LayoutDir: "layouts", + I18nDir: "i18n", + DataDir: "data", + }, + }, + } +} + +// fromLoadConfigResult creates a new Config from res. +func fromLoadConfigResult(fs afero.Fs, logger loggers.Logger, res config.LoadConfigResult) (*Configs, error) { + if !res.Cfg.IsSet("languages") { + // We need at least one + lang := res.Cfg.GetString("defaultContentLanguage") + if lang == "" { + lang = "en" + } + res.Cfg.Set("languages", maps.Params{lang: maps.Params{}}) + } + bcfg := res.BaseConfig + cfg := res.Cfg + + all := newDefaultConfig() + + err := decodeConfigFromParams(fs, logger, bcfg, cfg, all, nil) + if err != nil { + return nil, err + } + + langConfigMap := make(map[string]*Config) + + languagesConfig := cfg.GetStringMap("languages") + + var isMultihost bool + + if err := all.CompileConfig(logger); err != nil { + return nil, err + } + + for k, v := range languagesConfig { + mergedConfig := config.New() + var differentRootKeys []string + switch x := v.(type) { + case maps.Params: + _, found := x["params"] + if !found { + x["params"] = maps.Params{ + maps.MergeStrategyKey: maps.ParamsMergeStrategyDeep, + } + } + + for kk, vv := range x { + if kk == "_merge" { + continue + } + if kk == "baseurl" { + // baseURL configure don the language level is a multihost setup. + isMultihost = true + } + mergedConfig.Set(kk, vv) + rootv := cfg.Get(kk) + if rootv != nil && cfg.IsSet(kk) { + // This overrides a root key and potentially needs a merge. + if !reflect.DeepEqual(rootv, vv) { + switch vvv := vv.(type) { + case maps.Params: + differentRootKeys = append(differentRootKeys, kk) + + // Use the language value as base. + mergedConfigEntry := xmaps.Clone(vvv) + // Merge in the root value. + maps.MergeParams(mergedConfigEntry, rootv.(maps.Params)) + + mergedConfig.Set(kk, mergedConfigEntry) + default: + // Apply new values to the root. + differentRootKeys = append(differentRootKeys, "") + } + } + } else { + switch vv.(type) { + case maps.Params: + differentRootKeys = append(differentRootKeys, kk) + default: + // Apply new values to the root. + differentRootKeys = append(differentRootKeys, "") + } + } + } + differentRootKeys = helpers.UniqueStringsSorted(differentRootKeys) + + if len(differentRootKeys) == 0 { + langConfigMap[k] = all + continue + } + + // Create a copy of the complete config and replace the root keys with the language specific ones. + clone := all.cloneForLang() + + if err := decodeConfigFromParams(fs, logger, bcfg, mergedConfig, clone, differentRootKeys); err != nil { + return nil, fmt.Errorf("failed to decode config for language %q: %w", k, err) + } + if err := clone.CompileConfig(logger); err != nil { + return nil, err + } + + // Adjust Goldmark config defaults for multilingual, single-host sites. + if len(languagesConfig) > 1 && !isMultihost && !clone.Markup.Goldmark.DuplicateResourceFiles { + if !clone.Markup.Goldmark.DuplicateResourceFiles { + if clone.Markup.Goldmark.RenderHooks.Link.EnableDefault == nil { + clone.Markup.Goldmark.RenderHooks.Link.EnableDefault = types.NewBool(true) + } + if clone.Markup.Goldmark.RenderHooks.Image.EnableDefault == nil { + clone.Markup.Goldmark.RenderHooks.Image.EnableDefault = types.NewBool(true) + } + } + } + + langConfigMap[k] = clone + case maps.ParamsMergeStrategy: + default: + panic(fmt.Sprintf("unknown type in languages config: %T", v)) + + } + } + + bcfg.PublishDir = all.PublishDir + res.BaseConfig = bcfg + all.CommonDirs.CacheDir = bcfg.CacheDir + for _, l := range langConfigMap { + l.CommonDirs.CacheDir = bcfg.CacheDir + } + + cm := &Configs{ + Base: all, + LanguageConfigMap: langConfigMap, + LoadingInfo: res, + IsMultihost: isMultihost, + } + + return cm, nil +} + +func decodeConfigFromParams(fs afero.Fs, logger loggers.Logger, bcfg config.BaseConfig, p config.Provider, target *Config, keys []string) error { + var decoderSetups []decodeWeight + + if len(keys) == 0 { + for _, v := range allDecoderSetups { + decoderSetups = append(decoderSetups, v) + } + } else { + for _, key := range keys { + if v, found := allDecoderSetups[key]; found { + decoderSetups = append(decoderSetups, v) + } else { + logger.Warnf("Skip unknown config key %q", key) + } + } + } + + // Sort them to get the dependency order right. + sort.Slice(decoderSetups, func(i, j int) bool { + ki, kj := decoderSetups[i], decoderSetups[j] + if ki.weight == kj.weight { + return ki.key < kj.key + } + return ki.weight < kj.weight + }) + + for _, v := range decoderSetups { + p := decodeConfig{p: p, c: target, fs: fs, bcfg: bcfg} + if err := v.decode(v, p); err != nil { + return fmt.Errorf("failed to decode %q: %w", v.key, err) + } + } + + return nil +} + +func createDefaultOutputFormats(allFormats output.Formats) map[string][]string { + if len(allFormats) == 0 { + panic("no output formats") + } + rssOut, rssFound := allFormats.GetByName(output.RSSFormat.Name) + htmlOut, _ := allFormats.GetByName(output.HTMLFormat.Name) + + defaultListTypes := []string{htmlOut.Name} + if rssFound { + defaultListTypes = append(defaultListTypes, rssOut.Name) + } + + m := map[string][]string{ + kinds.KindPage: {htmlOut.Name}, + kinds.KindHome: defaultListTypes, + kinds.KindSection: defaultListTypes, + kinds.KindTerm: defaultListTypes, + kinds.KindTaxonomy: defaultListTypes, + } + + // May be disabled + if rssFound { + m["rss"] = []string{rssOut.Name} + } + + return m +} diff --git a/config/allconfig/allconfig_integration_test.go b/config/allconfig/allconfig_integration_test.go new file mode 100644 index 000000000..8f6cacf84 --- /dev/null +++ b/config/allconfig/allconfig_integration_test.go @@ -0,0 +1,381 @@ +package allconfig_test + +import ( + "path/filepath" + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/common/hugo" + "github.com/gohugoio/hugo/config/allconfig" + "github.com/gohugoio/hugo/hugolib" + "github.com/gohugoio/hugo/media" +) + +func TestDirsMount(t *testing.T) { + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ["taxonomy", "term"] +[languages] +[languages.en] +weight = 1 +[languages.sv] +weight = 2 +[[module.mounts]] +source = 'content/en' +target = 'content' +lang = 'en' +[[module.mounts]] +source = 'content/sv' +target = 'content' +lang = 'sv' +-- content/en/p1.md -- +--- +title: "p1" +--- +-- content/sv/p1.md -- +--- +title: "p1" +--- +-- layouts/_default/single.html -- +Title: {{ .Title }} + ` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{T: t, TxtarString: files}, + ).Build() + + // b.AssertFileContent("public/p1/index.html", "Title: p1") + + sites := b.H.Sites + b.Assert(len(sites), qt.Equals, 2) + + configs := b.H.Configs + mods := configs.Modules + b.Assert(len(mods), qt.Equals, 1) + mod := mods[0] + b.Assert(mod.Mounts(), qt.HasLen, 8) + + enConcp := sites[0].Conf + enConf := enConcp.GetConfig().(*allconfig.Config) + + b.Assert(enConcp.BaseURL().String(), qt.Equals, "https://example.com/") + modConf := enConf.Module + b.Assert(modConf.Mounts, qt.HasLen, 8) + b.Assert(modConf.Mounts[0].Source, qt.Equals, filepath.FromSlash("content/en")) + b.Assert(modConf.Mounts[0].Target, qt.Equals, "content") + b.Assert(modConf.Mounts[0].Lang, qt.Equals, "en") + b.Assert(modConf.Mounts[1].Source, qt.Equals, filepath.FromSlash("content/sv")) + b.Assert(modConf.Mounts[1].Target, qt.Equals, "content") + b.Assert(modConf.Mounts[1].Lang, qt.Equals, "sv") +} + +func TestConfigAliases(t *testing.T) { + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +logI18nWarnings = true +logPathWarnings = true +` + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{T: t, TxtarString: files}, + ).Build() + + conf := b.H.Configs.Base + + b.Assert(conf.PrintI18nWarnings, qt.Equals, true) + b.Assert(conf.PrintPathWarnings, qt.Equals, true) +} + +func TestRedefineContentTypes(t *testing.T) { + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +[mediaTypes] +[mediaTypes."text/html"] +suffixes = ["html", "xhtml"] +` + + b := hugolib.Test(t, files) + + conf := b.H.Configs.Base + contentTypes := conf.ContentTypes.Config + + b.Assert(contentTypes.HTML.Suffixes(), qt.DeepEquals, []string{"html", "xhtml"}) + b.Assert(contentTypes.Markdown.Suffixes(), qt.DeepEquals, []string{"md", "mdown", "markdown"}) +} + +func TestPaginationConfig(t *testing.T) { + files := ` +-- hugo.toml -- + [languages.en] + weight = 1 + [languages.en.pagination] + pagerSize = 20 + [languages.de] + weight = 2 + [languages.de.pagination] + path = "page-de" + +` + + b := hugolib.Test(t, files) + + confEn := b.H.Sites[0].Conf.Pagination() + confDe := b.H.Sites[1].Conf.Pagination() + + b.Assert(confEn.Path, qt.Equals, "page") + b.Assert(confEn.PagerSize, qt.Equals, 20) + b.Assert(confDe.Path, qt.Equals, "page-de") + b.Assert(confDe.PagerSize, qt.Equals, 10) +} + +func TestPaginationConfigDisableAliases(t *testing.T) { + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term"] +[pagination] +disableAliases = true +pagerSize = 2 +-- layouts/_default/list.html -- +{{ $paginator := .Paginate site.RegularPages }} +{{ template "_internal/pagination.html" . }} +{{ range $paginator.Pages }} + {{ .Title }} +{{ end }} +-- content/p1.md -- +--- +title: "p1" +--- +-- content/p2.md -- +--- +title: "p2" +--- +-- content/p3.md -- +--- +title: "p3" +--- +` + + b := hugolib.Test(t, files) + + b.AssertFileExists("public/page/1/index.html", false) + b.AssertFileContent("public/page/2/index.html", "pagination-default") +} + +func TestMapUglyURLs(t *testing.T) { + files := ` +-- hugo.toml -- +[uglyurls] + posts = true +` + + b := hugolib.Test(t, files) + + c := b.H.Configs.Base + + b.Assert(c.C.IsUglyURLSection("posts"), qt.IsTrue) + b.Assert(c.C.IsUglyURLSection("blog"), qt.IsFalse) +} + +// Issue 13199 +func TestInvalidOutputFormat(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['page','rss','section','sitemap','taxonomy','term'] +[outputs] +home = ['html','foo'] +-- layouts/index.html -- +x +` + + b, err := hugolib.TestE(t, files) + b.Assert(err, qt.IsNotNil) + b.Assert(err.Error(), qt.Contains, `failed to create config: unknown output format "foo" for kind "home"`) +} + +// Issue 13201 +func TestLanguageConfigSlice(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['page','rss','section','sitemap','taxonomy','term'] +[languages.en] +title = 'TITLE_EN' +weight = 2 +[languages.de] +title = 'TITLE_DE' +weight = 1 +[languages.fr] +title = 'TITLE_FR' +weight = 3 +` + + b := hugolib.Test(t, files) + b.Assert(b.H.Configs.LanguageConfigSlice[0].Title, qt.Equals, `TITLE_DE`) +} + +func TestContentTypesDefault(t *testing.T) { + files := ` +-- hugo.toml -- +baseURL = "https://example.com" + + +` + + b := hugolib.Test(t, files) + + ct := b.H.Configs.Base.ContentTypes + c := ct.Config + s := ct.SourceStructure.(map[string]media.ContentTypeConfig) + + b.Assert(c.IsContentFile("foo.md"), qt.Equals, true) + b.Assert(len(s), qt.Equals, 6) +} + +func TestMergeDeep(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +theme = ["theme1", "theme2"] +_merge = "deep" +-- themes/theme1/hugo.toml -- +[sitemap] +filename = 'mysitemap.xml' +[services] +[services.googleAnalytics] +id = 'foo bar' +[taxonomies] + foo = 'bars' +-- themes/theme2/config/_default/hugo.toml -- +[taxonomies] + bar = 'baz' +-- layouts/home.html -- +GA ID: {{ site.Config.Services.GoogleAnalytics.ID }}. + +` + + b := hugolib.Test(t, files) + + conf := b.H.Configs + base := conf.Base + + b.Assert(base.Environment, qt.Equals, hugo.EnvironmentProduction) + b.Assert(base.BaseURL, qt.Equals, "https://example.com") + b.Assert(base.Sitemap.Filename, qt.Equals, "mysitemap.xml") + b.Assert(base.Taxonomies, qt.DeepEquals, map[string]string{"bar": "baz", "foo": "bars"}) + + b.AssertFileContent("public/index.html", "GA ID: foo bar.") +} + +func TestMergeDeepBuildStats(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +title = "Theme 1" +_merge = "deep" +[module] +[module.hugoVersion] +[[module.imports]] +path = "theme1" +-- themes/theme1/hugo.toml -- +[build] +[build.buildStats] +disableIDs = true +enable = true +-- layouts/home.html -- +Home. + +` + + b := hugolib.Test(t, files, hugolib.TestOptOsFs()) + + conf := b.H.Configs + base := conf.Base + + b.Assert(base.Title, qt.Equals, "Theme 1") + b.Assert(len(base.Module.Imports), qt.Equals, 1) + b.Assert(base.Build.BuildStats.Enable, qt.Equals, true) + b.AssertFileExists("/hugo_stats.json", true) +} + +func TestMergeDeepBuildStatsTheme(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +_merge = "deep" +theme = ["theme1"] +-- themes/theme1/hugo.toml -- +title = "Theme 1" +[build] +[build.buildStats] +disableIDs = true +enable = true +-- layouts/home.html -- +Home. + +` + + b := hugolib.Test(t, files, hugolib.TestOptOsFs()) + + conf := b.H.Configs + base := conf.Base + + b.Assert(base.Title, qt.Equals, "Theme 1") + b.Assert(len(base.Module.Imports), qt.Equals, 1) + b.Assert(base.Build.BuildStats.Enable, qt.Equals, true) + b.AssertFileExists("/hugo_stats.json", true) +} + +func TestDefaultConfigLanguageBlankWhenNoEnglishExists(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +[languages] +[languages.nn] +weight = 20 +[languages.sv] +weight = 10 +[languages.sv.taxonomies] + tag = "taggar" +-- layouts/all.html -- +All. +` + + b := hugolib.Test(t, files) + + b.Assert(b.H.Conf.DefaultContentLanguage(), qt.Equals, "sv") +} + +func TestDefaultConfigEnvDisableLanguagesIssue13707(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableLanguages = [] +[languages] +[languages.en] +weight = 1 +[languages.nn] +weight = 2 +[languages.sv] +weight = 3 +` + + b := hugolib.Test(t, files, hugolib.TestOptWithConfig(func(conf *hugolib.IntegrationTestConfig) { + conf.Environ = []string{`HUGO_DISABLELANGUAGES=sv nn`} + })) + + b.Assert(len(b.H.Sites), qt.Equals, 1) +} diff --git a/config/allconfig/alldecoders.go b/config/allconfig/alldecoders.go new file mode 100644 index 000000000..035349790 --- /dev/null +++ b/config/allconfig/alldecoders.go @@ -0,0 +1,469 @@ +// Copyright 2024 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 allconfig + +import ( + "fmt" + "strings" + + "github.com/gohugoio/hugo/cache/filecache" + + "github.com/gohugoio/hugo/cache/httpcache" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/common/types" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/config/privacy" + "github.com/gohugoio/hugo/config/security" + "github.com/gohugoio/hugo/config/services" + "github.com/gohugoio/hugo/deploy/deployconfig" + "github.com/gohugoio/hugo/hugolib/segments" + "github.com/gohugoio/hugo/langs" + "github.com/gohugoio/hugo/markup/markup_config" + "github.com/gohugoio/hugo/media" + "github.com/gohugoio/hugo/minifiers" + "github.com/gohugoio/hugo/modules" + + "github.com/gohugoio/hugo/navigation" + "github.com/gohugoio/hugo/output" + "github.com/gohugoio/hugo/related" + "github.com/gohugoio/hugo/resources/images" + "github.com/gohugoio/hugo/resources/page" + "github.com/gohugoio/hugo/resources/page/pagemeta" + "github.com/mitchellh/mapstructure" + "github.com/spf13/afero" + "github.com/spf13/cast" +) + +type decodeConfig struct { + p config.Provider + c *Config + fs afero.Fs + bcfg config.BaseConfig +} + +type decodeWeight struct { + key string + decode func(decodeWeight, decodeConfig) error + getCompiler func(c *Config) configCompiler + weight int + internalOrDeprecated bool // Hide it from the docs. +} + +var allDecoderSetups = map[string]decodeWeight{ + "": { + key: "", + weight: -100, // Always first. + decode: func(d decodeWeight, p decodeConfig) error { + if err := mapstructure.WeakDecode(p.p.Get(""), &p.c.RootConfig); err != nil { + return err + } + + // This need to match with Lang which is always lower case. + p.c.RootConfig.DefaultContentLanguage = strings.ToLower(p.c.RootConfig.DefaultContentLanguage) + + return nil + }, + }, + "imaging": { + key: "imaging", + decode: func(d decodeWeight, p decodeConfig) error { + var err error + p.c.Imaging, err = images.DecodeConfig(p.p.GetStringMap(d.key)) + return err + }, + }, + "caches": { + key: "caches", + decode: func(d decodeWeight, p decodeConfig) error { + var err error + p.c.Caches, err = filecache.DecodeConfig(p.fs, p.bcfg, p.p.GetStringMap(d.key)) + if p.c.IgnoreCache { + // Set MaxAge in all caches to 0. + for k, cache := range p.c.Caches { + cache.MaxAge = 0 + p.c.Caches[k] = cache + } + } + return err + }, + }, + "httpcache": { + key: "httpcache", + decode: func(d decodeWeight, p decodeConfig) error { + var err error + p.c.HTTPCache, err = httpcache.DecodeConfig(p.bcfg, p.p.GetStringMap(d.key)) + if p.c.IgnoreCache { + p.c.HTTPCache.Cache.For.Excludes = []string{"**"} + p.c.HTTPCache.Cache.For.Includes = []string{} + } + return err + }, + }, + "build": { + key: "build", + decode: func(d decodeWeight, p decodeConfig) error { + p.c.Build = config.DecodeBuildConfig(p.p) + return nil + }, + getCompiler: func(c *Config) configCompiler { + return &c.Build + }, + }, + "frontmatter": { + key: "frontmatter", + decode: func(d decodeWeight, p decodeConfig) error { + var err error + p.c.Frontmatter, err = pagemeta.DecodeFrontMatterConfig(p.p) + return err + }, + }, + "markup": { + key: "markup", + decode: func(d decodeWeight, p decodeConfig) error { + var err error + p.c.Markup, err = markup_config.Decode(p.p) + return err + }, + }, + "segments": { + key: "segments", + decode: func(d decodeWeight, p decodeConfig) error { + var err error + p.c.Segments, err = segments.DecodeSegments(p.p.GetStringMap(d.key)) + return err + }, + }, + "server": { + key: "server", + decode: func(d decodeWeight, p decodeConfig) error { + var err error + p.c.Server, err = config.DecodeServer(p.p) + return err + }, + getCompiler: func(c *Config) configCompiler { + return &c.Server + }, + }, + "minify": { + key: "minify", + decode: func(d decodeWeight, p decodeConfig) error { + var err error + p.c.Minify, err = minifiers.DecodeConfig(p.p.Get(d.key)) + return err + }, + }, + "contenttypes": { + key: "contenttypes", + weight: 100, // This needs to be decoded after media types. + decode: func(d decodeWeight, p decodeConfig) error { + var err error + p.c.ContentTypes, err = media.DecodeContentTypes(p.p.GetStringMap(d.key), p.c.MediaTypes.Config) + return err + }, + }, + "mediatypes": { + key: "mediatypes", + decode: func(d decodeWeight, p decodeConfig) error { + var err error + p.c.MediaTypes, err = media.DecodeTypes(p.p.GetStringMap(d.key)) + return err + }, + }, + "outputs": { + key: "outputs", + decode: func(d decodeWeight, p decodeConfig) error { + defaults := createDefaultOutputFormats(p.c.OutputFormats.Config) + m := maps.CleanConfigStringMap(p.p.GetStringMap("outputs")) + p.c.Outputs = make(map[string][]string) + for k, v := range m { + s := types.ToStringSlicePreserveString(v) + for i, v := range s { + s[i] = strings.ToLower(v) + } + p.c.Outputs[k] = s + } + // Apply defaults. + for k, v := range defaults { + if _, found := p.c.Outputs[k]; !found { + p.c.Outputs[k] = v + } + } + return nil + }, + }, + "outputformats": { + key: "outputformats", + decode: func(d decodeWeight, p decodeConfig) error { + var err error + p.c.OutputFormats, err = output.DecodeConfig(p.c.MediaTypes.Config, p.p.Get(d.key)) + return err + }, + }, + "params": { + key: "params", + decode: func(d decodeWeight, p decodeConfig) error { + p.c.Params = maps.CleanConfigStringMap(p.p.GetStringMap("params")) + if p.c.Params == nil { + p.c.Params = make(map[string]any) + } + + // Before Hugo 0.112.0 this was configured via site Params. + if mainSections, found := p.c.Params["mainsections"]; found { + p.c.MainSections = types.ToStringSlicePreserveString(mainSections) + if p.c.MainSections == nil { + p.c.MainSections = []string{} + } + } + + return nil + }, + }, + "module": { + key: "module", + decode: func(d decodeWeight, p decodeConfig) error { + var err error + p.c.Module, err = modules.DecodeConfig(p.p) + return err + }, + }, + "permalinks": { + key: "permalinks", + decode: func(d decodeWeight, p decodeConfig) error { + var err error + p.c.Permalinks, err = page.DecodePermalinksConfig(p.p.GetStringMap(d.key)) + return err + }, + }, + "sitemap": { + key: "sitemap", + decode: func(d decodeWeight, p decodeConfig) error { + var err error + if p.p.IsSet(d.key) { + p.c.Sitemap, err = config.DecodeSitemap(p.c.Sitemap, p.p.GetStringMap(d.key)) + } + return err + }, + }, + "taxonomies": { + key: "taxonomies", + decode: func(d decodeWeight, p decodeConfig) error { + if p.p.IsSet(d.key) { + p.c.Taxonomies = maps.CleanConfigStringMapString(p.p.GetStringMapString(d.key)) + } + return nil + }, + }, + "related": { + key: "related", + weight: 100, // This needs to be decoded after taxonomies. + decode: func(d decodeWeight, p decodeConfig) error { + if p.p.IsSet(d.key) { + var err error + p.c.Related, err = related.DecodeConfig(p.p.GetParams(d.key)) + if err != nil { + return fmt.Errorf("failed to decode related config: %w", err) + } + } else { + p.c.Related = related.DefaultConfig + if _, found := p.c.Taxonomies["tag"]; found { + p.c.Related.Add(related.IndexConfig{Name: "tags", Weight: 80, Type: related.TypeBasic}) + } + } + return nil + }, + }, + "languages": { + key: "languages", + decode: func(d decodeWeight, p decodeConfig) error { + var err error + m := p.p.GetStringMap(d.key) + if len(m) == 1 { + // In v0.112.4 we moved this to the language config, but it's very commmon for mono language sites to have this at the top level. + var first maps.Params + var ok bool + for _, v := range m { + first, ok = v.(maps.Params) + if ok { + break + } + } + if first != nil { + if _, found := first["languagecode"]; !found { + first["languagecode"] = p.p.GetString("languagecode") + } + } + } + p.c.Languages, err = langs.DecodeConfig(m) + if err != nil { + return err + } + + // Validate defaultContentLanguage. + if p.c.DefaultContentLanguage != "" { + var found bool + for lang := range p.c.Languages { + if lang == p.c.DefaultContentLanguage { + found = true + break + } + } + if !found { + return fmt.Errorf("config value %q for defaultContentLanguage does not match any language definition", p.c.DefaultContentLanguage) + } + } + + return nil + }, + }, + "cascade": { + key: "cascade", + decode: func(d decodeWeight, p decodeConfig) error { + var err error + p.c.Cascade, err = page.DecodeCascadeConfig(nil, true, p.p.Get(d.key)) + return err + }, + }, + "menus": { + key: "menus", + decode: func(d decodeWeight, p decodeConfig) error { + var err error + p.c.Menus, err = navigation.DecodeConfig(p.p.Get(d.key)) + return err + }, + }, + "page": { + key: "page", + decode: func(d decodeWeight, p decodeConfig) error { + p.c.Page = config.PageConfig{ + NextPrevSortOrder: "desc", + NextPrevInSectionSortOrder: "desc", + } + if p.p.IsSet(d.key) { + if err := mapstructure.WeakDecode(p.p.Get(d.key), &p.c.Page); err != nil { + return err + } + } + + return nil + }, + getCompiler: func(c *Config) configCompiler { + return &c.Page + }, + }, + "pagination": { + key: "pagination", + decode: func(d decodeWeight, p decodeConfig) error { + p.c.Pagination = config.Pagination{ + PagerSize: 10, + Path: "page", + } + if p.p.IsSet(d.key) { + if err := mapstructure.WeakDecode(p.p.Get(d.key), &p.c.Pagination); err != nil { + return err + } + } + + return nil + }, + }, + "privacy": { + key: "privacy", + decode: func(d decodeWeight, p decodeConfig) error { + var err error + p.c.Privacy, err = privacy.DecodeConfig(p.p) + return err + }, + }, + "security": { + key: "security", + decode: func(d decodeWeight, p decodeConfig) error { + var err error + p.c.Security, err = security.DecodeConfig(p.p) + return err + }, + }, + "services": { + key: "services", + decode: func(d decodeWeight, p decodeConfig) error { + var err error + p.c.Services, err = services.DecodeConfig(p.p) + return err + }, + }, + "deployment": { + key: "deployment", + decode: func(d decodeWeight, p decodeConfig) error { + var err error + p.c.Deployment, err = deployconfig.DecodeConfig(p.p) + return err + }, + }, + "author": { + key: "author", + decode: func(d decodeWeight, p decodeConfig) error { + p.c.Author = maps.CleanConfigStringMap(p.p.GetStringMap(d.key)) + return nil + }, + internalOrDeprecated: true, + }, + "social": { + key: "social", + decode: func(d decodeWeight, p decodeConfig) error { + p.c.Social = maps.CleanConfigStringMapString(p.p.GetStringMapString(d.key)) + return nil + }, + internalOrDeprecated: true, + }, + "uglyurls": { + key: "uglyurls", + decode: func(d decodeWeight, p decodeConfig) error { + v := p.p.Get(d.key) + switch vv := v.(type) { + case bool: + p.c.UglyURLs = vv + case string: + p.c.UglyURLs = vv == "true" + case maps.Params: + p.c.UglyURLs = cast.ToStringMapBool(maps.CleanConfigStringMap(vv)) + default: + p.c.UglyURLs = cast.ToStringMapBool(v) + } + return nil + }, + internalOrDeprecated: true, + }, + "internal": { + key: "internal", + decode: func(d decodeWeight, p decodeConfig) error { + return mapstructure.WeakDecode(p.p.GetStringMap(d.key), &p.c.Internal) + }, + internalOrDeprecated: true, + }, +} + +func init() { + for k, v := range allDecoderSetups { + // Verify that k and v.key is all lower case. + if k != strings.ToLower(k) { + panic(fmt.Sprintf("key %q is not lower case", k)) + } + if v.key != strings.ToLower(v.key) { + panic(fmt.Sprintf("key %q is not lower case", v.key)) + } + + if k != v.key { + panic(fmt.Sprintf("key %q is not the same as the map key %q", k, v.key)) + } + } +} diff --git a/config/allconfig/configlanguage.go b/config/allconfig/configlanguage.go new file mode 100644 index 000000000..6990a3590 --- /dev/null +++ b/config/allconfig/configlanguage.go @@ -0,0 +1,261 @@ +// Copyright 2024 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 allconfig + +import ( + "time" + + "github.com/gohugoio/hugo/common/paths" + "github.com/gohugoio/hugo/common/urls" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/identity" + "github.com/gohugoio/hugo/langs" +) + +type ConfigLanguage struct { + config *Config + baseConfig config.BaseConfig + + m *Configs + language *langs.Language +} + +func (c ConfigLanguage) Language() *langs.Language { + return c.language +} + +func (c ConfigLanguage) Languages() langs.Languages { + return c.m.Languages +} + +func (c ConfigLanguage) LanguagesDefaultFirst() langs.Languages { + return c.m.LanguagesDefaultFirst +} + +func (c ConfigLanguage) PathParser() *paths.PathParser { + return c.m.ContentPathParser +} + +func (c ConfigLanguage) LanguagePrefix() string { + if c.DefaultContentLanguageInSubdir() && c.DefaultContentLanguage() == c.Language().Lang { + return c.Language().Lang + } + + if !c.IsMultilingual() || c.DefaultContentLanguage() == c.Language().Lang { + return "" + } + return c.Language().Lang +} + +func (c ConfigLanguage) BaseURL() urls.BaseURL { + return c.config.C.BaseURL +} + +func (c ConfigLanguage) BaseURLLiveReload() urls.BaseURL { + return c.config.C.BaseURLLiveReload +} + +func (c ConfigLanguage) Environment() string { + return c.config.Environment +} + +func (c ConfigLanguage) IsMultihost() bool { + if len(c.m.Languages)-len(c.config.C.DisabledLanguages) <= 1 { + return false + } + return c.m.IsMultihost +} + +func (c ConfigLanguage) FastRenderMode() bool { + return c.config.Internal.FastRenderMode +} + +func (c ConfigLanguage) IsMultilingual() bool { + return len(c.m.Languages) > 1 +} + +func (c ConfigLanguage) TemplateMetrics() bool { + return c.config.TemplateMetrics +} + +func (c ConfigLanguage) TemplateMetricsHints() bool { + return c.config.TemplateMetricsHints +} + +func (c ConfigLanguage) IsLangDisabled(lang string) bool { + return c.config.C.DisabledLanguages[lang] +} + +func (c ConfigLanguage) IgnoredLogs() map[string]bool { + return c.config.C.IgnoredLogs +} + +func (c ConfigLanguage) NoBuildLock() bool { + return c.config.NoBuildLock +} + +func (c ConfigLanguage) NewContentEditor() string { + return c.config.NewContentEditor +} + +func (c ConfigLanguage) Timeout() time.Duration { + return c.config.C.Timeout +} + +func (c ConfigLanguage) BaseConfig() config.BaseConfig { + return c.baseConfig +} + +func (c ConfigLanguage) Dirs() config.CommonDirs { + return c.config.CommonDirs +} + +func (c ConfigLanguage) DirsBase() config.CommonDirs { + return c.m.Base.CommonDirs +} + +func (c ConfigLanguage) WorkingDir() string { + return c.m.Base.WorkingDir +} + +func (c ConfigLanguage) Quiet() bool { + return c.m.Base.Internal.Quiet +} + +func (c ConfigLanguage) Watching() bool { + return c.m.Base.Internal.Watch +} + +func (c ConfigLanguage) NewIdentityManager(name string, opts ...identity.ManagerOption) identity.Manager { + if !c.Watching() { + return identity.NopManager + } + return identity.NewManager(name, opts...) +} + +func (c ConfigLanguage) ContentTypes() config.ContentTypesProvider { + return c.config.ContentTypes.Config +} + +// GetConfigSection is mostly used in tests. The switch statement isn't complete, but what's in use. +func (c ConfigLanguage) GetConfigSection(s string) any { + switch s { + case "security": + return c.config.Security + case "build": + return c.config.Build + case "frontmatter": + return c.config.Frontmatter + case "caches": + return c.config.Caches + case "markup": + return c.config.Markup + case "mediaTypes": + return c.config.MediaTypes.Config + case "outputFormats": + return c.config.OutputFormats.Config + case "permalinks": + return c.config.Permalinks + case "minify": + return c.config.Minify + case "allModules": + return c.m.Modules + case "deployment": + return c.config.Deployment + case "httpCacheCompiled": + return c.config.C.HTTPCache + default: + panic("not implemented: " + s) + } +} + +func (c ConfigLanguage) GetConfig() any { + return c.config +} + +func (c ConfigLanguage) CanonifyURLs() bool { + return c.config.CanonifyURLs +} + +func (c ConfigLanguage) IsUglyURLs(section string) bool { + return c.config.C.IsUglyURLSection(section) +} + +func (c ConfigLanguage) IgnoreFile(s string) bool { + return c.config.C.IgnoreFile(s) +} + +func (c ConfigLanguage) DisablePathToLower() bool { + return c.config.DisablePathToLower +} + +func (c ConfigLanguage) RemovePathAccents() bool { + return c.config.RemovePathAccents +} + +func (c ConfigLanguage) DefaultContentLanguage() string { + return c.config.DefaultContentLanguage +} + +func (c ConfigLanguage) DefaultContentLanguageInSubdir() bool { + return c.config.DefaultContentLanguageInSubdir +} + +func (c ConfigLanguage) SummaryLength() int { + return c.config.SummaryLength +} + +func (c ConfigLanguage) BuildExpired() bool { + return c.config.BuildExpired +} + +func (c ConfigLanguage) BuildFuture() bool { + return c.config.BuildFuture +} + +func (c ConfigLanguage) BuildDrafts() bool { + return c.config.BuildDrafts +} + +func (c ConfigLanguage) Running() bool { + return c.config.Internal.Running +} + +func (c ConfigLanguage) PrintUnusedTemplates() bool { + return c.config.PrintUnusedTemplates +} + +func (c ConfigLanguage) EnableMissingTranslationPlaceholders() bool { + return c.config.EnableMissingTranslationPlaceholders +} + +func (c ConfigLanguage) PrintI18nWarnings() bool { + return c.config.PrintI18nWarnings +} + +func (c ConfigLanguage) CreateTitle(s string) string { + return c.config.C.CreateTitle(s) +} + +func (c ConfigLanguage) Pagination() config.Pagination { + return c.config.Pagination +} + +func (c ConfigLanguage) StaticDirs() []string { + return c.config.staticDirs() +} + +func (c ConfigLanguage) EnableEmoji() bool { + return c.config.EnableEmoji +} diff --git a/config/allconfig/docshelper.go b/config/allconfig/docshelper.go new file mode 100644 index 000000000..1a5fb6153 --- /dev/null +++ b/config/allconfig/docshelper.go @@ -0,0 +1,48 @@ +// Copyright 2024 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 allconfig + +import ( + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/docshelper" +) + +// This is is just some helpers used to create some JSON used in the Hugo docs. +func init() { + docsProvider := func() docshelper.DocProvider { + cfg := config.New() + for configRoot, v := range allDecoderSetups { + if v.internalOrDeprecated { + continue + } + cfg.Set(configRoot, make(maps.Params)) + } + lang := maps.Params{ + "en": maps.Params{ + "menus": maps.Params{}, + "params": maps.Params{}, + }, + } + cfg.Set("languages", lang) + cfg.SetDefaultMergeStrategy() + + configHelpers := map[string]any{ + "mergeStrategy": cfg.Get(""), + } + return docshelper.DocProvider{"config_helpers": configHelpers} + } + + docshelper.AddDocProviderFunc(docsProvider) +} diff --git a/config/allconfig/load.go b/config/allconfig/load.go new file mode 100644 index 000000000..4fb8bbaef --- /dev/null +++ b/config/allconfig/load.go @@ -0,0 +1,544 @@ +// Copyright 2024 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 allconfig contains the full configuration for Hugo. +package allconfig + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/gobwas/glob" + "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/common/hexec" + "github.com/gohugoio/hugo/common/hugo" + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/common/paths" + "github.com/gohugoio/hugo/common/types" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/helpers" + hglob "github.com/gohugoio/hugo/hugofs/glob" + "github.com/gohugoio/hugo/modules" + "github.com/gohugoio/hugo/parser/metadecoders" + "github.com/spf13/afero" +) + +//lint:ignore ST1005 end user message. +var ErrNoConfigFile = errors.New("Unable to locate config file or config directory. Perhaps you need to create a new site.\n Run `hugo help new` for details.\n") + +func LoadConfig(d ConfigSourceDescriptor) (*Configs, error) { + if len(d.Environ) == 0 && !hugo.IsRunningAsTest() { + d.Environ = os.Environ() + } + + if d.Logger == nil { + d.Logger = loggers.NewDefault() + } + + l := &configLoader{ConfigSourceDescriptor: d, cfg: config.New()} + // Make sure we always do this, even in error situations, + // as we have commands (e.g. "hugo mod init") that will + // use a partial configuration to do its job. + defer l.deleteMergeStrategies() + res, _, err := l.loadConfigMain(d) + if err != nil { + return nil, fmt.Errorf("failed to load config: %w", err) + } + + configs, err := fromLoadConfigResult(d.Fs, d.Logger, res) + if err != nil { + return nil, fmt.Errorf("failed to create config from result: %w", err) + } + + moduleConfig, modulesClient, err := l.loadModules(configs, d.IgnoreModuleDoesNotExist) + if err != nil { + return nil, fmt.Errorf("failed to load modules: %w", err) + } + + if len(l.ModulesConfigFiles) > 0 { + // Config merged in from modules. + // Re-read the config. + configs, err = fromLoadConfigResult(d.Fs, d.Logger, res) + if err != nil { + return nil, fmt.Errorf("failed to create config from modules config: %w", err) + } + if err := configs.transientErr(); err != nil { + return nil, fmt.Errorf("failed to create config from modules config: %w", err) + } + configs.LoadingInfo.ConfigFiles = append(configs.LoadingInfo.ConfigFiles, l.ModulesConfigFiles...) + } else if err := configs.transientErr(); err != nil { + return nil, fmt.Errorf("failed to create config: %w", err) + } + + configs.Modules = moduleConfig.AllModules + configs.ModulesClient = modulesClient + + if err := configs.Init(); err != nil { + return nil, fmt.Errorf("failed to init config: %w", err) + } + + loggers.SetGlobalLogger(d.Logger) + + return configs, nil +} + +// ConfigSourceDescriptor describes where to find the config (e.g. config.toml etc.). +type ConfigSourceDescriptor struct { + Fs afero.Fs + Logger loggers.Logger + + // Config received from the command line. + // These will override any config file settings. + Flags config.Provider + + // Path to the config file to use, e.g. /my/project/config.toml + Filename string + + // The (optional) directory for additional configuration files. + ConfigDir string + + // production, development + Environment string + + // Defaults to os.Environ if not set. + Environ []string + + // If set, this will be used to ignore the module does not exist error. + IgnoreModuleDoesNotExist bool +} + +func (d ConfigSourceDescriptor) configFilenames() []string { + if d.Filename == "" { + return nil + } + return strings.Split(d.Filename, ",") +} + +type configLoader struct { + cfg config.Provider + BaseConfig config.BaseConfig + ConfigSourceDescriptor + + // collected + ModulesConfig modules.ModulesConfig + ModulesConfigFiles []string +} + +// Handle some legacy values. +func (l configLoader) applyConfigAliases() error { + aliases := []types.KeyValueStr{ + {Key: "indexes", Value: "taxonomies"}, + {Key: "logI18nWarnings", Value: "printI18nWarnings"}, + {Key: "logPathWarnings", Value: "printPathWarnings"}, + {Key: "ignoreErrors", Value: "ignoreLogs"}, + } + + for _, alias := range aliases { + if l.cfg.IsSet(alias.Key) { + vv := l.cfg.Get(alias.Key) + l.cfg.Set(alias.Value, vv) + } + } + + return nil +} + +func (l configLoader) applyDefaultConfig() error { + defaultSettings := maps.Params{ + // These dirs are used early/before we build the config struct. + "themesDir": "themes", + "configDir": "config", + } + + l.cfg.SetDefaults(defaultSettings) + + return nil +} + +func (l configLoader) normalizeCfg(cfg config.Provider) error { + if b, ok := cfg.Get("minifyOutput").(bool); ok && b { + cfg.Set("minify.minifyOutput", true) + } else if b, ok := cfg.Get("minify").(bool); ok && b { + cfg.Set("minify", maps.Params{"minifyOutput": true}) + } + + return nil +} + +func (l configLoader) cleanExternalConfig(cfg config.Provider) error { + if cfg.IsSet("internal") { + cfg.Set("internal", nil) + } + return nil +} + +func (l configLoader) applyFlagsOverrides(cfg config.Provider) error { + for _, k := range cfg.Keys() { + l.cfg.Set(k, cfg.Get(k)) + } + return nil +} + +func (l configLoader) applyOsEnvOverrides(environ []string) error { + if len(environ) == 0 { + return nil + } + + const delim = "__env__delim" + + // Extract all that start with the HUGO prefix. + // The delimiter is the following rune, usually "_". + const hugoEnvPrefix = "HUGO" + var hugoEnv []types.KeyValueStr + for _, v := range environ { + key, val := config.SplitEnvVar(v) + if strings.HasPrefix(key, hugoEnvPrefix) { + delimiterAndKey := strings.TrimPrefix(key, hugoEnvPrefix) + if len(delimiterAndKey) < 2 { + continue + } + // Allow delimiters to be case sensitive. + // It turns out there isn't that many allowed special + // chars in environment variables when used in Bash and similar, + // so variables on the form HUGOxPARAMSxFOO=bar is one option. + key := strings.ReplaceAll(delimiterAndKey[1:], delimiterAndKey[:1], delim) + key = strings.ToLower(key) + hugoEnv = append(hugoEnv, types.KeyValueStr{ + Key: key, + Value: val, + }) + + } + } + + for _, env := range hugoEnv { + existing, nestedKey, owner, err := maps.GetNestedParamFn(env.Key, delim, l.cfg.Get) + if err != nil { + return err + } + + if existing != nil { + val, err := metadecoders.Default.UnmarshalStringTo(env.Value, existing) + if err == nil { + val = l.envValToVal(env.Key, val) + if owner != nil { + owner[nestedKey] = val + } else { + l.cfg.Set(env.Key, val) + } + continue + } + } + + if owner != nil && nestedKey != "" { + owner[nestedKey] = env.Value + } else { + var val any + key := strings.ReplaceAll(env.Key, delim, ".") + _, ok := allDecoderSetups[key] + if ok { + // A map. + if v, err := metadecoders.Default.UnmarshalStringTo(env.Value, map[string]any{}); err == nil { + val = v + } + } + + if val == nil { + // A string. + val = l.envStringToVal(key, env.Value) + } + l.cfg.Set(key, val) + } + + } + + return nil +} + +func (l *configLoader) envValToVal(k string, v any) any { + switch v := v.(type) { + case string: + return l.envStringToVal(k, v) + default: + return v + } +} + +func (l *configLoader) envStringToVal(k, v string) any { + switch k { + case "disablekinds", "disablelanguages": + if strings.Contains(v, ",") { + return strings.Split(v, ",") + } else { + return strings.Fields(v) + } + default: + return v + } +} + +func (l *configLoader) loadConfigMain(d ConfigSourceDescriptor) (config.LoadConfigResult, modules.ModulesConfig, error) { + var res config.LoadConfigResult + + if d.Flags != nil { + if err := l.normalizeCfg(d.Flags); err != nil { + return res, l.ModulesConfig, err + } + } + + if d.Fs == nil { + return res, l.ModulesConfig, errors.New("no filesystem provided") + } + + if d.Flags != nil { + if err := l.applyFlagsOverrides(d.Flags); err != nil { + return res, l.ModulesConfig, err + } + workingDir := filepath.Clean(l.cfg.GetString("workingDir")) + + l.BaseConfig = config.BaseConfig{ + WorkingDir: workingDir, + ThemesDir: paths.AbsPathify(workingDir, l.cfg.GetString("themesDir")), + } + + } + + names := d.configFilenames() + + if names != nil { + for _, name := range names { + var filename string + filename, err := l.loadConfig(name) + if err == nil { + res.ConfigFiles = append(res.ConfigFiles, filename) + } else if err != ErrNoConfigFile { + return res, l.ModulesConfig, l.wrapFileError(err, filename) + } + } + } else { + for _, name := range config.DefaultConfigNames { + var filename string + filename, err := l.loadConfig(name) + if err == nil { + res.ConfigFiles = append(res.ConfigFiles, filename) + break + } else if err != ErrNoConfigFile { + return res, l.ModulesConfig, l.wrapFileError(err, filename) + } + } + } + + if d.ConfigDir != "" { + absConfigDir := paths.AbsPathify(l.BaseConfig.WorkingDir, d.ConfigDir) + dcfg, dirnames, err := config.LoadConfigFromDir(l.Fs, absConfigDir, l.Environment) + if err == nil { + if len(dirnames) > 0 { + if err := l.normalizeCfg(dcfg); err != nil { + return res, l.ModulesConfig, err + } + if err := l.cleanExternalConfig(dcfg); err != nil { + return res, l.ModulesConfig, err + } + l.cfg.Set("", dcfg.Get("")) + res.ConfigFiles = append(res.ConfigFiles, dirnames...) + } + } else if err != ErrNoConfigFile { + if len(dirnames) > 0 { + return res, l.ModulesConfig, l.wrapFileError(err, dirnames[0]) + } + return res, l.ModulesConfig, err + } + } + + res.Cfg = l.cfg + + if err := l.applyDefaultConfig(); err != nil { + return res, l.ModulesConfig, err + } + + // Some settings are used before we're done collecting all settings, + // so apply OS environment both before and after. + if err := l.applyOsEnvOverrides(d.Environ); err != nil { + return res, l.ModulesConfig, err + } + + workingDir := filepath.Clean(l.cfg.GetString("workingDir")) + + l.BaseConfig = config.BaseConfig{ + WorkingDir: workingDir, + CacheDir: l.cfg.GetString("cacheDir"), + ThemesDir: paths.AbsPathify(workingDir, l.cfg.GetString("themesDir")), + } + + var err error + l.BaseConfig.CacheDir, err = helpers.GetCacheDir(l.Fs, l.BaseConfig.CacheDir) + if err != nil { + return res, l.ModulesConfig, err + } + + res.BaseConfig = l.BaseConfig + + l.cfg.SetDefaultMergeStrategy() + + res.ConfigFiles = append(res.ConfigFiles, l.ModulesConfigFiles...) + + if d.Flags != nil { + if err := l.applyFlagsOverrides(d.Flags); err != nil { + return res, l.ModulesConfig, err + } + } + + if err := l.applyOsEnvOverrides(d.Environ); err != nil { + return res, l.ModulesConfig, err + } + + if err = l.applyConfigAliases(); err != nil { + return res, l.ModulesConfig, err + } + + return res, l.ModulesConfig, err +} + +func (l *configLoader) loadModules(configs *Configs, ignoreModuleDoesNotExist bool) (modules.ModulesConfig, *modules.Client, error) { + bcfg := configs.LoadingInfo.BaseConfig + conf := configs.Base + workingDir := bcfg.WorkingDir + themesDir := bcfg.ThemesDir + publishDir := bcfg.PublishDir + + cfg := configs.LoadingInfo.Cfg + + var ignoreVendor glob.Glob + if s := conf.IgnoreVendorPaths; s != "" { + ignoreVendor, _ = hglob.GetGlob(hglob.NormalizePath(s)) + } + + ex := hexec.New(conf.Security, workingDir, l.Logger) + + hook := func(m *modules.ModulesConfig) error { + for _, tc := range m.AllModules { + if len(tc.ConfigFilenames()) > 0 { + if tc.Watch() { + l.ModulesConfigFiles = append(l.ModulesConfigFiles, tc.ConfigFilenames()...) + } + + // Merge in the theme config using the configured + // merge strategy. + cfg.Merge("", tc.Cfg().Get("")) + + } + } + + return nil + } + + modulesClient := modules.NewClient(modules.ClientConfig{ + Fs: l.Fs, + Logger: l.Logger, + Exec: ex, + HookBeforeFinalize: hook, + WorkingDir: workingDir, + ThemesDir: themesDir, + PublishDir: publishDir, + Environment: l.Environment, + CacheDir: conf.Caches.CacheDirModules(), + ModuleConfig: conf.Module, + IgnoreVendor: ignoreVendor, + IgnoreModuleDoesNotExist: ignoreModuleDoesNotExist, + }) + + moduleConfig, err := modulesClient.Collect() + + // We want to watch these for changes and trigger rebuild on version + // changes etc. + if moduleConfig.GoModulesFilename != "" { + l.ModulesConfigFiles = append(l.ModulesConfigFiles, moduleConfig.GoModulesFilename) + } + + if moduleConfig.GoWorkspaceFilename != "" { + l.ModulesConfigFiles = append(l.ModulesConfigFiles, moduleConfig.GoWorkspaceFilename) + } + + return moduleConfig, modulesClient, err +} + +func (l configLoader) loadConfig(configName string) (string, error) { + baseDir := l.BaseConfig.WorkingDir + var baseFilename string + if filepath.IsAbs(configName) { + baseFilename = configName + } else { + baseFilename = filepath.Join(baseDir, configName) + } + + var filename string + if paths.ExtNoDelimiter(configName) != "" { + exists, _ := helpers.Exists(baseFilename, l.Fs) + if exists { + filename = baseFilename + } + } else { + for _, ext := range config.ValidConfigFileExtensions { + filenameToCheck := baseFilename + "." + ext + exists, _ := helpers.Exists(filenameToCheck, l.Fs) + if exists { + filename = filenameToCheck + break + } + } + } + + if filename == "" { + return "", ErrNoConfigFile + } + + m, err := config.FromFileToMap(l.Fs, filename) + if err != nil { + return filename, err + } + + // Set overwrites keys of the same name, recursively. + l.cfg.Set("", m) + + if err := l.normalizeCfg(l.cfg); err != nil { + return filename, err + } + + if err := l.cleanExternalConfig(l.cfg); err != nil { + return filename, err + } + + return filename, nil +} + +func (l configLoader) deleteMergeStrategies() { + l.cfg.WalkParams(func(params ...maps.KeyParams) bool { + params[len(params)-1].Params.DeleteMergeStrategy() + return false + }) +} + +func (l configLoader) wrapFileError(err error, filename string) error { + fe := herrors.UnwrapFileError(err) + if fe != nil { + pos := fe.Position() + pos.Filename = filename + fe.UpdatePosition(pos) + return err + } + return herrors.NewFileErrorFromFile(err, filename, l.Fs, nil) +} diff --git a/config/allconfig/load_test.go b/config/allconfig/load_test.go new file mode 100644 index 000000000..3c16e71e9 --- /dev/null +++ b/config/allconfig/load_test.go @@ -0,0 +1,67 @@ +package allconfig + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/afero" +) + +func BenchmarkLoad(b *testing.B) { + tempDir := b.TempDir() + configFilename := filepath.Join(tempDir, "hugo.toml") + config := ` +baseURL = "https://example.com" +defaultContentLanguage = 'en' + +[module] +[[module.mounts]] +source = 'content/en' +target = 'content/en' +lang = 'en' +[[module.mounts]] +source = 'content/nn' +target = 'content/nn' +lang = 'nn' +[[module.mounts]] +source = 'content/no' +target = 'content/no' +lang = 'no' +[[module.mounts]] +source = 'content/sv' +target = 'content/sv' +lang = 'sv' +[[module.mounts]] +source = 'layouts' +target = 'layouts' + +[languages] +[languages.en] +title = "English" +weight = 1 +[languages.nn] +title = "Nynorsk" +weight = 2 +[languages.no] +title = "Norsk" +weight = 3 +[languages.sv] +title = "Svenska" +weight = 4 +` + if err := os.WriteFile(configFilename, []byte(config), 0o666); err != nil { + b.Fatal(err) + } + d := ConfigSourceDescriptor{ + Fs: afero.NewOsFs(), + Filename: configFilename, + } + + for i := 0; i < b.N; i++ { + _, err := LoadConfig(d) + if err != nil { + b.Fatal(err) + } + } +} diff --git a/config/commonConfig.go b/config/commonConfig.go new file mode 100644 index 000000000..947078672 --- /dev/null +++ b/config/commonConfig.go @@ -0,0 +1,511 @@ +// Copyright 2019 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 config + +import ( + "fmt" + "net/http" + "regexp" + "slices" + "sort" + "strings" + + "github.com/bep/logg" + "github.com/gobwas/glob" + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/common/types" + + "github.com/gohugoio/hugo/common/herrors" + "github.com/mitchellh/mapstructure" + "github.com/spf13/cast" +) + +type BaseConfig struct { + WorkingDir string + CacheDir string + ThemesDir string + PublishDir string +} + +type CommonDirs struct { + // The directory where Hugo will look for themes. + ThemesDir string + + // Where to put the generated files. + PublishDir string + + // The directory to put the generated resources files. This directory should in most situations be considered temporary + // and not be committed to version control. But there may be cached content in here that you want to keep, + // e.g. resources/_gen/images for performance reasons or CSS built from SASS when your CI server doesn't have the full setup. + ResourceDir string + + // The project root directory. + WorkingDir string + + // The root directory for all cache files. + CacheDir string + + // The content source directory. + // Deprecated: Use module mounts. + ContentDir string + // Deprecated: Use module mounts. + // The data source directory. + DataDir string + // Deprecated: Use module mounts. + // The layout source directory. + LayoutDir string + // Deprecated: Use module mounts. + // The i18n source directory. + I18nDir string + // Deprecated: Use module mounts. + // The archetypes source directory. + ArcheTypeDir string + // Deprecated: Use module mounts. + // The assets source directory. + AssetDir string +} + +type LoadConfigResult struct { + Cfg Provider + ConfigFiles []string + BaseConfig BaseConfig +} + +var defaultBuild = BuildConfig{ + UseResourceCacheWhen: "fallback", + BuildStats: BuildStats{}, + + CacheBusters: []CacheBuster{ + { + Source: `(postcss|tailwind)\.config\.js`, + Target: cssTargetCachebusterRe, + }, + }, +} + +// BuildConfig holds some build related configuration. +type BuildConfig struct { + // When to use the resource file cache. + // One of never, fallback, always. Default is fallback + UseResourceCacheWhen string + + // When enabled, will collect and write a hugo_stats.json with some build + // related aggregated data (e.g. CSS class names). + // Note that this was a bool <= v0.115.0. + BuildStats BuildStats + + // Can be used to toggle off writing of the IntelliSense /assets/jsconfig.js + // file. + NoJSConfigInAssets bool + + // Can used to control how the resource cache gets evicted on rebuilds. + CacheBusters []CacheBuster +} + +// BuildStats configures if and what to write to the hugo_stats.json file. +type BuildStats struct { + Enable bool + DisableTags bool + DisableClasses bool + DisableIDs bool +} + +func (w BuildStats) Enabled() bool { + if !w.Enable { + return false + } + return !w.DisableTags || !w.DisableClasses || !w.DisableIDs +} + +func (b BuildConfig) clone() BuildConfig { + b.CacheBusters = slices.Clone(b.CacheBusters) + return b +} + +func (b BuildConfig) UseResourceCache(err error) bool { + if b.UseResourceCacheWhen == "never" { + return false + } + + if b.UseResourceCacheWhen == "fallback" { + return herrors.IsFeatureNotAvailableError(err) + } + + return true +} + +// MatchCacheBuster returns the cache buster for the given path p, nil if none. +func (s BuildConfig) MatchCacheBuster(logger loggers.Logger, p string) (func(string) bool, error) { + var matchers []func(string) bool + for _, cb := range s.CacheBusters { + if matcher := cb.compiledSource(p); matcher != nil { + matchers = append(matchers, matcher) + } + } + if len(matchers) > 0 { + return (func(cacheKey string) bool { + for _, m := range matchers { + if m(cacheKey) { + return true + } + } + return false + }), nil + } + return nil, nil +} + +func (b *BuildConfig) CompileConfig(logger loggers.Logger) error { + for i, cb := range b.CacheBusters { + if err := cb.CompileConfig(logger); err != nil { + return fmt.Errorf("failed to compile cache buster %q: %w", cb.Source, err) + } + b.CacheBusters[i] = cb + } + return nil +} + +func DecodeBuildConfig(cfg Provider) BuildConfig { + m := cfg.GetStringMap("build") + + b := defaultBuild.clone() + if m == nil { + return b + } + + // writeStats was a bool <= v0.115.0. + if writeStats, ok := m["writestats"]; ok { + if bb, ok := writeStats.(bool); ok { + m["buildstats"] = BuildStats{Enable: bb} + } + } + + err := mapstructure.WeakDecode(m, &b) + if err != nil { + return b + } + + b.UseResourceCacheWhen = strings.ToLower(b.UseResourceCacheWhen) + when := b.UseResourceCacheWhen + if when != "never" && when != "always" && when != "fallback" { + b.UseResourceCacheWhen = "fallback" + } + + return b +} + +// SitemapConfig configures the sitemap to be generated. +type SitemapConfig struct { + // The page change frequency. + ChangeFreq string + // The priority of the page. + Priority float64 + // The sitemap filename. + Filename string + // Whether to disable page inclusion. + Disable bool +} + +func DecodeSitemap(prototype SitemapConfig, input map[string]any) (SitemapConfig, error) { + err := mapstructure.WeakDecode(input, &prototype) + return prototype, err +} + +// Config for the dev server. +type Server struct { + Headers []Headers + Redirects []Redirect + + compiledHeaders []glob.Glob + compiledRedirects []redirect +} + +type redirect struct { + from glob.Glob + fromRe *regexp.Regexp + headers map[string]glob.Glob +} + +func (r redirect) matchHeader(header http.Header) bool { + for k, v := range r.headers { + if !v.Match(header.Get(k)) { + return false + } + } + return true +} + +func (s *Server) CompileConfig(logger loggers.Logger) error { + if s.compiledHeaders != nil { + return nil + } + for _, h := range s.Headers { + g, err := glob.Compile(h.For) + if err != nil { + return fmt.Errorf("failed to compile Headers glob %q: %w", h.For, err) + } + s.compiledHeaders = append(s.compiledHeaders, g) + } + for _, r := range s.Redirects { + if r.From == "" && r.FromRe == "" { + return fmt.Errorf("redirects must have either From or FromRe set") + } + rd := redirect{ + headers: make(map[string]glob.Glob), + } + if r.From != "" { + g, err := glob.Compile(r.From) + if err != nil { + return fmt.Errorf("failed to compile Redirect glob %q: %w", r.From, err) + } + rd.from = g + } + if r.FromRe != "" { + re, err := regexp.Compile(r.FromRe) + if err != nil { + return fmt.Errorf("failed to compile Redirect regexp %q: %w", r.FromRe, err) + } + rd.fromRe = re + } + for k, v := range r.FromHeaders { + g, err := glob.Compile(v) + if err != nil { + return fmt.Errorf("failed to compile Redirect header glob %q: %w", v, err) + } + rd.headers[k] = g + } + s.compiledRedirects = append(s.compiledRedirects, rd) + } + + return nil +} + +func (s *Server) MatchHeaders(pattern string) []types.KeyValueStr { + if s.compiledHeaders == nil { + return nil + } + + var matches []types.KeyValueStr + + for i, g := range s.compiledHeaders { + if g.Match(pattern) { + h := s.Headers[i] + for k, v := range h.Values { + matches = append(matches, types.KeyValueStr{Key: k, Value: cast.ToString(v)}) + } + } + } + + sort.Slice(matches, func(i, j int) bool { + return matches[i].Key < matches[j].Key + }) + + return matches +} + +func (s *Server) MatchRedirect(pattern string, header http.Header) Redirect { + if s.compiledRedirects == nil { + return Redirect{} + } + + pattern = strings.TrimSuffix(pattern, "index.html") + + for i, r := range s.compiledRedirects { + redir := s.Redirects[i] + + var found bool + + if r.from != nil { + if r.from.Match(pattern) { + found = header == nil || r.matchHeader(header) + // We need to do regexp group replacements if needed. + } + } + + if r.fromRe != nil { + m := r.fromRe.FindStringSubmatch(pattern) + if m != nil { + if !found { + found = header == nil || r.matchHeader(header) + } + + if found { + // Replace $1, $2 etc. in To. + for i, g := range m[1:] { + redir.To = strings.ReplaceAll(redir.To, fmt.Sprintf("$%d", i+1), g) + } + } + } + } + + if found { + return redir + } + } + + return Redirect{} +} + +type Headers struct { + For string + Values map[string]any +} + +type Redirect struct { + // From is the Glob pattern to match. + // One of From or FromRe must be set. + From string + + // FromRe is the regexp to match. + // This regexp can contain group matches (e.g. $1) that can be used in the To field. + // One of From or FromRe must be set. + FromRe string + + // To is the target URL. + To string + + // Headers to match for the redirect. + // This maps the HTTP header name to a Glob pattern with values to match. + // If the map is empty, the redirect will always be triggered. + FromHeaders map[string]string + + // HTTP status code to use for the redirect. + // A status code of 200 will trigger a URL rewrite. + Status int + + // Forcode redirect, even if original request path exists. + Force bool +} + +// CacheBuster configures cache busting for assets. +type CacheBuster struct { + // Trigger for files matching this regexp. + Source string + + // Cache bust targets matching this regexp. + // This regexp can contain group matches (e.g. $1) from the source regexp. + Target string + + compiledSource func(string) func(string) bool +} + +func (c *CacheBuster) CompileConfig(logger loggers.Logger) error { + if c.compiledSource != nil { + return nil + } + + source := c.Source + sourceRe, err := regexp.Compile(source) + if err != nil { + return fmt.Errorf("failed to compile cache buster source %q: %w", c.Source, err) + } + target := c.Target + var compileErr error + debugl := logger.Logger().WithLevel(logg.LevelDebug).WithField(loggers.FieldNameCmd, "cachebuster") + + c.compiledSource = func(s string) func(string) bool { + m := sourceRe.FindStringSubmatch(s) + matchString := "no match" + match := m != nil + if match { + matchString = "match!" + } + debugl.Logf("Matching %q with source %q: %s", s, source, matchString) + if !match { + return nil + } + groups := m[1:] + currentTarget := target + // Replace $1, $2 etc. in target. + for i, g := range groups { + currentTarget = strings.ReplaceAll(target, fmt.Sprintf("$%d", i+1), g) + } + targetRe, err := regexp.Compile(currentTarget) + if err != nil { + compileErr = fmt.Errorf("failed to compile cache buster target %q: %w", currentTarget, err) + return nil + } + return func(ss string) bool { + match = targetRe.MatchString(ss) + matchString := "no match" + if match { + matchString = "match!" + } + logger.Debugf("Matching %q with target %q: %s", ss, currentTarget, matchString) + + return match + } + } + return compileErr +} + +func (r Redirect) IsZero() bool { + return r.From == "" && r.FromRe == "" +} + +const ( + // Keep this a little coarse grained, some false positives are OK. + cssTargetCachebusterRe = `(css|styles|scss|sass)` +) + +func DecodeServer(cfg Provider) (Server, error) { + s := &Server{} + + _ = mapstructure.WeakDecode(cfg.GetStringMap("server"), s) + + for i, redir := range s.Redirects { + redir.To = strings.TrimSuffix(redir.To, "index.html") + s.Redirects[i] = redir + } + + if len(s.Redirects) == 0 { + // Set up a default redirect for 404s. + s.Redirects = []Redirect{ + { + From: "/**", + To: "/404.html", + Status: 404, + }, + } + } + + return *s, nil +} + +// Pagination configures the pagination behavior. +type Pagination struct { + // Default number of elements per pager in pagination. + PagerSize int + + // The path element used during pagination. + Path string + + // Whether to disable generation of alias for the first pagination page. + DisableAliases bool +} + +// PageConfig configures the behavior of pages. +type PageConfig struct { + // Sort order for Page.Next and Page.Prev. Default "desc" (the default page sort order in Hugo). + NextPrevSortOrder string + + // Sort order for Page.NextInSection and Page.PrevInSection. Default "desc". + NextPrevInSectionSortOrder string +} + +func (c *PageConfig) CompileConfig(loggers.Logger) error { + c.NextPrevInSectionSortOrder = strings.ToLower(c.NextPrevInSectionSortOrder) + c.NextPrevSortOrder = strings.ToLower(c.NextPrevSortOrder) + return nil +} diff --git a/config/commonConfig_test.go b/config/commonConfig_test.go new file mode 100644 index 000000000..05ba185e3 --- /dev/null +++ b/config/commonConfig_test.go @@ -0,0 +1,197 @@ +// Copyright 2020 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 config + +import ( + "errors" + "testing" + + "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/common/types" + + qt "github.com/frankban/quicktest" +) + +func TestBuild(t *testing.T) { + c := qt.New(t) + + v := New() + v.Set("build", map[string]any{ + "useResourceCacheWhen": "always", + }) + + b := DecodeBuildConfig(v) + + c.Assert(b.UseResourceCacheWhen, qt.Equals, "always") + + v.Set("build", map[string]any{ + "useResourceCacheWhen": "foo", + }) + + b = DecodeBuildConfig(v) + + c.Assert(b.UseResourceCacheWhen, qt.Equals, "fallback") + + c.Assert(b.UseResourceCache(herrors.ErrFeatureNotAvailable), qt.Equals, true) + c.Assert(b.UseResourceCache(errors.New("err")), qt.Equals, false) + + b.UseResourceCacheWhen = "always" + c.Assert(b.UseResourceCache(herrors.ErrFeatureNotAvailable), qt.Equals, true) + c.Assert(b.UseResourceCache(errors.New("err")), qt.Equals, true) + c.Assert(b.UseResourceCache(nil), qt.Equals, true) + + b.UseResourceCacheWhen = "never" + c.Assert(b.UseResourceCache(herrors.ErrFeatureNotAvailable), qt.Equals, false) + c.Assert(b.UseResourceCache(errors.New("err")), qt.Equals, false) + c.Assert(b.UseResourceCache(nil), qt.Equals, false) +} + +func TestServer(t *testing.T) { + c := qt.New(t) + + cfg, err := FromConfigString(`[[server.headers]] +for = "/*.jpg" + +[server.headers.values] +X-Frame-Options = "DENY" +X-XSS-Protection = "1; mode=block" +X-Content-Type-Options = "nosniff" + +[[server.redirects]] +from = "/foo/**" +to = "/baz/index.html" +status = 200 + +[[server.redirects]] +from = "/loop/**" +to = "/loop/foo/" +status = 200 + +[[server.redirects]] +from = "/b/**" +fromRe = "/b/(.*)/" +to = "/baz/$1/" +status = 200 + +[[server.redirects]] +fromRe = "/c/(.*)/" +to = "/boo/$1/" +status = 200 + +[[server.redirects]] +fromRe = "/d/(.*)/" +to = "/boo/$1/" +status = 200 + +[[server.redirects]] +from = "/google/**" +to = "https://google.com/" +status = 301 + + + +`, "toml") + + c.Assert(err, qt.IsNil) + + s, err := DecodeServer(cfg) + c.Assert(err, qt.IsNil) + c.Assert(s.CompileConfig(loggers.NewDefault()), qt.IsNil) + + c.Assert(s.MatchHeaders("/foo.jpg"), qt.DeepEquals, []types.KeyValueStr{ + {Key: "X-Content-Type-Options", Value: "nosniff"}, + {Key: "X-Frame-Options", Value: "DENY"}, + {Key: "X-XSS-Protection", Value: "1; mode=block"}, + }) + + c.Assert(s.MatchRedirect("/foo/bar/baz", nil), qt.DeepEquals, Redirect{ + From: "/foo/**", + To: "/baz/", + Status: 200, + }) + + c.Assert(s.MatchRedirect("/foo/bar/", nil), qt.DeepEquals, Redirect{ + From: "/foo/**", + To: "/baz/", + Status: 200, + }) + + c.Assert(s.MatchRedirect("/b/c/", nil), qt.DeepEquals, Redirect{ + From: "/b/**", + FromRe: "/b/(.*)/", + To: "/baz/c/", + Status: 200, + }) + + c.Assert(s.MatchRedirect("/c/d/", nil).To, qt.Equals, "/boo/d/") + c.Assert(s.MatchRedirect("/c/d/e/", nil).To, qt.Equals, "/boo/d/e/") + + c.Assert(s.MatchRedirect("/someother", nil), qt.DeepEquals, Redirect{}) + + c.Assert(s.MatchRedirect("/google/foo", nil), qt.DeepEquals, Redirect{ + From: "/google/**", + To: "https://google.com/", + Status: 301, + }) +} + +func TestBuildConfigCacheBusters(t *testing.T) { + c := qt.New(t) + cfg := New() + conf := DecodeBuildConfig(cfg) + l := loggers.NewDefault() + c.Assert(conf.CompileConfig(l), qt.IsNil) + + m, _ := conf.MatchCacheBuster(l, "tailwind.config.js") + c.Assert(m, qt.IsNotNil) + c.Assert(m("css"), qt.IsTrue) + c.Assert(m("js"), qt.IsFalse) + + m, _ = conf.MatchCacheBuster(l, "foo.bar") + c.Assert(m, qt.IsNil) +} + +func TestBuildConfigCacheBusterstTailwindSetup(t *testing.T) { + c := qt.New(t) + cfg := New() + cfg.Set("build", map[string]any{ + "cacheBusters": []map[string]string{ + { + "source": "assets/watching/hugo_stats\\.json", + "target": "css", + }, + { + "source": "(postcss|tailwind)\\.config\\.js", + "target": "css", + }, + { + "source": "assets/.*\\.(js|ts|jsx|tsx)", + "target": "js", + }, + { + "source": "assets/.*\\.(.*)$", + "target": "$1", + }, + }, + }) + + conf := DecodeBuildConfig(cfg) + l := loggers.NewDefault() + c.Assert(conf.CompileConfig(l), qt.IsNil) + + m, err := conf.MatchCacheBuster(l, "assets/watching/hugo_stats.json") + c.Assert(err, qt.IsNil) + c.Assert(m("css"), qt.IsTrue) +} diff --git a/config/configLoader.go b/config/configLoader.go index 2e37a5b35..dd103f27b 100644 --- a/config/configLoader.go +++ b/config/configLoader.go @@ -14,21 +14,37 @@ package config import ( + "fmt" + "os" "path/filepath" "strings" + "github.com/gohugoio/hugo/common/herrors" + + "github.com/gohugoio/hugo/common/paths" + "github.com/gohugoio/hugo/common/maps" "github.com/gohugoio/hugo/parser/metadecoders" "github.com/spf13/afero" - "github.com/spf13/viper" ) var ( + // See issue #8979 for context. + // Hugo has always used config.toml etc. as the default config file name. + // But hugo.toml is a more descriptive name, but we need to check for both. + DefaultConfigNames = []string{"hugo", "config"} + + DefaultConfigNamesSet = make(map[string]bool) + ValidConfigFileExtensions = []string{"toml", "yaml", "yml", "json"} validConfigFileExtensionsMap map[string]bool = make(map[string]bool) ) func init() { + for _, name := range DefaultConfigNames { + DefaultConfigNamesSet[name] = true + } + for _, ext := range ValidConfigFileExtensions { validConfigFileExtensionsMap[ext] = true } @@ -41,43 +57,46 @@ func IsValidConfigFilename(filename string) bool { return validConfigFileExtensionsMap[ext] } +func FromTOMLConfigString(config string) Provider { + cfg, err := FromConfigString(config, "toml") + if err != nil { + panic(err) + } + return cfg +} + // FromConfigString creates a config from the given YAML, JSON or TOML config. This is useful in tests. func FromConfigString(config, configType string) (Provider, error) { - v := newViper() m, err := readConfig(metadecoders.FormatFromString(configType), []byte(config)) if err != nil { return nil, err } - - v.MergeConfigMap(m) - - return v, nil + return NewFrom(m), nil } // FromFile loads the configuration from the given filename. func FromFile(fs afero.Fs, filename string) (Provider, error) { m, err := loadConfigFromFile(fs, filename) if err != nil { - return nil, err + fe := herrors.UnwrapFileError(err) + if fe != nil { + pos := fe.Position() + pos.Filename = filename + fe.UpdatePosition(pos) + return nil, err + } + return nil, herrors.NewFileErrorFromFile(err, filename, fs, nil) } - - v := newViper() - - err = v.MergeConfigMap(m) - if err != nil { - return nil, err - } - - return v, nil + return NewFrom(m), nil } // FromFileToMap is the same as FromFile, but it returns the config values // as a simple map. -func FromFileToMap(fs afero.Fs, filename string) (map[string]interface{}, error) { +func FromFileToMap(fs afero.Fs, filename string) (map[string]any, error) { return loadConfigFromFile(fs, filename) } -func readConfig(format metadecoders.Format, data []byte) (map[string]interface{}, error) { +func readConfig(format metadecoders.Format, data []byte) (map[string]any, error) { m, err := metadecoders.Default.UnmarshalToMap(data, format) if err != nil { return nil, err @@ -86,10 +105,9 @@ func readConfig(format metadecoders.Format, data []byte) (map[string]interface{} RenameKeys(m) return m, nil - } -func loadConfigFromFile(fs afero.Fs, filename string) (map[string]interface{}, error) { +func loadConfigFromFile(fs afero.Fs, filename string) (map[string]any, error) { m, err := metadecoders.Default.UnmarshalFileToMap(fs, filename) if err != nil { return nil, err @@ -98,6 +116,100 @@ func loadConfigFromFile(fs afero.Fs, filename string) (map[string]interface{}, e return m, nil } +func LoadConfigFromDir(sourceFs afero.Fs, configDir, environment string) (Provider, []string, error) { + defaultConfigDir := filepath.Join(configDir, "_default") + environmentConfigDir := filepath.Join(configDir, environment) + cfg := New() + + var configDirs []string + // Merge from least to most specific. + for _, dir := range []string{defaultConfigDir, environmentConfigDir} { + if _, err := sourceFs.Stat(dir); err == nil { + configDirs = append(configDirs, dir) + } + } + + if len(configDirs) == 0 { + return nil, nil, nil + } + + // Keep track of these so we can watch them for changes. + var dirnames []string + + for _, configDir := range configDirs { + err := afero.Walk(sourceFs, configDir, func(path string, fi os.FileInfo, err error) error { + if fi == nil || err != nil { + return nil + } + + if fi.IsDir() { + dirnames = append(dirnames, path) + return nil + } + + if !IsValidConfigFilename(path) { + return nil + } + + name := paths.Filename(filepath.Base(path)) + + item, err := metadecoders.Default.UnmarshalFileToMap(sourceFs, path) + if err != nil { + // This will be used in error reporting, use the most specific value. + dirnames = []string{path} + return fmt.Errorf("failed to unmarshal config for path %q: %w", path, err) + } + + var keyPath []string + if !DefaultConfigNamesSet[name] { + // Can be params.jp, menus.en etc. + name, lang := paths.FileAndExtNoDelimiter(name) + + keyPath = []string{name} + + if lang != "" { + keyPath = []string{"languages", lang} + switch name { + case "menu", "menus": + keyPath = append(keyPath, "menus") + case "params": + keyPath = append(keyPath, "params") + } + } + } + + 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 + } else { + nm := make(map[string]any) + m[key] = nm + m = nm + } + } + } + + // Migrate menu => menus etc. + RenameKeys(root) + + // Set will overwrite keys with the same name, recursively. + cfg.Set("", root) + + return nil + }) + if err != nil { + return nil, dirnames, err + } + + } + + return cfg, dirnames, nil +} + var keyAliases maps.KeyRenamer func init() { @@ -114,12 +226,6 @@ func init() { // RenameKeys renames config keys in m recursively according to a global Hugo // alias definition. -func RenameKeys(m map[string]interface{}) { +func RenameKeys(m map[string]any) { keyAliases.Rename(m) } - -func newViper() *viper.Viper { - v := viper.New() - - return v -} diff --git a/config/configProvider.go b/config/configProvider.go index 187fb7b10..c21342dce 100644 --- a/config/configProvider.go +++ b/config/configProvider.go @@ -14,19 +14,92 @@ package config import ( - "github.com/spf13/cast" + "time" + + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/common/paths" + "github.com/gohugoio/hugo/common/types" + "github.com/gohugoio/hugo/common/urls" + "github.com/gohugoio/hugo/identity" + "github.com/gohugoio/hugo/langs" ) +// AllProvider is a sub set of all config settings. +type AllProvider interface { + Language() *langs.Language + Languages() langs.Languages + LanguagesDefaultFirst() langs.Languages + LanguagePrefix() string + BaseURL() urls.BaseURL + BaseURLLiveReload() urls.BaseURL + PathParser() *paths.PathParser + Environment() string + IsMultihost() bool + IsMultilingual() bool + NoBuildLock() bool + BaseConfig() BaseConfig + Dirs() CommonDirs + Quiet() bool + DirsBase() CommonDirs + ContentTypes() ContentTypesProvider + GetConfigSection(string) any + GetConfig() any + CanonifyURLs() bool + DisablePathToLower() bool + RemovePathAccents() bool + IsUglyURLs(section string) bool + DefaultContentLanguage() string + DefaultContentLanguageInSubdir() bool + IsLangDisabled(string) bool + SummaryLength() int + Pagination() Pagination + BuildExpired() bool + BuildFuture() bool + BuildDrafts() bool + Running() bool + Watching() bool + NewIdentityManager(name string, opts ...identity.ManagerOption) identity.Manager + FastRenderMode() bool + PrintUnusedTemplates() bool + EnableMissingTranslationPlaceholders() bool + TemplateMetrics() bool + TemplateMetricsHints() bool + PrintI18nWarnings() bool + CreateTitle(s string) string + IgnoreFile(s string) bool + NewContentEditor() string + Timeout() time.Duration + StaticDirs() []string + IgnoredLogs() map[string]bool + WorkingDir() string + EnableEmoji() bool +} + +// We cannot import the media package as that would create a circular dependency. +// This interface defines a subset of what media.ContentTypes provides. +type ContentTypesProvider interface { + IsContentSuffix(suffix string) bool + IsContentFile(filename string) bool + IsIndexContentFile(filename string) bool + IsHTMLSuffix(suffix string) bool +} + // Provider provides the configuration settings for Hugo. type Provider interface { GetString(key string) string GetInt(key string) int GetBool(key string) bool - GetStringMap(key string) map[string]interface{} + GetParams(key string) maps.Params + GetStringMap(key string) map[string]any GetStringMapString(key string) map[string]string GetStringSlice(key string) []string - Get(key string) interface{} - Set(key string, value interface{}) + Get(key string) any + Set(key string, value any) + Keys() []string + Merge(key string, value any) + SetDefaults(params maps.Params) + SetDefaultMergeStrategy() + WalkParams(walkFn func(params ...maps.KeyParams) bool) IsSet(key string) bool } @@ -35,24 +108,5 @@ type Provider interface { // we do not attempt to split it into fields. func GetStringSlicePreserveString(cfg Provider, key string) []string { sd := cfg.Get(key) - return toStringSlicePreserveString(sd) -} - -func toStringSlicePreserveString(v interface{}) []string { - if sds, ok := v.(string); ok { - return []string{sds} - } - return cast.ToStringSlice(v) -} - -// SetBaseTestDefaults provides some common config defaults used in tests. -func SetBaseTestDefaults(cfg Provider) { - cfg.Set("resourceDir", "resources") - cfg.Set("contentDir", "content") - cfg.Set("dataDir", "data") - cfg.Set("i18nDir", "i18n") - cfg.Set("layoutDir", "layouts") - cfg.Set("assetDir", "assets") - cfg.Set("archetypeDir", "archetypes") - cfg.Set("publishDir", "public") + return types.ToStringSlicePreserveString(sd) } diff --git a/config/configProvider_test.go b/config/configProvider_test.go index d9fff56b6..0afba1e58 100644 --- a/config/configProvider_test.go +++ b/config/configProvider_test.go @@ -17,12 +17,11 @@ import ( "testing" qt "github.com/frankban/quicktest" - "github.com/spf13/viper" ) func TestGetStringSlicePreserveString(t *testing.T) { c := qt.New(t) - cfg := viper.New() + cfg := New() s := "This is a string" sSlice := []string{"This", "is", "a", "slice"} diff --git a/config/defaultConfigProvider.go b/config/defaultConfigProvider.go new file mode 100644 index 000000000..8c1d63851 --- /dev/null +++ b/config/defaultConfigProvider.go @@ -0,0 +1,366 @@ +// Copyright 2021 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 config + +import ( + "fmt" + "strings" + "sync" + + xmaps "golang.org/x/exp/maps" + + "github.com/spf13/cast" + + "github.com/gohugoio/hugo/common/maps" +) + +// New creates a Provider backed by an empty maps.Params. +func New() Provider { + return &defaultConfigProvider{ + root: make(maps.Params), + } +} + +// NewFrom creates a Provider backed by params. +func NewFrom(params maps.Params) Provider { + maps.PrepareParams(params) + return &defaultConfigProvider{ + root: params, + } +} + +// defaultConfigProvider is a Provider backed by a map where all keys are lower case. +// All methods are thread safe. +type defaultConfigProvider struct { + mu sync.RWMutex + root maps.Params + + keyCache sync.Map +} + +func (c *defaultConfigProvider) Get(k string) any { + if k == "" { + return c.root + } + c.mu.RLock() + key, m := c.getNestedKeyAndMap(strings.ToLower(k), false) + if m == nil { + c.mu.RUnlock() + return nil + } + v := m[key] + c.mu.RUnlock() + return v +} + +func (c *defaultConfigProvider) GetBool(k string) bool { + v := c.Get(k) + return cast.ToBool(v) +} + +func (c *defaultConfigProvider) GetInt(k string) int { + v := c.Get(k) + return cast.ToInt(v) +} + +func (c *defaultConfigProvider) IsSet(k string) bool { + var found bool + c.mu.RLock() + key, m := c.getNestedKeyAndMap(strings.ToLower(k), false) + if m != nil { + _, found = m[key] + } + c.mu.RUnlock() + return found +} + +func (c *defaultConfigProvider) GetString(k string) string { + v := c.Get(k) + return cast.ToString(v) +} + +func (c *defaultConfigProvider) GetParams(k string) maps.Params { + v := c.Get(k) + if v == nil { + return nil + } + return v.(maps.Params) +} + +func (c *defaultConfigProvider) GetStringMap(k string) map[string]any { + v := c.Get(k) + return maps.ToStringMap(v) +} + +func (c *defaultConfigProvider) GetStringMapString(k string) map[string]string { + v := c.Get(k) + return maps.ToStringMapString(v) +} + +func (c *defaultConfigProvider) GetStringSlice(k string) []string { + v := c.Get(k) + return cast.ToStringSlice(v) +} + +func (c *defaultConfigProvider) Set(k string, v any) { + c.mu.Lock() + defer c.mu.Unlock() + + k = strings.ToLower(k) + + if k == "" { + if p, err := maps.ToParamsAndPrepare(v); err == nil { + // Set the values directly in root. + maps.SetParams(c.root, p) + } else { + c.root[k] = v + } + + return + } + + switch vv := v.(type) { + case map[string]any, map[any]any, map[string]string: + p := maps.MustToParamsAndPrepare(vv) + v = p + } + + key, m := c.getNestedKeyAndMap(k, true) + if m == nil { + return + } + + if existing, found := m[key]; found { + if p1, ok := existing.(maps.Params); ok { + if p2, ok := v.(maps.Params); ok { + maps.SetParams(p1, p2) + return + } + } + } + + m[key] = v +} + +// SetDefaults will set values from params if not already set. +func (c *defaultConfigProvider) SetDefaults(params maps.Params) { + maps.PrepareParams(params) + for k, v := range params { + if _, found := c.root[k]; !found { + c.root[k] = v + } + } +} + +func (c *defaultConfigProvider) Merge(k string, v any) { + c.mu.Lock() + defer c.mu.Unlock() + k = strings.ToLower(k) + + if k == "" { + rs, f := c.root.GetMergeStrategy() + if f && rs == maps.ParamsMergeStrategyNone { + // The user has set a "no merge" strategy on this, + // nothing more to do. + return + } + + if p, err := maps.ToParamsAndPrepare(v); err == nil { + // As there may be keys in p not in root, we need to handle + // those as a special case. + var keysToDelete []string + for kk, vv := range p { + if pp, ok := vv.(maps.Params); ok { + if pppi, ok := c.root[kk]; ok { + ppp := pppi.(maps.Params) + maps.MergeParamsWithStrategy("", ppp, pp) + } else { + // We need to use the default merge strategy for + // this key. + np := make(maps.Params) + strategy := c.determineMergeStrategy(maps.KeyParams{Key: "", Params: c.root}, maps.KeyParams{Key: kk, Params: np}) + np.SetMergeStrategy(strategy) + maps.MergeParamsWithStrategy("", np, pp) + c.root[kk] = np + if np.IsZero() { + // Just keep it until merge is done. + keysToDelete = append(keysToDelete, kk) + } + } + } + } + // Merge the rest. + maps.MergeParams(c.root, p) + for _, k := range keysToDelete { + delete(c.root, k) + } + } else { + panic(fmt.Sprintf("unsupported type %T received in Merge", v)) + } + + return + } + + switch vv := v.(type) { + case map[string]any, map[any]any, map[string]string: + p := maps.MustToParamsAndPrepare(vv) + v = p + } + + key, m := c.getNestedKeyAndMap(k, true) + if m == nil { + return + } + + if existing, found := m[key]; found { + if p1, ok := existing.(maps.Params); ok { + if p2, ok := v.(maps.Params); ok { + maps.MergeParamsWithStrategy("", p1, p2) + } + } + } else { + m[key] = v + } +} + +func (c *defaultConfigProvider) Keys() []string { + c.mu.RLock() + defer c.mu.RUnlock() + return xmaps.Keys(c.root) +} + +func (c *defaultConfigProvider) WalkParams(walkFn func(params ...maps.KeyParams) bool) { + var walk func(params ...maps.KeyParams) + walk = func(params ...maps.KeyParams) { + if walkFn(params...) { + return + } + p1 := params[len(params)-1] + i := len(params) + for k, v := range p1.Params { + if p2, ok := v.(maps.Params); ok { + paramsplus1 := make([]maps.KeyParams, i+1) + copy(paramsplus1, params) + paramsplus1[i] = maps.KeyParams{Key: k, Params: p2} + walk(paramsplus1...) + } + } + } + walk(maps.KeyParams{Key: "", Params: c.root}) +} + +func (c *defaultConfigProvider) determineMergeStrategy(params ...maps.KeyParams) maps.ParamsMergeStrategy { + if len(params) == 0 { + return maps.ParamsMergeStrategyNone + } + + var ( + strategy maps.ParamsMergeStrategy + prevIsRoot bool + curr = params[len(params)-1] + ) + + if len(params) > 1 { + prev := params[len(params)-2] + prevIsRoot = prev.Key == "" + + // Inherit from parent (but not from the root unless it's set by user). + s, found := prev.Params.GetMergeStrategy() + if !prevIsRoot && !found { + panic("invalid state, merge strategy not set on parent") + } + if found || !prevIsRoot { + strategy = s + } + } + + switch curr.Key { + case "": + // Don't set a merge strategy on the root unless set by user. + // This will be handled as a special case. + case "params": + strategy = maps.ParamsMergeStrategyDeep + case "outputformats", "mediatypes": + if prevIsRoot { + strategy = maps.ParamsMergeStrategyShallow + } + case "menus": + isMenuKey := prevIsRoot + if !isMenuKey { + // Can also be set below languages. + // root > languages > en > menus + if len(params) == 4 && params[1].Key == "languages" { + isMenuKey = true + } + } + if isMenuKey { + strategy = maps.ParamsMergeStrategyShallow + } + default: + if strategy == "" { + strategy = maps.ParamsMergeStrategyNone + } + } + + return strategy +} + +func (c *defaultConfigProvider) SetDefaultMergeStrategy() { + c.WalkParams(func(params ...maps.KeyParams) bool { + if len(params) == 0 { + return false + } + p := params[len(params)-1].Params + var found bool + if _, found = p.GetMergeStrategy(); found { + // Set by user. + return false + } + strategy := c.determineMergeStrategy(params...) + if strategy != "" { + p.SetMergeStrategy(strategy) + } + return false + }) +} + +func (c *defaultConfigProvider) getNestedKeyAndMap(key string, create bool) (string, maps.Params) { + var parts []string + v, ok := c.keyCache.Load(key) + if ok { + parts = v.([]string) + } else { + parts = strings.Split(key, ".") + c.keyCache.Store(key, parts) + } + current := c.root + for i := range len(parts) - 1 { + next, found := current[parts[i]] + if !found { + if create { + next = make(maps.Params) + current[parts[i]] = next + } else { + return "", nil + } + } + var ok bool + current, ok = next.(maps.Params) + if !ok { + // E.g. a string, not a map that we can store values in. + return "", nil + } + } + return parts[len(parts)-1], current +} diff --git a/config/defaultConfigProvider_test.go b/config/defaultConfigProvider_test.go new file mode 100644 index 000000000..cd6247e60 --- /dev/null +++ b/config/defaultConfigProvider_test.go @@ -0,0 +1,400 @@ +// Copyright 2021 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 config + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + "testing" + + "github.com/gohugoio/hugo/common/para" + + "github.com/gohugoio/hugo/common/maps" + + qt "github.com/frankban/quicktest" +) + +func TestDefaultConfigProvider(t *testing.T) { + c := qt.New(t) + + c.Run("Set and get", func(c *qt.C) { + cfg := New() + var k string + var v any + + k, v = "foo", "bar" + cfg.Set(k, v) + c.Assert(cfg.Get(k), qt.Equals, v) + c.Assert(cfg.Get(strings.ToUpper(k)), qt.Equals, v) + c.Assert(cfg.GetString(k), qt.Equals, v) + + k, v = "foo", 42 + cfg.Set(k, v) + c.Assert(cfg.Get(k), qt.Equals, v) + c.Assert(cfg.GetInt(k), qt.Equals, v) + + c.Assert(cfg.Get(""), qt.DeepEquals, maps.Params{ + "foo": 42, + }) + }) + + c.Run("Set and get map", func(c *qt.C) { + cfg := New() + + cfg.Set("foo", map[string]any{ + "bar": "baz", + }) + + c.Assert(cfg.Get("foo"), qt.DeepEquals, maps.Params{ + "bar": "baz", + }) + + c.Assert(cfg.GetStringMap("foo"), qt.DeepEquals, map[string]any{"bar": string("baz")}) + c.Assert(cfg.GetStringMapString("foo"), qt.DeepEquals, map[string]string{"bar": string("baz")}) + }) + + c.Run("Set and get nested", func(c *qt.C) { + cfg := New() + + cfg.Set("a", map[string]any{ + "B": "bv", + }) + cfg.Set("a.c", "cv") + + c.Assert(cfg.Get("a"), qt.DeepEquals, maps.Params{ + "b": "bv", + "c": "cv", + }) + c.Assert(cfg.Get("a.c"), qt.Equals, "cv") + + cfg.Set("b.a", "av") + c.Assert(cfg.Get("b"), qt.DeepEquals, maps.Params{ + "a": "av", + }) + + cfg.Set("b", map[string]any{ + "b": "bv", + }) + + c.Assert(cfg.Get("b"), qt.DeepEquals, maps.Params{ + "a": "av", + "b": "bv", + }) + + cfg = New() + + cfg.Set("a", "av") + + cfg.Set("", map[string]any{ + "a": "av2", + "b": "bv2", + }) + + c.Assert(cfg.Get(""), qt.DeepEquals, maps.Params{ + "a": "av2", + "b": "bv2", + }) + + cfg = New() + + cfg.Set("a", "av") + + cfg.Set("", map[string]any{ + "b": "bv2", + }) + + c.Assert(cfg.Get(""), qt.DeepEquals, maps.Params{ + "a": "av", + "b": "bv2", + }) + + cfg = New() + + cfg.Set("", map[string]any{ + "foo": map[string]any{ + "a": "av", + }, + }) + + cfg.Set("", map[string]any{ + "foo": map[string]any{ + "b": "bv2", + }, + }) + + c.Assert(cfg.Get("foo"), qt.DeepEquals, maps.Params{ + "a": "av", + "b": "bv2", + }) + }) + + c.Run("Merge default strategy", func(c *qt.C) { + cfg := New() + + cfg.Set("a", map[string]any{ + "B": "bv", + }) + + cfg.Merge("a", map[string]any{ + "B": "bv2", + "c": "cv2", + }) + + c.Assert(cfg.Get("a"), qt.DeepEquals, maps.Params{ + "b": "bv", + "c": "cv2", + }) + + cfg = New() + + cfg.Set("a", "av") + + cfg.Merge("", map[string]any{ + "a": "av2", + "b": "bv2", + }) + + c.Assert(cfg.Get(""), qt.DeepEquals, maps.Params{ + "a": "av", + }) + }) + + c.Run("Merge shallow", func(c *qt.C) { + cfg := New() + + cfg.Set("a", map[string]any{ + "_merge": "shallow", + "B": "bv", + "c": map[string]any{ + "b": "bv", + }, + }) + + cfg.Merge("a", map[string]any{ + "c": map[string]any{ + "d": "dv2", + }, + "e": "ev2", + }) + + c.Assert(cfg.Get("a"), qt.DeepEquals, maps.Params{ + "e": "ev2", + "_merge": maps.ParamsMergeStrategyShallow, + "b": "bv", + "c": maps.Params{ + "b": "bv", + }, + }) + }) + + // Issue #8679 + c.Run("Merge typed maps", func(c *qt.C) { + for _, left := range []any{ + map[string]string{ + "c": "cv1", + }, + map[string]any{ + "c": "cv1", + }, + map[any]any{ + "c": "cv1", + }, + } { + cfg := New() + + cfg.Set("", map[string]any{ + "b": left, + }) + + cfg.Merge("", maps.Params{ + "b": maps.Params{ + "c": "cv2", + "d": "dv2", + }, + }) + + c.Assert(cfg.Get(""), qt.DeepEquals, maps.Params{ + "b": maps.Params{ + "c": "cv1", + "d": "dv2", + }, + }) + } + + for _, left := range []any{ + map[string]string{ + "b": "bv1", + }, + map[string]any{ + "b": "bv1", + }, + map[any]any{ + "b": "bv1", + }, + } { + for _, right := range []any{ + map[string]string{ + "b": "bv2", + "c": "cv2", + }, + map[string]any{ + "b": "bv2", + "c": "cv2", + }, + map[any]any{ + "b": "bv2", + "c": "cv2", + }, + } { + cfg := New() + + cfg.Set("a", left) + + cfg.Merge("a", right) + + c.Assert(cfg.Get(""), qt.DeepEquals, maps.Params{ + "a": maps.Params{ + "b": "bv1", + "c": "cv2", + }, + }) + } + } + }) + + // Issue #8701 + c.Run("Prevent _merge only maps", func(c *qt.C) { + cfg := New() + + cfg.Set("", map[string]any{ + "B": "bv", + }) + + cfg.Merge("", map[string]any{ + "c": map[string]any{ + "_merge": "shallow", + "d": "dv2", + }, + }) + + c.Assert(cfg.Get(""), qt.DeepEquals, maps.Params{ + "b": "bv", + }) + }) + + c.Run("IsSet", func(c *qt.C) { + cfg := New() + + cfg.Set("a", map[string]any{ + "B": "bv", + }) + + c.Assert(cfg.IsSet("A"), qt.IsTrue) + c.Assert(cfg.IsSet("a.b"), qt.IsTrue) + c.Assert(cfg.IsSet("z"), qt.IsFalse) + }) + + c.Run("Para", func(c *qt.C) { + cfg := New() + p := para.New(4) + r, _ := p.Start(context.Background()) + + setAndGet := func(k string, v int) error { + vs := strconv.Itoa(v) + cfg.Set(k, v) + err := errors.New("get failed") + if cfg.Get(k) != v { + return err + } + if cfg.GetInt(k) != v { + return err + } + if cfg.GetString(k) != vs { + return err + } + if !cfg.IsSet(k) { + return err + } + return nil + } + + for i := range 20 { + i := i + r.Run(func() error { + const v = 42 + k := fmt.Sprintf("k%d", i) + if err := setAndGet(k, v); err != nil { + return err + } + + m := maps.Params{ + "new": 42, + } + + cfg.Merge("", m) + + return nil + }) + } + + c.Assert(r.Wait(), qt.IsNil) + }) +} + +func BenchmarkDefaultConfigProvider(b *testing.B) { + type cfger interface { + Get(key string) any + Set(key string, value any) + IsSet(key string) bool + } + + newMap := func() map[string]any { + return map[string]any{ + "a": map[string]any{ + "b": map[string]any{ + "c": 32, + "d": 43, + }, + }, + "b": 62, + } + } + + runMethods := func(b *testing.B, cfg cfger) { + m := newMap() + cfg.Set("mymap", m) + cfg.Set("num", 32) + if !(cfg.IsSet("mymap") && cfg.IsSet("mymap.a") && cfg.IsSet("mymap.a.b") && cfg.IsSet("mymap.a.b.c")) { + b.Fatal("IsSet failed") + } + + if cfg.Get("num") != 32 { + b.Fatal("Get failed") + } + + if cfg.Get("mymap.a.b.c") != 32 { + b.Fatal("Get failed") + } + } + + b.Run("Custom", func(b *testing.B) { + cfg := New() + for i := 0; i < b.N; i++ { + runMethods(b, cfg) + } + }) +} diff --git a/config/env.go b/config/env.go index f482cd247..4dcd63653 100644 --- a/config/env.go +++ b/config/env.go @@ -18,6 +18,12 @@ import ( "runtime" "strconv" "strings" + + "github.com/pbnjay/memory" +) + +const ( + gigabyte = 1 << 30 ) // GetNumWorkerMultiplier returns the base value used to calculate the number @@ -33,6 +39,36 @@ func GetNumWorkerMultiplier() int { return runtime.NumCPU() } +// GetMemoryLimit returns the upper memory limit in bytes for Hugo's in-memory caches. +// Note that this does not represent "all of the memory" that Hugo will use, +// so it needs to be set to a lower number than the available system memory. +// It will read from the HUGO_MEMORYLIMIT (in Gigabytes) environment variable. +// If that is not set, it will set aside a quarter of the total system memory. +func GetMemoryLimit() uint64 { + if mem := os.Getenv("HUGO_MEMORYLIMIT"); mem != "" { + if v := stringToGibabyte(mem); v > 0 { + return v + } + } + + // There is a FreeMemory function, but as the kernel in most situations + // will take whatever memory that is left and use for caching etc., + // that value is not something that we can use. + m := memory.TotalMemory() + if m != 0 { + return uint64(m / 4) + } + + return 2 * gigabyte +} + +func stringToGibabyte(f string) uint64 { + if v, err := strconv.ParseFloat(f, 32); err == nil && v > 0 { + return uint64(v * gigabyte) + } + return 0 +} + // SetEnvVars sets vars on the form key=value in the oldVars slice. func SetEnvVars(oldVars *[]string, keyValues ...string) { for i := 0; i < len(keyValues); i += 2 { @@ -41,8 +77,8 @@ func SetEnvVars(oldVars *[]string, keyValues ...string) { } func SplitEnvVar(v string) (string, string) { - parts := strings.Split(v, "=") - return parts[0], parts[1] + name, value, _ := strings.Cut(v, "=") + return name, value } func setEnvVar(vars *[]string, key, value string) { diff --git a/config/namespace.go b/config/namespace.go new file mode 100644 index 000000000..e41b56e2d --- /dev/null +++ b/config/namespace.go @@ -0,0 +1,75 @@ +// Copyright 2024 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 config + +import ( + "encoding/json" + + "github.com/gohugoio/hugo/common/hashing" +) + +func DecodeNamespace[S, C any](configSource any, buildConfig func(any) (C, any, error)) (*ConfigNamespace[S, C], error) { + // Calculate the hash of the input (not including any defaults applied later). + // This allows us to introduce new config options without breaking the hash. + h := hashing.HashStringHex(configSource) + + // Build the config + c, ext, err := buildConfig(configSource) + if err != nil { + return nil, err + } + + if ext == nil { + ext = configSource + } + + if ext == nil { + panic("ext is nil") + } + + ns := &ConfigNamespace[S, C]{ + SourceStructure: ext, + SourceHash: h, + Config: c, + } + + return ns, nil +} + +// ConfigNamespace holds a Hugo configuration namespace. +// The construct looks a little odd, but it's built to make the configuration elements +// both self-documenting and contained in a common structure. +type ConfigNamespace[S, C any] struct { + // SourceStructure represents the source configuration with any defaults applied. + // This is used for documentation and printing of the configuration setup to the user. + SourceStructure any + + // SourceHash is a hash of the source configuration before any defaults gets applied. + SourceHash string + + // Config is the final configuration as used by Hugo. + Config C +} + +// MarshalJSON marshals the source structure. +func (ns *ConfigNamespace[S, C]) MarshalJSON() ([]byte, error) { + return json.Marshal(ns.SourceStructure) +} + +// Signature returns the signature of the source structure. +// Note that this is for documentation purposes only and SourceStructure may not always be cast to S (it's usually just a map). +func (ns *ConfigNamespace[S, C]) Signature() S { + var s S + return s +} diff --git a/config/namespace_test.go b/config/namespace_test.go new file mode 100644 index 000000000..f443523a4 --- /dev/null +++ b/config/namespace_test.go @@ -0,0 +1,60 @@ +// Copyright 2024 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 config + +import ( + "strings" + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/common/maps" + "github.com/mitchellh/mapstructure" +) + +func TestNamespace(t *testing.T) { + c := qt.New(t) + c.Assert(true, qt.Equals, true) + + // ns, err := config.DecodeNamespace[map[string]DocsMediaTypeConfig](in, defaultMediaTypesConfig, buildConfig) + + ns, err := DecodeNamespace[[]*tstNsExt]( + map[string]any{"foo": "bar"}, + func(v any) (*tstNsExt, any, error) { + t := &tstNsExt{} + m, err := maps.ToStringMapE(v) + if err != nil { + return nil, nil, err + } + return t, nil, mapstructure.WeakDecode(m, t) + }, + ) + + c.Assert(err, qt.IsNil) + c.Assert(ns, qt.Not(qt.IsNil)) + c.Assert(ns.SourceStructure, qt.DeepEquals, map[string]any{"foo": "bar"}) + c.Assert(ns.SourceHash, qt.Equals, "1420f6c7782f7459") + c.Assert(ns.Config, qt.DeepEquals, &tstNsExt{Foo: "bar"}) + c.Assert(ns.Signature(), qt.DeepEquals, []*tstNsExt(nil)) +} + +type ( + tstNsExt struct { + Foo string + } +) + +func (t *tstNsExt) Init() error { + t.Foo = strings.ToUpper(t.Foo) + return nil +} diff --git a/config/privacy/privacyConfig.go b/config/privacy/privacyConfig.go index ea34563eb..900f73540 100644 --- a/config/privacy/privacyConfig.go +++ b/config/privacy/privacyConfig.go @@ -30,9 +30,10 @@ type Config struct { Disqus Disqus GoogleAnalytics GoogleAnalytics Instagram Instagram - Twitter Twitter + Twitter Twitter // deprecated in favor of X in v0.141.0 Vimeo Vimeo YouTube YouTube + X X } // Disqus holds the privacy configuration settings related to the Disqus template. @@ -44,15 +45,9 @@ type Disqus struct { type GoogleAnalytics struct { Service `mapstructure:",squash"` - // Enabling this will disable the use of Cookies and use Session Storage to Store the GA Client ID. - UseSessionStorage bool - // Enabling this will make the GA templates respect the // "Do Not Track" HTTP header. See https://www.paulfurley.com/google-analytics-dnt/. RespectDoNotTrack bool - - // Enabling this will make it so the users' IP addresses are anonymized within Google Analytics. - AnonymizeIP bool } // Instagram holds the privacy configuration settings related to the Instagram shortcode. @@ -64,7 +59,8 @@ type Instagram struct { Simple bool } -// Twitter holds the privacy configuration settingsrelated to the Twitter shortcode. +// 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"` @@ -76,17 +72,21 @@ type Twitter struct { Simple bool } -// Vimeo holds the privacy configuration settingsrelated to the Vimeo shortcode. +// Vimeo holds the privacy configuration settings related to the Vimeo shortcode. type Vimeo struct { Service `mapstructure:",squash"` + // When set to true, the Vimeo player will be blocked from tracking any session data, + // including all cookies and stats. + EnableDNT bool + // If simple mode is enabled, only a thumbnail is fetched from i.vimeocdn.com and // shown with a play button overlaid. If a user clicks the button, he/she will // be taken to the video page on vimeo.com in a new browser tab. Simple bool } -// YouTube holds the privacy configuration settingsrelated to the YouTube shortcode. +// YouTube holds the privacy configuration settings related to the YouTube shortcode. type YouTube struct { Service `mapstructure:",squash"` @@ -96,6 +96,20 @@ type YouTube struct { PrivacyEnhanced bool } +// X holds the privacy configuration settings related to the X shortcode. +type X struct { + Service `mapstructure:",squash"` + + // When set to true, the X post 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 X post will + // be built. + Simple bool +} + // DecodeConfig creates a privacy Config from a given Hugo configuration. func DecodeConfig(cfg config.Provider) (pc Config, err error) { if !cfg.IsSet(privacyConfigKey) { diff --git a/config/privacy/privacyConfig_test.go b/config/privacy/privacyConfig_test.go index d798721e1..1dd20215b 100644 --- a/config/privacy/privacyConfig_test.go +++ b/config/privacy/privacyConfig_test.go @@ -18,7 +18,6 @@ import ( qt "github.com/frankban/quicktest" "github.com/gohugoio/hugo/config" - "github.com/spf13/viper" ) func TestDecodeConfigFromTOML(t *testing.T) { @@ -34,17 +33,16 @@ disable = true [privacy.googleAnalytics] disable = true respectDoNotTrack = true -anonymizeIP = true -useSessionStorage = true [privacy.instagram] disable = true simple = true -[privacy.twitter] +[privacy.x] disable = true enableDNT = true simple = true [privacy.vimeo] disable = true +enableDNT = true simple = true [privacy.youtube] disable = true @@ -60,15 +58,14 @@ simple = true got := []bool{ pc.Disqus.Disable, pc.GoogleAnalytics.Disable, - pc.GoogleAnalytics.RespectDoNotTrack, pc.GoogleAnalytics.AnonymizeIP, - pc.GoogleAnalytics.UseSessionStorage, pc.Instagram.Disable, - pc.Instagram.Simple, pc.Twitter.Disable, pc.Twitter.EnableDNT, - pc.Twitter.Simple, pc.Vimeo.Disable, pc.Vimeo.Simple, - pc.YouTube.PrivacyEnhanced, pc.YouTube.Disable, + pc.GoogleAnalytics.RespectDoNotTrack, pc.Instagram.Disable, + pc.Instagram.Simple, + pc.Vimeo.Disable, pc.Vimeo.EnableDNT, pc.Vimeo.Simple, + pc.YouTube.PrivacyEnhanced, pc.YouTube.Disable, pc.X.Disable, pc.X.EnableDNT, + pc.X.Simple, } c.Assert(got, qt.All(qt.Equals), true) - } func TestDecodeConfigFromTOMLCaseInsensitive(t *testing.T) { @@ -94,7 +91,7 @@ PrivacyENhanced = true func TestDecodeConfigDefault(t *testing.T) { c := qt.New(t) - pc, err := DecodeConfig(viper.New()) + pc, err := DecodeConfig(config.New()) c.Assert(err, qt.IsNil) c.Assert(pc, qt.Not(qt.IsNil)) c.Assert(pc.YouTube.PrivacyEnhanced, qt.Equals, false) diff --git a/config/security/securityConfig.go b/config/security/securityConfig.go new file mode 100644 index 000000000..a3ec5197d --- /dev/null +++ b/config/security/securityConfig.go @@ -0,0 +1,230 @@ +// Copyright 2018 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 security + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" + + "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/common/types" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/parser" + "github.com/gohugoio/hugo/parser/metadecoders" + "github.com/mitchellh/mapstructure" +) + +const securityConfigKey = "security" + +// DefaultConfig holds the default security policy. +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). + "^postcss$", + "^tailwindcss$", + ), + // These have been tested to work with Hugo's external programs + // on Windows, Linux and MacOS. + OsEnv: MustNewWhitelist(`(?i)^((HTTPS?|NO)_PROXY|PATH(EXT)?|APPDATA|TE?MP|TERM|GO\w+|(XDG_CONFIG_)?HOME|USERPROFILE|SSH_AUTH_SOCK|DISPLAY|LANG|SYSTEMDRIVE)$`), + }, + Funcs: Funcs{ + Getenv: MustNewWhitelist("^HUGO_", "^CI$"), + }, + HTTP: HTTP{ + URLs: MustNewWhitelist(".*"), + Methods: MustNewWhitelist("(?i)GET|POST"), + }, +} + +// Config is the top level security config. +// {"name": "security", "description": "This section holds the top level security config.", "newIn": "0.91.0" } +type Config struct { + // Restricts access to os.Exec.... + // { "newIn": "0.91.0" } + Exec Exec `json:"exec"` + + // Restricts access to certain template funcs. + Funcs Funcs `json:"funcs"` + + // Restricts access to resources.GetRemote, getJSON, getCSV. + HTTP HTTP `json:"http"` + + // Allow inline shortcodes + EnableInlineShortcodes bool `json:"enableInlineShortcodes"` +} + +// Exec holds os/exec policies. +type Exec struct { + Allow Whitelist `json:"allow"` + OsEnv Whitelist `json:"osEnv"` +} + +// Funcs holds template funcs policies. +type Funcs struct { + // OS env keys allowed to query in os.Getenv. + Getenv Whitelist `json:"getenv"` +} + +type HTTP struct { + // URLs to allow in remote HTTP (resources.Get, getJSON, getCSV). + URLs Whitelist `json:"urls"` + + // HTTP methods to allow. + Methods Whitelist `json:"methods"` + + // Media types where the Content-Type in the response is used instead of resolving from the file content. + MediaTypes Whitelist `json:"mediaTypes"` +} + +// ToTOML converts c to TOML with [security] as the root. +func (c Config) ToTOML() string { + sec := c.ToSecurityMap() + + var b bytes.Buffer + + if err := parser.InterfaceToConfig(sec, metadecoders.TOML, &b); err != nil { + panic(err) + } + + return strings.TrimSpace(b.String()) +} + +func (c Config) CheckAllowedExec(name string) error { + if !c.Exec.Allow.Accept(name) { + return &AccessDeniedError{ + name: name, + path: "security.exec.allow", + policies: c.ToTOML(), + } + } + return nil +} + +func (c Config) CheckAllowedGetEnv(name string) error { + if !c.Funcs.Getenv.Accept(name) { + return &AccessDeniedError{ + name: name, + path: "security.funcs.getenv", + policies: c.ToTOML(), + } + } + return nil +} + +func (c Config) CheckAllowedHTTPURL(url string) error { + if !c.HTTP.URLs.Accept(url) { + return &AccessDeniedError{ + name: url, + path: "security.http.urls", + policies: c.ToTOML(), + } + } + return nil +} + +func (c Config) CheckAllowedHTTPMethod(method string) error { + if !c.HTTP.Methods.Accept(method) { + return &AccessDeniedError{ + name: method, + path: "security.http.method", + 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. + asJson, err := json.Marshal(c) + herrors.Must(err) + m := make(map[string]any) + herrors.Must(json.Unmarshal(asJson, &m)) + + // Add the root + sec := map[string]any{ + "security": m, + } + return sec +} + +// DecodeConfig creates a privacy Config from a given Hugo configuration. +func DecodeConfig(cfg config.Provider) (Config, error) { + sc := DefaultConfig + if cfg.IsSet(securityConfigKey) { + m := cfg.GetStringMap(securityConfigKey) + dec, err := mapstructure.NewDecoder( + &mapstructure.DecoderConfig{ + WeaklyTypedInput: true, + Result: &sc, + DecodeHook: stringSliceToWhitelistHook(), + }, + ) + if err != nil { + return sc, err + } + + if err = dec.Decode(m); err != nil { + return sc, err + } + } + + if !sc.EnableInlineShortcodes { + // Legacy + sc.EnableInlineShortcodes = cfg.GetBool("enableInlineShortcodes") + } + + return sc, nil +} + +func stringSliceToWhitelistHook() mapstructure.DecodeHookFuncType { + return func( + f reflect.Type, + t reflect.Type, + data any, + ) (any, error) { + if t != reflect.TypeOf(Whitelist{}) { + return data, nil + } + + wl := types.ToStringSlicePreserveString(data) + + return NewWhitelist(wl...) + } +} + +// AccessDeniedError represents a security policy conflict. +type AccessDeniedError struct { + path string + name string + policies string +} + +func (e *AccessDeniedError) Error() string { + return fmt.Sprintf("access denied: %q is not whitelisted in policy %q; the current security configuration is:\n\n%s\n\n", e.name, e.path, e.policies) +} + +// IsAccessDenied reports whether err is an AccessDeniedError +func IsAccessDenied(err error) bool { + var notFoundErr *AccessDeniedError + return errors.As(err, ¬FoundErr) +} diff --git a/config/security/securityConfig_test.go b/config/security/securityConfig_test.go new file mode 100644 index 000000000..faa05a97f --- /dev/null +++ b/config/security/securityConfig_test.go @@ -0,0 +1,167 @@ +// Copyright 2018 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 security + +import ( + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/config" +) + +func TestDecodeConfigFromTOML(t *testing.T) { + c := qt.New(t) + + c.Run("Slice whitelist", func(c *qt.C) { + c.Parallel() + tomlConfig := ` + + +someOtherValue = "bar" + +[security] +enableInlineShortcodes=true +[security.exec] +allow=["a", "b"] +osEnv=["a", "b", "c"] +[security.funcs] +getEnv=["a", "b"] + +` + + cfg, err := config.FromConfigString(tomlConfig, "toml") + c.Assert(err, qt.IsNil) + + pc, err := DecodeConfig(cfg) + c.Assert(err, qt.IsNil) + c.Assert(pc, qt.Not(qt.IsNil)) + c.Assert(pc.EnableInlineShortcodes, qt.IsTrue) + c.Assert(pc.Exec.Allow.Accept("a"), qt.IsTrue) + c.Assert(pc.Exec.Allow.Accept("d"), qt.IsFalse) + c.Assert(pc.Exec.OsEnv.Accept("a"), qt.IsTrue) + c.Assert(pc.Exec.OsEnv.Accept("e"), qt.IsFalse) + c.Assert(pc.Funcs.Getenv.Accept("a"), qt.IsTrue) + c.Assert(pc.Funcs.Getenv.Accept("c"), qt.IsFalse) + }) + + c.Run("String whitelist", func(c *qt.C) { + c.Parallel() + tomlConfig := ` + + +someOtherValue = "bar" + +[security] +[security.exec] +allow="a" +osEnv="b" + +` + + cfg, err := config.FromConfigString(tomlConfig, "toml") + c.Assert(err, qt.IsNil) + + pc, err := DecodeConfig(cfg) + c.Assert(err, qt.IsNil) + c.Assert(pc, qt.Not(qt.IsNil)) + c.Assert(pc.Exec.Allow.Accept("a"), qt.IsTrue) + c.Assert(pc.Exec.Allow.Accept("d"), qt.IsFalse) + c.Assert(pc.Exec.OsEnv.Accept("b"), qt.IsTrue) + c.Assert(pc.Exec.OsEnv.Accept("e"), qt.IsFalse) + }) + + c.Run("Default exec.osEnv", func(c *qt.C) { + c.Parallel() + tomlConfig := ` + + +someOtherValue = "bar" + +[security] +[security.exec] +allow="a" + +` + + cfg, err := config.FromConfigString(tomlConfig, "toml") + c.Assert(err, qt.IsNil) + + pc, err := DecodeConfig(cfg) + c.Assert(err, qt.IsNil) + c.Assert(pc, qt.Not(qt.IsNil)) + c.Assert(pc.Exec.Allow.Accept("a"), qt.IsTrue) + c.Assert(pc.Exec.OsEnv.Accept("PATH"), qt.IsTrue) + c.Assert(pc.Exec.OsEnv.Accept("e"), qt.IsFalse) + }) + + c.Run("Enable inline shortcodes, legacy", func(c *qt.C) { + c.Parallel() + tomlConfig := ` + + +someOtherValue = "bar" +enableInlineShortcodes=true + +[security] +[security.exec] +allow="a" +osEnv="b" + +` + + cfg, err := config.FromConfigString(tomlConfig, "toml") + c.Assert(err, qt.IsNil) + + pc, err := DecodeConfig(cfg) + c.Assert(err, qt.IsNil) + c.Assert(pc.EnableInlineShortcodes, qt.IsTrue) + }) +} + +func TestToTOML(t *testing.T) { + c := qt.New(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)$']\n\n [security.funcs]\n getenv = ['^HUGO_', '^CI$']\n\n [security.http]\n methods = ['(?i)GET|POST']\n urls = ['.*']", + ) +} + +func TestDecodeConfigDefault(t *testing.T) { + t.Parallel() + c := qt.New(t) + + pc, err := DecodeConfig(config.New()) + 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.HTTP.URLs.Accept("https://example.org"), qt.IsTrue) + c.Assert(pc.HTTP.Methods.Accept("POST"), qt.IsTrue) + c.Assert(pc.HTTP.Methods.Accept("GET"), qt.IsTrue) + c.Assert(pc.HTTP.Methods.Accept("get"), qt.IsTrue) + c.Assert(pc.HTTP.Methods.Accept("DELETE"), qt.IsFalse) + c.Assert(pc.HTTP.MediaTypes.Accept("application/msword"), qt.IsFalse) + + c.Assert(pc.Exec.OsEnv.Accept("PATH"), qt.IsTrue) + c.Assert(pc.Exec.OsEnv.Accept("GOROOT"), qt.IsTrue) + c.Assert(pc.Exec.OsEnv.Accept("HOME"), qt.IsTrue) + c.Assert(pc.Exec.OsEnv.Accept("SSH_AUTH_SOCK"), qt.IsTrue) + 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) +} diff --git a/config/security/whitelist.go b/config/security/whitelist.go new file mode 100644 index 000000000..5ce369a1f --- /dev/null +++ b/config/security/whitelist.go @@ -0,0 +1,116 @@ +// Copyright 2021 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 security + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" +) + +const ( + acceptNoneKeyword = "none" +) + +// Whitelist holds a whitelist. +type Whitelist struct { + acceptNone bool + patterns []*regexp.Regexp + + // Store this for debugging/error reporting + patternsStrings []string +} + +// MarshalJSON is for internal use only. +func (w Whitelist) MarshalJSON() ([]byte, error) { + if w.acceptNone { + return json.Marshal(acceptNoneKeyword) + } + + return json.Marshal(w.patternsStrings) +} + +// 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. +func NewWhitelist(patterns ...string) (Whitelist, error) { + if len(patterns) == 0 { + return Whitelist{acceptNone: true}, nil + } + + var acceptSome bool + var patternsStrings []string + + for _, p := range patterns { + if p == acceptNoneKeyword { + acceptSome = false + break + } + + if ps := strings.TrimSpace(p); ps != "" { + acceptSome = true + patternsStrings = append(patternsStrings, ps) + } + } + + if !acceptSome { + return Whitelist{ + acceptNone: true, + }, nil + } + + var patternsr []*regexp.Regexp + + for i := range patterns { + p := strings.TrimSpace(patterns[i]) + if p == "" { + continue + } + re, err := regexp.Compile(p) + if err != nil { + return Whitelist{}, fmt.Errorf("failed to compile whitelist pattern %q: %w", p, err) + } + patternsr = append(patternsr, re) + } + + return Whitelist{patterns: patternsr, patternsStrings: patternsStrings}, nil +} + +// MustNewWhitelist creates a new Whitelist from zero or more patterns and panics on error. +func MustNewWhitelist(patterns ...string) Whitelist { + w, err := NewWhitelist(patterns...) + if err != nil { + panic(err) + } + return w +} + +// Accept reports whether name is whitelisted. +func (w Whitelist) Accept(name string) bool { + if w.acceptNone { + return false + } + + for _, p := range w.patterns { + if p.MatchString(name) { + return true + } + } + return false +} + +func (w Whitelist) String() string { + return fmt.Sprint(w.patternsStrings) +} diff --git a/config/security/whitelist_test.go b/config/security/whitelist_test.go new file mode 100644 index 000000000..add3345a8 --- /dev/null +++ b/config/security/whitelist_test.go @@ -0,0 +1,46 @@ +// Copyright 2021 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 security + +import ( + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestWhitelist(t *testing.T) { + t.Parallel() + c := qt.New(t) + + c.Run("none", func(c *qt.C) { + c.Assert(MustNewWhitelist("none", "foo").Accept("foo"), qt.IsFalse) + c.Assert(MustNewWhitelist().Accept("foo"), qt.IsFalse) + c.Assert(MustNewWhitelist("").Accept("foo"), qt.IsFalse) + c.Assert(MustNewWhitelist(" ", " ").Accept("foo"), qt.IsFalse) + c.Assert(Whitelist{}.Accept("foo"), qt.IsFalse) + }) + + c.Run("One", func(c *qt.C) { + w := MustNewWhitelist("^foo.*") + c.Assert(w.Accept("foo"), qt.IsTrue) + c.Assert(w.Accept("mfoo"), qt.IsFalse) + }) + + c.Run("Multiple", func(c *qt.C) { + w := MustNewWhitelist("^foo.*", "^bar.*") + c.Assert(w.Accept("foo"), qt.IsTrue) + c.Assert(w.Accept("bar"), qt.IsTrue) + c.Assert(w.Accept("mbar"), qt.IsFalse) + }) +} diff --git a/config/services/servicesConfig.go b/config/services/servicesConfig.go index 559848f5c..f9d5e1a6e 100644 --- a/config/services/servicesConfig.go +++ b/config/services/servicesConfig.go @@ -31,7 +31,8 @@ type Config struct { Disqus Disqus GoogleAnalytics GoogleAnalytics Instagram Instagram - Twitter Twitter + Twitter Twitter // deprecated in favor of X in v0.141.0 + X X RSS RSS } @@ -53,9 +54,15 @@ type Instagram struct { // This means that if you use Bootstrap 4 or want to provide your own CSS, you want // to disable the inline CSS provided by Hugo. DisableInlineCSS bool + + // App or Client Access Token. + // If you are using a Client Access Token, remember that you must combine it with your App ID + // using a pipe symbol (|) otherwise the request will fail. + AccessToken string } // 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 @@ -63,6 +70,14 @@ type Twitter struct { 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. + // This means that if you want to provide your own CSS, you want + // to disable the inline CSS provided by Hugo. + DisableInlineCSS bool +} + // RSS holds the functional configuration settings related to the RSS feeds. type RSS struct { // Limit the number of pages. @@ -86,6 +101,9 @@ func DecodeConfig(cfg config.Provider) (c Config, err error) { if c.RSS.Limit == 0 { c.RSS.Limit = cfg.GetInt(rssLimitKey) + if c.RSS.Limit == 0 { + c.RSS.Limit = -1 + } } return diff --git a/config/services/servicesConfig_test.go b/config/services/servicesConfig_test.go index ed3038159..952a7fe1c 100644 --- a/config/services/servicesConfig_test.go +++ b/config/services/servicesConfig_test.go @@ -18,7 +18,6 @@ import ( qt "github.com/frankban/quicktest" "github.com/gohugoio/hugo/config" - "github.com/spf13/viper" ) func TestDecodeConfigFromTOML(t *testing.T) { @@ -37,6 +36,8 @@ id = "ga_id" disableInlineCSS = true [services.twitter] disableInlineCSS = true +[services.x] +disableInlineCSS = true ` cfg, err := config.FromConfigString(tomlConfig, "toml") c.Assert(err, qt.IsNil) @@ -55,7 +56,7 @@ disableInlineCSS = true func TestUseSettingsFromRootIfSet(t *testing.T) { c := qt.New(t) - cfg := viper.New() + cfg := config.New() cfg.Set("disqusShortname", "root_short") cfg.Set("googleAnalytics", "ga_root") @@ -65,5 +66,4 @@ func TestUseSettingsFromRootIfSet(t *testing.T) { c.Assert(config.Disqus.Shortname, qt.Equals, "root_short") c.Assert(config.GoogleAnalytics.ID, qt.Equals, "ga_root") - } diff --git a/config/sitemap.go b/config/sitemap.go deleted file mode 100644 index 4031b7ec1..000000000 --- a/config/sitemap.go +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright 2019 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 config - -import ( - "github.com/spf13/cast" - jww "github.com/spf13/jwalterweatherman" -) - -// Sitemap configures the sitemap to be generated. -type Sitemap struct { - ChangeFreq string - Priority float64 - Filename string -} - -func DecodeSitemap(prototype Sitemap, input map[string]interface{}) Sitemap { - - for key, value := range input { - switch key { - case "changefreq": - prototype.ChangeFreq = cast.ToString(value) - case "priority": - prototype.Priority = cast.ToFloat64(value) - case "filename": - prototype.Filename = cast.ToString(value) - default: - jww.WARN.Printf("Unknown Sitemap field: %s\n", key) - } - } - - return prototype -} diff --git a/config/testconfig/testconfig.go b/config/testconfig/testconfig.go new file mode 100644 index 000000000..8f70e6cb7 --- /dev/null +++ b/config/testconfig/testconfig.go @@ -0,0 +1,83 @@ +// Copyright 2024 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. + +// This package should only be used for testing. +package testconfig + +import ( + _ "unsafe" + + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/config/allconfig" + "github.com/gohugoio/hugo/deps" + "github.com/gohugoio/hugo/hugofs" + toml "github.com/pelletier/go-toml/v2" + "github.com/spf13/afero" +) + +func GetTestConfigs(fs afero.Fs, cfg config.Provider) *allconfig.Configs { + if fs == nil { + fs = afero.NewMemMapFs() + } + if cfg == nil { + cfg = config.New() + } + // Make sure that the workingDir exists. + workingDir := cfg.GetString("workingDir") + if workingDir != "" { + if err := fs.MkdirAll(workingDir, 0o777); err != nil { + panic(err) + } + } + + configs, err := allconfig.LoadConfig(allconfig.ConfigSourceDescriptor{Fs: fs, Flags: cfg, Environ: []string{"EMPTY_TEST_ENVIRONMENT"}}) + if err != nil { + panic(err) + } + return configs +} + +func GetTestConfig(fs afero.Fs, cfg config.Provider) config.AllProvider { + return GetTestConfigs(fs, cfg).GetFirstLanguageConfig() +} + +func GetTestDeps(fs afero.Fs, cfg config.Provider, beforeInit ...func(*deps.Deps)) *deps.Deps { + if fs == nil { + fs = afero.NewMemMapFs() + } + conf := GetTestConfig(fs, cfg) + d := &deps.Deps{ + Conf: conf, + Fs: hugofs.NewFrom(fs, conf.BaseConfig()), + } + for _, f := range beforeInit { + f(d) + } + if err := d.Init(); err != nil { + panic(err) + } + return d +} + +func GetTestConfigSectionFromStruct(section string, v any) config.AllProvider { + data, err := toml.Marshal(v) + if err != nil { + panic(err) + } + p := maps.Params{ + section: config.FromTOMLConfigString(string(data)).Get(""), + } + cfg := config.NewFrom(p) + return GetTestConfig(nil, cfg) +} diff --git a/create/content.go b/create/content.go index 0e05adf93..a4661c1ba 100644 --- a/create/content.go +++ b/create/content.go @@ -16,138 +16,189 @@ package create import ( "bytes" - - "github.com/pkg/errors" - + "errors" + "fmt" "io" "os" - "os/exec" "path/filepath" "strings" - "github.com/gohugoio/hugo/hugofs/files" + "github.com/gohugoio/hugo/hugofs/glob" + + "github.com/gohugoio/hugo/common/hexec" + "github.com/gohugoio/hugo/common/hstrings" + "github.com/gohugoio/hugo/common/paths" "github.com/gohugoio/hugo/hugofs" "github.com/gohugoio/hugo/helpers" "github.com/gohugoio/hugo/hugolib" "github.com/spf13/afero" - jww "github.com/spf13/jwalterweatherman" ) -// NewContent creates a new content file in the content directory based upon the -// given kind, which is used to lookup an archetype. -func NewContent( - sites *hugolib.HugoSites, kind, targetPath string) error { - targetPath = filepath.Clean(targetPath) - ext := helpers.Ext(targetPath) - ps := sites.PathSpec - archetypeFs := ps.BaseFs.SourceFilesystems.Archetypes.Fs - sourceFs := ps.Fs.Source +const ( + // DefaultArchetypeTemplateTemplate is the template used in 'hugo new site' + // and the template we use as a fall back. + DefaultArchetypeTemplateTemplate = `--- +title: "{{ replace .File.ContentBaseName "-" " " | title }}" +date: {{ .Date }} +draft: true +--- - jww.INFO.Printf("attempting to create %q of %q of ext %q", targetPath, kind, ext) +` +) - archetypeFilename, isDir := findArchetype(ps, kind, ext) - contentPath, s := resolveContentPath(sites, sourceFs, targetPath) - - if isDir { - - langFs, err := hugofs.NewLanguageFs(sites.LanguageSet(), archetypeFs) - if err != nil { - return err - } - - cm, err := mapArcheTypeDir(ps, langFs, archetypeFilename) - if err != nil { - return err - } - - if cm.siteUsed { - if err := sites.Build(hugolib.BuildCfg{SkipRender: true}); err != nil { - return err - } - } - - name := filepath.Base(targetPath) - return newContentFromDir(archetypeFilename, sites, sourceFs, cm, name, contentPath) +// NewContent creates a new content file in h (or a full bundle if the archetype is a directory) +// in targetPath. +func NewContent(h *hugolib.HugoSites, kind, targetPath string, force bool) error { + if _, err := h.BaseFs.Content.Fs.Stat(""); err != nil { + return errors.New("no existing content directory configured for this project") } - // Building the sites can be expensive, so only do it if really needed. - siteUsed := false - - if archetypeFilename != "" { + cf := hugolib.NewContentFactory(h) + if kind == "" { var err error - siteUsed, err = usesSiteVar(archetypeFs, archetypeFilename) + kind, err = cf.SectionFromFilename(targetPath) if err != nil { return err } } - if siteUsed { - if err := sites.Build(hugolib.BuildCfg{SkipRender: true}); err != nil { - return err - } + b := &contentBuilder{ + archeTypeFs: h.PathSpec.BaseFs.Archetypes.Fs, + sourceFs: h.PathSpec.Fs.Source, + ps: h.PathSpec, + h: h, + cf: cf, + + kind: kind, + targetPath: targetPath, + force: force, } - content, err := executeArcheTypeAsTemplate(s, "", kind, targetPath, archetypeFilename) + ext := paths.Ext(targetPath) + + b.setArcheTypeFilenameToUse(ext) + + withBuildLock := func() (string, error) { + if !h.Configs.Base.NoBuildLock { + unlock, err := h.BaseFs.LockBuild() + if err != nil { + return "", fmt.Errorf("failed to acquire a build lock: %s", err) + } + defer unlock() + } + + if b.isDir { + return "", b.buildDir() + } + + if ext == "" { + return "", fmt.Errorf("failed to resolve %q to an archetype template", targetPath) + } + + if !h.Conf.ContentTypes().IsContentFile(b.targetPath) { + return "", fmt.Errorf("target path %q is not a known content format", b.targetPath) + } + + return b.buildFile() + } + + filename, err := withBuildLock() if err != nil { return err } - if err := helpers.SafeWriteToDisk(contentPath, bytes.NewReader(content), s.Fs.Source); err != nil { - return err - } - - jww.FEEDBACK.Println(contentPath, "created") - - editor := s.Cfg.GetString("newContentEditor") - if editor != "" { - jww.FEEDBACK.Printf("Editing %s with %q ...\n", targetPath, editor) - - cmd := exec.Command(editor, contentPath) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - return cmd.Run() + if filename != "" { + return b.openInEditorIfConfigured(filename) } return nil } -func targetSite(sites *hugolib.HugoSites, fi hugofs.FileMetaInfo) *hugolib.Site { - for _, s := range sites.Sites { - if fi.Meta().Lang() == s.Language().Lang { - return s - } - } - return sites.Sites[0] +type contentBuilder struct { + archeTypeFs afero.Fs + sourceFs afero.Fs + + ps *helpers.PathSpec + h *hugolib.HugoSites + cf hugolib.ContentFactory + + // Builder state + archetypeFi hugofs.FileMetaInfo + targetPath string + kind string + isDir bool + dirMap archetypeMap + force bool } -func newContentFromDir( - archetypeDir string, - sites *hugolib.HugoSites, - targetFs afero.Fs, - cm archetypeMap, name, targetPath string) error { +func (b *contentBuilder) buildDir() error { + // Split the dir into content files and the rest. + if err := b.mapArcheTypeDir(); err != nil { + return err + } + + var contentTargetFilenames []string + var baseDir string + + for _, fi := range b.dirMap.contentFiles { + + targetFilename := filepath.Join(b.targetPath, strings.TrimPrefix(fi.Meta().PathInfo.Path(), b.archetypeFi.Meta().PathInfo.Path())) + + // ===> post/my-post/pages/bio.md + abs, err := b.cf.CreateContentPlaceHolder(targetFilename, b.force) + if err != nil { + return err + } + if baseDir == "" { + baseDir = strings.TrimSuffix(abs, targetFilename) + } + + contentTargetFilenames = append(contentTargetFilenames, abs) + } + + var contentInclusionFilter *glob.FilenameFilter + if !b.dirMap.siteUsed { + // We don't need to build everything. + contentInclusionFilter = glob.NewFilenameFilterForInclusionFunc(func(filename string) bool { + filename = strings.TrimPrefix(filename, string(os.PathSeparator)) + for _, cn := range contentTargetFilenames { + if strings.Contains(cn, filename) { + return true + } + } + return false + }) + } + + if err := b.h.Build(hugolib.BuildCfg{NoBuildLock: true, SkipRender: true, ContentInclusionFilter: contentInclusionFilter}); err != nil { + return err + } + + for i, filename := range contentTargetFilenames { + if err := b.applyArcheType(filename, b.dirMap.contentFiles[i]); err != nil { + return err + } + } + + // Copy the rest as is. + for _, fi := range b.dirMap.otherFiles { + meta := fi.Meta() - for _, f := range cm.otherFiles { - meta := f.Meta() - filename := meta.Path() - // Just copy the file to destination. in, err := meta.Open() if err != nil { - return errors.Wrap(err, "failed to open non-content file") + return fmt.Errorf("failed to open non-content file: %w", err) } - - targetFilename := filepath.Join(targetPath, strings.TrimPrefix(filename, archetypeDir)) - + targetFilename := filepath.Join(baseDir, b.targetPath, strings.TrimPrefix(fi.Meta().Filename, b.archetypeFi.Meta().Filename)) targetDir := filepath.Dir(targetFilename) - if err := targetFs.MkdirAll(targetDir, 0777); err != nil && !os.IsExist(err) { - return errors.Wrapf(err, "failed to create target directory for %s:", targetDir) + + if err := b.sourceFs.MkdirAll(targetDir, 0o777); err != nil && !os.IsExist(err) { + return fmt.Errorf("failed to create target directory for %q: %w", targetDir, err) } - out, err := targetFs.Create(targetFilename) + out, err := b.sourceFs.Create(targetFilename) if err != nil { return err } @@ -161,26 +212,185 @@ func newContentFromDir( out.Close() } - for _, f := range cm.contentFiles { - filename := f.Meta().Path() - s := targetSite(sites, f) - targetFilename := filepath.Join(targetPath, strings.TrimPrefix(filename, archetypeDir)) - - content, err := executeArcheTypeAsTemplate(s, name, archetypeDir, targetFilename, filename) - if err != nil { - return errors.Wrap(err, "failed to execute archetype template") - } - - if err := helpers.SafeWriteToDisk(targetFilename, bytes.NewReader(content), targetFs); err != nil { - return errors.Wrap(err, "failed to save results") - } - } - - jww.FEEDBACK.Println(targetPath, "created") + b.h.Log.Printf("Content dir %q created", filepath.Join(baseDir, b.targetPath)) return nil } +func (b *contentBuilder) buildFile() (string, error) { + contentPlaceholderAbsFilename, err := b.cf.CreateContentPlaceHolder(b.targetPath, b.force) + if err != nil { + return "", err + } + + usesSite, err := b.usesSiteVar(b.archetypeFi) + if err != nil { + return "", err + } + + var contentInclusionFilter *glob.FilenameFilter + if !usesSite { + // We don't need to build everything. + contentInclusionFilter = glob.NewFilenameFilterForInclusionFunc(func(filename string) bool { + filename = strings.TrimPrefix(filename, string(os.PathSeparator)) + return strings.Contains(contentPlaceholderAbsFilename, filename) + }) + } + + if err := b.h.Build(hugolib.BuildCfg{NoBuildLock: true, SkipRender: true, ContentInclusionFilter: contentInclusionFilter}); err != nil { + return "", err + } + + if err := b.applyArcheType(contentPlaceholderAbsFilename, b.archetypeFi); err != nil { + return "", err + } + + b.h.Log.Printf("Content %q created", contentPlaceholderAbsFilename) + + return contentPlaceholderAbsFilename, nil +} + +func (b *contentBuilder) setArcheTypeFilenameToUse(ext string) { + var pathsToCheck []string + + if b.kind != "" { + pathsToCheck = append(pathsToCheck, b.kind+ext) + } + + pathsToCheck = append(pathsToCheck, "default"+ext) + + for _, p := range pathsToCheck { + fi, err := b.archeTypeFs.Stat(p) + if err == nil { + b.archetypeFi = fi.(hugofs.FileMetaInfo) + b.isDir = fi.IsDir() + return + } + } +} + +func (b *contentBuilder) applyArcheType(contentFilename string, archetypeFi hugofs.FileMetaInfo) error { + p := b.h.GetContentPage(contentFilename) + if p == nil { + panic(fmt.Sprintf("[BUG] no Page found for %q", contentFilename)) + } + + f, err := b.sourceFs.Create(contentFilename) + if err != nil { + return err + } + defer f.Close() + + if archetypeFi == nil { + return b.cf.ApplyArchetypeTemplate(f, p, b.kind, DefaultArchetypeTemplateTemplate) + } + + return b.cf.ApplyArchetypeFi(f, p, b.kind, archetypeFi) +} + +func (b *contentBuilder) mapArcheTypeDir() error { + var m archetypeMap + + seen := map[hstrings.Strings2]bool{} + + walkFn := func(path string, fim hugofs.FileMetaInfo) error { + if fim.IsDir() { + return nil + } + + pi := fim.Meta().PathInfo + + if pi.IsContent() { + pathLang := hstrings.Strings2{pi.PathBeforeLangAndOutputFormatAndExt(), fim.Meta().Lang} + if seen[pathLang] { + // Duplicate content file, e.g. page.md and page.html. + // In the regular build, we will filter out the duplicates, but + // for archetype folders these are ambiguous and we need to + // fail. + return fmt.Errorf("duplicate content file found in archetype folder: %q; having both e.g. %s.md and %s.html is ambigous", path, pi.BaseNameNoIdentifier(), pi.BaseNameNoIdentifier()) + } + seen[pathLang] = true + m.contentFiles = append(m.contentFiles, fim) + if !m.siteUsed { + var err error + m.siteUsed, err = b.usesSiteVar(fim) + if err != nil { + return err + } + } + return nil + } + + m.otherFiles = append(m.otherFiles, fim) + + return nil + } + + walkCfg := hugofs.WalkwayConfig{ + WalkFn: walkFn, + Fs: b.archeTypeFs, + Root: filepath.FromSlash(b.archetypeFi.Meta().PathInfo.Path()), + } + + w := hugofs.NewWalkway(walkCfg) + + if err := w.Walk(); err != nil { + return fmt.Errorf("failed to walk archetype dir %q: %w", b.archetypeFi.Meta().Filename, err) + } + + b.dirMap = m + + return nil +} + +func (b *contentBuilder) openInEditorIfConfigured(filename string) error { + editor := b.h.Conf.NewContentEditor() + if editor == "" { + return nil + } + + editorExec := strings.Fields(editor)[0] + editorFlags := strings.Fields(editor)[1:] + + var args []any + for _, editorFlag := range editorFlags { + args = append(args, editorFlag) + } + args = append( + args, + filename, + hexec.WithStdin(os.Stdin), + hexec.WithStderr(os.Stderr), + hexec.WithStdout(os.Stdout), + ) + + b.h.Log.Printf("Editing %q with %q ...\n", filename, editorExec) + + cmd, err := b.h.Deps.ExecHelper.New(editorExec, args...) + if err != nil { + return err + } + + return cmd.Run() +} + +func (b *contentBuilder) usesSiteVar(fi hugofs.FileMetaInfo) (bool, error) { + if fi == nil { + return false, nil + } + f, err := fi.Meta().Open() + if err != nil { + return false, err + } + defer f.Close() + bb, err := io.ReadAll(f) + if err != nil { + return false, fmt.Errorf("failed to read archetype file: %w", err) + } + + return bytes.Contains(bb, []byte(".Site")) || bytes.Contains(bb, []byte("site.")), nil +} + type archetypeMap struct { // These needs to be parsed and executed as Go templates. contentFiles []hugofs.FileMetaInfo @@ -190,160 +400,3 @@ type archetypeMap struct { // expensive, so only do when needed. siteUsed bool } - -func mapArcheTypeDir( - ps *helpers.PathSpec, - fs afero.Fs, - archetypeDir string) (archetypeMap, error) { - - var m archetypeMap - - walkFn := func(path string, fi hugofs.FileMetaInfo, err error) error { - - if err != nil { - return err - } - - if fi.IsDir() { - return nil - } - - fil := fi.(hugofs.FileMetaInfo) - - if files.IsContentFile(path) { - m.contentFiles = append(m.contentFiles, fil) - if !m.siteUsed { - m.siteUsed, err = usesSiteVar(fs, path) - if err != nil { - return err - } - } - return nil - } - - m.otherFiles = append(m.otherFiles, fil) - - return nil - } - - walkCfg := hugofs.WalkwayConfig{ - WalkFn: walkFn, - Fs: fs, - Root: archetypeDir, - } - - w := hugofs.NewWalkway(walkCfg) - - if err := w.Walk(); err != nil { - return m, errors.Wrapf(err, "failed to walk archetype dir %q", archetypeDir) - } - - return m, nil -} - -func usesSiteVar(fs afero.Fs, filename string) (bool, error) { - f, err := fs.Open(filename) - if err != nil { - return false, errors.Wrap(err, "failed to open archetype file") - } - defer f.Close() - return helpers.ReaderContains(f, []byte(".Site")), nil -} - -// Resolve the target content path. -func resolveContentPath(sites *hugolib.HugoSites, fs afero.Fs, targetPath string) (string, *hugolib.Site) { - targetDir := filepath.Dir(targetPath) - first := sites.Sites[0] - - var ( - s *hugolib.Site - siteContentDir string - ) - - // Try the filename: my-post.en.md - for _, ss := range sites.Sites { - if strings.Contains(targetPath, "."+ss.Language().Lang+".") { - s = ss - break - } - } - - var dirLang string - - for _, dir := range sites.BaseFs.Content.Dirs { - meta := dir.Meta() - contentDir := meta.Filename() - - if !strings.HasSuffix(contentDir, helpers.FilePathSeparator) { - contentDir += helpers.FilePathSeparator - } - - if strings.HasPrefix(targetPath, contentDir) { - siteContentDir = contentDir - dirLang = meta.Lang() - break - } - } - - if s == nil && dirLang != "" { - for _, ss := range sites.Sites { - if ss.Lang() == dirLang { - s = ss - break - } - } - } - - if s == nil { - s = first - } - - if targetDir != "" && targetDir != "." { - exists, _ := helpers.Exists(targetDir, fs) - - if exists { - return targetPath, s - } - } - - if siteContentDir == "" { - - } - - if siteContentDir != "" { - pp := filepath.Join(siteContentDir, strings.TrimPrefix(targetPath, siteContentDir)) - return s.PathSpec.AbsPathify(pp), s - } else { - var contentDir string - for _, dir := range sites.BaseFs.Content.Dirs { - contentDir = dir.Meta().Filename() - if dir.Meta().Lang() == s.Lang() { - break - } - } - return s.PathSpec.AbsPathify(filepath.Join(contentDir, targetPath)), s - } - -} - -// FindArchetype takes a given kind/archetype of content and returns the path -// to the archetype in the archetype filesystem, blank if none found. -func findArchetype(ps *helpers.PathSpec, kind, ext string) (outpath string, isDir bool) { - fs := ps.BaseFs.Archetypes.Fs - - var pathsToCheck []string - - if kind != "" { - pathsToCheck = append(pathsToCheck, kind+ext) - } - pathsToCheck = append(pathsToCheck, "default"+ext, "default") - - for _, p := range pathsToCheck { - fi, err := fs.Stat(p) - if err == nil { - return p, fi.IsDir() - } - } - - return "", false -} diff --git a/create/content_template_handler.go b/create/content_template_handler.go deleted file mode 100644 index e4cddedf5..000000000 --- a/create/content_template_handler.go +++ /dev/null @@ -1,149 +0,0 @@ -// Copyright 2017 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 create - -import ( - "bytes" - "fmt" - "path/filepath" - "strings" - "time" - - "github.com/pkg/errors" - - "github.com/gohugoio/hugo/helpers" - "github.com/gohugoio/hugo/source" - - "github.com/gohugoio/hugo/hugolib" - "github.com/gohugoio/hugo/tpl" - "github.com/spf13/afero" -) - -// ArchetypeFileData represents the data available to an archetype template. -type ArchetypeFileData struct { - // The archetype content type, either given as --kind option or extracted - // from the target path's section, i.e. "blog/mypost.md" will resolve to - // "blog". - Type string - - // The current date and time as a RFC3339 formatted string, suitable for use in front matter. - Date string - - // The Site, fully equipped with all the pages etc. Note: This will only be set if it is actually - // used in the archetype template. Also, if this is a multilingual setup, - // this site is the site that best matches the target content file, based - // on the presence of language code in the filename. - Site *hugolib.SiteInfo - - // Name will in most cases be the same as TranslationBaseName, e.g. "my-post". - // But if that value is "index" (bundles), the Name is instead the owning folder. - // This is the value you in most cases would want to use to construct the title in your - // archetype template. - Name string - - // The target content file. Note that the .Content will be empty, as that - // has not been created yet. - source.File -} - -const ( - // ArchetypeTemplateTemplate is used as initial template when adding an archetype template. - ArchetypeTemplateTemplate = `--- -title: "{{ replace .Name "-" " " | title }}" -date: {{ .Date }} -draft: true ---- - -` -) - -var ( - archetypeShortcodeReplacementsPre = strings.NewReplacer( - "{{<", "{x{<", - "{{%", "{x{%", - ">}}", ">}x}", - "%}}", "%}x}") - - archetypeShortcodeReplacementsPost = strings.NewReplacer( - "{x{<", "{{<", - "{x{%", "{{%", - ">}x}", ">}}", - "%}x}", "%}}") -) - -func executeArcheTypeAsTemplate(s *hugolib.Site, name, kind, targetPath, archetypeFilename string) ([]byte, error) { - - var ( - archetypeContent []byte - archetypeTemplate []byte - err error - ) - - f, err := s.SourceSpec.NewFileInfoFrom(targetPath, targetPath) - if err != nil { - return nil, err - } - - if name == "" { - name = f.TranslationBaseName() - - if name == "index" || name == "_index" { - // Page bundles; the directory name will hopefully have a better name. - dir := strings.TrimSuffix(f.Dir(), helpers.FilePathSeparator) - _, name = filepath.Split(dir) - } - } - - data := ArchetypeFileData{ - Type: kind, - Date: time.Now().Format(time.RFC3339), - Name: name, - File: f, - Site: &s.Info, - } - - if archetypeFilename == "" { - // TODO(bep) archetype revive the issue about wrong tpl funcs arg order - archetypeTemplate = []byte(ArchetypeTemplateTemplate) - } else { - archetypeTemplate, err = afero.ReadFile(s.BaseFs.Archetypes.Fs, archetypeFilename) - if err != nil { - return nil, fmt.Errorf("failed to read archetype file %s", err) - } - - } - - // The archetype template may contain shortcodes, and these does not play well - // with the Go templates. Need to set some temporary delimiters. - archetypeTemplate = []byte(archetypeShortcodeReplacementsPre.Replace(string(archetypeTemplate))) - - // Reuse the Hugo template setup to get the template funcs properly set up. - templateHandler := s.Deps.Tmpl().(tpl.TemplateManager) - templateName := helpers.Filename(archetypeFilename) - if err := templateHandler.AddTemplate("_text/"+templateName, string(archetypeTemplate)); err != nil { - return nil, errors.Wrapf(err, "Failed to parse archetype file %q:", archetypeFilename) - } - - templ, _ := templateHandler.Lookup(templateName) - - var buff bytes.Buffer - if err := templateHandler.Execute(templ, &buff, data); err != nil { - return nil, errors.Wrapf(err, "Failed to process archetype file %q:", archetypeFilename) - } - - archetypeContent = []byte(archetypeShortcodeReplacementsPost.Replace(buff.String())) - - return archetypeContent, nil - -} diff --git a/create/content_test.go b/create/content_test.go index f43d3a5f4..429edfc26 100644 --- a/create/content_test.go +++ b/create/content_test.go @@ -14,76 +14,99 @@ package create_test import ( + "fmt" "os" "path/filepath" "strings" "testing" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/config/allconfig" + "github.com/gohugoio/hugo/config/testconfig" + "github.com/gohugoio/hugo/deps" "github.com/gohugoio/hugo/hugolib" - "fmt" - "github.com/gohugoio/hugo/hugofs" qt "github.com/frankban/quicktest" "github.com/gohugoio/hugo/create" "github.com/gohugoio/hugo/helpers" "github.com/spf13/afero" - "github.com/spf13/viper" ) -func TestNewContent(t *testing.T) { - +// TODO(bep) clean this up. Export the test site builder in Hugolib or something. +func TestNewContentFromFile(t *testing.T) { cases := []struct { + name string kind string path string - expected []string + expected any }{ - {"post", "post/sample-1.md", []string{`title = "Post Arch title"`, `test = "test1"`, "date = \"2015-01-12T19:20:04-07:00\""}}, - {"post", "post/org-1.org", []string{`#+title: ORG-1`}}, - {"emptydate", "post/sample-ed.md", []string{`title = "Empty Date Arch title"`, `test = "test1"`}}, - {"stump", "stump/sample-2.md", []string{`title: "Sample 2"`}}, // no archetype file - {"", "sample-3.md", []string{`title: "Sample 3"`}}, // no archetype - {"product", "product/sample-4.md", []string{`title = "SAMPLE-4"`}}, // empty archetype front matter - {"lang", "post/lang-1.md", []string{`Site Lang: en|Name: Lang 1|i18n: Hugo Rocks!`}}, - {"lang", "post/lang-2.en.md", []string{`Site Lang: en|Name: Lang 2|i18n: Hugo Rocks!`}}, - {"lang", "content/post/lang-3.nn.md", []string{`Site Lang: nn|Name: Lang 3|i18n: Hugo Rokkar!`}}, - {"lang", "content_nn/post/lang-4.md", []string{`Site Lang: nn|Name: Lang 4|i18n: Hugo Rokkar!`}}, - {"lang", "content_nn/post/lang-5.en.md", []string{`Site Lang: en|Name: Lang 5|i18n: Hugo Rocks!`}}, - {"lang", "post/my-bundle/index.md", []string{`Site Lang: en|Name: My Bundle|i18n: Hugo Rocks!`}}, - {"lang", "post/my-bundle/index.en.md", []string{`Site Lang: en|Name: My Bundle|i18n: Hugo Rocks!`}}, - {"lang", "content/post/my-bundle/index.nn.md", []string{`Site Lang: nn|Name: My Bundle|i18n: Hugo Rokkar!`}}, - {"shortcodes", "shortcodes/go.md", []string{ + {"Post", "post", "post/sample-1.md", []string{`title = "Post Arch title"`, `test = "test1"`, "date = \"2015-01-12T19:20:04-07:00\""}}, + {"Post org-mode", "post", "post/org-1.org", []string{`#+title: ORG-1`}}, + {"Post, unknown content filetype", "post", "post/sample-1.pdoc", false}, + {"Empty date", "emptydate", "post/sample-ed.md", []string{`title = "Empty Date Arch title"`, `test = "test1"`}}, + {"Archetype file not found", "stump", "stump/sample-2.md", []string{`title: "Sample 2"`}}, // no archetype file + {"No archetype", "", "sample-3.md", []string{`title: "Sample 3"`}}, // no archetype + {"Empty archetype", "product", "product/sample-4.md", []string{`title = "SAMPLE-4"`}}, // empty archetype front matter + {"Filenames", "filenames", "content/mypage/index.md", []string{"title = \"INDEX\"\n+++\n\n\nContentBaseName: mypage"}}, + {"Branch Name", "name", "content/tags/tag-a/_index.md", []string{"+++\ntitle = 'Tag A'\n+++"}}, + + {"Lang 1", "lang", "post/lang-1.md", []string{`Site Lang: en|Name: Lang 1|i18n: Hugo Rocks!`}}, + {"Lang 2", "lang", "post/lang-2.en.md", []string{`Site Lang: en|Name: Lang 2|i18n: Hugo Rocks!`}}, + {"Lang nn file", "lang", "content/post/lang-3.nn.md", []string{`Site Lang: nn|Name: Lang 3|i18n: Hugo Rokkar!`}}, + {"Lang nn dir", "lang", "content_nn/post/lang-4.md", []string{`Site Lang: nn|Name: Lang 4|i18n: Hugo Rokkar!`}}, + {"Lang en in nn dir", "lang", "content_nn/post/lang-5.en.md", []string{`Site Lang: en|Name: Lang 5|i18n: Hugo Rocks!`}}, + {"Lang en default", "lang", "post/my-bundle/index.md", []string{`Site Lang: en|Name: My Bundle|i18n: Hugo Rocks!`}}, + {"Lang en file", "lang", "post/my-bundle/index.en.md", []string{`Site Lang: en|Name: My Bundle|i18n: Hugo Rocks!`}}, + {"Lang nn bundle", "lang", "content/post/my-bundle/index.nn.md", []string{`Site Lang: nn|Name: My Bundle|i18n: Hugo Rokkar!`}}, + {"Site", "site", "content/mypage/index.md", []string{"RegularPages .Site: 10", "RegularPages site: 10"}}, + {"Shortcodes", "shortcodes", "shortcodes/go.md", []string{ `title = "GO"`, "{{< myshortcode >}}", "{{% myshortcode %}}", - "{{}}\n{{%/* comment */%}}"}}, // shortcodes + "{{}}\n{{%/* comment */%}}", + }}, // shortcodes } + c := qt.New(t) + for i, cas := range cases { cas := cas - t.Run(fmt.Sprintf("%s-%d", cas.kind, i), func(t *testing.T) { - t.Parallel() - c := qt.New(t) + + c.Run(cas.name, func(c *qt.C) { + c.Parallel() + mm := afero.NewMemMapFs() c.Assert(initFs(mm), qt.IsNil) cfg, fs := newTestCfg(c, mm) - h, err := hugolib.NewHugoSites(deps.DepsCfg{Cfg: cfg, Fs: fs}) + conf := testconfig.GetTestConfigs(fs.Source, cfg) + h, err := hugolib.NewHugoSites(deps.DepsCfg{Configs: conf, Fs: fs}) c.Assert(err, qt.IsNil) + err = create.NewContent(h, cas.kind, cas.path, false) - c.Assert(create.NewContent(h, cas.kind, cas.path), qt.IsNil) + if b, ok := cas.expected.(bool); ok && !b { + if !b { + c.Assert(err, qt.Not(qt.IsNil)) + } + return + } + + c.Assert(err, qt.IsNil) fname := filepath.FromSlash(cas.path) if !strings.HasPrefix(fname, "content") { fname = filepath.Join("content", fname) } - content := readFileFromFs(t, fs.Source, fname) - for _, v := range cas.expected { + + content := readFileFromFs(c, fs.Source, fname) + + for _, v := range cas.expected.([]string) { found := strings.Contains(content, v) if !found { - t.Fatalf("[%d] %q missing from output:\n%q", i, v, content) + c.Fatalf("[%d] %q missing from output:\n%q", i, v, content) } } }) @@ -91,60 +114,50 @@ func TestNewContent(t *testing.T) { } } -func TestNewContentFromDir(t *testing.T) { +func TestNewContentFromDirSiteFunction(t *testing.T) { mm := afero.NewMemMapFs() c := qt.New(t) archetypeDir := filepath.Join("archetypes", "my-bundle") - c.Assert(mm.MkdirAll(archetypeDir, 0755), qt.IsNil) - - archetypeThemeDir := filepath.Join("themes", "mytheme", "archetypes", "my-theme-bundle") - c.Assert(mm.MkdirAll(archetypeThemeDir, 0755), qt.IsNil) + defaultArchetypeDir := filepath.Join("archetypes", "default") + c.Assert(mm.MkdirAll(archetypeDir, 0o755), qt.IsNil) + c.Assert(mm.MkdirAll(defaultArchetypeDir, 0o755), qt.IsNil) contentFile := ` File: %s -Site Lang: {{ .Site.Language.Lang }} -Name: {{ replace .Name "-" " " | title }} -i18n: {{ T "hugo" }} +site RegularPages: {{ len site.RegularPages }} + ` - c.Assert(afero.WriteFile(mm, filepath.Join(archetypeDir, "index.md"), []byte(fmt.Sprintf(contentFile, "index.md")), 0755), qt.IsNil) - c.Assert(afero.WriteFile(mm, filepath.Join(archetypeDir, "index.nn.md"), []byte(fmt.Sprintf(contentFile, "index.nn.md")), 0755), qt.IsNil) - - c.Assert(afero.WriteFile(mm, filepath.Join(archetypeDir, "pages", "bio.md"), []byte(fmt.Sprintf(contentFile, "bio.md")), 0755), qt.IsNil) - c.Assert(afero.WriteFile(mm, filepath.Join(archetypeDir, "resources", "hugo1.json"), []byte(`hugo1: {{ printf "no template handling in here" }}`), 0755), qt.IsNil) - c.Assert(afero.WriteFile(mm, filepath.Join(archetypeDir, "resources", "hugo2.xml"), []byte(`hugo2: {{ printf "no template handling in here" }}`), 0755), qt.IsNil) - - c.Assert(afero.WriteFile(mm, filepath.Join(archetypeThemeDir, "index.md"), []byte(fmt.Sprintf(contentFile, "index.md")), 0755), qt.IsNil) - c.Assert(afero.WriteFile(mm, filepath.Join(archetypeThemeDir, "resources", "hugo1.json"), []byte(`hugo1: {{ printf "no template handling in here" }}`), 0755), qt.IsNil) + c.Assert(afero.WriteFile(mm, filepath.Join(archetypeDir, "index.md"), fmt.Appendf(nil, contentFile, "index.md"), 0o755), qt.IsNil) + c.Assert(afero.WriteFile(mm, filepath.Join(defaultArchetypeDir, "index.md"), []byte("default archetype index.md"), 0o755), qt.IsNil) c.Assert(initFs(mm), qt.IsNil) cfg, fs := newTestCfg(c, mm) - h, err := hugolib.NewHugoSites(deps.DepsCfg{Cfg: cfg, Fs: fs}) + conf := testconfig.GetTestConfigs(fs.Source, cfg) + h, err := hugolib.NewHugoSites(deps.DepsCfg{Configs: conf, Fs: fs}) c.Assert(err, qt.IsNil) c.Assert(len(h.Sites), qt.Equals, 2) - c.Assert(create.NewContent(h, "my-bundle", "post/my-post"), qt.IsNil) + c.Assert(create.NewContent(h, "my-bundle", "post/my-post", false), qt.IsNil) + cContains(c, readFileFromFs(t, fs.Source, filepath.Join("content", "post/my-post/index.md")), `site RegularPages: 10`) - cContains(c, readFileFromFs(t, fs.Source, filepath.Join("content", "post/my-post/resources/hugo1.json")), `hugo1: {{ printf "no template handling in here" }}`) - cContains(c, readFileFromFs(t, fs.Source, filepath.Join("content", "post/my-post/resources/hugo2.xml")), `hugo2: {{ printf "no template handling in here" }}`) + // Default bundle archetype + c.Assert(create.NewContent(h, "", "post/my-post2", false), qt.IsNil) + cContains(c, readFileFromFs(t, fs.Source, filepath.Join("content", "post/my-post2/index.md")), `default archetype index.md`) - // Content files should get the correct site context. - // TODO(bep) archetype check i18n - cContains(c, readFileFromFs(t, fs.Source, filepath.Join("content", "post/my-post/index.md")), `File: index.md`, `Site Lang: en`, `Name: My Post`, `i18n: Hugo Rocks!`) - cContains(c, readFileFromFs(t, fs.Source, filepath.Join("content", "post/my-post/index.nn.md")), `File: index.nn.md`, `Site Lang: nn`, `Name: My Post`, `i18n: Hugo Rokkar!`) - - cContains(c, readFileFromFs(t, fs.Source, filepath.Join("content", "post/my-post/pages/bio.md")), `File: bio.md`, `Site Lang: en`, `Name: My Post`) - - c.Assert(create.NewContent(h, "my-theme-bundle", "post/my-theme-post"), qt.IsNil) - cContains(c, readFileFromFs(t, fs.Source, filepath.Join("content", "post/my-theme-post/index.md")), `File: index.md`, `Site Lang: en`, `Name: My Theme Post`, `i18n: Hugo Rocks!`) - cContains(c, readFileFromFs(t, fs.Source, filepath.Join("content", "post/my-theme-post/resources/hugo1.json")), `hugo1: {{ printf "no template handling in here" }}`) + // Regular file with bundle kind. + c.Assert(create.NewContent(h, "my-bundle", "post/foo.md", false), qt.IsNil) + cContains(c, readFileFromFs(t, fs.Source, filepath.Join("content", "post/foo.md")), `draft: true`) + // Regular files should fall back to the default archetype (we have no regular file archetype). + c.Assert(create.NewContent(h, "my-bundle", "mypage.md", false), qt.IsNil) + cContains(c, readFileFromFs(t, fs.Source, filepath.Join("content", "mypage.md")), `draft: true`) } func initFs(fs afero.Fs) error { - perm := os.FileMode(0755) + perm := os.FileMode(0o755) var err error // create directories @@ -160,7 +173,16 @@ func initFs(fs afero.Fs) error { } } - // create files + // create some dummy content + for i := 1; i <= 10; i++ { + filename := filepath.Join("content", fmt.Sprintf("page%d.md", i)) + afero.WriteFile(fs, filename, []byte(`--- +title: Test +--- +`), 0o666) + } + + // create archetype files for _, v := range []struct { path string content string @@ -173,11 +195,40 @@ func initFs(fs afero.Fs) error { path: filepath.Join("archetypes", "post.org"), content: "#+title: {{ .BaseFileName | upper }}", }, + { + path: filepath.Join("archetypes", "name.md"), + content: `+++ +title = '{{ replace .Name "-" " " | title }}' ++++`, + }, { path: filepath.Join("archetypes", "product.md"), content: `+++ title = "{{ .BaseFileName | upper }}" +++`, + }, + { + path: filepath.Join("archetypes", "filenames.md"), + content: `... +title = "{{ .BaseFileName | upper }}" ++++ + + +ContentBaseName: {{ .File.ContentBaseName }} + +`, + }, + { + path: filepath.Join("archetypes", "site.md"), + content: `... +title = "{{ .BaseFileName | upper }}" ++++ + +Len RegularPages .Site: {{ len .Site.RegularPages }} +Len RegularPages site: {{ len site.RegularPages }} + + +`, }, { path: filepath.Join("archetypes", "emptydate.md"), @@ -185,7 +236,7 @@ title = "{{ .BaseFileName | upper }}" }, { path: filepath.Join("archetypes", "lang.md"), - content: `Site Lang: {{ .Site.Language.Lang }}|Name: {{ replace .Name "-" " " | title }}|i18n: {{ T "hugo" }}`, + content: `Site Lang: {{ site.Language.Lang }}|Name: {{ replace .Name "-" " " | title }}|i18n: {{ T "hugo" }}`, }, // #3623x { @@ -221,14 +272,14 @@ Some text. return nil } -func cContains(c *qt.C, v interface{}, matches ...string) { +func cContains(c *qt.C, v any, matches ...string) { for _, m := range matches { c.Assert(v, qt.Contains, m) } } // TODO(bep) extract common testing package with this and some others -func readFileFromFs(t *testing.T, fs afero.Fs, filename string) string { +func readFileFromFs(t testing.TB, fs afero.Fs, filename string) string { t.Helper() filename = filepath.FromSlash(filename) b, err := afero.ReadFile(fs, filename) @@ -247,8 +298,7 @@ func readFileFromFs(t *testing.T, fs afero.Fs, filename string) string { return string(b) } -func newTestCfg(c *qt.C, mm afero.Fs) (*viper.Viper, *hugofs.Fs) { - +func newTestCfg(c *qt.C, mm afero.Fs) (config.Provider, *hugofs.Fs) { cfg := ` theme = "mytheme" @@ -259,27 +309,37 @@ languageName = "English" [languages.nn] weight = 2 languageName = "Nynorsk" -contentDir = "content_nn" +[module] +[[module.mounts]] + source = 'archetypes' + target = 'archetypes' +[[module.mounts]] + source = 'content' + target = 'content' + lang = 'en' +[[module.mounts]] + source = 'content_nn' + target = 'content' + lang = 'nn' ` if mm == nil { mm = afero.NewMemMapFs() } - mm.MkdirAll(filepath.FromSlash("content_nn"), 0777) + mm.MkdirAll(filepath.FromSlash("content_nn"), 0o777) - mm.MkdirAll(filepath.FromSlash("themes/mytheme"), 0777) + mm.MkdirAll(filepath.FromSlash("themes/mytheme"), 0o777) c.Assert(afero.WriteFile(mm, filepath.Join("i18n", "en.toml"), []byte(`[hugo] -other = "Hugo Rocks!"`), 0755), qt.IsNil) +other = "Hugo Rocks!"`), 0o755), qt.IsNil) c.Assert(afero.WriteFile(mm, filepath.Join("i18n", "nn.toml"), []byte(`[hugo] -other = "Hugo Rokkar!"`), 0755), qt.IsNil) +other = "Hugo Rokkar!"`), 0o755), qt.IsNil) - c.Assert(afero.WriteFile(mm, "config.toml", []byte(cfg), 0755), qt.IsNil) + c.Assert(afero.WriteFile(mm, "config.toml", []byte(cfg), 0o755), qt.IsNil) - v, _, err := hugolib.LoadConfig(hugolib.ConfigSourceDescriptor{Fs: mm, Filename: "config.toml"}) + res, err := allconfig.LoadConfig(allconfig.ConfigSourceDescriptor{Fs: mm, Filename: "config.toml"}) c.Assert(err, qt.IsNil) - return v, hugofs.NewFrom(mm, v) - + return res.LoadingInfo.Cfg, hugofs.NewFrom(mm, res.LoadingInfo.BaseConfig) } diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/filesaver.js b/create/skeletons/site/assets/.gitkeep similarity index 100% rename from docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/filesaver.js rename to create/skeletons/site/assets/.gitkeep diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/exclamation.svg b/create/skeletons/site/content/.gitkeep similarity index 100% rename from docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/exclamation.svg rename to create/skeletons/site/content/.gitkeep diff --git a/create/skeletons/site/data/.gitkeep b/create/skeletons/site/data/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/create/skeletons/site/i18n/.gitkeep b/create/skeletons/site/i18n/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/create/skeletons/site/layouts/.gitkeep b/create/skeletons/site/layouts/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/create/skeletons/site/static/.gitkeep b/create/skeletons/site/static/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/create/skeletons/site/themes/.gitkeep b/create/skeletons/site/themes/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/create/skeletons/skeletons.go b/create/skeletons/skeletons.go new file mode 100644 index 000000000..a6241ef92 --- /dev/null +++ b/create/skeletons/skeletons.go @@ -0,0 +1,182 @@ +// Copyright 2024 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 skeletons + +import ( + "bytes" + "embed" + "errors" + "io/fs" + "path/filepath" + "strings" + + "github.com/gohugoio/hugo/helpers" + "github.com/gohugoio/hugo/parser" + "github.com/gohugoio/hugo/parser/metadecoders" + "github.com/spf13/afero" +) + +//go:embed all:site/* +var siteFs embed.FS + +//go:embed all:theme/* +var themeFs embed.FS + +// CreateTheme creates a theme skeleton. +func CreateTheme(createpath string, sourceFs afero.Fs, format string) error { + if exists, _ := helpers.Exists(createpath, sourceFs); exists { + return errors.New(createpath + " already exists") + } + + format = strings.ToLower(format) + + siteConfig := map[string]any{ + "baseURL": "https://example.org/", + "languageCode": "en-US", + "title": "My New Hugo Site", + "menus": map[string]any{ + "main": []any{ + map[string]any{ + "name": "Home", + "pageRef": "/", + "weight": 10, + }, + map[string]any{ + "name": "Posts", + "pageRef": "/posts", + "weight": 20, + }, + map[string]any{ + "name": "Tags", + "pageRef": "/tags", + "weight": 30, + }, + }, + }, + "module": map[string]any{ + "hugoVersion": map[string]any{ + "extended": false, + "min": "0.146.0", + }, + }, + } + + err := createSiteConfig(sourceFs, createpath, siteConfig, format) + if err != nil { + return err + } + + defaultArchetype := map[string]any{ + "title": "{{ replace .File.ContentBaseName \"-\" \" \" | title }}", + "date": "{{ .Date }}", + "draft": true, + } + + err = createDefaultArchetype(sourceFs, createpath, defaultArchetype, format) + if err != nil { + return err + } + + return copyFiles(createpath, sourceFs, themeFs) +} + +// CreateSite creates a site skeleton. +func CreateSite(createpath string, sourceFs afero.Fs, force bool, format string) error { + format = strings.ToLower(format) + if exists, _ := helpers.Exists(createpath, sourceFs); exists { + if isDir, _ := helpers.IsDir(createpath, sourceFs); !isDir { + return errors.New(createpath + " already exists but not a directory") + } + + isEmpty, _ := helpers.IsEmpty(createpath, sourceFs) + + switch { + case !isEmpty && !force: + return errors.New(createpath + " already exists and is not empty. See --force.") + case !isEmpty && force: + var all []string + fs.WalkDir(siteFs, ".", func(path string, d fs.DirEntry, err error) error { + if d.IsDir() && path != "." { + all = append(all, path) + } + return nil + }) + all = append(all, filepath.Join(createpath, "hugo."+format)) + for _, path := range all { + if exists, _ := helpers.Exists(path, sourceFs); exists { + return errors.New(path + " already exists") + } + } + } + } + + siteConfig := map[string]any{ + "baseURL": "https://example.org/", + "title": "My New Hugo Site", + "languageCode": "en-us", + } + + err := createSiteConfig(sourceFs, createpath, siteConfig, format) + if err != nil { + return err + } + + defaultArchetype := map[string]any{ + "title": "{{ replace .File.ContentBaseName \"-\" \" \" | title }}", + "date": "{{ .Date }}", + "draft": true, + } + + err = createDefaultArchetype(sourceFs, createpath, defaultArchetype, format) + if err != nil { + return err + } + + return copyFiles(createpath, sourceFs, siteFs) +} + +func copyFiles(createpath string, sourceFs afero.Fs, skeleton embed.FS) error { + return fs.WalkDir(skeleton, ".", func(path string, d fs.DirEntry, err error) error { + _, slug, _ := strings.Cut(path, "/") + if d.IsDir() { + return sourceFs.MkdirAll(filepath.Join(createpath, slug), 0o777) + } else { + if filepath.Base(path) != ".gitkeep" { + data, _ := fs.ReadFile(skeleton, path) + return helpers.WriteToDisk(filepath.Join(createpath, slug), bytes.NewReader(data), sourceFs) + } + return nil + } + }) +} + +func createSiteConfig(fs afero.Fs, createpath string, in map[string]any, format string) (err error) { + var buf bytes.Buffer + err = parser.InterfaceToConfig(in, metadecoders.FormatFromString(format), &buf) + if err != nil { + return err + } + + return helpers.WriteToDisk(filepath.Join(createpath, "hugo."+format), &buf, fs) +} + +func createDefaultArchetype(fs afero.Fs, createpath string, in map[string]any, format string) (err error) { + var buf bytes.Buffer + err = parser.InterfaceToFrontMatter(in, metadecoders.FormatFromString(format), &buf) + if err != nil { + return err + } + + return helpers.WriteToDisk(filepath.Join(createpath, "archetypes", "default.md"), &buf, fs) +} diff --git a/create/skeletons/theme/assets/css/main.css b/create/skeletons/theme/assets/css/main.css new file mode 100644 index 000000000..166ade924 --- /dev/null +++ b/create/skeletons/theme/assets/css/main.css @@ -0,0 +1,22 @@ +body { + color: #222; + font-family: sans-serif; + line-height: 1.5; + margin: 1rem; + max-width: 768px; +} + +header { + border-bottom: 1px solid #222; + margin-bottom: 1rem; +} + +footer { + border-top: 1px solid #222; + margin-top: 1rem; +} + +a { + color: #00e; + text-decoration: none; +} diff --git a/create/skeletons/theme/assets/js/main.js b/create/skeletons/theme/assets/js/main.js new file mode 100644 index 000000000..e2aac5275 --- /dev/null +++ b/create/skeletons/theme/assets/js/main.js @@ -0,0 +1 @@ +console.log('This site was generated by Hugo.'); diff --git a/create/skeletons/theme/content/_index.md b/create/skeletons/theme/content/_index.md new file mode 100644 index 000000000..652623b57 --- /dev/null +++ b/create/skeletons/theme/content/_index.md @@ -0,0 +1,9 @@ ++++ +title = 'Home' +date = 2023-01-01T08:00:00-07:00 +draft = false ++++ + +Laborum voluptate pariatur ex culpa magna nostrud est incididunt fugiat +pariatur do dolor ipsum enim. Consequat tempor do dolor eu. Non id id anim anim +excepteur excepteur pariatur nostrud qui irure ullamco. diff --git a/create/skeletons/theme/content/posts/_index.md b/create/skeletons/theme/content/posts/_index.md new file mode 100644 index 000000000..e7066c092 --- /dev/null +++ b/create/skeletons/theme/content/posts/_index.md @@ -0,0 +1,7 @@ ++++ +title = 'Posts' +date = 2023-01-01T08:30:00-07:00 +draft = false ++++ + +Tempor est exercitation ad qui pariatur quis adipisicing aliquip nisi ea consequat ipsum occaecat. Nostrud consequat ullamco laboris fugiat esse esse adipisicing velit laborum ipsum incididunt ut enim. Dolor pariatur nulla quis fugiat dolore excepteur. Aliquip ad quis aliqua enim do consequat. diff --git a/create/skeletons/theme/content/posts/post-1.md b/create/skeletons/theme/content/posts/post-1.md new file mode 100644 index 000000000..3e3fc6b25 --- /dev/null +++ b/create/skeletons/theme/content/posts/post-1.md @@ -0,0 +1,10 @@ ++++ +title = 'Post 1' +date = 2023-01-15T09:00:00-07:00 +draft = false +tags = ['red'] ++++ + +Tempor proident minim aliquip reprehenderit dolor et ad anim Lorem duis sint eiusmod. Labore ut ea duis dolor. Incididunt consectetur proident qui occaecat incididunt do nisi Lorem. Tempor do laborum elit laboris excepteur eiusmod do. Eiusmod nisi excepteur ut amet pariatur adipisicing Lorem. + +Occaecat nulla excepteur dolore excepteur duis eiusmod ullamco officia anim in voluptate ea occaecat officia. Cillum sint esse velit ea officia minim fugiat. Elit ea esse id aliquip pariatur cupidatat id duis minim incididunt ea ea. Anim ut duis sunt nisi. Culpa cillum sit voluptate voluptate eiusmod dolor. Enim nisi Lorem ipsum irure est excepteur voluptate eu in enim nisi. Nostrud ipsum Lorem anim sint labore consequat do. diff --git a/create/skeletons/theme/content/posts/post-2.md b/create/skeletons/theme/content/posts/post-2.md new file mode 100644 index 000000000..22b828769 --- /dev/null +++ b/create/skeletons/theme/content/posts/post-2.md @@ -0,0 +1,10 @@ ++++ +title = 'Post 2' +date = 2023-02-15T10:00:00-07:00 +draft = false +tags = ['red','green'] ++++ + +Anim eiusmod irure incididunt sint cupidatat. Incididunt irure irure irure nisi ipsum do ut quis fugiat consectetur proident cupidatat incididunt cillum. Dolore voluptate occaecat qui mollit laborum ullamco et. Ipsum laboris officia anim laboris culpa eiusmod ex magna ex cupidatat anim ipsum aute. Mollit aliquip occaecat qui sunt velit ut cupidatat reprehenderit enim sunt laborum. Velit veniam in officia nulla adipisicing ut duis officia. + +Exercitation voluptate irure in irure tempor mollit Lorem nostrud ad officia. Velit id fugiat occaecat do tempor. Sit officia Lorem aliquip eu deserunt consectetur. Aute proident deserunt in nulla aliquip dolore ipsum Lorem ut cupidatat consectetur sit sint laborum. Esse cupidatat sit sint sunt tempor exercitation deserunt. Labore dolor duis laborum est do nisi ut veniam dolor et nostrud nostrud. diff --git a/create/skeletons/theme/content/posts/post-3/bryce-canyon.jpg b/create/skeletons/theme/content/posts/post-3/bryce-canyon.jpg new file mode 100644 index 000000000..9a923bea0 Binary files /dev/null and b/create/skeletons/theme/content/posts/post-3/bryce-canyon.jpg differ diff --git a/create/skeletons/theme/content/posts/post-3/index.md b/create/skeletons/theme/content/posts/post-3/index.md new file mode 100644 index 000000000..ca42a664b --- /dev/null +++ b/create/skeletons/theme/content/posts/post-3/index.md @@ -0,0 +1,12 @@ ++++ +title = 'Post 3' +date = 2023-03-15T11:00:00-07:00 +draft = false +tags = ['red','green','blue'] ++++ + +Occaecat aliqua consequat laborum ut ex aute aliqua culpa quis irure esse magna dolore quis. Proident fugiat labore eu laboris officia Lorem enim. Ipsum occaecat cillum ut tempor id sint aliqua incididunt nisi incididunt reprehenderit. Voluptate ad minim sint est aute aliquip esse occaecat tempor officia qui sunt. Aute ex ipsum id ut in est velit est laborum incididunt. Aliqua qui id do esse sunt eiusmod id deserunt eu nostrud aute sit ipsum. Deserunt esse cillum Lorem non magna adipisicing mollit amet consequat. + +![Bryce Canyon National Park](bryce-canyon.jpg) + +Sit excepteur do velit veniam mollit in nostrud laboris incididunt ea. Amet eu cillum ut reprehenderit culpa aliquip labore laborum amet sit sit duis. Laborum id proident nostrud dolore laborum reprehenderit quis mollit nulla amet veniam officia id id. Aliquip in deserunt qui magna duis qui pariatur officia sunt deserunt. diff --git a/create/skeletons/theme/data/.gitkeep b/create/skeletons/theme/data/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/create/skeletons/theme/i18n/.gitkeep b/create/skeletons/theme/i18n/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/create/skeletons/theme/layouts/_partials/footer.html b/create/skeletons/theme/layouts/_partials/footer.html new file mode 100644 index 000000000..a7cd916d0 --- /dev/null +++ b/create/skeletons/theme/layouts/_partials/footer.html @@ -0,0 +1 @@ +

Copyright {{ now.Year }}. All rights reserved.

diff --git a/create/skeletons/theme/layouts/_partials/head.html b/create/skeletons/theme/layouts/_partials/head.html new file mode 100644 index 000000000..02c224018 --- /dev/null +++ b/create/skeletons/theme/layouts/_partials/head.html @@ -0,0 +1,5 @@ + + +{{ if .IsHome }}{{ site.Title }}{{ else }}{{ printf "%s | %s" .Title site.Title }}{{ end }} +{{ partialCached "head/css.html" . }} +{{ partialCached "head/js.html" . }} diff --git a/create/skeletons/theme/layouts/_partials/head/css.html b/create/skeletons/theme/layouts/_partials/head/css.html new file mode 100644 index 000000000..d76d23a16 --- /dev/null +++ b/create/skeletons/theme/layouts/_partials/head/css.html @@ -0,0 +1,9 @@ +{{- with resources.Get "css/main.css" }} + {{- if hugo.IsDevelopment }} + + {{- else }} + {{- with . | minify | fingerprint }} + + {{- end }} + {{- end }} +{{- end }} diff --git a/create/skeletons/theme/layouts/_partials/head/js.html b/create/skeletons/theme/layouts/_partials/head/js.html new file mode 100644 index 000000000..16ffbedfe --- /dev/null +++ b/create/skeletons/theme/layouts/_partials/head/js.html @@ -0,0 +1,16 @@ +{{- with resources.Get "js/main.js" }} + {{- $opts := dict + "minify" (not hugo.IsDevelopment) + "sourceMap" (cond hugo.IsDevelopment "external" "") + "targetPath" "js/main.js" + }} + {{- with . | js.Build $opts }} + {{- if hugo.IsDevelopment }} + + {{- else }} + {{- with . | fingerprint }} + + {{- end }} + {{- end }} + {{- end }} +{{- end }} diff --git a/create/skeletons/theme/layouts/_partials/header.html b/create/skeletons/theme/layouts/_partials/header.html new file mode 100644 index 000000000..7980a00e1 --- /dev/null +++ b/create/skeletons/theme/layouts/_partials/header.html @@ -0,0 +1,2 @@ +

{{ site.Title }}

+{{ partial "menu.html" (dict "menuID" "main" "page" .) }} diff --git a/create/skeletons/theme/layouts/_partials/menu.html b/create/skeletons/theme/layouts/_partials/menu.html new file mode 100644 index 000000000..14245b55d --- /dev/null +++ b/create/skeletons/theme/layouts/_partials/menu.html @@ -0,0 +1,51 @@ +{{- /* +Renders a menu for the given menu ID. + +@context {page} page The current page. +@context {string} menuID The menu ID. + +@example: {{ partial "menu.html" (dict "menuID" "main" "page" .) }} +*/}} + +{{- $page := .page }} +{{- $menuID := .menuID }} + +{{- with index site.Menus $menuID }} + +{{- end }} + +{{- define "_partials/inline/menu/walk.html" }} + {{- $page := .page }} + {{- range .menuEntries }} + {{- $attrs := dict "href" .URL }} + {{- if $page.IsMenuCurrent .Menu . }} + {{- $attrs = merge $attrs (dict "class" "active" "aria-current" "page") }} + {{- else if $page.HasMenuCurrent .Menu .}} + {{- $attrs = merge $attrs (dict "class" "ancestor" "aria-current" "true") }} + {{- end }} + {{- $name := .Name }} + {{- with .Identifier }} + {{- with T . }} + {{- $name = . }} + {{- end }} + {{- end }} +
  • + {{ $name }} + {{- with .Children }} +
      + {{- partial "inline/menu/walk.html" (dict "page" $page "menuEntries" .) }} +
    + {{- end }} +
  • + {{- end }} +{{- end }} diff --git a/create/skeletons/theme/layouts/_partials/terms.html b/create/skeletons/theme/layouts/_partials/terms.html new file mode 100644 index 000000000..8a6ebec2a --- /dev/null +++ b/create/skeletons/theme/layouts/_partials/terms.html @@ -0,0 +1,23 @@ +{{- /* +For a given taxonomy, renders a list of terms assigned to the page. + +@context {page} page The current page. +@context {string} taxonomy The taxonomy. + +@example: {{ partial "terms.html" (dict "taxonomy" "tags" "page" .) }} +*/}} + +{{- $page := .page }} +{{- $taxonomy := .taxonomy }} + +{{- with $page.GetTerms $taxonomy }} + {{- $label := (index . 0).Parent.LinkTitle }} +
    +
    {{ $label }}:
    + +
    +{{- end }} diff --git a/create/skeletons/theme/layouts/baseof.html b/create/skeletons/theme/layouts/baseof.html new file mode 100644 index 000000000..39dcbec61 --- /dev/null +++ b/create/skeletons/theme/layouts/baseof.html @@ -0,0 +1,17 @@ + + + + {{ partial "head.html" . }} + + +
    + {{ partial "header.html" . }} +
    +
    + {{ block "main" . }}{{ end }} +
    +
    + {{ partial "footer.html" . }} +
    + + diff --git a/create/skeletons/theme/layouts/home.html b/create/skeletons/theme/layouts/home.html new file mode 100644 index 000000000..0df659742 --- /dev/null +++ b/create/skeletons/theme/layouts/home.html @@ -0,0 +1,7 @@ +{{ define "main" }} + {{ .Content }} + {{ range site.RegularPages }} +

    {{ .LinkTitle }}

    + {{ .Summary }} + {{ end }} +{{ end }} diff --git a/create/skeletons/theme/layouts/page.html b/create/skeletons/theme/layouts/page.html new file mode 100644 index 000000000..7e286c802 --- /dev/null +++ b/create/skeletons/theme/layouts/page.html @@ -0,0 +1,10 @@ +{{ define "main" }} +

    {{ .Title }}

    + + {{ $dateMachine := .Date | time.Format "2006-01-02T15:04:05-07:00" }} + {{ $dateHuman := .Date | time.Format ":date_long" }} + + + {{ .Content }} + {{ partial "terms.html" (dict "taxonomy" "tags" "page" .) }} +{{ end }} diff --git a/create/skeletons/theme/layouts/section.html b/create/skeletons/theme/layouts/section.html new file mode 100644 index 000000000..50fc92d40 --- /dev/null +++ b/create/skeletons/theme/layouts/section.html @@ -0,0 +1,8 @@ +{{ define "main" }} +

    {{ .Title }}

    + {{ .Content }} + {{ range .Pages }} +

    {{ .LinkTitle }}

    + {{ .Summary }} + {{ end }} +{{ end }} diff --git a/create/skeletons/theme/layouts/taxonomy.html b/create/skeletons/theme/layouts/taxonomy.html new file mode 100644 index 000000000..c2e787519 --- /dev/null +++ b/create/skeletons/theme/layouts/taxonomy.html @@ -0,0 +1,7 @@ +{{ define "main" }} +

    {{ .Title }}

    + {{ .Content }} + {{ range .Pages }} +

    {{ .LinkTitle }}

    + {{ end }} +{{ end }} diff --git a/create/skeletons/theme/layouts/term.html b/create/skeletons/theme/layouts/term.html new file mode 100644 index 000000000..c2e787519 --- /dev/null +++ b/create/skeletons/theme/layouts/term.html @@ -0,0 +1,7 @@ +{{ define "main" }} +

    {{ .Title }}

    + {{ .Content }} + {{ range .Pages }} +

    {{ .LinkTitle }}

    + {{ end }} +{{ end }} diff --git a/create/skeletons/theme/static/favicon.ico b/create/skeletons/theme/static/favicon.ico new file mode 100644 index 000000000..67f8b7778 Binary files /dev/null and b/create/skeletons/theme/static/favicon.ico differ diff --git a/deploy/cloudfront.go b/deploy/cloudfront.go index dbdf9baf4..3202a73ea 100644 --- a/deploy/cloudfront.go +++ b/deploy/cloudfront.go @@ -11,41 +11,62 @@ // See the License for the specific language governing permissions and // limitations under the License. +//go:build withdeploy + package deploy import ( "context" + "net/url" "time" - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/cloudfront" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/cloudfront" + "github.com/aws/aws-sdk-go-v2/service/cloudfront/types" + "github.com/gohugoio/hugo/deploy/deployconfig" + gcaws "gocloud.dev/aws" ) +// V2ConfigFromURLParams will fail for any unknown params, so we need to remove them. +// This is a mysterious API, but inspecting the code the known params are: +var v2ConfigValidParams = map[string]bool{ + "endpoint": true, + "region": true, + "profile": true, + "awssdk": true, +} + // InvalidateCloudFront invalidates the CloudFront cache for distributionID. -// It uses the default AWS credentials from the environment. -func InvalidateCloudFront(ctx context.Context, distributionID string) error { - // SharedConfigEnable enables loading "shared config (~/.aws/config) and - // shared credentials (~/.aws/credentials) files". - // See https://docs.aws.amazon.com/sdk-for-go/api/aws/session/ for more - // details. - // This is the same codepath used by Go CDK when creating an s3 URL. - // TODO: Update this to a Go CDK helper once available - // (https://github.com/google/go-cloud/issues/2003). - sess, err := session.NewSessionWithOptions(session.Options{SharedConfigState: session.SharedConfigEnable}) +// Uses AWS credentials config from the bucket URL. +func InvalidateCloudFront(ctx context.Context, target *deployconfig.Target) error { + u, err := url.Parse(target.URL) if err != nil { return err } + vals := u.Query() + + // Remove any unknown params. + for k := range vals { + if !v2ConfigValidParams[k] { + vals.Del(k) + } + } + + cfg, err := gcaws.V2ConfigFromURLParams(ctx, vals) + if err != nil { + return err + } + cf := cloudfront.NewFromConfig(cfg) req := &cloudfront.CreateInvalidationInput{ - DistributionId: aws.String(distributionID), - InvalidationBatch: &cloudfront.InvalidationBatch{ + DistributionId: aws.String(target.CloudFrontDistributionID), + InvalidationBatch: &types.InvalidationBatch{ CallerReference: aws.String(time.Now().Format("20060102150405")), - Paths: &cloudfront.Paths{ - Items: []*string{aws.String("/*")}, - Quantity: aws.Int64(1), + Paths: &types.Paths{ + Items: []string{"/*"}, + Quantity: aws.Int32(1), }, }, } - _, err = cloudfront.New(sess).CreateInvalidationWithContext(ctx, req) + _, err = cf.CreateInvalidation(ctx, req) return err } diff --git a/deploy/deploy.go b/deploy/deploy.go index 1d911f29b..57e1f41a2 100644 --- a/deploy/deploy.go +++ b/deploy/deploy.go @@ -11,6 +11,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//go:build withdeploy + package deploy import ( @@ -18,9 +20,10 @@ import ( "compress/gzip" "context" "crypto/md5" + "encoding/hex" + "errors" "fmt" "io" - "io/ioutil" "mime" "os" "path/filepath" @@ -31,16 +34,20 @@ import ( "sync" "github.com/dustin/go-humanize" + "github.com/gobwas/glob" + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/common/para" "github.com/gohugoio/hugo/config" - "github.com/pkg/errors" + "github.com/gohugoio/hugo/deploy/deployconfig" + "github.com/gohugoio/hugo/media" "github.com/spf13/afero" - jww "github.com/spf13/jwalterweatherman" "golang.org/x/text/unicode/norm" "gocloud.dev/blob" _ "gocloud.dev/blob/fileblob" // import _ "gocloud.dev/blob/gcsblob" // import _ "gocloud.dev/blob/s3blob" // import + "gocloud.dev/gcerrors" ) // Deployer supports deploying the site to target cloud providers. @@ -48,15 +55,13 @@ type Deployer struct { localFs afero.Fs bucket *blob.Bucket - target *target // the target to deploy to - matchers []*matcher // matchers to apply to uploaded files - ordering []*regexp.Regexp // orders uploads - quiet bool // true reduces STDOUT - confirm bool // true enables confirmation before making changes - dryRun bool // true skips conformations and prints changes instead of applying them - force bool // true forces upload of all files - invalidateCDN bool // true enables invalidate CDN cache (if possible) - maxDeletes int // caps the # of files to delete; -1 to disable + mediaTypes media.Types // Hugo's MediaType to guess ContentType + quiet bool // true reduces STDOUT // TODO(bep) remove, this is a global feature. + + cfg deployconfig.DeployConfig + logger loggers.Logger + + target *deployconfig.Target // the target to deploy to // For tests... summary deploySummary // summary of latest Deploy results @@ -66,22 +71,20 @@ type deploySummary struct { NumLocal, NumRemote, NumUploads, NumDeletes int } -// New constructs a new *Deployer. -func New(cfg config.Provider, localFs afero.Fs) (*Deployer, error) { - targetName := cfg.GetString("target") +const metaMD5Hash = "md5chksum" // the meta key to store md5hash in - // Load the [deployment] section of the config. - dcfg, err := decodeConfig(cfg) - if err != nil { - return nil, err - } +// New constructs a new *Deployer. +func New(cfg config.AllProvider, logger loggers.Logger, localFs afero.Fs) (*Deployer, error) { + dcfg := cfg.GetConfigSection(deployconfig.DeploymentConfigKey).(deployconfig.DeployConfig) + targetName := dcfg.Target if len(dcfg.Targets) == 0 { return nil, errors.New("no deployment targets found") } + mediaTypes := cfg.GetConfigSection("mediaTypes").(media.Types) // Find the target to deploy to. - var tgt *target + var tgt *deployconfig.Target if targetName == "" { // Default to the first target. tgt = dcfg.Targets[0] @@ -95,17 +98,13 @@ func New(cfg config.Provider, localFs afero.Fs) (*Deployer, error) { return nil, fmt.Errorf("deployment target %q not found", targetName) } } + return &Deployer{ - localFs: localFs, - target: tgt, - matchers: dcfg.Matchers, - ordering: dcfg.ordering, - quiet: cfg.GetBool("quiet"), - confirm: cfg.GetBool("confirm"), - dryRun: cfg.GetBool("dryRun"), - force: cfg.GetBool("force"), - invalidateCDN: cfg.GetBool("invalidateCDN"), - maxDeletes: cfg.GetInt("maxDeletes"), + localFs: localFs, + target: tgt, + quiet: cfg.BuildExpired(), + mediaTypes: mediaTypes, + cfg: dcfg, }, nil } @@ -113,49 +112,65 @@ func (d *Deployer) openBucket(ctx context.Context) (*blob.Bucket, error) { if d.bucket != nil { return d.bucket, nil } - jww.FEEDBACK.Printf("Deploying to target %q (%s)\n", d.target.Name, d.target.URL) + d.logger.Printf("Deploying to target %q (%s)\n", d.target.Name, d.target.URL) return blob.OpenBucket(ctx, d.target.URL) } // Deploy deploys the site to a target. func (d *Deployer) Deploy(ctx context.Context) error { + if d.logger == nil { + d.logger = loggers.NewDefault() + } + bucket, err := d.openBucket(ctx) if err != nil { return err } + if d.cfg.Workers <= 0 { + d.cfg.Workers = 10 + } + // Load local files from the source directory. - local, err := walkLocal(d.localFs, d.matchers) + var include, exclude glob.Glob + var mappath func(string) string + if d.target != nil { + include, exclude = d.target.IncludeGlob, d.target.ExcludeGlob + if d.target.StripIndexHTML { + mappath = stripIndexHTML + } + } + local, err := d.walkLocal(d.localFs, d.cfg.Matchers, include, exclude, d.mediaTypes, mappath) if err != nil { return err } - jww.INFO.Printf("Found %d local files.\n", len(local)) + d.logger.Infof("Found %d local files.\n", len(local)) d.summary.NumLocal = len(local) // Load remote files from the target. - remote, err := walkRemote(ctx, bucket) + remote, err := d.walkRemote(ctx, bucket, include, exclude) if err != nil { return err } - jww.INFO.Printf("Found %d remote files.\n", len(remote)) + d.logger.Infof("Found %d remote files.\n", len(remote)) d.summary.NumRemote = len(remote) // Diff local vs remote to see what changes need to be applied. - uploads, deletes := findDiffs(local, remote, d.force) + uploads, deletes := d.findDiffs(local, remote, d.cfg.Force) d.summary.NumUploads = len(uploads) d.summary.NumDeletes = len(deletes) if len(uploads)+len(deletes) == 0 { if !d.quiet { - jww.FEEDBACK.Println("No changes required.") + d.logger.Println("No changes required.") } return nil } if !d.quiet { - jww.FEEDBACK.Println(summarizeChanges(uploads, deletes)) + d.logger.Println(summarizeChanges(uploads, deletes)) } // Ask for confirmation before proceeding. - if d.confirm && !d.dryRun { + if d.cfg.Confirm && !d.cfg.DryRun { fmt.Printf("Continue? (Y/n) ") var confirm string if _, err := fmt.Scanln(&confirm); err != nil { @@ -168,12 +183,9 @@ func (d *Deployer) Deploy(ctx context.Context) error { // Order the uploads. They are organized in groups; all uploads in a group // must be complete before moving on to the next group. - uploadGroups := applyOrdering(d.ordering, uploads) + uploadGroups := applyOrdering(d.cfg.Ordering, uploads) - // Apply the changes in parallel, using an inverted worker - // pool (https://www.youtube.com/watch?v=5zXAHh5tJqQ&t=26m58s). - // sem prevents more than nParallel concurrent goroutines. - const nParallel = 10 + nParallel := d.cfg.Workers var errs []error var errMu sync.Mutex // protects errs @@ -186,16 +198,16 @@ func (d *Deployer) Deploy(ctx context.Context) error { // Within the group, apply uploads in parallel. sem := make(chan struct{}, nParallel) for _, upload := range uploads { - if d.dryRun { + if d.cfg.DryRun { if !d.quiet { - jww.FEEDBACK.Printf("[DRY RUN] Would upload: %v\n", upload) + d.logger.Printf("[DRY RUN] Would upload: %v\n", upload) } continue } sem <- struct{}{} go func(upload *fileToUpload) { - if err := doSingleUpload(ctx, bucket, upload); err != nil { + if err := d.doSingleUpload(ctx, bucket, upload); err != nil { errMu.Lock() defer errMu.Unlock() errs = append(errs, err) @@ -209,27 +221,31 @@ func (d *Deployer) Deploy(ctx context.Context) error { } } - if d.maxDeletes != -1 && len(deletes) > d.maxDeletes { - jww.WARN.Printf("Skipping %d deletes because it is more than --maxDeletes (%d). If this is expected, set --maxDeletes to a larger number, or -1 to disable this check.\n", len(deletes), d.maxDeletes) + if d.cfg.MaxDeletes != -1 && len(deletes) > d.cfg.MaxDeletes { + d.logger.Warnf("Skipping %d deletes because it is more than --maxDeletes (%d). If this is expected, set --maxDeletes to a larger number, or -1 to disable this check.\n", len(deletes), d.cfg.MaxDeletes) d.summary.NumDeletes = 0 } else { // Apply deletes in parallel. sort.Slice(deletes, func(i, j int) bool { return deletes[i] < deletes[j] }) sem := make(chan struct{}, nParallel) for _, del := range deletes { - if d.dryRun { + if d.cfg.DryRun { if !d.quiet { - jww.FEEDBACK.Printf("[DRY RUN] Would delete %s\n", del) + d.logger.Printf("[DRY RUN] Would delete %s\n", del) } continue } sem <- struct{}{} go func(del string) { - jww.INFO.Printf("Deleting %s...\n", del) + d.logger.Infof("Deleting %s...\n", del) if err := bucket.Delete(ctx, del); err != nil { - errMu.Lock() - defer errMu.Unlock() - errs = append(errs, err) + if gcerrors.Code(err) == gcerrors.NotFound { + d.logger.Warnf("Failed to delete %q because it wasn't found: %v", del, err) + } else { + errMu.Lock() + defer errMu.Unlock() + errs = append(errs, err) + } } <-sem }(del) @@ -239,32 +255,45 @@ func (d *Deployer) Deploy(ctx context.Context) error { sem <- struct{}{} } } + if len(errs) > 0 { if !d.quiet { - jww.FEEDBACK.Printf("Encountered %d errors.\n", len(errs)) + d.logger.Printf("Encountered %d errors.\n", len(errs)) } return errs[0] } if !d.quiet { - jww.FEEDBACK.Println("Success!") + d.logger.Println("Success!") } - if d.invalidateCDN { + if d.cfg.InvalidateCDN { if d.target.CloudFrontDistributionID != "" { - jww.FEEDBACK.Println("Invalidating CloudFront CDN...") - if err := InvalidateCloudFront(ctx, d.target.CloudFrontDistributionID); err != nil { - jww.FEEDBACK.Printf("Failed to invalidate CloudFront CDN: %v\n", err) - return err + if d.cfg.DryRun { + if !d.quiet { + d.logger.Printf("[DRY RUN] Would invalidate CloudFront CDN with ID %s\n", d.target.CloudFrontDistributionID) + } + } else { + d.logger.Println("Invalidating CloudFront CDN...") + if err := InvalidateCloudFront(ctx, d.target); err != nil { + d.logger.Printf("Failed to invalidate CloudFront CDN: %v\n", err) + return err + } } } if d.target.GoogleCloudCDNOrigin != "" { - jww.FEEDBACK.Println("Invalidating Google Cloud CDN...") - if err := InvalidateGoogleCloudCDN(ctx, d.target.GoogleCloudCDNOrigin); err != nil { - jww.FEEDBACK.Printf("Failed to invalidate Google Cloud CDN: %v\n", err) - return err + if d.cfg.DryRun { + if !d.quiet { + d.logger.Printf("[DRY RUN] Would invalidate Google Cloud CDN with origin %s\n", d.target.GoogleCloudCDNOrigin) + } + } else { + d.logger.Println("Invalidating Google Cloud CDN...") + if err := InvalidateGoogleCloudCDN(ctx, d.target.GoogleCloudCDNOrigin); err != nil { + d.logger.Printf("Failed to invalidate Google Cloud CDN: %v\n", err) + return err + } } } - jww.FEEDBACK.Println("Success!") + d.logger.Println("Success!") } return nil } @@ -279,12 +308,13 @@ func summarizeChanges(uploads []*fileToUpload, deletes []string) string { } // doSingleUpload executes a single file upload. -func doSingleUpload(ctx context.Context, bucket *blob.Bucket, upload *fileToUpload) error { - jww.INFO.Printf("Uploading %v...\n", upload) +func (d *Deployer) doSingleUpload(ctx context.Context, bucket *blob.Bucket, upload *fileToUpload) error { + d.logger.Infof("Uploading %v...\n", upload) opts := &blob.WriterOptions{ CacheControl: upload.Local.CacheControl(), ContentEncoding: upload.Local.ContentEncoding(), ContentType: upload.Local.ContentType(), + Metadata: map[string]string{metaMD5Hash: hex.EncodeToString(upload.Local.MD5())}, } w, err := bucket.NewWriter(ctx, upload.Local.SlashPath, opts) if err != nil { @@ -317,14 +347,15 @@ type localFile struct { // gzipped before upload. UploadSize int64 - fs afero.Fs - matcher *matcher - md5 []byte // cache - gzipped bytes.Buffer // cached of gzipped contents if gzipping + fs afero.Fs + matcher *deployconfig.Matcher + md5 []byte // cache + gzipped bytes.Buffer // cached of gzipped contents if gzipping + mediaTypes media.Types } // newLocalFile initializes a *localFile. -func newLocalFile(fs afero.Fs, nativePath, slashpath string, m *matcher) (*localFile, error) { +func newLocalFile(fs afero.Fs, nativePath, slashpath string, m *deployconfig.Matcher, mt media.Types) (*localFile, error) { f, err := fs.Open(nativePath) if err != nil { return nil, err @@ -335,6 +366,7 @@ func newLocalFile(fs afero.Fs, nativePath, slashpath string, m *matcher) (*local SlashPath: slashpath, fs: fs, matcher: m, + mediaTypes: mt, } if m != nil && m.Gzip { // We're going to gzip the content. Do it once now, and cache the result @@ -367,7 +399,7 @@ func (lf *localFile) Reader() (io.ReadCloser, error) { // We've got the gzipped contents cached in gzipped. // Note: we can't use lf.gzipped directly as a Reader, since we it discards // data after it is read, and we may read it more than once. - return ioutil.NopCloser(bytes.NewReader(lf.gzipped.Bytes())), nil + return io.NopCloser(bytes.NewReader(lf.gzipped.Bytes())), nil } // Not expected to fail since we did it successfully earlier in newLocalFile, // but could happen due to changes in the underlying filesystem. @@ -405,10 +437,13 @@ func (lf *localFile) ContentType() string { if lf.matcher != nil && lf.matcher.ContentType != "" { return lf.matcher.ContentType } - // TODO: Hugo has a MediaType and a MediaTypes list and also a concept - // of custom MIME types. - // Use 1) The matcher 2) Hugo's MIME types 3) TypeByExtension. - return mime.TypeByExtension(filepath.Ext(lf.NativePath)) + + ext := filepath.Ext(lf.NativePath) + if mimeType, _, found := lf.mediaTypes.GetFirstBySuffix(strings.TrimPrefix(ext, ".")); found { + return mimeType.Type + } + + return mime.TypeByExtension(ext) } // Force returns true if the file should be forced to re-upload based on the @@ -435,10 +470,30 @@ func (lf *localFile) MD5() []byte { return lf.md5 } +// knownHiddenDirectory checks if the specified name is a well known +// hidden directory. +func knownHiddenDirectory(name string) bool { + knownDirectories := []string{ + ".well-known", + } + + for _, dir := range knownDirectories { + if name == dir { + return true + } + } + return false +} + // walkLocal walks the source directory and returns a flat list of files, // using localFile.SlashPath as the map keys. -func walkLocal(fs afero.Fs, matchers []*matcher) (map[string]*localFile, error) { - retval := map[string]*localFile{} +func (d *Deployer) walkLocal(fs afero.Fs, matchers []*deployconfig.Matcher, include, exclude glob.Glob, mediaTypes media.Types, mappath func(string) string) (map[string]*localFile, error) { + retval := make(map[string]*localFile) + var mu sync.Mutex + + workers := para.New(d.cfg.Workers) + g, _ := workers.Start(context.Background()) + err := afero.Walk(fs, "", func(path string, info os.FileInfo, err error) error { if err != nil { return err @@ -446,7 +501,10 @@ func walkLocal(fs afero.Fs, matchers []*matcher) (map[string]*localFile, error) if info.IsDir() { // Skip hidden directories. if path != "" && strings.HasPrefix(info.Name(), ".") { - return filepath.SkipDir + // Except for specific hidden directories + if !knownHiddenDirectory(info.Name()) { + return filepath.SkipDir + } } return nil } @@ -456,35 +514,68 @@ func walkLocal(fs afero.Fs, matchers []*matcher) (map[string]*localFile, error) return nil } - // When a file system is HFS+, its filepath is in NFD form. - if runtime.GOOS == "darwin" { - path = norm.NFC.String(path) - } - - // Find the first matching matcher (if any). - slashpath := filepath.ToSlash(path) - var m *matcher - for _, cur := range matchers { - if cur.Matches(slashpath) { - m = cur - break + // Process each file in a worker + g.Run(func() error { + // When a file system is HFS+, its filepath is in NFD form. + if runtime.GOOS == "darwin" { + path = norm.NFC.String(path) } - } - lf, err := newLocalFile(fs, path, slashpath, m) - if err != nil { - return err - } - retval[lf.SlashPath] = lf + + // Check include/exclude matchers. + slashpath := filepath.ToSlash(path) + if include != nil && !include.Match(slashpath) { + d.logger.Infof(" dropping %q due to include\n", slashpath) + return nil + } + if exclude != nil && exclude.Match(slashpath) { + d.logger.Infof(" dropping %q due to exclude\n", slashpath) + return nil + } + + // Find the first matching matcher (if any). + var m *deployconfig.Matcher + for _, cur := range matchers { + if cur.Matches(slashpath) { + m = cur + break + } + } + // Apply any additional modifications to the local path, to map it to + // the remote path. + if mappath != nil { + slashpath = mappath(slashpath) + } + lf, err := newLocalFile(fs, path, slashpath, m, mediaTypes) + if err != nil { + return err + } + mu.Lock() + retval[lf.SlashPath] = lf + mu.Unlock() + return nil + }) return nil }) if err != nil { return nil, err } + if err := g.Wait(); err != nil { + return nil, err + } return retval, nil } +// stripIndexHTML remaps keys matching "/index.html" to "/". +func stripIndexHTML(slashpath string) string { + const suffix = "/index.html" + if strings.HasSuffix(slashpath, suffix) { + return slashpath[:len(slashpath)-len(suffix)+1] + } + return slashpath +} + // walkRemote walks the target bucket and returns a flat list. -func walkRemote(ctx context.Context, bucket *blob.Bucket) (map[string]*blob.ListObject, error) { +func (d *Deployer) walkRemote(ctx context.Context, bucket *blob.Bucket, include, exclude glob.Glob) (map[string]*blob.ListObject, error) { retval := map[string]*blob.ListObject{} iter := bucket.List(nil) for { @@ -495,7 +586,16 @@ func walkRemote(ctx context.Context, bucket *blob.Bucket) (map[string]*blob.List if err != nil { return nil, err } - // If the remote didn't give us an MD5, compute one. + // Check include/exclude matchers. + if include != nil && !include.Match(obj.Key) { + d.logger.Infof(" remote dropping %q due to include\n", obj.Key) + continue + } + if exclude != nil && exclude.Match(obj.Key) { + d.logger.Infof(" remote dropping %q due to exclude\n", obj.Key) + continue + } + // If the remote didn't give us an MD5, use remote attributes MD5, if that doesn't exist compute one. // This can happen for some providers (e.g., fileblob, which uses the // local filesystem), but not for the most common Cloud providers // (S3, GCS, Azure). Although, it can happen for S3 if the blob was uploaded @@ -503,13 +603,25 @@ func walkRemote(ctx context.Context, bucket *blob.Bucket) (map[string]*blob.List // Although it's unfortunate to have to read the file, it's likely better // than assuming a delta and re-uploading it. if len(obj.MD5) == 0 { - r, err := bucket.NewReader(ctx, obj.Key, nil) + var attrMD5 []byte + attrs, err := bucket.Attributes(ctx, obj.Key) if err == nil { - h := md5.New() - if _, err := io.Copy(h, r); err == nil { - obj.MD5 = h.Sum(nil) + md5String, exists := attrs.Metadata[metaMD5Hash] + if exists { + attrMD5, _ = hex.DecodeString(md5String) } - r.Close() + } + if len(attrMD5) == 0 { + r, err := bucket.NewReader(ctx, obj.Key, nil) + if err == nil { + h := md5.New() + if _, err := io.Copy(h, r); err == nil { + obj.MD5 = h.Sum(nil) + } + r.Close() + } + } else { + obj.MD5 = attrMD5 } } retval[obj.Key] = obj @@ -553,7 +665,7 @@ func (u *fileToUpload) String() string { // findDiffs diffs localFiles vs remoteFiles to see what changes should be // applied to the remote target. It returns a slice of *fileToUpload and a // slice of paths for files to delete. -func findDiffs(localFiles map[string]*localFile, remoteFiles map[string]*blob.ListObject, force bool) ([]*fileToUpload, []string) { +func (d *Deployer) findDiffs(localFiles map[string]*localFile, remoteFiles map[string]*blob.ListObject, force bool) ([]*fileToUpload, []string) { var uploads []*fileToUpload var deletes []string @@ -594,8 +706,6 @@ func findDiffs(localFiles map[string]*localFile, remoteFiles map[string]*blob.Li } else if !bytes.Equal(lf.MD5(), remoteFile.MD5) { upload = true reason = reasonMD5Differs - } else { - // Nope! Leave uploaded = false. } found[path] = true } else { @@ -604,10 +714,10 @@ func findDiffs(localFiles map[string]*localFile, remoteFiles map[string]*blob.Li reason = reasonNotFound } if upload { - jww.DEBUG.Printf("%s needs to be uploaded: %v\n", path, reason) + d.logger.Debugf("%s needs to be uploaded: %v\n", path, reason) uploads = append(uploads, &fileToUpload{lf, reason}) } else { - jww.DEBUG.Printf("%s exists at target and does not need to be uploaded", path) + d.logger.Debugf("%s exists at target and does not need to be uploaded", path) } } @@ -632,7 +742,6 @@ func findDiffs(localFiles map[string]*localFile, remoteFiles map[string]*blob.Li // // The subslices are sorted by Local.SlashPath. func applyOrdering(ordering []*regexp.Regexp, uploads []*fileToUpload) [][]*fileToUpload { - // Sort the whole slice by Local.SlashPath first. sort.Slice(uploads, func(i, j int) bool { return uploads[i].Local.SlashPath < uploads[j].Local.SlashPath }) diff --git a/deploy/deployConfig.go b/deploy/deployConfig.go deleted file mode 100644 index 3bc51294d..000000000 --- a/deploy/deployConfig.go +++ /dev/null @@ -1,105 +0,0 @@ -// Copyright 2019 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 deploy - -import ( - "fmt" - "regexp" - - "github.com/gohugoio/hugo/config" - "github.com/mitchellh/mapstructure" -) - -const deploymentConfigKey = "deployment" - -// deployConfig is the complete configuration for deployment. -type deployConfig struct { - Targets []*target - Matchers []*matcher - Order []string - - ordering []*regexp.Regexp // compiled Order -} - -type target struct { - Name string - URL string - - CloudFrontDistributionID string - - // GoogleCloudCDNOrigin specifies the Google Cloud project and CDN origin to - // invalidate when deploying this target. It is specified as /. - GoogleCloudCDNOrigin string -} - -// matcher represents configuration to be applied to files whose paths match -// a specified pattern. -type matcher struct { - // Pattern is the string pattern to match against paths. - // Matching is done against paths converted to use / as the path separator. - Pattern string - - // CacheControl specifies caching attributes to use when serving the blob. - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control - CacheControl string - - // ContentEncoding specifies the encoding used for the blob's content, if any. - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding - ContentEncoding string - - // ContentType specifies the MIME type of the blob being written. - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type - ContentType string - - // Gzip determines whether the file should be gzipped before upload. - // If so, the ContentEncoding field will automatically be set to "gzip". - Gzip bool - - // Force indicates that matching files should be re-uploaded. Useful when - // other route-determined metadata (e.g., ContentType) has changed. - Force bool - - // re is Pattern compiled. - re *regexp.Regexp -} - -func (m *matcher) Matches(path string) bool { - return m.re.MatchString(path) -} - -// decode creates a config from a given Hugo configuration. -func decodeConfig(cfg config.Provider) (deployConfig, error) { - var dcfg deployConfig - if !cfg.IsSet(deploymentConfigKey) { - return dcfg, nil - } - if err := mapstructure.WeakDecode(cfg.GetStringMap(deploymentConfigKey), &dcfg); err != nil { - return dcfg, err - } - var err error - for _, m := range dcfg.Matchers { - m.re, err = regexp.Compile(m.Pattern) - if err != nil { - return dcfg, fmt.Errorf("invalid deployment.matchers.pattern: %v", err) - } - } - for _, o := range dcfg.Order { - re, err := regexp.Compile(o) - if err != nil { - return dcfg, fmt.Errorf("invalid deployment.orderings.pattern: %v", err) - } - dcfg.ordering = append(dcfg.ordering, re) - } - return dcfg, nil -} diff --git a/deploy/deployConfig_test.go b/deploy/deployConfig_test.go deleted file mode 100644 index f4aaa5eaf..000000000 --- a/deploy/deployConfig_test.go +++ /dev/null @@ -1,156 +0,0 @@ -// Copyright 2019 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 deploy - -import ( - "fmt" - "testing" - - qt "github.com/frankban/quicktest" - "github.com/gohugoio/hugo/config" - "github.com/spf13/viper" -) - -func TestDecodeConfigFromTOML(t *testing.T) { - c := qt.New(t) - - tomlConfig := ` - -someOtherValue = "foo" - -[deployment] - -order = ["o1", "o2"] - -# All lowercase. -[[deployment.targets]] -name = "name0" -url = "url0" -cloudfrontdistributionid = "cdn0" - -# All uppercase. -[[deployment.targets]] -NAME = "name1" -URL = "url1" -CLOUDFRONTDISTRIBUTIONID = "cdn1" - -# Camelcase. -[[deployment.targets]] -name = "name2" -url = "url2" -cloudFrontDistributionID = "cdn2" - -# All lowercase. -[[deployment.matchers]] -pattern = "^pattern0$" -cachecontrol = "cachecontrol0" -contentencoding = "contentencoding0" -contenttype = "contenttype0" - -# All uppercase. -[[deployment.matchers]] -PATTERN = "^pattern1$" -CACHECONTROL = "cachecontrol1" -CONTENTENCODING = "contentencoding1" -CONTENTTYPE = "contenttype1" -GZIP = true -FORCE = true - -# Camelcase. -[[deployment.matchers]] -pattern = "^pattern2$" -cacheControl = "cachecontrol2" -contentEncoding = "contentencoding2" -contentType = "contenttype2" -gzip = true -force = true -` - cfg, err := config.FromConfigString(tomlConfig, "toml") - c.Assert(err, qt.IsNil) - - dcfg, err := decodeConfig(cfg) - c.Assert(err, qt.IsNil) - - // Order. - c.Assert(len(dcfg.Order), qt.Equals, 2) - c.Assert(dcfg.Order[0], qt.Equals, "o1") - c.Assert(dcfg.Order[1], qt.Equals, "o2") - c.Assert(len(dcfg.ordering), qt.Equals, 2) - - // Targets. - c.Assert(len(dcfg.Targets), qt.Equals, 3) - for i := 0; i < 3; i++ { - tgt := dcfg.Targets[i] - c.Assert(tgt.Name, qt.Equals, fmt.Sprintf("name%d", i)) - c.Assert(tgt.URL, qt.Equals, fmt.Sprintf("url%d", i)) - c.Assert(tgt.CloudFrontDistributionID, qt.Equals, fmt.Sprintf("cdn%d", i)) - } - - // Matchers. - c.Assert(len(dcfg.Matchers), qt.Equals, 3) - for i := 0; i < 3; i++ { - m := dcfg.Matchers[i] - c.Assert(m.Pattern, qt.Equals, fmt.Sprintf("^pattern%d$", i)) - c.Assert(m.re, qt.Not(qt.IsNil)) - c.Assert(m.CacheControl, qt.Equals, fmt.Sprintf("cachecontrol%d", i)) - c.Assert(m.ContentEncoding, qt.Equals, fmt.Sprintf("contentencoding%d", i)) - c.Assert(m.ContentType, qt.Equals, fmt.Sprintf("contenttype%d", i)) - c.Assert(m.Gzip, qt.Equals, i != 0) - c.Assert(m.Force, qt.Equals, i != 0) - } -} - -func TestInvalidOrderingPattern(t *testing.T) { - c := qt.New(t) - - tomlConfig := ` - -someOtherValue = "foo" - -[deployment] -order = ["["] # invalid regular expression -` - cfg, err := config.FromConfigString(tomlConfig, "toml") - c.Assert(err, qt.IsNil) - - _, err = decodeConfig(cfg) - c.Assert(err, qt.Not(qt.IsNil)) -} - -func TestInvalidMatcherPattern(t *testing.T) { - c := qt.New(t) - - tomlConfig := ` - -someOtherValue = "foo" - -[deployment] -[[deployment.matchers]] -Pattern = "[" # invalid regular expression -` - cfg, err := config.FromConfigString(tomlConfig, "toml") - c.Assert(err, qt.IsNil) - - _, err = decodeConfig(cfg) - c.Assert(err, qt.Not(qt.IsNil)) -} - -func TestDecodeConfigDefault(t *testing.T) { - c := qt.New(t) - - dcfg, err := decodeConfig(viper.New()) - c.Assert(err, qt.IsNil) - c.Assert(len(dcfg.Targets), qt.Equals, 0) - c.Assert(len(dcfg.Matchers), qt.Equals, 0) -} diff --git a/deploy/deploy_azure.go b/deploy/deploy_azure.go index 6251429ff..b1ce7358c 100644 --- a/deploy/deploy_azure.go +++ b/deploy/deploy_azure.go @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -// +build !solaris +//go:build !solaris && withdeploy package deploy diff --git a/deploy/deploy_test.go b/deploy/deploy_test.go index ed20daef4..bdc8299a0 100644 --- a/deploy/deploy_test.go +++ b/deploy/deploy_test.go @@ -11,6 +11,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//go:build withdeploy + package deploy import ( @@ -20,7 +22,6 @@ import ( "crypto/md5" "fmt" "io" - "io/ioutil" "os" "path" "path/filepath" @@ -28,6 +29,10 @@ import ( "sort" "testing" + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/deploy/deployconfig" + "github.com/gohugoio/hugo/hugofs" + "github.com/gohugoio/hugo/media" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/spf13/afero" @@ -105,7 +110,7 @@ func TestFindDiffs(t *testing.T) { { Description: "local == remote with route.Force true -> diffs", Local: []*localFile{ - {NativePath: "aaa", SlashPath: "aaa", UploadSize: 1, matcher: &matcher{Force: true}, md5: hash1}, + {NativePath: "aaa", SlashPath: "aaa", UploadSize: 1, matcher: &deployconfig.Matcher{Force: true}, md5: hash1}, makeLocal("bbb", 2, hash1), }, Remote: []*blob.ListObject{ @@ -194,7 +199,8 @@ func TestFindDiffs(t *testing.T) { for _, r := range tc.Remote { remote[r.Key] = r } - gotUpdates, gotDeletes := findDiffs(local, remote, tc.Force) + d := newDeployer() + gotUpdates, gotDeletes := d.findDiffs(local, remote, tc.Force) gotUpdates = applyOrdering(nil, gotUpdates)[0] sort.Slice(gotDeletes, func(i, j int) bool { return gotDeletes[i] < gotDeletes[j] }) if diff := cmp.Diff(gotUpdates, tc.WantUpdates, cmpopts.IgnoreUnexported(localFile{})); diff != "" { @@ -207,6 +213,129 @@ func TestFindDiffs(t *testing.T) { } } +func TestWalkLocal(t *testing.T) { + tests := map[string]struct { + Given []string + Expect []string + MapPath func(string) string + }{ + "Empty": { + Given: []string{}, + Expect: []string{}, + }, + "Normal": { + Given: []string{"file.txt", "normal_dir/file.txt"}, + Expect: []string{"file.txt", "normal_dir/file.txt"}, + }, + "Hidden": { + Given: []string{"file.txt", ".hidden_dir/file.txt", "normal_dir/file.txt"}, + Expect: []string{"file.txt", "normal_dir/file.txt"}, + }, + "Well Known": { + Given: []string{"file.txt", ".hidden_dir/file.txt", ".well-known/file.txt"}, + Expect: []string{"file.txt", ".well-known/file.txt"}, + }, + "StripIndexHTML": { + Given: []string{"index.html", "file.txt", "dir/index.html", "dir/file.txt"}, + Expect: []string{"index.html", "file.txt", "dir/", "dir/file.txt"}, + MapPath: stripIndexHTML, + }, + } + + for desc, tc := range tests { + t.Run(desc, func(t *testing.T) { + fs := afero.NewMemMapFs() + for _, name := range tc.Given { + dir, _ := path.Split(name) + if dir != "" { + if err := fs.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + if fd, err := fs.Create(name); err != nil { + t.Fatal(err) + } else { + fd.Close() + } + } + d := newDeployer() + if got, err := d.walkLocal(fs, nil, nil, nil, media.DefaultTypes, tc.MapPath); err != nil { + t.Fatal(err) + } else { + expect := map[string]any{} + for _, path := range tc.Expect { + if _, ok := got[path]; !ok { + t.Errorf("expected %q in results, but was not found", path) + } + expect[path] = nil + } + for path := range got { + if _, ok := expect[path]; !ok { + t.Errorf("got %q in results unexpectedly", path) + } + } + } + }) + } +} + +func TestStripIndexHTML(t *testing.T) { + tests := map[string]struct { + Input string + Output string + }{ + "Unmapped": {Input: "normal_file.txt", Output: "normal_file.txt"}, + "Stripped": {Input: "directory/index.html", Output: "directory/"}, + "NoSlash": {Input: "prefix_index.html", Output: "prefix_index.html"}, + "Root": {Input: "index.html", Output: "index.html"}, + } + for desc, tc := range tests { + t.Run(desc, func(t *testing.T) { + got := stripIndexHTML(tc.Input) + if got != tc.Output { + t.Errorf("got %q, expect %q", got, tc.Output) + } + }) + } +} + +func TestStripIndexHTMLMatcher(t *testing.T) { + // StripIndexHTML should not affect matchers. + fs := afero.NewMemMapFs() + if err := fs.Mkdir("dir", 0o755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"index.html", "dir/index.html", "file.txt"} { + if fd, err := fs.Create(name); err != nil { + t.Fatal(err) + } else { + fd.Close() + } + } + d := newDeployer() + const pattern = `\.html$` + matcher := &deployconfig.Matcher{Pattern: pattern, Gzip: true, Re: regexp.MustCompile(pattern)} + if got, err := d.walkLocal(fs, []*deployconfig.Matcher{matcher}, nil, nil, media.DefaultTypes, stripIndexHTML); err != nil { + t.Fatal(err) + } else { + for _, name := range []string{"index.html", "dir/"} { + lf := got[name] + if lf == nil { + t.Errorf("missing file %q", name) + } else if lf.matcher == nil { + t.Errorf("file %q has nil matcher, expect %q", name, pattern) + } + } + const name = "file.txt" + lf := got[name] + if lf == nil { + t.Errorf("missing file %q", name) + } else if lf.matcher != nil { + t.Errorf("file %q has matcher %q, expect nil", name, lf.matcher.Pattern) + } + } +} + func TestLocalFile(t *testing.T) { const ( content = "hello world!" @@ -227,7 +356,8 @@ func TestLocalFile(t *testing.T) { tests := []struct { Description string Path string - Matcher *matcher + Matcher *deployconfig.Matcher + MediaTypesConfig map[string]any WantContent []byte WantSize int64 WantMD5 []byte @@ -252,7 +382,7 @@ func TestLocalFile(t *testing.T) { { Description: "CacheControl from matcher", Path: "foo.txt", - Matcher: &matcher{CacheControl: "max-age=630720000"}, + Matcher: &deployconfig.Matcher{CacheControl: "max-age=630720000"}, WantContent: contentBytes, WantSize: contentLen, WantMD5: contentMD5[:], @@ -261,7 +391,7 @@ func TestLocalFile(t *testing.T) { { Description: "ContentEncoding from matcher", Path: "foo.txt", - Matcher: &matcher{ContentEncoding: "foobar"}, + Matcher: &deployconfig.Matcher{ContentEncoding: "foobar"}, WantContent: contentBytes, WantSize: contentLen, WantMD5: contentMD5[:], @@ -270,7 +400,7 @@ func TestLocalFile(t *testing.T) { { Description: "ContentType from matcher", Path: "foo.txt", - Matcher: &matcher{ContentType: "foo/bar"}, + Matcher: &deployconfig.Matcher{ContentType: "foo/bar"}, WantContent: contentBytes, WantSize: contentLen, WantMD5: contentMD5[:], @@ -279,12 +409,25 @@ func TestLocalFile(t *testing.T) { { Description: "gzipped content", Path: "foo.txt", - Matcher: &matcher{Gzip: true}, + Matcher: &deployconfig.Matcher{Gzip: true}, WantContent: gzBytes, WantSize: gzLen, WantMD5: gzMD5[:], WantContentEncoding: "gzip", }, + { + Description: "Custom MediaType", + Path: "foo.hugo", + MediaTypesConfig: map[string]any{ + "hugo/custom": map[string]any{ + "suffixes": []string{"hugo"}, + }, + }, + WantContent: contentBytes, + WantSize: contentLen, + WantMD5: contentMD5[:], + WantContentType: "hugo/custom", + }, } for _, tc := range tests { @@ -293,7 +436,15 @@ func TestLocalFile(t *testing.T) { if err := afero.WriteFile(fs, tc.Path, []byte(content), os.ModePerm); err != nil { t.Fatal(err) } - lf, err := newLocalFile(fs, tc.Path, filepath.ToSlash(tc.Path), tc.Matcher) + mediaTypes := media.DefaultTypes + if len(tc.MediaTypesConfig) > 0 { + mt, err := media.DecodeTypes(tc.MediaTypesConfig) + if err != nil { + t.Fatal(err) + } + mediaTypes = mt.Config + } + lf, err := newLocalFile(fs, tc.Path, filepath.ToSlash(tc.Path), tc.Matcher, mediaTypes) if err != nil { t.Fatal(err) } @@ -320,7 +471,7 @@ func TestLocalFile(t *testing.T) { if err != nil { t.Fatal(err) } - gotContent, err := ioutil.ReadAll(r) + gotContent, err := io.ReadAll(r) if err != nil { t.Fatal(err) } @@ -333,7 +484,7 @@ func TestLocalFile(t *testing.T) { if err != nil { t.Fatal(err) } - gotContent, err = ioutil.ReadAll(r) + gotContent, err = io.ReadAll(r) if err != nil { t.Fatal(err) } @@ -433,47 +584,35 @@ type fsTest struct { // 1. An in-memory afero.Fs paired with an in-memory Go CDK bucket. // 2. A filesystem-based afero.Fs paired with an filesystem-based Go CDK bucket. // It returns the pair of tests and a cleanup function. -func initFsTests() ([]*fsTest, func(), error) { - tmpfsdir, err := ioutil.TempDir("", "fs") - if err != nil { - return nil, nil, err - } - tmpbucketdir, err := ioutil.TempDir("", "bucket") - if err != nil { - return nil, nil, err - } +func initFsTests(t *testing.T) []*fsTest { + t.Helper() + + tmpfsdir := t.TempDir() + tmpbucketdir := t.TempDir() memfs := afero.NewMemMapFs() membucket := memblob.OpenBucket(nil) + t.Cleanup(func() { membucket.Close() }) - filefs := afero.NewBasePathFs(afero.NewOsFs(), tmpfsdir) + filefs := hugofs.NewBasePathFs(afero.NewOsFs(), tmpfsdir) filebucket, err := fileblob.OpenBucket(tmpbucketdir, nil) if err != nil { - return nil, nil, err + t.Fatal(err) } + t.Cleanup(func() { filebucket.Close() }) tests := []*fsTest{ {"mem", memfs, membucket}, {"file", filefs, filebucket}, } - cleanup := func() { - membucket.Close() - filebucket.Close() - os.RemoveAll(tmpfsdir) - os.RemoveAll(tmpbucketdir) - } - return tests, cleanup, nil + return tests } // TestEndToEndSync verifies that basic adds, updates, and deletes are working // correctly. func TestEndToEndSync(t *testing.T) { ctx := context.Background() - tests, cleanup, err := initFsTests() - if err != nil { - t.Fatal(err) - } - defer cleanup() + tests := initFsTests(t) for _, test := range tests { t.Run(test.name, func(t *testing.T) { local, err := initLocalFs(ctx, test.fs) @@ -482,8 +621,9 @@ func TestEndToEndSync(t *testing.T) { } deployer := &Deployer{ localFs: test.fs, - maxDeletes: -1, bucket: test.bucket, + mediaTypes: media.DefaultTypes, + cfg: deployconfig.DeployConfig{Workers: 2, MaxDeletes: -1}, } // Initial deployment should sync remote with local. @@ -555,11 +695,7 @@ func TestEndToEndSync(t *testing.T) { // TestMaxDeletes verifies that the "maxDeletes" flag is working correctly. func TestMaxDeletes(t *testing.T) { ctx := context.Background() - tests, cleanup, err := initFsTests() - if err != nil { - t.Fatal(err) - } - defer cleanup() + tests := initFsTests(t) for _, test := range tests { t.Run(test.name, func(t *testing.T) { local, err := initLocalFs(ctx, test.fs) @@ -568,8 +704,9 @@ func TestMaxDeletes(t *testing.T) { } deployer := &Deployer{ localFs: test.fs, - maxDeletes: -1, bucket: test.bucket, + mediaTypes: media.DefaultTypes, + cfg: deployconfig.DeployConfig{Workers: 2, MaxDeletes: -1}, } // Sync remote with local. @@ -590,7 +727,7 @@ func TestMaxDeletes(t *testing.T) { } // A deployment with maxDeletes=0 shouldn't change anything. - deployer.maxDeletes = 0 + deployer.cfg.MaxDeletes = 0 if err := deployer.Deploy(ctx); err != nil { t.Errorf("deploy failed: %v", err) } @@ -600,7 +737,7 @@ func TestMaxDeletes(t *testing.T) { } // A deployment with maxDeletes=1 shouldn't change anything either. - deployer.maxDeletes = 1 + deployer.cfg.MaxDeletes = 1 if err := deployer.Deploy(ctx); err != nil { t.Errorf("deploy failed: %v", err) } @@ -610,7 +747,7 @@ func TestMaxDeletes(t *testing.T) { } // A deployment with maxDeletes=2 should make the changes. - deployer.maxDeletes = 2 + deployer.cfg.MaxDeletes = 2 if err := deployer.Deploy(ctx); err != nil { t.Errorf("deploy failed: %v", err) } @@ -628,7 +765,7 @@ func TestMaxDeletes(t *testing.T) { } // A deployment with maxDeletes=-1 should make the changes. - deployer.maxDeletes = -1 + deployer.cfg.MaxDeletes = -1 if err := deployer.Deploy(ctx); err != nil { t.Errorf("deploy failed: %v", err) } @@ -640,15 +777,165 @@ func TestMaxDeletes(t *testing.T) { } } +// TestIncludeExclude verifies that the include/exclude options for targets work. +func TestIncludeExclude(t *testing.T) { + ctx := context.Background() + tests := []struct { + Include string + Exclude string + Want deploySummary + }{ + { + Want: deploySummary{NumLocal: 5, NumUploads: 5}, + }, + { + Include: "**aaa", + Want: deploySummary{NumLocal: 3, NumUploads: 3}, + }, + { + Include: "**bbb", + Want: deploySummary{NumLocal: 2, NumUploads: 2}, + }, + { + Include: "aaa", + Want: deploySummary{NumLocal: 1, NumUploads: 1}, + }, + { + Exclude: "**aaa", + Want: deploySummary{NumLocal: 2, NumUploads: 2}, + }, + { + Exclude: "**bbb", + Want: deploySummary{NumLocal: 3, NumUploads: 3}, + }, + { + Exclude: "aaa", + Want: deploySummary{NumLocal: 4, NumUploads: 4}, + }, + { + Include: "**aaa", + Exclude: "**nested**", + Want: deploySummary{NumLocal: 2, NumUploads: 2}, + }, + } + for _, test := range tests { + t.Run(fmt.Sprintf("include %q exclude %q", test.Include, test.Exclude), func(t *testing.T) { + fsTests := initFsTests(t) + fsTest := fsTests[1] // just do file-based test + + _, err := initLocalFs(ctx, fsTest.fs) + if err != nil { + t.Fatal(err) + } + tgt := &deployconfig.Target{ + Include: test.Include, + Exclude: test.Exclude, + } + if err := tgt.ParseIncludeExclude(); err != nil { + t.Error(err) + } + deployer := &Deployer{ + localFs: fsTest.fs, + cfg: deployconfig.DeployConfig{Workers: 2, MaxDeletes: -1}, bucket: fsTest.bucket, + target: tgt, + mediaTypes: media.DefaultTypes, + } + + // Sync remote with local. + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("deploy: failed: %v", err) + } + if !cmp.Equal(deployer.summary, test.Want) { + t.Errorf("deploy: got %v, want %v", deployer.summary, test.Want) + } + }) + } +} + +// TestIncludeExcludeRemoteDelete verifies deleted local files that don't match include/exclude patterns +// are not deleted on the remote. +func TestIncludeExcludeRemoteDelete(t *testing.T) { + ctx := context.Background() + + tests := []struct { + Include string + Exclude string + Want deploySummary + }{ + { + Want: deploySummary{NumLocal: 3, NumRemote: 5, NumUploads: 0, NumDeletes: 2}, + }, + { + Include: "**aaa", + Want: deploySummary{NumLocal: 2, NumRemote: 3, NumUploads: 0, NumDeletes: 1}, + }, + { + Include: "subdir/**", + Want: deploySummary{NumLocal: 1, NumRemote: 2, NumUploads: 0, NumDeletes: 1}, + }, + { + Exclude: "**bbb", + Want: deploySummary{NumLocal: 2, NumRemote: 3, NumUploads: 0, NumDeletes: 1}, + }, + { + Exclude: "bbb", + Want: deploySummary{NumLocal: 3, NumRemote: 4, NumUploads: 0, NumDeletes: 1}, + }, + } + for _, test := range tests { + t.Run(fmt.Sprintf("include %q exclude %q", test.Include, test.Exclude), func(t *testing.T) { + fsTests := initFsTests(t) + fsTest := fsTests[1] // just do file-based test + + local, err := initLocalFs(ctx, fsTest.fs) + if err != nil { + t.Fatal(err) + } + deployer := &Deployer{ + localFs: fsTest.fs, + cfg: deployconfig.DeployConfig{Workers: 2, MaxDeletes: -1}, bucket: fsTest.bucket, + mediaTypes: media.DefaultTypes, + } + + // Initial sync to get the files on the remote + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("deploy: failed: %v", err) + } + + // Delete two files, [1] and [2]. + if err := fsTest.fs.Remove(local[1].Name); err != nil { + t.Fatal(err) + } + if err := fsTest.fs.Remove(local[2].Name); err != nil { + t.Fatal(err) + } + + // Second sync + tgt := &deployconfig.Target{ + Include: test.Include, + Exclude: test.Exclude, + } + if err := tgt.ParseIncludeExclude(); err != nil { + t.Error(err) + } + deployer.target = tgt + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("deploy: failed: %v", err) + } + + if !cmp.Equal(deployer.summary, test.Want) { + t.Errorf("deploy: got %v, want %v", deployer.summary, test.Want) + } + }) + } +} + // TestCompression verifies that gzip compression works correctly. // In particular, MD5 hashes must be of the compressed content. func TestCompression(t *testing.T) { ctx := context.Background() - tests, cleanup, err := initFsTests() - if err != nil { - t.Fatal(err) - } - defer cleanup() + + tests := initFsTests(t) for _, test := range tests { t.Run(test.name, func(t *testing.T) { local, err := initLocalFs(ctx, test.fs) @@ -656,9 +943,10 @@ func TestCompression(t *testing.T) { t.Fatal(err) } deployer := &Deployer{ - localFs: test.fs, - bucket: test.bucket, - matchers: []*matcher{{Pattern: ".*", Gzip: true, re: regexp.MustCompile(".*")}}, + localFs: test.fs, + bucket: test.bucket, + cfg: deployconfig.DeployConfig{Workers: 2, MaxDeletes: -1, Matchers: []*deployconfig.Matcher{{Pattern: ".*", Gzip: true, Re: regexp.MustCompile(".*")}}}, + mediaTypes: media.DefaultTypes, } // Initial deployment should sync remote with local. @@ -702,11 +990,7 @@ func TestCompression(t *testing.T) { // attribute for matcher works. func TestMatching(t *testing.T) { ctx := context.Background() - tests, cleanup, err := initFsTests() - if err != nil { - t.Fatal(err) - } - defer cleanup() + tests := initFsTests(t) for _, test := range tests { t.Run(test.name, func(t *testing.T) { _, err := initLocalFs(ctx, test.fs) @@ -714,9 +998,10 @@ func TestMatching(t *testing.T) { t.Fatal(err) } deployer := &Deployer{ - localFs: test.fs, - bucket: test.bucket, - matchers: []*matcher{{Pattern: "^subdir/aaa$", Force: true, re: regexp.MustCompile("^subdir/aaa$")}}, + localFs: test.fs, + bucket: test.bucket, + cfg: deployconfig.DeployConfig{Workers: 2, MaxDeletes: -1, Matchers: []*deployconfig.Matcher{{Pattern: "^subdir/aaa$", Force: true, Re: regexp.MustCompile("^subdir/aaa$")}}}, + mediaTypes: media.DefaultTypes, } // Initial deployment to sync remote with local. @@ -740,7 +1025,7 @@ func TestMatching(t *testing.T) { } // Repeat with a matcher that should now match 3 files. - deployer.matchers = []*matcher{{Pattern: "aaa", Force: true, re: regexp.MustCompile("aaa")}} + deployer.cfg.Matchers = []*deployconfig.Matcher{{Pattern: "aaa", Force: true, Re: regexp.MustCompile("aaa")}} if err := deployer.Deploy(ctx); err != nil { t.Errorf("no-op deploy with triple force matcher: %v", err) } @@ -808,3 +1093,10 @@ func verifyRemote(ctx context.Context, bucket *blob.Bucket, local []*fileData) ( } return diff, nil } + +func newDeployer() *Deployer { + return &Deployer{ + logger: loggers.NewDefault(), + cfg: deployconfig.DeployConfig{Workers: 2}, + } +} diff --git a/deploy/deployconfig/deployConfig.go b/deploy/deployconfig/deployConfig.go new file mode 100644 index 000000000..b16b7c627 --- /dev/null +++ b/deploy/deployconfig/deployConfig.go @@ -0,0 +1,179 @@ +// Copyright 2024 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 deployconfig + +import ( + "errors" + "fmt" + "regexp" + + "github.com/gobwas/glob" + "github.com/gohugoio/hugo/config" + hglob "github.com/gohugoio/hugo/hugofs/glob" + "github.com/mitchellh/mapstructure" +) + +const DeploymentConfigKey = "deployment" + +// DeployConfig is the complete configuration for deployment. +type DeployConfig struct { + Targets []*Target + Matchers []*Matcher + Order []string + + // Usually set via flags. + // Target deployment Name; defaults to the first one. + Target string + // Show a confirm prompt before deploying. + Confirm bool + // DryRun will try the deployment without any remote changes. + DryRun bool + // Force will re-upload all files. + Force bool + // Invalidate the CDN cache listed in the deployment target. + InvalidateCDN bool + // MaxDeletes is the maximum number of files to delete. + MaxDeletes int + // Number of concurrent workers to use when uploading files. + Workers int + + Ordering []*regexp.Regexp `json:"-"` // compiled Order +} + +type Target struct { + Name string + URL string + + CloudFrontDistributionID string + + // GoogleCloudCDNOrigin specifies the Google Cloud project and CDN origin to + // invalidate when deploying this target. It is specified as /. + GoogleCloudCDNOrigin string + + // Optional patterns of files to include/exclude for this target. + // Parsed using github.com/gobwas/glob. + Include string + Exclude string + + // Parsed versions of Include/Exclude. + IncludeGlob glob.Glob `json:"-"` + ExcludeGlob glob.Glob `json:"-"` + + // If true, any local path matching /index.html will be mapped to the + // remote path /. This does not affect the top-level index.html file, + // since that would result in an empty path. + StripIndexHTML bool +} + +func (tgt *Target) ParseIncludeExclude() error { + var err error + if tgt.Include != "" { + tgt.IncludeGlob, err = hglob.GetGlob(tgt.Include) + if err != nil { + return fmt.Errorf("invalid deployment.target.include %q: %v", tgt.Include, err) + } + } + if tgt.Exclude != "" { + tgt.ExcludeGlob, err = hglob.GetGlob(tgt.Exclude) + if err != nil { + return fmt.Errorf("invalid deployment.target.exclude %q: %v", tgt.Exclude, err) + } + } + return nil +} + +// Matcher represents configuration to be applied to files whose paths match +// a specified pattern. +type Matcher struct { + // Pattern is the string pattern to match against paths. + // Matching is done against paths converted to use / as the path separator. + Pattern string + + // CacheControl specifies caching attributes to use when serving the blob. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control + CacheControl string + + // ContentEncoding specifies the encoding used for the blob's content, if any. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding + ContentEncoding string + + // ContentType specifies the MIME type of the blob being written. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type + ContentType string + + // Gzip determines whether the file should be gzipped before upload. + // If so, the ContentEncoding field will automatically be set to "gzip". + Gzip bool + + // Force indicates that matching files should be re-uploaded. Useful when + // other route-determined metadata (e.g., ContentType) has changed. + Force bool + + // Re is Pattern compiled. + Re *regexp.Regexp `json:"-"` +} + +func (m *Matcher) Matches(path string) bool { + return m.Re.MatchString(path) +} + +var DefaultConfig = DeployConfig{ + Workers: 10, + InvalidateCDN: true, + MaxDeletes: 256, +} + +// DecodeConfig creates a config from a given Hugo configuration. +func DecodeConfig(cfg config.Provider) (DeployConfig, error) { + dcfg := DefaultConfig + + if !cfg.IsSet(DeploymentConfigKey) { + return dcfg, nil + } + if err := mapstructure.WeakDecode(cfg.GetStringMap(DeploymentConfigKey), &dcfg); err != nil { + return dcfg, err + } + + if dcfg.Workers <= 0 { + dcfg.Workers = 10 + } + + for _, tgt := range dcfg.Targets { + if *tgt == (Target{}) { + return dcfg, errors.New("empty deployment target") + } + if err := tgt.ParseIncludeExclude(); err != nil { + return dcfg, err + } + } + var err error + for _, m := range dcfg.Matchers { + if *m == (Matcher{}) { + return dcfg, errors.New("empty deployment matcher") + } + m.Re, err = regexp.Compile(m.Pattern) + if err != nil { + return dcfg, fmt.Errorf("invalid deployment.matchers.pattern: %v", err) + } + } + for _, o := range dcfg.Order { + re, err := regexp.Compile(o) + if err != nil { + return dcfg, fmt.Errorf("invalid deployment.orderings.pattern: %v", err) + } + dcfg.Ordering = append(dcfg.Ordering, re) + } + + return dcfg, nil +} diff --git a/deploy/deployconfig/deployConfig_test.go b/deploy/deployconfig/deployConfig_test.go new file mode 100644 index 000000000..38d0aadd6 --- /dev/null +++ b/deploy/deployconfig/deployConfig_test.go @@ -0,0 +1,198 @@ +// Copyright 2024 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. + +//go:build withdeploy + +package deployconfig + +import ( + "fmt" + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/config" +) + +func TestDecodeConfigFromTOML(t *testing.T) { + c := qt.New(t) + + tomlConfig := ` + +someOtherValue = "foo" + +[deployment] + +order = ["o1", "o2"] + +# All lowercase. +[[deployment.targets]] +name = "name0" +url = "url0" +cloudfrontdistributionid = "cdn0" +include = "*.html" + +# All uppercase. +[[deployment.targets]] +NAME = "name1" +URL = "url1" +CLOUDFRONTDISTRIBUTIONID = "cdn1" +INCLUDE = "*.jpg" + +# Camelcase. +[[deployment.targets]] +name = "name2" +url = "url2" +cloudFrontDistributionID = "cdn2" +exclude = "*.png" + +# All lowercase. +[[deployment.matchers]] +pattern = "^pattern0$" +cachecontrol = "cachecontrol0" +contentencoding = "contentencoding0" +contenttype = "contenttype0" + +# All uppercase. +[[deployment.matchers]] +PATTERN = "^pattern1$" +CACHECONTROL = "cachecontrol1" +CONTENTENCODING = "contentencoding1" +CONTENTTYPE = "contenttype1" +GZIP = true +FORCE = true + +# Camelcase. +[[deployment.matchers]] +pattern = "^pattern2$" +cacheControl = "cachecontrol2" +contentEncoding = "contentencoding2" +contentType = "contenttype2" +gzip = true +force = true +` + cfg, err := config.FromConfigString(tomlConfig, "toml") + c.Assert(err, qt.IsNil) + + dcfg, err := DecodeConfig(cfg) + c.Assert(err, qt.IsNil) + + // Order. + c.Assert(len(dcfg.Order), qt.Equals, 2) + c.Assert(dcfg.Order[0], qt.Equals, "o1") + c.Assert(dcfg.Order[1], qt.Equals, "o2") + c.Assert(len(dcfg.Ordering), qt.Equals, 2) + + // Targets. + c.Assert(len(dcfg.Targets), qt.Equals, 3) + wantInclude := []string{"*.html", "*.jpg", ""} + wantExclude := []string{"", "", "*.png"} + for i := 0; i < 3; i++ { + tgt := dcfg.Targets[i] + c.Assert(tgt.Name, qt.Equals, fmt.Sprintf("name%d", i)) + c.Assert(tgt.URL, qt.Equals, fmt.Sprintf("url%d", i)) + c.Assert(tgt.CloudFrontDistributionID, qt.Equals, fmt.Sprintf("cdn%d", i)) + c.Assert(tgt.Include, qt.Equals, wantInclude[i]) + if wantInclude[i] != "" { + c.Assert(tgt.IncludeGlob, qt.Not(qt.IsNil)) + } + c.Assert(tgt.Exclude, qt.Equals, wantExclude[i]) + if wantExclude[i] != "" { + c.Assert(tgt.ExcludeGlob, qt.Not(qt.IsNil)) + } + } + + // Matchers. + c.Assert(len(dcfg.Matchers), qt.Equals, 3) + for i := 0; i < 3; i++ { + m := dcfg.Matchers[i] + c.Assert(m.Pattern, qt.Equals, fmt.Sprintf("^pattern%d$", i)) + c.Assert(m.Re, qt.Not(qt.IsNil)) + c.Assert(m.CacheControl, qt.Equals, fmt.Sprintf("cachecontrol%d", i)) + c.Assert(m.ContentEncoding, qt.Equals, fmt.Sprintf("contentencoding%d", i)) + c.Assert(m.ContentType, qt.Equals, fmt.Sprintf("contenttype%d", i)) + c.Assert(m.Gzip, qt.Equals, i != 0) + c.Assert(m.Force, qt.Equals, i != 0) + } +} + +func TestInvalidOrderingPattern(t *testing.T) { + c := qt.New(t) + + tomlConfig := ` + +someOtherValue = "foo" + +[deployment] +order = ["["] # invalid regular expression +` + cfg, err := config.FromConfigString(tomlConfig, "toml") + c.Assert(err, qt.IsNil) + + _, err = DecodeConfig(cfg) + c.Assert(err, qt.Not(qt.IsNil)) +} + +func TestInvalidMatcherPattern(t *testing.T) { + c := qt.New(t) + + tomlConfig := ` + +someOtherValue = "foo" + +[deployment] +[[deployment.matchers]] +Pattern = "[" # invalid regular expression +` + cfg, err := config.FromConfigString(tomlConfig, "toml") + c.Assert(err, qt.IsNil) + + _, err = DecodeConfig(cfg) + c.Assert(err, qt.Not(qt.IsNil)) +} + +func TestDecodeConfigDefault(t *testing.T) { + c := qt.New(t) + + dcfg, err := DecodeConfig(config.New()) + c.Assert(err, qt.IsNil) + c.Assert(len(dcfg.Targets), qt.Equals, 0) + c.Assert(len(dcfg.Matchers), qt.Equals, 0) +} + +func TestEmptyTarget(t *testing.T) { + c := qt.New(t) + + tomlConfig := ` +[deployment] +[[deployment.targets]] +` + cfg, err := config.FromConfigString(tomlConfig, "toml") + c.Assert(err, qt.IsNil) + + _, err = DecodeConfig(cfg) + c.Assert(err, qt.Not(qt.IsNil)) +} + +func TestEmptyMatcher(t *testing.T) { + c := qt.New(t) + + tomlConfig := ` +[deployment] +[[deployment.matchers]] +` + cfg, err := config.FromConfigString(tomlConfig, "toml") + c.Assert(err, qt.IsNil) + + _, err = DecodeConfig(cfg) + c.Assert(err, qt.Not(qt.IsNil)) +} diff --git a/deploy/google.go b/deploy/google.go index be3ce52f0..5b302e95b 100644 --- a/deploy/google.go +++ b/deploy/google.go @@ -11,6 +11,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//go:build withdeploy + package deploy import ( diff --git a/deps/deps.go b/deps/deps.go index 8f3d81632..d0d6d95fc 100644 --- a/deps/deps.go +++ b/deps/deps.go @@ -1,48 +1,51 @@ package deps import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" "sync" - "time" - - "github.com/pkg/errors" - "go.uber.org/atomic" + "sync/atomic" + "github.com/bep/logg" + "github.com/gohugoio/hugo/cache/dynacache" "github.com/gohugoio/hugo/cache/filecache" + "github.com/gohugoio/hugo/common/hexec" "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/common/types" "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/config/allconfig" + "github.com/gohugoio/hugo/config/security" "github.com/gohugoio/hugo/helpers" "github.com/gohugoio/hugo/hugofs" - "github.com/gohugoio/hugo/langs" + "github.com/gohugoio/hugo/identity" + "github.com/gohugoio/hugo/internal/js" + "github.com/gohugoio/hugo/internal/warpc" "github.com/gohugoio/hugo/media" "github.com/gohugoio/hugo/resources/page" + "github.com/gohugoio/hugo/resources/postpub" + "github.com/gohugoio/hugo/tpl/tplimpl" "github.com/gohugoio/hugo/metrics" - "github.com/gohugoio/hugo/output" "github.com/gohugoio/hugo/resources" "github.com/gohugoio/hugo/source" "github.com/gohugoio/hugo/tpl" - jww "github.com/spf13/jwalterweatherman" + "github.com/spf13/afero" ) // Deps holds dependencies used by many. // There will be normally only one instance of deps in play // at a given time, i.e. one per Site built. type Deps struct { - // The logger to use. - Log *loggers.Logger `json:"-"` + Log loggers.Logger `json:"-"` - // Used to log errors that may repeat itself many times. - DistinctErrorLog *helpers.DistinctLogger - - // Used to log warnings that may repeat itself many times. - DistinctWarningLog *helpers.DistinctLogger - - // The templates to use. This will usually implement the full tpl.TemplateManager. - tmpl tpl.TemplateHandler - - // We use this to parse and execute ad-hoc text templates. - textTmpl tpl.TemplateParseFinder + ExecHelper *hexec.Exec // The file systems to use. Fs *hugofs.Fs `json:"-"` @@ -60,80 +63,287 @@ type Deps struct { ResourceSpec *resources.Spec // The configuration to use - Cfg config.Provider `json:"-"` + Conf config.AllProvider `json:"-"` - // The file cache to use. - FileCaches filecache.Caches + // The memory cache to use. + MemCache *dynacache.Cache // The translation func to use - Translate func(translationID string, args ...interface{}) string `json:"-"` - - // The language in use. TODO(bep) consolidate with site - Language *langs.Language + Translate func(ctx context.Context, translationID string, templateData any) string `json:"-"` // The site building. Site page.Site - // All the output formats available for the current site. - OutputFormatsConfig output.Formats - - templateProvider ResourceProvider - WithTemplate func(templ tpl.TemplateManager) error `json:"-"` + TemplateStore *tplimpl.TemplateStore // Used in tests - OverloadedTemplateFuncs map[string]interface{} + OverloadedTemplateFuncs map[string]any - translationProvider ResourceProvider + TranslationProvider ResourceProvider Metrics metrics.Provider - // Timeout is configurable in site config. - Timeout time.Duration - // BuildStartListeners will be notified before a build starts. - BuildStartListeners *Listeners + BuildStartListeners *Listeners[any] - // Atomic flags set during a build. - BuildFlags *BuildFlags + // BuildEndListeners will be notified after a build finishes. + BuildEndListeners *Listeners[any] + + // OnChangeListeners will be notified when something changes. + OnChangeListeners *Listeners[identity.Identity] + + // Resources that gets closed when the build is done or the server shuts down. + BuildClosers *types.Closers + + // This is common/global for all sites. + BuildState *BuildState + + // Misc counters. + Counters *Counters + + // Holds RPC dispatchers for Katex etc. + // TODO(bep) rethink this re. a plugin setup, but this will have to do for now. + WasmDispatchers *warpc.Dispatchers + + // The JS batcher client. + JSBatcherClient js.BatcherClient + + isClosed bool *globalErrHandler } -type globalErrHandler struct { - // Channel for some "hard to get to" build errors - buildErrors chan error +func (d Deps) Clone(s page.Site, conf config.AllProvider) (*Deps, error) { + d.Conf = conf + d.Site = s + d.ExecHelper = nil + d.ContentSpec = nil + + if err := d.Init(); err != nil { + return nil, err + } + + return &d, nil } -// SendErr sends the error on a channel to be handled later. +func (d *Deps) GetTemplateStore() *tplimpl.TemplateStore { + return d.TemplateStore +} + +func (d *Deps) Init() error { + if d.Conf == nil { + panic("conf is nil") + } + + if d.Fs == nil { + // For tests. + d.Fs = hugofs.NewFrom(afero.NewMemMapFs(), d.Conf.BaseConfig()) + } + + if d.Log == nil { + d.Log = loggers.NewDefault() + } + + if d.globalErrHandler == nil { + d.globalErrHandler = &globalErrHandler{ + logger: d.Log, + } + } + if d.BuildState == nil { + d.BuildState = &BuildState{} + } + if d.Counters == nil { + d.Counters = &Counters{} + } + if d.BuildState.DeferredExecutions == nil { + if d.BuildState.DeferredExecutionsGroupedByRenderingContext == nil { + d.BuildState.DeferredExecutionsGroupedByRenderingContext = make(map[tpl.RenderingContext]*DeferredExecutions) + } + d.BuildState.DeferredExecutions = &DeferredExecutions{ + Executions: maps.NewCache[string, *tpl.DeferredExecution](), + FilenamesWithPostPrefix: maps.NewCache[string, bool](), + } + } + + if d.BuildStartListeners == nil { + d.BuildStartListeners = &Listeners[any]{} + } + + if d.BuildEndListeners == nil { + d.BuildEndListeners = &Listeners[any]{} + } + + if d.BuildClosers == nil { + d.BuildClosers = &types.Closers{} + } + + if d.OnChangeListeners == nil { + d.OnChangeListeners = &Listeners[identity.Identity]{} + } + + if d.Metrics == nil && d.Conf.TemplateMetrics() { + d.Metrics = metrics.NewProvider(d.Conf.TemplateMetricsHints()) + } + + if d.ExecHelper == nil { + d.ExecHelper = hexec.New(d.Conf.GetConfigSection("security").(security.Config), d.Conf.WorkingDir(), d.Log) + } + + if d.MemCache == nil { + d.MemCache = dynacache.New(dynacache.Options{Watching: d.Conf.Watching(), Log: d.Log}) + } + + if d.PathSpec == nil { + hashBytesReceiverFunc := func(name string, match []byte) { + s := string(match) + switch s { + case postpub.PostProcessPrefix: + d.BuildState.AddFilenameWithPostPrefix(name) + case tpl.HugoDeferredTemplatePrefix: + d.BuildState.DeferredExecutions.FilenamesWithPostPrefix.Set(name, true) + } + } + + // Skip binary files. + mediaTypes := d.Conf.GetConfigSection("mediaTypes").(media.Types) + hashBytesShouldCheck := func(name string) bool { + ext := strings.TrimPrefix(filepath.Ext(name), ".") + return mediaTypes.IsTextSuffix(ext) + } + d.Fs.PublishDir = hugofs.NewHasBytesReceiver( + d.Fs.PublishDir, + hashBytesShouldCheck, + hashBytesReceiverFunc, + []byte(tpl.HugoDeferredTemplatePrefix), + []byte(postpub.PostProcessPrefix)) + + pathSpec, err := helpers.NewPathSpec(d.Fs, d.Conf, d.Log) + if err != nil { + return err + } + d.PathSpec = pathSpec + } else { + var err error + d.PathSpec, err = helpers.NewPathSpecWithBaseBaseFsProvided(d.Fs, d.Conf, d.Log, d.PathSpec.BaseFs) + if err != nil { + return err + } + } + + if d.ContentSpec == nil { + contentSpec, err := helpers.NewContentSpec(d.Conf, d.Log, d.Content.Fs, d.ExecHelper) + if err != nil { + return err + } + d.ContentSpec = contentSpec + } + + if d.SourceSpec == nil { + d.SourceSpec = source.NewSourceSpec(d.PathSpec, nil, d.Fs.Source) + } + + var common *resources.SpecCommon + if d.ResourceSpec != nil { + common = d.ResourceSpec.SpecCommon + } + + fileCaches, err := filecache.NewCaches(d.PathSpec) + if err != nil { + return fmt.Errorf("failed to create file caches from configuration: %w", err) + } + + resourceSpec, err := resources.NewSpec(d.PathSpec, common, fileCaches, d.MemCache, d.BuildState, d.Log, d, d.ExecHelper, d.BuildClosers, d.BuildState) + if err != nil { + return fmt.Errorf("failed to create resource spec: %w", err) + } + d.ResourceSpec = resourceSpec + + return nil +} + +// TODO(bep) rework this to get it in line with how we manage templates. +func (d *Deps) Compile(prototype *Deps) error { + var err error + if prototype == nil { + + if err = d.TranslationProvider.NewResource(d); err != nil { + return err + } + return nil + } + + if err = d.TranslationProvider.CloneResource(d, prototype); err != nil { + return err + } + + return nil +} + +// MkdirTemp returns a temporary directory path that will be cleaned up on exit. +func (d Deps) MkdirTemp(pattern string) (string, error) { + filename, err := os.MkdirTemp("", pattern) + if err != nil { + return "", err + } + d.BuildClosers.Add( + types.CloserFunc( + func() error { + return os.RemoveAll(filename) + }, + ), + ) + + return filename, nil +} + +type globalErrHandler struct { + logger loggers.Logger + + // Channel for some "hard to get to" build errors + buildErrors chan error + // Used to signal that the build is done. + quit chan struct{} +} + +// SendError sends the error on a channel to be handled later. // This can be used in situations where returning and aborting the current // operation isn't practical. func (e *globalErrHandler) SendError(err error) { if e.buildErrors != nil { select { + case <-e.quit: case e.buildErrors <- err: default: } return } - - jww.ERROR.Println(err) + e.logger.Errorln(err) } func (e *globalErrHandler) StartErrorCollector() chan error { + e.quit = make(chan struct{}) e.buildErrors = make(chan error, 10) return e.buildErrors } +func (e *globalErrHandler) StopErrorCollector() { + if e.buildErrors != nil { + close(e.quit) + close(e.buildErrors) + } +} + // Listeners represents an event listener. -type Listeners struct { +type Listeners[T any] struct { sync.Mutex // A list of funcs to be notified about an event. - listeners []func() + // If the return value is true, the listener will be removed. + listeners []func(...T) bool } // Add adds a function to a Listeners instance. -func (b *Listeners) Add(f func()) { +func (b *Listeners[T]) Add(f func(...T) bool) { if b == nil { return } @@ -143,245 +353,146 @@ func (b *Listeners) Add(f func()) { } // Notify executes all listener functions. -func (b *Listeners) Notify() { +func (b *Listeners[T]) Notify(vs ...T) { b.Lock() defer b.Unlock() + temp := b.listeners[:0] for _, notify := range b.listeners { - notify() + if !notify(vs...) { + temp = append(temp, notify) + } } + b.listeners = temp } // ResourceProvider is used to create and refresh, and clone resources needed. type ResourceProvider interface { - Update(deps *Deps) error - Clone(deps *Deps) error + NewResource(dst *Deps) error + CloneResource(dst, src *Deps) error } -func (d *Deps) Tmpl() tpl.TemplateHandler { - return d.tmpl -} - -func (d *Deps) TextTmpl() tpl.TemplateParseFinder { - return d.textTmpl -} - -func (d *Deps) SetTmpl(tmpl tpl.TemplateHandler) { - d.tmpl = tmpl -} - -func (d *Deps) SetTextTmpl(tmpl tpl.TemplateParseFinder) { - d.textTmpl = tmpl -} - -// LoadResources loads translations and templates. -func (d *Deps) LoadResources() error { - // Note that the translations need to be loaded before the templates. - if err := d.translationProvider.Update(d); err != nil { - return errors.Wrap(err, "loading translations") +func (d *Deps) Close() error { + if d.isClosed { + return nil } + d.isClosed = true - if err := d.templateProvider.Update(d); err != nil { - return errors.Wrap(err, "loading templates") + if d.MemCache != nil { + d.MemCache.Stop() } - - return nil -} - -// New initializes a Dep struct. -// Defaults are set for nil values, -// but TemplateProvider, TranslationProvider and Language are always required. -func New(cfg DepsCfg) (*Deps, error) { - var ( - logger = cfg.Logger - fs = cfg.Fs - ) - - if cfg.TemplateProvider == nil { - panic("Must have a TemplateProvider") + if d.WasmDispatchers != nil { + d.WasmDispatchers.Close() } - - if cfg.TranslationProvider == nil { - panic("Must have a TranslationProvider") - } - - if cfg.Language == nil { - panic("Must have a Language") - } - - if logger == nil { - logger = loggers.NewErrorLogger() - } - - if fs == nil { - // Default to the production file system. - fs = hugofs.NewDefault(cfg.Language) - } - - if cfg.MediaTypes == nil { - cfg.MediaTypes = media.DefaultTypes - } - - if cfg.OutputFormats == nil { - cfg.OutputFormats = output.DefaultFormats - } - - ps, err := helpers.NewPathSpec(fs, cfg.Language, logger) - - if err != nil { - return nil, errors.Wrap(err, "create PathSpec") - } - - fileCaches, err := filecache.NewCaches(ps) - if err != nil { - return nil, errors.WithMessage(err, "failed to create file caches from configuration") - } - - resourceSpec, err := resources.NewSpec(ps, fileCaches, logger, cfg.OutputFormats, cfg.MediaTypes) - if err != nil { - return nil, err - } - - contentSpec, err := helpers.NewContentSpec(cfg.Language, logger, ps.BaseFs.Content.Fs) - if err != nil { - return nil, err - } - - sp := source.NewSourceSpec(ps, fs.Source) - - timeoutms := cfg.Language.GetInt("timeout") - if timeoutms <= 0 { - timeoutms = 3000 - } - - distinctErrorLogger := helpers.NewDistinctLogger(logger.ERROR) - distinctWarnLogger := helpers.NewDistinctLogger(logger.WARN) - - d := &Deps{ - Fs: fs, - Log: logger, - DistinctErrorLog: distinctErrorLogger, - DistinctWarningLog: distinctWarnLogger, - templateProvider: cfg.TemplateProvider, - translationProvider: cfg.TranslationProvider, - WithTemplate: cfg.WithTemplate, - OverloadedTemplateFuncs: cfg.OverloadedTemplateFuncs, - PathSpec: ps, - ContentSpec: contentSpec, - SourceSpec: sp, - ResourceSpec: resourceSpec, - Cfg: cfg.Language, - Language: cfg.Language, - Site: cfg.Site, - FileCaches: fileCaches, - BuildStartListeners: &Listeners{}, - BuildFlags: &BuildFlags{}, - Timeout: time.Duration(timeoutms) * time.Millisecond, - globalErrHandler: &globalErrHandler{}, - } - - if cfg.Cfg.GetBool("templateMetrics") { - d.Metrics = metrics.NewProvider(cfg.Cfg.GetBool("templateMetricsHints")) - } - - return d, nil -} - -// ForLanguage creates a copy of the Deps with the language dependent -// parts switched out. -func (d Deps) ForLanguage(cfg DepsCfg, onCreated func(d *Deps) error) (*Deps, error) { - l := cfg.Language - var err error - - d.PathSpec, err = helpers.NewPathSpecWithBaseBaseFsProvided(d.Fs, l, d.Log, d.BaseFs) - if err != nil { - return nil, err - } - - d.ContentSpec, err = helpers.NewContentSpec(l, d.Log, d.BaseFs.Content.Fs) - if err != nil { - return nil, err - } - - d.Site = cfg.Site - - // The resource cache is global so reuse. - // TODO(bep) clean up these inits. - resourceCache := d.ResourceSpec.ResourceCache - d.ResourceSpec, err = resources.NewSpec(d.PathSpec, d.ResourceSpec.FileCaches, d.Log, cfg.OutputFormats, cfg.MediaTypes) - if err != nil { - return nil, err - } - d.ResourceSpec.ResourceCache = resourceCache - - d.Cfg = l - d.Language = l - - if onCreated != nil { - if err = onCreated(&d); err != nil { - return nil, err - } - } - - if err := d.translationProvider.Clone(&d); err != nil { - return nil, err - } - - if err := d.templateProvider.Clone(&d); err != nil { - return nil, err - } - - d.BuildStartListeners = &Listeners{} - - return &d, nil - + return d.BuildClosers.Close() } // DepsCfg contains configuration options that can be used to configure Hugo // on a global level, i.e. logging etc. // Nil values will be given default values. type DepsCfg struct { + // The logger to use. Only set in some tests. + // TODO(bep) get rid of this. + TestLogger loggers.Logger - // The Logger to use. - Logger *loggers.Logger + // The logging level to use. + LogLevel logg.Level + + // Logging output. + StdErr io.Writer + + // The console output. + StdOut io.Writer // The file systems to use Fs *hugofs.Fs - // The language to use. - Language *langs.Language - // The Site in use Site page.Site - // The configuration to use. - Cfg config.Provider - - // The media types configured. - MediaTypes media.Types - - // The output formats configured. - OutputFormats output.Formats + Configs *allconfig.Configs // Template handling. TemplateProvider ResourceProvider - WithTemplate func(templ tpl.TemplateManager) error - // Used in tests - OverloadedTemplateFuncs map[string]interface{} // i18n handling. TranslationProvider ResourceProvider - // Whether we are in running (server) mode - Running bool + // ChangesFromBuild for changes passed back to the server/watch process. + ChangesFromBuild chan []identity.Identity } -// BuildFlags are flags that may be turned on during a build. -type BuildFlags struct { - HasLateTemplate atomic.Bool +// BuildState are state used during a build. +type BuildState struct { + counter uint64 + + mu sync.Mutex // protects state below. + + OnSignalRebuild func(ids ...identity.Identity) + + // A set of filenames in /public that + // contains a post-processing prefix. + filenamesWithPostPrefix map[string]bool + + DeferredExecutions *DeferredExecutions + + // Deferred executions grouped by rendering context. + DeferredExecutionsGroupedByRenderingContext map[tpl.RenderingContext]*DeferredExecutions } -func NewBuildFlags() BuildFlags { - return BuildFlags{ - //HasLateTemplate: atomic.NewBool(false), +// Misc counters. +type Counters struct { + // Counter for the math.Counter function. + MathCounter atomic.Uint64 +} + +type DeferredExecutions struct { + // A set of filenames in /public that + // contains a post-processing prefix. + FilenamesWithPostPrefix *maps.Cache[string, bool] + + // Maps a placeholder to a deferred execution. + Executions *maps.Cache[string, *tpl.DeferredExecution] +} + +var _ identity.SignalRebuilder = (*BuildState)(nil) + +// StartStageRender will be called before a stage is rendered. +func (b *BuildState) StartStageRender(stage tpl.RenderingContext) { +} + +// StopStageRender will be called after a stage is rendered. +func (b *BuildState) StopStageRender(stage tpl.RenderingContext) { + b.DeferredExecutionsGroupedByRenderingContext[stage] = b.DeferredExecutions + b.DeferredExecutions = &DeferredExecutions{ + Executions: maps.NewCache[string, *tpl.DeferredExecution](), + FilenamesWithPostPrefix: maps.NewCache[string, bool](), } } + +func (b *BuildState) SignalRebuild(ids ...identity.Identity) { + b.OnSignalRebuild(ids...) +} + +func (b *BuildState) AddFilenameWithPostPrefix(filename string) { + b.mu.Lock() + defer b.mu.Unlock() + if b.filenamesWithPostPrefix == nil { + b.filenamesWithPostPrefix = make(map[string]bool) + } + b.filenamesWithPostPrefix[filename] = true +} + +func (b *BuildState) GetFilenamesWithPostPrefix() []string { + b.mu.Lock() + defer b.mu.Unlock() + var filenames []string + for filename := range b.filenamesWithPostPrefix { + filenames = append(filenames, filename) + } + sort.Strings(filenames) + return filenames +} + +func (b *BuildState) Incr() int { + return int(atomic.AddUint64(&b.counter, uint64(1))) +} diff --git a/deps/deps_test.go b/deps/deps_test.go index e2dca0ecc..e92ed2327 100644 --- a/deps/deps_test.go +++ b/deps/deps_test.go @@ -11,18 +11,21 @@ // See the License for the specific language governing permissions and // limitations under the License. -package deps +package deps_test import ( "testing" qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/deps" ) func TestBuildFlags(t *testing.T) { c := qt.New(t) - var bf BuildFlags - c.Assert(bf.HasLateTemplate.Load(), qt.Equals, false) - bf.HasLateTemplate.Store(true) - c.Assert(bf.HasLateTemplate.Load(), qt.Equals, true) + var bf deps.BuildState + bf.Incr() + bf.Incr() + bf.Incr() + + c.Assert(bf.Incr(), qt.Equals, 4) } diff --git a/docs/.codespellrc b/docs/.codespellrc new file mode 100644 index 000000000..564fc77c0 --- /dev/null +++ b/docs/.codespellrc @@ -0,0 +1,13 @@ +# Config file for codespell. +# https://github.com/codespell-project/codespell#using-a-config-file + +[codespell] + +# Comma separated list of dirs to be skipped. +skip = _vendor,.cspell.json,chroma.css,chroma_dark.css + +# Comma separated list of words to be ignored. Words must be lowercased. +ignore-words-list = abl,edn,te,ue,trys,januar,womens,crossreferences + +# Check file names as well. +check-filenames = true diff --git a/docs/.cspell.json b/docs/.cspell.json new file mode 100644 index 000000000..bf61489da --- /dev/null +++ b/docs/.cspell.json @@ -0,0 +1,185 @@ +{ + "version": "0.2", + "allowCompoundWords": true, + "files": [ + "**/*.md" + ], + "flagWords": [ + "alot", + "hte", + "langauge", + "reccommend", + "seperate", + "teh" + ], + "ignorePaths": [ + "**/emojis.md", + "**/commands/*", + "**/showcase/*", + "**/tools/*" + ], + "ignoreRegExpList": [ + "# cspell: ignore fenced code blocks", + "^(\\s*`{3,}).*[\\s\\S]*?^\\1$", + "# cspell: ignore words joined with dot", + "\\w+\\.\\w+", + "# cspell: ignore strings within backticks", + "`.+`", + "# cspell: ignore strings within double quotes", + "\".+\"", + "# cspell: ignore strings within brackets", + "\\[.+\\]", + "# cspell: ignore strings within parentheses", + "\\(.+\\)", + "# cspell: ignore words that begin with a slash", + "/\\w+", + "# cspell: ignore everything within action delimiters", + "\\{\\{.+\\}\\}", + "# cspell: ignore everything after a right arrow", + "\\s+→\\s+.+" + ], + "language": "en", + "words": [ + "composability", + "configurators", + "defang", + "deindent", + "downscale", + "downscaling", + "exif", + "geolocalized", + "grayscale", + "marshal", + "marshaling", + "multihost", + "multiplatfom", + "performantly", + "preconfigured", + "prerendering", + "redirection", + "redirections", + "subexpression", + "suppressible", + "synchronisation", + "templating", + "transpile", + "unmarshal", + "unmarshaling", + "unmarshals", + "# ----------------------------------------------------------------------", + "# cspell: ignore hugo terminology", + "# ----------------------------------------------------------------------", + "alignx", + "attrlink", + "canonify", + "codeowners", + "dynacache", + "eturl", + "getenv", + "gohugo", + "gohugoio", + "keyvals", + "leftdelim", + "linkify", + "numworkermultiplier", + "rightdelim", + "shortcode", + "stringifier", + "struct", + "toclevels", + "unmarshal", + "unpublishdate", + "zgotmplz", + "# ----------------------------------------------------------------------", + "# cspell: ignore foreign language words", + "# ----------------------------------------------------------------------", + "bezpieczeństwo", + "blatt", + "buch", + "descripción", + "dokumentation", + "erklärungen", + "libros", + "mercredi", + "miesiąc", + "miesiąc", + "miesiąca", + "miesiące", + "miesięcy", + "misérables", + "mittwoch", + "muchos", + "novembre", + "otro", + "pocos", + "produkte", + "projekt", + "prywatność", + "referenz", + "régime", + "# ----------------------------------------------------------------------", + "# cspell: ignore names", + "# ----------------------------------------------------------------------", + "Atishay", + "Cosette", + "Eliott", + "Furet", + "Gregor", + "Jaco", + "Lanczos", + "Ninke", + "Noll", + "Pastorius", + "Samsa", + "Stucki", + "Thénardier", + "WASI", + "# ----------------------------------------------------------------------", + "# cspell: ignore operating systems and software packages", + "# ----------------------------------------------------------------------", + "asciidoctor", + "brotli", + "cifs", + "corejs", + "disqus", + "docutils", + "dpkg", + "doas", + "eopkg", + "gitee", + "goldmark", + "katex", + "kubuntu", + "lubuntu", + "mathjax", + "nosql", + "pandoc", + "pkgin", + "rclone", + "xubuntu", + "# ----------------------------------------------------------------------", + "# cspell: ignore miscellaneous", + "# ----------------------------------------------------------------------", + "achristie", + "ccpa", + "cpra", + "ddmaurier", + "dring", + "fleqn", + "inor", + "jausten", + "jdoe", + "jsmith", + "leqno", + "milli", + "monokai", + "mysanityprojectid", + "rgba", + "rsmith", + "tdewolff", + "tjones", + "vcard", + "wcag", + "xfeff" + ] +} diff --git a/docs/.github/ISSUE_TEMPLATE/config.yml b/docs/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..3ba13e0ce --- /dev/null +++ b/docs/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/docs/.github/ISSUE_TEMPLATE/default.md b/docs/.github/ISSUE_TEMPLATE/default.md new file mode 100644 index 000000000..ada35b3a5 --- /dev/null +++ b/docs/.github/ISSUE_TEMPLATE/default.md @@ -0,0 +1,6 @@ +--- +name: Default +about: This is the default issue template. +labels: + - NeedsTriage +--- diff --git a/docs/.github/SUPPORT.md b/docs/.github/SUPPORT.md new file mode 100644 index 000000000..96a4400c3 --- /dev/null +++ b/docs/.github/SUPPORT.md @@ -0,0 +1,3 @@ +### Asking support questions + +We have an active [discussion forum](https://discourse.gohugo.io) where users and developers can ask questions. Please don't use the GitHub issue tracker to ask questions. diff --git a/docs/.github/stale.yml b/docs/.github/stale.yml index 389205294..1e72eb329 100644 --- a/docs/.github/stale.yml +++ b/docs/.github/stale.yml @@ -17,6 +17,6 @@ markComment: > If you still think this is important, please tell us why. This issue will automatically be closed in the near future if no further activity occurs. Thank you for all your contributions. - + # Comment to post when closing a stale issue. Set to `false` to disable closeComment: false diff --git a/docs/.github/workflows/calibreapp-image-actions.yml b/docs/.github/workflows/calibreapp-image-actions.yml deleted file mode 100644 index 3d95fd0c8..000000000 --- a/docs/.github/workflows/calibreapp-image-actions.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: Compress images -on: pull_request -jobs: - build: - name: calibreapp/image-actions - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@master - - name: calibreapp/image-actions - uses: docker://calibreapp/github-image-actions - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/docs/.github/workflows/codeql-analysis.yml b/docs/.github/workflows/codeql-analysis.yml new file mode 100644 index 000000000..86441b845 --- /dev/null +++ b/docs/.github/workflows/codeql-analysis.yml @@ -0,0 +1,26 @@ +name: "CodeQL" + +on: + schedule: + - cron: "0 0 1 * *" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: "javascript" + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/docs/.github/workflows/spellcheck.yml b/docs/.github/workflows/spellcheck.yml new file mode 100644 index 000000000..e01ab1764 --- /dev/null +++ b/docs/.github/workflows/spellcheck.yml @@ -0,0 +1,27 @@ +name: "Check spelling" +on: + push: + pull_request: + branches-ignore: + - "dependabot/**" + +permissions: + contents: read + +jobs: + spellcheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: streetsidesoftware/cspell-action@v5 + with: + check_dot_files: false + files: content/**/*.md + incremental_files_only: true + inline: warning + strict: false + - uses: codespell-project/actions-codespell@v2 + with: + check_filenames: true + check_hidden: true + # by default, codespell uses configuration from the .codespellrc diff --git a/docs/.github/workflows/super-linter.yml b/docs/.github/workflows/super-linter.yml new file mode 100644 index 000000000..d8e408ee2 --- /dev/null +++ b/docs/.github/workflows/super-linter.yml @@ -0,0 +1,41 @@ +name: Super Linter + +on: + workflow_dispatch: + +permissions: + contents: read # to fetch code (actions/checkout) + +jobs: + build: + permissions: + contents: read # to fetch code (actions/checkout) + statuses: write # to mark status of each linter run (github/super-linter/slim) + + name: Lint Code Base + runs-on: ubuntu-latest + if: ${{ github.actor != 'dependabot[bot]' }} + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Lint Code Base + uses: super-linter/super-linter/slim@v6 + env: + DEFAULT_BRANCH: master + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + IGNORE_GITIGNORED_FILES: true + LINTER_RULES_PATH: / + LOG_LEVEL: NOTICE + MARKDOWN_CONFIG_FILE: .markdownlint.yaml + SUPPRESS_POSSUM: true + VALIDATE_CSS: false + VALIDATE_EDITORCONFIG: false + VALIDATE_GITLEAKS: false + VALIDATE_HTML: false + VALIDATE_JAVASCRIPT_STANDARD: false + VALIDATE_JSCPD: false + VALIDATE_NATURAL_LANGUAGE: false + VALIDATE_SHELL_SHFMT: false + VALIDATE_XML: false diff --git a/docs/.gitignore b/docs/.gitignore index b203a37cd..5208c5c3a 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -1,5 +1,12 @@ -/.idea -/public -nohup.out .DS_Store -trace.out \ No newline at end of file +.hugo_build.lock +/.idea +/.vscode +/dist +/public +/resources +hugo_stats.json +node_modules/ +nohup.out +package-lock.json +trace.out diff --git a/docs/.markdownlint.yaml b/docs/.markdownlint.yaml new file mode 100644 index 000000000..dbb5b2ee8 --- /dev/null +++ b/docs/.markdownlint.yaml @@ -0,0 +1,27 @@ +# https://github.com/DavidAnson/markdownlint/blob/main/doc/Rules.md + +MD001: false +MD002: false +MD003: false +MD004: false +MD007: false +MD012: + maximum: 2 +MD013: false +MD014: false +MD022: false +MD024: false +MD031: false +MD032: false +MD033: false +MD034: false +MD036: false +MD037: false +MD038: false +MD041: false +MD046: false +MD049: false +MD050: false +MD051: false +MD053: false +MD055: false diff --git a/docs/.markdownlintignore b/docs/.markdownlintignore new file mode 100644 index 000000000..4ac45b395 --- /dev/null +++ b/docs/.markdownlintignore @@ -0,0 +1,6 @@ +**/commands/** +**/functions/** +**/news/** +**/showcase/** +**/zh/** +**/license.md diff --git a/docs/.prettierignore b/docs/.prettierignore new file mode 100644 index 000000000..f24bbcef0 --- /dev/null +++ b/docs/.prettierignore @@ -0,0 +1,18 @@ +# Ignore all SVG icons. +**/icons.html + +# These are whitespace sensitive. +layouts/_default/_markup/render-code* +layouts/_default/_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 diff --git a/docs/.prettierrc b/docs/.prettierrc new file mode 100644 index 000000000..395ae39af --- /dev/null +++ b/docs/.prettierrc @@ -0,0 +1,24 @@ +{ + "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": { + "useTabs": true, + "printWidth": 120, + "singleQuote": true + } + } + ] +} diff --git a/docs/.textlintignore b/docs/.textlintignore new file mode 100644 index 000000000..97a18e37c --- /dev/null +++ b/docs/.textlintignore @@ -0,0 +1,3 @@ +**/news/** +**/showcase/** +**/zh/** \ No newline at end of file diff --git a/docs/.vscode/extensions.json b/docs/.vscode/extensions.json new file mode 100644 index 000000000..76c6afe3f --- /dev/null +++ b/docs/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "DavidAnson.vscode-markdownlint", + "EditorConfig.EditorConfig", + "streetsidesoftware.code-spell-checker" + ] +} diff --git a/docs/LICENSE.md b/docs/LICENSE.md index b62a9b5ff..d4facbf8a 100644 --- a/docs/LICENSE.md +++ b/docs/LICENSE.md @@ -1,194 +1,3 @@ -Apache License -============== +See [content/LICENSE.md](content/LICENSE.md) for the license of the content of this repository. -_Version 2.0, January 2004_ -_<>_ - -### Terms and Conditions for use, reproduction, and distribution - -#### 1. Definitions - -“License” shall mean the terms and conditions for use, reproduction, and -distribution as defined by Sections 1 through 9 of this document. - -“Licensor” shall mean the copyright owner or entity authorized by the copyright -owner that is granting the License. - -“Legal Entity” shall mean the union of the acting entity and all other entities -that control, are controlled by, or are under common control with that entity. -For the purposes of this definition, “control” means **(i)** the power, direct or -indirect, to cause the direction or management of such entity, whether by -contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the -outstanding shares, or **(iii)** beneficial ownership of such entity. - -“You” (or “Your”) shall mean an individual or Legal Entity exercising -permissions granted by this License. - -“Source” form shall mean the preferred form for making modifications, including -but not limited to software source code, documentation source, and configuration -files. - -“Object” form shall mean any form resulting from mechanical transformation or -translation of a Source form, including but not limited to compiled object code, -generated documentation, and conversions to other media types. - -“Work” shall mean the work of authorship, whether in Source or Object form, made -available under the License, as indicated by a copyright notice that is included -in or attached to the work (an example is provided in the Appendix below). - -“Derivative Works” shall mean any work, whether in Source or Object form, that -is based on (or derived from) the Work and for which the editorial revisions, -annotations, elaborations, or other modifications represent, as a whole, an -original work of authorship. For the purposes of this License, Derivative Works -shall not include works that remain separable from, or merely link (or bind by -name) to the interfaces of, the Work and Derivative Works thereof. - -“Contribution” shall mean any work of authorship, including the original version -of the Work and any modifications or additions to that Work or Derivative Works -thereof, that is intentionally submitted to Licensor for inclusion in the Work -by the copyright owner or by an individual or Legal Entity authorized to submit -on behalf of the copyright owner. For the purposes of this definition, -“submitted” means any form of electronic, verbal, or written communication sent -to the Licensor or its representatives, including but not limited to -communication on electronic mailing lists, source code control systems, and -issue tracking systems that are managed by, or on behalf of, the Licensor for -the purpose of discussing and improving the Work, but excluding communication -that is conspicuously marked or otherwise designated in writing by the copyright -owner as “Not a Contribution.” - -“Contributor” shall mean Licensor and any individual or Legal Entity on behalf -of whom a Contribution has been received by Licensor and subsequently -incorporated within the Work. - -#### 2. Grant of Copyright License - -Subject to the terms and conditions of this License, each Contributor hereby -grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, -irrevocable copyright license to reproduce, prepare Derivative Works of, -publicly display, publicly perform, sublicense, and distribute the Work and such -Derivative Works in Source or Object form. - -#### 3. Grant of Patent License - -Subject to the terms and conditions of this License, each Contributor hereby -grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, -irrevocable (except as stated in this section) patent license to make, have -made, use, offer to sell, sell, import, and otherwise transfer the Work, where -such license applies only to those patent claims licensable by such Contributor -that are necessarily infringed by their Contribution(s) alone or by combination -of their Contribution(s) with the Work to which such Contribution(s) was -submitted. If You institute patent litigation against any entity (including a -cross-claim or counterclaim in a lawsuit) alleging that the Work or a -Contribution incorporated within the Work constitutes direct or contributory -patent infringement, then any patent licenses granted to You under this License -for that Work shall terminate as of the date such litigation is filed. - -#### 4. Redistribution - -You may reproduce and distribute copies of the Work or Derivative Works thereof -in any medium, with or without modifications, and in Source or Object form, -provided that You meet the following conditions: - -* **(a)** You must give any other recipients of the Work or Derivative Works a copy of -this License; and -* **(b)** You must cause any modified files to carry prominent notices stating that You -changed the files; and -* **(c)** You must retain, in the Source form of any Derivative Works that You distribute, -all copyright, patent, trademark, and attribution notices from the Source form -of the Work, excluding those notices that do not pertain to any part of the -Derivative Works; and -* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any -Derivative Works that You distribute must include a readable copy of the -attribution notices contained within such NOTICE file, excluding those notices -that do not pertain to any part of the Derivative Works, in at least one of the -following places: within a NOTICE text file distributed as part of the -Derivative Works; within the Source form or documentation, if provided along -with the Derivative Works; or, within a display generated by the Derivative -Works, if and wherever such third-party notices normally appear. The contents of -the NOTICE file are for informational purposes only and do not modify the -License. You may add Your own attribution notices within Derivative Works that -You distribute, alongside or as an addendum to the NOTICE text from the Work, -provided that such additional attribution notices cannot be construed as -modifying the License. - -You may add Your own copyright statement to Your modifications and may provide -additional or different license terms and conditions for use, reproduction, or -distribution of Your modifications, or for any such Derivative Works as a whole, -provided Your use, reproduction, and distribution of the Work otherwise complies -with the conditions stated in this License. - -#### 5. Submission of Contributions - -Unless You explicitly state otherwise, any Contribution intentionally submitted -for inclusion in the Work by You to the Licensor shall be under the terms and -conditions of this License, without any additional terms or conditions. -Notwithstanding the above, nothing herein shall supersede or modify the terms of -any separate license agreement you may have executed with Licensor regarding -such Contributions. - -#### 6. Trademarks - -This License does not grant permission to use the trade names, trademarks, -service marks, or product names of the Licensor, except as required for -reasonable and customary use in describing the origin of the Work and -reproducing the content of the NOTICE file. - -#### 7. Disclaimer of Warranty - -Unless required by applicable law or agreed to in writing, Licensor provides the -Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, -including, without limitation, any warranties or conditions of TITLE, -NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are -solely responsible for determining the appropriateness of using or -redistributing the Work and assume any risks associated with Your exercise of -permissions under this License. - -#### 8. Limitation of Liability - -In no event and under no legal theory, whether in tort (including negligence), -contract, or otherwise, unless required by applicable law (such as deliberate -and grossly negligent acts) or agreed to in writing, shall any Contributor be -liable to You for damages, including any direct, indirect, special, incidental, -or consequential damages of any character arising as a result of this License or -out of the use or inability to use the Work (including but not limited to -damages for loss of goodwill, work stoppage, computer failure or malfunction, or -any and all other commercial damages or losses), even if such Contributor has -been advised of the possibility of such damages. - -#### 9. Accepting Warranty or Additional Liability - -While redistributing the Work or Derivative Works thereof, You may choose to -offer, and charge a fee for, acceptance of support, warranty, indemnity, or -other liability obligations and/or rights consistent with this License. However, -in accepting such obligations, You may act only on Your own behalf and on Your -sole responsibility, not on behalf of any other Contributor, and only if You -agree to indemnify, defend, and hold each Contributor harmless for any liability -incurred by, or claims asserted against, such Contributor by reason of your -accepting any such warranty or additional liability. - -_END OF TERMS AND CONDITIONS_ - -### APPENDIX: How to apply the Apache License to your work - -To apply the Apache License to your work, attach the following boilerplate -notice, with the fields enclosed by brackets `[]` replaced with your own -identifying information. (Don't include the brackets!) The text should be -enclosed in the appropriate comment syntax for the file format. We also -recommend that a file or class name and description of purpose be included on -the same “printed page” as the copyright notice for easier identification within -third-party archives. - - Copyright [yyyy] [name of copyright owner] - - 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. +The theme (layouts, CSS, JavaScript etc.) of this repository has no open source license. It is custom made for the Hugo sites and is not meant for reuse. diff --git a/docs/README.md b/docs/README.md index a2c767b7b..58d0e748c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,48 +1,27 @@ +Hugo + +A fast and flexible static site generator built with love by [bep], [spf13], and [friends] in [Go]. + +--- + [![Netlify Status](https://api.netlify.com/api/v1/badges/e0dbbfc7-34f1-4393-a679-c16e80162705/deploy-status)](https://app.netlify.com/sites/gohugoio/deploys) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://gohugo.io/contribute/documentation/) -# Hugo Docs +This is the repository for the [Hugo](https://github.com/gohugoio/hugo) documentation site. -Documentation site for [Hugo](https://github.com/gohugoio/hugo), the very fast and flexible static site generator built with love in Go. +Please see the [contributing] section for guidelines, examples, and process. -## Contributing +[bep]: https://github.com/bep +[spf13]: https://github.com/spf13 +[friends]: https://github.com/gohugoio/hugo/graphs/contributors +[go]: https://go.dev/ +[contributing]: https://gohugo.io/contribute/documentation -We welcome contributions to Hugo of any kind including documentation, suggestions, bug reports, pull requests etc. Also check out our [contribution guide](https://gohugo.io/contribute/documentation/). We would love to hear from you. +# Install -Note that this repository contains solely the documentation for Hugo. For contributions that aren't documentation-related please refer to the [hugo](https://github.com/gohugoio/hugo) repository. - -*Pull requests shall **only** contain changes to the actual documentation. However, changes on the code base of Hugo **and** the documentation shall be a single, atomic pull request in the [hugo](https://github.com/gohugoio/hugo) repository.* - -Spelling fixes are most welcomed, and if you want to contribute longer sections to the documentation, it would be great if you had the following criteria in mind when writing: - -* Short is good. People go to the library to read novels. If there is more than one way to _do a thing_ in Hugo, describe the current _best practice_ (avoid "… but you can also do …" and "… in older versions of Hugo you had to …". -* For example, try to find short snippets that teaches people about the concept. If the example is also useful as-is (copy and paste), then great. Don't list long and similar examples just so people can use them on their sites. -* Hugo has users from all over the world, so easy to understand and [simple English](https://simple.wikipedia.org/wiki/Basic_English) is good. - -## Branches - -* The `master` branch is where the site is automatically built from, and is the place to put changes relevant to the current Hugo version. -* The `next` branch is where we store changes that are related to the next Hugo release. This can be previewed here: https://next--gohugoio.netlify.com/ - -## Build - -To view the documentation site locally, you need to clone this repository: - -```bash -git clone https://github.com/gohugoio/hugoDocs.git +```sh +npm i +hugo server ``` -Also note that the documentation version for a given version of Hugo can also be found in the `/docs` sub-folder of the [Hugo source repository](https://github.com/gohugoio/hugo). - -Then to view the docs in your browser, run Hugo and open up the link: - -```bash -▶ hugo server - -Started building sites ... -. -. -Serving pages from memory -Web Server is available at http://localhost:1313/ (bind address 127.0.0.1) -Press Ctrl+C to stop -``` +**Note:** We're working on removing the need to run `npm i` for local development. Stay tuned. diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_algolia.css b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_algolia.css deleted file mode 100644 index 0122f9758..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_algolia.css +++ /dev/null @@ -1,11 +0,0 @@ -.searchbox{display:inline-block;position:relative;width:200px;height:32px!important;white-space:nowrap;box-sizing:border-box;visibility:visible!important}.searchbox .algolia-autocomplete{display:block;width:100%;height:100%}.searchbox__wrapper{width:100%;height:100%;z-index:999;position:relative}.searchbox__input{display:inline-block;box-sizing:border-box;transition:box-shadow .4s ease,background .4s ease;border:0;border-radius:16px;box-shadow:inset 0 0 0 1px #ccc;background:#fff!important;padding:0 26px 0 32px;width:100%;height:100%;vertical-align:middle;white-space:normal;font-size:12px;-webkit-appearance:none;-moz-appearance:none;appearance:none}.searchbox__input::-webkit-search-cancel-button,.searchbox__input::-webkit-search-decoration,.searchbox__input::-webkit-search-results-button,.searchbox__input::-webkit-search-results-decoration{display:none}.searchbox__input:hover{box-shadow:inset 0 0 0 1px #b3b3b3}.searchbox__input:active,.searchbox__input:focus{outline:0;box-shadow:inset 0 0 0 1px #aaa;background:#fff}.searchbox__input::-webkit-input-placeholder{color:#aaa}.searchbox__input:-ms-input-placeholder{color:#aaa}.searchbox__input::-ms-input-placeholder{color:#aaa}.searchbox__input::placeholder{color:#aaa}.searchbox__submit{position:absolute;top:0;margin:0;border:0;border-radius:16px 0 0 16px;background-color:rgba(69,142,225,0);padding:0;width:32px;height:100%;vertical-align:middle;text-align:center;font-size:inherit;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;right:inherit;left:0}.searchbox__submit:before{display:inline-block;margin-right:-4px;height:100%;vertical-align:middle;content:""}.searchbox__submit:active,.searchbox__submit:hover{cursor:pointer}.searchbox__submit:focus{outline:0}.searchbox__submit svg{width:14px;height:14px;vertical-align:middle;fill:#6d7e96}.searchbox__reset{display:block;position:absolute;top:8px;right:8px;margin:0;border:0;background:none;cursor:pointer;padding:0;font-size:inherit;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;fill:rgba(0,0,0,.5)}.searchbox__reset.hide{display:none}.searchbox__reset:focus{outline:0}.searchbox__reset svg{display:block;margin:4px;width:8px;height:8px}.searchbox__input:valid~.searchbox__reset{display:block;-webkit-animation-name:sbx-reset-in;animation-name:sbx-reset-in;-webkit-animation-duration:.15s;animation-duration:.15s}@-webkit-keyframes sbx-reset-in{0%{-webkit-transform:translate3d(-20%,0,0);transform:translate3d(-20%,0,0);opacity:0}to{-webkit-transform:none;transform:none;opacity:1}}@keyframes sbx-reset-in{0%{-webkit-transform:translate3d(-20%,0,0);transform:translate3d(-20%,0,0);opacity:0}to{-webkit-transform:none;transform:none;opacity:1}}.algolia-autocomplete.algolia-autocomplete-right .ds-dropdown-menu{right:0!important;left:inherit!important}.algolia-autocomplete.algolia-autocomplete-right .ds-dropdown-menu:before{right:48px}.algolia-autocomplete.algolia-autocomplete-left .ds-dropdown-menu{left:0!important;right:inherit!important}.algolia-autocomplete.algolia-autocomplete-left .ds-dropdown-menu:before{left:48px}.algolia-autocomplete .ds-dropdown-menu{top:-6px;border-radius:4px;margin:6px 0 0;padding:0;text-align:left;height:auto;position:relative;background:transparent;border:none;z-index:999;max-width:600px;min-width:500px;box-shadow:0 1px 0 0 rgba(0,0,0,.2),0 2px 3px 0 rgba(0,0,0,.1)}.algolia-autocomplete .ds-dropdown-menu:before{display:block;position:absolute;content:"";width:14px;height:14px;background:#fff;z-index:1000;top:-7px;border-top:1px solid #d9d9d9;border-right:1px solid #d9d9d9;-webkit-transform:rotate(-45deg);transform:rotate(-45deg);border-radius:2px}.algolia-autocomplete .ds-dropdown-menu .ds-suggestions{position:relative;z-index:1000;margin-top:8px}.algolia-autocomplete .ds-dropdown-menu .ds-suggestions a:hover{text-decoration:none}.algolia-autocomplete .ds-dropdown-menu .ds-suggestion{cursor:pointer}.algolia-autocomplete .ds-dropdown-menu .ds-suggestion.ds-cursor .algolia-docsearch-suggestion.suggestion-layout-simple,.algolia-autocomplete .ds-dropdown-menu .ds-suggestion.ds-cursor .algolia-docsearch-suggestion:not(.suggestion-layout-simple) .algolia-docsearch-suggestion--content{background-color:rgba(69,142,225,.05)}.algolia-autocomplete .ds-dropdown-menu [class^=ds-dataset-]{position:relative;border:1px solid #d9d9d9;background:#fff;border-radius:4px;overflow:auto;padding:0 8px 8px}.algolia-autocomplete .ds-dropdown-menu *{box-sizing:border-box}.algolia-autocomplete .algolia-docsearch-suggestion{display:block;position:relative;padding:0 8px;background:#fff;color:#02060c;overflow:hidden}.algolia-autocomplete .algolia-docsearch-suggestion--highlight{color:#174d8c;background:rgba(143,187,237,.1);padding:.1em .05em}.algolia-autocomplete .algolia-docsearch-suggestion--category-header .algolia-docsearch-suggestion--category-header-lvl0 .algolia-docsearch-suggestion--highlight,.algolia-autocomplete .algolia-docsearch-suggestion--category-header .algolia-docsearch-suggestion--category-header-lvl1 .algolia-docsearch-suggestion--highlight,.algolia-autocomplete .algolia-docsearch-suggestion--text .algolia-docsearch-suggestion--highlight{padding:0 0 1px;background:inherit;box-shadow:inset 0 -2px 0 0 rgba(69,142,225,.8);color:inherit}.algolia-autocomplete .algolia-docsearch-suggestion--content{display:block;float:right;width:70%;position:relative;padding:5.33333px 0 5.33333px 10.66667px;cursor:pointer}.algolia-autocomplete .algolia-docsearch-suggestion--content:before{content:"";position:absolute;display:block;top:0;height:100%;width:1px;background:#ddd;left:-1px}.algolia-autocomplete .algolia-docsearch-suggestion--category-header{position:relative;border-bottom:1px solid #ddd;display:none;margin-top:8px;padding:4px 0;font-size:1em;color:#33363d}.algolia-autocomplete .algolia-docsearch-suggestion--wrapper{width:100%;float:left;padding:8px 0 0}.algolia-autocomplete .algolia-docsearch-suggestion--subcategory-column{float:left;width:30%;text-align:right;position:relative;padding:5.33333px 10.66667px;color:#a4a7ae;font-size:.9em;word-wrap:break-word}.algolia-autocomplete .algolia-docsearch-suggestion--subcategory-column:before{content:"";position:absolute;display:block;top:0;height:100%;width:1px;background:#ddd;right:0}.algolia-autocomplete .algolia-docsearch-suggestion--subcategory-inline{display:none}.algolia-autocomplete .algolia-docsearch-suggestion--title{margin-bottom:4px;color:#02060c;font-size:.9em;font-weight:700}.algolia-autocomplete .algolia-docsearch-suggestion--text{display:block;line-height:1.2em;font-size:.85em;color:#63676d}.algolia-autocomplete .algolia-docsearch-suggestion--no-results{width:100%;padding:8px 0;text-align:center;font-size:1.2em}.algolia-autocomplete .algolia-docsearch-suggestion--no-results:before{display:none}.algolia-autocomplete .algolia-docsearch-suggestion code{padding:1px 5px;font-size:90%;border:none;color:#222;background-color:#ebebeb;border-radius:3px;font-family:Menlo,Monaco,Consolas,Courier New,monospace}.algolia-autocomplete .algolia-docsearch-suggestion code .algolia-docsearch-suggestion--highlight{background:none}.algolia-autocomplete .algolia-docsearch-suggestion.algolia-docsearch-suggestion__main .algolia-docsearch-suggestion--category-header,.algolia-autocomplete .algolia-docsearch-suggestion.algolia-docsearch-suggestion__secondary{display:block}@media (min-width:768px){.algolia-autocomplete .algolia-docsearch-suggestion .algolia-docsearch-suggestion--subcategory-column{display:block}}@media (max-width:768px){.algolia-autocomplete .algolia-docsearch-suggestion .algolia-docsearch-suggestion--subcategory-column{display:inline-block;width:auto;float:left;padding:0;color:#02060c;font-size:.9em;font-weight:700;text-align:left;opacity:.5}.algolia-autocomplete .algolia-docsearch-suggestion .algolia-docsearch-suggestion--subcategory-column:before{display:none}.algolia-autocomplete .algolia-docsearch-suggestion .algolia-docsearch-suggestion--subcategory-column:after{content:"|"}.algolia-autocomplete .algolia-docsearch-suggestion .algolia-docsearch-suggestion--content{display:inline-block;width:auto;text-align:left;float:left;padding:0}.algolia-autocomplete .algolia-docsearch-suggestion .algolia-docsearch-suggestion--content:before{display:none}}.algolia-autocomplete .suggestion-layout-simple.algolia-docsearch-suggestion{border-bottom:1px solid #eee;padding:8px;margin:0}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--content{width:100%;padding:0}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--content:before{display:none}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--category-header{margin:0;padding:0;display:block;width:100%;border:none}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--category-header-lvl0,.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--category-header-lvl1{opacity:.6;font-size:.85em}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--category-header-lvl1:before{background-image:url('data:image/svg+xml;utf8,');content:"";width:10px;height:10px;display:inline-block}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--wrapper{width:100%;float:left;margin:0;padding:0}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--duplicate-content,.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--subcategory-inline{display:none!important}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--title{margin:0;color:#458ee1;font-size:.9em;font-weight:400}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--title:before{content:"#";font-weight:700;color:#458ee1;display:inline-block}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--text{margin:4px 0 0;display:block;line-height:1.4em;padding:5.33333px 8px;background:#f8f8f8;font-size:.85em;opacity:.8}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--text .algolia-docsearch-suggestion--highlight{color:#3f4145;font-weight:700;box-shadow:none}.algolia-autocomplete .algolia-docsearch-footer{width:134px;height:20px;z-index:2000;margin-top:10.66667px;float:right;font-size:0;line-height:0}.algolia-autocomplete .algolia-docsearch-footer--logo{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='168' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cpath d='M78.988.938h16.594a2.968 2.968 0 0 1 2.966 2.966V20.5a2.967 2.967 0 0 1-2.966 2.964H78.988a2.967 2.967 0 0 1-2.966-2.964V3.897A2.961 2.961 0 0 1 78.988.938zm41.937 17.866c-4.386.02-4.386-3.54-4.386-4.106l-.007-13.336 2.675-.424v13.254c0 .322 0 2.358 1.718 2.364v2.248zm-10.846-2.18c.821 0 1.43-.047 1.855-.129v-2.719a6.334 6.334 0 0 0-1.574-.199 5.7 5.7 0 0 0-.897.069 2.699 2.699 0 0 0-.814.24c-.24.116-.439.28-.582.491-.15.212-.219.335-.219.656 0 .628.219.991.616 1.23s.938.362 1.615.362zm-.233-9.7c.883 0 1.629.109 2.231.328.602.218 1.088.525 1.444.915.363.396.609.922.76 1.483.157.56.232 1.175.232 1.85v6.874a32.5 32.5 0 0 1-1.868.314c-.834.123-1.772.185-2.813.185-.69 0-1.327-.069-1.895-.198a4.001 4.001 0 0 1-1.471-.636 3.085 3.085 0 0 1-.951-1.134c-.226-.465-.343-1.12-.343-1.803 0-.656.13-1.073.384-1.525a3.24 3.24 0 0 1 1.047-1.106c.445-.287.95-.492 1.532-.615a8.8 8.8 0 0 1 1.82-.185 8.404 8.404 0 0 1 1.972.24v-.438c0-.307-.035-.6-.11-.874a1.88 1.88 0 0 0-.384-.73 1.784 1.784 0 0 0-.724-.493 3.164 3.164 0 0 0-1.143-.205c-.616 0-1.177.075-1.69.164a7.735 7.735 0 0 0-1.26.307l-.321-2.192c.335-.117.834-.233 1.478-.349a10.98 10.98 0 0 1 2.073-.178zm52.842 9.626c.822 0 1.43-.048 1.854-.13V13.7a6.347 6.347 0 0 0-1.574-.199c-.294 0-.595.021-.896.069a2.7 2.7 0 0 0-.814.24 1.46 1.46 0 0 0-.582.491c-.15.212-.218.335-.218.656 0 .628.218.991.615 1.23.404.245.938.362 1.615.362zm-.226-9.694c.883 0 1.629.108 2.231.327.602.219 1.088.526 1.444.915.355.39.609.923.759 1.483a6.8 6.8 0 0 1 .233 1.852v6.873c-.41.088-1.034.19-1.868.314-.834.123-1.772.184-2.813.184-.69 0-1.327-.068-1.895-.198a4.001 4.001 0 0 1-1.471-.635 3.085 3.085 0 0 1-.951-1.134c-.226-.465-.343-1.12-.343-1.804 0-.656.13-1.073.384-1.524.26-.45.608-.82 1.047-1.107.445-.286.95-.491 1.532-.614a8.803 8.803 0 0 1 2.751-.13c.329.034.671.096 1.04.185v-.437a3.3 3.3 0 0 0-.109-.875 1.873 1.873 0 0 0-.384-.731 1.784 1.784 0 0 0-.724-.492 3.165 3.165 0 0 0-1.143-.205c-.616 0-1.177.075-1.69.164a7.75 7.75 0 0 0-1.26.307l-.321-2.193c.335-.116.834-.232 1.478-.348a11.633 11.633 0 0 1 2.073-.177zm-8.034-1.271a1.626 1.626 0 0 1-1.628-1.62c0-.895.725-1.62 1.628-1.62.904 0 1.63.725 1.63 1.62 0 .895-.733 1.62-1.63 1.62zm1.348 13.22h-2.689V7.27l2.69-.423v11.956zm-4.714 0c-4.386.02-4.386-3.54-4.386-4.107l-.008-13.336 2.676-.424v13.254c0 .322 0 2.358 1.718 2.364v2.248zm-8.698-5.903c0-1.156-.253-2.119-.746-2.788-.493-.677-1.183-1.01-2.067-1.01-.882 0-1.574.333-2.065 1.01-.493.676-.733 1.632-.733 2.788 0 1.168.246 1.953.74 2.63.492.683 1.183 1.018 2.066 1.018.882 0 1.574-.342 2.067-1.019.492-.683.738-1.46.738-2.63zm2.737-.007c0 .902-.13 1.584-.397 2.33a5.52 5.52 0 0 1-1.128 1.906 4.986 4.986 0 0 1-1.752 1.223c-.685.286-1.739.45-2.265.45-.528-.006-1.574-.157-2.252-.45a5.096 5.096 0 0 1-1.744-1.223c-.487-.527-.863-1.162-1.137-1.906a6.345 6.345 0 0 1-.41-2.33c0-.902.123-1.77.397-2.508a5.554 5.554 0 0 1 1.15-1.892 5.133 5.133 0 0 1 1.75-1.216c.679-.287 1.425-.423 2.232-.423.808 0 1.553.142 2.237.423a4.88 4.88 0 0 1 1.753 1.216 5.644 5.644 0 0 1 1.135 1.892c.287.738.431 1.606.431 2.508zm-20.138 0c0 1.12.246 2.363.738 2.882.493.52 1.13.78 1.91.78.424 0 .828-.062 1.204-.178.377-.116.677-.253.917-.417V9.33a10.476 10.476 0 0 0-1.766-.226c-.971-.028-1.71.37-2.23 1.004-.513.636-.773 1.75-.773 2.788zm7.438 5.274c0 1.824-.466 3.156-1.404 4.004-.936.846-2.367 1.27-4.296 1.27-.705 0-2.17-.137-3.34-.396l.431-2.118c.98.205 2.272.26 2.95.26 1.074 0 1.84-.219 2.299-.656.459-.437.684-1.086.684-1.948v-.437a8.07 8.07 0 0 1-1.047.397c-.43.13-.93.198-1.492.198-.739 0-1.41-.116-2.018-.349a4.206 4.206 0 0 1-1.567-1.025c-.431-.45-.774-1.017-1.013-1.694-.24-.677-.363-1.885-.363-2.773 0-.834.13-1.88.384-2.577.26-.696.629-1.298 1.129-1.796.493-.498 1.095-.881 1.8-1.162a6.605 6.605 0 0 1 2.428-.457c.87 0 1.67.109 2.45.24.78.129 1.444.265 1.985.415V18.17z' fill='%235468FF'/%3E%3Cpath d='M6.972 6.677v1.627c-.712-.446-1.52-.67-2.425-.67-.585 0-1.045.13-1.38.391a1.24 1.24 0 0 0-.502 1.03c0 .425.164.765.494 1.02.33.256.835.532 1.516.83.447.192.795.356 1.045.495.25.138.537.332.862.582.324.25.563.548.718.894.154.345.23.741.23 1.188 0 .947-.334 1.691-1.004 2.234-.67.542-1.537.814-2.601.814-1.18 0-2.16-.229-2.936-.686v-1.708c.84.628 1.814.942 2.92.942.585 0 1.048-.136 1.388-.407.34-.271.51-.646.51-1.125 0-.287-.1-.55-.302-.79-.203-.24-.42-.42-.655-.542-.234-.123-.585-.29-1.053-.503a61.27 61.27 0 0 1-.582-.271 13.67 13.67 0 0 1-.55-.287 4.275 4.275 0 0 1-.567-.351 6.92 6.92 0 0 1-.455-.4c-.18-.17-.31-.34-.39-.51-.08-.17-.155-.37-.224-.598a2.553 2.553 0 0 1-.104-.742c0-.915.333-1.638.998-2.17.664-.532 1.523-.798 2.576-.798.968 0 1.793.17 2.473.51zm7.468 5.696v-.287c-.022-.607-.187-1.088-.495-1.444-.309-.357-.75-.535-1.324-.535-.532 0-.99.194-1.373.583-.382.388-.622.949-.717 1.683h3.909zm1.005 2.792v1.404c-.596.34-1.383.51-2.362.51-1.255 0-2.255-.377-3-1.132-.744-.755-1.116-1.744-1.116-2.968 0-1.297.34-2.316 1.021-3.055.68-.74 1.548-1.11 2.6-1.11 1.033 0 1.852.323 2.458.966.606.644.91 1.572.91 2.784 0 .33-.033.676-.096 1.038h-5.314c.107.702.405 1.239.894 1.611.49.372 1.106.558 1.85.558.862 0 1.58-.202 2.155-.606zm6.605-1.77h-1.212c-.596 0-1.045.116-1.349.35-.303.234-.454.532-.454.894 0 .372.117.664.35.877.235.213.575.32 1.022.32.51 0 .912-.142 1.204-.424.293-.281.44-.651.44-1.108v-.91zm-4.068-2.554V9.325c.627-.361 1.457-.542 2.489-.542 2.116 0 3.175 1.026 3.175 3.08V17h-1.548v-.957c-.415.68-1.143 1.02-2.186 1.02-.766 0-1.38-.22-1.843-.661-.462-.442-.694-1.003-.694-1.684 0-.776.293-1.38.878-1.81.585-.431 1.404-.647 2.457-.647h1.34V11.8c0-.554-.133-.971-.399-1.253-.266-.282-.707-.423-1.324-.423a4.07 4.07 0 0 0-2.345.718zm9.333-1.93v1.42c.394-1 1.101-1.5 2.123-1.5.148 0 .313.016.494.048v1.531a1.885 1.885 0 0 0-.75-.143c-.542 0-.989.24-1.34.718-.351.479-.527 1.048-.527 1.707V17h-1.563V8.91h1.563zm5.01 4.084c.022.82.272 1.492.75 2.019.479.526 1.15.79 2.01.79.639 0 1.235-.176 1.788-.527v1.404c-.521.319-1.186.479-1.995.479-1.265 0-2.276-.4-3.031-1.197-.755-.798-1.133-1.792-1.133-2.984 0-1.16.38-2.151 1.14-2.975.761-.825 1.79-1.237 3.088-1.237.702 0 1.346.149 1.93.447v1.436a3.242 3.242 0 0 0-1.77-.495c-.84 0-1.513.266-2.019.798-.505.532-.758 1.213-.758 2.042zM40.24 5.72v4.579c.458-1 1.293-1.5 2.505-1.5.787 0 1.42.245 1.899.734.479.49.718 1.17.718 2.042V17h-1.564v-5.106c0-.553-.14-.98-.422-1.284-.282-.303-.652-.455-1.11-.455-.531 0-1.002.202-1.411.606-.41.405-.615 1.022-.615 1.851V17h-1.563V5.72h1.563zm14.966 10.02c.596 0 1.096-.253 1.5-.758.404-.506.606-1.157.606-1.955 0-.915-.202-1.62-.606-2.114-.404-.495-.92-.742-1.548-.742-.553 0-1.05.224-1.491.67-.442.447-.662 1.133-.662 2.058 0 .958.212 1.67.638 2.138.425.469.946.703 1.563.703zM53.004 5.72v4.42c.574-.894 1.388-1.341 2.44-1.341 1.022 0 1.857.383 2.506 1.149.649.766.973 1.781.973 3.047 0 1.138-.309 2.109-.925 2.912-.617.803-1.463 1.205-2.537 1.205-1.075 0-1.894-.447-2.457-1.34V17h-1.58V5.72h1.58zm9.908 11.104l-3.223-7.913h1.739l1.005 2.632 1.26 3.415c.096-.32.48-1.458 1.15-3.415l.909-2.632h1.66l-2.92 7.866c-.777 2.074-1.963 3.11-3.559 3.11a2.92 2.92 0 0 1-.734-.079v-1.34c.17.042.351.064.543.064 1.032 0 1.755-.57 2.17-1.708z' fill='%235D6494'/%3E%3Cpath d='M89.632 5.967v-.772a.978.978 0 0 0-.978-.977h-2.28a.978.978 0 0 0-.978.977v.793c0 .088.082.15.171.13a7.127 7.127 0 0 1 1.984-.28c.65 0 1.295.088 1.917.259.082.02.164-.04.164-.13m-6.248 1.01l-.39-.389a.977.977 0 0 0-1.382 0l-.465.465a.973.973 0 0 0 0 1.38l.383.383c.062.061.15.047.205-.014.226-.307.472-.601.746-.874.281-.28.568-.526.883-.751.068-.042.075-.137.02-.2m4.16 2.453v3.341c0 .096.104.165.192.117l2.97-1.537c.068-.034.089-.117.055-.184a3.695 3.695 0 0 0-3.08-1.866c-.068 0-.136.054-.136.13m0 8.048a4.489 4.489 0 0 1-4.49-4.482 4.488 4.488 0 0 1 4.49-4.482 4.488 4.488 0 0 1 4.489 4.482 4.484 4.484 0 0 1-4.49 4.482m0-10.85a6.363 6.363 0 1 0 0 12.729 6.37 6.37 0 0 0 6.372-6.368 6.358 6.358 0 0 0-6.371-6.36' fill='%23FFF'/%3E%3C/g%3E%3C/svg%3E");background-repeat:no-repeat;background-position:50%;background-size:100%;overflow:hidden;text-indent:-9000px;padding:0!important;width:100%;height:100%;display:block} -/*# sourceMappingURL=docsearch.min.css.map */ -a.algolia-docsearch-suggestion { - text-decoration: none !important; -} -.algolia-docsearch-suggestion--category-header { - background: #0594cb; - padding-left: .25rem !important; - color: white !important; - border-radius: 3px; -} diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_anchorforid.css b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_anchorforid.css deleted file mode 100644 index ab5942854..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_anchorforid.css +++ /dev/null @@ -1,16 +0,0 @@ - -.header-link:after { - position: relative; - left: 0.5em; - opacity: 0; - font-size: 0.8em; - -moz-transition: opacity 0.2s ease-in-out 0.1s; - -ms-transition: opacity 0.2s ease-in-out 0.1s; -} -h2:hover .header-link, -h3:hover .header-link, -h4:hover .header-link, -h5:hover .header-link, -h6:hover .header-link { - opacity: 1; -} diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_animation.css b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_animation.css deleted file mode 100644 index 997931ac4..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_animation.css +++ /dev/null @@ -1,21 +0,0 @@ -.animated { - animation-duration: .5s; - animation-fill-mode: forwards; - animation-timing-function: ease-in-out; -} - -@keyframes fadeIn { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} -.fadeIn { - animation-name: fadeIn; -} -.animated-delay-1 { - animation-delay: 0.5s; -} diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_carousel.css b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_carousel.css deleted file mode 100644 index 11fae8702..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_carousel.css +++ /dev/null @@ -1,25 +0,0 @@ -/* These styles enhance the home page carousel, located here: themes/gohugoioTheme/layouts/partials/home-page-sections/showcase.html */ -.overflow-x-scroll{ - -webkit-overflow-scrolling: touch; -} -.row { - transition: 450ms transform; - font-size: 0; -} -.tile { - transition: 450ms all; -} -.details { - background: -webkit-gradient(linear, left bottom, left top, from(rgba(0,0,0,0.9)), to(rgba(0,0,0,0))); - background: linear-gradient(to top, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0) 100%); - transition: 450ms opacity; -} -.tile:hover .details { - opacity: 1; -} -.row:hover .tile { - opacity: 0.3; -} -.row:hover .tile:hover { - opacity: 1; -} diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_chroma.css b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_chroma.css deleted file mode 100644 index d00ea65e6..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_chroma.css +++ /dev/null @@ -1,65 +0,0 @@ -/* Background */ .chroma { background-color: #ffffff } -/* Error */ .chroma .err { color: #a61717; background-color: #e3d2d2 } -/* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; } -/* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; width: auto; overflow: auto; display: block; } -/* LineHighlight */ .chroma .hl { display: block; width: 100%;background-color: #ffffcc } -/* LineNumbersTable */ .chroma .lnt { margin-right: 0.4em; padding: 0 0.4em 0 0.4em; } -/* LineNumbers */ .chroma .ln { margin-right: 0.4em; padding: 0 0.4em 0 0.4em; } -/* Keyword */ .chroma .k { font-weight: bold } -/* KeywordConstant */ .chroma .kc { font-weight: bold } -/* KeywordDeclaration */ .chroma .kd { font-weight: bold } -/* KeywordNamespace */ .chroma .kn { font-weight: bold } -/* KeywordPseudo */ .chroma .kp { font-weight: bold } -/* KeywordReserved */ .chroma .kr { font-weight: bold } -/* KeywordType */ .chroma .kt { color: #445588; font-weight: bold } -/* NameAttribute */ .chroma .na { color: #008080 } -/* NameBuiltin */ .chroma .nb { color: #999999 } -/* NameClass */ .chroma .nc { color: #445588; font-weight: bold } -/* NameConstant */ .chroma .no { color: #008080 } -/* NameEntity */ .chroma .ni { color: #800080 } -/* NameException */ .chroma .ne { color: #990000; font-weight: bold } -/* NameFunction */ .chroma .nf { color: #990000; font-weight: bold } -/* NameNamespace */ .chroma .nn { color: #555555 } -/* NameTag */ .chroma .nt { color: #000080 } -/* NameVariable */ .chroma .nv { color: #008080 } -/* LiteralString */ .chroma .s { color: #bb8844 } -/* LiteralStringAffix */ .chroma .sa { color: #bb8844 } -/* LiteralStringBacktick */ .chroma .sb { color: #bb8844 } -/* LiteralStringChar */ .chroma .sc { color: #bb8844 } -/* LiteralStringDelimiter */ .chroma .dl { color: #bb8844 } -/* LiteralStringDoc */ .chroma .sd { color: #bb8844 } -/* LiteralStringDouble */ .chroma .s2 { color: #bb8844 } -/* LiteralStringEscape */ .chroma .se { color: #bb8844 } -/* LiteralStringHeredoc */ .chroma .sh { color: #bb8844 } -/* LiteralStringInterpol */ .chroma .si { color: #bb8844 } -/* LiteralStringOther */ .chroma .sx { color: #bb8844 } -/* LiteralStringRegex */ .chroma .sr { color: #808000 } -/* LiteralStringSingle */ .chroma .s1 { color: #bb8844 } -/* LiteralStringSymbol */ .chroma .ss { color: #bb8844 } -/* LiteralNumber */ .chroma .m { color: #009999 } -/* LiteralNumberBin */ .chroma .mb { color: #009999 } -/* LiteralNumberFloat */ .chroma .mf { color: #009999 } -/* LiteralNumberHex */ .chroma .mh { color: #009999 } -/* LiteralNumberInteger */ .chroma .mi { color: #009999 } -/* LiteralNumberIntegerLong */ .chroma .il { color: #009999 } -/* LiteralNumberOct */ .chroma .mo { color: #009999 } -/* Operator */ .chroma .o { font-weight: bold } -/* OperatorWord */ .chroma .ow { font-weight: bold } -/* Comment */ .chroma .c { color: #999988; font-style: italic } -/* CommentHashbang */ .chroma .ch { color: #999988; font-style: italic } -/* CommentMultiline */ .chroma .cm { color: #999988; font-style: italic } -/* CommentSingle */ .chroma .c1 { color: #999988; font-style: italic } -/* CommentSpecial */ .chroma .cs { color: #999999; font-weight: bold; font-style: italic } -/* CommentPreproc */ .chroma .cp { color: #999999; font-weight: bold } -/* CommentPreprocFile */ .chroma .cpf { color: #999999; font-weight: bold } -/* GenericDeleted */ .chroma .gd { color: #000000; background-color: #ffdddd } -/* GenericEmph */ .chroma .ge { font-style: italic } -/* GenericError */ .chroma .gr { color: #aa0000 } -/* GenericHeading */ .chroma .gh { color: #999999 } -/* GenericInserted */ .chroma .gi { color: #000000; background-color: #ddffdd } -/* GenericOutput */ .chroma .go { color: #888888 } -/* GenericPrompt */ .chroma .gp { color: #555555 } -/* GenericStrong */ .chroma .gs { font-weight: bold } -/* GenericSubheading */ .chroma .gu { color: #aaaaaa } -/* GenericTraceback */ .chroma .gt { color: #aa0000 } -/* TextWhitespace */ .chroma .w { color: #bbbbbb } diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_code.css b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_code.css deleted file mode 100644 index 2fb402fcf..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_code.css +++ /dev/null @@ -1,97 +0,0 @@ -.chroma .lntable pre { - padding: 0; - margin: 0; - border: 0; -} - -.chroma .lntable pre code { - padding: 0; - margin: 0; -} - -pre, .pre { - overflow-x: auto; - overflow-y: hidden; - overflow: scroll; -} - -code { - padding: 0.2em; - margin: 0; - font-size: 85%; - background-color: rgba(27,31,35,0.05); - border-radius: 3px; -} - - -pre code { - display: block; - padding: 1.5em 1.5em; - font-size: .875rem; - line-height: 2; - overflow-x: auto; -} - - -pre { - background-color: #fff; - color: #333; - white-space: pre; - hyphens: none; - position: relative; - border-width: 1px; - border-color: #ccc; - border-style: solid; -} - -/* The Pygments highlighter comes with its own styles. */ -.highlight pre { - background-color: inherit; - color: inherit; - padding: 0.5em; - font-size: .875rem; -} - - -/*We are adding the copy button content here so we can change it with javascript. See the "Clipboard scripts"*/ -.copy:after { - content: "Copy" -} -.copied:after { - content: "Copied" -} - -@media (--breakpoint-large) { - .full-width, pre.expand:hover - { - /*width: 100vw; - position: relative; - left: 50%; - right: 50%; - margin-left: -50vw; - margin-right: -50vw;*/ - /*width: 60vw;*/ - /*position: relative; - left: 50%; - right: 50%;*/ - /*margin-left: -30vw;*/ - margin-right: -30vw; - max-width: 100vw; - } -} - -.code-block .line-numbers-rows { - background: #2f3a46; - border: none; - bottom: -50px; - color: #98a4b3; - left: -178px; - padding: 50px 0; - top: -50px; - width: 138px -} - -.code-block .line-numbers-rows>span:before { - color: inherit; - padding-right: 30px -} diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_color-scheme.css b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_color-scheme.css deleted file mode 100644 index 1d61a7725..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_color-scheme.css +++ /dev/null @@ -1,38 +0,0 @@ -.primary-color {color: var(--primary-color)} -.bg-primary-color {background-color: var(--primary-color)} -.hover-bg-primary-color:hover {background-color: var(--primary-color)} - -.primary-color-dark {color: var(--primary-color-dark)} -.bg-primary-color-dark {background-color: var(--primary-color-dark)} -.hover-bg-primary-color-dark:hover {background-color: var(--primary-color-dark)} - -.primary-color-light {color: var(--primary-color-light)} -.bg-primary-color-light {background-color: var(--primary-color-light)} -.hover-bg-primary-color-light:hover {background-color: var(--primary-color-light)} - -.accent-color {color: var(--accent-color)} -.bg-accent-color {background-color: var(--accent-color)} -.hover-bg-accent-color:hover {background-color: var(--accent-color)} - -.accent-color-light {color: var(--accent-color-light)} -.hover-accent-color-light:hover {color: var(--accent-color-light)} -.bg-accent-color-light {background-color: var(--accent-color-light)} -.hover-bg-accent-color-light:hover {background-color: var(--accent-color-light)} - -.accent-color-dark {color: var(--accent-color-dark)} -.bg-accent-color-dark {background-color: var(--accent-color-dark)} -.hover-bg-accent-color-dark:hover {background-color: var(--accent-color-dark)} - -.text-color-primary {color: var(--text-color-primary)} -.text-on-primary-color {color: var(--text-on-primary-color)} -.text-color-secondary {color: var(--text-color-secondary)} -.text-color-disabled {color: var(--text-color-disabled)} -.divider-color {color: var(--divider-color)} -.warn-color {color: var(--warn-color)} - - -.nested-links a { - color: var(--primary-color); - text-decoration: none; - -} diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_columns.css b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_columns.css deleted file mode 100644 index e1e938c74..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_columns.css +++ /dev/null @@ -1,11 +0,0 @@ -.column-count-2 {column-count: 1} -.column-gap-1 {column-gap: 0} -.break-inside-avoid {break-inside: auto} - - -@media (--breakpoint-large) { - .column-count-3-l {column-count: 3} - .column-count-2-l {column-count: 2} - .column-gap-1-l {column-gap: 1} - .break-inside-avoid-l {break-inside: avoid} -} diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_content-tables.css b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_content-tables.css deleted file mode 100644 index 4e092e8bf..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_content-tables.css +++ /dev/null @@ -1,28 +0,0 @@ -.prose table { - width: 100%; - margin-bottom: 3em; - border-collapse: collapse; - border-spacing: 0; - font-size: 1em; - border: 1px solid var(--light-gray); - & th { - background-color: var(--primary-color); - border-bottom: 1px solid var(--primary-color); - color: white; - font-weight: 400; - - text-align: left; - padding: .375em .5em; - } - - & td, & tc { - padding: .75em .5em; - text-align: left; - border-right: 1px solid var(--light-gray); - } - -} - -.prose table tr:nth-child(even) { - background-color: var(--light-gray); -} diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_content.css b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_content.css deleted file mode 100644 index 9c8a8a14d..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_content.css +++ /dev/null @@ -1,41 +0,0 @@ -.prose ul, .prose ol { - margin-bottom: 2em; -} -.prose ul li, .prose ol li { - margin-bottom: .5em; -} -.prose li:hover { - background-color: var(--light-gray) -} -.prose ::selection { - background: var(--primary-color); /* WebKit/Blink Browsers */ - color: white; -} - - -body { - -line-height: 1.45; - -} - -p {margin-bottom: 1.3em;} - -h1, h2, h3, h4 { -margin: 1.414em 0 0.5em; - -line-height: 1.2; -} - -h1 { -margin-top: 0; -font-size: 2.441em; -} - -h2 {font-size: 1.953em;} - -h3 {font-size: 1.563em;} - -h4 {font-size: 1.25em;} - -small, .font_small {font-size: 0.8em;} diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_definition-lists.css b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_definition-lists.css deleted file mode 100644 index e28f67d4b..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_definition-lists.css +++ /dev/null @@ -1,9 +0,0 @@ - -dl dt { - font-weight: bold; - font-size: 1.125rem; -} -dd { - margin: .5em 0 2em 0; - padding: 0; -} diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_documentation-styles.css b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_documentation-styles.css deleted file mode 100644 index 0ea8e9b72..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_documentation-styles.css +++ /dev/null @@ -1,54 +0,0 @@ -.note, -.warning { - - border-left-width: 4px; - border-left-style: solid; - position: relative; - border-color: var(--primary-color); - - display: block; -} -.note #exclamation-icon, -.warning #exclamation-icon { - - fill: var(--primary-color); - position: absolute; - top: 35%; - left: -12px; - /*background-color: white;*/ -} - - .admonition-content { - display: block; - margin: 0px; - padding: .125em 1em; - /*margin-left: 1em;*/ - margin-top: 2em; - margin-bottom: 2em; - overflow-x: auto; - /*font-size: .9375em;*/ - background-color: var(--black-05); - } - - - .hide-child-menu .child-menu { - display: none; - } - .hide-child-menu:hover .child-menu, - .hide-child-menu:focus .child-menu, - .hide-child-menu:active .child-menu { - display: block; - } - - -/*documentation-copy headings exaggerate spacing and size to chunk content */ - .documentation-copy h2 { - margin-top: 3em; - &.minor { - font-size: inherit; - margin-top: inherit; - border-bottom: none; - } - } - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_fluid-type.css b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_fluid-type.css deleted file mode 100644 index da9f04c81..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_fluid-type.css +++ /dev/null @@ -1,10 +0,0 @@ -.f2-fluid { - font-size: 2.25rem; -} - -@media (--breakpoint-large) { - .f2-fluid { - font-size: 1.25rem; - font-size: calc(0.875rem + 0.5 * ((100vw - 20rem) / 60)); - } -} diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_font-family.css b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_font-family.css deleted file mode 100644 index 9b451cf1c..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_font-family.css +++ /dev/null @@ -1,80 +0,0 @@ -/* From http://cssfontstack.com */ -code, .code, pre code, .highlight pre { - font-family: 'inconsolata',Menlo,Monaco,'Courier New',monospace; -} - -.sans-serif { - font-family: 'Muli', - avenir, - 'helvetica neue', helvetica, - ubuntu, - roboto, noto, - 'segoe ui', arial, - sans-serif; -} - - -.serif { - font-family: Palatino,"Palatino Linotype","Palatino LT STD","Book Antiqua",Georgia,serif; -} - -/* Monospaced Typefaces (for code) */ - - -.courier { - font-family: 'Courier Next', - courier, - monospace; -} - - -/* Sans-Serif Typefaces */ - -.helvetica { - font-family: 'helvetica neue', helvetica, - sans-serif; -} - -.avenir { - font-family: 'avenir next', avenir, - sans-serif; -} - - -/* Serif Typefaces */ - -.athelas { - font-family: athelas, - georgia, - serif; -} - -.georgia { - font-family: georgia, - serif; -} - -.times { - font-family: times, - serif; -} - -.bodoni { - font-family: "Bodoni MT", - serif; -} - -.calisto { - font-family: "Calisto MT", - serif; -} - -.garamond { - font-family: garamond, - serif; -} - -.baskerville { - font-family: baskerville, - serif; -} diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_hljs.css b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_hljs.css deleted file mode 100644 index c49107655..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_hljs.css +++ /dev/null @@ -1,11 +0,0 @@ -/* modified from:*/ -@import 'highlight.js/styles/atom-one-light.css'; - -/* hljs-template-variable covers the handlebars templating*/ -.hljs-template-variable { - color: var(--primary-color); -} - -.hljs-attr { - color: var(--accent-color-light); -} diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_hugo-internal-template-styling.css b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_hugo-internal-template-styling.css deleted file mode 100644 index 0b1df9610..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_hugo-internal-template-styling.css +++ /dev/null @@ -1,52 +0,0 @@ -/* pagination.html: https://github.com/gohugoio/hugo/blob/master/tpl/tplimpl/template_embedded.go#L117 */ -.pagination { - margin: 3rem 0; -} - -.pagination li { - display: inline-block; - margin-right: .375rem; - font-size: .875rem; - margin-bottom: 2.5em; -} -.pagination li a { - padding: .5rem .625rem; - background-color: white; - color: #333; - border: 1px solid #ddd; - border-radius: 3px; - text-decoration: none; -} -.pagination li.disabled { - display: none; -} -.pagination li.active a:link, -.pagination li.active a:active, -.pagination li.active a:visited { - background-color: #ddd; -} - -/* Hides non-meaningful TOC items*/ -#TableOfContents ul li ul li ul li{ - display: none; - } - - -#TableOfContents ul li { - color: black; - display: block; - margin-bottom: .375em; - line-height: 1.375; -} - -#TableOfContents ul li a{ - width: 100%; - padding: .25em .375em; - margin-left: -.375em; - -} -#TableOfContents ul li a:hover { - background-color: #999; - color: white; - -} diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_no-js.css b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_no-js.css deleted file mode 100644 index 7991450fe..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_no-js.css +++ /dev/null @@ -1,7 +0,0 @@ -.no-js .needs-js { - opacity: 0 -} -.js .needs-js { - opacity: 1; - transition: opacity .15s ease-in; -} diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_social-icons.css b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_social-icons.css deleted file mode 100644 index 04ea11ec5..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_social-icons.css +++ /dev/null @@ -1,23 +0,0 @@ -.facebook, .twitter, .instagram, .youtube { - fill: #BABABA; -} -.facebook:hover { - fill: #3b5998; -} - -.twitter { - fill: #55acee; -} - -.twitter:hover { - fill: #BABABA; -} - - -.instagram:hover { - fill: #e95950; -} - -.youtube:hover { - fill: #bb0000; -} diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_stickyheader.css b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_stickyheader.css deleted file mode 100644 index 7759bed96..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_stickyheader.css +++ /dev/null @@ -1,15 +0,0 @@ - -@media (min-width: 75em) { - - [data-scrolldir="down"] .sticky { - position: fixed; - top:100px; - right:0; - } - - [data-scrolldir="up"] .sticky { - position: fixed; - top:100px; - right:0; - } -} diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_svg.css b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_svg.css deleted file mode 100644 index 299a4a963..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_svg.css +++ /dev/null @@ -1 +0,0 @@ -.fill-current { fill: currentColor; } diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_tabs.css b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_tabs.css deleted file mode 100644 index 6e0022cc9..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_tabs.css +++ /dev/null @@ -1,34 +0,0 @@ -.tab-button{ - margin-bottom:1px; - position: relative; - z-index: 1; - color:#333; - border-color:#ccc; - outline: none; - background-color:white; -} -.tab-pane code{ - background:#f1f2f2; - border-radius:0; -} -.tab-pane .chroma{ - background:none; - padding:0; -} -.tab-button.active{ - border-bottom-color:#f1f2f2; - background-color: #f1f2f2; -} -.tab-content .tab-pane{ - display: none; -} -.tab-content .tab-pane.active{ - display: block; -} -/* Treatment of copy buttons inside a tab module */ -.tab-content .copy, .tab-content .copied{ - display: none; -} -.tab-content .tab-pane.active + .copy, .tab-content .tab-pane.active + .copied{ - display: block; -} \ No newline at end of file diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_tachyons.css b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_tachyons.css deleted file mode 100644 index d697c4d85..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_tachyons.css +++ /dev/null @@ -1,94 +0,0 @@ -/*! TACHYONS v4.7.0 | http://tachyons.io */ - -/* - * NOTE: The Tachyons folder is for backup/reference only. This file references the module - * ________ ______ - * ___ __/_____ _________ /______ ______________________ - * __ / _ __ `/ ___/_ __ \_ / / / __ \_ __ \_ ___/ - * _ / / /_/ // /__ _ / / / /_/ // /_/ / / / /(__ ) - * /_/ \__,_/ \___/ /_/ /_/_\__, / \____//_/ /_//____/ - * /____/ - * - * TABLE OF CONTENTS - * - * 1. External Library Includes - * - Normalize.css | http://normalize.css.github.io - * 2. Tachyons Modules - * 3. Variables - * - Media Queries - * - Colors - * 4. Debugging - * - Debug all - * - Debug children - * - */ - - -/* External Library Includes */ -@import 'tachyons/src/_normalize'; - - -/* Modules */ -@import 'tachyons/src/_box-sizing'; -/*@import 'tachyons/src/_aspect-ratios';*/ -@import 'tachyons/src/_images'; -@import 'tachyons/src/_background-size'; -@import 'tachyons/src/_background-position'; -/*@import 'tachyons/src/_outlines';*/ -@import 'tachyons/src/_borders'; -@import 'tachyons/src/_border-colors'; -@import 'tachyons/src/_border-radius'; -@import 'tachyons/src/_border-style'; -@import 'tachyons/src/_border-widths'; -@import 'tachyons/src/_box-shadow'; -/*@import 'tachyons/src/_code';*/ -@import 'tachyons/src/_coordinates'; -@import 'tachyons/src/_clears'; -@import 'tachyons/src/_display'; -@import 'tachyons/src/_flexbox'; -@import 'tachyons/src/_floats'; -/*@import 'tachyons/src/_font-family';*/ -@import 'tachyons/src/_font-style'; -@import 'tachyons/src/_font-weight'; -@import 'tachyons/src/_forms'; -@import 'tachyons/src/_heights'; -@import 'tachyons/src/_letter-spacing'; -@import 'tachyons/src/_line-height'; -@import 'tachyons/src/_links'; -@import 'tachyons/src/_lists'; -@import 'tachyons/src/_max-widths'; -@import 'tachyons/src/_widths'; -@import 'tachyons/src/_overflow'; -@import 'tachyons/src/_position'; -@import 'tachyons/src/_opacity'; -/*@import 'tachyons/src/_rotations';*/ -@import 'tachyons/src/_skins'; -@import 'tachyons/src/_skins-pseudo'; -@import 'tachyons/src/_spacing'; -@import 'tachyons/src/_negative-margins'; -@import 'tachyons/src/_tables'; -@import 'tachyons/src/_text-decoration'; -@import 'tachyons/src/_text-align'; -@import 'tachyons/src/_text-transform'; -@import 'tachyons/src/_type-scale'; -@import 'tachyons/src/_typography'; -@import 'tachyons/src/_utilities'; -@import 'tachyons/src/_visibility'; -@import 'tachyons/src/_white-space'; -@import 'tachyons/src/_vertical-align'; -@import 'tachyons/src/_hovers'; -@import 'tachyons/src/_z-index'; -@import 'tachyons/src/_nested'; -/*@import 'tachyons/src/_styles';*/ - -/* Variables */ -/* Importing here will allow you to override any variables in the modules */ -@import 'tachyons/src/_colors'; -@import 'tachyons/src/_media-queries'; - -/* Debugging */ -/*@import 'tachyons/src/_debug-children'; -@import 'tachyons/src/_debug-grid';*/ - -/* Uncomment out the line below to help debug layout issues */ -/* @import 'tachyons/src/_debug'; */ diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_variables.css b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_variables.css deleted file mode 100644 index 8701b1530..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/_variables.css +++ /dev/null @@ -1,16 +0,0 @@ -:root { - --primary-color: #0594CB; - --primary-color-dark: #0A1922; - --primary-color-light: #f9f9f9; - --accent-color: #EBB951; - --accent-color-light: #FF4088; - --accent-color-dark: #33ba91; - --text-color-primary: #373737; - --text-on-primary-color: #fff; - --text-color-secondary: #ccc; - --text-color-disabled: #F7f7f7; - --divider-color: #f6f6f6; - --warn-color: red; - - --blue: var(--primary-color); -} diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/main.css b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/main.css deleted file mode 100644 index 1cd15fb10..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/css/main.css +++ /dev/null @@ -1,39 +0,0 @@ -/*Base Styles*/ -@import '_tachyons'; - -/* purgecss start ignore */ -@import '_anchorforid'; -@import '_animation'; -@import '_documentation-styles'; - -@import 'docsearch.js/dist/cdn/docsearch.min'; -@import '_carousel'; -@import '_code'; -@import '_tabs'; -@import '_color-scheme'; -@import '_columns'; -@import '_content'; -@import '_content-tables'; -@import '_definition-lists'; -@import '_fluid-type'; -@import '_font-family'; -@import '_hugo-internal-template-styling'; -@import '_no-js'; -@import '_social-icons'; -@import '_stickyheader'; - -@import '_svg'; -@import '_chroma'; -@import '_variables'; - -.nested-blockquote blockquote { - border-left: 4px solid var(--primary-color); - padding-left: 1em; - /*margin: 0;*/ -} - - -.mw-90 { - max-width:90%; -} -/* purgecss end ignore */ \ No newline at end of file diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/index.js b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/index.js deleted file mode 100644 index 5a3dbc8c1..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/index.js +++ /dev/null @@ -1,16 +0,0 @@ -require("typeface-muli") -import styles from './css/main.css'; -import './js/anchorforid.js' -import './js/clipboardjs.js' -import './js/codeblocks.js' -import './js/docsearch.js' -import './js/hljs.js' -import './js/lazysizes.js' -import './js/menutoggle.js' -import './js/scrolldir.js' -import './js/smoothscroll.js' -import './js/tabs.js' -import './js/nojs.js' - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/anchorforid.js b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/anchorforid.js deleted file mode 100644 index cb0855d52..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/anchorforid.js +++ /dev/null @@ -1,34 +0,0 @@ -/** -* Anchor for ID BPNY -**/ -var anchorForId = function (id) { - var anchor = document.createElement("a"); - anchor.className = "header-link"; - anchor.href = "#" + id; - anchor.innerHTML = ' '; - return anchor; -}; - -var linkifyAnchors = function (level, containingElement) { - var headers = containingElement.getElementsByTagName("h" + level); - for (var h = 0; h < headers.length; h++) { - var header = headers[h]; - - if (typeof header.id !== "undefined" && header.id !== "") { - header.appendChild(anchorForId(header.id)); - } - } -}; - - -document.onreadystatechange = function () { - if (this.readyState === "complete") { - var contentBlock = document.getElementsByClassName("prose")[0] - if (!contentBlock) { - return; - } - for (var level = 2; level <= 4; level++) { - linkifyAnchors(level, contentBlock); - } - } -}; diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/clipboardjs.js b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/clipboardjs.js deleted file mode 100644 index ffae31c7f..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/clipboardjs.js +++ /dev/null @@ -1,30 +0,0 @@ -var Clipboard = require('clipboard/dist/clipboard.js'); -new Clipboard('.copy', { - target: function(trigger) { - if(trigger.classList.contains('copy-toggle')){ - return trigger.previousElementSibling; - } - return trigger.nextElementSibling; - } - }).on('success', function(e) { - successMessage(e.trigger, 'Copied!'); - e.clearSelection(); - }).on('error', function(e) { - successMessage(e.trigger, fallbackMessage(e.action)); -}); - -function successMessage(elem, msg) { - elem.setAttribute('class', 'copied bg-primary-color-dark f6 absolute top-0 right-0 lh-solid hover-bg-primary-color-dark bn white ph3 pv2'); - elem.setAttribute('aria-label', msg); -} - -function fallbackMessage(elem, action) { - var actionMsg = ''; - var actionKey = (action === 'cut' ? 'X' : 'C'); - if (isMac) { - actionMsg = 'Press ⌘-' + actionKey; - } else { - actionMsg = 'Press Ctrl-' + actionKey; - } - return actionMsg; -} diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/codeblocks.js b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/codeblocks.js deleted file mode 100644 index d8039c5d6..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/codeblocks.js +++ /dev/null @@ -1,10 +0,0 @@ -let article = document.getElementById('prose') - -if (article) { - let codeBlocks = article.getElementsByTagName('code') - for (let [key, codeBlock] of Object.entries(codeBlocks)){ - var widthDif = codeBlock.scrollWidth - codeBlock.clientWidth - if (widthDif > 0) - codeBlock.parentNode.classList.add('expand') - } -} diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/docsearch.js b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/docsearch.js deleted file mode 100644 index 0074da8cd..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/docsearch.js +++ /dev/null @@ -1,7 +0,0 @@ -var docsearch = require('docsearch.js/dist/cdn/docsearch.js'); -docsearch({ - apiKey: '167e7998590aebda7f9fedcf86bc4a55', - indexName: 'hugodocs', - inputSelector: '#search-input', - debug: true // Set debug to true if you want to inspect the dropdown -}); diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/hljs.js b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/hljs.js deleted file mode 100644 index c2252e783..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/hljs.js +++ /dev/null @@ -1,19 +0,0 @@ -var hljs = require('highlight.js/lib/highlight.js'); - -hljs.registerLanguage('bash', require('highlight.js/lib/languages/bash')); -hljs.registerLanguage('css', require('highlight.js/lib/languages/css')); -hljs.registerLanguage('markdown', require('highlight.js/lib/languages/markdown')); -hljs.registerLanguage('diff', require('highlight.js/lib/languages/diff')); -// hljs.registerLanguage('go', require('highlight.js/lib/languages/go')); -hljs.registerLanguage('javascript', require('highlight.js/lib/languages/javascript')); -hljs.registerLanguage('json', require('highlight.js/lib/languages/json')); -hljs.registerLanguage('yaml', require('highlight.js/lib/languages/yaml')); -hljs.registerLanguage('xml', require('highlight.js/lib/languages/xml')); -hljs.registerLanguage('html', require('highlight.js/lib/languages/handlebars')); - -hljs.registerLanguage("go", function(e) { - var t = { keyword: "code output note warning break default func interface select case map struct chan else goto package switch const fallthrough if range end type continue for import return var go defer bool byte complex64 complex128 float32 float64 int8 int16 int32 int64 string uint8 uint16 uint32 uint64 int uint uintptr rune id autoplay Get", literal: "file download copy true false iota nil Pages with", built_in: "append cap close complex highlight copy imag len make new panic print println real recover delete Site Data tweet youtube ref relref vimeo instagram gist figure innershortcode" }; - return { aliases: ["golang","hugo"], k: t, i: "Jquery is working

    '); -// diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/menutoggle.js b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/menutoggle.js deleted file mode 100644 index d0e645385..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/menutoggle.js +++ /dev/null @@ -1,31 +0,0 @@ -// Grab any element that has the 'js-toggle' class and add an event listner for the toggleClass function -var toggleBtns = document.getElementsByClassName('js-toggle') - for (var i = 0; i < toggleBtns.length; i++) { - toggleBtns[i].addEventListener('click', toggleClass, false) - } - -function toggleClass() { - // Define the data target via the dataset "target" (e.g. data-target=".docsmenu") - var content = this.dataset.target.split(' ') - // Find any menu items that are open - var mobileCurrentlyOpen = document.querySelector('.mobilemenu:not(.dn)') - var desktopCurrentlyOpen = document.querySelector('.desktopmenu:not(.dn)') - var desktopActive = document.querySelector('.desktopmenu:not(.dn)') - - // Loop through the targets' divs - for (var i = 0; i < content.length; i++) { - var matches = document.querySelectorAll(content[i]); - //for each, if the div has the 'dn' class (which is "display:none;"), remove it, otherwise, add that class - [].forEach.call(matches, function(dom) { - dom.classList.contains('dn') ? - dom.classList.remove('dn') : - dom.classList.add('dn'); - return false; - }); - // close the currently open menu items - if (mobileCurrentlyOpen) mobileCurrentlyOpen.classList.add('dn') - if (desktopCurrentlyOpen) desktopCurrentlyOpen.classList.add('dn') - if (desktopActive) desktopActive.classList.remove('db') - - } - } diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/nojs.js b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/nojs.js deleted file mode 100644 index 50b5126a9..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/nojs.js +++ /dev/null @@ -1 +0,0 @@ -document.documentElement.className = document.documentElement.className.replace(/\bno-js\b/, 'js'); diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/scrolldir.js b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/scrolldir.js deleted file mode 100644 index 0b69978cd..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/scrolldir.js +++ /dev/null @@ -1 +0,0 @@ -var scrollDir = require('scrolldir/dist/scrolldir.auto.min.js'); diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/smoothscroll.js b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/smoothscroll.js deleted file mode 100644 index 4bb2d99b8..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/smoothscroll.js +++ /dev/null @@ -1,80 +0,0 @@ -// query selector targets Hugo TOC -(function() { - - 'use strict'; - - // Feature Test - if ('querySelector' in document && 'addEventListener' in window && Array.prototype.forEach) { - - // Function to animate the scroll - var smoothScroll = function(anchor, duration) { - - // Calculate how far and how fast to scroll - var startLocation = window.pageYOffset; - var endLocation = anchor.offsetTop; - var distance = endLocation - startLocation; - var increments = distance / (duration / 16); - var stopAnimation; - - // Scroll the page by an increment, and check if it's time to stop - var animateScroll = function() { - window.scrollBy(0, increments); - stopAnimation(); - }; - - // If scrolling down - if (increments >= 0) { - // Stop animation when you reach the anchor OR the bottom of the page - stopAnimation = function() { - var travelled = window.pageYOffset; - if ((travelled >= (endLocation - increments)) || ((window.innerHeight + travelled) >= document.body.offsetHeight)) { - clearInterval(runAnimation); - } - }; - } - // If scrolling up - else { - // Stop animation when you reach the anchor OR the top of the page - stopAnimation = function() { - var travelled = window.pageYOffset; - if (travelled <= (endLocation || 0)) { - clearInterval(runAnimation); - } - }; - } - - // Loop the animation function - var runAnimation = setInterval(animateScroll, 16); - - }; - - // Define smooth scroll links - var scrollToggle = document.querySelectorAll('#TableOfContents ul li a'); - - // For each smooth scroll link - [].forEach.call(scrollToggle, function(toggle) { - - // When the smooth scroll link is clicked - toggle.addEventListener('click', function(e) { - - // Prevent the default link behavior - e.preventDefault(); - - // Get anchor link and calculate distance from the top - var dataID = toggle.getAttribute('href'); - var dataTarget = document.querySelector(dataID); - var dataSpeed = toggle.getAttribute('data-speed'); - - // If the anchor exists - if (dataTarget) { - // Scroll to the anchor - smoothScroll(dataTarget, dataSpeed || 500); - } - - }, false); - - }); - - } - -})(); diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/tabs.js b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/tabs.js deleted file mode 100644 index a689d474e..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/js/tabs.js +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Scripts which manages Code Toggle tabs. - */ -var i; -// store tabs variable -var allTabs = document.querySelectorAll("[data-toggle-tab]"); -var allPanes = document.querySelectorAll("[data-pane]"); - -function toggleTabs(event) { - - if(event.target){ - event.preventDefault(); - var clickedTab = event.currentTarget; - var targetKey = clickedTab.getAttribute("data-toggle-tab") - }else { - var targetKey = event - } - // We store the config language selected in users' localStorage - if(window.localStorage){ - window.localStorage.setItem("configLangPref", targetKey) - } - var selectedTabs = document.querySelectorAll("[data-toggle-tab='" + targetKey + "']"); - var selectedPanes = document.querySelectorAll("[data-pane='" + targetKey + "']"); - - for (var i = 0; i < allTabs.length; i++) { - allTabs[i].classList.remove("active"); - allPanes[i].classList.remove("active"); - } - - for (var i = 0; i < selectedTabs.length; i++) { - selectedTabs[i].classList.add("active"); - selectedPanes[i].classList.add("active"); - } - -} - -for (i = 0; i < allTabs.length; i++) { - allTabs[i].addEventListener("click", toggleTabs) -} -// Upon page load, if user has a preferred language in its localStorage, tabs are set to it. -if(window.localStorage.getItem('configLangPref')) { - toggleTabs(window.localStorage.getItem('configLangPref')) -} diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/output/css/app.css b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/output/css/app.css deleted file mode 100644 index 93c008e6e..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/output/css/app.css +++ /dev/null @@ -1,5282 +0,0 @@ -/* muli-200normal - latin */ -@font-face { - font-family: 'Muli'; - font-style: normal; - font-display: swap; - font-weight: 200; - src: - local('Muli Extra Light '), - local('Muli-Extra Light'), - url(/fonts/muli-latin-200.woff2) format('woff2'), - url(/fonts/muli-latin-200.woff) format('woff'); /* Modern Browsers */ -} -/* muli-200italic - latin */ -@font-face { - font-family: 'Muli'; - font-style: italic; - font-display: swap; - font-weight: 200; - src: - local('Muli Extra Light italic'), - local('Muli-Extra Lightitalic'), - url(/fonts/muli-latin-200italic.woff2) format('woff2'), - url(/fonts/muli-latin-200italic.woff) format('woff'); /* Modern Browsers */ -} -/* muli-300normal - latin */ -@font-face { - font-family: 'Muli'; - font-style: normal; - font-display: swap; - font-weight: 300; - src: - local('Muli Light '), - local('Muli-Light'), - url(/fonts/muli-latin-300.woff2) format('woff2'), - url(/fonts/muli-latin-300.woff) format('woff'); /* Modern Browsers */ -} -/* muli-300italic - latin */ -@font-face { - font-family: 'Muli'; - font-style: italic; - font-display: swap; - font-weight: 300; - src: - local('Muli Light italic'), - local('Muli-Lightitalic'), - url(/fonts/muli-latin-300italic.woff2) format('woff2'), - url(/fonts/muli-latin-300italic.woff) format('woff'); /* Modern Browsers */ -} -/* muli-400normal - latin */ -@font-face { - font-family: 'Muli'; - font-style: normal; - font-display: swap; - font-weight: 400; - src: - local('Muli Regular '), - local('Muli-Regular'), - url(/fonts/muli-latin-400.woff2) format('woff2'), - url(/fonts/muli-latin-400.woff) format('woff'); /* Modern Browsers */ -} -/* muli-400italic - latin */ -@font-face { - font-family: 'Muli'; - font-style: italic; - font-display: swap; - font-weight: 400; - src: - local('Muli Regular italic'), - local('Muli-Regularitalic'), - url(/fonts/muli-latin-400italic.woff2) format('woff2'), - url(/fonts/muli-latin-400italic.woff) format('woff'); /* Modern Browsers */ -} -/* muli-600normal - latin */ -@font-face { - font-family: 'Muli'; - font-style: normal; - font-display: swap; - font-weight: 600; - src: - local('Muli SemiBold '), - local('Muli-SemiBold'), - url(/fonts/muli-latin-600.woff2) format('woff2'), - url(/fonts/muli-latin-600.woff) format('woff'); /* Modern Browsers */ -} -/* muli-600italic - latin */ -@font-face { - font-family: 'Muli'; - font-style: italic; - font-display: swap; - font-weight: 600; - src: - local('Muli SemiBold italic'), - local('Muli-SemiBolditalic'), - url(/fonts/muli-latin-600italic.woff2) format('woff2'), - url(/fonts/muli-latin-600italic.woff) format('woff'); /* Modern Browsers */ -} -/* muli-700normal - latin */ -@font-face { - font-family: 'Muli'; - font-style: normal; - font-display: swap; - font-weight: 700; - src: - local('Muli Bold '), - local('Muli-Bold'), - url(/fonts/muli-latin-700.woff2) format('woff2'), - url(/fonts/muli-latin-700.woff) format('woff'); /* Modern Browsers */ -} -/* muli-700italic - latin */ -@font-face { - font-family: 'Muli'; - font-style: italic; - font-display: swap; - font-weight: 700; - src: - local('Muli Bold italic'), - local('Muli-Bolditalic'), - url(/fonts/muli-latin-700italic.woff2) format('woff2'), - url(/fonts/muli-latin-700italic.woff) format('woff'); /* Modern Browsers */ -} -/* muli-800normal - latin */ -@font-face { - font-family: 'Muli'; - font-style: normal; - font-display: swap; - font-weight: 800; - src: - local('Muli ExtraBold '), - local('Muli-ExtraBold'), - url(/fonts/muli-latin-800.woff2) format('woff2'), - url(/fonts/muli-latin-800.woff) format('woff'); /* Modern Browsers */ -} -/* muli-800italic - latin */ -@font-face { - font-family: 'Muli'; - font-style: italic; - font-display: swap; - font-weight: 800; - src: - local('Muli ExtraBold italic'), - local('Muli-ExtraBolditalic'), - url(/fonts/muli-latin-800italic.woff2) format('woff2'), - url(/fonts/muli-latin-800italic.woff) format('woff'); /* Modern Browsers */ -} -/* muli-900normal - latin */ -@font-face { - font-family: 'Muli'; - font-style: normal; - font-display: swap; - font-weight: 900; - src: - local('Muli Black '), - local('Muli-Black'), - url(/fonts/muli-latin-900.woff2) format('woff2'), - url(/fonts/muli-latin-900.woff) format('woff'); /* Modern Browsers */ -} -/* muli-900italic - latin */ -@font-face { - font-family: 'Muli'; - font-style: italic; - font-display: swap; - font-weight: 900; - src: - local('Muli Black italic'), - local('Muli-Blackitalic'), - url(/fonts/muli-latin-900italic.woff2) format('woff2'), - url(/fonts/muli-latin-900italic.woff) format('woff'); /* Modern Browsers */ -} - - -/*Base Styles*/ -/*! TACHYONS v4.7.0 | http://tachyons.io */ -/* - * NOTE: The Tachyons folder is for backup/reference only. This file references the module - * ________ ______ - * ___ __/_____ _________ /______ ______________________ - * __ / _ __ `/ ___/_ __ \_ / / / __ \_ __ \_ ___/ - * _ / / /_/ // /__ _ / / / /_/ // /_/ / / / /(__ ) - * /_/ \__,_/ \___/ /_/ /_/_\__, / \____//_/ /_//____/ - * /____/ - * - * TABLE OF CONTENTS - * - * 1. External Library Includes - * - Normalize.css | http://normalize.css.github.io - * 2. Tachyons Modules - * 3. Variables - * - Media Queries - * - Colors - * 4. Debugging - * - Debug all - * - Debug children - * - */ -/* External Library Includes */ -/*! normalize.css v8.0.0 | MIT License | github.com/necolas/normalize.css */ -/* Document - ========================================================================== */ -/** - * 1. Correct the line height in all browsers. - * 2. Prevent adjustments of font size after orientation changes in iOS. - */ -html { - line-height: 1.15; /* 1 */ - -webkit-text-size-adjust: 100%; /* 2 */ -} -/* Sections - ========================================================================== */ -/** - * Remove the margin in all browsers. - */ -body { - margin: 0; -} -/** - * Correct the font size and margin on `h1` elements within `section` and - * `article` contexts in Chrome, Firefox, and Safari. - */ -h1 { - font-size: 2em; - margin: 0.67em 0; -} -/* Grouping content - ========================================================================== */ -/** - * 1. Add the correct box sizing in Firefox. - * 2. Show the overflow in Edge and IE. - */ -hr { - -webkit-box-sizing: content-box; - box-sizing: content-box; /* 1 */ - height: 0; /* 1 */ - overflow: visible; /* 2 */ -} -/** - * 1. Correct the inheritance and scaling of font size in all browsers. - * 2. Correct the odd `em` font sizing in all browsers. - */ -pre { - font-family: monospace, monospace; /* 1 */ - font-size: 1em; /* 2 */ -} -/* Text-level semantics - ========================================================================== */ -/** - * Remove the gray background on active links in IE 10. - */ -a { - background-color: transparent; -} -/** - * 1. Remove the bottom border in Chrome 57- - * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. - */ -abbr[title] { - border-bottom: none; /* 1 */ - text-decoration: underline; /* 2 */ - -webkit-text-decoration: underline dotted; - text-decoration: underline dotted; /* 2 */ -} -/** - * Add the correct font weight in Chrome, Edge, and Safari. - */ -b, -strong { - font-weight: bolder; -} -/** - * 1. Correct the inheritance and scaling of font size in all browsers. - * 2. Correct the odd `em` font sizing in all browsers. - */ -code, -kbd, -samp { - font-family: monospace, monospace; /* 1 */ - font-size: 1em; /* 2 */ -} -/** - * Add the correct font size in all browsers. - */ -small { - font-size: 80%; -} -/** - * Prevent `sub` and `sup` elements from affecting the line height in - * all browsers. - */ -sub, -sup { - font-size: 75%; - line-height: 0; - position: relative; - vertical-align: baseline; -} -sub { - bottom: -0.25em; -} -sup { - top: -0.5em; -} -/* Embedded content - ========================================================================== */ -/** - * Remove the border on images inside links in IE 10. - */ -img { - border-style: none; -} -/* Forms - ========================================================================== */ -/** - * 1. Change the font styles in all browsers. - * 2. Remove the margin in Firefox and Safari. - */ -button, -input, -optgroup, -select, -textarea { - font-family: inherit; /* 1 */ - font-size: 100%; /* 1 */ - line-height: 1.15; /* 1 */ - margin: 0; /* 2 */ -} -/** - * Show the overflow in IE. - * 1. Show the overflow in Edge. - */ -button, -input { /* 1 */ - overflow: visible; -} -/** - * Remove the inheritance of text transform in Edge, Firefox, and IE. - * 1. Remove the inheritance of text transform in Firefox. - */ -button, -select { /* 1 */ - text-transform: none; -} -/** - * Correct the inability to style clickable types in iOS and Safari. - */ -button, -[type="button"], -[type="reset"], -[type="submit"] { - -webkit-appearance: button; -} -/** - * Remove the inner border and padding in Firefox. - */ -button::-moz-focus-inner, -[type="button"]::-moz-focus-inner, -[type="reset"]::-moz-focus-inner, -[type="submit"]::-moz-focus-inner { - border-style: none; - padding: 0; -} -/** - * Restore the focus styles unset by the previous rule. - */ -button:-moz-focusring, -[type="button"]:-moz-focusring, -[type="reset"]:-moz-focusring, -[type="submit"]:-moz-focusring { - outline: 1px dotted ButtonText; -} -/** - * Correct the padding in Firefox. - */ -fieldset { - padding: 0.35em 0.75em 0.625em; -} -/** - * 1. Correct the text wrapping in Edge and IE. - * 2. Correct the color inheritance from `fieldset` elements in IE. - * 3. Remove the padding so developers are not caught out when they zero out - * `fieldset` elements in all browsers. - */ -legend { - -webkit-box-sizing: border-box; - box-sizing: border-box; /* 1 */ - color: inherit; /* 2 */ - display: table; /* 1 */ - max-width: 100%; /* 1 */ - padding: 0; /* 3 */ - white-space: normal; /* 1 */ -} -/** - * Add the correct vertical alignment in Chrome, Firefox, and Opera. - */ -progress { - vertical-align: baseline; -} -/** - * Remove the default vertical scrollbar in IE 10+. - */ -textarea { - overflow: auto; -} -/** - * 1. Add the correct box sizing in IE 10. - * 2. Remove the padding in IE 10. - */ -[type="checkbox"], -[type="radio"] { - -webkit-box-sizing: border-box; - box-sizing: border-box; /* 1 */ - padding: 0; /* 2 */ -} -/** - * Correct the cursor style of increment and decrement buttons in Chrome. - */ -[type="number"]::-webkit-inner-spin-button, -[type="number"]::-webkit-outer-spin-button { - height: auto; -} -/** - * 1. Correct the odd appearance in Chrome and Safari. - * 2. Correct the outline style in Safari. - */ -[type="search"] { - -webkit-appearance: textfield; /* 1 */ - outline-offset: -2px; /* 2 */ -} -/** - * Remove the inner padding in Chrome and Safari on macOS. - */ -[type="search"]::-webkit-search-decoration { - -webkit-appearance: none; -} -/** - * 1. Correct the inability to style clickable types in iOS and Safari. - * 2. Change font properties to `inherit` in Safari. - */ -::-webkit-file-upload-button { - -webkit-appearance: button; /* 1 */ - font: inherit; /* 2 */ -} -/* Interactive - ========================================================================== */ -/* - * Add the correct display in Edge, IE 10+, and Firefox. - */ -details { - display: block; -} -/* - * Add the correct display in all browsers. - */ -summary { - display: list-item; -} -/* Misc - ========================================================================== */ -/** - * Add the correct display in IE 10+. - */ -template { - display: none; -} -/** - * Add the correct display in IE 10. - */ -[hidden] { - display: none; -} -/* Modules */ -/* - - BOX SIZING - -*/ -html, -body, -div, -article, -aside, -section, -main, -nav, -footer, -header, -form, -fieldset, -legend, -pre, -code, -a, -h1,h2,h3,h4,h5,h6, -p, -ul, -ol, -li, -dl, -dt, -dd, -blockquote, -figcaption, -figure, -textarea, -table, -td, -th, -tr, -input[type="email"], -input[type="number"], -input[type="password"], -input[type="tel"], -input[type="text"], -input[type="url"], -.border-box { - -webkit-box-sizing: border-box; - box-sizing: border-box; -} -/*@import 'tachyons/src/_aspect-ratios';*/ -/* - - IMAGES - Docs: http://tachyons.io/docs/elements/images/ - -*/ -/* Responsive images! */ -img { max-width: 100%; } -/* - - BACKGROUND SIZE - Docs: http://tachyons.io/docs/themes/background-size/ - - Media Query Extensions: - -ns = not-small - -m = medium - -l = large - -*/ -/* - Often used in combination with background image set as an inline style - on an html element. -*/ -.cover { background-size: cover!important; } -.contain { background-size: contain!important; } -@media screen and (min-width: 30em) { - .cover-ns { background-size: cover!important; } - .contain-ns { background-size: contain!important; } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .cover-m { background-size: cover!important; } - .contain-m { background-size: contain!important; } -} -@media screen and (min-width: 60em) { - .cover-l { background-size: cover!important; } - .contain-l { background-size: contain!important; } -} -/* - - BACKGROUND POSITION - - Base: - bg = background - - Modifiers: - -center = center center - -top = top center - -right = center right - -bottom = bottom center - -left = center left - - Media Query Extensions: - -ns = not-small - -m = medium - -l = large - - */ -.bg-center { - background-repeat: no-repeat; - background-position: center center; -} -.bg-top { - background-repeat: no-repeat; - background-position: top center; -} -.bg-right { - background-repeat: no-repeat; - background-position: center right; -} -.bg-bottom { - background-repeat: no-repeat; - background-position: bottom center; -} -.bg-left { - background-repeat: no-repeat; - background-position: center left; -} -@media screen and (min-width: 30em) { - .bg-center-ns { - background-repeat: no-repeat; - background-position: center center; - } - - .bg-top-ns { - background-repeat: no-repeat; - background-position: top center; - } - - .bg-right-ns { - background-repeat: no-repeat; - background-position: center right; - } - - .bg-bottom-ns { - background-repeat: no-repeat; - background-position: bottom center; - } - - .bg-left-ns { - background-repeat: no-repeat; - background-position: center left; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .bg-center-m { - background-repeat: no-repeat; - background-position: center center; - } - - .bg-top-m { - background-repeat: no-repeat; - background-position: top center; - } - - .bg-right-m { - background-repeat: no-repeat; - background-position: center right; - } - - .bg-bottom-m { - background-repeat: no-repeat; - background-position: bottom center; - } - - .bg-left-m { - background-repeat: no-repeat; - background-position: center left; - } -} -@media screen and (min-width: 60em) { - .bg-center-l { - background-repeat: no-repeat; - background-position: center center; - } - - .bg-top-l { - background-repeat: no-repeat; - background-position: top center; - } - - .bg-right-l { - background-repeat: no-repeat; - background-position: center right; - } - - .bg-bottom-l { - background-repeat: no-repeat; - background-position: bottom center; - } - - .bg-left-l { - background-repeat: no-repeat; - background-position: center left; - } -} -/*@import 'tachyons/src/_outlines';*/ -/* - - BORDERS - Docs: http://tachyons.io/docs/themes/borders/ - - Base: - b = border - - Modifiers: - a = all - t = top - r = right - b = bottom - l = left - n = none - - Media Query Extensions: - -ns = not-small - -m = medium - -l = large - -*/ -.ba { border-style: solid; border-width: 1px; } -.bt { border-top-style: solid; border-top-width: 1px; } -.br { border-right-style: solid; border-right-width: 1px; } -.bb { border-bottom-style: solid; border-bottom-width: 1px; } -.bl { border-left-style: solid; border-left-width: 1px; } -.bn { border-style: none; border-width: 0; } -@media screen and (min-width: 30em) { - .ba-ns { border-style: solid; border-width: 1px; } - .bt-ns { border-top-style: solid; border-top-width: 1px; } - .br-ns { border-right-style: solid; border-right-width: 1px; } - .bb-ns { border-bottom-style: solid; border-bottom-width: 1px; } - .bl-ns { border-left-style: solid; border-left-width: 1px; } - .bn-ns { border-style: none; border-width: 0; } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .ba-m { border-style: solid; border-width: 1px; } - .bt-m { border-top-style: solid; border-top-width: 1px; } - .br-m { border-right-style: solid; border-right-width: 1px; } - .bb-m { border-bottom-style: solid; border-bottom-width: 1px; } - .bl-m { border-left-style: solid; border-left-width: 1px; } - .bn-m { border-style: none; border-width: 0; } -} -@media screen and (min-width: 60em) { - .ba-l { border-style: solid; border-width: 1px; } - .bt-l { border-top-style: solid; border-top-width: 1px; } - .br-l { border-right-style: solid; border-right-width: 1px; } - .bb-l { border-bottom-style: solid; border-bottom-width: 1px; } - .bl-l { border-left-style: solid; border-left-width: 1px; } - .bn-l { border-style: none; border-width: 0; } -} -/* - - BORDER COLORS - Docs: http://tachyons.io/docs/themes/borders/ - - Border colors can be used to extend the base - border classes ba,bt,bb,br,bl found in the _borders.css file. - - The base border class by default will set the color of the border - to that of the current text color. These classes are for the cases - where you desire for the text and border colors to be different. - - Base: - b = border - - Modifiers: - --color-name = each color variable name is also a border color name - -*/ -.b--black { border-color: #000; } -.b--near-black { border-color: #111; } -.b--dark-gray { border-color: #333; } -.b--mid-gray { border-color: #555; } -.b--gray { border-color: #777; } -.b--silver { border-color: #999; } -.b--light-silver { border-color: #aaa; } -.b--moon-gray { border-color: #ccc; } -.b--light-gray { border-color: #eee; } -.b--near-white { border-color: #f4f4f4; } -.b--white { border-color: #fff; } -.b--white-90 { border-color: rgba(255, 255, 255, .9); } -.b--white-80 { border-color: rgba(255, 255, 255, .8); } -.b--white-70 { border-color: rgba(255, 255, 255, .7); } -.b--white-60 { border-color: rgba(255, 255, 255, .6); } -.b--white-50 { border-color: rgba(255, 255, 255, .5); } -.b--white-40 { border-color: rgba(255, 255, 255, .4); } -.b--white-30 { border-color: rgba(255, 255, 255, .3); } -.b--white-20 { border-color: rgba(255, 255, 255, .2); } -.b--white-10 { border-color: rgba(255, 255, 255, .1); } -.b--white-05 { border-color: rgba(255, 255, 255, .05); } -.b--white-025 { border-color: rgba(255, 255, 255, .025); } -.b--white-0125 { border-color: rgba(255, 255, 255, .0125); } -.b--black-90 { border-color: rgba(0, 0, 0, .9); } -.b--black-80 { border-color: rgba(0, 0, 0, .8); } -.b--black-70 { border-color: rgba(0, 0, 0, .7); } -.b--black-60 { border-color: rgba(0, 0, 0, .6); } -.b--black-50 { border-color: rgba(0, 0, 0, .5); } -.b--black-40 { border-color: rgba(0, 0, 0, .4); } -.b--black-30 { border-color: rgba(0, 0, 0, .3); } -.b--black-20 { border-color: rgba(0, 0, 0, .2); } -.b--black-10 { border-color: rgba(0, 0, 0, .1); } -.b--black-05 { border-color: rgba(0, 0, 0, .05); } -.b--black-025 { border-color: rgba(0, 0, 0, .025); } -.b--black-0125 { border-color: rgba(0, 0, 0, .0125); } -.b--dark-red { border-color: #e7040f; } -.b--red { border-color: #ff4136; } -.b--light-red { border-color: #ff725c; } -.b--orange { border-color: #ff6300; } -.b--gold { border-color: #ffb700; } -.b--yellow { border-color: #ffd700; } -.b--light-yellow { border-color: #fbf1a9; } -.b--purple { border-color: #5e2ca5; } -.b--light-purple { border-color: #a463f2; } -.b--dark-pink { border-color: #d5008f; } -.b--hot-pink { border-color: #ff41b4; } -.b--pink { border-color: #ff80cc; } -.b--light-pink { border-color: #ffa3d7; } -.b--dark-green { border-color: #137752; } -.b--green { border-color: #19a974; } -.b--light-green { border-color: #9eebcf; } -.b--navy { border-color: #001b44; } -.b--dark-blue { border-color: #00449e; } -.b--blue { border-color: #0594CB; } -.b--light-blue { border-color: #96ccff; } -.b--lightest-blue { border-color: #cdecff; } -.b--washed-blue { border-color: #f6fffe; } -.b--washed-green { border-color: #e8fdf5; } -.b--washed-yellow { border-color: #fffceb; } -.b--washed-red { border-color: #ffdfdf; } -.b--transparent { border-color: transparent; } -.b--inherit { border-color: inherit; } -/* - - BORDER RADIUS - Docs: http://tachyons.io/docs/themes/border-radius/ - - Base: - br = border-radius - - Modifiers: - 0 = 0/none - 1 = 1st step in scale - 2 = 2nd step in scale - 3 = 3rd step in scale - 4 = 4th step in scale - - Literal values: - -100 = 100% - -pill = 9999px - - Media Query Extensions: - -ns = not-small - -m = medium - -l = large - -*/ -.br0 { border-radius: 0; } -.br1 { border-radius: .125rem; } -.br2 { border-radius: .25rem; } -.br3 { border-radius: .5rem; } -.br4 { border-radius: 1rem; } -.br-100 { border-radius: 100%; } -.br-pill { border-radius: 9999px; } -.br--bottom { - border-top-left-radius: 0; - border-top-right-radius: 0; - } -.br--top { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - } -.br--right { - border-top-left-radius: 0; - border-bottom-left-radius: 0; - } -.br--left { - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } -@media screen and (min-width: 30em) { - .br0-ns { border-radius: 0; } - .br1-ns { border-radius: .125rem; } - .br2-ns { border-radius: .25rem; } - .br3-ns { border-radius: .5rem; } - .br4-ns { border-radius: 1rem; } - .br-100-ns { border-radius: 100%; } - .br-pill-ns { border-radius: 9999px; } - .br--bottom-ns { - border-top-left-radius: 0; - border-top-right-radius: 0; - } - .br--top-ns { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - } - .br--right-ns { - border-top-left-radius: 0; - border-bottom-left-radius: 0; - } - .br--left-ns { - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .br0-m { border-radius: 0; } - .br1-m { border-radius: .125rem; } - .br2-m { border-radius: .25rem; } - .br3-m { border-radius: .5rem; } - .br4-m { border-radius: 1rem; } - .br-100-m { border-radius: 100%; } - .br-pill-m { border-radius: 9999px; } - .br--bottom-m { - border-top-left-radius: 0; - border-top-right-radius: 0; - } - .br--top-m { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - } - .br--right-m { - border-top-left-radius: 0; - border-bottom-left-radius: 0; - } - .br--left-m { - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } -} -@media screen and (min-width: 60em) { - .br0-l { border-radius: 0; } - .br1-l { border-radius: .125rem; } - .br2-l { border-radius: .25rem; } - .br3-l { border-radius: .5rem; } - .br4-l { border-radius: 1rem; } - .br-100-l { border-radius: 100%; } - .br-pill-l { border-radius: 9999px; } - .br--bottom-l { - border-top-left-radius: 0; - border-top-right-radius: 0; - } - .br--top-l { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - } - .br--right-l { - border-top-left-radius: 0; - border-bottom-left-radius: 0; - } - .br--left-l { - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } -} -/* - - BORDER STYLES - Docs: http://tachyons.io/docs/themes/borders/ - - Depends on base border module in _borders.css - - Base: - b = border-style - - Modifiers: - --none = none - --dotted = dotted - --dashed = dashed - --solid = solid - - Media Query Extensions: - -ns = not-small - -m = medium - -l = large - - */ -.b--dotted { border-style: dotted; } -.b--dashed { border-style: dashed; } -.b--solid { border-style: solid; } -.b--none { border-style: none; } -@media screen and (min-width: 30em) { - .b--dotted-ns { border-style: dotted; } - .b--dashed-ns { border-style: dashed; } - .b--solid-ns { border-style: solid; } - .b--none-ns { border-style: none; } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .b--dotted-m { border-style: dotted; } - .b--dashed-m { border-style: dashed; } - .b--solid-m { border-style: solid; } - .b--none-m { border-style: none; } -} -@media screen and (min-width: 60em) { - .b--dotted-l { border-style: dotted; } - .b--dashed-l { border-style: dashed; } - .b--solid-l { border-style: solid; } - .b--none-l { border-style: none; } -} -/* - - BORDER WIDTHS - Docs: http://tachyons.io/docs/themes/borders/ - - Base: - bw = border-width - - Modifiers: - 0 = 0 width border - 1 = 1st step in border-width scale - 2 = 2nd step in border-width scale - 3 = 3rd step in border-width scale - 4 = 4th step in border-width scale - 5 = 5th step in border-width scale - - Media Query Extensions: - -ns = not-small - -m = medium - -l = large - -*/ -.bw0 { border-width: 0; } -.bw1 { border-width: .125rem; } -.bw2 { border-width: .25rem; } -.bw3 { border-width: .5rem; } -.bw4 { border-width: 1rem; } -.bw5 { border-width: 2rem; } -/* Resets */ -.bt-0 { border-top-width: 0; } -.br-0 { border-right-width: 0; } -.bb-0 { border-bottom-width: 0; } -.bl-0 { border-left-width: 0; } -@media screen and (min-width: 30em) { - .bw0-ns { border-width: 0; } - .bw1-ns { border-width: .125rem; } - .bw2-ns { border-width: .25rem; } - .bw3-ns { border-width: .5rem; } - .bw4-ns { border-width: 1rem; } - .bw5-ns { border-width: 2rem; } - .bt-0-ns { border-top-width: 0; } - .br-0-ns { border-right-width: 0; } - .bb-0-ns { border-bottom-width: 0; } - .bl-0-ns { border-left-width: 0; } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .bw0-m { border-width: 0; } - .bw1-m { border-width: .125rem; } - .bw2-m { border-width: .25rem; } - .bw3-m { border-width: .5rem; } - .bw4-m { border-width: 1rem; } - .bw5-m { border-width: 2rem; } - .bt-0-m { border-top-width: 0; } - .br-0-m { border-right-width: 0; } - .bb-0-m { border-bottom-width: 0; } - .bl-0-m { border-left-width: 0; } -} -@media screen and (min-width: 60em) { - .bw0-l { border-width: 0; } - .bw1-l { border-width: .125rem; } - .bw2-l { border-width: .25rem; } - .bw3-l { border-width: .5rem; } - .bw4-l { border-width: 1rem; } - .bw5-l { border-width: 2rem; } - .bt-0-l { border-top-width: 0; } - .br-0-l { border-right-width: 0; } - .bb-0-l { border-bottom-width: 0; } - .bl-0-l { border-left-width: 0; } -} -/* - - BOX-SHADOW - Docs: http://tachyons.io/docs/themes/box-shadow/ - - Media Query Extensions: - -ns = not-small - -m = medium - -l = large - - */ -.shadow-1 { -webkit-box-shadow: 0px 0px 4px 2px rgba(0, 0, 0, .2); box-shadow: 0px 0px 4px 2px rgba(0, 0, 0, .2); } -.shadow-2 { -webkit-box-shadow: 0px 0px 8px 2px rgba(0, 0, 0, .2); box-shadow: 0px 0px 8px 2px rgba(0, 0, 0, .2); } -.shadow-3 { -webkit-box-shadow: 2px 2px 4px 2px rgba(0, 0, 0, .2); box-shadow: 2px 2px 4px 2px rgba(0, 0, 0, .2); } -.shadow-4 { -webkit-box-shadow: 2px 2px 8px 0px rgba(0, 0, 0, .2); box-shadow: 2px 2px 8px 0px rgba(0, 0, 0, .2); } -.shadow-5 { -webkit-box-shadow: 4px 4px 8px 0px rgba(0, 0, 0, .2); box-shadow: 4px 4px 8px 0px rgba(0, 0, 0, .2); } -@media screen and (min-width: 30em) { - .shadow-1-ns { -webkit-box-shadow: 0px 0px 4px 2px rgba(0, 0, 0, .2); box-shadow: 0px 0px 4px 2px rgba(0, 0, 0, .2); } - .shadow-2-ns { -webkit-box-shadow: 0px 0px 8px 2px rgba(0, 0, 0, .2); box-shadow: 0px 0px 8px 2px rgba(0, 0, 0, .2); } - .shadow-3-ns { -webkit-box-shadow: 2px 2px 4px 2px rgba(0, 0, 0, .2); box-shadow: 2px 2px 4px 2px rgba(0, 0, 0, .2); } - .shadow-4-ns { -webkit-box-shadow: 2px 2px 8px 0px rgba(0, 0, 0, .2); box-shadow: 2px 2px 8px 0px rgba(0, 0, 0, .2); } - .shadow-5-ns { -webkit-box-shadow: 4px 4px 8px 0px rgba(0, 0, 0, .2); box-shadow: 4px 4px 8px 0px rgba(0, 0, 0, .2); } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .shadow-1-m { -webkit-box-shadow: 0px 0px 4px 2px rgba(0, 0, 0, .2); box-shadow: 0px 0px 4px 2px rgba(0, 0, 0, .2); } - .shadow-2-m { -webkit-box-shadow: 0px 0px 8px 2px rgba(0, 0, 0, .2); box-shadow: 0px 0px 8px 2px rgba(0, 0, 0, .2); } - .shadow-3-m { -webkit-box-shadow: 2px 2px 4px 2px rgba(0, 0, 0, .2); box-shadow: 2px 2px 4px 2px rgba(0, 0, 0, .2); } - .shadow-4-m { -webkit-box-shadow: 2px 2px 8px 0px rgba(0, 0, 0, .2); box-shadow: 2px 2px 8px 0px rgba(0, 0, 0, .2); } - .shadow-5-m { -webkit-box-shadow: 4px 4px 8px 0px rgba(0, 0, 0, .2); box-shadow: 4px 4px 8px 0px rgba(0, 0, 0, .2); } -} -@media screen and (min-width: 60em) { - .shadow-1-l { -webkit-box-shadow: 0px 0px 4px 2px rgba(0, 0, 0, .2); box-shadow: 0px 0px 4px 2px rgba(0, 0, 0, .2); } - .shadow-2-l { -webkit-box-shadow: 0px 0px 8px 2px rgba(0, 0, 0, .2); box-shadow: 0px 0px 8px 2px rgba(0, 0, 0, .2); } - .shadow-3-l { -webkit-box-shadow: 2px 2px 4px 2px rgba(0, 0, 0, .2); box-shadow: 2px 2px 4px 2px rgba(0, 0, 0, .2); } - .shadow-4-l { -webkit-box-shadow: 2px 2px 8px 0px rgba(0, 0, 0, .2); box-shadow: 2px 2px 8px 0px rgba(0, 0, 0, .2); } - .shadow-5-l { -webkit-box-shadow: 4px 4px 8px 0px rgba(0, 0, 0, .2); box-shadow: 4px 4px 8px 0px rgba(0, 0, 0, .2); } -} -/*@import 'tachyons/src/_code';*/ -/* - - COORDINATES - Docs: http://tachyons.io/docs/layout/position/ - - Use in combination with the position module. - - Base: - top - bottom - right - left - - Modifiers: - -0 = literal value 0 - -1 = literal value 1 - -2 = literal value 2 - --1 = literal value -1 - --2 = literal value -2 - - Media Query Extensions: - -ns = not-small - -m = medium - -l = large - -*/ -.top-0 { top: 0; } -.right-0 { right: 0; } -.bottom-0 { bottom: 0; } -.left-0 { left: 0; } -.top-1 { top: 1rem; } -.right-1 { right: 1rem; } -.bottom-1 { bottom: 1rem; } -.left-1 { left: 1rem; } -.top-2 { top: 2rem; } -.right-2 { right: 2rem; } -.bottom-2 { bottom: 2rem; } -.left-2 { left: 2rem; } -.top--1 { top: -1rem; } -.right--1 { right: -1rem; } -.bottom--1 { bottom: -1rem; } -.left--1 { left: -1rem; } -.top--2 { top: -2rem; } -.right--2 { right: -2rem; } -.bottom--2 { bottom: -2rem; } -.left--2 { left: -2rem; } -.absolute--fill { - top: 0; - right: 0; - bottom: 0; - left: 0; -} -@media screen and (min-width: 30em) { - .top-0-ns { top: 0; } - .left-0-ns { left: 0; } - .right-0-ns { right: 0; } - .bottom-0-ns { bottom: 0; } - .top-1-ns { top: 1rem; } - .left-1-ns { left: 1rem; } - .right-1-ns { right: 1rem; } - .bottom-1-ns { bottom: 1rem; } - .top-2-ns { top: 2rem; } - .left-2-ns { left: 2rem; } - .right-2-ns { right: 2rem; } - .bottom-2-ns { bottom: 2rem; } - .top--1-ns { top: -1rem; } - .right--1-ns { right: -1rem; } - .bottom--1-ns { bottom: -1rem; } - .left--1-ns { left: -1rem; } - .top--2-ns { top: -2rem; } - .right--2-ns { right: -2rem; } - .bottom--2-ns { bottom: -2rem; } - .left--2-ns { left: -2rem; } - .absolute--fill-ns { - top: 0; - right: 0; - bottom: 0; - left: 0; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .top-0-m { top: 0; } - .left-0-m { left: 0; } - .right-0-m { right: 0; } - .bottom-0-m { bottom: 0; } - .top-1-m { top: 1rem; } - .left-1-m { left: 1rem; } - .right-1-m { right: 1rem; } - .bottom-1-m { bottom: 1rem; } - .top-2-m { top: 2rem; } - .left-2-m { left: 2rem; } - .right-2-m { right: 2rem; } - .bottom-2-m { bottom: 2rem; } - .top--1-m { top: -1rem; } - .right--1-m { right: -1rem; } - .bottom--1-m { bottom: -1rem; } - .left--1-m { left: -1rem; } - .top--2-m { top: -2rem; } - .right--2-m { right: -2rem; } - .bottom--2-m { bottom: -2rem; } - .left--2-m { left: -2rem; } - .absolute--fill-m { - top: 0; - right: 0; - bottom: 0; - left: 0; - } -} -@media screen and (min-width: 60em) { - .top-0-l { top: 0; } - .left-0-l { left: 0; } - .right-0-l { right: 0; } - .bottom-0-l { bottom: 0; } - .top-1-l { top: 1rem; } - .left-1-l { left: 1rem; } - .right-1-l { right: 1rem; } - .bottom-1-l { bottom: 1rem; } - .top-2-l { top: 2rem; } - .left-2-l { left: 2rem; } - .right-2-l { right: 2rem; } - .bottom-2-l { bottom: 2rem; } - .top--1-l { top: -1rem; } - .right--1-l { right: -1rem; } - .bottom--1-l { bottom: -1rem; } - .left--1-l { left: -1rem; } - .top--2-l { top: -2rem; } - .right--2-l { right: -2rem; } - .bottom--2-l { bottom: -2rem; } - .left--2-l { left: -2rem; } - .absolute--fill-l { - top: 0; - right: 0; - bottom: 0; - left: 0; - } -} -/* - - CLEARFIX - http://tachyons.io/docs/layout/clearfix/ - -*/ -/* Nicolas Gallaghers Clearfix solution - Ref: http://nicolasgallagher.com/micro-clearfix-hack/ */ -.cf:before, -.cf:after { content: " "; display: table; } -.cf:after { clear: both; } -.cf { *zoom: 1; } -.cl { clear: left; } -.cr { clear: right; } -.cb { clear: both; } -.cn { clear: none; } -@media screen and (min-width: 30em) { - .cl-ns { clear: left; } - .cr-ns { clear: right; } - .cb-ns { clear: both; } - .cn-ns { clear: none; } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .cl-m { clear: left; } - .cr-m { clear: right; } - .cb-m { clear: both; } - .cn-m { clear: none; } -} -@media screen and (min-width: 60em) { - .cl-l { clear: left; } - .cr-l { clear: right; } - .cb-l { clear: both; } - .cn-l { clear: none; } -} -/* - - DISPLAY - Docs: http://tachyons.io/docs/layout/display - - Base: - d = display - - Modifiers: - n = none - b = block - ib = inline-block - it = inline-table - t = table - tc = table-cell - t-row = table-row - t-columm = table-column - t-column-group = table-column-group - - Media Query Extensions: - -ns = not-small - -m = medium - -l = large - -*/ -.dn { display: none; } -.di { display: inline; } -.db { display: block; } -.dib { display: inline-block; } -.dit { display: inline-table; } -.dt { display: table; } -.dtc { display: table-cell; } -.dt-row { display: table-row; } -.dt-row-group { display: table-row-group; } -.dt-column { display: table-column; } -.dt-column-group { display: table-column-group; } -/* - This will set table to full width and then - all cells will be equal width -*/ -.dt--fixed { - table-layout: fixed; - width: 100%; -} -@media screen and (min-width: 30em) { - .dn-ns { display: none; } - .di-ns { display: inline; } - .db-ns { display: block; } - .dib-ns { display: inline-block; } - .dit-ns { display: inline-table; } - .dt-ns { display: table; } - .dtc-ns { display: table-cell; } - .dt-row-ns { display: table-row; } - .dt-row-group-ns { display: table-row-group; } - .dt-column-ns { display: table-column; } - .dt-column-group-ns { display: table-column-group; } - - .dt--fixed-ns { - table-layout: fixed; - width: 100%; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .dn-m { display: none; } - .di-m { display: inline; } - .db-m { display: block; } - .dib-m { display: inline-block; } - .dit-m { display: inline-table; } - .dt-m { display: table; } - .dtc-m { display: table-cell; } - .dt-row-m { display: table-row; } - .dt-row-group-m { display: table-row-group; } - .dt-column-m { display: table-column; } - .dt-column-group-m { display: table-column-group; } - - .dt--fixed-m { - table-layout: fixed; - width: 100%; - } -} -@media screen and (min-width: 60em) { - .dn-l { display: none; } - .di-l { display: inline; } - .db-l { display: block; } - .dib-l { display: inline-block; } - .dit-l { display: inline-table; } - .dt-l { display: table; } - .dtc-l { display: table-cell; } - .dt-row-l { display: table-row; } - .dt-row-group-l { display: table-row-group; } - .dt-column-l { display: table-column; } - .dt-column-group-l { display: table-column-group; } - - .dt--fixed-l { - table-layout: fixed; - width: 100%; - } -} -/* - - FLEXBOX - - Media Query Extensions: - -ns = not-small - -m = medium - -l = large - -*/ -.flex { display: -webkit-box; display: -ms-flexbox; display: flex; } -.inline-flex { display: -webkit-inline-box; display: -ms-inline-flexbox; display: inline-flex; } -/* 1. Fix for Chrome 44 bug. - * https://code.google.com/p/chromium/issues/detail?id=506893 */ -.flex-auto { - -webkit-box-flex: 1; - -ms-flex: 1 1 auto; - flex: 1 1 auto; - min-width: 0; /* 1 */ - min-height: 0; /* 1 */ -} -.flex-none { -webkit-box-flex: 0; -ms-flex: none; flex: none; } -.flex-column { -webkit-box-orient: vertical; -webkit-box-direction: normal; -ms-flex-direction: column; flex-direction: column; } -.flex-row { -webkit-box-orient: horizontal; -webkit-box-direction: normal; -ms-flex-direction: row; flex-direction: row; } -.flex-wrap { -ms-flex-wrap: wrap; flex-wrap: wrap; } -.flex-nowrap { -ms-flex-wrap: nowrap; flex-wrap: nowrap; } -.flex-wrap-reverse { -ms-flex-wrap: wrap-reverse; flex-wrap: wrap-reverse; } -.flex-column-reverse { -webkit-box-orient: vertical; -webkit-box-direction: reverse; -ms-flex-direction: column-reverse; flex-direction: column-reverse; } -.flex-row-reverse { -webkit-box-orient: horizontal; -webkit-box-direction: reverse; -ms-flex-direction: row-reverse; flex-direction: row-reverse; } -.items-start { -webkit-box-align: start; -ms-flex-align: start; align-items: flex-start; } -.items-end { -webkit-box-align: end; -ms-flex-align: end; align-items: flex-end; } -.items-center { -webkit-box-align: center; -ms-flex-align: center; align-items: center; } -.items-baseline { -webkit-box-align: baseline; -ms-flex-align: baseline; align-items: baseline; } -.items-stretch { -webkit-box-align: stretch; -ms-flex-align: stretch; align-items: stretch; } -.self-start { -ms-flex-item-align: start; align-self: flex-start; } -.self-end { -ms-flex-item-align: end; align-self: flex-end; } -.self-center { -ms-flex-item-align: center; align-self: center; } -.self-baseline { -ms-flex-item-align: baseline; align-self: baseline; } -.self-stretch { -ms-flex-item-align: stretch; align-self: stretch; } -.justify-start { -webkit-box-pack: start; -ms-flex-pack: start; justify-content: flex-start; } -.justify-end { -webkit-box-pack: end; -ms-flex-pack: end; justify-content: flex-end; } -.justify-center { -webkit-box-pack: center; -ms-flex-pack: center; justify-content: center; } -.justify-between { -webkit-box-pack: justify; -ms-flex-pack: justify; justify-content: space-between; } -.justify-around { -ms-flex-pack: distribute; justify-content: space-around; } -.content-start { -ms-flex-line-pack: start; align-content: flex-start; } -.content-end { -ms-flex-line-pack: end; align-content: flex-end; } -.content-center { -ms-flex-line-pack: center; align-content: center; } -.content-between { -ms-flex-line-pack: justify; align-content: space-between; } -.content-around { -ms-flex-line-pack: distribute; align-content: space-around; } -.content-stretch { -ms-flex-line-pack: stretch; align-content: stretch; } -.order-0 { -webkit-box-ordinal-group: 1; -ms-flex-order: 0; order: 0; } -.order-1 { -webkit-box-ordinal-group: 2; -ms-flex-order: 1; order: 1; } -.order-2 { -webkit-box-ordinal-group: 3; -ms-flex-order: 2; order: 2; } -.order-3 { -webkit-box-ordinal-group: 4; -ms-flex-order: 3; order: 3; } -.order-4 { -webkit-box-ordinal-group: 5; -ms-flex-order: 4; order: 4; } -.order-5 { -webkit-box-ordinal-group: 6; -ms-flex-order: 5; order: 5; } -.order-6 { -webkit-box-ordinal-group: 7; -ms-flex-order: 6; order: 6; } -.order-7 { -webkit-box-ordinal-group: 8; -ms-flex-order: 7; order: 7; } -.order-8 { -webkit-box-ordinal-group: 9; -ms-flex-order: 8; order: 8; } -.order-last { -webkit-box-ordinal-group: 100000; -ms-flex-order: 99999; order: 99999; } -.flex-grow-0 { -webkit-box-flex: 0; -ms-flex-positive: 0; flex-grow: 0; } -.flex-grow-1 { -webkit-box-flex: 1; -ms-flex-positive: 1; flex-grow: 1; } -.flex-shrink-0 { -ms-flex-negative: 0; flex-shrink: 0; } -.flex-shrink-1 { -ms-flex-negative: 1; flex-shrink: 1; } -@media screen and (min-width: 30em) { - .flex-ns { display: -webkit-box; display: -ms-flexbox; display: flex; } - .inline-flex-ns { display: -webkit-inline-box; display: -ms-inline-flexbox; display: inline-flex; } - .flex-auto-ns { - -webkit-box-flex: 1; - -ms-flex: 1 1 auto; - flex: 1 1 auto; - min-width: 0; /* 1 */ - min-height: 0; /* 1 */ - } - .flex-none-ns { -webkit-box-flex: 0; -ms-flex: none; flex: none; } - .flex-column-ns { -webkit-box-orient: vertical; -webkit-box-direction: normal; -ms-flex-direction: column; flex-direction: column; } - .flex-row-ns { -webkit-box-orient: horizontal; -webkit-box-direction: normal; -ms-flex-direction: row; flex-direction: row; } - .flex-wrap-ns { -ms-flex-wrap: wrap; flex-wrap: wrap; } - .flex-nowrap-ns { -ms-flex-wrap: nowrap; flex-wrap: nowrap; } - .flex-wrap-reverse-ns { -ms-flex-wrap: wrap-reverse; flex-wrap: wrap-reverse; } - .flex-column-reverse-ns { -webkit-box-orient: vertical; -webkit-box-direction: reverse; -ms-flex-direction: column-reverse; flex-direction: column-reverse; } - .flex-row-reverse-ns { -webkit-box-orient: horizontal; -webkit-box-direction: reverse; -ms-flex-direction: row-reverse; flex-direction: row-reverse; } - .items-start-ns { -webkit-box-align: start; -ms-flex-align: start; align-items: flex-start; } - .items-end-ns { -webkit-box-align: end; -ms-flex-align: end; align-items: flex-end; } - .items-center-ns { -webkit-box-align: center; -ms-flex-align: center; align-items: center; } - .items-baseline-ns { -webkit-box-align: baseline; -ms-flex-align: baseline; align-items: baseline; } - .items-stretch-ns { -webkit-box-align: stretch; -ms-flex-align: stretch; align-items: stretch; } - - .self-start-ns { -ms-flex-item-align: start; align-self: flex-start; } - .self-end-ns { -ms-flex-item-align: end; align-self: flex-end; } - .self-center-ns { -ms-flex-item-align: center; align-self: center; } - .self-baseline-ns { -ms-flex-item-align: baseline; align-self: baseline; } - .self-stretch-ns { -ms-flex-item-align: stretch; align-self: stretch; } - - .justify-start-ns { -webkit-box-pack: start; -ms-flex-pack: start; justify-content: flex-start; } - .justify-end-ns { -webkit-box-pack: end; -ms-flex-pack: end; justify-content: flex-end; } - .justify-center-ns { -webkit-box-pack: center; -ms-flex-pack: center; justify-content: center; } - .justify-between-ns { -webkit-box-pack: justify; -ms-flex-pack: justify; justify-content: space-between; } - .justify-around-ns { -ms-flex-pack: distribute; justify-content: space-around; } - - .content-start-ns { -ms-flex-line-pack: start; align-content: flex-start; } - .content-end-ns { -ms-flex-line-pack: end; align-content: flex-end; } - .content-center-ns { -ms-flex-line-pack: center; align-content: center; } - .content-between-ns { -ms-flex-line-pack: justify; align-content: space-between; } - .content-around-ns { -ms-flex-line-pack: distribute; align-content: space-around; } - .content-stretch-ns { -ms-flex-line-pack: stretch; align-content: stretch; } - - .order-0-ns { -webkit-box-ordinal-group: 1; -ms-flex-order: 0; order: 0; } - .order-1-ns { -webkit-box-ordinal-group: 2; -ms-flex-order: 1; order: 1; } - .order-2-ns { -webkit-box-ordinal-group: 3; -ms-flex-order: 2; order: 2; } - .order-3-ns { -webkit-box-ordinal-group: 4; -ms-flex-order: 3; order: 3; } - .order-4-ns { -webkit-box-ordinal-group: 5; -ms-flex-order: 4; order: 4; } - .order-5-ns { -webkit-box-ordinal-group: 6; -ms-flex-order: 5; order: 5; } - .order-6-ns { -webkit-box-ordinal-group: 7; -ms-flex-order: 6; order: 6; } - .order-7-ns { -webkit-box-ordinal-group: 8; -ms-flex-order: 7; order: 7; } - .order-8-ns { -webkit-box-ordinal-group: 9; -ms-flex-order: 8; order: 8; } - .order-last-ns { -webkit-box-ordinal-group: 100000; -ms-flex-order: 99999; order: 99999; } - - .flex-grow-0-ns { -webkit-box-flex: 0; -ms-flex-positive: 0; flex-grow: 0; } - .flex-grow-1-ns { -webkit-box-flex: 1; -ms-flex-positive: 1; flex-grow: 1; } - - .flex-shrink-0-ns { -ms-flex-negative: 0; flex-shrink: 0; } - .flex-shrink-1-ns { -ms-flex-negative: 1; flex-shrink: 1; } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .flex-m { display: -webkit-box; display: -ms-flexbox; display: flex; } - .inline-flex-m { display: -webkit-inline-box; display: -ms-inline-flexbox; display: inline-flex; } - .flex-auto-m { - -webkit-box-flex: 1; - -ms-flex: 1 1 auto; - flex: 1 1 auto; - min-width: 0; /* 1 */ - min-height: 0; /* 1 */ - } - .flex-none-m { -webkit-box-flex: 0; -ms-flex: none; flex: none; } - .flex-column-m { -webkit-box-orient: vertical; -webkit-box-direction: normal; -ms-flex-direction: column; flex-direction: column; } - .flex-row-m { -webkit-box-orient: horizontal; -webkit-box-direction: normal; -ms-flex-direction: row; flex-direction: row; } - .flex-wrap-m { -ms-flex-wrap: wrap; flex-wrap: wrap; } - .flex-nowrap-m { -ms-flex-wrap: nowrap; flex-wrap: nowrap; } - .flex-wrap-reverse-m { -ms-flex-wrap: wrap-reverse; flex-wrap: wrap-reverse; } - .flex-column-reverse-m { -webkit-box-orient: vertical; -webkit-box-direction: reverse; -ms-flex-direction: column-reverse; flex-direction: column-reverse; } - .flex-row-reverse-m { -webkit-box-orient: horizontal; -webkit-box-direction: reverse; -ms-flex-direction: row-reverse; flex-direction: row-reverse; } - .items-start-m { -webkit-box-align: start; -ms-flex-align: start; align-items: flex-start; } - .items-end-m { -webkit-box-align: end; -ms-flex-align: end; align-items: flex-end; } - .items-center-m { -webkit-box-align: center; -ms-flex-align: center; align-items: center; } - .items-baseline-m { -webkit-box-align: baseline; -ms-flex-align: baseline; align-items: baseline; } - .items-stretch-m { -webkit-box-align: stretch; -ms-flex-align: stretch; align-items: stretch; } - - .self-start-m { -ms-flex-item-align: start; align-self: flex-start; } - .self-end-m { -ms-flex-item-align: end; align-self: flex-end; } - .self-center-m { -ms-flex-item-align: center; align-self: center; } - .self-baseline-m { -ms-flex-item-align: baseline; align-self: baseline; } - .self-stretch-m { -ms-flex-item-align: stretch; align-self: stretch; } - - .justify-start-m { -webkit-box-pack: start; -ms-flex-pack: start; justify-content: flex-start; } - .justify-end-m { -webkit-box-pack: end; -ms-flex-pack: end; justify-content: flex-end; } - .justify-center-m { -webkit-box-pack: center; -ms-flex-pack: center; justify-content: center; } - .justify-between-m { -webkit-box-pack: justify; -ms-flex-pack: justify; justify-content: space-between; } - .justify-around-m { -ms-flex-pack: distribute; justify-content: space-around; } - - .content-start-m { -ms-flex-line-pack: start; align-content: flex-start; } - .content-end-m { -ms-flex-line-pack: end; align-content: flex-end; } - .content-center-m { -ms-flex-line-pack: center; align-content: center; } - .content-between-m { -ms-flex-line-pack: justify; align-content: space-between; } - .content-around-m { -ms-flex-line-pack: distribute; align-content: space-around; } - .content-stretch-m { -ms-flex-line-pack: stretch; align-content: stretch; } - - .order-0-m { -webkit-box-ordinal-group: 1; -ms-flex-order: 0; order: 0; } - .order-1-m { -webkit-box-ordinal-group: 2; -ms-flex-order: 1; order: 1; } - .order-2-m { -webkit-box-ordinal-group: 3; -ms-flex-order: 2; order: 2; } - .order-3-m { -webkit-box-ordinal-group: 4; -ms-flex-order: 3; order: 3; } - .order-4-m { -webkit-box-ordinal-group: 5; -ms-flex-order: 4; order: 4; } - .order-5-m { -webkit-box-ordinal-group: 6; -ms-flex-order: 5; order: 5; } - .order-6-m { -webkit-box-ordinal-group: 7; -ms-flex-order: 6; order: 6; } - .order-7-m { -webkit-box-ordinal-group: 8; -ms-flex-order: 7; order: 7; } - .order-8-m { -webkit-box-ordinal-group: 9; -ms-flex-order: 8; order: 8; } - .order-last-m { -webkit-box-ordinal-group: 100000; -ms-flex-order: 99999; order: 99999; } - - .flex-grow-0-m { -webkit-box-flex: 0; -ms-flex-positive: 0; flex-grow: 0; } - .flex-grow-1-m { -webkit-box-flex: 1; -ms-flex-positive: 1; flex-grow: 1; } - - .flex-shrink-0-m { -ms-flex-negative: 0; flex-shrink: 0; } - .flex-shrink-1-m { -ms-flex-negative: 1; flex-shrink: 1; } -} -@media screen and (min-width: 60em) { - .flex-l { display: -webkit-box; display: -ms-flexbox; display: flex; } - .inline-flex-l { display: -webkit-inline-box; display: -ms-inline-flexbox; display: inline-flex; } - .flex-auto-l { - -webkit-box-flex: 1; - -ms-flex: 1 1 auto; - flex: 1 1 auto; - min-width: 0; /* 1 */ - min-height: 0; /* 1 */ - } - .flex-none-l { -webkit-box-flex: 0; -ms-flex: none; flex: none; } - .flex-column-l { -webkit-box-orient: vertical; -webkit-box-direction: normal; -ms-flex-direction: column; flex-direction: column; } - .flex-row-l { -webkit-box-orient: horizontal; -webkit-box-direction: normal; -ms-flex-direction: row; flex-direction: row; } - .flex-wrap-l { -ms-flex-wrap: wrap; flex-wrap: wrap; } - .flex-nowrap-l { -ms-flex-wrap: nowrap; flex-wrap: nowrap; } - .flex-wrap-reverse-l { -ms-flex-wrap: wrap-reverse; flex-wrap: wrap-reverse; } - .flex-column-reverse-l { -webkit-box-orient: vertical; -webkit-box-direction: reverse; -ms-flex-direction: column-reverse; flex-direction: column-reverse; } - .flex-row-reverse-l { -webkit-box-orient: horizontal; -webkit-box-direction: reverse; -ms-flex-direction: row-reverse; flex-direction: row-reverse; } - - .items-start-l { -webkit-box-align: start; -ms-flex-align: start; align-items: flex-start; } - .items-end-l { -webkit-box-align: end; -ms-flex-align: end; align-items: flex-end; } - .items-center-l { -webkit-box-align: center; -ms-flex-align: center; align-items: center; } - .items-baseline-l { -webkit-box-align: baseline; -ms-flex-align: baseline; align-items: baseline; } - .items-stretch-l { -webkit-box-align: stretch; -ms-flex-align: stretch; align-items: stretch; } - - .self-start-l { -ms-flex-item-align: start; align-self: flex-start; } - .self-end-l { -ms-flex-item-align: end; align-self: flex-end; } - .self-center-l { -ms-flex-item-align: center; align-self: center; } - .self-baseline-l { -ms-flex-item-align: baseline; align-self: baseline; } - .self-stretch-l { -ms-flex-item-align: stretch; align-self: stretch; } - - .justify-start-l { -webkit-box-pack: start; -ms-flex-pack: start; justify-content: flex-start; } - .justify-end-l { -webkit-box-pack: end; -ms-flex-pack: end; justify-content: flex-end; } - .justify-center-l { -webkit-box-pack: center; -ms-flex-pack: center; justify-content: center; } - .justify-between-l { -webkit-box-pack: justify; -ms-flex-pack: justify; justify-content: space-between; } - .justify-around-l { -ms-flex-pack: distribute; justify-content: space-around; } - - .content-start-l { -ms-flex-line-pack: start; align-content: flex-start; } - .content-end-l { -ms-flex-line-pack: end; align-content: flex-end; } - .content-center-l { -ms-flex-line-pack: center; align-content: center; } - .content-between-l { -ms-flex-line-pack: justify; align-content: space-between; } - .content-around-l { -ms-flex-line-pack: distribute; align-content: space-around; } - .content-stretch-l { -ms-flex-line-pack: stretch; align-content: stretch; } - - .order-0-l { -webkit-box-ordinal-group: 1; -ms-flex-order: 0; order: 0; } - .order-1-l { -webkit-box-ordinal-group: 2; -ms-flex-order: 1; order: 1; } - .order-2-l { -webkit-box-ordinal-group: 3; -ms-flex-order: 2; order: 2; } - .order-3-l { -webkit-box-ordinal-group: 4; -ms-flex-order: 3; order: 3; } - .order-4-l { -webkit-box-ordinal-group: 5; -ms-flex-order: 4; order: 4; } - .order-5-l { -webkit-box-ordinal-group: 6; -ms-flex-order: 5; order: 5; } - .order-6-l { -webkit-box-ordinal-group: 7; -ms-flex-order: 6; order: 6; } - .order-7-l { -webkit-box-ordinal-group: 8; -ms-flex-order: 7; order: 7; } - .order-8-l { -webkit-box-ordinal-group: 9; -ms-flex-order: 8; order: 8; } - .order-last-l { -webkit-box-ordinal-group: 100000; -ms-flex-order: 99999; order: 99999; } - - .flex-grow-0-l { -webkit-box-flex: 0; -ms-flex-positive: 0; flex-grow: 0; } - .flex-grow-1-l { -webkit-box-flex: 1; -ms-flex-positive: 1; flex-grow: 1; } - - .flex-shrink-0-l { -ms-flex-negative: 0; flex-shrink: 0; } - .flex-shrink-1-l { -ms-flex-negative: 1; flex-shrink: 1; } -} -/* - - FLOATS - http://tachyons.io/docs/layout/floats/ - - 1. Floated elements are automatically rendered as block level elements. - Setting floats to display inline will fix the double margin bug in - ie6. You know... just in case. - - 2. Don't forget to clearfix your floats with .cf - - Base: - f = float - - Modifiers: - l = left - r = right - n = none - - Media Query Extensions: - -ns = not-small - -m = medium - -l = large - -*/ -.fl { float: left; _display: inline; } -.fr { float: right; _display: inline; } -.fn { float: none; } -@media screen and (min-width: 30em) { - .fl-ns { float: left; _display: inline; } - .fr-ns { float: right; _display: inline; } - .fn-ns { float: none; } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .fl-m { float: left; _display: inline; } - .fr-m { float: right; _display: inline; } - .fn-m { float: none; } -} -@media screen and (min-width: 60em) { - .fl-l { float: left; _display: inline; } - .fr-l { float: right; _display: inline; } - .fn-l { float: none; } -} -/*@import 'tachyons/src/_font-family';*/ -/* - - FONT STYLE - Docs: http://tachyons.io/docs/typography/font-style/ - - Media Query Extensions: - -ns = not-small - -m = medium - -l = large - -*/ -.i { font-style: italic; } -.fs-normal { font-style: normal; } -@media screen and (min-width: 30em) { - .i-ns { font-style: italic; } - .fs-normal-ns { font-style: normal; } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .i-m { font-style: italic; } - .fs-normal-m { font-style: normal; } -} -@media screen and (min-width: 60em) { - .i-l { font-style: italic; } - .fs-normal-l { font-style: normal; } -} -/* - - FONT WEIGHT - Docs: http://tachyons.io/docs/typography/font-weight/ - - Base - fw = font-weight - - Modifiers: - 1 = literal value 100 - 2 = literal value 200 - 3 = literal value 300 - 4 = literal value 400 - 5 = literal value 500 - 6 = literal value 600 - 7 = literal value 700 - 8 = literal value 800 - 9 = literal value 900 - - Media Query Extensions: - -ns = not-small - -m = medium - -l = large - -*/ -.normal { font-weight: normal; } -.b { font-weight: bold; } -.fw1 { font-weight: 100; } -.fw2 { font-weight: 200; } -.fw3 { font-weight: 300; } -.fw4 { font-weight: 400; } -.fw5 { font-weight: 500; } -.fw6 { font-weight: 600; } -.fw7 { font-weight: 700; } -.fw8 { font-weight: 800; } -.fw9 { font-weight: 900; } -@media screen and (min-width: 30em) { - .normal-ns { font-weight: normal; } - .b-ns { font-weight: bold; } - .fw1-ns { font-weight: 100; } - .fw2-ns { font-weight: 200; } - .fw3-ns { font-weight: 300; } - .fw4-ns { font-weight: 400; } - .fw5-ns { font-weight: 500; } - .fw6-ns { font-weight: 600; } - .fw7-ns { font-weight: 700; } - .fw8-ns { font-weight: 800; } - .fw9-ns { font-weight: 900; } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .normal-m { font-weight: normal; } - .b-m { font-weight: bold; } - .fw1-m { font-weight: 100; } - .fw2-m { font-weight: 200; } - .fw3-m { font-weight: 300; } - .fw4-m { font-weight: 400; } - .fw5-m { font-weight: 500; } - .fw6-m { font-weight: 600; } - .fw7-m { font-weight: 700; } - .fw8-m { font-weight: 800; } - .fw9-m { font-weight: 900; } -} -@media screen and (min-width: 60em) { - .normal-l { font-weight: normal; } - .b-l { font-weight: bold; } - .fw1-l { font-weight: 100; } - .fw2-l { font-weight: 200; } - .fw3-l { font-weight: 300; } - .fw4-l { font-weight: 400; } - .fw5-l { font-weight: 500; } - .fw6-l { font-weight: 600; } - .fw7-l { font-weight: 700; } - .fw8-l { font-weight: 800; } - .fw9-l { font-weight: 900; } -} -/* - - FORMS - -*/ -.input-reset { - -webkit-appearance: none; - -moz-appearance: none; -} -.button-reset::-moz-focus-inner, -.input-reset::-moz-focus-inner { - border: 0; - padding: 0; -} -/* - - HEIGHTS - Docs: http://tachyons.io/docs/layout/heights/ - - Base: - h = height - min-h = min-height - min-vh = min-height vertical screen height - vh = vertical screen height - - Modifiers - 1 = 1st step in height scale - 2 = 2nd step in height scale - 3 = 3rd step in height scale - 4 = 4th step in height scale - 5 = 5th step in height scale - - -25 = literal value 25% - -50 = literal value 50% - -75 = literal value 75% - -100 = literal value 100% - - -auto = string value of auto - -inherit = string value of inherit - - Media Query Extensions: - -ns = not-small - -m = medium - -l = large - -*/ -/* Height Scale */ -.h1 { height: 1rem; } -.h2 { height: 2rem; } -.h3 { height: 4rem; } -.h4 { height: 8rem; } -.h5 { height: 16rem; } -/* Height Percentages - Based off of height of parent */ -.h-25 { height: 25%; } -.h-50 { height: 50%; } -.h-75 { height: 75%; } -.h-100 { height: 100%; } -.min-h-100 { min-height: 100%; } -/* Screen Height Percentage */ -.vh-25 { height: 25vh; } -.vh-50 { height: 50vh; } -.vh-75 { height: 75vh; } -.vh-100 { height: 100vh; } -.min-vh-100 { min-height: 100vh; } -/* String Properties */ -.h-auto { height: auto; } -.h-inherit { height: inherit; } -@media screen and (min-width: 30em) { - .h1-ns { height: 1rem; } - .h2-ns { height: 2rem; } - .h3-ns { height: 4rem; } - .h4-ns { height: 8rem; } - .h5-ns { height: 16rem; } - .h-25-ns { height: 25%; } - .h-50-ns { height: 50%; } - .h-75-ns { height: 75%; } - .h-100-ns { height: 100%; } - .min-h-100-ns { min-height: 100%; } - .vh-25-ns { height: 25vh; } - .vh-50-ns { height: 50vh; } - .vh-75-ns { height: 75vh; } - .vh-100-ns { height: 100vh; } - .min-vh-100-ns { min-height: 100vh; } - .h-auto-ns { height: auto; } - .h-inherit-ns { height: inherit; } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .h1-m { height: 1rem; } - .h2-m { height: 2rem; } - .h3-m { height: 4rem; } - .h4-m { height: 8rem; } - .h5-m { height: 16rem; } - .h-25-m { height: 25%; } - .h-50-m { height: 50%; } - .h-75-m { height: 75%; } - .h-100-m { height: 100%; } - .min-h-100-m { min-height: 100%; } - .vh-25-m { height: 25vh; } - .vh-50-m { height: 50vh; } - .vh-75-m { height: 75vh; } - .vh-100-m { height: 100vh; } - .min-vh-100-m { min-height: 100vh; } - .h-auto-m { height: auto; } - .h-inherit-m { height: inherit; } -} -@media screen and (min-width: 60em) { - .h1-l { height: 1rem; } - .h2-l { height: 2rem; } - .h3-l { height: 4rem; } - .h4-l { height: 8rem; } - .h5-l { height: 16rem; } - .h-25-l { height: 25%; } - .h-50-l { height: 50%; } - .h-75-l { height: 75%; } - .h-100-l { height: 100%; } - .min-h-100-l { min-height: 100%; } - .vh-25-l { height: 25vh; } - .vh-50-l { height: 50vh; } - .vh-75-l { height: 75vh; } - .vh-100-l { height: 100vh; } - .min-vh-100-l { min-height: 100vh; } - .h-auto-l { height: auto; } - .h-inherit-l { height: inherit; } -} -/* - - LETTER SPACING - Docs: http://tachyons.io/docs/typography/tracking/ - - Media Query Extensions: - -ns = not-small - -m = medium - -l = large - -*/ -.tracked { letter-spacing: .1em; } -.tracked-tight { letter-spacing: -.05em; } -.tracked-mega { letter-spacing: .25em; } -@media screen and (min-width: 30em) { - .tracked-ns { letter-spacing: .1em; } - .tracked-tight-ns { letter-spacing: -.05em; } - .tracked-mega-ns { letter-spacing: .25em; } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .tracked-m { letter-spacing: .1em; } - .tracked-tight-m { letter-spacing: -.05em; } - .tracked-mega-m { letter-spacing: .25em; } -} -@media screen and (min-width: 60em) { - .tracked-l { letter-spacing: .1em; } - .tracked-tight-l { letter-spacing: -.05em; } - .tracked-mega-l { letter-spacing: .25em; } -} -/* - - LINE HEIGHT / LEADING - Docs: http://tachyons.io/docs/typography/line-height - - Media Query Extensions: - -ns = not-small - -m = medium - -l = large - -*/ -.lh-solid { line-height: 1; } -.lh-title { line-height: 1.25; } -.lh-copy { line-height: 1.5; } -@media screen and (min-width: 30em) { - .lh-solid-ns { line-height: 1; } - .lh-title-ns { line-height: 1.25; } - .lh-copy-ns { line-height: 1.5; } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .lh-solid-m { line-height: 1; } - .lh-title-m { line-height: 1.25; } - .lh-copy-m { line-height: 1.5; } -} -@media screen and (min-width: 60em) { - .lh-solid-l { line-height: 1; } - .lh-title-l { line-height: 1.25; } - .lh-copy-l { line-height: 1.5; } -} -/* - - LINKS - Docs: http://tachyons.io/docs/elements/links/ - -*/ -.link { - text-decoration: none; - -webkit-transition: color .15s ease-in; - transition: color .15s ease-in; -} -.link:link, -.link:visited { - -webkit-transition: color .15s ease-in; - transition: color .15s ease-in; -} -.link:hover { - -webkit-transition: color .15s ease-in; - transition: color .15s ease-in; -} -.link:active { - -webkit-transition: color .15s ease-in; - transition: color .15s ease-in; -} -.link:focus { - -webkit-transition: color .15s ease-in; - transition: color .15s ease-in; - outline: 1px dotted currentColor; -} -/* - - LISTS - http://tachyons.io/docs/elements/lists/ - -*/ -.list { list-style-type: none; } -/* - - MAX WIDTHS - Docs: http://tachyons.io/docs/layout/max-widths/ - - Base: - mw = max-width - - Modifiers - 1 = 1st step in width scale - 2 = 2nd step in width scale - 3 = 3rd step in width scale - 4 = 4th step in width scale - 5 = 5th step in width scale - 6 = 6st step in width scale - 7 = 7nd step in width scale - 8 = 8rd step in width scale - 9 = 9th step in width scale - - -100 = literal value 100% - - -none = string value none - - - Media Query Extensions: - -ns = not-small - -m = medium - -l = large - -*/ -/* Max Width Percentages */ -.mw-100 { max-width: 100%; } -/* Max Width Scale */ -.mw1 { max-width: 1rem; } -.mw2 { max-width: 2rem; } -.mw3 { max-width: 4rem; } -.mw4 { max-width: 8rem; } -.mw5 { max-width: 16rem; } -.mw6 { max-width: 32rem; } -.mw7 { max-width: 48rem; } -.mw8 { max-width: 64rem; } -.mw9 { max-width: 96rem; } -/* Max Width String Properties */ -.mw-none { max-width: none; } -@media screen and (min-width: 30em) { - .mw-100-ns { max-width: 100%; } - - .mw1-ns { max-width: 1rem; } - .mw2-ns { max-width: 2rem; } - .mw3-ns { max-width: 4rem; } - .mw4-ns { max-width: 8rem; } - .mw5-ns { max-width: 16rem; } - .mw6-ns { max-width: 32rem; } - .mw7-ns { max-width: 48rem; } - .mw8-ns { max-width: 64rem; } - .mw9-ns { max-width: 96rem; } - - .mw-none-ns { max-width: none; } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .mw-100-m { max-width: 100%; } - - .mw1-m { max-width: 1rem; } - .mw2-m { max-width: 2rem; } - .mw3-m { max-width: 4rem; } - .mw4-m { max-width: 8rem; } - .mw5-m { max-width: 16rem; } - .mw6-m { max-width: 32rem; } - .mw7-m { max-width: 48rem; } - .mw8-m { max-width: 64rem; } - .mw9-m { max-width: 96rem; } - - .mw-none-m { max-width: none; } -} -@media screen and (min-width: 60em) { - .mw-100-l { max-width: 100%; } - - .mw1-l { max-width: 1rem; } - .mw2-l { max-width: 2rem; } - .mw3-l { max-width: 4rem; } - .mw4-l { max-width: 8rem; } - .mw5-l { max-width: 16rem; } - .mw6-l { max-width: 32rem; } - .mw7-l { max-width: 48rem; } - .mw8-l { max-width: 64rem; } - .mw9-l { max-width: 96rem; } - - .mw-none-l { max-width: none; } -} -/* - - WIDTHS - Docs: http://tachyons.io/docs/layout/widths/ - - Base: - w = width - - Modifiers - 1 = 1st step in width scale - 2 = 2nd step in width scale - 3 = 3rd step in width scale - 4 = 4th step in width scale - 5 = 5th step in width scale - - -10 = literal value 10% - -20 = literal value 20% - -25 = literal value 25% - -30 = literal value 30% - -33 = literal value 33% - -34 = literal value 34% - -40 = literal value 40% - -50 = literal value 50% - -60 = literal value 60% - -70 = literal value 70% - -75 = literal value 75% - -80 = literal value 80% - -90 = literal value 90% - -100 = literal value 100% - - -third = 100% / 3 (Not supported in opera mini or IE8) - -two-thirds = 100% / 1.5 (Not supported in opera mini or IE8) - -auto = string value auto - - - Media Query Extensions: - -ns = not-small - -m = medium - -l = large - -*/ -/* Width Scale */ -.w1 { width: 1rem; } -.w2 { width: 2rem; } -.w3 { width: 4rem; } -.w4 { width: 8rem; } -.w5 { width: 16rem; } -.w-10 { width: 10%; } -.w-20 { width: 20%; } -.w-25 { width: 25%; } -.w-30 { width: 30%; } -.w-33 { width: 33%; } -.w-34 { width: 34%; } -.w-40 { width: 40%; } -.w-50 { width: 50%; } -.w-60 { width: 60%; } -.w-70 { width: 70%; } -.w-75 { width: 75%; } -.w-80 { width: 80%; } -.w-90 { width: 90%; } -.w-100 { width: 100%; } -.w-third { width: 33.33333%; } -.w-two-thirds { width: 66.66667%; } -.w-auto { width: auto; } -@media screen and (min-width: 30em) { - .w1-ns { width: 1rem; } - .w2-ns { width: 2rem; } - .w3-ns { width: 4rem; } - .w4-ns { width: 8rem; } - .w5-ns { width: 16rem; } - .w-10-ns { width: 10%; } - .w-20-ns { width: 20%; } - .w-25-ns { width: 25%; } - .w-30-ns { width: 30%; } - .w-33-ns { width: 33%; } - .w-34-ns { width: 34%; } - .w-40-ns { width: 40%; } - .w-50-ns { width: 50%; } - .w-60-ns { width: 60%; } - .w-70-ns { width: 70%; } - .w-75-ns { width: 75%; } - .w-80-ns { width: 80%; } - .w-90-ns { width: 90%; } - .w-100-ns { width: 100%; } - .w-third-ns { width: 33.33333%; } - .w-two-thirds-ns { width: 66.66667%; } - .w-auto-ns { width: auto; } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .w1-m { width: 1rem; } - .w2-m { width: 2rem; } - .w3-m { width: 4rem; } - .w4-m { width: 8rem; } - .w5-m { width: 16rem; } - .w-10-m { width: 10%; } - .w-20-m { width: 20%; } - .w-25-m { width: 25%; } - .w-30-m { width: 30%; } - .w-33-m { width: 33%; } - .w-34-m { width: 34%; } - .w-40-m { width: 40%; } - .w-50-m { width: 50%; } - .w-60-m { width: 60%; } - .w-70-m { width: 70%; } - .w-75-m { width: 75%; } - .w-80-m { width: 80%; } - .w-90-m { width: 90%; } - .w-100-m { width: 100%; } - .w-third-m { width: 33.33333%; } - .w-two-thirds-m { width: 66.66667%; } - .w-auto-m { width: auto; } -} -@media screen and (min-width: 60em) { - .w1-l { width: 1rem; } - .w2-l { width: 2rem; } - .w3-l { width: 4rem; } - .w4-l { width: 8rem; } - .w5-l { width: 16rem; } - .w-10-l { width: 10%; } - .w-20-l { width: 20%; } - .w-25-l { width: 25%; } - .w-30-l { width: 30%; } - .w-33-l { width: 33%; } - .w-34-l { width: 34%; } - .w-40-l { width: 40%; } - .w-50-l { width: 50%; } - .w-60-l { width: 60%; } - .w-70-l { width: 70%; } - .w-75-l { width: 75%; } - .w-80-l { width: 80%; } - .w-90-l { width: 90%; } - .w-100-l { width: 100%; } - .w-third-l { width: 33.33333%; } - .w-two-thirds-l { width: 66.66667%; } - .w-auto-l { width: auto; } -} -/* - - OVERFLOW - - Media Query Extensions: - -ns = not-small - -m = medium - -l = large - - */ -.overflow-visible { overflow: visible; } -.overflow-hidden { overflow: hidden; } -.overflow-scroll { overflow: scroll; } -.overflow-auto { overflow: auto; } -.overflow-x-visible { overflow-x: visible; } -.overflow-x-hidden { overflow-x: hidden; } -.overflow-x-scroll { overflow-x: scroll; } -.overflow-x-auto { overflow-x: auto; } -.overflow-y-visible { overflow-y: visible; } -.overflow-y-hidden { overflow-y: hidden; } -.overflow-y-scroll { overflow-y: scroll; } -.overflow-y-auto { overflow-y: auto; } -@media screen and (min-width: 30em) { - .overflow-visible-ns { overflow: visible; } - .overflow-hidden-ns { overflow: hidden; } - .overflow-scroll-ns { overflow: scroll; } - .overflow-auto-ns { overflow: auto; } - .overflow-x-visible-ns { overflow-x: visible; } - .overflow-x-hidden-ns { overflow-x: hidden; } - .overflow-x-scroll-ns { overflow-x: scroll; } - .overflow-x-auto-ns { overflow-x: auto; } - - .overflow-y-visible-ns { overflow-y: visible; } - .overflow-y-hidden-ns { overflow-y: hidden; } - .overflow-y-scroll-ns { overflow-y: scroll; } - .overflow-y-auto-ns { overflow-y: auto; } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .overflow-visible-m { overflow: visible; } - .overflow-hidden-m { overflow: hidden; } - .overflow-scroll-m { overflow: scroll; } - .overflow-auto-m { overflow: auto; } - - .overflow-x-visible-m { overflow-x: visible; } - .overflow-x-hidden-m { overflow-x: hidden; } - .overflow-x-scroll-m { overflow-x: scroll; } - .overflow-x-auto-m { overflow-x: auto; } - - .overflow-y-visible-m { overflow-y: visible; } - .overflow-y-hidden-m { overflow-y: hidden; } - .overflow-y-scroll-m { overflow-y: scroll; } - .overflow-y-auto-m { overflow-y: auto; } -} -@media screen and (min-width: 60em) { - .overflow-visible-l { overflow: visible; } - .overflow-hidden-l { overflow: hidden; } - .overflow-scroll-l { overflow: scroll; } - .overflow-auto-l { overflow: auto; } - - .overflow-x-visible-l { overflow-x: visible; } - .overflow-x-hidden-l { overflow-x: hidden; } - .overflow-x-scroll-l { overflow-x: scroll; } - .overflow-x-auto-l { overflow-x: auto; } - - .overflow-y-visible-l { overflow-y: visible; } - .overflow-y-hidden-l { overflow-y: hidden; } - .overflow-y-scroll-l { overflow-y: scroll; } - .overflow-y-auto-l { overflow-y: auto; } -} -/* - - POSITIONING - Docs: http://tachyons.io/docs/layout/position/ - - Media Query Extensions: - -ns = not-small - -m = medium - -l = large - -*/ -.static { position: static; } -.relative { position: relative; } -.absolute { position: absolute; } -.fixed { position: fixed; } -@media screen and (min-width: 30em) { - .static-ns { position: static; } - .relative-ns { position: relative; } - .absolute-ns { position: absolute; } - .fixed-ns { position: fixed; } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .static-m { position: static; } - .relative-m { position: relative; } - .absolute-m { position: absolute; } - .fixed-m { position: fixed; } -} -@media screen and (min-width: 60em) { - .static-l { position: static; } - .relative-l { position: relative; } - .absolute-l { position: absolute; } - .fixed-l { position: fixed; } -} -/* - - OPACITY - Docs: http://tachyons.io/docs/themes/opacity/ - -*/ -.o-100 { opacity: 1; } -.o-90 { opacity: .9; } -.o-80 { opacity: .8; } -.o-70 { opacity: .7; } -.o-60 { opacity: .6; } -.o-50 { opacity: .5; } -.o-40 { opacity: .4; } -.o-30 { opacity: .3; } -.o-20 { opacity: .2; } -.o-10 { opacity: .1; } -.o-05 { opacity: .05; } -.o-025 { opacity: .025; } -.o-0 { opacity: 0; } -/*@import 'tachyons/src/_rotations';*/ -/* - - SKINS - Docs: http://tachyons.io/docs/themes/skins/ - - Classes for setting foreground and background colors on elements. - If you haven't declared a border color, but set border on an element, it will - be set to the current text color. - -*/ -/* Text colors */ -.black-90 { color: rgba(0, 0, 0, .9); } -.black-80 { color: rgba(0, 0, 0, .8); } -.black-70 { color: rgba(0, 0, 0, .7); } -.black-60 { color: rgba(0, 0, 0, .6); } -.black-50 { color: rgba(0, 0, 0, .5); } -.black-40 { color: rgba(0, 0, 0, .4); } -.black-30 { color: rgba(0, 0, 0, .3); } -.black-20 { color: rgba(0, 0, 0, .2); } -.black-10 { color: rgba(0, 0, 0, .1); } -.black-05 { color: rgba(0, 0, 0, .05); } -.white-90 { color: rgba(255, 255, 255, .9); } -.white-80 { color: rgba(255, 255, 255, .8); } -.white-70 { color: rgba(255, 255, 255, .7); } -.white-60 { color: rgba(255, 255, 255, .6); } -.white-50 { color: rgba(255, 255, 255, .5); } -.white-40 { color: rgba(255, 255, 255, .4); } -.white-30 { color: rgba(255, 255, 255, .3); } -.white-20 { color: rgba(255, 255, 255, .2); } -.white-10 { color: rgba(255, 255, 255, .1); } -.black { color: #000; } -.near-black { color: #111; } -.dark-gray { color: #333; } -.mid-gray { color: #555; } -.gray { color: #777; } -.silver { color: #999; } -.light-silver { color: #aaa; } -.moon-gray { color: #ccc; } -.light-gray { color: #eee; } -.near-white { color: #f4f4f4; } -.white { color: #fff; } -.dark-red { color: #e7040f; } -.red { color: #ff4136; } -.light-red { color: #ff725c; } -.orange { color: #ff6300; } -.gold { color: #ffb700; } -.yellow { color: #ffd700; } -.light-yellow { color: #fbf1a9; } -.purple { color: #5e2ca5; } -.light-purple { color: #a463f2; } -.dark-pink { color: #d5008f; } -.hot-pink { color: #ff41b4; } -.pink { color: #ff80cc; } -.light-pink { color: #ffa3d7; } -.dark-green { color: #137752; } -.green { color: #19a974; } -.light-green { color: #9eebcf; } -.navy { color: #001b44; } -.dark-blue { color: #00449e; } -.blue { color: #0594CB; } -.light-blue { color: #96ccff; } -.lightest-blue { color: #cdecff; } -.washed-blue { color: #f6fffe; } -.washed-green { color: #e8fdf5; } -.washed-yellow { color: #fffceb; } -.washed-red { color: #ffdfdf; } -.color-inherit { color: inherit; } -.bg-black-90 { background-color: rgba(0, 0, 0, .9); } -.bg-black-80 { background-color: rgba(0, 0, 0, .8); } -.bg-black-70 { background-color: rgba(0, 0, 0, .7); } -.bg-black-60 { background-color: rgba(0, 0, 0, .6); } -.bg-black-50 { background-color: rgba(0, 0, 0, .5); } -.bg-black-40 { background-color: rgba(0, 0, 0, .4); } -.bg-black-30 { background-color: rgba(0, 0, 0, .3); } -.bg-black-20 { background-color: rgba(0, 0, 0, .2); } -.bg-black-10 { background-color: rgba(0, 0, 0, .1); } -.bg-black-05 { background-color: rgba(0, 0, 0, .05); } -.bg-white-90 { background-color: rgba(255, 255, 255, .9); } -.bg-white-80 { background-color: rgba(255, 255, 255, .8); } -.bg-white-70 { background-color: rgba(255, 255, 255, .7); } -.bg-white-60 { background-color: rgba(255, 255, 255, .6); } -.bg-white-50 { background-color: rgba(255, 255, 255, .5); } -.bg-white-40 { background-color: rgba(255, 255, 255, .4); } -.bg-white-30 { background-color: rgba(255, 255, 255, .3); } -.bg-white-20 { background-color: rgba(255, 255, 255, .2); } -.bg-white-10 { background-color: rgba(255, 255, 255, .1); } -/* Background colors */ -.bg-black { background-color: #000; } -.bg-near-black { background-color: #111; } -.bg-dark-gray { background-color: #333; } -.bg-mid-gray { background-color: #555; } -.bg-gray { background-color: #777; } -.bg-silver { background-color: #999; } -.bg-light-silver { background-color: #aaa; } -.bg-moon-gray { background-color: #ccc; } -.bg-light-gray { background-color: #eee; } -.bg-near-white { background-color: #f4f4f4; } -.bg-white { background-color: #fff; } -.bg-transparent { background-color: transparent; } -.bg-dark-red { background-color: #e7040f; } -.bg-red { background-color: #ff4136; } -.bg-light-red { background-color: #ff725c; } -.bg-orange { background-color: #ff6300; } -.bg-gold { background-color: #ffb700; } -.bg-yellow { background-color: #ffd700; } -.bg-light-yellow { background-color: #fbf1a9; } -.bg-purple { background-color: #5e2ca5; } -.bg-light-purple { background-color: #a463f2; } -.bg-dark-pink { background-color: #d5008f; } -.bg-hot-pink { background-color: #ff41b4; } -.bg-pink { background-color: #ff80cc; } -.bg-light-pink { background-color: #ffa3d7; } -.bg-dark-green { background-color: #137752; } -.bg-green { background-color: #19a974; } -.bg-light-green { background-color: #9eebcf; } -.bg-navy { background-color: #001b44; } -.bg-dark-blue { background-color: #00449e; } -.bg-blue { background-color: #0594CB; } -.bg-light-blue { background-color: #96ccff; } -.bg-lightest-blue { background-color: #cdecff; } -.bg-washed-blue { background-color: #f6fffe; } -.bg-washed-green { background-color: #e8fdf5; } -.bg-washed-yellow { background-color: #fffceb; } -.bg-washed-red { background-color: #ffdfdf; } -.bg-inherit { background-color: inherit; } -/* - - SKINS:PSEUDO - - Customize the color of an element when - it is focused or hovered over. - - */ -.hover-black:hover, -.hover-black:focus { color: #000; } -.hover-near-black:hover, -.hover-near-black:focus { color: #111; } -.hover-dark-gray:hover, -.hover-dark-gray:focus { color: #333; } -.hover-mid-gray:hover, -.hover-mid-gray:focus { color: #555; } -.hover-gray:hover, -.hover-gray:focus { color: #777; } -.hover-silver:hover, -.hover-silver:focus { color: #999; } -.hover-light-silver:hover, -.hover-light-silver:focus { color: #aaa; } -.hover-moon-gray:hover, -.hover-moon-gray:focus { color: #ccc; } -.hover-light-gray:hover, -.hover-light-gray:focus { color: #eee; } -.hover-near-white:hover, -.hover-near-white:focus { color: #f4f4f4; } -.hover-white:hover, -.hover-white:focus { color: #fff; } -.hover-black-90:hover, -.hover-black-90:focus { color: rgba(0, 0, 0, .9); } -.hover-black-80:hover, -.hover-black-80:focus { color: rgba(0, 0, 0, .8); } -.hover-black-70:hover, -.hover-black-70:focus { color: rgba(0, 0, 0, .7); } -.hover-black-60:hover, -.hover-black-60:focus { color: rgba(0, 0, 0, .6); } -.hover-black-50:hover, -.hover-black-50:focus { color: rgba(0, 0, 0, .5); } -.hover-black-40:hover, -.hover-black-40:focus { color: rgba(0, 0, 0, .4); } -.hover-black-30:hover, -.hover-black-30:focus { color: rgba(0, 0, 0, .3); } -.hover-black-20:hover, -.hover-black-20:focus { color: rgba(0, 0, 0, .2); } -.hover-black-10:hover, -.hover-black-10:focus { color: rgba(0, 0, 0, .1); } -.hover-white-90:hover, -.hover-white-90:focus { color: rgba(255, 255, 255, .9); } -.hover-white-80:hover, -.hover-white-80:focus { color: rgba(255, 255, 255, .8); } -.hover-white-70:hover, -.hover-white-70:focus { color: rgba(255, 255, 255, .7); } -.hover-white-60:hover, -.hover-white-60:focus { color: rgba(255, 255, 255, .6); } -.hover-white-50:hover, -.hover-white-50:focus { color: rgba(255, 255, 255, .5); } -.hover-white-40:hover, -.hover-white-40:focus { color: rgba(255, 255, 255, .4); } -.hover-white-30:hover, -.hover-white-30:focus { color: rgba(255, 255, 255, .3); } -.hover-white-20:hover, -.hover-white-20:focus { color: rgba(255, 255, 255, .2); } -.hover-white-10:hover, -.hover-white-10:focus { color: rgba(255, 255, 255, .1); } -.hover-inherit:hover, -.hover-inherit:focus { color: inherit; } -.hover-bg-black:hover, -.hover-bg-black:focus { background-color: #000; } -.hover-bg-near-black:hover, -.hover-bg-near-black:focus { background-color: #111; } -.hover-bg-dark-gray:hover, -.hover-bg-dark-gray:focus { background-color: #333; } -.hover-bg-mid-gray:hover, -.hover-bg-mid-gray:focus { background-color: #555; } -.hover-bg-gray:hover, -.hover-bg-gray:focus { background-color: #777; } -.hover-bg-silver:hover, -.hover-bg-silver:focus { background-color: #999; } -.hover-bg-light-silver:hover, -.hover-bg-light-silver:focus { background-color: #aaa; } -.hover-bg-moon-gray:hover, -.hover-bg-moon-gray:focus { background-color: #ccc; } -.hover-bg-light-gray:hover, -.hover-bg-light-gray:focus { background-color: #eee; } -.hover-bg-near-white:hover, -.hover-bg-near-white:focus { background-color: #f4f4f4; } -.hover-bg-white:hover, -.hover-bg-white:focus { background-color: #fff; } -.hover-bg-transparent:hover, -.hover-bg-transparent:focus { background-color: transparent; } -.hover-bg-black-90:hover, -.hover-bg-black-90:focus { background-color: rgba(0, 0, 0, .9); } -.hover-bg-black-80:hover, -.hover-bg-black-80:focus { background-color: rgba(0, 0, 0, .8); } -.hover-bg-black-70:hover, -.hover-bg-black-70:focus { background-color: rgba(0, 0, 0, .7); } -.hover-bg-black-60:hover, -.hover-bg-black-60:focus { background-color: rgba(0, 0, 0, .6); } -.hover-bg-black-50:hover, -.hover-bg-black-50:focus { background-color: rgba(0, 0, 0, .5); } -.hover-bg-black-40:hover, -.hover-bg-black-40:focus { background-color: rgba(0, 0, 0, .4); } -.hover-bg-black-30:hover, -.hover-bg-black-30:focus { background-color: rgba(0, 0, 0, .3); } -.hover-bg-black-20:hover, -.hover-bg-black-20:focus { background-color: rgba(0, 0, 0, .2); } -.hover-bg-black-10:hover, -.hover-bg-black-10:focus { background-color: rgba(0, 0, 0, .1); } -.hover-bg-white-90:hover, -.hover-bg-white-90:focus { background-color: rgba(255, 255, 255, .9); } -.hover-bg-white-80:hover, -.hover-bg-white-80:focus { background-color: rgba(255, 255, 255, .8); } -.hover-bg-white-70:hover, -.hover-bg-white-70:focus { background-color: rgba(255, 255, 255, .7); } -.hover-bg-white-60:hover, -.hover-bg-white-60:focus { background-color: rgba(255, 255, 255, .6); } -.hover-bg-white-50:hover, -.hover-bg-white-50:focus { background-color: rgba(255, 255, 255, .5); } -.hover-bg-white-40:hover, -.hover-bg-white-40:focus { background-color: rgba(255, 255, 255, .4); } -.hover-bg-white-30:hover, -.hover-bg-white-30:focus { background-color: rgba(255, 255, 255, .3); } -.hover-bg-white-20:hover, -.hover-bg-white-20:focus { background-color: rgba(255, 255, 255, .2); } -.hover-bg-white-10:hover, -.hover-bg-white-10:focus { background-color: rgba(255, 255, 255, .1); } -.hover-dark-red:hover, -.hover-dark-red:focus { color: #e7040f; } -.hover-red:hover, -.hover-red:focus { color: #ff4136; } -.hover-light-red:hover, -.hover-light-red:focus { color: #ff725c; } -.hover-orange:hover, -.hover-orange:focus { color: #ff6300; } -.hover-gold:hover, -.hover-gold:focus { color: #ffb700; } -.hover-yellow:hover, -.hover-yellow:focus { color: #ffd700; } -.hover-light-yellow:hover, -.hover-light-yellow:focus { color: #fbf1a9; } -.hover-purple:hover, -.hover-purple:focus { color: #5e2ca5; } -.hover-light-purple:hover, -.hover-light-purple:focus { color: #a463f2; } -.hover-dark-pink:hover, -.hover-dark-pink:focus { color: #d5008f; } -.hover-hot-pink:hover, -.hover-hot-pink:focus { color: #ff41b4; } -.hover-pink:hover, -.hover-pink:focus { color: #ff80cc; } -.hover-light-pink:hover, -.hover-light-pink:focus { color: #ffa3d7; } -.hover-dark-green:hover, -.hover-dark-green:focus { color: #137752; } -.hover-green:hover, -.hover-green:focus { color: #19a974; } -.hover-light-green:hover, -.hover-light-green:focus { color: #9eebcf; } -.hover-navy:hover, -.hover-navy:focus { color: #001b44; } -.hover-dark-blue:hover, -.hover-dark-blue:focus { color: #00449e; } -.hover-blue:hover, -.hover-blue:focus { color: #0594CB; } -.hover-light-blue:hover, -.hover-light-blue:focus { color: #96ccff; } -.hover-lightest-blue:hover, -.hover-lightest-blue:focus { color: #cdecff; } -.hover-washed-blue:hover, -.hover-washed-blue:focus { color: #f6fffe; } -.hover-washed-green:hover, -.hover-washed-green:focus { color: #e8fdf5; } -.hover-washed-yellow:hover, -.hover-washed-yellow:focus { color: #fffceb; } -.hover-washed-red:hover, -.hover-washed-red:focus { color: #ffdfdf; } -.hover-bg-dark-red:hover, -.hover-bg-dark-red:focus { background-color: #e7040f; } -.hover-bg-red:hover, -.hover-bg-red:focus { background-color: #ff4136; } -.hover-bg-light-red:hover, -.hover-bg-light-red:focus { background-color: #ff725c; } -.hover-bg-orange:hover, -.hover-bg-orange:focus { background-color: #ff6300; } -.hover-bg-gold:hover, -.hover-bg-gold:focus { background-color: #ffb700; } -.hover-bg-yellow:hover, -.hover-bg-yellow:focus { background-color: #ffd700; } -.hover-bg-light-yellow:hover, -.hover-bg-light-yellow:focus { background-color: #fbf1a9; } -.hover-bg-purple:hover, -.hover-bg-purple:focus { background-color: #5e2ca5; } -.hover-bg-light-purple:hover, -.hover-bg-light-purple:focus { background-color: #a463f2; } -.hover-bg-dark-pink:hover, -.hover-bg-dark-pink:focus { background-color: #d5008f; } -.hover-bg-hot-pink:hover, -.hover-bg-hot-pink:focus { background-color: #ff41b4; } -.hover-bg-pink:hover, -.hover-bg-pink:focus { background-color: #ff80cc; } -.hover-bg-light-pink:hover, -.hover-bg-light-pink:focus { background-color: #ffa3d7; } -.hover-bg-dark-green:hover, -.hover-bg-dark-green:focus { background-color: #137752; } -.hover-bg-green:hover, -.hover-bg-green:focus { background-color: #19a974; } -.hover-bg-light-green:hover, -.hover-bg-light-green:focus { background-color: #9eebcf; } -.hover-bg-navy:hover, -.hover-bg-navy:focus { background-color: #001b44; } -.hover-bg-dark-blue:hover, -.hover-bg-dark-blue:focus { background-color: #00449e; } -.hover-bg-blue:hover, -.hover-bg-blue:focus { background-color: #0594CB; } -.hover-bg-light-blue:hover, -.hover-bg-light-blue:focus { background-color: #96ccff; } -.hover-bg-lightest-blue:hover, -.hover-bg-lightest-blue:focus { background-color: #cdecff; } -.hover-bg-washed-blue:hover, -.hover-bg-washed-blue:focus { background-color: #f6fffe; } -.hover-bg-washed-green:hover, -.hover-bg-washed-green:focus { background-color: #e8fdf5; } -.hover-bg-washed-yellow:hover, -.hover-bg-washed-yellow:focus { background-color: #fffceb; } -.hover-bg-washed-red:hover, -.hover-bg-washed-red:focus { background-color: #ffdfdf; } -.hover-bg-inherit:hover, -.hover-bg-inherit:focus { background-color: inherit; } -/* Variables */ -/* - SPACING - Docs: http://tachyons.io/docs/layout/spacing/ - - An eight step powers of two scale ranging from 0 to 16rem. - - Base: - p = padding - m = margin - - Modifiers: - a = all - h = horizontal - v = vertical - t = top - r = right - b = bottom - l = left - - 0 = none - 1 = 1st step in spacing scale - 2 = 2nd step in spacing scale - 3 = 3rd step in spacing scale - 4 = 4th step in spacing scale - 5 = 5th step in spacing scale - 6 = 6th step in spacing scale - 7 = 7th step in spacing scale - - Media Query Extensions: - -ns = not-small - -m = medium - -l = large - -*/ -.pa0 { padding: 0; } -.pa1 { padding: .25rem; } -.pa2 { padding: .5rem; } -.pa3 { padding: 1rem; } -.pa4 { padding: 2rem; } -.pa5 { padding: 4rem; } -.pa6 { padding: 8rem; } -.pa7 { padding: 16rem; } -.pl0 { padding-left: 0; } -.pl1 { padding-left: .25rem; } -.pl2 { padding-left: .5rem; } -.pl3 { padding-left: 1rem; } -.pl4 { padding-left: 2rem; } -.pl5 { padding-left: 4rem; } -.pl6 { padding-left: 8rem; } -.pl7 { padding-left: 16rem; } -.pr0 { padding-right: 0; } -.pr1 { padding-right: .25rem; } -.pr2 { padding-right: .5rem; } -.pr3 { padding-right: 1rem; } -.pr4 { padding-right: 2rem; } -.pr5 { padding-right: 4rem; } -.pr6 { padding-right: 8rem; } -.pr7 { padding-right: 16rem; } -.pb0 { padding-bottom: 0; } -.pb1 { padding-bottom: .25rem; } -.pb2 { padding-bottom: .5rem; } -.pb3 { padding-bottom: 1rem; } -.pb4 { padding-bottom: 2rem; } -.pb5 { padding-bottom: 4rem; } -.pb6 { padding-bottom: 8rem; } -.pb7 { padding-bottom: 16rem; } -.pt0 { padding-top: 0; } -.pt1 { padding-top: .25rem; } -.pt2 { padding-top: .5rem; } -.pt3 { padding-top: 1rem; } -.pt4 { padding-top: 2rem; } -.pt5 { padding-top: 4rem; } -.pt6 { padding-top: 8rem; } -.pt7 { padding-top: 16rem; } -.pv0 { - padding-top: 0; - padding-bottom: 0; -} -.pv1 { - padding-top: .25rem; - padding-bottom: .25rem; -} -.pv2 { - padding-top: .5rem; - padding-bottom: .5rem; -} -.pv3 { - padding-top: 1rem; - padding-bottom: 1rem; -} -.pv4 { - padding-top: 2rem; - padding-bottom: 2rem; -} -.pv5 { - padding-top: 4rem; - padding-bottom: 4rem; -} -.pv6 { - padding-top: 8rem; - padding-bottom: 8rem; -} -.pv7 { - padding-top: 16rem; - padding-bottom: 16rem; -} -.ph0 { - padding-left: 0; - padding-right: 0; -} -.ph1 { - padding-left: .25rem; - padding-right: .25rem; -} -.ph2 { - padding-left: .5rem; - padding-right: .5rem; -} -.ph3 { - padding-left: 1rem; - padding-right: 1rem; -} -.ph4 { - padding-left: 2rem; - padding-right: 2rem; -} -.ph5 { - padding-left: 4rem; - padding-right: 4rem; -} -.ph6 { - padding-left: 8rem; - padding-right: 8rem; -} -.ph7 { - padding-left: 16rem; - padding-right: 16rem; -} -.ma0 { margin: 0; } -.ma1 { margin: .25rem; } -.ma2 { margin: .5rem; } -.ma3 { margin: 1rem; } -.ma4 { margin: 2rem; } -.ma5 { margin: 4rem; } -.ma6 { margin: 8rem; } -.ma7 { margin: 16rem; } -.ml0 { margin-left: 0; } -.ml1 { margin-left: .25rem; } -.ml2 { margin-left: .5rem; } -.ml3 { margin-left: 1rem; } -.ml4 { margin-left: 2rem; } -.ml5 { margin-left: 4rem; } -.ml6 { margin-left: 8rem; } -.ml7 { margin-left: 16rem; } -.mr0 { margin-right: 0; } -.mr1 { margin-right: .25rem; } -.mr2 { margin-right: .5rem; } -.mr3 { margin-right: 1rem; } -.mr4 { margin-right: 2rem; } -.mr5 { margin-right: 4rem; } -.mr6 { margin-right: 8rem; } -.mr7 { margin-right: 16rem; } -.mb0 { margin-bottom: 0; } -.mb1 { margin-bottom: .25rem; } -.mb2 { margin-bottom: .5rem; } -.mb3 { margin-bottom: 1rem; } -.mb4 { margin-bottom: 2rem; } -.mb5 { margin-bottom: 4rem; } -.mb6 { margin-bottom: 8rem; } -.mb7 { margin-bottom: 16rem; } -.mt0 { margin-top: 0; } -.mt1 { margin-top: .25rem; } -.mt2 { margin-top: .5rem; } -.mt3 { margin-top: 1rem; } -.mt4 { margin-top: 2rem; } -.mt5 { margin-top: 4rem; } -.mt6 { margin-top: 8rem; } -.mt7 { margin-top: 16rem; } -.mv0 { - margin-top: 0; - margin-bottom: 0; -} -.mv1 { - margin-top: .25rem; - margin-bottom: .25rem; -} -.mv2 { - margin-top: .5rem; - margin-bottom: .5rem; -} -.mv3 { - margin-top: 1rem; - margin-bottom: 1rem; -} -.mv4 { - margin-top: 2rem; - margin-bottom: 2rem; -} -.mv5 { - margin-top: 4rem; - margin-bottom: 4rem; -} -.mv6 { - margin-top: 8rem; - margin-bottom: 8rem; -} -.mv7 { - margin-top: 16rem; - margin-bottom: 16rem; -} -.mh0 { - margin-left: 0; - margin-right: 0; -} -.mh1 { - margin-left: .25rem; - margin-right: .25rem; -} -.mh2 { - margin-left: .5rem; - margin-right: .5rem; -} -.mh3 { - margin-left: 1rem; - margin-right: 1rem; -} -.mh4 { - margin-left: 2rem; - margin-right: 2rem; -} -.mh5 { - margin-left: 4rem; - margin-right: 4rem; -} -.mh6 { - margin-left: 8rem; - margin-right: 8rem; -} -.mh7 { - margin-left: 16rem; - margin-right: 16rem; -} -@media screen and (min-width: 30em) { - .pa0-ns { padding: 0; } - .pa1-ns { padding: .25rem; } - .pa2-ns { padding: .5rem; } - .pa3-ns { padding: 1rem; } - .pa4-ns { padding: 2rem; } - .pa5-ns { padding: 4rem; } - .pa6-ns { padding: 8rem; } - .pa7-ns { padding: 16rem; } - - .pl0-ns { padding-left: 0; } - .pl1-ns { padding-left: .25rem; } - .pl2-ns { padding-left: .5rem; } - .pl3-ns { padding-left: 1rem; } - .pl4-ns { padding-left: 2rem; } - .pl5-ns { padding-left: 4rem; } - .pl6-ns { padding-left: 8rem; } - .pl7-ns { padding-left: 16rem; } - - .pr0-ns { padding-right: 0; } - .pr1-ns { padding-right: .25rem; } - .pr2-ns { padding-right: .5rem; } - .pr3-ns { padding-right: 1rem; } - .pr4-ns { padding-right: 2rem; } - .pr5-ns { padding-right: 4rem; } - .pr6-ns { padding-right: 8rem; } - .pr7-ns { padding-right: 16rem; } - - .pb0-ns { padding-bottom: 0; } - .pb1-ns { padding-bottom: .25rem; } - .pb2-ns { padding-bottom: .5rem; } - .pb3-ns { padding-bottom: 1rem; } - .pb4-ns { padding-bottom: 2rem; } - .pb5-ns { padding-bottom: 4rem; } - .pb6-ns { padding-bottom: 8rem; } - .pb7-ns { padding-bottom: 16rem; } - - .pt0-ns { padding-top: 0; } - .pt1-ns { padding-top: .25rem; } - .pt2-ns { padding-top: .5rem; } - .pt3-ns { padding-top: 1rem; } - .pt4-ns { padding-top: 2rem; } - .pt5-ns { padding-top: 4rem; } - .pt6-ns { padding-top: 8rem; } - .pt7-ns { padding-top: 16rem; } - - .pv0-ns { - padding-top: 0; - padding-bottom: 0; - } - .pv1-ns { - padding-top: .25rem; - padding-bottom: .25rem; - } - .pv2-ns { - padding-top: .5rem; - padding-bottom: .5rem; - } - .pv3-ns { - padding-top: 1rem; - padding-bottom: 1rem; - } - .pv4-ns { - padding-top: 2rem; - padding-bottom: 2rem; - } - .pv5-ns { - padding-top: 4rem; - padding-bottom: 4rem; - } - .pv6-ns { - padding-top: 8rem; - padding-bottom: 8rem; - } - .pv7-ns { - padding-top: 16rem; - padding-bottom: 16rem; - } - .ph0-ns { - padding-left: 0; - padding-right: 0; - } - .ph1-ns { - padding-left: .25rem; - padding-right: .25rem; - } - .ph2-ns { - padding-left: .5rem; - padding-right: .5rem; - } - .ph3-ns { - padding-left: 1rem; - padding-right: 1rem; - } - .ph4-ns { - padding-left: 2rem; - padding-right: 2rem; - } - .ph5-ns { - padding-left: 4rem; - padding-right: 4rem; - } - .ph6-ns { - padding-left: 8rem; - padding-right: 8rem; - } - .ph7-ns { - padding-left: 16rem; - padding-right: 16rem; - } - - .ma0-ns { margin: 0; } - .ma1-ns { margin: .25rem; } - .ma2-ns { margin: .5rem; } - .ma3-ns { margin: 1rem; } - .ma4-ns { margin: 2rem; } - .ma5-ns { margin: 4rem; } - .ma6-ns { margin: 8rem; } - .ma7-ns { margin: 16rem; } - - .ml0-ns { margin-left: 0; } - .ml1-ns { margin-left: .25rem; } - .ml2-ns { margin-left: .5rem; } - .ml3-ns { margin-left: 1rem; } - .ml4-ns { margin-left: 2rem; } - .ml5-ns { margin-left: 4rem; } - .ml6-ns { margin-left: 8rem; } - .ml7-ns { margin-left: 16rem; } - - .mr0-ns { margin-right: 0; } - .mr1-ns { margin-right: .25rem; } - .mr2-ns { margin-right: .5rem; } - .mr3-ns { margin-right: 1rem; } - .mr4-ns { margin-right: 2rem; } - .mr5-ns { margin-right: 4rem; } - .mr6-ns { margin-right: 8rem; } - .mr7-ns { margin-right: 16rem; } - - .mb0-ns { margin-bottom: 0; } - .mb1-ns { margin-bottom: .25rem; } - .mb2-ns { margin-bottom: .5rem; } - .mb3-ns { margin-bottom: 1rem; } - .mb4-ns { margin-bottom: 2rem; } - .mb5-ns { margin-bottom: 4rem; } - .mb6-ns { margin-bottom: 8rem; } - .mb7-ns { margin-bottom: 16rem; } - - .mt0-ns { margin-top: 0; } - .mt1-ns { margin-top: .25rem; } - .mt2-ns { margin-top: .5rem; } - .mt3-ns { margin-top: 1rem; } - .mt4-ns { margin-top: 2rem; } - .mt5-ns { margin-top: 4rem; } - .mt6-ns { margin-top: 8rem; } - .mt7-ns { margin-top: 16rem; } - - .mv0-ns { - margin-top: 0; - margin-bottom: 0; - } - .mv1-ns { - margin-top: .25rem; - margin-bottom: .25rem; - } - .mv2-ns { - margin-top: .5rem; - margin-bottom: .5rem; - } - .mv3-ns { - margin-top: 1rem; - margin-bottom: 1rem; - } - .mv4-ns { - margin-top: 2rem; - margin-bottom: 2rem; - } - .mv5-ns { - margin-top: 4rem; - margin-bottom: 4rem; - } - .mv6-ns { - margin-top: 8rem; - margin-bottom: 8rem; - } - .mv7-ns { - margin-top: 16rem; - margin-bottom: 16rem; - } - - .mh0-ns { - margin-left: 0; - margin-right: 0; - } - .mh1-ns { - margin-left: .25rem; - margin-right: .25rem; - } - .mh2-ns { - margin-left: .5rem; - margin-right: .5rem; - } - .mh3-ns { - margin-left: 1rem; - margin-right: 1rem; - } - .mh4-ns { - margin-left: 2rem; - margin-right: 2rem; - } - .mh5-ns { - margin-left: 4rem; - margin-right: 4rem; - } - .mh6-ns { - margin-left: 8rem; - margin-right: 8rem; - } - .mh7-ns { - margin-left: 16rem; - margin-right: 16rem; - } - -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .pa0-m { padding: 0; } - .pa1-m { padding: .25rem; } - .pa2-m { padding: .5rem; } - .pa3-m { padding: 1rem; } - .pa4-m { padding: 2rem; } - .pa5-m { padding: 4rem; } - .pa6-m { padding: 8rem; } - .pa7-m { padding: 16rem; } - - .pl0-m { padding-left: 0; } - .pl1-m { padding-left: .25rem; } - .pl2-m { padding-left: .5rem; } - .pl3-m { padding-left: 1rem; } - .pl4-m { padding-left: 2rem; } - .pl5-m { padding-left: 4rem; } - .pl6-m { padding-left: 8rem; } - .pl7-m { padding-left: 16rem; } - - .pr0-m { padding-right: 0; } - .pr1-m { padding-right: .25rem; } - .pr2-m { padding-right: .5rem; } - .pr3-m { padding-right: 1rem; } - .pr4-m { padding-right: 2rem; } - .pr5-m { padding-right: 4rem; } - .pr6-m { padding-right: 8rem; } - .pr7-m { padding-right: 16rem; } - - .pb0-m { padding-bottom: 0; } - .pb1-m { padding-bottom: .25rem; } - .pb2-m { padding-bottom: .5rem; } - .pb3-m { padding-bottom: 1rem; } - .pb4-m { padding-bottom: 2rem; } - .pb5-m { padding-bottom: 4rem; } - .pb6-m { padding-bottom: 8rem; } - .pb7-m { padding-bottom: 16rem; } - - .pt0-m { padding-top: 0; } - .pt1-m { padding-top: .25rem; } - .pt2-m { padding-top: .5rem; } - .pt3-m { padding-top: 1rem; } - .pt4-m { padding-top: 2rem; } - .pt5-m { padding-top: 4rem; } - .pt6-m { padding-top: 8rem; } - .pt7-m { padding-top: 16rem; } - - .pv0-m { - padding-top: 0; - padding-bottom: 0; - } - .pv1-m { - padding-top: .25rem; - padding-bottom: .25rem; - } - .pv2-m { - padding-top: .5rem; - padding-bottom: .5rem; - } - .pv3-m { - padding-top: 1rem; - padding-bottom: 1rem; - } - .pv4-m { - padding-top: 2rem; - padding-bottom: 2rem; - } - .pv5-m { - padding-top: 4rem; - padding-bottom: 4rem; - } - .pv6-m { - padding-top: 8rem; - padding-bottom: 8rem; - } - .pv7-m { - padding-top: 16rem; - padding-bottom: 16rem; - } - - .ph0-m { - padding-left: 0; - padding-right: 0; - } - .ph1-m { - padding-left: .25rem; - padding-right: .25rem; - } - .ph2-m { - padding-left: .5rem; - padding-right: .5rem; - } - .ph3-m { - padding-left: 1rem; - padding-right: 1rem; - } - .ph4-m { - padding-left: 2rem; - padding-right: 2rem; - } - .ph5-m { - padding-left: 4rem; - padding-right: 4rem; - } - .ph6-m { - padding-left: 8rem; - padding-right: 8rem; - } - .ph7-m { - padding-left: 16rem; - padding-right: 16rem; - } - - .ma0-m { margin: 0; } - .ma1-m { margin: .25rem; } - .ma2-m { margin: .5rem; } - .ma3-m { margin: 1rem; } - .ma4-m { margin: 2rem; } - .ma5-m { margin: 4rem; } - .ma6-m { margin: 8rem; } - .ma7-m { margin: 16rem; } - - .ml0-m { margin-left: 0; } - .ml1-m { margin-left: .25rem; } - .ml2-m { margin-left: .5rem; } - .ml3-m { margin-left: 1rem; } - .ml4-m { margin-left: 2rem; } - .ml5-m { margin-left: 4rem; } - .ml6-m { margin-left: 8rem; } - .ml7-m { margin-left: 16rem; } - - .mr0-m { margin-right: 0; } - .mr1-m { margin-right: .25rem; } - .mr2-m { margin-right: .5rem; } - .mr3-m { margin-right: 1rem; } - .mr4-m { margin-right: 2rem; } - .mr5-m { margin-right: 4rem; } - .mr6-m { margin-right: 8rem; } - .mr7-m { margin-right: 16rem; } - - .mb0-m { margin-bottom: 0; } - .mb1-m { margin-bottom: .25rem; } - .mb2-m { margin-bottom: .5rem; } - .mb3-m { margin-bottom: 1rem; } - .mb4-m { margin-bottom: 2rem; } - .mb5-m { margin-bottom: 4rem; } - .mb6-m { margin-bottom: 8rem; } - .mb7-m { margin-bottom: 16rem; } - - .mt0-m { margin-top: 0; } - .mt1-m { margin-top: .25rem; } - .mt2-m { margin-top: .5rem; } - .mt3-m { margin-top: 1rem; } - .mt4-m { margin-top: 2rem; } - .mt5-m { margin-top: 4rem; } - .mt6-m { margin-top: 8rem; } - .mt7-m { margin-top: 16rem; } - - .mv0-m { - margin-top: 0; - margin-bottom: 0; - } - .mv1-m { - margin-top: .25rem; - margin-bottom: .25rem; - } - .mv2-m { - margin-top: .5rem; - margin-bottom: .5rem; - } - .mv3-m { - margin-top: 1rem; - margin-bottom: 1rem; - } - .mv4-m { - margin-top: 2rem; - margin-bottom: 2rem; - } - .mv5-m { - margin-top: 4rem; - margin-bottom: 4rem; - } - .mv6-m { - margin-top: 8rem; - margin-bottom: 8rem; - } - .mv7-m { - margin-top: 16rem; - margin-bottom: 16rem; - } - - .mh0-m { - margin-left: 0; - margin-right: 0; - } - .mh1-m { - margin-left: .25rem; - margin-right: .25rem; - } - .mh2-m { - margin-left: .5rem; - margin-right: .5rem; - } - .mh3-m { - margin-left: 1rem; - margin-right: 1rem; - } - .mh4-m { - margin-left: 2rem; - margin-right: 2rem; - } - .mh5-m { - margin-left: 4rem; - margin-right: 4rem; - } - .mh6-m { - margin-left: 8rem; - margin-right: 8rem; - } - .mh7-m { - margin-left: 16rem; - margin-right: 16rem; - } - -} -@media screen and (min-width: 60em) { - .pa0-l { padding: 0; } - .pa1-l { padding: .25rem; } - .pa2-l { padding: .5rem; } - .pa3-l { padding: 1rem; } - .pa4-l { padding: 2rem; } - .pa5-l { padding: 4rem; } - .pa6-l { padding: 8rem; } - .pa7-l { padding: 16rem; } - - .pl0-l { padding-left: 0; } - .pl1-l { padding-left: .25rem; } - .pl2-l { padding-left: .5rem; } - .pl3-l { padding-left: 1rem; } - .pl4-l { padding-left: 2rem; } - .pl5-l { padding-left: 4rem; } - .pl6-l { padding-left: 8rem; } - .pl7-l { padding-left: 16rem; } - - .pr0-l { padding-right: 0; } - .pr1-l { padding-right: .25rem; } - .pr2-l { padding-right: .5rem; } - .pr3-l { padding-right: 1rem; } - .pr4-l { padding-right: 2rem; } - .pr5-l { padding-right: 4rem; } - .pr6-l { padding-right: 8rem; } - .pr7-l { padding-right: 16rem; } - - .pb0-l { padding-bottom: 0; } - .pb1-l { padding-bottom: .25rem; } - .pb2-l { padding-bottom: .5rem; } - .pb3-l { padding-bottom: 1rem; } - .pb4-l { padding-bottom: 2rem; } - .pb5-l { padding-bottom: 4rem; } - .pb6-l { padding-bottom: 8rem; } - .pb7-l { padding-bottom: 16rem; } - - .pt0-l { padding-top: 0; } - .pt1-l { padding-top: .25rem; } - .pt2-l { padding-top: .5rem; } - .pt3-l { padding-top: 1rem; } - .pt4-l { padding-top: 2rem; } - .pt5-l { padding-top: 4rem; } - .pt6-l { padding-top: 8rem; } - .pt7-l { padding-top: 16rem; } - - .pv0-l { - padding-top: 0; - padding-bottom: 0; - } - .pv1-l { - padding-top: .25rem; - padding-bottom: .25rem; - } - .pv2-l { - padding-top: .5rem; - padding-bottom: .5rem; - } - .pv3-l { - padding-top: 1rem; - padding-bottom: 1rem; - } - .pv4-l { - padding-top: 2rem; - padding-bottom: 2rem; - } - .pv5-l { - padding-top: 4rem; - padding-bottom: 4rem; - } - .pv6-l { - padding-top: 8rem; - padding-bottom: 8rem; - } - .pv7-l { - padding-top: 16rem; - padding-bottom: 16rem; - } - - .ph0-l { - padding-left: 0; - padding-right: 0; - } - .ph1-l { - padding-left: .25rem; - padding-right: .25rem; - } - .ph2-l { - padding-left: .5rem; - padding-right: .5rem; - } - .ph3-l { - padding-left: 1rem; - padding-right: 1rem; - } - .ph4-l { - padding-left: 2rem; - padding-right: 2rem; - } - .ph5-l { - padding-left: 4rem; - padding-right: 4rem; - } - .ph6-l { - padding-left: 8rem; - padding-right: 8rem; - } - .ph7-l { - padding-left: 16rem; - padding-right: 16rem; - } - - .ma0-l { margin: 0; } - .ma1-l { margin: .25rem; } - .ma2-l { margin: .5rem; } - .ma3-l { margin: 1rem; } - .ma4-l { margin: 2rem; } - .ma5-l { margin: 4rem; } - .ma6-l { margin: 8rem; } - .ma7-l { margin: 16rem; } - - .ml0-l { margin-left: 0; } - .ml1-l { margin-left: .25rem; } - .ml2-l { margin-left: .5rem; } - .ml3-l { margin-left: 1rem; } - .ml4-l { margin-left: 2rem; } - .ml5-l { margin-left: 4rem; } - .ml6-l { margin-left: 8rem; } - .ml7-l { margin-left: 16rem; } - - .mr0-l { margin-right: 0; } - .mr1-l { margin-right: .25rem; } - .mr2-l { margin-right: .5rem; } - .mr3-l { margin-right: 1rem; } - .mr4-l { margin-right: 2rem; } - .mr5-l { margin-right: 4rem; } - .mr6-l { margin-right: 8rem; } - .mr7-l { margin-right: 16rem; } - - .mb0-l { margin-bottom: 0; } - .mb1-l { margin-bottom: .25rem; } - .mb2-l { margin-bottom: .5rem; } - .mb3-l { margin-bottom: 1rem; } - .mb4-l { margin-bottom: 2rem; } - .mb5-l { margin-bottom: 4rem; } - .mb6-l { margin-bottom: 8rem; } - .mb7-l { margin-bottom: 16rem; } - - .mt0-l { margin-top: 0; } - .mt1-l { margin-top: .25rem; } - .mt2-l { margin-top: .5rem; } - .mt3-l { margin-top: 1rem; } - .mt4-l { margin-top: 2rem; } - .mt5-l { margin-top: 4rem; } - .mt6-l { margin-top: 8rem; } - .mt7-l { margin-top: 16rem; } - - .mv0-l { - margin-top: 0; - margin-bottom: 0; - } - .mv1-l { - margin-top: .25rem; - margin-bottom: .25rem; - } - .mv2-l { - margin-top: .5rem; - margin-bottom: .5rem; - } - .mv3-l { - margin-top: 1rem; - margin-bottom: 1rem; - } - .mv4-l { - margin-top: 2rem; - margin-bottom: 2rem; - } - .mv5-l { - margin-top: 4rem; - margin-bottom: 4rem; - } - .mv6-l { - margin-top: 8rem; - margin-bottom: 8rem; - } - .mv7-l { - margin-top: 16rem; - margin-bottom: 16rem; - } - - .mh0-l { - margin-left: 0; - margin-right: 0; - } - .mh1-l { - margin-left: .25rem; - margin-right: .25rem; - } - .mh2-l { - margin-left: .5rem; - margin-right: .5rem; - } - .mh3-l { - margin-left: 1rem; - margin-right: 1rem; - } - .mh4-l { - margin-left: 2rem; - margin-right: 2rem; - } - .mh5-l { - margin-left: 4rem; - margin-right: 4rem; - } - .mh6-l { - margin-left: 8rem; - margin-right: 8rem; - } - .mh7-l { - margin-left: 16rem; - margin-right: 16rem; - } -} -/* - NEGATIVE MARGINS - - Base: - n = negative - - Modifiers: - a = all - t = top - r = right - b = bottom - l = left - - 1 = 1st step in spacing scale - 2 = 2nd step in spacing scale - 3 = 3rd step in spacing scale - 4 = 4th step in spacing scale - 5 = 5th step in spacing scale - 6 = 6th step in spacing scale - 7 = 7th step in spacing scale - - Media Query Extensions: - -ns = not-small - -m = medium - -l = large - -*/ -.na1 { margin: -0.25rem; } -.na2 { margin: -0.5rem; } -.na3 { margin: -1rem; } -.na4 { margin: -2rem; } -.na5 { margin: -4rem; } -.na6 { margin: -8rem; } -.na7 { margin: -16rem; } -.nl1 { margin-left: -0.25rem; } -.nl2 { margin-left: -0.5rem; } -.nl3 { margin-left: -1rem; } -.nl4 { margin-left: -2rem; } -.nl5 { margin-left: -4rem; } -.nl6 { margin-left: -8rem; } -.nl7 { margin-left: -16rem; } -.nr1 { margin-right: -0.25rem; } -.nr2 { margin-right: -0.5rem; } -.nr3 { margin-right: -1rem; } -.nr4 { margin-right: -2rem; } -.nr5 { margin-right: -4rem; } -.nr6 { margin-right: -8rem; } -.nr7 { margin-right: -16rem; } -.nb1 { margin-bottom: -0.25rem; } -.nb2 { margin-bottom: -0.5rem; } -.nb3 { margin-bottom: -1rem; } -.nb4 { margin-bottom: -2rem; } -.nb5 { margin-bottom: -4rem; } -.nb6 { margin-bottom: -8rem; } -.nb7 { margin-bottom: -16rem; } -.nt1 { margin-top: -0.25rem; } -.nt2 { margin-top: -0.5rem; } -.nt3 { margin-top: -1rem; } -.nt4 { margin-top: -2rem; } -.nt5 { margin-top: -4rem; } -.nt6 { margin-top: -8rem; } -.nt7 { margin-top: -16rem; } -@media screen and (min-width: 30em) { - - .na1-ns { margin: -0.25rem; } - .na2-ns { margin: -0.5rem; } - .na3-ns { margin: -1rem; } - .na4-ns { margin: -2rem; } - .na5-ns { margin: -4rem; } - .na6-ns { margin: -8rem; } - .na7-ns { margin: -16rem; } - - .nl1-ns { margin-left: -0.25rem; } - .nl2-ns { margin-left: -0.5rem; } - .nl3-ns { margin-left: -1rem; } - .nl4-ns { margin-left: -2rem; } - .nl5-ns { margin-left: -4rem; } - .nl6-ns { margin-left: -8rem; } - .nl7-ns { margin-left: -16rem; } - - .nr1-ns { margin-right: -0.25rem; } - .nr2-ns { margin-right: -0.5rem; } - .nr3-ns { margin-right: -1rem; } - .nr4-ns { margin-right: -2rem; } - .nr5-ns { margin-right: -4rem; } - .nr6-ns { margin-right: -8rem; } - .nr7-ns { margin-right: -16rem; } - - .nb1-ns { margin-bottom: -0.25rem; } - .nb2-ns { margin-bottom: -0.5rem; } - .nb3-ns { margin-bottom: -1rem; } - .nb4-ns { margin-bottom: -2rem; } - .nb5-ns { margin-bottom: -4rem; } - .nb6-ns { margin-bottom: -8rem; } - .nb7-ns { margin-bottom: -16rem; } - - .nt1-ns { margin-top: -0.25rem; } - .nt2-ns { margin-top: -0.5rem; } - .nt3-ns { margin-top: -1rem; } - .nt4-ns { margin-top: -2rem; } - .nt5-ns { margin-top: -4rem; } - .nt6-ns { margin-top: -8rem; } - .nt7-ns { margin-top: -16rem; } - -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .na1-m { margin: -0.25rem; } - .na2-m { margin: -0.5rem; } - .na3-m { margin: -1rem; } - .na4-m { margin: -2rem; } - .na5-m { margin: -4rem; } - .na6-m { margin: -8rem; } - .na7-m { margin: -16rem; } - - .nl1-m { margin-left: -0.25rem; } - .nl2-m { margin-left: -0.5rem; } - .nl3-m { margin-left: -1rem; } - .nl4-m { margin-left: -2rem; } - .nl5-m { margin-left: -4rem; } - .nl6-m { margin-left: -8rem; } - .nl7-m { margin-left: -16rem; } - - .nr1-m { margin-right: -0.25rem; } - .nr2-m { margin-right: -0.5rem; } - .nr3-m { margin-right: -1rem; } - .nr4-m { margin-right: -2rem; } - .nr5-m { margin-right: -4rem; } - .nr6-m { margin-right: -8rem; } - .nr7-m { margin-right: -16rem; } - - .nb1-m { margin-bottom: -0.25rem; } - .nb2-m { margin-bottom: -0.5rem; } - .nb3-m { margin-bottom: -1rem; } - .nb4-m { margin-bottom: -2rem; } - .nb5-m { margin-bottom: -4rem; } - .nb6-m { margin-bottom: -8rem; } - .nb7-m { margin-bottom: -16rem; } - - .nt1-m { margin-top: -0.25rem; } - .nt2-m { margin-top: -0.5rem; } - .nt3-m { margin-top: -1rem; } - .nt4-m { margin-top: -2rem; } - .nt5-m { margin-top: -4rem; } - .nt6-m { margin-top: -8rem; } - .nt7-m { margin-top: -16rem; } - -} -@media screen and (min-width: 60em) { - .na1-l { margin: -0.25rem; } - .na2-l { margin: -0.5rem; } - .na3-l { margin: -1rem; } - .na4-l { margin: -2rem; } - .na5-l { margin: -4rem; } - .na6-l { margin: -8rem; } - .na7-l { margin: -16rem; } - - .nl1-l { margin-left: -0.25rem; } - .nl2-l { margin-left: -0.5rem; } - .nl3-l { margin-left: -1rem; } - .nl4-l { margin-left: -2rem; } - .nl5-l { margin-left: -4rem; } - .nl6-l { margin-left: -8rem; } - .nl7-l { margin-left: -16rem; } - - .nr1-l { margin-right: -0.25rem; } - .nr2-l { margin-right: -0.5rem; } - .nr3-l { margin-right: -1rem; } - .nr4-l { margin-right: -2rem; } - .nr5-l { margin-right: -4rem; } - .nr6-l { margin-right: -8rem; } - .nr7-l { margin-right: -16rem; } - - .nb1-l { margin-bottom: -0.25rem; } - .nb2-l { margin-bottom: -0.5rem; } - .nb3-l { margin-bottom: -1rem; } - .nb4-l { margin-bottom: -2rem; } - .nb5-l { margin-bottom: -4rem; } - .nb6-l { margin-bottom: -8rem; } - .nb7-l { margin-bottom: -16rem; } - - .nt1-l { margin-top: -0.25rem; } - .nt2-l { margin-top: -0.5rem; } - .nt3-l { margin-top: -1rem; } - .nt4-l { margin-top: -2rem; } - .nt5-l { margin-top: -4rem; } - .nt6-l { margin-top: -8rem; } - .nt7-l { margin-top: -16rem; } -} -/* - - TABLES - Docs: http://tachyons.io/docs/elements/tables/ - -*/ -.collapse { - border-collapse: collapse; - border-spacing: 0; -} -.striped--light-silver:nth-child(odd) { - background-color: #aaa; -} -.striped--moon-gray:nth-child(odd) { - background-color: #ccc; -} -.striped--light-gray:nth-child(odd) { - background-color: #eee; -} -.striped--near-white:nth-child(odd) { - background-color: #f4f4f4; -} -.stripe-light:nth-child(odd) { - background-color: rgba(255, 255, 255, .1); -} -.stripe-dark:nth-child(odd) { - background-color: rgba(0, 0, 0, .1); -} -/* - - TEXT DECORATION - Docs: http://tachyons.io/docs/typography/text-decoration/ - - - Media Query Extensions: - -ns = not-small - -m = medium - -l = large - -*/ -.strike { text-decoration: line-through; } -.underline { text-decoration: underline; } -.no-underline { text-decoration: none; } -@media screen and (min-width: 30em) { - .strike-ns { text-decoration: line-through; } - .underline-ns { text-decoration: underline; } - .no-underline-ns { text-decoration: none; } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .strike-m { text-decoration: line-through; } - .underline-m { text-decoration: underline; } - .no-underline-m { text-decoration: none; } -} -@media screen and (min-width: 60em) { - .strike-l { text-decoration: line-through; } - .underline-l { text-decoration: underline; } - .no-underline-l { text-decoration: none; } -} -/* - - TEXT ALIGN - Docs: http://tachyons.io/docs/typography/text-align/ - - Base - t = text-align - - Modifiers - l = left - r = right - c = center - j = justify - - Media Query Extensions: - -ns = not-small - -m = medium - -l = large - -*/ -.tl { text-align: left; } -.tr { text-align: right; } -.tc { text-align: center; } -.tj { text-align: justify; } -@media screen and (min-width: 30em) { - .tl-ns { text-align: left; } - .tr-ns { text-align: right; } - .tc-ns { text-align: center; } - .tj-ns { text-align: justify; } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .tl-m { text-align: left; } - .tr-m { text-align: right; } - .tc-m { text-align: center; } - .tj-m { text-align: justify; } -} -@media screen and (min-width: 60em) { - .tl-l { text-align: left; } - .tr-l { text-align: right; } - .tc-l { text-align: center; } - .tj-l { text-align: justify; } -} -/* - - TEXT TRANSFORM - Docs: http://tachyons.io/docs/typography/text-transform/ - - Base: - tt = text-transform - - Modifiers - c = capitalize - l = lowercase - u = uppercase - n = none - - Media Query Extensions: - -ns = not-small - -m = medium - -l = large - -*/ -.ttc { text-transform: capitalize; } -.ttl { text-transform: lowercase; } -.ttu { text-transform: uppercase; } -.ttn { text-transform: none; } -@media screen and (min-width: 30em) { - .ttc-ns { text-transform: capitalize; } - .ttl-ns { text-transform: lowercase; } - .ttu-ns { text-transform: uppercase; } - .ttn-ns { text-transform: none; } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .ttc-m { text-transform: capitalize; } - .ttl-m { text-transform: lowercase; } - .ttu-m { text-transform: uppercase; } - .ttn-m { text-transform: none; } -} -@media screen and (min-width: 60em) { - .ttc-l { text-transform: capitalize; } - .ttl-l { text-transform: lowercase; } - .ttu-l { text-transform: uppercase; } - .ttn-l { text-transform: none; } -} -/* - - TYPE SCALE - Docs: http://tachyons.io/docs/typography/scale/ - - Base: - f = font-size - - Modifiers - 1 = 1st step in size scale - 2 = 2nd step in size scale - 3 = 3rd step in size scale - 4 = 4th step in size scale - 5 = 5th step in size scale - 6 = 6th step in size scale - 7 = 7th step in size scale - - Media Query Extensions: - -ns = not-small - -m = medium - -l = large -*/ -/* - * For Hero/Marketing Titles - * - * These generally are too large for mobile - * so be careful using them on smaller screens. - * */ -.f-6, -.f-headline { - font-size: 6rem; -} -.f-5, -.f-subheadline { - font-size: 5rem; -} -/* Type Scale */ -.f1 { font-size: 3rem; } -.f2 { font-size: 2.25rem; } -.f3 { font-size: 1.5rem; } -.f4 { font-size: 1.25rem; } -.f5 { font-size: 1rem; } -.f6 { font-size: .875rem; } -.f7 { font-size: .75rem; } -/* Small and hard to read for many people so use with extreme caution */ -@media screen and (min-width: 30em){ - .f-6-ns, - .f-headline-ns { font-size: 6rem; } - .f-5-ns, - .f-subheadline-ns { font-size: 5rem; } - .f1-ns { font-size: 3rem; } - .f2-ns { font-size: 2.25rem; } - .f3-ns { font-size: 1.5rem; } - .f4-ns { font-size: 1.25rem; } - .f5-ns { font-size: 1rem; } - .f6-ns { font-size: .875rem; } - .f7-ns { font-size: .75rem; } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .f-6-m, - .f-headline-m { font-size: 6rem; } - .f-5-m, - .f-subheadline-m { font-size: 5rem; } - .f1-m { font-size: 3rem; } - .f2-m { font-size: 2.25rem; } - .f3-m { font-size: 1.5rem; } - .f4-m { font-size: 1.25rem; } - .f5-m { font-size: 1rem; } - .f6-m { font-size: .875rem; } - .f7-m { font-size: .75rem; } -} -@media screen and (min-width: 60em) { - .f-6-l, - .f-headline-l { - font-size: 6rem; - } - .f-5-l, - .f-subheadline-l { - font-size: 5rem; - } - .f1-l { font-size: 3rem; } - .f2-l { font-size: 2.25rem; } - .f3-l { font-size: 1.5rem; } - .f4-l { font-size: 1.25rem; } - .f5-l { font-size: 1rem; } - .f6-l { font-size: .875rem; } - .f7-l { font-size: .75rem; } -} -/* - - TYPOGRAPHY - http://tachyons.io/docs/typography/measure/ - - Media Query Extensions: - -ns = not-small - -m = medium - -l = large - -*/ -/* Measure is limited to ~66 characters */ -.measure { - max-width: 30em; -} -/* Measure is limited to ~80 characters */ -.measure-wide { - max-width: 34em; -} -/* Measure is limited to ~45 characters */ -.measure-narrow { - max-width: 20em; -} -/* Book paragraph style - paragraphs are indented with no vertical spacing. */ -.indent { - text-indent: 1em; - margin-top: 0; - margin-bottom: 0; -} -.small-caps { - -webkit-font-feature-settings: "c2sc"; - font-feature-settings: "c2sc"; - font-variant: small-caps; -} -/* Combine this class with a width to truncate text (or just leave as is to truncate at width of containing element. */ -.truncate { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -@media screen and (min-width: 30em) { - .measure-ns { - max-width: 30em; - } - .measure-wide-ns { - max-width: 34em; - } - .measure-narrow-ns { - max-width: 20em; - } - .indent-ns { - text-indent: 1em; - margin-top: 0; - margin-bottom: 0; - } - .small-caps-ns { - -webkit-font-feature-settings: "c2sc"; - font-feature-settings: "c2sc"; - font-variant: small-caps; - } - .truncate-ns { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .measure-m { - max-width: 30em; - } - .measure-wide-m { - max-width: 34em; - } - .measure-narrow-m { - max-width: 20em; - } - .indent-m { - text-indent: 1em; - margin-top: 0; - margin-bottom: 0; - } - .small-caps-m { - -webkit-font-feature-settings: "c2sc"; - font-feature-settings: "c2sc"; - font-variant: small-caps; - } - .truncate-m { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } -} -@media screen and (min-width: 60em) { - .measure-l { - max-width: 30em; - } - .measure-wide-l { - max-width: 34em; - } - .measure-narrow-l { - max-width: 20em; - } - .indent-l { - text-indent: 1em; - margin-top: 0; - margin-bottom: 0; - } - .small-caps-l { - -webkit-font-feature-settings: "c2sc"; - font-feature-settings: "c2sc"; - font-variant: small-caps; - } - .truncate-l { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } -} -/* - - UTILITIES - - Media Query Extensions: - -ns = not-small - -m = medium - -l = large - -*/ -/* Equivalent to .overflow-y-scroll */ -.overflow-container { - overflow-y: scroll; -} -.center { - margin-right: auto; - margin-left: auto; -} -.mr-auto { margin-right: auto; } -.ml-auto { margin-left: auto; } -@media screen and (min-width: 30em){ - .center-ns { - margin-right: auto; - margin-left: auto; - } - .mr-auto-ns { margin-right: auto; } - .ml-auto-ns { margin-left: auto; } -} -@media screen and (min-width: 30em) and (max-width: 60em){ - .center-m { - margin-right: auto; - margin-left: auto; - } - .mr-auto-m { margin-right: auto; } - .ml-auto-m { margin-left: auto; } -} -@media screen and (min-width: 60em){ - .center-l { - margin-right: auto; - margin-left: auto; - } - .mr-auto-l { margin-right: auto; } - .ml-auto-l { margin-left: auto; } -} -/* - - VISIBILITY - - Media Query Extensions: - -ns = not-small - -m = medium - -l = large - -*/ -/* - Text that is hidden but accessible - Ref: http://snook.ca/archives/html_and_css/hiding-content-for-accessibility -*/ -.clip { - position: fixed !important; - _position: absolute !important; - clip: rect(1px 1px 1px 1px); /* IE6, IE7 */ - clip: rect(1px, 1px, 1px, 1px); -} -@media screen and (min-width: 30em) { - .clip-ns { - position: fixed !important; - _position: absolute !important; - clip: rect(1px 1px 1px 1px); /* IE6, IE7 */ - clip: rect(1px, 1px, 1px, 1px); - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .clip-m { - position: fixed !important; - _position: absolute !important; - clip: rect(1px 1px 1px 1px); /* IE6, IE7 */ - clip: rect(1px, 1px, 1px, 1px); - } -} -@media screen and (min-width: 60em) { - .clip-l { - position: fixed !important; - _position: absolute !important; - clip: rect(1px 1px 1px 1px); /* IE6, IE7 */ - clip: rect(1px, 1px, 1px, 1px); - } -} -/* - - WHITE SPACE - - Media Query Extensions: - -ns = not-small - -m = medium - -l = large - -*/ -.ws-normal { white-space: normal; } -.nowrap { white-space: nowrap; } -.pre { white-space: pre; } -@media screen and (min-width: 30em) { - .ws-normal-ns { white-space: normal; } - .nowrap-ns { white-space: nowrap; } - .pre-ns { white-space: pre; } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .ws-normal-m { white-space: normal; } - .nowrap-m { white-space: nowrap; } - .pre-m { white-space: pre; } -} -@media screen and (min-width: 60em) { - .ws-normal-l { white-space: normal; } - .nowrap-l { white-space: nowrap; } - .pre-l { white-space: pre; } -} -/* - - VERTICAL ALIGN - - Media Query Extensions: - -ns = not-small - -m = medium - -l = large - -*/ -.v-base { vertical-align: baseline; } -.v-mid { vertical-align: middle; } -.v-top { vertical-align: top; } -.v-btm { vertical-align: bottom; } -@media screen and (min-width: 30em) { - .v-base-ns { vertical-align: baseline; } - .v-mid-ns { vertical-align: middle; } - .v-top-ns { vertical-align: top; } - .v-btm-ns { vertical-align: bottom; } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .v-base-m { vertical-align: baseline; } - .v-mid-m { vertical-align: middle; } - .v-top-m { vertical-align: top; } - .v-btm-m { vertical-align: bottom; } -} -@media screen and (min-width: 60em) { - .v-base-l { vertical-align: baseline; } - .v-mid-l { vertical-align: middle; } - .v-top-l { vertical-align: top; } - .v-btm-l { vertical-align: bottom; } -} -/* - - HOVER EFFECTS - Docs: http://tachyons.io/docs/themes/hovers/ - - - Dim - - Glow - - Hide Child - - Underline text - - Grow - - Pointer - - Shadow - -*/ -/* - - Dim element on hover by adding the dim class. - -*/ -.dim { - opacity: 1; - -webkit-transition: opacity .15s ease-in; - transition: opacity .15s ease-in; -} -.dim:hover, -.dim:focus { - opacity: .5; - -webkit-transition: opacity .15s ease-in; - transition: opacity .15s ease-in; -} -.dim:active { - opacity: .8; -webkit-transition: opacity .15s ease-out; transition: opacity .15s ease-out; -} -/* - - Animate opacity to 100% on hover by adding the glow class. - -*/ -.glow { - -webkit-transition: opacity .15s ease-in; - transition: opacity .15s ease-in; -} -.glow:hover, -.glow:focus { - opacity: 1; - -webkit-transition: opacity .15s ease-in; - transition: opacity .15s ease-in; -} -/* - - Hide child & reveal on hover: - - Put the hide-child class on a parent element and any nested element with the - child class will be hidden and displayed on hover or focus. - -
    -
    Hidden until hover or focus
    -
    Hidden until hover or focus
    -
    Hidden until hover or focus
    -
    Hidden until hover or focus
    -
    -*/ -.hide-child .child { - opacity: 0; - -webkit-transition: opacity .15s ease-in; - transition: opacity .15s ease-in; -} -.hide-child:hover .child, -.hide-child:focus .child, -.hide-child:active .child { - opacity: 1; - -webkit-transition: opacity .15s ease-in; - transition: opacity .15s ease-in; -} -.underline-hover:hover, -.underline-hover:focus { - text-decoration: underline; -} -/* Can combine this with overflow-hidden to make background images grow on hover - * even if you are using background-size: cover */ -.grow { - -moz-osx-font-smoothing: grayscale; - -webkit-backface-visibility: hidden; - backface-visibility: hidden; - -webkit-transform: translateZ(0); - transform: translateZ(0); - -webkit-transition: -webkit-transform 0.25s ease-out; - transition: -webkit-transform 0.25s ease-out; - transition: transform 0.25s ease-out; - transition: transform 0.25s ease-out, -webkit-transform 0.25s ease-out; -} -.grow:hover, -.grow:focus { - -webkit-transform: scale(1.05); - transform: scale(1.05); -} -.grow:active { - -webkit-transform: scale(.90); - transform: scale(.90); -} -.grow-large { - -moz-osx-font-smoothing: grayscale; - -webkit-backface-visibility: hidden; - backface-visibility: hidden; - -webkit-transform: translateZ(0); - transform: translateZ(0); - -webkit-transition: -webkit-transform .25s ease-in-out; - transition: -webkit-transform .25s ease-in-out; - transition: transform .25s ease-in-out; - transition: transform .25s ease-in-out, -webkit-transform .25s ease-in-out; -} -.grow-large:hover, -.grow-large:focus { - -webkit-transform: scale(1.2); - transform: scale(1.2); -} -.grow-large:active { - -webkit-transform: scale(.95); - transform: scale(.95); -} -/* Add pointer on hover */ -.pointer:hover { - cursor: pointer; -} -/* - Add shadow on hover. - - Performant box-shadow animation pattern from - http://tobiasahlin.com/blog/how-to-animate-box-shadow/ -*/ -.shadow-hover { - cursor: pointer; - position: relative; - -webkit-transition: all 0.5s cubic-bezier(0.165, 0.84, 0.44, 1); - transition: all 0.5s cubic-bezier(0.165, 0.84, 0.44, 1); -} -.shadow-hover::after { - content: ''; - -webkit-box-shadow: 0px 0px 16px 2px rgba(0, 0, 0, .2); - box-shadow: 0px 0px 16px 2px rgba(0, 0, 0, .2); - border-radius: inherit; - opacity: 0; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: -1; - -webkit-transition: opacity 0.5s cubic-bezier(0.165, 0.84, 0.44, 1); - transition: opacity 0.5s cubic-bezier(0.165, 0.84, 0.44, 1); -} -.shadow-hover:hover::after, -.shadow-hover:focus::after { - opacity: 1; -} -/* Combine with classes in skins and skins-pseudo for - * many different transition possibilities. */ -.bg-animate, -.bg-animate:hover, -.bg-animate:focus { - -webkit-transition: background-color .15s ease-in-out; - transition: background-color .15s ease-in-out; -} -/* - - Z-INDEX - - Base - z = z-index - - Modifiers - -0 = literal value 0 - -1 = literal value 1 - -2 = literal value 2 - -3 = literal value 3 - -4 = literal value 4 - -5 = literal value 5 - -999 = literal value 999 - -9999 = literal value 9999 - - -max = largest accepted z-index value as integer - - -inherit = string value inherit - -initial = string value initial - -unset = string value unset - - MDN: https://developer.mozilla.org/en/docs/Web/CSS/z-index - Spec: http://www.w3.org/TR/CSS2/zindex.html - Articles: - https://philipwalton.com/articles/what-no-one-told-you-about-z-index/ - - Tips on extending: - There might be a time worth using negative z-index values. - Or if you are using tachyons with another project, you might need to - adjust these values to suit your needs. - -*/ -.z-0 { z-index: 0; } -.z-1 { z-index: 1; } -.z-2 { z-index: 2; } -.z-3 { z-index: 3; } -.z-4 { z-index: 4; } -.z-5 { z-index: 5; } -.z-999 { z-index: 999; } -.z-9999 { z-index: 9999; } -.z-max { - z-index: 2147483647; -} -.z-inherit { z-index: inherit; } -.z-initial { z-index: auto; z-index: initial; } -.z-unset { z-index: unset; } -/* - - NESTED - Tachyons module for styling nested elements - that are generated by a cms. - -*/ -.nested-copy-line-height p, -.nested-copy-line-height ul, -.nested-copy-line-height ol { - line-height: 1.5; -} -.nested-headline-line-height h1, -.nested-headline-line-height h2, -.nested-headline-line-height h3, -.nested-headline-line-height h4, -.nested-headline-line-height h5, -.nested-headline-line-height h6 { - line-height: 1.25; -} -.nested-list-reset ul, -.nested-list-reset ol { - padding-left: 0; - margin-left: 0; - list-style-type: none; -} -.nested-copy-indent p+p { - text-indent: 1em; - margin-top: 0; - margin-bottom: 0; -} -.nested-copy-separator p+p { - margin-top: 1.5em; -} -.nested-img img { - width: 100%; - max-width: 100%; - display: block; -} -.nested-links a { - color: #0594CB; - -webkit-transition: color .15s ease-in; - transition: color .15s ease-in; -} -.nested-links a:hover, -.nested-links a:focus { - color: #96ccff; - -webkit-transition: color .15s ease-in; - transition: color .15s ease-in; -} -/*@import 'tachyons/src/_styles';*/ -/* Variables */ -/* Importing here will allow you to override any variables in the modules */ -/* - - Tachyons - COLOR VARIABLES - - Grayscale - - Solids - - Transparencies - Colors - -*/ -/* - - CUSTOM MEDIA QUERIES - - Media query values can be changed to fit your own content. - There are no magic bullets when it comes to media query width values. - They should be declared in em units - and they should be set to meet - the needs of your content. You can also add additional media queries, - or remove some of the existing ones. - - These media queries can be referenced like so: - - @media (--breakpoint-not-small) { - .medium-and-larger-specific-style { - background-color: red; - } - } - - @media (--breakpoint-medium) { - .medium-screen-specific-style { - background-color: red; - } - } - - @media (--breakpoint-large) { - .large-and-larger-screen-specific-style { - background-color: red; - } - } - -*/ -/* Media Queries */ -/* Debugging */ -/*@import 'tachyons/src/_debug-children'; -@import 'tachyons/src/_debug-grid';*/ -/* Uncomment out the line below to help debug layout issues */ -/* @import 'tachyons/src/_debug'; */ -/* purgecss start ignore */ -.header-link:after { - position: relative; - left: 0.5em; - opacity: 0; - font-size: 0.8em; - -moz-transition: opacity 0.2s ease-in-out 0.1s; - -ms-transition: opacity 0.2s ease-in-out 0.1s; -} -h2:hover .header-link, -h3:hover .header-link, -h4:hover .header-link, -h5:hover .header-link, -h6:hover .header-link { - opacity: 1; -} -.animated { - -webkit-animation-duration: .5s; - animation-duration: .5s; - -webkit-animation-fill-mode: forwards; - animation-fill-mode: forwards; - -webkit-animation-timing-function: ease-in-out; - animation-timing-function: ease-in-out; -} -@-webkit-keyframes fadeIn { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} -@keyframes fadeIn { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} -.fadeIn { - -webkit-animation-name: fadeIn; - animation-name: fadeIn; -} -.animated-delay-1 { - -webkit-animation-delay: 0.5s; - animation-delay: 0.5s; -} -.note, -.warning { - - border-left-width: 4px; - border-left-style: solid; - position: relative; - border-color: #0594CB; - - display: block; -} -.note #exclamation-icon, -.warning #exclamation-icon { - - fill: #0594CB; - position: absolute; - top: 35%; - left: -12px; - /*background-color: white;*/ -} -.admonition-content { - display: block; - margin: 0px; - padding: .125em 1em; - /*margin-left: 1em;*/ - margin-top: 2em; - margin-bottom: 2em; - overflow-x: auto; - /*font-size: .9375em;*/ - background-color: rgba(0, 0, 0, .05); - } -.hide-child-menu .child-menu { - display: none; - } -.hide-child-menu:hover .child-menu, - .hide-child-menu:focus .child-menu, - .hide-child-menu:active .child-menu { - display: block; - } -/*documentation-copy headings exaggerate spacing and size to chunk content */ -.documentation-copy h2 { - margin-top: 3em - } -.documentation-copy h2.minor { - font-size: inherit; - margin-top: inherit; - border-bottom: none; -} -.searchbox{display:inline-block;position:relative;width:200px;height:32px!important;white-space:nowrap;-webkit-box-sizing:border-box;box-sizing:border-box;visibility:visible!important} -.searchbox .algolia-autocomplete{display:block;width:100%;height:100%} -.searchbox__wrapper{width:100%;height:100%;z-index:999;position:relative} -.searchbox__input{display:inline-block;-webkit-box-sizing:border-box;box-sizing:border-box;-webkit-transition:background .4s ease,-webkit-box-shadow .4s ease;transition:background .4s ease,-webkit-box-shadow .4s ease;transition:box-shadow .4s ease,background .4s ease;transition:box-shadow .4s ease,background .4s ease,-webkit-box-shadow .4s ease;border:0;border-radius:16px;-webkit-box-shadow:inset 0 0 0 1px #ccc;box-shadow:inset 0 0 0 1px #ccc;background:#fff!important;padding:0 26px 0 32px;width:100%;height:100%;vertical-align:middle;white-space:normal;font-size:12px;-webkit-appearance:none;-moz-appearance:none;appearance:none} -.searchbox__input::-webkit-search-cancel-button,.searchbox__input::-webkit-search-decoration,.searchbox__input::-webkit-search-results-button,.searchbox__input::-webkit-search-results-decoration{display:none} -.searchbox__input:hover{-webkit-box-shadow:inset 0 0 0 1px #b3b3b3;box-shadow:inset 0 0 0 1px #b3b3b3} -.searchbox__input:active,.searchbox__input:focus{outline:0;-webkit-box-shadow:inset 0 0 0 1px #aaa;box-shadow:inset 0 0 0 1px #aaa;background:#fff} -.searchbox__input::-webkit-input-placeholder{color:#aaa} -.searchbox__input:-ms-input-placeholder{color:#aaa} -.searchbox__input::-ms-input-placeholder{color:#aaa} -.searchbox__input::placeholder{color:#aaa} -.searchbox__submit{position:absolute;top:0;margin:0;border:0;border-radius:16px 0 0 16px;background-color:rgba(69, 142, 225, 0);padding:0;width:32px;height:100%;vertical-align:middle;text-align:center;font-size:inherit;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;right:inherit;left:0} -.searchbox__submit:before{display:inline-block;margin-right:-4px;height:100%;vertical-align:middle;content:""} -.searchbox__submit:active,.searchbox__submit:hover{cursor:pointer} -.searchbox__submit:focus{outline:0} -.searchbox__submit svg{width:14px;height:14px;vertical-align:middle;fill:#6d7e96} -.searchbox__reset{display:block;position:absolute;top:8px;right:8px;margin:0;border:0;background:none;cursor:pointer;padding:0;font-size:inherit;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;fill:rgba(0, 0, 0, .5)} -.searchbox__reset.hide{display:none} -.searchbox__reset:focus{outline:0} -.searchbox__reset svg{display:block;margin:4px;width:8px;height:8px} -.searchbox__input:valid~.searchbox__reset{display:block;-webkit-animation-name:sbx-reset-in;animation-name:sbx-reset-in;-webkit-animation-duration:.15s;animation-duration:.15s} -@-webkit-keyframes sbx-reset-in{0%{-webkit-transform:translate3d(-20%,0,0);transform:translate3d(-20%,0,0);opacity:0}to{-webkit-transform:none;transform:none;opacity:1}} -@keyframes sbx-reset-in{0%{-webkit-transform:translate3d(-20%,0,0);transform:translate3d(-20%,0,0);opacity:0}to{-webkit-transform:none;transform:none;opacity:1}} -.algolia-autocomplete.algolia-autocomplete-right .ds-dropdown-menu{right:0!important;left:inherit!important} -.algolia-autocomplete.algolia-autocomplete-right .ds-dropdown-menu:before{right:48px} -.algolia-autocomplete.algolia-autocomplete-left .ds-dropdown-menu{left:0!important;right:inherit!important} -.algolia-autocomplete.algolia-autocomplete-left .ds-dropdown-menu:before{left:48px} -.algolia-autocomplete .ds-dropdown-menu{top:-6px;border-radius:4px;margin:6px 0 0;padding:0;text-align:left;height:auto;position:relative;background:transparent;border:none;z-index:999;max-width:600px;min-width:500px;-webkit-box-shadow:0 1px 0 0 rgba(0, 0, 0, .2),0 2px 3px 0 rgba(0, 0, 0, .1);box-shadow:0 1px 0 0 rgba(0, 0, 0, .2),0 2px 3px 0 rgba(0, 0, 0, .1)} -.algolia-autocomplete .ds-dropdown-menu:before{display:block;position:absolute;content:"";width:14px;height:14px;background:#fff;z-index:1000;top:-7px;border-top:1px solid #d9d9d9;border-right:1px solid #d9d9d9;-webkit-transform:rotate(-45deg);transform:rotate(-45deg);border-radius:2px} -.algolia-autocomplete .ds-dropdown-menu .ds-suggestions{position:relative;z-index:1000;margin-top:8px} -.algolia-autocomplete .ds-dropdown-menu .ds-suggestions a:hover{text-decoration:none} -.algolia-autocomplete .ds-dropdown-menu .ds-suggestion{cursor:pointer} -.algolia-autocomplete .ds-dropdown-menu .ds-suggestion.ds-cursor .algolia-docsearch-suggestion.suggestion-layout-simple,.algolia-autocomplete .ds-dropdown-menu .ds-suggestion.ds-cursor .algolia-docsearch-suggestion:not(.suggestion-layout-simple) .algolia-docsearch-suggestion--content{background-color:rgba(69, 142, 225, .05)} -.algolia-autocomplete .ds-dropdown-menu [class^=ds-dataset-]{position:relative;border:1px solid #d9d9d9;background:#fff;border-radius:4px;overflow:auto;padding:0 8px 8px} -.algolia-autocomplete .ds-dropdown-menu *{-webkit-box-sizing:border-box;box-sizing:border-box} -.algolia-autocomplete .algolia-docsearch-suggestion{display:block;position:relative;padding:0 8px;background:#fff;color:#02060c;overflow:hidden} -.algolia-autocomplete .algolia-docsearch-suggestion--highlight{color:#174d8c;background:rgba(143, 187, 237, .1);padding:.1em .05em} -.algolia-autocomplete .algolia-docsearch-suggestion--category-header .algolia-docsearch-suggestion--category-header-lvl0 .algolia-docsearch-suggestion--highlight,.algolia-autocomplete .algolia-docsearch-suggestion--category-header .algolia-docsearch-suggestion--category-header-lvl1 .algolia-docsearch-suggestion--highlight,.algolia-autocomplete .algolia-docsearch-suggestion--text .algolia-docsearch-suggestion--highlight{padding:0 0 1px;background:inherit;-webkit-box-shadow:inset 0 -2px 0 0 rgba(69, 142, 225, .8);box-shadow:inset 0 -2px 0 0 rgba(69, 142, 225, .8);color:inherit} -.algolia-autocomplete .algolia-docsearch-suggestion--content{display:block;float:right;width:70%;position:relative;padding:5.33333px 0 5.33333px 10.66667px;cursor:pointer} -.algolia-autocomplete .algolia-docsearch-suggestion--content:before{content:"";position:absolute;display:block;top:0;height:100%;width:1px;background:#ddd;left:-1px} -.algolia-autocomplete .algolia-docsearch-suggestion--category-header{position:relative;border-bottom:1px solid #ddd;display:none;margin-top:8px;padding:4px 0;font-size:1em;color:#33363d} -.algolia-autocomplete .algolia-docsearch-suggestion--wrapper{width:100%;float:left;padding:8px 0 0} -.algolia-autocomplete .algolia-docsearch-suggestion--subcategory-column{float:left;width:30%;text-align:right;position:relative;padding:5.33333px 10.66667px;color:#a4a7ae;font-size:.9em;word-wrap:break-word} -.algolia-autocomplete .algolia-docsearch-suggestion--subcategory-column:before{content:"";position:absolute;display:block;top:0;height:100%;width:1px;background:#ddd;right:0} -.algolia-autocomplete .algolia-docsearch-suggestion--subcategory-inline{display:none} -.algolia-autocomplete .algolia-docsearch-suggestion--title{margin-bottom:4px;color:#02060c;font-size:.9em;font-weight:700} -.algolia-autocomplete .algolia-docsearch-suggestion--text{display:block;line-height:1.2em;font-size:.85em;color:#63676d} -.algolia-autocomplete .algolia-docsearch-suggestion--no-results{width:100%;padding:8px 0;text-align:center;font-size:1.2em} -.algolia-autocomplete .algolia-docsearch-suggestion--no-results:before{display:none} -.algolia-autocomplete .algolia-docsearch-suggestion code{padding:1px 5px;font-size:90%;border:none;color:#222;background-color:#ebebeb;border-radius:3px;font-family:Menlo,Monaco,Consolas,Courier New,monospace} -.algolia-autocomplete .algolia-docsearch-suggestion code .algolia-docsearch-suggestion--highlight{background:none} -.algolia-autocomplete .algolia-docsearch-suggestion.algolia-docsearch-suggestion__main .algolia-docsearch-suggestion--category-header,.algolia-autocomplete .algolia-docsearch-suggestion.algolia-docsearch-suggestion__secondary{display:block} -@media (min-width:768px){.algolia-autocomplete .algolia-docsearch-suggestion .algolia-docsearch-suggestion--subcategory-column{display:block}} -@media (max-width:768px){.algolia-autocomplete .algolia-docsearch-suggestion .algolia-docsearch-suggestion--subcategory-column{display:inline-block;width:auto;float:left;padding:0;color:#02060c;font-size:.9em;font-weight:700;text-align:left;opacity:.5}.algolia-autocomplete .algolia-docsearch-suggestion .algolia-docsearch-suggestion--subcategory-column:before{display:none}.algolia-autocomplete .algolia-docsearch-suggestion .algolia-docsearch-suggestion--subcategory-column:after{content:"|"}.algolia-autocomplete .algolia-docsearch-suggestion .algolia-docsearch-suggestion--content{display:inline-block;width:auto;text-align:left;float:left;padding:0}.algolia-autocomplete .algolia-docsearch-suggestion .algolia-docsearch-suggestion--content:before{display:none}} -.algolia-autocomplete .suggestion-layout-simple.algolia-docsearch-suggestion{border-bottom:1px solid #eee;padding:8px;margin:0} -.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--content{width:100%;padding:0} -.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--content:before{display:none} -.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--category-header{margin:0;padding:0;display:block;width:100%;border:none} -.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--category-header-lvl0,.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--category-header-lvl1{opacity:.6;font-size:.85em} -.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--category-header-lvl1:before{background-image:url('data:image/svg+xml;utf8,');content:"";width:10px;height:10px;display:inline-block} -.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--wrapper{width:100%;float:left;margin:0;padding:0} -.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--duplicate-content,.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--subcategory-inline{display:none!important} -.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--title{margin:0;color:#458ee1;font-size:.9em;font-weight:400} -.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--title:before{content:"#";font-weight:700;color:#458ee1;display:inline-block} -.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--text{margin:4px 0 0;display:block;line-height:1.4em;padding:5.33333px 8px;background:#f8f8f8;font-size:.85em;opacity:.8} -.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--text .algolia-docsearch-suggestion--highlight{color:#3f4145;font-weight:700;-webkit-box-shadow:none;box-shadow:none} -.algolia-autocomplete .algolia-docsearch-footer{width:134px;height:20px;z-index:2000;margin-top:10.66667px;float:right;font-size:0;line-height:0} -.algolia-autocomplete .algolia-docsearch-footer--logo{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='168' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cpath d='M78.988.938h16.594a2.968 2.968 0 0 1 2.966 2.966V20.5a2.967 2.967 0 0 1-2.966 2.964H78.988a2.967 2.967 0 0 1-2.966-2.964V3.897A2.961 2.961 0 0 1 78.988.938zm41.937 17.866c-4.386.02-4.386-3.54-4.386-4.106l-.007-13.336 2.675-.424v13.254c0 .322 0 2.358 1.718 2.364v2.248zm-10.846-2.18c.821 0 1.43-.047 1.855-.129v-2.719a6.334 6.334 0 0 0-1.574-.199 5.7 5.7 0 0 0-.897.069 2.699 2.699 0 0 0-.814.24c-.24.116-.439.28-.582.491-.15.212-.219.335-.219.656 0 .628.219.991.616 1.23s.938.362 1.615.362zm-.233-9.7c.883 0 1.629.109 2.231.328.602.218 1.088.525 1.444.915.363.396.609.922.76 1.483.157.56.232 1.175.232 1.85v6.874a32.5 32.5 0 0 1-1.868.314c-.834.123-1.772.185-2.813.185-.69 0-1.327-.069-1.895-.198a4.001 4.001 0 0 1-1.471-.636 3.085 3.085 0 0 1-.951-1.134c-.226-.465-.343-1.12-.343-1.803 0-.656.13-1.073.384-1.525a3.24 3.24 0 0 1 1.047-1.106c.445-.287.95-.492 1.532-.615a8.8 8.8 0 0 1 1.82-.185 8.404 8.404 0 0 1 1.972.24v-.438c0-.307-.035-.6-.11-.874a1.88 1.88 0 0 0-.384-.73 1.784 1.784 0 0 0-.724-.493 3.164 3.164 0 0 0-1.143-.205c-.616 0-1.177.075-1.69.164a7.735 7.735 0 0 0-1.26.307l-.321-2.192c.335-.117.834-.233 1.478-.349a10.98 10.98 0 0 1 2.073-.178zm52.842 9.626c.822 0 1.43-.048 1.854-.13V13.7a6.347 6.347 0 0 0-1.574-.199c-.294 0-.595.021-.896.069a2.7 2.7 0 0 0-.814.24 1.46 1.46 0 0 0-.582.491c-.15.212-.218.335-.218.656 0 .628.218.991.615 1.23.404.245.938.362 1.615.362zm-.226-9.694c.883 0 1.629.108 2.231.327.602.219 1.088.526 1.444.915.355.39.609.923.759 1.483a6.8 6.8 0 0 1 .233 1.852v6.873c-.41.088-1.034.19-1.868.314-.834.123-1.772.184-2.813.184-.69 0-1.327-.068-1.895-.198a4.001 4.001 0 0 1-1.471-.635 3.085 3.085 0 0 1-.951-1.134c-.226-.465-.343-1.12-.343-1.804 0-.656.13-1.073.384-1.524.26-.45.608-.82 1.047-1.107.445-.286.95-.491 1.532-.614a8.803 8.803 0 0 1 2.751-.13c.329.034.671.096 1.04.185v-.437a3.3 3.3 0 0 0-.109-.875 1.873 1.873 0 0 0-.384-.731 1.784 1.784 0 0 0-.724-.492 3.165 3.165 0 0 0-1.143-.205c-.616 0-1.177.075-1.69.164a7.75 7.75 0 0 0-1.26.307l-.321-2.193c.335-.116.834-.232 1.478-.348a11.633 11.633 0 0 1 2.073-.177zm-8.034-1.271a1.626 1.626 0 0 1-1.628-1.62c0-.895.725-1.62 1.628-1.62.904 0 1.63.725 1.63 1.62 0 .895-.733 1.62-1.63 1.62zm1.348 13.22h-2.689V7.27l2.69-.423v11.956zm-4.714 0c-4.386.02-4.386-3.54-4.386-4.107l-.008-13.336 2.676-.424v13.254c0 .322 0 2.358 1.718 2.364v2.248zm-8.698-5.903c0-1.156-.253-2.119-.746-2.788-.493-.677-1.183-1.01-2.067-1.01-.882 0-1.574.333-2.065 1.01-.493.676-.733 1.632-.733 2.788 0 1.168.246 1.953.74 2.63.492.683 1.183 1.018 2.066 1.018.882 0 1.574-.342 2.067-1.019.492-.683.738-1.46.738-2.63zm2.737-.007c0 .902-.13 1.584-.397 2.33a5.52 5.52 0 0 1-1.128 1.906 4.986 4.986 0 0 1-1.752 1.223c-.685.286-1.739.45-2.265.45-.528-.006-1.574-.157-2.252-.45a5.096 5.096 0 0 1-1.744-1.223c-.487-.527-.863-1.162-1.137-1.906a6.345 6.345 0 0 1-.41-2.33c0-.902.123-1.77.397-2.508a5.554 5.554 0 0 1 1.15-1.892 5.133 5.133 0 0 1 1.75-1.216c.679-.287 1.425-.423 2.232-.423.808 0 1.553.142 2.237.423a4.88 4.88 0 0 1 1.753 1.216 5.644 5.644 0 0 1 1.135 1.892c.287.738.431 1.606.431 2.508zm-20.138 0c0 1.12.246 2.363.738 2.882.493.52 1.13.78 1.91.78.424 0 .828-.062 1.204-.178.377-.116.677-.253.917-.417V9.33a10.476 10.476 0 0 0-1.766-.226c-.971-.028-1.71.37-2.23 1.004-.513.636-.773 1.75-.773 2.788zm7.438 5.274c0 1.824-.466 3.156-1.404 4.004-.936.846-2.367 1.27-4.296 1.27-.705 0-2.17-.137-3.34-.396l.431-2.118c.98.205 2.272.26 2.95.26 1.074 0 1.84-.219 2.299-.656.459-.437.684-1.086.684-1.948v-.437a8.07 8.07 0 0 1-1.047.397c-.43.13-.93.198-1.492.198-.739 0-1.41-.116-2.018-.349a4.206 4.206 0 0 1-1.567-1.025c-.431-.45-.774-1.017-1.013-1.694-.24-.677-.363-1.885-.363-2.773 0-.834.13-1.88.384-2.577.26-.696.629-1.298 1.129-1.796.493-.498 1.095-.881 1.8-1.162a6.605 6.605 0 0 1 2.428-.457c.87 0 1.67.109 2.45.24.78.129 1.444.265 1.985.415V18.17z' fill='%235468FF'/%3E%3Cpath d='M6.972 6.677v1.627c-.712-.446-1.52-.67-2.425-.67-.585 0-1.045.13-1.38.391a1.24 1.24 0 0 0-.502 1.03c0 .425.164.765.494 1.02.33.256.835.532 1.516.83.447.192.795.356 1.045.495.25.138.537.332.862.582.324.25.563.548.718.894.154.345.23.741.23 1.188 0 .947-.334 1.691-1.004 2.234-.67.542-1.537.814-2.601.814-1.18 0-2.16-.229-2.936-.686v-1.708c.84.628 1.814.942 2.92.942.585 0 1.048-.136 1.388-.407.34-.271.51-.646.51-1.125 0-.287-.1-.55-.302-.79-.203-.24-.42-.42-.655-.542-.234-.123-.585-.29-1.053-.503a61.27 61.27 0 0 1-.582-.271 13.67 13.67 0 0 1-.55-.287 4.275 4.275 0 0 1-.567-.351 6.92 6.92 0 0 1-.455-.4c-.18-.17-.31-.34-.39-.51-.08-.17-.155-.37-.224-.598a2.553 2.553 0 0 1-.104-.742c0-.915.333-1.638.998-2.17.664-.532 1.523-.798 2.576-.798.968 0 1.793.17 2.473.51zm7.468 5.696v-.287c-.022-.607-.187-1.088-.495-1.444-.309-.357-.75-.535-1.324-.535-.532 0-.99.194-1.373.583-.382.388-.622.949-.717 1.683h3.909zm1.005 2.792v1.404c-.596.34-1.383.51-2.362.51-1.255 0-2.255-.377-3-1.132-.744-.755-1.116-1.744-1.116-2.968 0-1.297.34-2.316 1.021-3.055.68-.74 1.548-1.11 2.6-1.11 1.033 0 1.852.323 2.458.966.606.644.91 1.572.91 2.784 0 .33-.033.676-.096 1.038h-5.314c.107.702.405 1.239.894 1.611.49.372 1.106.558 1.85.558.862 0 1.58-.202 2.155-.606zm6.605-1.77h-1.212c-.596 0-1.045.116-1.349.35-.303.234-.454.532-.454.894 0 .372.117.664.35.877.235.213.575.32 1.022.32.51 0 .912-.142 1.204-.424.293-.281.44-.651.44-1.108v-.91zm-4.068-2.554V9.325c.627-.361 1.457-.542 2.489-.542 2.116 0 3.175 1.026 3.175 3.08V17h-1.548v-.957c-.415.68-1.143 1.02-2.186 1.02-.766 0-1.38-.22-1.843-.661-.462-.442-.694-1.003-.694-1.684 0-.776.293-1.38.878-1.81.585-.431 1.404-.647 2.457-.647h1.34V11.8c0-.554-.133-.971-.399-1.253-.266-.282-.707-.423-1.324-.423a4.07 4.07 0 0 0-2.345.718zm9.333-1.93v1.42c.394-1 1.101-1.5 2.123-1.5.148 0 .313.016.494.048v1.531a1.885 1.885 0 0 0-.75-.143c-.542 0-.989.24-1.34.718-.351.479-.527 1.048-.527 1.707V17h-1.563V8.91h1.563zm5.01 4.084c.022.82.272 1.492.75 2.019.479.526 1.15.79 2.01.79.639 0 1.235-.176 1.788-.527v1.404c-.521.319-1.186.479-1.995.479-1.265 0-2.276-.4-3.031-1.197-.755-.798-1.133-1.792-1.133-2.984 0-1.16.38-2.151 1.14-2.975.761-.825 1.79-1.237 3.088-1.237.702 0 1.346.149 1.93.447v1.436a3.242 3.242 0 0 0-1.77-.495c-.84 0-1.513.266-2.019.798-.505.532-.758 1.213-.758 2.042zM40.24 5.72v4.579c.458-1 1.293-1.5 2.505-1.5.787 0 1.42.245 1.899.734.479.49.718 1.17.718 2.042V17h-1.564v-5.106c0-.553-.14-.98-.422-1.284-.282-.303-.652-.455-1.11-.455-.531 0-1.002.202-1.411.606-.41.405-.615 1.022-.615 1.851V17h-1.563V5.72h1.563zm14.966 10.02c.596 0 1.096-.253 1.5-.758.404-.506.606-1.157.606-1.955 0-.915-.202-1.62-.606-2.114-.404-.495-.92-.742-1.548-.742-.553 0-1.05.224-1.491.67-.442.447-.662 1.133-.662 2.058 0 .958.212 1.67.638 2.138.425.469.946.703 1.563.703zM53.004 5.72v4.42c.574-.894 1.388-1.341 2.44-1.341 1.022 0 1.857.383 2.506 1.149.649.766.973 1.781.973 3.047 0 1.138-.309 2.109-.925 2.912-.617.803-1.463 1.205-2.537 1.205-1.075 0-1.894-.447-2.457-1.34V17h-1.58V5.72h1.58zm9.908 11.104l-3.223-7.913h1.739l1.005 2.632 1.26 3.415c.096-.32.48-1.458 1.15-3.415l.909-2.632h1.66l-2.92 7.866c-.777 2.074-1.963 3.11-3.559 3.11a2.92 2.92 0 0 1-.734-.079v-1.34c.17.042.351.064.543.064 1.032 0 1.755-.57 2.17-1.708z' fill='%235D6494'/%3E%3Cpath d='M89.632 5.967v-.772a.978.978 0 0 0-.978-.977h-2.28a.978.978 0 0 0-.978.977v.793c0 .088.082.15.171.13a7.127 7.127 0 0 1 1.984-.28c.65 0 1.295.088 1.917.259.082.02.164-.04.164-.13m-6.248 1.01l-.39-.389a.977.977 0 0 0-1.382 0l-.465.465a.973.973 0 0 0 0 1.38l.383.383c.062.061.15.047.205-.014.226-.307.472-.601.746-.874.281-.28.568-.526.883-.751.068-.042.075-.137.02-.2m4.16 2.453v3.341c0 .096.104.165.192.117l2.97-1.537c.068-.034.089-.117.055-.184a3.695 3.695 0 0 0-3.08-1.866c-.068 0-.136.054-.136.13m0 8.048a4.489 4.489 0 0 1-4.49-4.482 4.488 4.488 0 0 1 4.49-4.482 4.488 4.488 0 0 1 4.489 4.482 4.484 4.484 0 0 1-4.49 4.482m0-10.85a6.363 6.363 0 1 0 0 12.729 6.37 6.37 0 0 0 6.372-6.368 6.358 6.358 0 0 0-6.371-6.36' fill='%23FFF'/%3E%3C/g%3E%3C/svg%3E");background-repeat:no-repeat;background-position:50%;background-size:100%;overflow:hidden;text-indent:-9000px;padding:0!important;width:100%;height:100%;display:block} -/* These styles enhance the home page carousel, located here: themes/gohugoioTheme/layouts/partials/home-page-sections/showcase.html */ -.overflow-x-scroll{ - -webkit-overflow-scrolling: touch; -} -.row { - -webkit-transition: 450ms -webkit-transform; - transition: 450ms -webkit-transform; - transition: 450ms transform; - transition: 450ms transform, 450ms -webkit-transform; - font-size: 0; -} -.tile { - -webkit-transition: 450ms all; - transition: 450ms all; -} -.details { - background: -webkit-gradient(linear, left bottom, left top, from(rgba(0, 0, 0, .9)), to(rgba(0, 0, 0, 0))); - background: linear-gradient(to top, rgba(0, 0, 0, .9) 0%, rgba(0, 0, 0, 0) 100%); - -webkit-transition: 450ms opacity; - transition: 450ms opacity; -} -.tile:hover .details { - opacity: 1; -} -.row:hover .tile { - opacity: 0.3; -} -.row:hover .tile:hover { - opacity: 1; -} -.chroma .lntable pre { - padding: 0; - margin: 0; - border: 0; -} -.chroma .lntable pre code { - padding: 0; - margin: 0; -} -pre, .pre { - overflow-x: auto; - overflow-y: hidden; - overflow: scroll; -} -code { - padding: 0.2em; - margin: 0; - font-size: 85%; - background-color: rgba(27, 31, 35, .05); - border-radius: 3px; -} -pre code { - display: block; - padding: 1.5em 1.5em; - font-size: .875rem; - line-height: 2; - overflow-x: auto; -} -pre { - background-color: #fff; - color: #333; - white-space: pre; - -webkit-hyphens: none; - -ms-hyphens: none; - hyphens: none; - position: relative; - border-width: 1px; - border-color: #ccc; - border-style: solid; -} -/* The Pygments highlighter comes with its own styles. */ -.highlight pre { - background-color: inherit; - color: inherit; - padding: 0.5em; - font-size: .875rem; -} -/*We are adding the copy button content here so we can change it with javascript. See the "Clipboard scripts"*/ -.copy:after { - content: "Copy" -} -.copied:after { - content: "Copied" -} -@media screen and (min-width: 60em) { - .full-width, pre.expand:hover - { - /*width: 100vw; - position: relative; - left: 50%; - right: 50%; - margin-left: -50vw; - margin-right: -50vw;*/ - /*width: 60vw;*/ - /*position: relative; - left: 50%; - right: 50%;*/ - /*margin-left: -30vw;*/ - margin-right: -30vw; - max-width: 100vw; - } -} -.code-block .line-numbers-rows { - background: #2f3a46; - border: none; - bottom: -50px; - color: #98a4b3; - left: -178px; - padding: 50px 0; - top: -50px; - width: 138px -} -.code-block .line-numbers-rows>span:before { - color: inherit; - padding-right: 30px -} -.tab-button{ - margin-bottom:1px; - position: relative; - z-index: 1; - color:#333; - border-color:#ccc; - outline: none; - background-color:white; -} -.tab-pane code{ - background:#f1f2f2; - border-radius:0; -} -.tab-pane .chroma{ - background:none; - padding:0; -} -.tab-button.active{ - border-bottom-color:#f1f2f2; - background-color: #f1f2f2; -} -.tab-content .tab-pane{ - display: none; -} -.tab-content .tab-pane.active{ - display: block; -} -/* Treatment of copy buttons inside a tab module */ -.tab-content .copy, .tab-content .copied{ - display: none; -} -.tab-content .tab-pane.active + .copy, .tab-content .tab-pane.active + .copied{ - display: block; -} -.primary-color {color: #0594CB} -.bg-primary-color {background-color: #0594CB} -.hover-bg-primary-color:hover {background-color: #0594CB} -.primary-color-dark {color: #0A1922} -.bg-primary-color-dark {background-color: #0A1922} -.hover-bg-primary-color-dark:hover {background-color: #0A1922} -.primary-color-light {color: #f9f9f9} -.bg-primary-color-light {background-color: #f9f9f9} -.hover-bg-primary-color-light:hover {background-color: #f9f9f9} -.accent-color {color: #EBB951} -.bg-accent-color {background-color: #EBB951} -.hover-bg-accent-color:hover {background-color: #EBB951} -.accent-color-light {color: #FF4088} -.hover-accent-color-light:hover {color: #FF4088} -.bg-accent-color-light {background-color: #FF4088} -.hover-bg-accent-color-light:hover {background-color: #FF4088} -.accent-color-dark {color: #33ba91} -.bg-accent-color-dark {background-color: #33ba91} -.hover-bg-accent-color-dark:hover {background-color: #33ba91} -.text-color-primary {color: #373737} -.text-on-primary-color {color: #fff} -.text-color-secondary {color: #ccc} -.text-color-disabled {color: #F7f7f7} -.divider-color {color: #f6f6f6} -.warn-color {color: red} -.nested-links a { - color: #0594CB; - text-decoration: none; - -} -.column-count-2 {-webkit-column-count: 1;column-count: 1} -.column-gap-1 {-webkit-column-gap: 0;column-gap: 0} -.break-inside-avoid {-webkit-column-break-inside: auto;break-inside: auto} -@media screen and (min-width: 60em) { - .column-count-3-l {-webkit-column-count: 3;column-count: 3} - .column-count-2-l {-webkit-column-count: 2;column-count: 2} - .column-gap-1-l {-webkit-column-gap: 1;column-gap: 1} - .break-inside-avoid-l {-webkit-column-break-inside: avoid;break-inside: avoid} -} -.prose ul, .prose ol { - margin-bottom: 2em; -} -.prose ul li, .prose ol li { - margin-bottom: .5em; -} -.prose li:hover { - background-color: #eee -} -.prose ::selection { - background: #0594CB; /* WebKit/Blink Browsers */ - color: white; -} -body { - -line-height: 1.45; - -} -p {margin-bottom: 1.3em;} -h1, h2, h3, h4 { -margin: 1.414em 0 0.5em; - -line-height: 1.2; -} -h1 { -margin-top: 0; -font-size: 2.441em; -} -h2 {font-size: 1.953em;} -h3 {font-size: 1.563em;} -h4 {font-size: 1.25em;} -small, .font_small {font-size: 0.8em;} -.prose table { - width: 100%; - margin-bottom: 3em; - border-collapse: collapse; - border-spacing: 0; - font-size: 1em; - border: 1px solid #eee - -} -.prose table th { - background-color: #0594CB; - border-bottom: 1px solid #0594CB; - color: white; - font-weight: 400; - text-align: left; - padding: .375em .5em; -} -.prose table td, .prose table tc { - padding: .75em .5em; - text-align: left; - border-right: 1px solid #eee; -} -.prose table tr:nth-child(even) { - background-color: #eee; -} -dl dt { - font-weight: bold; - font-size: 1.125rem; -} -dd { - margin: .5em 0 2em 0; - padding: 0; -} -.f2-fluid { - font-size: 2.25rem; -} -@media screen and (min-width: 60em) { - .f2-fluid { - font-size: 1.25rem; - font-size: calc(0.70833rem + 0.83333vw); - } -} -/* From http://cssfontstack.com */ -code, .code, pre code, .highlight pre { - font-family: 'inconsolata',Menlo,Monaco,'Courier New',monospace; -} -.sans-serif { - font-family: 'Muli', - avenir, - 'helvetica neue', helvetica, - ubuntu, - roboto, noto, - 'segoe ui', arial, - sans-serif; -} -.serif { - font-family: Palatino,"Palatino Linotype","Palatino LT STD","Book Antiqua",Georgia,serif; -} -/* Monospaced Typefaces (for code) */ -.courier { - font-family: 'Courier Next', - courier, - monospace; -} -/* Sans-Serif Typefaces */ -.helvetica { - font-family: 'helvetica neue', helvetica, - sans-serif; -} -.avenir { - font-family: 'avenir next', avenir, - sans-serif; -} -/* Serif Typefaces */ -.athelas { - font-family: athelas, - georgia, - serif; -} -.georgia { - font-family: georgia, - serif; -} -.times { - font-family: times, - serif; -} -.bodoni { - font-family: "Bodoni MT", - serif; -} -.calisto { - font-family: "Calisto MT", - serif; -} -.garamond { - font-family: garamond, - serif; -} -.baskerville { - font-family: baskerville, - serif; -} -/* pagination.html: https://github.com/gohugoio/hugo/blob/master/tpl/tplimpl/template_embedded.go#L117 */ -.pagination { - margin: 3rem 0; -} -.pagination li { - display: inline-block; - margin-right: .375rem; - font-size: .875rem; - margin-bottom: 2.5em; -} -.pagination li a { - padding: .5rem .625rem; - background-color: white; - color: #333; - border: 1px solid #ddd; - border-radius: 3px; - text-decoration: none; -} -.pagination li.disabled { - display: none; -} -.pagination li.active a:link, -.pagination li.active a:active, -.pagination li.active a:visited { - background-color: #ddd; -} -/* Hides non-meaningful TOC items*/ -#TableOfContents ul li ul li ul li{ - display: none; - } -#TableOfContents ul li { - color: black; - display: block; - margin-bottom: .375em; - line-height: 1.375; -} -#TableOfContents ul li a{ - width: 100%; - padding: .25em .375em; - margin-left: -.375em; - -} -#TableOfContents ul li a:hover { - background-color: #999; - color: white; - -} -.no-js .needs-js { - opacity: 0 -} -.js .needs-js { - opacity: 1; - -webkit-transition: opacity .15s ease-in; - transition: opacity .15s ease-in; -} -.facebook, .twitter, .instagram, .youtube { - fill: #BABABA; -} -.facebook:hover { - fill: #3b5998; -} -.twitter { - fill: #55acee; -} -.twitter:hover { - fill: #BABABA; -} -.instagram:hover { - fill: #e95950; -} -.youtube:hover { - fill: #bb0000; -} -@media (min-width: 75em) { - - [data-scrolldir="down"] .sticky { - position: fixed; - top:100px; - right:0; - } - - [data-scrolldir="up"] .sticky { - position: fixed; - top:100px; - right:0; - } -} -.fill-current { fill: currentColor; } -/* Background */ -.chroma { background-color: #ffffff } -/* Error */ -.chroma .err { color: #a61717; background-color: #e3d2d2 } -/* LineTableTD */ -.chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; } -/* LineTable */ -.chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; width: auto; overflow: auto; display: block; } -/* LineHighlight */ -.chroma .hl { display: block; width: 100%;background-color: #ffffcc } -/* LineNumbersTable */ -.chroma .lnt { margin-right: 0.4em; padding: 0 0.4em 0 0.4em; } -/* LineNumbers */ -.chroma .ln { margin-right: 0.4em; padding: 0 0.4em 0 0.4em; } -/* Keyword */ -.chroma .k { font-weight: bold } -/* KeywordConstant */ -.chroma .kc { font-weight: bold } -/* KeywordDeclaration */ -.chroma .kd { font-weight: bold } -/* KeywordNamespace */ -.chroma .kn { font-weight: bold } -/* KeywordPseudo */ -.chroma .kp { font-weight: bold } -/* KeywordReserved */ -.chroma .kr { font-weight: bold } -/* KeywordType */ -.chroma .kt { color: #445588; font-weight: bold } -/* NameAttribute */ -.chroma .na { color: #008080 } -/* NameBuiltin */ -.chroma .nb { color: #999999 } -/* NameClass */ -.chroma .nc { color: #445588; font-weight: bold } -/* NameConstant */ -.chroma .no { color: #008080 } -/* NameEntity */ -.chroma .ni { color: #800080 } -/* NameException */ -.chroma .ne { color: #990000; font-weight: bold } -/* NameFunction */ -.chroma .nf { color: #990000; font-weight: bold } -/* NameNamespace */ -.chroma .nn { color: #555555 } -/* NameTag */ -.chroma .nt { color: #000080 } -/* NameVariable */ -.chroma .nv { color: #008080 } -/* LiteralString */ -.chroma .s { color: #bb8844 } -/* LiteralStringAffix */ -.chroma .sa { color: #bb8844 } -/* LiteralStringBacktick */ -.chroma .sb { color: #bb8844 } -/* LiteralStringChar */ -.chroma .sc { color: #bb8844 } -/* LiteralStringDelimiter */ -.chroma .dl { color: #bb8844 } -/* LiteralStringDoc */ -.chroma .sd { color: #bb8844 } -/* LiteralStringDouble */ -.chroma .s2 { color: #bb8844 } -/* LiteralStringEscape */ -.chroma .se { color: #bb8844 } -/* LiteralStringHeredoc */ -.chroma .sh { color: #bb8844 } -/* LiteralStringInterpol */ -.chroma .si { color: #bb8844 } -/* LiteralStringOther */ -.chroma .sx { color: #bb8844 } -/* LiteralStringRegex */ -.chroma .sr { color: #808000 } -/* LiteralStringSingle */ -.chroma .s1 { color: #bb8844 } -/* LiteralStringSymbol */ -.chroma .ss { color: #bb8844 } -/* LiteralNumber */ -.chroma .m { color: #009999 } -/* LiteralNumberBin */ -.chroma .mb { color: #009999 } -/* LiteralNumberFloat */ -.chroma .mf { color: #009999 } -/* LiteralNumberHex */ -.chroma .mh { color: #009999 } -/* LiteralNumberInteger */ -.chroma .mi { color: #009999 } -/* LiteralNumberIntegerLong */ -.chroma .il { color: #009999 } -/* LiteralNumberOct */ -.chroma .mo { color: #009999 } -/* Operator */ -.chroma .o { font-weight: bold } -/* OperatorWord */ -.chroma .ow { font-weight: bold } -/* Comment */ -.chroma .c { color: #999988; font-style: italic } -/* CommentHashbang */ -.chroma .ch { color: #999988; font-style: italic } -/* CommentMultiline */ -.chroma .cm { color: #999988; font-style: italic } -/* CommentSingle */ -.chroma .c1 { color: #999988; font-style: italic } -/* CommentSpecial */ -.chroma .cs { color: #999999; font-weight: bold; font-style: italic } -/* CommentPreproc */ -.chroma .cp { color: #999999; font-weight: bold } -/* CommentPreprocFile */ -.chroma .cpf { color: #999999; font-weight: bold } -/* GenericDeleted */ -.chroma .gd { color: #000000; background-color: #ffdddd } -/* GenericEmph */ -.chroma .ge { font-style: italic } -/* GenericError */ -.chroma .gr { color: #aa0000 } -/* GenericHeading */ -.chroma .gh { color: #999999 } -/* GenericInserted */ -.chroma .gi { color: #000000; background-color: #ddffdd } -/* GenericOutput */ -.chroma .go { color: #888888 } -/* GenericPrompt */ -.chroma .gp { color: #555555 } -/* GenericStrong */ -.chroma .gs { font-weight: bold } -/* GenericSubheading */ -.chroma .gu { color: #aaaaaa } -/* GenericTraceback */ -.chroma .gt { color: #aa0000 } -/* TextWhitespace */ -.chroma .w { color: #bbbbbb } -.nested-blockquote blockquote { - border-left: 4px solid #0594CB; - padding-left: 1em; - /*margin: 0;*/ -} -.mw-90 { - max-width:90%; -} -/* purgecss end ignore */ diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/output/js/app.js b/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/output/js/app.js deleted file mode 100644 index 3097ec5a6..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/assets/output/js/app.js +++ /dev/null @@ -1,18 +0,0 @@ -!function(e){var t={};function n(r){if(t[r])return t[r].exports;var i=t[r]={i:r,l:!1,exports:{}};return e[r].call(i.exports,i,i.exports,n),i.l=!0,i.exports}n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var i in e)n.d(r,i,function(t){return e[t]}.bind(null,i));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=1)}([function(e,t,n){!function(t,n){var r=function(e,t){"use strict";if(!t.getElementsByClassName)return;var n,r,i=t.documentElement,s=e.Date,o=e.HTMLPictureElement,a=e.addEventListener,c=e.setTimeout,u=e.requestAnimationFrame||c,l=e.requestIdleCallback,h=/^picture$/i,d=["load","error","lazyincluded","_lazyloaded"],f={},p=Array.prototype.forEach,g=function(e,t){return f[t]||(f[t]=new RegExp("(\\s|^)"+t+"(\\s|$)")),f[t].test(e.getAttribute("class")||"")&&f[t]},m=function(e,t){g(e,t)||e.setAttribute("class",(e.getAttribute("class")||"").trim()+" "+t)},v=function(e,t){var n;(n=g(e,t))&&e.setAttribute("class",(e.getAttribute("class")||"").replace(n," "))},y=function(e,t,n){var r=n?"addEventListener":"removeEventListener";n&&y(e,t),d.forEach(function(n){e[r](n,t)})},b=function(e,r,i,s,o){var a=t.createEvent("Event");return i||(i={}),i.instance=n,a.initEvent(r,!s,!o),a.detail=i,e.dispatchEvent(a),a},w=function(t,n){var i;!o&&(i=e.picturefill||r.pf)?(n&&n.src&&!t.getAttribute("srcset")&&t.setAttribute("srcset",n.src),i({reevaluate:!0,elements:[t]})):n&&n.src&&(t.src=n.src)},_=function(e,t){return(getComputedStyle(e,null)||{})[t]},E=function(e,t,n){for(n=n||e.offsetWidth;n0)&&"visible"!=_(s,"overflow")&&(r=s.getBoundingClientRect(),o=R>r.left&&kr.top-1&&T500&&i.clientWidth>500?500:370),L=r.expand,I=L*r.expFactor),H2&&f>2&&!t.hidden?(H=I,q=0):H=f>1&&q>1&&B<6?L:0;for(;s=d&&(T=a.top)<=O&&(R=a.right)>=d*D&&(k=a.left)<=A&&(M||R||k||T)&&(r.loadHidden||"hidden"!=_(m[s],"visibility"))&&(u&&B<3&&!p&&(f<3||q<4)||F(m[s],h))){if(X(m[s]),l=!0,B>9)break}else!l&&u&&!c&&B<4&&q<4&&f>2&&(o[0]||r.preloadAfterLoad)&&(o[0]||!p&&(M||R||k||T||"auto"!=m[s].getAttribute(r.sizesAttr)))&&(c=o[0]||m[s]);else X(m[s]);c&&!l&&X(c)}},K=function(e){var t,n=0,i=r.throttleDelay,o=r.ricTimeout,a=function(){t=!1,n=s.now(),e()},u=l&&o>49?function(){l(a,{timeout:o}),o!==r.ricTimeout&&(o=r.ricTimeout)}:S(function(){c(a)},!0);return function(e){var r;(e=!0===e)&&(o=33),t||(t=!0,(r=i-(s.now()-n))<0&&(r=0),e||r<9?u():c(u,r))}}(U),V=function(e){m(e.target,r.loadedClass),v(e.target,r.loadingClass),y(e.target,W),b(e.target,"lazyloaded")},J=S(V),W=function(e){J({target:e.target})},G=function(e){var t,n=e.getAttribute(r.srcsetAttr);(t=r.customMedia[e.getAttribute("data-media")||e.getAttribute("media")])&&e.setAttribute("media",t),n&&e.setAttribute("srcset",n)},Q=S(function(e,t,n,i,s){var o,a,u,l,f,g;(f=b(e,"lazybeforeunveil",t)).defaultPrevented||(i&&(n?m(e,r.autosizesClass):e.setAttribute("sizes",i)),a=e.getAttribute(r.srcsetAttr),o=e.getAttribute(r.srcAttr),s&&(u=e.parentNode,l=u&&h.test(u.nodeName||"")),g=t.firesLoad||"src"in e&&(a||o||l),f={target:e},g&&(y(e,z,!0),clearTimeout(d),d=c(z,2500),m(e,r.loadingClass),y(e,W,!0)),l&&p.call(u.getElementsByTagName("source"),G),a?e.setAttribute("srcset",a):o&&!l&&(j.test(e.nodeName)?function(e,t){try{e.contentWindow.location.replace(t)}catch(n){e.src=t}}(e,o):e.src=o),s&&(a||l)&&w(e,{src:o})),e._lazyRace&&delete e._lazyRace,v(e,r.lazyClass),x(function(){(!g||e.complete&&e.naturalWidth>1)&&(g?z(f):B--,V(f))},!0)}),X=function(e){var t,n=P.test(e.nodeName),i=n&&(e.getAttribute(r.sizesAttr)||e.getAttribute("sizes")),s="auto"==i;(!s&&u||!n||!e.getAttribute("src")&&!e.srcset||e.complete||g(e,r.errorClass)||!g(e,r.lazyClass))&&(t=b(e,"lazyunveilread").detail,s&&N.updateElem(e,!0,e.offsetWidth),e._lazyRace=!0,B++,Q(e,t,s,i,n))},Z=function(){if(!u)if(s.now()-E<999)c(Z,999);else{var e=C(function(){r.loadMode=3,K()});u=!0,r.loadMode=3,K(),a("scroll",function(){3==r.loadMode&&(r.loadMode=2),e()},!0)}};return{_:function(){E=s.now(),n.elements=t.getElementsByClassName(r.lazyClass),o=t.getElementsByClassName(r.lazyClass+" "+r.preloadClass),D=r.hFac,a("scroll",K,!0),a("resize",K,!0),e.MutationObserver?new MutationObserver(K).observe(i,{childList:!0,subtree:!0,attributes:!0}):(i.addEventListener("DOMNodeInserted",K,!0),i.addEventListener("DOMAttrModified",K,!0),setInterval(K,999)),a("hashchange",K,!0),["focus","mouseover","click","load","transitionend","animationend","webkitAnimationEnd"].forEach(function(e){t.addEventListener(e,K,!0)}),/d$|^c/.test(t.readyState)?Z():(a("load",Z),t.addEventListener("DOMContentLoaded",K),c(Z,2e4)),n.elements.length?(U(),x._lsFlush()):K()},checkElems:K,unveil:X}}(),N=function(){var e,n=S(function(e,t,n,r){var i,s,o;if(e._lazysizesWidth=r,r+="px",e.setAttribute("sizes",r),h.test(t.nodeName||""))for(i=t.getElementsByTagName("source"),s=0,o=i.length;s0&&void 0!==arguments[0]?arguments[0]:{};this.action="function"==typeof e.action?e.action:this.defaultAction,this.target="function"==typeof e.target?e.target:this.defaultTarget,this.text="function"==typeof e.text?e.text:this.defaultText,this.container="object"===r(e.container)?e.container:document.body}},{key:"listenClick",value:function(e){var t=this;this.listener=(0,a.default)(e,"click",function(e){return t.onClick(e)})}},{key:"onClick",value:function(e){var t=e.delegateTarget||e.currentTarget;this.clipboardAction&&(this.clipboardAction=null),this.clipboardAction=new s.default({action:this.action(t),target:this.target(t),text:this.text(t),container:this.container,trigger:t,emitter:this})}},{key:"defaultAction",value:function(e){return l("action",e)}},{key:"defaultTarget",value:function(e){var t=l("target",e);if(t)return document.querySelector(t)}},{key:"defaultText",value:function(e){return l("text",e)}},{key:"destroy",value:function(){this.listener.destroy(),this.clipboardAction&&(this.clipboardAction.destroy(),this.clipboardAction=null)}}],[{key:"isSupported",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:["copy","cut"],t="string"==typeof e?[e]:e,n=!!document.queryCommandSupported;return t.forEach(function(e){n=n&&!!document.queryCommandSupported(e)}),n}}]),t}();function l(e,t){var n="data-clipboard-"+e;if(t.hasAttribute(n))return t.getAttribute(n)}e.exports=u},function(e,t,n){"use strict";var r="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},i=function(){function e(e,t){for(var n=0;n0&&void 0!==arguments[0]?arguments[0]:{};this.action=e.action,this.container=e.container,this.emitter=e.emitter,this.target=e.target,this.text=e.text,this.trigger=e.trigger,this.selectedText=""}},{key:"initSelection",value:function(){this.text?this.selectFake():this.target&&this.selectTarget()}},{key:"selectFake",value:function(){var e=this,t="rtl"==document.documentElement.getAttribute("dir");this.removeFake(),this.fakeHandlerCallback=function(){return e.removeFake()},this.fakeHandler=this.container.addEventListener("click",this.fakeHandlerCallback)||!0,this.fakeElem=document.createElement("textarea"),this.fakeElem.style.fontSize="12pt",this.fakeElem.style.border="0",this.fakeElem.style.padding="0",this.fakeElem.style.margin="0",this.fakeElem.style.position="absolute",this.fakeElem.style[t?"right":"left"]="-9999px";var n=window.pageYOffset||document.documentElement.scrollTop;this.fakeElem.style.top=n+"px",this.fakeElem.setAttribute("readonly",""),this.fakeElem.value=this.text,this.container.appendChild(this.fakeElem),this.selectedText=(0,s.default)(this.fakeElem),this.copyText()}},{key:"removeFake",value:function(){this.fakeHandler&&(this.container.removeEventListener("click",this.fakeHandlerCallback),this.fakeHandler=null,this.fakeHandlerCallback=null),this.fakeElem&&(this.container.removeChild(this.fakeElem),this.fakeElem=null)}},{key:"selectTarget",value:function(){this.selectedText=(0,s.default)(this.target),this.copyText()}},{key:"copyText",value:function(){var e=void 0;try{e=document.execCommand(this.action)}catch(t){e=!1}this.handleResult(e)}},{key:"handleResult",value:function(e){this.emitter.emit(e?"success":"error",{action:this.action,text:this.selectedText,trigger:this.trigger,clearSelection:this.clearSelection.bind(this)})}},{key:"clearSelection",value:function(){this.trigger&&this.trigger.focus(),window.getSelection().removeAllRanges()}},{key:"destroy",value:function(){this.removeFake()}},{key:"action",set:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"copy";if(this._action=e,"copy"!==this._action&&"cut"!==this._action)throw new Error('Invalid "action" value, use either "copy" or "cut"')},get:function(){return this._action}},{key:"target",set:function(e){if(void 0!==e){if(!e||"object"!==(void 0===e?"undefined":r(e))||1!==e.nodeType)throw new Error('Invalid "target" value, use a valid Element');if("copy"===this.action&&e.hasAttribute("disabled"))throw new Error('Invalid "target" attribute. Please use "readonly" instead of "disabled" attribute');if("cut"===this.action&&(e.hasAttribute("readonly")||e.hasAttribute("disabled")))throw new Error('Invalid "target" attribute. You can\'t cut text from elements with "readonly" or "disabled" attributes');this._target=e}},get:function(){return this._target}}]),e}();e.exports=o},function(e,t){e.exports=function(e){var t;if("SELECT"===e.nodeName)e.focus(),t=e.value;else if("INPUT"===e.nodeName||"TEXTAREA"===e.nodeName){var n=e.hasAttribute("readonly");n||e.setAttribute("readonly",""),e.select(),e.setSelectionRange(0,e.value.length),n||e.removeAttribute("readonly"),t=e.value}else{e.hasAttribute("contenteditable")&&e.focus();var r=window.getSelection(),i=document.createRange();i.selectNodeContents(e),r.removeAllRanges(),r.addRange(i),t=r.toString()}return t}},function(e,t){function n(){}n.prototype={on:function(e,t,n){var r=this.e||(this.e={});return(r[e]||(r[e]=[])).push({fn:t,ctx:n}),this},once:function(e,t,n){var r=this;function i(){r.off(e,i),t.apply(n,arguments)}return i._=t,this.on(e,i,n)},emit:function(e){for(var t=[].slice.call(arguments,1),n=((this.e||(this.e={}))[e]||[]).slice(),r=0,i=n.length;r0&&n.parentNode.classList.add("expand")}}},function(e,t,n){n(9)({apiKey:"167e7998590aebda7f9fedcf86bc4a55",indexName:"hugodocs",inputSelector:"#search-input",debug:!0})},function(e,t,n){ -/*! docsearch 2.6.1 | © Algolia | github.com/algolia/docsearch */ -!function(t,n){e.exports=n()}("undefined"!=typeof self&&self,function(){return function(e){var t={};function n(r){if(t[r])return t[r].exports;var i=t[r]={i:r,l:!1,exports:{}};return e[r].call(i.exports,i,i.exports,n),i.l=!0,i.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{configurable:!1,enumerable:!0,get:r})},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=22)}([function(e,t,n){"use strict";var r=n(1);function i(e){return e.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,"\\$&")}e.exports={isArray:null,isFunction:null,isObject:null,bind:null,each:null,map:null,mixin:null,isMsie:function(e){if(void 0===e&&(e=navigator.userAgent),/(msie|trident)/i.test(e)){var t=e.match(/(msie |rv:)(\d+(.\d+)?)/i);if(t)return t[2]}return!1},escapeRegExChars:function(e){return e.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,"\\$&")},isNumber:function(e){return"number"==typeof e},toStr:function(e){return void 0===e||null===e?"":e+""},cloneDeep:function(e){var t=this.mixin({},e),n=this;return this.each(t,function(e,r){e&&(n.isArray(e)?t[r]=[].concat(e):n.isObject(e)&&(t[r]=n.cloneDeep(e)))}),t},error:function(e){throw new Error(e)},every:function(e,t){var n=!0;return e?(this.each(e,function(r,i){if(!(n=t.call(null,r,i,e)))return!1}),!!n):n},any:function(e,t){var n=!1;return e?(this.each(e,function(r,i){if(t.call(null,r,i,e))return n=!0,!1}),n):n},getUniqueId:function(){var e=0;return function(){return e++}}(),templatify:function(e){if(this.isFunction(e))return e;var t=r.element(e);return"SCRIPT"===t.prop("tagName")?function(){return t.text()}:function(){return String(e)}},defer:function(e){setTimeout(e,0)},noop:function(){},formatPrefix:function(e,t){return t?"":e+"-"},className:function(e,t,n){return(n?"":".")+e+t},escapeHighlightedString:function(e,t,n){t=t||"";var r=document.createElement("div");r.appendChild(document.createTextNode(t)),n=n||"";var s=document.createElement("div");s.appendChild(document.createTextNode(n));var o=document.createElement("div");return o.appendChild(document.createTextNode(e)),o.innerHTML.replace(RegExp(i(r.innerHTML),"g"),t).replace(RegExp(i(s.innerHTML),"g"),n)}}},function(e,t,n){"use strict";e.exports={element:null}},function(e,t){var n=Object.prototype.hasOwnProperty,r=Object.prototype.toString;e.exports=function(e,t,i){if("[object Function]"!==r.call(t))throw new TypeError("iterator must be a function");var s=e.length;if(s===+s)for(var o=0;o was loaded but did not call our provided callback"),JSONPScriptError:s("JSONPScriptError"," - -{{ end }} \ No newline at end of file diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/head-additions.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/head-additions.html deleted file mode 100644 index af615ee7c..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/head-additions.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/hero.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/hero.html deleted file mode 100644 index 9e7240433..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/hero.html +++ /dev/null @@ -1,18 +0,0 @@ - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/home-page-sections/features-icons.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/home-page-sections/features-icons.html deleted file mode 100644 index a7733acdc..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/home-page-sections/features-icons.html +++ /dev/null @@ -1,34 +0,0 @@ -
    - {{ if .Params.features }} -
    - {{ $features := .Params.features }} - {{ range $i, $e := $features }} - {{ $features_count := $e | len }} - -
    - -
    -
    - {{ with .image_path }} - icon depicting {{ $e.heading }} - {{ end }} -
    - -
    -

    - {{ .heading }} -

    -
    -

    {{.tagline}}

    -
    - {{ .copy }} -
    -
    -
    -
    - -
    - {{ end }} -
    - {{ end }} -
    diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/home-page-sections/features-single.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/home-page-sections/features-single.html deleted file mode 100644 index f36b3d674..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/home-page-sections/features-single.html +++ /dev/null @@ -1,32 +0,0 @@ -{{ if .Params.sections }} - {{ range .Params.sections }} - {{ $.Scratch.Add "i" 1 }}{{ $i := $.Scratch.Get "i" }} - -
    -
    - -
    -
    - image depicting an example of {{ .heading }} -
    -
    - - - -
    -
    - - {{ end }} -{{ end }} diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/home-page-sections/installation.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/home-page-sections/installation.html deleted file mode 100644 index 4bea1a54a..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/home-page-sections/installation.html +++ /dev/null @@ -1,39 +0,0 @@ -
    - - -
    -
    -

    Install in seconds, build in milliseconds.

    -

    Hugo works on macOS, Windows, Linux, FreeBSD, and others.

    -

    Host on any server or your favorite CDN.

    -
    -
    - - -
    -
    - Hugo Gopher -
    -

    Mac OS

    -
    - $ brew install hugo
    -
    -

    Windows

    -
    - $ choco install hugo -confirm
    -
    -

    Linux

    -
    - $ snap install hugo
    -
    - - - -
    - - - - - - -
    diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/home-page-sections/open-source-involvement.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/home-page-sections/open-source-involvement.html deleted file mode 100644 index 5300fb7a8..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/home-page-sections/open-source-involvement.html +++ /dev/null @@ -1,59 +0,0 @@ -
    -
    - Github Logo -
    -
    - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/home-page-sections/showcase.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/home-page-sections/showcase.html deleted file mode 100644 index c73cfa5e9..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/home-page-sections/showcase.html +++ /dev/null @@ -1,44 +0,0 @@ -
    -

    Showcase

    - {{/* NOTE: transitions for this section are in themes/gohugoioTheme/src/css/_carousel.css */}} -
    -
    -
    - {{ $showcasePages := where .Site.RegularPages "Section" "showcase" }} - {{ if $showcasePages }} - {{ template "home_showcase_item" (index $showcasePages 0) }} - {{ range $p := first 10 ($showcasePages | after 1 | shuffle) }} - {{template "home_showcase_item" $p }} - {{end}} - {{end}} -
    -
    -
    - {{/* END */}} -
    {{/* using Flex to make the button show up on the right side */}} - See All -
    -
    - - -{{ define "home_showcase_item" }} - {{ $img := (.Resources.ByType "image").GetMatch "*featured*" }} - {{ with $img }} - {{ $big := .Fill "1024x512 top" }} - {{ $small := $big.Resize "512x" }} - - {{with $.Title}} -
    -
    - {{.}} → -
    -
    - {{end}} -
    - {{ end }} -{{ end }} \ No newline at end of file diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/home-page-sections/sponsors.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/home-page-sections/sponsors.html deleted file mode 100644 index fa179f7f4..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/home-page-sections/sponsors.html +++ /dev/null @@ -1,38 +0,0 @@ -{{$classes_box := "ba b--light-gray bg-light-gray br3 flex flex-column flex-wrap items-center justify-center ph3 pv4 mb4 w-100 w-30-l "}} -{{$gtag := .gtag | default "unknown" }} -{{ with .cx.Site.Data.sponsors }} -
    -
      -

    Hugo Sponsors

    -
    - {{ range .banners }} - {{ $banner := . }} - {{if .logo}} -
    - {{with .link -}} - {{ $url := printf "%s?%s" . (querify "utm_source" "homepage" "utm_medium" "banner" "utm_campaign" "hugosponsor") | safeURL }} - {{ if eq (getenv "HUGO_ENV") "production" | or (eq $.cx.Site.Params.env "production") }} - {{ $gtagID := printf "Sponsor %s %s" $banner.name $gtag | title }} - - {{ else }} - - {{ end }} - {{- end}} - Logo for {{ .name }} - {{with .link}}{{end}} - {{with .copy}} -

    - {{- . -}} -

    - {{end}} -
    - {{else}} -
    -

    Your Logo Here

    -
    - {{end}} - {{end}} -
    -
    -
    -{{end}} diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/home-page-sections/tweets.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/home-page-sections/tweets.html deleted file mode 100644 index 5aebf6737..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/home-page-sections/tweets.html +++ /dev/null @@ -1,25 +0,0 @@ -
    - - {{ $interior_classes := $.Site.Params.flex_box_interior_classes }} - -

    See what others are saying about Hugo…

    - -
    - - {{ range first 4 (sort $.Site.Data.homepagetweets.tweet "date" "desc" ) }} - - {{ end }} -
    -
    diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/icon-link.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/icon-link.html deleted file mode 100644 index dec9ae48b..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/icon-link.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/nav-links-docs-mobile.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/nav-links-docs-mobile.html deleted file mode 100644 index ad9d535b4..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/nav-links-docs-mobile.html +++ /dev/null @@ -1,11 +0,0 @@ -{{ $currentPage := . }} -{{ $menu := .Site.Menus.docs.ByWeight }} -
      - {{ range $menu }}{{ $post := printf "%s" .Post }} -
    • - - {{ .Name }} - -
    • - {{end}} -
    diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/nav-links-docs.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/nav-links-docs.html deleted file mode 100644 index 61aa11dde..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/nav-links-docs.html +++ /dev/null @@ -1,23 +0,0 @@ -{{ $currentPage := . }} - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/nav-links-global-mobile.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/nav-links-global-mobile.html deleted file mode 100644 index 4a1631d96..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/nav-links-global-mobile.html +++ /dev/null @@ -1,11 +0,0 @@ -{{ $currentPage := . }} -{{ $menu := .Site.Menus.global }} - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/nav-links.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/nav-links.html deleted file mode 100644 index af3790b16..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/nav-links.html +++ /dev/null @@ -1,37 +0,0 @@ -{{ $currentPage := . }} -{{ $.Scratch.Add "listlinkClasses" "f6 link primary-color-dark hover-white db brand-font ma0 w-100 pv3 ph4" }} - - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/nav-mobile.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/nav-mobile.html deleted file mode 100644 index 00b1a691c..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/nav-mobile.html +++ /dev/null @@ -1,12 +0,0 @@ - -
    - {{ partial "nav-links-docs-mobile.html" . }} -
    - -
    - - - -
    diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/nav-top.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/nav-top.html deleted file mode 100644 index d8e87eb63..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/nav-top.html +++ /dev/null @@ -1,16 +0,0 @@ -{{ $currentPage := . }} -
    - - - {{ partial "nav-links" .}} -
    - {{ partial "nav-button-open" .}} -
    -
    diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/page-edit.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/page-edit.html deleted file mode 100644 index edf84669e..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/page-edit.html +++ /dev/null @@ -1,3 +0,0 @@ -Improve this page diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/page-header.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/page-header.html deleted file mode 100644 index dcc96242f..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/page-header.html +++ /dev/null @@ -1,20 +0,0 @@ -{{ $currentPage := . }} -{{ $currentURL := .RelPermalink }} -
    -
      - -
    • - - News: - -
    • - {{ range $name, $taxonomy := .Site.Taxonomies.categories }} - {{ $link := $name | printf "%s%s" "/categories/" | printf "%s/" }} -
    • - - {{ $name | humanize }} - -
    • - {{ end }} -
    -
    diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/pagelayout.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/pagelayout.html deleted file mode 100644 index dd048223e..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/pagelayout.html +++ /dev/null @@ -1,34 +0,0 @@ -{{ $section_to_display := .section_to_display }} -
    - -
    -
    - {{ partial "nav-links-docs.html" .context }} -
    - -
    - - - - -
    - {{ $interior_classes := .context.Site.Params.flex_box_interior_classes }} -
    - {{ range $section_to_display }} - {{ partial "boxes-section-summaries" (dict "context" . "classes" $interior_classes "fullcontent" true) }} - {{ end }} -
    -
    - -
    - -
    - -
    diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/previous-next-links-in-section-with-title.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/previous-next-links-in-section-with-title.html deleted file mode 100644 index 71a14c0ef..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/previous-next-links-in-section-with-title.html +++ /dev/null @@ -1,14 +0,0 @@ -{{ if or .PrevInSection .NextInSection }} -{{/* this div holds these a tags as a unit for flex-box display */}} - -{{ end }} diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/previous-next-links-in-section.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/previous-next-links-in-section.html deleted file mode 100644 index af9f4aac1..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/previous-next-links-in-section.html +++ /dev/null @@ -1,16 +0,0 @@ -{{ if or .PrevInSection .NextInSection }} -{{/* this div holds these a tags as a unit for flex-box display */}} - -{{ end }} diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/previous-next-links.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/previous-next-links.html deleted file mode 100644 index cd43dd840..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/previous-next-links.html +++ /dev/null @@ -1,25 +0,0 @@ -{{if .Prev }} - - {{ partial "svg/ic_chevron_left_black_24px.svg" (dict "size" "30px") }} {{ .Prev.Title }} - -{{end}} - -{{if .Next }} - - {{ .Next.Title }} {{ partial "svg/ic_chevron_right_black_24px.svg" (dict "size" "30px") }} - -{{end}} - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/related.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/related.html deleted file mode 100644 index fb11699af..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/related.html +++ /dev/null @@ -1,9 +0,0 @@ -{{ $related := .Site.RegularPages.Related . | first 5 }} -{{ with $related }} -

    See Also

    - -{{ end }} \ No newline at end of file diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/site-footer.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/site-footer.html deleted file mode 100644 index 39ddb36d1..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/site-footer.html +++ /dev/null @@ -1,48 +0,0 @@ -
    -
    -
    - - - -
    - Hugo Logo -
    - - - - - - {{ with getenv "REPOSITORY_URL" -}} -

    Netlify badge

    - {{- end }} - -
    - -
      - {{ partial "home-page-sections/sponsors.html" (dict "cx" . "gtag" "footer" "classes_section" "pb3 w-100" "classes_copy" "f7 w-90-ns") }} -
    - -
    - -
      -

    The Hugo logos are copyright © Steve Francia 2013–{{ now.Year }}.

    -

    The Hugo Gopher is based on an original work by Renée French.

    -
    - - - -
    - {{- partial "nav-mobile.html" . -}} -
    - -
    diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/site-manifest.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/site-manifest.html deleted file mode 100644 index 54472ba16..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/site-manifest.html +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/site-nav.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/site-nav.html deleted file mode 100644 index 0266c9939..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/site-nav.html +++ /dev/null @@ -1,38 +0,0 @@ -{{ $currentPage := . }} - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/site-scripts.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/site-scripts.html deleted file mode 100644 index 7dec9de18..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/site-scripts.html +++ /dev/null @@ -1,17 +0,0 @@ - -{{ $scripts := resources.Get "output/js/app.js" }} -{{ $isDev := eq hugo.Environment "development" }} -{{ if not $isDev }} -{{ $scripts = $scripts | fingerprint }} -{{ end }} -{{ with $scripts }} - {{ if $isDev }} - - {{ else }} - - {{ end }} - {{ $.Scratch.Set "scripts" . }} -{{end}} - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/site-search.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/site-search.html deleted file mode 100644 index d8c4b97bf..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/site-search.html +++ /dev/null @@ -1,6 +0,0 @@ - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/social-follow.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/social-follow.html deleted file mode 100644 index 7b517dbb4..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/social-follow.html +++ /dev/null @@ -1,7 +0,0 @@ - -{{ with .Site.Social.twitter }} - -{{ end }} -Star diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/summary.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/summary.html deleted file mode 100644 index 0f140cf70..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/summary.html +++ /dev/null @@ -1,13 +0,0 @@ -
    -
    - {{ humanize .Section }} -

    - - {{ .Title }} - -

    - -
    -
    diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/Twitter_Logo_Blue.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/Twitter_Logo_Blue.svg deleted file mode 100644 index da9438414..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/Twitter_Logo_Blue.svg +++ /dev/null @@ -1 +0,0 @@ -Twitter_Logo_Blue diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/apple.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/apple.svg deleted file mode 100644 index 6f3c20f76..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/apple.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/clipboard.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/clipboard.svg deleted file mode 100644 index e1b170359..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/clipboard.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/clippy.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/clippy.svg deleted file mode 100644 index e1b170359..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/clippy.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/cloud.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/cloud.svg deleted file mode 100644 index 2ea15de87..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/cloud.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/content.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/content.svg deleted file mode 100644 index bc696b90b..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/content.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/design.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/design.svg deleted file mode 100644 index 9f9d71769..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/design.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/facebook.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/facebook.svg deleted file mode 100644 index 6e6af44a2..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/facebook.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/focus.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/focus.svg deleted file mode 100644 index ed2c929b4..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/focus.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/freebsd.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/freebsd.svg deleted file mode 100644 index 842be09a1..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/freebsd.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/functions.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/functions.svg deleted file mode 100644 index 717a35686..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/functions.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/github-corner.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/github-corner.svg deleted file mode 100644 index 29bc57ad3..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/github-corner.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/github-squared.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/github-squared.svg deleted file mode 100644 index dabc741e0..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/github-squared.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/gitter.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/gitter.svg deleted file mode 100644 index 9c2de7da2..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/gitter.svg +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/gme.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/gme.svg deleted file mode 100644 index 9ab114aa3..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/gme.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/godoc-icon.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/godoc-icon.html deleted file mode 100644 index 1a6b82159..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/godoc-icon.html +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/gopher-2.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/gopher-2.svg deleted file mode 100644 index 961221f18..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/gopher-2.svg +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/gopher-front.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/gopher-front.svg deleted file mode 100644 index 0f8fbe0d9..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/gopher-front.svg +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/gopher-homepage.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/gopher-homepage.svg deleted file mode 100644 index 36d9f1c41..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/gopher-homepage.svg +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/gopher-side_path.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/gopher-side_path.svg deleted file mode 100644 index 05cfb84d1..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/gopher-side_path.svg +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/gopher-small.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/gopher-small.svg deleted file mode 100644 index bc1e5010c..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/gopher-small.svg +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/gopher.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/gopher.svg deleted file mode 100644 index 7f6ec255c..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/gopher.svg +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/hugo-h-only.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/hugo-h-only.svg deleted file mode 100644 index ea72a6f51..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/hugo-h-only.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/hugo.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/hugo.svg deleted file mode 100644 index 58d025596..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/hugo.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/ic_arrow_drop_down.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/ic_arrow_drop_down.svg deleted file mode 100644 index 3ba28c3f5..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/ic_arrow_drop_down.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/ic_arrow_drop_up.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/ic_arrow_drop_up.svg deleted file mode 100644 index 8ec2eb766..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/ic_arrow_drop_up.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/ic_chevron_left_black_24px.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/ic_chevron_left_black_24px.svg deleted file mode 100644 index da37757cf..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/ic_chevron_left_black_24px.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/ic_chevron_right_black_24px.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/ic_chevron_right_black_24px.svg deleted file mode 100644 index 47689a91e..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/ic_chevron_right_black_24px.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/idea.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/idea.svg deleted file mode 100644 index 5c2ccc2f4..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/idea.svg +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/instagram.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/instagram.svg deleted file mode 100644 index ae915113b..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/instagram.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/javascript.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/javascript.svg deleted file mode 100644 index b0e2f5b0d..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/javascript.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/json.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/json.svg deleted file mode 100644 index d2ba6d0fc..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/json.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/link-ext.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/link-ext.svg deleted file mode 100644 index ba9400b7f..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/link-ext.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/link-permalink.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/link-permalink.svg deleted file mode 100644 index f5de52d02..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/link-permalink.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/md.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/md.svg deleted file mode 100644 index f1a794565..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/md.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/mdsolid.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/mdsolid.svg deleted file mode 100644 index d0d9ae938..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/mdsolid.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - Svg Vector Icons : http://www.onlinewebfonts.com/icon - - \ No newline at end of file diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/newlogo.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/newlogo.svg deleted file mode 100644 index 83b706383..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/newlogo.svg +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/sass.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/sass.svg deleted file mode 100644 index da3d9cfcf..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/sass.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/search.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/search.svg deleted file mode 100644 index 181789b54..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/search.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/twitter.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/twitter.svg deleted file mode 100644 index 247ca9062..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/twitter.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/website.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/website.svg deleted file mode 100644 index 2bdcf5f94..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/website.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/windows.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/windows.svg deleted file mode 100644 index fe3bf0296..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/windows.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/yaml.svg b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/yaml.svg deleted file mode 100644 index 59eeb71c2..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/svg/yaml.svg +++ /dev/null @@ -1 +0,0 @@ -icon \ No newline at end of file diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/tags.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/tags.html deleted file mode 100644 index 59e3e51a0..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/tags.html +++ /dev/null @@ -1,37 +0,0 @@ -{{ $currentPageUrl := .RelPermalink }} -{{ if and .Params.tags .Site.Taxonomies.tags }} - {{ $name := index .Params.tags 0 }} - {{ $name := $name | urlize }} - {{ $tags := index .Site.Taxonomies.tags $name }} - -
    - -
    -{{end}} diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/toc.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/toc.html deleted file mode 100644 index 583feec4f..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/partials/toc.html +++ /dev/null @@ -1,13 +0,0 @@ - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/robots.txt b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/robots.txt deleted file mode 100644 index 25b9e9a0d..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/robots.txt +++ /dev/null @@ -1,8 +0,0 @@ -User-agent: * -# robotstxt.org - if ENV production variable is false robots will be disallowed. -{{ if eq (getenv "HUGO_ENV") "production" }} - Disallow: admin/ - Disallow: -{{ else }} - Disallow: / -{{ end }} diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/articlelist.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/articlelist.html deleted file mode 100644 index 2755b1e2d..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/articlelist.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - {{ range $ind, $art := $.Site.Data.articles.article }} - - - - - - {{ end }} - -
    Title - Author - Date -
    {{$art.title | markdownify }}{{ $art.author | markdownify }}{{ $art.date }}
    diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/code-toggle.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/code-toggle.html deleted file mode 100644 index c695a7aae..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/code-toggle.html +++ /dev/null @@ -1,24 +0,0 @@ -{{ $langs := (slice "yaml" "toml" "json") }} -
    -
    - {{- with .Get "file" -}} -
    {{ . }}.
    - {{- end -}} - {{ range $langs }} -   - {{ end }} -
    -
    - {{ range $langs }} -
    - {{ highlight ($.Inner | transform.Remarshal . | safeHTML) . ""}} -
    - {{ if ne ($.Get "copy") "false" }} - - {{/* Functionality located within filesaver.js The copy here is located in the css with .copy class so it can be replaced with JS on success */}} - {{end}} - {{ end }} -
    - -
    diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/code.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/code.html deleted file mode 100644 index 6df49956a..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/code.html +++ /dev/null @@ -1,15 +0,0 @@ -
    - {{- with .Get "file" -}} -
    {{.}}
    - {{- end -}} - - {{ if ne (.Get "copy") "false" }} - - {{/* Functionality located within filesaver.js The copy here is located in the css with .copy class so it can be replaced with JS on success */}} - {{end}} -
    - {{- .Inner -}} -
    - -
    diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/datatable.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/datatable.html deleted file mode 100644 index 7ddda86d0..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/datatable.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - {{ range $ind, $art := $.Site.Data.articles.article }} - - - - - - {{ end }} - -
    TitleAuthorDate
    {{$art.title | markdownify }}{{ $art.author | markdownify }}{{ $art.date }}
    \ No newline at end of file diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/directoryindex.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/directoryindex.html deleted file mode 100644 index 37e7d3ad1..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/directoryindex.html +++ /dev/null @@ -1,13 +0,0 @@ -{{- $pathURL := .Get "pathURL" -}} -{{- $path := .Get "path" -}} -{{- $files := readDir $path -}} - - - -{{- range $files }} - - - - -{{- end }} -
    Size in bytesName
    {{ .Size }} {{ .Name }}
    diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/docfile.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/docfile.html deleted file mode 100644 index 2f982aae8..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/docfile.html +++ /dev/null @@ -1,11 +0,0 @@ -{{ $file := .Get 0}} -{{ $filepath := $file }} -{{ $syntax := index (split $file ".") 1 }} -{{ $syntaxoverride := eq (len .Params) 2 }} -
    -
    {{$filepath}}
    - -
    {{- readFile $file -}}
    -
    diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/exfile.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/exfile.html deleted file mode 100644 index 226782957..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/exfile.html +++ /dev/null @@ -1,12 +0,0 @@ -{{ $file := .Get 0}} -{{ $filepath := replace $file "static/" ""}} -{{ $syntax := index (split $file ".") 1 }} -{{ $syntaxoverride := eq (len .Params) 2 }} -
    -
    {{$filepath}}
    - -
    {{- readFile $file -}}
    - Source -
    diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/exfm.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/exfm.html deleted file mode 100644 index c0429bbe1..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/exfm.html +++ /dev/null @@ -1,13 +0,0 @@ - -{{ $file := .Get 0}} -{{ $filepath := replace $file "static/" ""}} -{{ $syntax := index (split $file ".") 1 }} -{{ $syntaxoverride := eq (len .Params) 2 }} -
    -
    {{$filepath}}
    - -
    {{- readFile $file -}}
    - Source -
    \ No newline at end of file diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/gh.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/gh.html deleted file mode 100644 index e027dc0f0..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/gh.html +++ /dev/null @@ -1,9 +0,0 @@ -{{ range .Params }} - {{ if eq (substr . 0 1) "@" }} - {{ . }} - {{ else if eq (substr . 0 2) "0x" }} - {{ substr . 2 6 }} - {{ else }} - #{{ . }} - {{ end }} -{{ end }} \ No newline at end of file diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/ghrepo.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/ghrepo.html deleted file mode 100644 index e9df40d6a..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/ghrepo.html +++ /dev/null @@ -1 +0,0 @@ -GitHub repository \ No newline at end of file diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/nohighlight.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/nohighlight.html deleted file mode 100644 index 0f254b4ca..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/nohighlight.html +++ /dev/null @@ -1 +0,0 @@ -
    {{ .Inner }}
    diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/note.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/note.html deleted file mode 100644 index 24d2cd0b2..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/note.html +++ /dev/null @@ -1,9 +0,0 @@ -{{ $_hugo_config := `{ "version": 1 }` }} - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/output.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/output.html deleted file mode 100644 index df1a8ae89..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/output.html +++ /dev/null @@ -1,11 +0,0 @@ -{{$file := .Get "file"}} -{{$icon := index (split $file ".") 1 }} -
    -
    {{$file}}
    - -
    - {{- .Inner -}} -
    -
    \ No newline at end of file diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/readfile.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/readfile.html deleted file mode 100644 index f777abe26..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/readfile.html +++ /dev/null @@ -1,6 +0,0 @@ -{{$file := .Get "file"}} -{{- if eq (.Get "markdown") "true" -}} -{{- $file | readFile | markdownify -}} -{{- else -}} -{{ $file | readFile | safeHTML }} -{{- end -}} \ No newline at end of file diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/tip.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/tip.html deleted file mode 100644 index 139e3376b..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/tip.html +++ /dev/null @@ -1,9 +0,0 @@ -{{ $_hugo_config := `{ "version": 1 }` }} - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/warning.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/warning.html deleted file mode 100644 index c9147be64..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/warning.html +++ /dev/null @@ -1,9 +0,0 @@ -{{ $_hugo_config := `{ "version": 1 }` }} - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/yt.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/yt.html deleted file mode 100644 index 6915cec5f..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/shortcodes/yt.html +++ /dev/null @@ -1,11 +0,0 @@ -
    - - {{if (.Get "thumbnail")}} -
    - {{else}} -
    - {{end}} -
    -{{ if (.Get "description") }} -
    {{ .Get "description" | markdownify }}
    -{{ end }} \ No newline at end of file diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/showcase/list.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/showcase/list.html deleted file mode 100644 index b0083fc0f..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/showcase/list.html +++ /dev/null @@ -1,46 +0,0 @@ -{{ define "main" }} -
    -
    -

    - {{ .Title }} -

    -
    - {{ .Content }} -
    -
    -
    - {{ range (.Paginate (.Pages | shuffle ) 20).Pages }} - {{template "showcase_items" .}} - {{ end }} -
    - -
    The Showcase articles are copyright the content authors. Any open source license will be attached.
    -
    -{{ end }} - - -{{define "showcase_items"}} - -
    - {{ $img := (.Resources.ByType "image").GetMatch "*featured*" }} - {{ with $img }} - {{ $big := .Fill "1024x512 top" }} - {{ $small := $big.Resize "512x" }} - - {{end}} -
    {{/* the margin aligns to the bottom */}} -

    - {{- .Title -}} -

    -
    -
    -
    - - -{{end}} diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/showcase/single.html b/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/showcase/single.html deleted file mode 100644 index a7cf439cb..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/layouts/showcase/single.html +++ /dev/null @@ -1,106 +0,0 @@ -{{ define "title" }} -Showcase: {{ .Title }} -{{ end }} - -{{ define "main" }} -
    - - -
    - -
    - {{template "sc-details" .}} -
    - -
    - {{template "sc-main-column" .}} -
    - - - -
    - -
    {{/* bottom row */}} - Last Update: {{ .Lastmod.Format "January 2, 2006" }}
    - {{ partial "page-edit.html" . }} -
    -
    The Showcase articles are copyright the content authors. Any open source license will be attached.
    -
    -{{ end }} - - - -{{define "sc-main-column"}} - {{ $img := (.Resources.ByType "image").GetMatch "*featured*" }} - {{ with $img }} - {{ $big := .Fill "1024x512 top" }} - {{ $small := $big.Resize "512x" }} - {{ $img.Title }} - {{ end }} - - -{{end}} - -{{define "sc-details"}} - -{{end}} - -{{define "sc-navigation"}} - {{$section := where .Site.RegularPages "Section" .Section}} - {{$number_of_entries := $section | len}} - -{{end}} diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/static/apple-touch-icon.png b/docs/_vendor/github.com/gohugoio/gohugoioTheme/static/apple-touch-icon.png deleted file mode 100644 index ecf1fc020..000000000 Binary files a/docs/_vendor/github.com/gohugoio/gohugoioTheme/static/apple-touch-icon.png and /dev/null differ diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/static/browserconfig.xml b/docs/_vendor/github.com/gohugoio/gohugoioTheme/static/browserconfig.xml deleted file mode 100644 index 62400c5f2..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/static/browserconfig.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - #2d89ef - - - diff --git a/docs/_vendor/github.com/gohugoio/gohugoioTheme/static/dist/app.bundle.js b/docs/_vendor/github.com/gohugoio/gohugoioTheme/static/dist/app.bundle.js deleted file mode 100644 index 6391e71e9..000000000 --- a/docs/_vendor/github.com/gohugoio/gohugoioTheme/static/dist/app.bundle.js +++ /dev/null @@ -1,22 +0,0 @@ -!function(t){function e(r){if(n[r])return n[r].exports;var i=n[r]={i:r,l:!1,exports:{}};return t[r].call(i.exports,i,i.exports,e),i.l=!0,i.exports}var n={};e.m=t,e.c=n,e.i=function(t){return t},e.d=function(t,n,r){e.o(t,n)||Object.defineProperty(t,n,{configurable:!1,enumerable:!0,get:r})},e.n=function(t){var n=t&&t.__esModule?function(){return t.default}:function(){return t};return e.d(n,"a",n),n},e.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},e.p="",e(e.s=11)}([function(t,e,n){"use strict";var r=function(t){var e=document.createElement("a");return e.className="header-link",e.href="#"+t,e.innerHTML=' ',e},i=function(t,e){for(var n=e.getElementsByTagName("h"+t),i=0;i0&&p.parentNode.classList.add("expand")}}catch(t){a=!0,u=t}finally{try{!s&&l.return&&l.return()}finally{if(a)throw u}}}},function(t,e,n){"use strict";n(13)({apiKey:"167e7998590aebda7f9fedcf86bc4a55",indexName:"hugodocs",inputSelector:"#search-input",debug:!0})},function(t,e,n){"use strict";n(14),n(15)},function(t,e,n){"use strict";function r(){for(var t=this.dataset.target.split(" "),e=document.querySelector(".mobilemenu:not(.dn)"),n=document.querySelector(".desktopmenu:not(.dn)"),r=document.querySelector(".desktopmenu:not(.dn)"),i=0;i=0?function(){var t=window.pageYOffset;(t>=i-s||window.innerHeight+t>=document.body.offsetHeight)&&clearInterval(u)}:function(){window.pageYOffset<=(i||0)&&clearInterval(u)};var u=setInterval(a,16)},e=document.querySelectorAll("#TableOfContents ul li a");[].forEach.call(e,function(e){e.addEventListener("click",function(n){n.preventDefault();var r=e.getAttribute("href"),i=document.querySelector(r),o=e.getAttribute("data-speed");i&&t(i,o||500)},!1)})}}()},function(t,e,n){"use strict";function r(t){if(t.target){t.preventDefault();var e=t.currentTarget,n=e.getAttribute("data-toggle-tab")}else var n=t;window.localStorage&&window.localStorage.setItem("configLangPref",n);for(var r=document.querySelectorAll("[data-toggle-tab='"+n+"']"),i=document.querySelectorAll("[data-pane='"+n+"']"),a=0;a0&&void 0!==arguments[0]?arguments[0]:{};this.action=t.action,this.container=t.container,this.emitter=t.emitter,this.target=t.target,this.text=t.text,this.trigger=t.trigger,this.selectedText=""}},{key:"initSelection",value:function(){this.text?this.selectFake():this.target&&this.selectTarget()}},{key:"selectFake",value:function(){var t=this,e="rtl"==document.documentElement.getAttribute("dir");this.removeFake(),this.fakeHandlerCallback=function(){return t.removeFake()},this.fakeHandler=this.container.addEventListener("click",this.fakeHandlerCallback)||!0,this.fakeElem=document.createElement("textarea"),this.fakeElem.style.fontSize="12pt",this.fakeElem.style.border="0",this.fakeElem.style.padding="0",this.fakeElem.style.margin="0",this.fakeElem.style.position="absolute",this.fakeElem.style[e?"right":"left"]="-9999px";var n=window.pageYOffset||document.documentElement.scrollTop;this.fakeElem.style.top=n+"px",this.fakeElem.setAttribute("readonly",""),this.fakeElem.value=this.text,this.container.appendChild(this.fakeElem),this.selectedText=(0,r.default)(this.fakeElem),this.copyText()}},{key:"removeFake",value:function(){this.fakeHandler&&(this.container.removeEventListener("click",this.fakeHandlerCallback),this.fakeHandler=null,this.fakeHandlerCallback=null),this.fakeElem&&(this.container.removeChild(this.fakeElem),this.fakeElem=null)}},{key:"selectTarget",value:function(){this.selectedText=(0,r.default)(this.target),this.copyText()}},{key:"copyText",value:function(){var t=void 0;try{t=document.execCommand(this.action)}catch(e){t=!1}this.handleResult(t)}},{key:"handleResult",value:function(t){this.emitter.emit(t?"success":"error",{action:this.action,text:this.selectedText,trigger:this.trigger,clearSelection:this.clearSelection.bind(this)})}},{key:"clearSelection",value:function(){this.trigger&&this.trigger.focus(),window.getSelection().removeAllRanges()}},{key:"destroy",value:function(){this.removeFake()}},{key:"action",set:function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"copy";if(this._action=t,"copy"!==this._action&&"cut"!==this._action)throw new Error('Invalid "action" value, use either "copy" or "cut"')},get:function(){return this._action}},{key:"target",set:function(t){if(void 0!==t){if(!t||"object"!==(void 0===t?"undefined":i(t))||1!==t.nodeType)throw new Error('Invalid "target" value, use a valid Element');if("copy"===this.action&&t.hasAttribute("disabled"))throw new Error('Invalid "target" attribute. Please use "readonly" instead of "disabled" attribute');if("cut"===this.action&&(t.hasAttribute("readonly")||t.hasAttribute("disabled")))throw new Error('Invalid "target" attribute. You can\'t cut text from elements with "readonly" or "disabled" attributes');this._target=t}},get:function(){return this._target}}]),t}();t.exports=s})},{select:5}],8:[function(e,n,r){!function(i,o){if("function"==typeof t&&t.amd)t(["module","./clipboard-action","tiny-emitter","good-listener"],o);else if(void 0!==r)o(n,e("./clipboard-action"),e("tiny-emitter"),e("good-listener"));else{var s={exports:{}};o(s,i.clipboardAction,i.tinyEmitter,i.goodListener),i.clipboard=s.exports}}(this,function(t,e,n,r){"use strict";function i(t){return t&&t.__esModule?t:{default:t}}function o(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}function s(t,e){if(!t)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return!e||"object"!=typeof e&&"function"!=typeof e?t:e}function a(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Super expression must either be null or a function, not "+typeof e);t.prototype=Object.create(e&&e.prototype,{constructor:{value:t,enumerable:!1,writable:!0,configurable:!0}}),e&&(Object.setPrototypeOf?Object.setPrototypeOf(t,e):t.__proto__=e)}function u(t,e){var n="data-clipboard-"+t;if(e.hasAttribute(n))return e.getAttribute(n)}var c=i(e),l=i(n),h=i(r),f="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},p=function(){function t(t,e){for(var n=0;n0&&void 0!==arguments[0]?arguments[0]:{};this.action="function"==typeof t.action?t.action:this.defaultAction,this.target="function"==typeof t.target?t.target:this.defaultTarget,this.text="function"==typeof t.text?t.text:this.defaultText,this.container="object"===f(t.container)?t.container:document.body}},{key:"listenClick",value:function(t){var e=this;this.listener=(0,h.default)(t,"click",function(t){return e.onClick(t)})}},{key:"onClick",value:function(t){var e=t.delegateTarget||t.currentTarget;this.clipboardAction&&(this.clipboardAction=null),this.clipboardAction=new c.default({action:this.action(e),target:this.target(e),text:this.text(e),container:this.container,trigger:e,emitter:this})}},{key:"defaultAction",value:function(t){return u("action",t)}},{key:"defaultTarget",value:function(t){var e=u("target",t);if(e)return document.querySelector(e)}},{key:"defaultText",value:function(t){return u("text",t)}},{key:"destroy",value:function(){this.listener.destroy(),this.clipboardAction&&(this.clipboardAction.destroy(),this.clipboardAction=null)}}],[{key:"isSupported",value:function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:["copy","cut"],e="string"==typeof t?[t]:t,n=!!document.queryCommandSupported;return e.forEach(function(t){n=n&&!!document.queryCommandSupported(t)}),n}}]),e}(l.default);t.exports=d})},{"./clipboard-action":7,"good-listener":4,"tiny-emitter":6}]},{},[8])(8)})},function(t,e,n){/*! docsearch 2.4.1 | © Algolia | github.com/algolia/docsearch */ -!function(e,n){t.exports=n()}(0,function(){return function(t){function e(r){if(n[r])return n[r].exports;var i=n[r]={i:r,l:!1,exports:{}};return t[r].call(i.exports,i,i.exports,e),i.l=!0,i.exports}var n={};return e.m=t,e.c=n,e.i=function(t){return t},e.d=function(t,n,r){e.o(t,n)||Object.defineProperty(t,n,{configurable:!1,enumerable:!0,get:r})},e.n=function(t){var n=t&&t.__esModule?function(){return t.default}:function(){return t};return e.d(n,"a",n),n},e.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},e.p="",e(e.s=46)}([function(t,e,n){"use strict";function r(t){return t.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,"\\$&")}var i=n(1);t.exports={isArray:null,isFunction:null,isObject:null,bind:null,each:null,map:null,mixin:null,isMsie:function(){return!!/(msie|trident)/i.test(navigator.userAgent)&&navigator.userAgent.match(/(msie |rv:)(\d+(.\d+)?)/i)[2]},escapeRegExChars:function(t){return t.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,"\\$&")},isNumber:function(t){return"number"==typeof t},toStr:function(t){return void 0===t||null===t?"":t+""},cloneDeep:function(t){var e=this.mixin({},t),n=this;return this.each(e,function(t,r){t&&(n.isArray(t)?e[r]=[].concat(t):n.isObject(t)&&(e[r]=n.cloneDeep(t)))}),e},error:function(t){throw new Error(t)},every:function(t,e){var n=!0;return t?(this.each(t,function(r,i){if(!(n=e.call(null,r,i,t)))return!1}),!!n):n},any:function(t,e){var n=!1;return t?(this.each(t,function(r,i){if(e.call(null,r,i,t))return n=!0,!1}),n):n},getUniqueId:function(){var t=0;return function(){return t++}}(),templatify:function(t){if(this.isFunction(t))return t;var e=i.element(t);return"SCRIPT"===e.prop("tagName")?function(){return e.text()}:function(){return String(t)}},defer:function(t){setTimeout(t,0)},noop:function(){},formatPrefix:function(t,e){return e?"":t+"-"},className:function(t,e,n){return(n?"":".")+t+e},escapeHighlightedString:function(t,e,n){e=e||"";var i=document.createElement("div");i.appendChild(document.createTextNode(e)),n=n||"";var o=document.createElement("div");o.appendChild(document.createTextNode(n));var s=document.createElement("div");return s.appendChild(document.createTextNode(t)),s.innerHTML.replace(RegExp(r(i.innerHTML),"g"),e).replace(RegExp(r(o.innerHTML),"g"),n)}}},function(t,e,n){"use strict";t.exports={element:null}},function(t,e){var n=Object.prototype.hasOwnProperty,r=Object.prototype.toString;t.exports=function(t,e,i){if("[object Function]"!==r.call(e))throw new TypeError("iterator must be a function");var o=t.length;if(o===+o)for(var s=0;s was loaded but did not call our provided callback"),JSONPScriptError:i("JSONPScriptError"," +{{ end }} +``` + +With that you can use the `mermaid` language in Markdown code blocks: + +````text {copy=true} +```mermaid +sequenceDiagram + participant Alice + participant Bob + Alice->>John: Hello John, how are you? + loop Healthcheck + John->>John: Fight against hypochondria + end + Note right of John: Rational thoughts
    prevail! + John-->>Alice: Great! + John->>Bob: How about you? + Bob-->>John: Jolly good! +``` +```` + +## Goat ASCII diagram examples + +### Graphics + +```goat + . + 0 3 P * Eye / ^ / + *-------* +y \ +) \ / Reflection + 1 /| 2 /| ^ \ \ \ v + *-------* | | v0 \ v3 --------*-------- + | |4 | |7 | *----\-----* + | *-----|-* +-----> +x / v X \ .-.<-------- o + |/ |/ / / o \ | / | Refraction / \ + *-------* v / \ +-' / \ + 5 6 +z v1 *------------------* v2 | o-----o + v + +``` + +### Complex + +```goat ++-------------------+ ^ .---. +| A Box |__.--.__ __.--> | .-. | | +| | '--' v | * |<--- | | ++-------------------+ '-' | | + Round *---(-. | + .-----------------. .-------. .----------. .-------. | | | + | Mixed Rounded | | | / Diagonals \ | | | | | | + | & Square Corners | '--. .--' / \ |---+---| '-)-' .--------. + '--+------------+-' .--. | '-------+--------' | | | | / Search / + | | | | '---. | '-------' | '-+------' + |<---------->| | | | v Interior | ^ + ' <---' '----' .-----------. ---. .--- v | + .------------------. Diag line | .-------. +---. \ / . | + | if (a > b) +---. .--->| | | | | Curved line \ / / \ | + | obj->fcn() | \ / | '-------' |<--' + / \ | + '------------------' '--' '--+--------' .--. .--. | .-. +Done?+-' + .---+-----. | ^ |\ | | /| .--+ | | \ / + | | | Join \|/ | | Curved | \| |/ | | \ | \ / + | | +----> o --o-- '-' Vertical '--' '--' '-- '--' + .---. + <--+---+-----' | /|\ | | 3 | + v not:line 'quotes' .-' '---' + .-. .---+--------. / A || B *bold* | ^ + | | | Not a dot | <---+---<-- A dash--is not a line v | + '-' '---------+--' / Nor/is this. --- + +``` + +### Process + +```goat + . + .---------. / \ + | START | / \ .-+-------+-. ___________ + '----+----' .-------. A / \ B | |COMPLEX| | / \ .-. + | | END |<-----+CHOICE +----->| | | +--->+ PREPARATION +--->| X | + v '-------' \ / | |PROCESS| | \___________/ '-' + .---------. \ / '-+---+---+-' + / INPUT / \ / + '-----+---' ' + | ^ + v | + .-----------. .-----+-----. .-. + | PROCESS +---------------->| PROCESS |<------+ X | + '-----------' '-----------' '-' +``` + +### File tree + +Created from + +```goat {width=300 color="orange"} +───Linux─┬─Android + ├─Debian─┬─Ubuntu─┬─Lubuntu + │ │ ├─Kubuntu + │ │ ├─Xubuntu + │ │ └─Xubuntu + │ └─Mint + ├─Centos + └─Fedora +``` + +### Sequence diagram + + + +```goat {class="w-40"} +┌─────┐ ┌───┐ +│Alice│ │Bob│ +└──┬──┘ └─┬─┘ + │ │ + │ Hello Bob! │ + │───────────>│ + │ │ + │Hello Alice!│ + │<───────────│ +┌──┴──┐ ┌─┴─┐ +│Alice│ │Bob│ +└─────┘ └───┘ + +``` + +### Flowchart + + + +```goat + _________________ + ╱ ╲ ┌─────┐ + ╱ DO YOU UNDERSTAND ╲____________________________________________________│GOOD!│ + ╲ FLOW CHARTS? ╱yes └──┬──┘ + ╲_________________╱ │ + │no │ + _________▽_________ ______________________ │ + ╱ ╲ ╱ ╲ ┌────┐ │ +╱ OKAY, YOU SEE THE ╲________________╱ ... AND YOU CAN SEE ╲___│GOOD│ │ +╲ LINE LABELED 'YES'? ╱yes ╲ THE ONES LABELED 'NO'? ╱yes└──┬─┘ │ + ╲___________________╱ ╲______________________╱ │ │ + │no │no │ │ + ________▽_________ _________▽__________ │ │ + ╱ ╲ ┌───────────┐ ╱ ╲ │ │ + ╱ BUT YOU SEE THE ╲___│WAIT, WHAT?│ ╱ BUT YOU JUST ╲___ │ │ + ╲ ONES LABELED 'NO'? ╱yes└───────────┘ ╲ FOLLOWED THEM TWICE? ╱yes│ │ │ + ╲__________________╱ ╲____________________╱ │ │ │ + │no │no │ │ │ + ┌───▽───┐ │ │ │ │ + │LISTEN.│ └───────┬───────┘ │ │ + └───┬───┘ ┌──────▽─────┐ │ │ + ┌─────▽────┐ │(THAT WASN'T│ │ │ + │I HATE YOU│ │A QUESTION) │ │ │ + └──────────┘ └──────┬─────┘ │ │ + ┌────▽───┐ │ │ + │SCREW IT│ │ │ + └────┬───┘ │ │ + └─────┬─────┘ │ + │ │ + └─────┬─────┘ + ┌───────▽──────┐ + │LET'S GO DRING│ + └───────┬──────┘ + ┌─────────▽─────────┐ + │HEY, I SHOULD TRY │ + │INSTALLING FREEBSD!│ + └───────────────────┘ + +``` + +### Table + + + +```goat {class="w-80 dark-blue"} +┌────────────────────────────────────────────────┐ +│ │ +├────────────────────────────────────────────────┤ +│SYNTAX = { PRODUCTION } . │ +├────────────────────────────────────────────────┤ +│PRODUCTION = IDENTIFIER "=" EXPRESSION "." . │ +├────────────────────────────────────────────────┤ +│EXPRESSION = TERM { "|" TERM } . │ +├────────────────────────────────────────────────┤ +│TERM = FACTOR { FACTOR } . │ +├────────────────────────────────────────────────┤ +│FACTOR = IDENTIFIER │ +├────────────────────────────────────────────────┤ +│ | LITERAL │ +├────────────────────────────────────────────────┤ +│ | "[" EXPRESSION "]" │ +├────────────────────────────────────────────────┤ +│ | "(" EXPRESSION ")" │ +├────────────────────────────────────────────────┤ +│ | "{" EXPRESSION "}" . │ +├────────────────────────────────────────────────┤ +│IDENTIFIER = letter { letter } . │ +├────────────────────────────────────────────────┤ +│LITERAL = """" character { character } """" .│ +└────────────────────────────────────────────────┘ +``` + +[code block render hook]: /render-hooks/code-blocks/ +[embedded code block render hook]: {{% eturl render-codeblock-goat %}} +[GoAT]: https://github.com/bep/goat diff --git a/docs/content/en/content-management/formats.md b/docs/content/en/content-management/formats.md index ea0568616..1acaae063 100644 --- a/docs/content/en/content-management/formats.md +++ b/docs/content/en/content-management/formats.md @@ -1,94 +1,132 @@ --- -title: Content Formats -linktitle: Content Formats -description: Both HTML and Markdown are supported content formats. -date: 2017-01-10 -publishdate: 2017-01-10 -lastmod: 2017-04-06 -categories: [content management] -keywords: [markdown,asciidoc,mmark,pandoc,content format] -menu: - docs: - parent: "content-management" - weight: 20 -weight: 20 #rem -draft: false +title: Content formats +description: Create your content using Markdown, HTML, Emacs Org Mode, AsciiDoc, Pandoc, or reStructuredText. +categories: [] +keywords: [] aliases: [/content/markdown-extras/,/content/supported-formats/,/doc/supported-formats/] -toc: true --- -You can put any file type into your `/content` directories, but Hugo uses the `markup` front matter value if set or the file extension (see `Markup identifiers` in the table below) to determine if the markup needs to be processed, e.g.: +## Introduction -* Markdown converted to HTML -* [Shortcodes](/content-management/shortcodes/) processed -* Layout applied +You may mix content formats throughout your site. For example: -## List of content formats +```text +content/ +└── posts/ + ├── post-1.md + ├── post-2.adoc + ├── post-3.org + ├── post-4.pandoc + ├── post-5.rst + └── post-6.html +``` -The current list of content formats in Hugo: +Regardless of content format, all content must have [front matter], preferably including both `title` and `date`. -| Name | Markup identifiers | Comment | -| ------------- | ------------- |-------------| -| Goldmark | md, markdown, goldmark |Note that you can set the default handler of `md` and `markdown` to something else, see [Configure Markup](/getting-started/configuration-markup/).{{< new-in "0.60.0" >}} | -| Blackfriday | blackfriday |Blackfriday will eventually be deprecated.| -|MMark|mmark|Mmark is deprecated and will be removed in a future release.| -|Emacs Org-Mode|org|See [go-org](https://github.com/niklasfasching/go-org).| -|Asciidoc|asciidoc, adoc, ad|Needs Asciidoc or [Asciidoctor][ascii] installed.| -|RST|rst|Needs [RST](http://docutils.sourceforge.net/rst.html) installed.| -|Pandoc|pandoc, pdc|Needs [Pandoc](https://www.pandoc.org/) installed.| -|HTML|html, htm|To be treated as a content file, with layout, shortcodes etc., it must have front matter. If not, it will be copied as-is.| +Hugo selects the content renderer based on the `markup` identifier in front matter, falling back to the file extension. See the [classification] table below for a list of markup identifiers and recognized file extensions. -The `markup identifier` is fetched from either the `markup` variable in front matter or from the file extension. For markup-related configuration, see [Configure Markup](/getting-started/configuration-markup/). +[classification]: #classification +[front matter]: /content-management/front-matter/ +## Formats -## External Helpers +### Markdown -Some of the formats in the table above needs external helpers installed on your PC. For example, for Asciidoc files, Hugo will try to call the `asciidoctor` or `asciidoc` command. This means that you will have to install the associated tool on your machine to be able to use these formats. ([See the Asciidoctor docs for installation instructions](https://asciidoctor.org/docs/install-toolchain/)). +Create your content in [Markdown] preceded by front matter. -Hugo passes reasonable default arguments to these external helpers by default: +Markdown is Hugo's default content format. Hugo natively renders Markdown to HTML using [Goldmark]. Goldmark is fast and conforms to the [CommonMark] and [GitHub Flavored Markdown] specifications. You can configure Goldmark in your [site configuration][configure goldmark]. -- `asciidoc`: `--no-header-footer --safe -` -- `asciidoctor`: `--no-header-footer --safe --trace -` -- `rst2html`: `--leave-comments --initial-header-level=2` -- `pandoc`: `--mathjax` +Hugo provides custom Markdown features including: -{{% warning "Performance of External Helpers" %}} -Because additional formats are external commands generation performance will rely heavily on the performance of the external tool you are using. As this feature is still in its infancy, feedback is welcome. -{{% /warning %}} +[Attributes] +: Apply HTML attributes such as `class` and `id` to Markdown images and block elements including blockquotes, fenced code blocks, headings, horizontal rules, lists, paragraphs, and tables. -## Learn Markdown +[Extensions] +: Leverage the embedded Markdown extensions to create tables, definition lists, footnotes, task lists, inserted text, mark text, subscripts, superscripts, and more. -Markdown syntax is simple enough to learn in a single sitting. The following are excellent resources to get you up and running: +[Mathematics] +: Include mathematical equations and expressions in Markdown using LaTeX markup. -* [Daring Fireball: Markdown, John Gruber (Creator of Markdown)][fireball] -* [Markdown Cheatsheet, Adam Pritchard][mdcheatsheet] -* [Markdown Tutorial (Interactive), Garen Torikian][mdtutorial] -* [The Markdown Guide, Matt Cone][mdguide] +[Render hooks] +: Override the conversion of Markdown to HTML when rendering fenced code blocks, headings, images, and links. For example, render every standalone image as an HTML `figure` element. -[`emojify` function]: /functions/emojify/ -[ascii]: https://asciidoctor.org/ -[bfconfig]: /getting-started/configuration/#configuring-blackfriday-rendering -[blackfriday]: https://github.com/russross/blackfriday -[mmark]: https://github.com/miekg/mmark -[config]: /getting-started/configuration/ -[developer tools]: /tools/ -[emojis]: https://www.webpagefx.com/tools/emoji-cheat-sheet/ -[fireball]: https://daringfireball.net/projects/markdown/ -[gfmtasks]: https://guides.github.com/features/mastering-markdown/#syntax -[helperssource]: https://github.com/gohugoio/hugo/blob/77c60a3440806067109347d04eb5368b65ea0fe8/helpers/general.go#L65 -[hl]: /content-management/syntax-highlighting/ -[hlsc]: /content-management/shortcodes/#highlight -[hugocss]: /css/style.css -[ietf]: https://tools.ietf.org/html/ -[mathjaxdocs]: https://docs.mathjax.org/en/latest/ -[mdcheatsheet]: https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet -[mdguide]: https://www.markdownguide.org/ -[mdtutorial]: https://www.markdowntutorial.com/ -[Miek Gieben's website]: https://miek.nl/2016/march/05/mmark-syntax-document/ -[mmark]: https://github.com/mmarkdown/mmark -[org]: https://orgmode.org/ -[pandoc]: https://www.pandoc.org/ -[Pygments]: http://pygments.org/ -[rest]: http://docutils.sourceforge.net/rst.html -[sc]: /content-management/shortcodes/ -[sct]: /templates/shortcode-templates/ +[Attributes]: /content-management/markdown-attributes/ +[CommonMark]: https://spec.commonmark.org/current/ +[Extensions]: /configuration/markup/#extensions +[GitHub Flavored Markdown]: https://github.github.com/gfm/ +[Goldmark]: https://github.com/yuin/goldmark +[Markdown]: https://daringfireball.net/projects/markdown/ +[Mathematics]: /content-management/mathematics/ +[Render hooks]: /render-hooks/introduction/ +[configure goldmark]: /configuration/markup/#goldmark + +### HTML + +Create your content in [HTML] preceded by front matter. The content is typically what you would place within an HTML document's `body` or `main` element. + +[HTML]: https://developer.mozilla.org/en-US/docs/Learn_web_development/Getting_started/Your_first_website/Creating_the_content + +### Emacs Org Mode + +Create your content in the [Emacs Org Mode] format preceded by front matter. You can use Org Mode keywords for front matter. See [details]. + +[details]: /content-management/front-matter/#emacs-org-mode +[Emacs Org Mode]: https://orgmode.org/ + +### AsciiDoc + +Create your content in the [AsciiDoc] format preceded by front matter. Hugo renders AsciiDoc content to HTML using the Asciidoctor executable. You must install Asciidoctor and its dependencies (Ruby) to use the AsciiDoc content format. + +You can configure the AsciiDoc renderer in your [site configuration][configure asciidoc]. + +In its default configuration, Hugo passes these CLI flags when calling the Asciidoctor executable: + +```text +--no-header-footer +``` + +The CLI flags passed to the Asciidoctor executable depend on configuration. You may inspect the flags when building your site: + +```text +hugo --logLevel info +``` + +[AsciiDoc]: https://asciidoc.org/ +[configure the AsciiDoc renderer]: /configuration/markup/#asciidoc +[configure asciidoc]: /configuration/markup/#asciidoc + +### Pandoc + +Create your content in the [Pandoc] format preceded by front matter. Hugo renders Pandoc content to HTML using the Pandoc executable. You must install Pandoc to use the Pandoc content format. + +Hugo passes these CLI flags when calling the Pandoc executable: + +```text +--mathjax +``` + +[Pandoc]: https://pandoc.org/ + +### reStructuredText + +Create your content in the [reStructuredText] format preceded by front matter. Hugo renders reStructuredText content to HTML using [Docutils], specifically rst2html. You must install Docutils and its dependencies (Python) to use the reStructuredText content format. + +Hugo passes these CLI flags when calling the rst2html executable: + +```text +--leave-comments --initial-header-level=2 +``` + +[Docutils]: https://docutils.sourceforge.io/ +[reStructuredText]: https://docutils.sourceforge.io/rst.html + +## Classification + +{{% include "/_common/content-format-table.md" %}} + +When converting content to HTML, Hugo uses: + +- Native renderers for Markdown, HTML, and Emacs Org mode +- External renderers for AsciiDoc, Pandoc, and reStructuredText + +Native renderers are faster than external renderers. diff --git a/docs/content/en/content-management/front-matter.md b/docs/content/en/content-management/front-matter.md index 0e3baf437..8bfbd1acc 100644 --- a/docs/content/en/content-management/front-matter.md +++ b/docs/content/en/content-management/front-matter.md @@ -1,210 +1,362 @@ --- -title: Front Matter -linktitle: -description: Hugo allows you to add front matter in yaml, toml, or json to your content files. -date: 2017-01-09 -publishdate: 2017-01-09 -lastmod: 2017-02-24 -categories: [content management] -keywords: ["front matter", "yaml", "toml", "json", "metadata", "archetypes"] -menu: - docs: - parent: "content-management" - weight: 30 -weight: 30 #rem -draft: false +title: Front matter +description: Use front matter to add metadata to your content. +categories: [] +keywords: [] aliases: [/content/front-matter/] -toc: true --- -**Front matter** allows you to keep metadata attached to an instance of a [content type][]---i.e., embedded inside a content file---and is one of the many features that gives Hugo its strength. +## Overview -{{< youtube Yh2xKRJGff4 >}} +The front matter at the top of each content file is metadata that: -## Front Matter Formats +- Describes the content +- Augments the content +- Establishes relationships with other content +- Controls the published structure of your site +- Determines template selection -Hugo supports four formats for front matter, each with their own identifying tokens. +Provide front matter using a serialization format, one of [JSON], [TOML], or [YAML]. Hugo determines the front matter format by examining the delimiters that separate the front matter from the page content. -TOML -: identified by opening and closing `+++`. +[json]: https://www.json.org/ +[toml]: https://toml.io/ +[yaml]: https://yaml.org/ -YAML -: identified by opening and closing `---`. +See examples of front matter delimiters by toggling between the serialization formats below. -JSON -: a single JSON object surrounded by '`{`' and '`}`', followed by a new line. - -ORG -: a group of Org mode keywords in the format '`#+KEY: VALUE`'. Any line that does not start with `#+` ends the front matter section. - Keyword values can be either strings (`#+KEY: VALUE`) or a whitespace separated list of strings (`#+KEY[]: VALUE_1 VALUE_2`). - -### Example - -{{< code-toggle >}} -title = "spf13-vim 3.0 release and new website" -description = "spf13-vim is a cross platform distribution of vim plugins and resources for Vim." -tags = [ ".vimrc", "plugins", "spf13-vim", "vim" ] -date = "2012-04-06" -categories = [ - "Development", - "VIM" -] -slug = "spf13-vim-3-0-release-and-new-website" +{{< code-toggle file=content/example.md fm=true >}} +title = 'Example' +date = 2024-02-02T04:14:54-08:00 +draft = false +weight = 10 +[params] +author = 'John Smith' {{< /code-toggle >}} -## Front Matter Variables +Front matter fields may be [boolean](g), [integer](g), [float](g), [string](g), [arrays](g), or [maps](g). Note that the TOML format also supports unquoted date/time values. -### Predefined +## Fields -There are a few predefined variables that Hugo is aware of. See [Page Variables][pagevars] for how to call many of these predefined variables in your templates. +The most common front matter fields are `date`, `draft`, `title`, and `weight`, but you can specify metadata using any of fields below. + +> [!note] +> The field names below are reserved. For example, you cannot create a custom field named `type`. Create custom fields under the `params` key. See the [parameters] section for details. + +[parameters]: #parameters aliases -: an array of one or more aliases (e.g., old published paths of renamed content) that will be created in the output directory structure . See [Aliases][aliases] for details. +: (`string array`) An array of one or more aliases, where each alias is a relative URL that will redirect the browser to the current location. Access these values from a template using the [`Aliases`] method on a `Page` object. See the [aliases] section for details. -audio -: an array of paths to audio files related to the page; used by the `opengraph` [internal template](/templates/internal) to populate `og:audio`. +build +: (`map`) A map of [build options]. cascade -: a map of Front Matter keys whose values are passed down to the page's descendents unless overwritten by self or a closer ancestor's cascade. See [Front Matter Cascade](#front-matter-cascade) for details. +: (`map`) A map of front matter keys whose values are passed down to the page's descendants unless overwritten by self or a closer ancestor's cascade. See the [cascade] section for details. date -: the datetime assigned to this page. This is usually fetched from the `date` field in front matter, but this behaviour is configurable. +: (`string`) The date associated with the page, typically the creation date. Note that the TOML format also supports unquoted date/time values. See the [dates](#dates) section for examples. Access this value from a template using the [`Date`] method on a `Page` object. description -: the description for the content. +: (`string`) Conceptually different than the page `summary`, the description is typically rendered within a `meta` element within the `head` element of the published HTML file. Access this value from a template using the [`Description`] method on a `Page` object. draft -: if `true`, the content will not be rendered unless the `--buildDrafts` flag is passed to the `hugo` command. +: (`bool`) Whether to disable rendering unless you pass the `--buildDrafts` flag to the `hugo` command. Access this value from a template using the [`Draft`] method on a `Page` object. expiryDate -: the datetime at which the content should no longer be published by Hugo; expired content will not be rendered unless the `--buildExpired` flag is passed to the `hugo` command. +: (`string`) The page expiration date. On or after the expiration date, the page will not be rendered unless you pass the `--buildExpired` flag to the `hugo` command. Note that the TOML format also supports unquoted date/time values. See the [dates](#dates) section for examples. Access this value from a template using the [`ExpiryDate`] method on a `Page` object. headless -: if `true`, sets a leaf bundle to be [headless][headless-bundle]. - -images -: an array of paths to images related to the page; used by [internal templates](/templates/internal) such as `_internal/twitter_cards.html`. +: (`bool`) Applicable to [leaf bundles], whether to set the `render` and `list` [build options] to `never`, creating a headless bundle of [page resources]. isCJKLanguage -: if `true`, Hugo will explicitly treat the content as a CJK language; both `.Summary` and `.WordCount` work properly in CJK languages. +: (`bool`) Whether the content language is in the [CJK](g) family. This value determines how Hugo calculates word count, and affects the values returned by the [`WordCount`], [`FuzzyWordCount`], [`ReadingTime`], and [`Summary`] methods on a `Page` object. keywords -: the meta keywords for the content. - -layout -: the layout Hugo should select from the [lookup order][lookup] when rendering the content. If a `type` is not specified in the front matter, Hugo will look for the layout of the same name in the layout directory that corresponds with a content's section. See ["Defining a Content Type"][definetype] +: (`string array`) An array of keywords, typically rendered within a `meta` element within the `head` element of the published HTML file, or used as a [taxonomy](g) to classify content. Access these values from a template using the [`Keywords`] method on a `Page` object. lastmod -: the datetime at which the content was last modified. +: (`string`) The date that the page was last modified. Note that the TOML format also supports unquoted date/time values. See the [dates](#dates) section for examples. Access this value from a template using the [`Lastmod`] method on a `Page` object. + +layout +: (`string`) Provide a template name to [target a specific template], overriding the default [template lookup order]. Set the value to the base file name of the template, excluding its extension. Access this value from a template using the [`Layout`] method on a `Page` object. linkTitle -: used for creating links to content; if set, Hugo defaults to using the `linktitle` before the `title`. Hugo can also [order lists of content by `linktitle`][bylinktitle]. +: (`string`) Typically a shorter version of the `title`. Access this value from a template using the [`LinkTitle`] method on a `Page` object. markup -: **experimental**; specify `"rst"` for reStructuredText (requires`rst2html`) or `"md"` (default) for Markdown. +: (`string`) An identifier corresponding to one of the supported [content formats]. If not provided, Hugo determines the content renderer based on the file extension. + +menus +: (`string`, `string array`, or `map`) If set, Hugo adds the page to the given menu or menus. See the [menus] page for details. + +modified +: Alias to [lastmod](#lastmod). outputs -: allows you to specify output formats specific to the content. See [output formats][outputs]. +: (`string array`) The [output formats] to render. See [configure outputs] for more information. + +params +: {{< new-in 0.123.0 />}} +: (`map`) A map of custom [page parameters]. + +pubdate +: Alias to [publishDate](#publishdate). publishDate -: if in the future, content will not be rendered unless the `--buildFuture` flag is passed to `hugo`. +: (`string`) The page publication date. Before the publication date, the page will not be rendered unless you pass the `--buildFuture` flag to the `hugo` command. Note that the TOML format also supports unquoted date/time values. See the [dates](#dates) section for examples. Access this value from a template using the [`PublishDate`] method on a `Page` object. + +published +: Alias to [publishDate](#publishdate). resources -: used for configuring page bundle resources. See [Page Resources][page-resources]. +: (`map array`) An array of maps to provide metadata for [page resources]. -series -: an array of series this page belongs to, as a subset of the `series` [taxonomy](/content-management/taxonomies/); used by the `opengraph` [internal template](/templates/internal) to populate `og:see_also`. +sitemap +: (`map`) A map of sitemap options. See the [sitemap templates] page for details. Access these values from a template using the [`Sitemap`] method on a `Page` object. slug -: appears as the tail of the output URL. A value specified in front matter will override the segment of the URL based on the filename. +: (`string`) Overrides the last segment of the URL path. Not applicable to section pages. See the [URL management] page for details. Access this value from a template using the [`Slug`] method on a `Page` object. summary -: text used when providing a summary of the article in the `.Summary` page variable; details available in the [content-summaries](/content-management/summaries/) section. +: (`string`) Conceptually different than the page `description`, the summary either summarizes the content or serves as a teaser to encourage readers to visit the page. Access this value from a template using the [`Summary`] method on a `Page` object. title -: the title for the content. +: (`string`) The page title. Access this value from a template using the [`Title`] method on a `Page` object. + +translationKey +: (`string`) An arbitrary value used to relate two or more translations of the same page, useful when the translated pages do not share a common path. Access this value from a template using the [`TranslationKey`] method on a `Page` object. type -: the type of the content; this value will be automatically derived from the directory (i.e., the [section][]) if not specified in front matter. +: (`string`) The [content type](g), overriding the value derived from the top-level section in which the page resides. Access this value from a template using the [`Type`] method on a `Page` object. + +unpublishdate +: Alias to [expirydate](#expirydate). url -: the full path to the content from the web root. It makes no assumptions about the path of the content file. It also ignores any language prefixes of -the multilingual feature. - -videos -: an array of paths to videos related to the page; used by the `opengraph` [internal template](/templates/internal) to populate `og:video`. +: (`string`) Overrides the entire URL path. Applicable to regular pages and section pages. See the [URL management] page for details. weight -: used for [ordering your content in lists][ordering]. Lower weight gets higher precedence. So content with lower weight will come first. +: (`int`) The page [weight](g), used to order the page within a [page collection](g). Access this value from a template using the [`Weight`] method on a `Page` object. -\ -: field name of the *plural* form of the index. See `tags` and `categories` in the above front matter examples. _Note that the plural form of user-defined taxonomies cannot be the same as any of the predefined front matter variables._ +[URL management]: /content-management/urls/#slug +[`Summary`]: /methods/page/summary/ +[`aliases`]: /methods/page/aliases/ +[`date`]: /methods/page/date/ +[`description`]: /methods/page/description/ +[`draft`]: /methods/page/draft/ +[`expirydate`]: /methods/page/expirydate/ +[`fuzzywordcount`]: /methods/page/wordcount/ +[`keywords`]: /methods/page/keywords/ +[`lastmod`]: /methods/page/date/ +[`layout`]: /methods/page/layout/ +[`linktitle`]: /methods/page/linktitle/ +[`publishdate`]: /methods/page/publishdate/ +[`readingtime`]: /methods/page/readingtime/ +[`sitemap`]: /methods/page/sitemap/ +[`slug`]: /methods/page/slug/ +[`summary`]: /methods/page/summary/ +[`title`]: /methods/page/title/ +[`translationkey`]: /methods/page/translationkey/ +[`type`]: /methods/page/type/ +[`weight`]: /methods/page/weight/ +[`wordcount`]: /methods/page/wordcount/ +[aliases]: /content-management/urls/#aliases +[build options]: /content-management/build-options/ +[cascade]: #cascade-1 +[configure outputs]: /configuration/outputs/#outputs-per-page +[content formats]: /content-management/formats/#classification +[leaf bundles]: /content-management/page-bundles/#leaf-bundles +[menus]: /content-management/menus/#define-in-front-matter +[output formats]: /configuration/output-formats/ +[page parameters]: #parameters +[page resources]: /content-management/page-resources/#metadata +[sitemap templates]: /templates/sitemap/ +[target a specific template]: /templates/lookup-order/#target-a-template +[template lookup order]: /templates/lookup-order/ -{{% note "Hugo's Default URL Destinations" %}} -If neither `slug` nor `url` is present and [permalinks are not configured otherwise in your site `config` file](/content-management/urls/#permalinks), Hugo will use the filename of your content to create the output URL. See [Content Organization](/content-management/organization) for an explanation of paths in Hugo and [URL Management](/content-management/urls/) for ways to customize Hugo's default behaviors. -{{% /note %}} +## Parameters -### User-Defined +{{< new-in 0.123.0 />}} -You can add fields to your front matter arbitrarily to meet your needs. These user-defined key-values are placed into a single `.Params` variable for use in your templates. +Specify custom page parameters under the `params` key in front matter: -The following fields can be accessed via `.Params.include_toc` and `.Params.show_comments`, respectively. The [Variables][] section provides more information on using Hugo's page- and site-level variables in your templates. +{{< code-toggle file=content/example.md fm=true >}} +title = 'Example' +date = 2024-02-02T04:14:54-08:00 +draft = false +weight = 10 +[params] +author = 'John Smith' +{{< /code-toggle >}} -{{< code-toggle copy="false" >}} -include_toc: true -show_comments: false -{{}} +Access these values from a template using the [`Params`] or [`Param`] method on a `Page` object. -## Front Matter Cascade +[`param`]: /methods/page/param/ +[`params`]: /methods/page/params/ -Any node or section can pass down to descendents a set of Front Matter values as long as defined underneath the reserved `cascade` Front Matter key. +Hugo provides [embedded templates] to optionally insert meta data within the `head` element of your rendered pages. These embedded templates expect the following front matter parameters: -### Example -```yaml -# content/blog/_index.md -title: Blog -cascade: - banner: images/typewriter.jpg +Parameter|Data type|Used by these embedded templates +:--|:--|:-- +`audio`|`[]string`|[`opengraph.html`] +`images`|`[]string`|[`opengraph.html`], [`schema.html`], [`twitter_cards.html`] +`videos`|`[]string`|[`opengraph.html`] + +The embedded templates will skip a parameter if not provided in front matter, but will throw an error if the data type is unexpected. + +## Taxonomies + +Classify content by adding taxonomy terms to front matter. For example, with this site configuration: + +{{< code-toggle file=hugo >}} +[taxonomies] +tag = 'tags' +genre = 'genres' +{{< /code-toggle >}} + +Add taxonomy terms as shown below: + +{{< code-toggle file=content/example.md fm=true >}} +title = 'Example' +date = 2024-02-02T04:14:54-08:00 +draft = false +weight = 10 +tags = ['red','blue'] +genres = ['mystery','romance'] +[params] +author = 'John Smith' +{{< /code-toggle >}} + +You can add taxonomy terms to the front matter of any these [page kinds](g): + +- `home` +- `page` +- `section` +- `taxonomy` +- `term` + +Access taxonomy terms from a template using the [`Params`] or [`GetTerms`] method on a `Page` object. For example: + +```go-html-template {file="layouts/_default/single.html"} +{{ with .GetTerms "tags" }} +

    Tags

    + +{{ end }} ``` -With the above example the Blog section page and its descendents will return `images/typewriter.jpg` when `.Params.banner` is invoked unless: +[`Params`]: /methods/page/params/ +[`GetTerms`]: /methods/page/getterms/ -- Said descendent has its own `banner` value set -- Or a closer ancestor node has its own `cascade.banner` value set. +## Cascade -## Order Content Through Front Matter +A [node](g) can cascade front matter values to its descendants. However, this cascading will be prevented if the descendant already defines the field, or if a closer ancestor node has already cascaded a value for that same field. -You can assign content-specific `weight` in the front matter of your content. These values are especially useful for [ordering][ordering] in list views. You can use `weight` for ordering of content and the convention of [`_weight`][taxweight] for ordering content within a taxonomy. See [Ordering and Grouping Hugo Lists][lists] to see how `weight` can be used to organize your content in list views. +For example, to cascade a "color" parameter from the home page to all its descendants: -## Override Global Markdown Configuration +{{< code-toggle file=content/_index.md fm=true >}} +title = 'Home' +[cascade.params] +color = 'red' +{{< /code-toggle >}} -It's possible to set some options for Markdown rendering in a content's front matter as an override to the [BlackFriday rendering options set in your project configuration][config]. +### Target -## Front Matter Format Specs + -[variables]: /variables/ -[aliases]: /content-management/urls/#aliases -[archetype]: /content-management/archetypes/ -[bylinktitle]: /templates/lists/#by-link-title -[config]: /getting-started/configuration/ "Hugo documentation for site configuration" -[content type]: /content-management/types/ -[contentorg]: /content-management/organization/ -[definetype]: /content-management/types/#defining-a-content-type "Learn how to specify a type and a layout in a content's front matter" -[headless-bundle]: /content-management/page-bundles/#headless-bundle -[json]: https://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf "Specification for JSON, JavaScript Object Notation" -[lists]: /templates/lists/#ordering-content "See how to order content in list pages; for example, templates that look to specific _index.md for content and front matter." -[lookup]: /templates/lookup-order/ "Hugo traverses your templates in a specific order when rendering content to allow for DRYer templating." -[ordering]: /templates/lists/ "Hugo provides multiple ways to sort and order your content in list templates" -[outputs]: /templates/output-formats/ "With the release of v22, you can output your content to any text format using Hugo's familiar templating" -[page-resources]: /content-management/page-resources/ -[pagevars]: /variables/page/ -[section]: /content-management/sections/ -[taxweight]: /content-management/taxonomies/ -[toml]: https://github.com/toml-lang/toml "Specification for TOML, Tom's Obvious Minimal Language" -[urls]: /content-management/urls/ -[variables]: /variables/ -[yaml]: https://yaml.org/spec/ "Specification for YAML, YAML Ain't Markup Language" +The `target`[^1] keyword allows you to target specific pages or [environments](g). For example, to cascade a "color" parameter from the home page only to pages within the "articles" section, including the "articles" section page itself: + +[^1]: The `_target` alias for `target` is deprecated and will be removed in a future release. + +{{< code-toggle file=content/_index.md fm=true >}} +title = 'Home' +[cascade.params] +color = 'red' +[cascade.target] +path = '{/articles,/articles/**}' +{{< /code-toggle >}} + +Use any combination of these keywords to target pages and/or environments: + +environment +: (`string`) A [glob](g) pattern matching the build [environment](g). For example: `{staging,production}`. + +kind +: (`string`) A [glob](g) pattern matching the [page kind](g). For example: ` {taxonomy,term}`. + +path +: (`string`) A [glob](g) pattern matching the page's [logical path](g). For example: `{/books,/books/**}`. + +### Array + +Define an array of cascade parameters to apply different values to different targets. For example: + +{{< code-toggle file=content/_index.md fm=true >}} +title = 'Home' +[[cascade]] +[cascade.params] +color = 'red' +[cascade.target] +path = '{/books/**}' +kind = 'page' +[[cascade]] +[cascade.params] +color = 'blue' +[cascade.target] +path = '{/films/**}' +kind = 'page' +{{< /code-toggle >}} + +> [!note] +> For multilingual sites, defining cascade values in your site configuration is often more efficient. This avoids repeating the same cascade values on the home, section, taxonomy, or term page for each language. See [details](/configuration/cascade/). +> +> If you choose to define cascade values in front matter for a multilingual site, you must create a corresponding home, section, taxonomy, or term page for every language. + +## Emacs Org Mode + +If your [content format] is [Emacs Org Mode], you may provide front matter using Org Mode keywords. For example: + +```text {file="content/example.org"} +#+TITLE: Example +#+DATE: 2024-02-02T04:14:54-08:00 +#+DRAFT: false +#+AUTHOR: John Smith +#+GENRES: mystery +#+GENRES: romance +#+TAGS: red +#+TAGS: blue +#+WEIGHT: 10 +``` + +Note that you can also specify array elements on a single line: + +```text {file="content/example.org"} +#+TAGS[]: red blue +``` + +[content format]: /content-management/formats/ +[emacs org mode]: https://orgmode.org/ + +## Dates + +When populating a date field, whether a [custom page parameter](#parameters) or one of the four predefined fields ([`date`](#date), [`expiryDate`](#expirydate), [`lastmod`](#lastmod), [`publishDate`](#publishdate)), use one of these parsable formats: + +{{% include "/_common/parsable-date-time-strings.md" %}} + +To override the default time zone, set the [`timeZone`](/configuration/all/#timezone) in your site configuration. The order of precedence for determining the time zone is: + +1. The time zone offset in the date/time string +1. The time zone specified in your site configuration +1. The `Etc/UTC` time zone + +[`opengraph.html`]: {{% eturl opengraph %}} +[`schema.html`]: {{% eturl schema %}} +[`twitter_cards.html`]: {{% eturl twitter_cards %}} +[embedded templates]: /templates/embedded/ diff --git a/docs/content/en/content-management/image-processing/index.md b/docs/content/en/content-management/image-processing/index.md index 0b9bcf323..8d60c4f93 100644 --- a/docs/content/en/content-management/image-processing/index.md +++ b/docs/content/en/content-management/image-processing/index.md @@ -1,295 +1,447 @@ --- -title: "Image Processing" -description: "Image Page resources can be resized and cropped." -date: 2018-01-24T13:10:00-05:00 -linktitle: "Image Processing" -categories: ["content management"] -keywords: [resources,images] -weight: 4004 -draft: false -toc: true -menu: - docs: - parent: "content-management" - weight: 32 +title: Image processing +description: Resize, crop, rotate, filter, and convert images. +categories: [] +keywords: [] --- -## The Image Page Resource +## Image resources -The `image` is a [Page Resource]({{< relref "/content-management/page-resources" >}}), and the processing methods listed below do not work on images inside your `/static` folder. +To process an image you must access the file as a page resource, global resource, or remote resource. -To get all images in a [Page Bundle]({{< relref "/content-management/organization#page-bundles" >}}): +### Page resource -```go-html-template -{{ with .Resources.ByType "image" }} -{{ end }} +{{% glossary-term "page resource" %}} +```text +content/ +└── posts/ + └── post-1/ <-- page bundle + ├── index.md + └── sunset.jpg <-- page resource ``` -## Image Processing Methods +To access an image as a page resource: +```go-html-template +{{ $image := .Resources.Get "sunset.jpg" }} +``` -The `image` resource implements the methods `Resize`, `Fit` and `Fill`, each returning the transformed image using the specified dimensions and processing options. The `image` resource also, since Hugo 0.58, implements the method `Exif` and `Filter`. +### Global resource + +{{% glossary-term "global resource" %}} + +```text +assets/ +└── images/ + └── sunset.jpg <-- global resource +``` + +To access an image as a global resource: + +```go-html-template +{{ $image := resources.Get "images/sunset.jpg" }} +``` + +### Remote resource + +{{% glossary-term "remote resource" %}} + +To access an image as a remote resource: + +```go-html-template +{{ $image := resources.GetRemote "https://gohugo.io/img/hugo-logo.png" }} +``` + +## Image rendering + +Once you have accessed an image as a resource, render it in your templates using the `Permalink`, `RelPermalink`, `Width`, and `Height` properties. + +Example 1: Throws an error if the resource is not found. + +```go-html-template +{{ $image := .Resources.GetMatch "sunset.jpg" }} + +``` + +Example 2: Skips image rendering if the resource is not found. + +```go-html-template +{{ $image := .Resources.GetMatch "sunset.jpg" }} +{{ with $image }} + +{{ end }} +``` + +Example 3: A more concise way to skip image rendering if the resource is not found. + +```go-html-template +{{ with .Resources.GetMatch "sunset.jpg" }} + +{{ end }} +``` + +Example 4: Skips rendering if there's problem accessing a remote resource. + +```go-html-template +{{ $url := "https://gohugo.io/img/hugo-logo.png" }} +{{ with try (resources.GetRemote $url) }} + {{ with .Err }} + {{ errorf "%s" . }} + {{ else with .Value }} + + {{ else }} + {{ errorf "Unable to get remote resource %q" $url }} + {{ end }} +{{ end }} +``` + +## Image processing methods + +The `image` resource implements the [`Process`], [`Resize`], [`Fit`], [`Fill`], [`Crop`], [`Filter`], [`Colors`] and [`Exif`] methods. + +> [!note] +> Metadata (EXIF, IPTC, XMP, etc.) is not preserved during image transformation. Use the `Exif` method with the _original_ image to extract EXIF metadata from JPEG, PNG, TIFF, and WebP images. + +### Process + +{{< new-in 0.119.0 />}} + +> [!note] +> The `Process` method is also available as a filter, which is more effective if you need to apply multiple filters to an image. See [Process filter](/functions/images/process). + +Process processes the image with the given specification. The specification can contain an optional action, one of `resize`, `crop`, `fit` or `fill`. This means that you can use this method instead of [`Resize`], [`Fit`], [`Fill`], or [`Crop`]. + +See [Options](#image-processing-options) for available options. + +You can also use this method apply image processing that does not need any scaling, e.g. format conversions: + +```go-html-template +{{/* Convert the image from JPG to PNG. */}} +{{ $png := $jpg.Process "png" }} +``` + +Some more examples: + +```go-html-template +{{/* Rotate the image 90 degrees counter-clockwise. */}} +{{ $image := $image.Process "r90" }} + +{{/* Scaling actions. */}} +{{ $image := $image.Process "resize 600x" }} +{{ $image := $image.Process "crop 600x400" }} +{{ $image := $image.Process "fit 600x400" }} +{{ $image := $image.Process "fill 600x400" }} +``` ### Resize -Resizes the image to the specified width and height. +Resize an image to the given width and/or height. -```go -// Resize to a width of 600px and preserve ratio -{{ $image := $resource.Resize "600x" }} +If you specify both width and height, the resulting image will be disproportionally scaled unless the original image has the same aspect ratio. -// Resize to a height of 400px and preserve ratio -{{ $image := $resource.Resize "x400" }} +```go-html-template +{{/* Resize to a width of 600px and preserve aspect ratio */}} +{{ $image := $image.Resize "600x" }} -// Resize to a width 600px and a height of 400px -{{ $image := $resource.Resize "600x400" }} +{{/* Resize to a height of 400px and preserve aspect ratio */}} +{{ $image := $image.Resize "x400" }} + +{{/* Resize to a width of 600px and a height of 400px */}} +{{ $image := $image.Resize "600x400" }} ``` ### Fit -Scale down the image to fit the given dimensions while maintaining aspect ratio. Both height and width are required. -```go -{{ $image := $resource.Fit "600x400" }} +Downscale an image to fit the given dimensions while maintaining aspect ratio. You must provide both width and height. + +```go-html-template +{{ $image := $image.Fit "600x400" }} ``` ### Fill -Resize and crop the image to match the given dimensions. Both height and width are required. -```go -{{ $image := $resource.Fill "600x400" }} +Crop and resize an image to match the given dimensions. You must provide both width and height. Use the [`anchor`] option to change the crop box anchor point. + +```go-html-template +{{ $image := $image.Fill "600x400" }} +``` + +### Crop + +Crop an image to match the given dimensions without resizing. You must provide both width and height. Use the [`anchor`] option to change the crop box anchor point. + +```go-html-template +{{ $image := $image.Crop "600x400" }} ``` ### Filter -Apply one or more filters to your image. See [Image Filters](/functions/images/#image-filters) for a full list. +Apply one or more [filters] to an image. ```go-html-template -{{ $img = $img.Filter (images.GaussianBlur 6) (images.Pixelate 8) }} +{{ $image := $image.Filter (images.GaussianBlur 6) (images.Pixelate 8) }} ``` -The above can also be written in a more functional style using pipes: +Write this in a more functional style using pipes. Hugo applies the filters in the order given. ```go-html-template -{{ $img = $img | images.Filter (images.GaussianBlur 6) (images.Pixelate 8) }} +{{ $image := $image | images.Filter (images.GaussianBlur 6) (images.Pixelate 8) }} ``` -The filters will be applied in the given order. - -Sometimes it can be useful to create the filter chain once and then reuse it: +Sometimes it can be useful to create the filter chain once and then reuse it. ```go-html-template {{ $filters := slice (images.GaussianBlur 6) (images.Pixelate 8) }} -{{ $img1 = $img1.Filter $filters }} -{{ $img2 = $img2.Filter $filters }} +{{ $image1 := $image1.Filter $filters }} +{{ $image2 := $image2.Filter $filters }} ``` -### Exif +### Colors -Provides an [Exif](https://en.wikipedia.org/wiki/Exif) object with metadata about the image. - -Note that this is only suported for JPEG and TIFF images, so it's recommended to wrap the access with a `with`, e.g.: +`.Colors` returns a slice of hex strings with the dominant colors in the image using a simple histogram method. ```go-html-template -{{ with $img.Exif }} -Date: {{ .Date }} -Lat/Long: {{ .Lat}}/{{ .Long }} -Tags: -{{ range $k, $v := .Tags }} -TAG: {{ $k }}: {{ $v }} -{{ end }} +{{ $colors := $image.Colors }} +``` + +This method is fast, but if you also scale down your images, it would be good for performance to extract the colors from the scaled down image. + +### EXIF + +Provides an [EXIF] object containing image metadata. + +You may access EXIF data in JPEG, PNG, TIFF, and WebP images. To prevent errors when processing images without EXIF data, wrap the access in a [`with`] statement. + +```go-html-template +{{ with $image.Exif }} + Date: {{ .Date }} + Lat/Long: {{ .Lat }}/{{ .Long }} + Tags: + {{ range $k, $v := .Tags }} + TAG: {{ $k }}: {{ $v }} + {{ end }} {{ end }} ``` -#### Exif fields +You may also access EXIF fields individually, using the [`lang.FormatNumber`] function to format the fields as needed. + +```go-html-template +{{ with $image.Exif }} +
      + {{ with .Date }}
    • Date: {{ .Format "January 02, 2006" }}
    • {{ end }} + {{ with .Tags.ApertureValue }}
    • Aperture: {{ lang.FormatNumber 2 . }}
    • {{ end }} + {{ with .Tags.BrightnessValue }}
    • Brightness: {{ lang.FormatNumber 2 . }}
    • {{ end }} + {{ with .Tags.ExposureTime }}
    • Exposure Time: {{ . }}
    • {{ end }} + {{ with .Tags.FNumber }}
    • F Number: {{ . }}
    • {{ end }} + {{ with .Tags.FocalLength }}
    • Focal Length: {{ . }}
    • {{ end }} + {{ with .Tags.ISOSpeedRatings }}
    • ISO Speed Ratings: {{ . }}
    • {{ end }} + {{ with .Tags.LensModel }}
    • Lens Model: {{ . }}
    • {{ end }} +
    +{{ end }} +``` + +#### EXIF methods Date -: "photo taken" date/time +: (`time.Time`) Returns the image creation date/time. Format with the [`time.Format`]function. Lat -: "photo taken where", GPS latitude +: (`float64`) Returns the GPS latitude in degrees. Long -: "photo taken where", GPS longitude +: (`float64`) Returns the GPS longitude in degrees. -See [Image Processing Config](#image-processing-config) for how to configure what gets included in Exif. +Tags +: (`exif.Tags`) Returns a collection of the available EXIF tags for this image. You may include or exclude specific tags from this collection in the [site configuration]. +## Image processing options +The [`Resize`], [`Fit`], [`Fill`], and [`Crop`] methods accept a space-delimited, case-insensitive list of options. The order of the options within the list is irrelevant. +### Dimensions +With the [`Resize`] method you must specify width, height, or both. The [`Fit`], [`Fill`], and [`Crop`] methods require both width and height. All dimensions are in pixels. -## Image Processing Options +```go-html-template +{{ $image := $image.Resize "600x" }} +{{ $image := $image.Resize "x400" }} +{{ $image := $image.Resize "600x400" }} +{{ $image := $image.Fit "600x400" }} +{{ $image := $image.Fill "600x400" }} +{{ $image := $image.Crop "600x400" }} +``` -In addition to the dimensions (e.g. `600x400`), Hugo supports a set of additional image options. +### Rotation -### Background Color +Rotates an image counter-clockwise by the given angle. Hugo performs rotation _before_ scaling. For example, if the original image is 600x400 and you wish to rotate the image 90 degrees counter-clockwise while scaling it by 50%: -The background color to fill into the transparency layer. This is mostly useful when converting to a format that does not support transparency, e.g. `JPEG`. +```go-html-template +{{ $image = $image.Resize "200x r90" }} +``` -You can set the background color to use with a 3 or 6 digit hex code starting with `#`. +In the example above, the width represents the desired width _after_ rotation. -```go +To rotate an image without scaling, use the dimensions of the original image: + +```go-html-template +{{ with .Resources.GetMatch "sunset.jpg" }} + {{ with .Resize (printf "%dx%d r90" .Height .Width) }} + + {{ end }} +{{ end }} +``` + +In the example above, on the second line, we have reversed width and height to reflect the desired dimensions _after_ rotation. + +### Anchor + +When using the [`Crop`] or [`Fill`] method, the _anchor_ determines the placement of the crop box. You may specify `TopLeft`, `Top`, `TopRight`, `Left`, `Center`, `Right`, `BottomLeft`, `Bottom`, `BottomRight`, or `Smart`. + +The default value is `Smart`, which uses [Smartcrop] image analysis to determine the optimal placement of the crop box. You may override the default value in the [site configuration]. + +For example, if you have a 400x200 image with a bird in the upper left quadrant, you can create a 200x100 thumbnail containing the bird: + +```go-html-template +{{ $image.Crop "200x100 TopLeft" }} +``` + +If you apply [rotation](#rotation) when using the [`Crop`] or [`Fill`] method, specify the anchor relative to the rotated image. + +### Target format + +By default, Hugo encodes the image in the source format. You may convert the image to another format by specifying `bmp`, `gif`, `jpeg`, `jpg`, `png`, `tif`, `tiff`, or `webp`. + +```go-html-template +{{ $image.Resize "600x webp" }} +``` + +To convert an image without scaling, use the dimensions of the original image: + +```go-html-template +{{ with .Resources.GetMatch "sunset.jpg" }} + {{ with .Resize (printf "%dx%d webp" .Width .Height) }} + + {{ end }} +{{ end }} +``` + +### Quality + +Applicable to JPEG and WebP images, the `q` value determines the quality of the converted image. Higher values produce better quality images, while lower values produce smaller files. Set this value to a whole number between 1 and 100, inclusive. + +The default value is 75. You may override the default value in the [site configuration]. + +```go-html-template +{{ $image.Resize "600x webp q50" }} +``` + +### Hint + +Applicable to WebP images, this option corresponds to a set of predefined encoding parameters, and is equivalent to the `-preset` flag for the [`cwebp`] encoder. + +Value|Example +:--|:-- +`drawing`|Hand or line drawing with high-contrast details +`icon`|Small colorful image +`photo`|Outdoor photograph with natural lighting +`picture`|Indoor photograph such as a portrait +`text`|Image that is primarily text + +The default value is `photo`. You may override the default value in the [site configuration]. + +```go-html-template +{{ $image.Resize "600x webp picture" }} +``` + +### Background color + +When converting an image from a format that supports transparency (e.g., PNG) to a format that does _not_ support transparency (e.g., JPEG), you may specify the background color of the resulting image. + +Use either a 3-digit or 6-digit hexadecimal color code (e.g., `#00f` or `#0000ff`). + +The default value is `#ffffff` (white). You may override the default value in the [site configuration]. + +```go-html-template {{ $image.Resize "600x jpg #b31280" }} ``` -For color codes, see https://www.google.com/search?q=color+picker +### Resampling filter -**Note** that you also set a default background color to use, see [Image Processing Config](#image-processing-config). +You may specify the resampling filter used when resizing an image. Commonly used resampling filters include: -### JPEG Quality -Only relevant for JPEG images, values 1 to 100 inclusive, higher is better. Default is 75. +Filter|Description +:--|:-- +`Box`|Simple and fast averaging filter appropriate for downscaling +`Lanczos`|High-quality resampling filter for photographic images yielding sharp results +`CatmullRom`|Sharp cubic filter that is faster than the Lanczos filter while providing similar results +`MitchellNetravali`|Cubic filter that produces smoother results with less ringing artifacts than CatmullRom +`Linear`|Bilinear resampling filter, produces smooth output, faster than cubic filters +`NearestNeighbor`|Fastest resampling filter, no antialiasing -```go -{{ $image.Resize "600x q50" }} -``` - -### Rotate -Rotates an image by the given angle counter-clockwise. The rotation will be performed first to get the dimensions correct. The main use of this is to be able to manually correct for [EXIF orientation](https://github.com/golang/go/issues/4341) of JPEG images. - -```go -{{ $image.Resize "600x r90" }} -``` - -### Anchor -Only relevant for the `Fill` method. This is useful for thumbnail generation where the main motive is located in, say, the left corner. -Valid are `Center`, `TopLeft`, `Top`, `TopRight`, `Left`, `Right`, `BottomLeft`, `Bottom`, `BottomRight`. - -```go -{{ $image.Fill "300x200 BottomLeft" }} -``` - -### Resample Filter -Filter used in resizing. Default is `Box`, a simple and fast resampling filter appropriate for downscaling. - -Examples are: `Box`, `NearestNeighbor`, `Linear`, `Gaussian`. - -See https://github.com/disintegration/imaging for more. If you want to trade quality for faster processing, this may be a option to test. - -```go -{{ $image.Resize "600x400 Gaussian" }} -``` - -### Target Format - -By default the images is encoded in the source format, but you can set the target format as an option. - -Valid values are `jpg`, `png`, `tif`, `bmp`, and `gif`. - -```go -{{ $image.Resize "600x jpg" }} -``` - -## Image Processing Examples - -_The photo of the sunset used in the examples below is Copyright [Bjørn Erik Pedersen](https://commons.wikimedia.org/wiki/User:Bep) (Creative Commons Attribution-Share Alike 4.0 International license)_ - - -{{< imgproc sunset Resize "300x" />}} - -{{< imgproc sunset Fill "90x120 left" />}} - -{{< imgproc sunset Fill "90x120 right" />}} - -{{< imgproc sunset Fit "90x90" />}} - -{{< imgproc sunset Resize "300x q10" />}} - - -This is the shortcode used in the examples above: - - -{{< code file="layouts/shortcodes/imgproc.html" >}} -{{< readfile file="layouts/shortcodes/imgproc.html" >}} -{{< /code >}} - -And it is used like this: +The default value is `Box`. You may override the default value in the [site configuration]. ```go-html-template -{{}} +{{ $image.Resize "600x400 Lanczos" }} ``` +See [github.com/disintegration/imaging] for the complete list of resampling filters. If you wish to improve image quality at the expense of performance, you may wish to experiment with the alternative filters. -{{% note %}} -**Tip:** Note the self-closing shortcode syntax above. The `imgproc` shortcode can be called both with and without **inner content**. -{{% /note %}} +## Image processing examples -## Image Processing Config +_The photo of the sunset used in the examples below is Copyright [Bjørn Erik Pedersen](https://bep.is) (Creative Commons Attribution-Share Alike 4.0 International license)_ -You can configure an `imaging` section in `config.toml` with default image processing options: +{{< imgproc path="sunset.jpg" spec="resize 480x" alt="A sunset" />}} -```toml -[imaging] -# Default resample filter used for resizing. Default is Box, -# a simple and fast averaging filter appropriate for downscaling. -# See https://github.com/disintegration/imaging -resampleFilter = "box" +{{< imgproc path="sunset.jpg" spec="fill 120x150 left" alt="A sunset" />}} -# Default JPEG quality setting. Default is 75. -quality = 75 +{{< imgproc path="sunset.jpg" spec="fill 120x150 right" alt="A sunset" />}} -# Anchor used when cropping pictures. -# Default is "smart" which does Smart Cropping, using https://github.com/muesli/smartcrop -# Smart Cropping is content aware and tries to find the best crop for each image. -# Valid values are Smart, Center, TopLeft, Top, TopRight, Left, Right, BottomLeft, Bottom, BottomRight -anchor = "smart" +{{< imgproc path="sunset.jpg" spec="fit 120x120" alt="A sunset" />}} -# Default background color. -# Hugo will preserve transparency for target formats that supports it, -# but will fall back to this color for JPEG. -# Expects a standard HEX color string with 3 or 6 digits. -# See https://www.google.com/search?q=color+picker -bgColor = "#ffffff" +{{< imgproc path="sunset.jpg" spec="crop 240x240 center" alt="A sunset" />}} -[imaging.exif] - # Regexp matching the fields you want to Exclude from the (massive) set of Exif info -# available. As we cache this info to disk, this is for performance and -# disk space reasons more than anything. -# If you want it all, put ".*" in this config setting. -# Note that if neither this or ExcludeFields is set, Hugo will return a small -# default set. -includeFields = "" +{{< imgproc path="sunset.jpg" spec="resize 360x q10" alt="A sunset" />}} -# Regexp matching the Exif fields you want to exclude. This may be easier to use -# than IncludeFields above, depending on what you want. -excludeFields = "" +## Configuration -# Hugo extracts the "photo taken" date/time into .Date by default. -# Set this to true to turn it off. -disableDate = false +See [configure imaging](/configuration/imaging). -# Hugo extracts the "photo taken where" (GPS latitude and longitude) into -# .Long and .Lat. Set this to true to turn it off. -disableLatLong = false +## Smart cropping of images +By default, Hugo uses the [Smartcrop] library when cropping images with the `Crop` or `Fill` methods. You can set the anchor point manually, but in most cases the `Smart` option will make a good choice. -``` +Examples using the sunset image from above: -## Smart Cropping of Images +{{< imgproc path="sunset.jpg" spec="fill 200x200 smart" alt="A sunset" />}} -By default, Hugo will use the [Smartcrop](https://github.com/muesli/smartcrop), a library created by [muesli](https://github.com/muesli), when cropping images with `.Fill`. You can set the anchor point manually, but in most cases the smart option will make a good choice. And we will work with the library author to improve this in the future. +{{< imgproc path="sunset.jpg" spec="crop 200x200 smart" alt="A sunset" />}} -An example using the sunset image from above: +## Image processing performance consideration +Hugo caches processed images in the `resources` directory. If you include this directory in source control, Hugo will not have to regenerate the images in a [CI/CD](g) workflow (e.g., GitHub Pages, GitLab Pages, Netlify, etc.). This results in faster builds. -{{< imgproc sunset Fill "200x200 smart" />}} +If you change image processing methods or options, or if you rename or remove images, the `resources` directory will contain unused images. To remove the unused images, perform garbage collection with: - -## Image Processing Performance Consideration - -Processed images are stored below `/resources` (can be set with `resourceDir` config setting). This folder is deliberately placed in the project, as it is recommended to check these into source control as part of the project. These images are not "Hugo fast" to generate, but once generated they can be reused. - -If you change your image settings (e.g. size), remove or rename images etc., you will end up with unused images taking up space and cluttering your project. - -To clean up, run: - -```bash +```sh hugo --gc ``` - -{{% note %}} -**GC** is short for **Garbage Collection**. -{{% /note %}} - - - +[`anchor`]: /content-management/image-processing#anchor +[`Colors`]: #colors +[`Crop`]: #crop +[`cwebp`]: https://developers.google.com/speed/webp/docs/cwebp +[`Exif`]: #exif +[`Fill`]: #fill +[`Filter`]: #filter +[`Fit`]: #fit +[`lang.FormatNumber`]: /functions/lang/formatnumber/ +[`Process`]: #process +[`Resize`]: #resize +[`time.Format`]: /functions/time/format/ +[`with`]: /functions/go-template/with/ +[EXIF]: https://en.wikipedia.org/wiki/Exif +[filters]: /functions/images/filter/#image-filters +[github.com/disintegration/imaging]: https://github.com/disintegration/imaging#image-resizing +[site configuration]: /configuration/imaging/ +[Smartcrop]: https://github.com/muesli/smartcrop#smartcrop diff --git a/docs/content/en/content-management/markdown-attributes.md b/docs/content/en/content-management/markdown-attributes.md new file mode 100644 index 000000000..f52a48f17 --- /dev/null +++ b/docs/content/en/content-management/markdown-attributes.md @@ -0,0 +1,108 @@ +--- +title: Markdown attributes +description: Use Markdown attributes to add HTML attributes when rendering Markdown to HTML. +categories: [] +keywords: [] +--- + +## Overview + +Hugo supports Markdown attributes on images and block elements including blockquotes, fenced code blocks, headings, horizontal rules, lists, paragraphs, and tables. + +For example: + +```text +This is a paragraph. +{class="foo bar" id="baz"} +``` + +With `class` and `id` you can use shorthand notation: + +```text +This is a paragraph. +{.foo .bar #baz} +``` + +Hugo renders both of these to: + +```html +

    This is a paragraph.

    +``` + +## Block elements + +Update your site configuration to enable Markdown attributes for block-level elements. + +{{< code-toggle file=hugo >}} +[markup.goldmark.parser.attribute] +title = true # default is true +block = true # default is false +{{< /code-toggle >}} + +## Standalone images + +By default, when the [Goldmark] Markdown renderer encounters a standalone image element (no other elements or text on the same line), it wraps the image element within a paragraph element per the [CommonMark specification]. + +[CommonMark specification]: https://spec.commonmark.org/current/ +[Goldmark]: https://github.com/yuin/goldmark + +If you were to place an attribute list beneath an image element, Hugo would apply the attributes to the surrounding paragraph, not the image. + +To apply attributes to a standalone image element, you must disable the default wrapping behavior: + +{{< code-toggle file=hugo >}} +[markup.goldmark.parser] +wrapStandAloneImageWithinParagraph = false # default is true +{{< /code-toggle >}} + +## Usage + +You may add [global HTML attributes], or HTML attributes specific to the current element type. Consistent with its content security model, Hugo removes HTML event attributes such as `onclick` and `onmouseover`. + +[global HTML attributes]: https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes + +The attribute list consists of one or more key-value pairs, separated by spaces or commas, wrapped by braces. You must quote string values that contain spaces. Unlike HTML, boolean attributes must have both key and value. + +For example: + +```text +> This is a blockquote. +{class="foo bar" hidden=hidden} +``` + +Hugo renders this to: + +```html + +``` + +In most cases, place the attribute list beneath the markup element. For headings and fenced code blocks, place the attribute list on the right. + +Element|Position of attribute list +:--|:-- +blockquote | bottom +fenced code block | right +heading | right +horizontal rule | bottom +image | bottom +list | bottom +paragraph | bottom +table | bottom + +For example: + +````text +## Section 1 {class=foo} + +```bash {class=foo linenos=inline} +declare a=1 +echo "${a}" +``` + +This is a paragraph. +{class=foo} +```` + +As shown above, the attribute list for fenced code blocks is not limited to HTML attributes. You can also configure syntax highlighting by passing one or more of [these options](/functions/transform/highlight/#options). diff --git a/docs/content/en/content-management/mathematics.md b/docs/content/en/content-management/mathematics.md new file mode 100644 index 000000000..e0c8ba4d0 --- /dev/null +++ b/docs/content/en/content-management/mathematics.md @@ -0,0 +1,238 @@ +--- +title: Mathematics in Markdown +linkTitle: Mathematics +description: Include mathematical equations and expressions in Markdown using LaTeX markup. +categories: [] +keywords: [] +--- + +{{< new-in 0.122.0 />}} + +## Overview + +Mathematical equations and expressions written in [LaTeX] are common in academic and scientific publications. Your browser typically renders this mathematical markup using an open-source JavaScript display engine such as [MathJax] or [KaTeX]. + +For example, with this LaTeX markup: + +```text +\[ +\begin{aligned} +KL(\hat{y} || y) &= \sum_{c=1}^{M}\hat{y}_c \log{\frac{\hat{y}_c}{y_c}} \\ +JS(\hat{y} || y) &= \frac{1}{2}(KL(y||\frac{y+\hat{y}}{2}) + KL(\hat{y}||\frac{y+\hat{y}}{2})) +\end{aligned} +\] +``` + +The MathJax display engine renders this: + +\[ +\begin{aligned} +KL(\hat{y} || y) &= \sum_{c=1}^{M}\hat{y}_c \log{\frac{\hat{y}_c}{y_c}} \\ +JS(\hat{y} || y) &= \frac{1}{2}(KL(y||\frac{y+\hat{y}}{2}) + KL(\hat{y}||\frac{y+\hat{y}}{2})) +\end{aligned} +\] + +Equations and expressions can be displayed inline with other text, or as standalone blocks. Block presentation is also known as "display" mode. + +Whether an equation or expression appears inline, or as a block, depends on the delimiters that surround the mathematical markup. Delimiters are defined in pairs, where each pair consists of an opening and closing delimiter. The opening and closing delimiters may be the same, or different. + +> [!note] +> You can configure Hugo to render mathematical markup on the client side using the MathJax or KaTeX display engine, or you can render the markup with the [`transform.ToMath`] function while building your site. +> +> The first approach is described below. + +## Setup + +Follow these instructions to include mathematical equations and expressions in your Markdown using LaTeX markup. + +### Step 1 + +Enable and configure the Goldmark [passthrough extension] in your site configuration. The passthrough extension preserves raw Markdown within delimited snippets of text, including the delimiters themselves. + +{{< code-toggle file=hugo copy=true >}} +[markup.goldmark.extensions.passthrough] +enable = true + +[markup.goldmark.extensions.passthrough.delimiters] +block = [['\[', '\]'], ['$$', '$$']] +inline = [['\(', '\)']] + +[params] +math = true +{{< /code-toggle >}} + +The configuration above enables mathematical rendering on every page unless you set the `math` parameter to `false` in front matter. To enable mathematical rendering as needed, set the `math` parameter to `false` in your site configuration, and set the `math` parameter to `true` in front matter. Use this parameter in your base template as shown in [Step 3]. + +> [!note] +> The configuration above precludes the use of the `$...$` delimiter pair for inline equations. Although you can add this delimiter pair to the configuration and JavaScript, you will need to double-escape the `$` symbol when used outside of math contexts to avoid unintended formatting. +> +> See the [inline delimiters](#inline-delimiters) section for details. + +To disable passthrough of inline snippets, omit the `inline` key from the configuration: + +{{< code-toggle file=hugo >}} +[markup.goldmark.extensions.passthrough.delimiters] +block = [['\[', '\]'], ['$$', '$$']] +{{< /code-toggle >}} + +You can define your own opening and closing delimiters, provided they match the delimiters that you set in [Step 2]. + +{{< code-toggle file=hugo >}} +[markup.goldmark.extensions.passthrough.delimiters] +block = [['@@', '@@']] +inline = [['@', '@']] +{{< /code-toggle >}} + +### Step 2 + +Create a partial template to load MathJax or KaTeX. The example below loads MathJax, or you can use KaTeX as described in the [engines](#engines) section. + +```go-html-template {file="layouts/partials/math.html" copy=true} + + +``` + +The delimiters above must match the delimiters in your site configuration. + +### Step 3 + +Conditionally call the partial template from the base template. + +```go-html-template {file="layouts/_default/baseof.html"} + + ... + {{ if .Param "math" }} + {{ partialCached "math.html" . }} + {{ end }} + ... + +``` + +The example above loads the partial template if you have set the `math` parameter in front matter to `true`. If you have not set the `math` parameter in front matter, the conditional statement falls back to the `math` parameter in your site configuration. + +### Step 4 + +Include mathematical equations and expressions in Markdown using LaTeX markup. + +```text {file="content/math-examples.md" copy=true} +This is an inline \(a^*=x-b^*\) equation. + +These are block equations: + +\[a^*=x-b^*\] + +\[ a^*=x-b^* \] + +\[ +a^*=x-b^* +\] + +These are also block equations: + +$$a^*=x-b^*$$ + +$$ a^*=x-b^* $$ + +$$ +a^*=x-b^* +$$ +``` + +If you set the `math` parameter to `false` in your site configuration, you must set the `math` parameter to `true` in front matter. For example: + +{{< code-toggle file=content/math-examples.md fm=true >}} +title = 'Math examples' +date = 2024-01-24T18:09:49-08:00 +[params] +math = true +{{< /code-toggle >}} + +## Inline delimiters + +The configuration, JavaScript, and examples above use the `\(...\)` delimiter pair for inline equations. The `$...$` delimiter pair is a common alternative, but using it may result in unintended formatting if you use the `$` symbol outside of math contexts. + +If you add the `$...$` delimiter pair to your configuration and JavaScript, you must double-escape the `$` when outside of math contexts, regardless of whether mathematical rendering is enabled on the page. For example: + +```text +A \\$5 bill _saved_ is a \\$5 bill _earned_. +``` + +> [!note] +> If you use the `$...$` delimiter pair for inline equations, and occasionally use the `$` symbol outside of math contexts, you must use MathJax instead of KaTeX to avoid unintended formatting caused by [this KaTeX limitation](https://github.com/KaTeX/KaTeX/issues/437). + +## Engines + +MathJax and KaTeX are open-source JavaScript display engines. Both engines are fast, but at the time of this writing MathJax v3.2.2 is slightly faster than KaTeX v0.16.11. + +> [!note] +> If you use the `$...$` delimiter pair for inline equations, and occasionally use the `$` symbol outside of math contexts, you must use MathJax instead of KaTeX to avoid unintended formatting caused by [this KaTeX limitation](https://github.com/KaTeX/KaTeX/issues/437). +> +>See the [inline delimiters](#inline-delimiters) section for details. + +To use KaTeX instead of MathJax, replace the partial template from [Step 2] with this: + +```go-html-template {file="layouts/partials/math.html" copy=true} + + + + +``` + +The delimiters above must match the delimiters in your site configuration. + +## Chemistry + +Both MathJax and KaTeX provide support for chemical equations. For example: + +```text +$$C_p[\ce{H2O(l)}] = \pu{75.3 J // mol K}$$ +``` + +$$C_p[\ce{H2O(l)}] = \pu{75.3 J // mol K}$$ + +As shown in [Step 2] above, MathJax supports chemical equations without additional configuration. To add chemistry support to KaTeX, enable the mhchem extension as described in the KaTeX [documentation](https://katex.org/docs/libs). + +[`transform.ToMath`]: /functions/transform/tomath/ +[KaTeX]: https://katex.org/ +[LaTeX]: https://www.latex-project.org/ +[MathJax]: https://www.mathjax.org/ +[passthrough extension]: /configuration/markup/#passthrough +[Step 2]: #step-2 +[Step 3]: #step-3 diff --git a/docs/content/en/content-management/menus.md b/docs/content/en/content-management/menus.md index 9ac6f8bff..6d01173dc 100644 --- a/docs/content/en/content-management/menus.md +++ b/docs/content/en/content-management/menus.md @@ -1,123 +1,97 @@ --- title: Menus -linktitle: Menus -description: Hugo has a simple yet powerful menu system. -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2017-03-31 -categories: [content management] -keywords: [menus] -draft: false -menu: - docs: - parent: "content-management" - weight: 120 -weight: 120 #rem +description: Create menus by defining entries, localizing each entry, and rendering the resulting data structure. +categories: [] +keywords: [] aliases: [/extras/menus/] -toc: true --- -{{% note "Lazy Blogger"%}} -If all you want is a simple menu for your sections, see the ["Section Menu for Lazy Bloggers" in Menu Templates](/templates/menu-templates/#section-menu-for-lazy-bloggers). -{{% /note %}} +## Overview -You can do this: +To create a menu for your site: -* Place content in one or many menus -* Handle nested menus with unlimited depth -* Create menu entries without being attached to any content -* Distinguish active element (and active branch) +1. Define the menu entries +1. [Localize](multilingual/#menus) each entry +1. Render the menu with a [template] -## What is a Menu in Hugo? +Create multiple menus, either flat or nested. For example, create a main menu for the header, and a separate menu for the footer. -A **menu** is a named array of menu entries accessible by name via the [`.Site.Menus` site variable][sitevars]. For example, you can access your site's `main` menu via `.Site.Menus.main`. +There are three ways to define menu entries: -{{% note "Menus on Multilingual Sites" %}} -If you make use of the [multilingual feature](/content-management/multilingual/), you can define language-independent menus. -{{% /note %}} +1. Automatically +1. In front matter +1. In site configuration -See the [Menu Entry Properties][me-props] for all the variables and functions related to a menu entry. +> [!note] +> Although you can use these methods in combination when defining a menu, the menu will be easier to conceptualize and maintain if you use one method throughout the site. -## Add content to menus +## Define automatically -Hugo allows you to add content to a menu via the content's [front matter](/content-management/front-matter/). +To automatically define a menu entry for each top-level [section](g) of your site, enable the section pages menu in your site configuration. -### Simple - -If all you need to do is add an entry to a menu, the simple form works well. - -#### A Single Menu - -``` ---- -menu: "main" ---- -``` - -#### Multiple Menus - -``` ---- -menu: ["main", "footer"] ---- -``` - -#### Advanced - - -``` ---- -menu: - docs: - parent: 'extras' - weight: 20 ---- -``` - -## Add Non-content Entries to a Menu - -You can also add entries to menus that aren’t attached to a piece of content. This takes place in your Hugo project's [`config` file][config]. - -Here’s an example snippet pulled from a configuration file: - -{{< code-toggle file="config" >}} -[[menu.main]] - name = "about hugo" - pre = "" - weight = -110 - identifier = "about" - url = "/about/" -[[menu.main]] - name = "getting started" - pre = "" - post = "New!" - weight = -100 - url = "/getting-started/" +{{< code-toggle file=hugo >}} +sectionPagesMenu = "main" {{< /code-toggle >}} -{{% note %}} -The URLs must be relative to the context root. If the `baseURL` is `https://example.com/mysite/`, then the URLs in the menu must not include the context root `mysite`. Using an absolute URL will override the baseURL. If the value used for `URL` in the above example is `https://subdomain.example.com/`, the output will be `https://subdomain.example.com`. -{{% /note %}} +This creates a menu structure that you can access with `site.Menus.main` in your templates. See [menu templates] for details. -## Nesting +## Define in front matter -All nesting of content is done via the `parent` field. +To add a page to the "main" menu: -The parent of an entry should be the identifier of another entry. The identifier should be unique (within a menu). +{{< code-toggle file=content/about.md fm=true >}} +title = 'About' +menus = 'main' +{{< /code-toggle >}} -The following order is used to determine an Identifier: +Access the entry with `site.Menus.main` in your templates. See [menu templates] for details. -`.Name > .LinkTitle > .Title` +To add a page to the "main" and "footer" menus: -This means that `.Title` will be used unless `.LinkTitle` is present, etc. In practice, `.Name` and `.Identifier` are only used to structure relationships and therefore never displayed. +{{< code-toggle file=content/contact.md fm=true >}} +title = 'Contact' +menus = ['main','footer'] +{{< /code-toggle >}} -In this example, the top level of the menu is defined in your [site `config` file][config]. All content entries are attached to one of these entries via the `.Parent` field. +Access the entry with `site.Menus.main` and `site.Menus.footer` in your templates. See [menu templates] for details. -## Render Menus +> [!note] +> The configuration key in the examples above is `menus`. The `menu` (singular) configuration key is an alias for `menus`. -See [Menu Templates](/templates/menu-templates/) for information on how to render your site menus within your templates. +### Properties -[config]: /getting-started/configuration/ -[multilingual]: /content-management/multilingual/ -[sitevars]: /variables/ -[me-props]: /variables/menus/ +Use these properties when defining menu entries in front matter: + +{{% include "/_common/menu-entry-properties.md" %}} + +### Example + +This front matter menu entry demonstrates some of the available properties: + +{{< code-toggle file=content/products/software.md fm=true >}} +title = 'Software' +[menus.main] +parent = 'Products' +weight = 20 +pre = '' +[menus.main.params] +class = 'center' +{{< /code-toggle >}} + +Access the entry with `site.Menus.main` in your templates. See [menu templates] for details. + +## Define in site configuration + +See [configure menus](/configuration/menus/). + +## Localize + +Hugo provides two methods to localize your menu entries. See [multilingual]. + +## Render + +See [menu templates]. + +[menu templates]: /templates/menu/ +[multilingual]: /content-management/multilingual/#menus +[template]: /templates/menu/ diff --git a/docs/content/en/content-management/multilingual.md b/docs/content/en/content-management/multilingual.md index d3f71676a..d419f4381 100644 --- a/docs/content/en/content-management/multilingual.md +++ b/docs/content/en/content-management/multilingual.md @@ -1,190 +1,42 @@ --- -title: Multilingual Mode -linktitle: Multilingual and i18n -description: Hugo supports the creation of websites with multiple languages side by side. -date: 2017-01-10 -publishdate: 2017-01-10 -lastmod: 2017-01-10 -categories: [content management] -keywords: [multilingual,i18n, internationalization] -menu: - docs: - parent: "content-management" - weight: 150 -weight: 150 #rem -draft: false +title: Multilingual mode +linkTitle: Multilingual +description: Localize your project for each language and region, including translations, images, dates, currencies, numbers, percentages, and collation sequence. Hugo's multilingual framework supports single-host and multihost configurations. +categories: [] +keywords: [] aliases: [/content/multilingual/,/tutorials/create-a-multilingual-site/] -toc: true --- -You should define the available languages in a `languages` section in your site configuration. +## Configuration -> Also See [Hugo Multilingual Part 1: Content translation](https://regisphilibert.com/blog/2018/08/hugo-multilingual-part-1-managing-content-translation/) +See [configure languages](/configuration/languages/). -## Configure Languages - -The following is an example of a site configuration for a multilingual Hugo project: - -{{< code-toggle file="config" >}} -DefaultContentLanguage = "en" -copyright = "Everything is mine" - -[params] -[params.navigation] -help = "Help" - -[languages] -[languages.en] -title = "My blog" -weight = 1 -[languages.en.params] -linkedin = "https://linkedin.com/whoever" - -[languages.fr] -title = "Mon blogue" -weight = 2 -[languages.fr.params] -linkedin = "https://linkedin.com/fr/whoever" -[languages.fr.params.navigation] -help = "Aide" -{{< /code-toggle >}} - -Anything not defined in a `languages` block will fall back to the global value for that key (e.g., `copyright` for the English `en` language). This also works for `params`, as demonstrated with `help` above: You will get the value `Aide` in French and `Help` in all the languages without this parameter set. - -With the configuration above, all content, sitemap, RSS feeds, paginations, -and taxonomy pages will be rendered below `/` in English (your default content language) and then below `/fr` in French. - -When working with front matter `Params` in [single page templates][singles], omit the `params` in the key for the translation. - -`defaultContentLanguage` sets the project's default language. If not set, the default language will be `en`. - -If the default language needs to be rendererd below its own language code (`/en`) like the others, set `defaultContentLanguageInSubdir: true`. - -Only the obvious non-global options can be overridden per language. Examples of global options are `baseURL`, `buildDrafts`, etc. - -### Disable a Language - -You can disable one or more languages. This can be useful when working on a new translation. - -```toml -disableLanguages = ["fr", "ja"] -``` - -Note that you cannot disable the default content language. - -We kept this as a standalone setting to make it easier to set via [OS environment](/getting-started/configuration/#configure-with-environment-variables): - -```bash -HUGO_DISABLELANGUAGES="fr ja" hugo -``` -If you have already a list of disabled languages in `config.toml`, you can enable them in development like this: - -```bash -HUGO_DISABLELANGUAGES=" " hugo server -``` - - -### Configure Multilingual Multihost - -From **Hugo 0.31** we support multiple languages in a multihost configuration. See [this issue](https://github.com/gohugoio/hugo/issues/4027) for details. - -This means that you can now configure a `baseURL` per `language`: - - -> If a `baseURL` is set on the `language` level, then all languages must have one and they must all be different. - -Example: - -{{< code-toggle file="config" >}} -[languages] -[languages.fr] -baseURL = "https://example.fr" -languageName = "Français" -weight = 1 -title = "En Français" - -[languages.en] -baseURL = "https://example.com" -languageName = "English" -weight = 2 -title = "In English" -{{}} - -With the above, the two sites will be generated into `public` with their own root: - -```bash -public -├── en -└── fr -``` - -**All URLs (i.e `.Permalink` etc.) will be generated from that root. So the English home page above will have its `.Permalink` set to `https://example.com/`.** - -When you run `hugo server` we will start multiple HTTP servers. You will typlically see something like this in the console: - -```bash -Web Server is available at 127.0.0.1:1313 (bind address 127.0.0.1) -Web Server is available at 127.0.0.1:1314 (bind address 127.0.0.1) -Press Ctrl+C to stop -``` - -Live reload and `--navigateToChanged` between the servers work as expected. - -### Taxonomies and Blackfriday - -Taxonomies and [Blackfriday configuration][config] can also be set per language: - - -{{< code-toggle file="config" >}} -[Taxonomies] -tag = "tags" - -[blackfriday] -angledQuotes = true -hrefTargetBlank = true - -[languages] -[languages.en] -weight = 1 -title = "English" -[languages.en.blackfriday] -angledQuotes = false - -[languages.fr] -weight = 2 -title = "Français" -[languages.fr.Taxonomies] -plaque = "plaques" -{{}} - -## Translate Your Content +## Translate your content There are two ways to manage your content translations. Both ensure each page is assigned a language and is linked to its counterpart translations. -### Translation by filename +### Translation by file name Considering the following example: 1. `/content/about.en.md` -2. `/content/about.fr.md` +1. `/content/about.fr.md` The first file is assigned the English language and is linked to the second. The second file is assigned the French language and is linked to the first. -Their language is __assigned__ according to the language code added as a __suffix to the filename__. +Their language is __assigned__ according to the language code added as a __suffix to the file name__. -By having the same **path and base filename**, the content pieces are __linked__ together as translated pages. +By having the same **path and base file name**, the content pieces are __linked__ together as translated pages. -{{< note >}} -If a file has no language code, it will be assigned the default language. -{{}} +> [!note] +> If a file has no language code, it will be assigned the default language. ### Translation by content directory -This system uses different content directories for each of the languages. Each language's content directory is set using the `contentDir` param. - -{{< code-toggle file="config" >}} +This system uses different content directories for each of the languages. Each language's `content` directory is set using the `contentDir` parameter. +{{< code-toggle file=hugo >}} languages: en: weight: 10 @@ -194,267 +46,384 @@ languages: weight: 20 languageName: "Français" contentDir: "content/french" - {{< /code-toggle >}} The value of `contentDir` can be any valid path -- even absolute path references. The only restriction is that the content directories cannot overlap. -Considering the following example in conjunction with the configuration above: +Considering the following example in conjunction with the configuration above: 1. `/content/english/about.md` -2. `/content/french/about.md` +1. `/content/french/about.md` The first file is assigned the English language and is linked to the second. The second file is assigned the French language and is linked to the first. -Their language is __assigned__ according to the content directory they are __placed__ in. +Their language is __assigned__ according to the `content` directory they are __placed__ in. -By having the same **path and basename** (relative to their language content directory), the content pieces are __linked__ together as translated pages. +By having the same **path and basename** (relative to their language `content` directory), the content pieces are __linked__ together as translated pages. -### Bypassing default linking. +### Bypassing default linking Any pages sharing the same `translationKey` set in front matter will be linked as translated pages regardless of basename or location. Considering the following example: 1. `/content/about-us.en.md` -2. `/content/om.nn.md` -3. `/content/presentation/a-propos.fr.md` +1. `/content/om.nn.md` +1. `/content/presentation/a-propos.fr.md` -```yaml -# set in all three pages +{{< code-toggle file=hugo >}} translationKey: "about" -``` - -By setting the `translationKey` front matter param to `about` in all three pages, they will be __linked__ as translated pages. +{{< /code-toggle >}} +By setting the `translationKey` front matter parameter to `about` in all three pages, they will be __linked__ as translated pages. ### Localizing permalinks -Because paths and filenames are used to handle linking, all translated pages will share the same URL (apart from the language subdirectory). +Because paths and file names are used to handle linking, all translated pages will share the same URL (apart from the language subdirectory). -To localize the URLs, the [`slug`]({{< ref "/content-management/organization/index.md#slug" >}}) or [`url`]({{< ref "/content-management/organization/index.md#url" >}}) front matter param can be set in any of the non-default language file. +To localize URLs: -For example, a French translation (`content/about.fr.md`) can have its own localized slug. +- For a regular page, set either [`slug`] or [`url`] in front matter +- For a section page, set [`url`] in front matter -{{< code-toggle >}} -Title: A Propos +For example, a French translation can have its own localized slug. + +{{< code-toggle file=content/about.fr.md fm=true >}} +title: A Propos slug: "a-propos" {{< /code-toggle >}} +At render, Hugo will build both `/about/` and `/fr/a-propos/` without affecting the translation link. -At render, Hugo will build both `/about/` and `/fr/a-propos/` while maintaining their translation linking. +### Page bundles -{{% note %}} -If using `url`, remember to include the language part as well: `/fr/compagnie/a-propos/`. -{{%/ note %}} - -### Page Bundles - -To avoid the burden of having to duplicate files, each Page Bundle inherits the resources of its linked translated pages' bundles except for the content files (markdown files, html files etc...). +To avoid the burden of having to duplicate files, each Page Bundle inherits the resources of its linked translated pages' bundles except for the content files (Markdown files, HTML files etc.). Therefore, from within a template, the page will have access to the files from all linked pages' bundles. If, across the linked bundles, two or more files share the same basename, only one will be included and chosen as follows: -* File from current language bundle, if present. -* First file found across bundles by order of language `Weight`. +- File from current language bundle, if present. +- First file found across bundles by order of language `Weight`. -{{% note %}} -Page Bundle resources follow the same language assignment logic as content files, both by filename (`image.jpg`, `image.fr.jpg`) and by directory (`english/about/header.jpg`, `french/about/header.jpg`). -{{%/ note %}} +> [!note] +> Page Bundle resources follow the same language assignment logic as content files, both by file name (`image.jpg`, `image.fr.jpg`) and by directory (`english/about/header.jpg`, `french/about/header.jpg`). -## Reference the Translated Content +## Reference translated content To create a list of links to translated content, use a template similar to the following: -{{< code file="layouts/partials/i18nlist.html" >}} +```go-html-template {file="layouts/partials/i18nlist.html"} {{ if .IsTranslated }}

    {{ i18n "translations" }}

    {{ end }} -{{< /code >}} +``` -The above can be put in a `partial` (i.e., inside `layouts/partials/`) and included in any template, whether a [single content page][contenttemplate] or the [homepage][]. It will not print anything if there are no translations for a given page. +The above can be put in a `partial` (i.e., inside `layouts/partials/`) and included in any template. It will not print anything if there are no translations for a given page. The above also uses the [`i18n` function][i18func] described in the next section. -### List All Available Languages +### List all available languages `.AllTranslations` on a `Page` can be used to list all translations, including the page itself. On the home page it can be used to build a language navigator: - -{{< code file="layouts/partials/allLanguages.html" >}} +```go-html-template {file="layouts/partials/allLanguages.html"} -{{< /code >}} - -## Translation of Strings - -Hugo uses [go-i18n][] to support string translations. [See the project's source repository][go-i18n-source] to find tools that will help you manage your translation workflows. - -Translations are collected from the `themes//i18n/` folder (built into the theme), as well as translations present in `i18n/` at the root of your project. In the `i18n`, the translations will be merged and take precedence over what is in the theme folder. Language files should be named according to [RFC 5646][] with names such as `en-US.toml`, `fr.toml`, etc. - -{{% note %}} -From **Hugo 0.31** you no longer need to use a valid language code. It can be anything. - -See https://github.com/gohugoio/hugo/issues/3564 - -{{% /note %}} - -From within your templates, use the `i18n` function like this: - -``` -{{ i18n "home" }} ``` -This uses a definition like this one in `i18n/en-US.toml`: +## Translation of strings -``` -[home] -other = "Home" +See the [`lang.Translate`] template function. + +## Localization + +The following localization examples assume your site's primary language is English, with translations to French and German. + +{{< code-toggle file=hugo >}} +defaultContentLanguage = 'en' + +[languages] +[languages.en] +contentDir = 'content/en' +languageName = 'English' +weight = 1 +[languages.fr] +contentDir = 'content/fr' +languageName = 'Français' +weight = 2 +[languages.de] +contentDir = 'content/de' +languageName = 'Deutsch' +weight = 3 + +{{< /code-toggle >}} + +### Dates + +With this front matter: + +{{< code-toggle file=hugo >}} +date = 2021-11-03T12:34:56+01:00 +{{< /code-toggle >}} + +And this template code: + +```go-html-template +{{ .Date | time.Format ":date_full" }} ``` -Often you will want to use to the page variables in the translations strings. To do that, pass on the "." context when calling `i18n`: +The rendered page displays: -``` -{{ i18n "wordCount" . }} +Language|Value +:--|:-- +English|Wednesday, November 3, 2021 +Français|mercredi 3 novembre 2021 +Deutsch|Mittwoch, 3. November 2021 + +See [`time.Format`] for details. + +### Currency + +With this template code: + +```go-html-template +{{ 512.5032 | lang.FormatCurrency 2 "USD" }} ``` -This uses a definition like this one in `i18n/en-US.toml`: +The rendered page displays: -``` -[wordCount] -other = "This article has {{ .WordCount }} words." -``` -An example of singular and plural form: +Language|Value +:--|:-- +English|$512.50 +Français|512,50 $US +Deutsch|512,50 $ -``` -[readingTime] -one = "One minute to read" -other = "{{.Count}} minutes to read" -``` -And then in the template: +See [lang.FormatCurrency] and [lang.FormatAccounting] for details. -``` -{{ i18n "readingTime" .ReadingTime }} +### Numbers + +With this template code: + +```go-html-template +{{ 512.5032 | lang.FormatNumber 2 }} ``` -## Customize Dates +The rendered page displays: -At the time of this writing, Go does not yet have support for internationalized locales for dates, but if you do some work, you can simulate it. For example, if you want to use French month names, you can add a data file like ``data/mois.yaml`` with this content: +Language|Value +:--|:-- +English|512.50 +Français|512,50 +Deutsch|512,50 -~~~yaml -1: "janvier" -2: "février" -3: "mars" -4: "avril" -5: "mai" -6: "juin" -7: "juillet" -8: "août" -9: "septembre" -10: "octobre" -11: "novembre" -12: "décembre" -~~~ +See [lang.FormatNumber] and [lang.FormatNumberCustom] for details. -...then index the non-English date names in your templates like so: +### Percentages -~~~html - -~~~ +With this template code: -This technique extracts the day, month and year by specifying ``.Date.Day``, ``.Date.Month``, and ``.Date.Year``, and uses the month number as a key, when indexing the month name data file. +```go-html-template +{{ 512.5032 | lang.FormatPercent 2 }} +``` + +The rendered page displays: + +Language|Value +:--|:-- +English|512.50% +Français|512,50 % +Deutsch|512,50 % + +See [lang.FormatPercent] for details. ## Menus -You can define your menus for each language independently. Creating multilingual menus works just like [creating regular menus][menus], except they're defined in language-specific blocks in the configuration file: +Localization of menu entries depends on how you define them: -``` -defaultContentLanguage = "en" +- When you define menu entries [automatically] using the section pages menu, you must use translation tables to localize each entry. +- When you define menu entries [in front matter], they are already localized based on the front matter itself. If the front matter values are insufficient, use translation tables to localize each entry. +- When you define menu entries [in site configuration], you must create language-specific menu entries under each language key. If the names of the menu entries are insufficient, use translation tables to localize each entry. + +### Create language-specific menu entries + +#### Method 1 -- Use a single configuration file + +For a simple menu with a small number of entries, use a single configuration file. For example: + +{{< code-toggle file=hugo >}} +[languages.de] +languageCode = 'de-DE' +languageName = 'Deutsch' +weight = 1 + +[[languages.de.menus.main]] +name = 'Produkte' +pageRef = '/products' +weight = 10 + +[[languages.de.menus.main]] +name = 'Leistungen' +pageRef = '/services' +weight = 20 [languages.en] -weight = 0 -languageName = "English" +languageCode = 'en-US' +languageName = 'English' +weight = 2 -[[languages.en.menu.main]] -url = "/" -name = "Home" -weight = 0 - - -[languages.de] +[[languages.en.menus.main]] +name = 'Products' +pageRef = '/products' weight = 10 -languageName = "Deutsch" -[[languages.de.menu.main]] -url = "/" -name = "Startseite" -weight = 0 +[[languages.en.menus.main]] +name = 'Services' +pageRef = '/services' +weight = 20 +{{< /code-toggle >}} + +#### Method 2 -- Use a configuration directory + +With a more complex menu structure, create a [configuration directory] and split the menu entries into multiple files, one file per language. For example: + +```text +config/ +└── _default/ + ├── menus.de.toml + ├── menus.en.toml + └── hugo.toml ``` -The rendering of the main navigation works as usual. `.Site.Menus` will just contain the menu in the current language. Note that `absLangURL` below will link to the correct locale of your website. Without it, menu entries in all languages would link to the English version, since it's the default content language that resides in the root directory. +{{< code-toggle file=config/_default/menus.de >}} +[[main]] +name = 'Produkte' +pageRef = '/products' +weight = 10 +[[main]] +name = 'Leistungen' +pageRef = '/services' +weight = 20 +{{< /code-toggle >}} -``` -
      - {{- $currentPage := . -}} - {{ range .Site.Menus.main -}} -
    • - {{ .Name }} -
    • - {{- end }} -
    +{{< code-toggle file=config/_default/menus.en >}} +[[main]] +name = 'Products' +pageRef = '/products' +weight = 10 +[[main]] +name = 'Services' +pageRef = '/services' +weight = 20 +{{< /code-toggle >}} +### Use translation tables + +When rendering the text that appears in menu each entry, the [example menu template] does this: + +```go-html-template +{{ or (T .Identifier) .Name | safeHTML }} ``` -## Missing Translations +It queries the translation table for the current language using the menu entry's `identifier` and returns the translated string. If the translation table does not exist, or if the `identifier` key is not present in the translation table, it falls back to `name`. + +The `identifier` depends on how you define menu entries: + +- If you define the menu entry [automatically] using the section pages menu, the `identifier` is the page's `.Section`. +- If you define the menu entry [in site configuration] or [in front matter], set the `identifier` property to the desired value. + +For example, if you define menu entries in site configuration: + +{{< code-toggle file=hugo >}} +[[menus.main]] + identifier = 'products' + name = 'Products' + pageRef = '/products' + weight = 10 +[[menus.main]] + identifier = 'services' + name = 'Services' + pageRef = '/services' + weight = 20 +{{< / code-toggle >}} + +Create corresponding entries in the translation tables: + +{{< code-toggle file=i18n/de >}} +products = 'Produkte' +services = 'Leistungen' +{{< / code-toggle >}} + +## Missing translations If a string does not have a translation for the current language, Hugo will use the value from the default language. If no default value is set, an empty string will be shown. While translating a Hugo website, it can be handy to have a visual indicator of missing translations. The [`enableMissingTranslationPlaceholders` configuration option][config] will flag all untranslated strings with the placeholder `[i18n] identifier`, where `identifier` is the id of the missing translation. -{{% note %}} -Hugo will generate your website with these missing translation placeholders. It might not be suitable for production environments. -{{% /note %}} +> [!note] +> Hugo will generate your website with these missing translation placeholders. It might not be suitable for production environments. -For merging of content from other languages (i.e. missing content translations), see [lang.Merge](/functions/lang.merge/). +For merging of content from other languages (i.e. missing content translations), see [lang.Merge]. -To track down missing translation strings, run Hugo with the `--i18n-warnings` flag: +To track down missing translation strings, run Hugo with the `--printI18nWarnings` flag: -``` - hugo --i18n-warnings | grep i18n +```sh +hugo --printI18nWarnings | grep i18n i18n|MISSING_TRANSLATION|en|wordCount ``` -## Multilingual Themes support +## Multilingual themes support To support Multilingual mode in your themes, some considerations must be taken for the URLs in the templates. If there is more than one language, URLs must meet the following criteria: -* Come from the built-in `.Permalink` or `.RelPermalink` -* Be constructed with the [`relLangURL` template function][rellangurl] or the [`absLangURL` template function][abslangurl] **OR** be prefixed with `{{ .LanguagePrefix }}` +- Come from the built-in `.Permalink` or `.RelPermalink` +- Be constructed with the [`relLangURL`] or [`absLangURL`] template function, or be prefixed with `{{ .LanguagePrefix }}` -If there is more than one language defined, the `LanguagePrefix` variable will equal `/en` (or whatever your `CurrentLanguage` is). If not enabled, it will be an empty string (and is therefore harmless for single-language Hugo websites). +If there is more than one language defined, the `LanguagePrefix` method will return `/en` (or whatever the current language is). If not enabled, it will be an empty string (and is therefore harmless for single-language Hugo websites). -[abslangurl]: /functions/abslangurl -[config]: /getting-started/configuration/ -[contenttemplate]: /templates/single-page-templates/ -[go-i18n-source]: https://github.com/nicksnyder/go-i18n -[go-i18n]: https://github.com/nicksnyder/go-i18n -[homepage]: /templates/homepage/ -[i18func]: /functions/i18n/ -[menus]: /content-management/menus/ -[rellangurl]: /functions/rellangurl -[RFC 5646]: https://tools.ietf.org/html/rfc5646 -[singles]: /templates/single-page-templates/ +## Generate multilingual content with `hugo new content` + +If you organize content with translations in the same directory: + +```sh +hugo new content post/test.en.md +hugo new content post/test.de.md +``` + +If you organize content with translations in different directories: + +```sh +hugo new content content/en/post/test.md +hugo new content content/de/post/test.md +``` + +[`absLangURL`]: /functions/urls/abslangurl/ +[`lang.Translate`]: /functions/lang/translate +[`relLangURL`]: /functions/urls/rellangurl/ +[`slug`]: /content-management/urls/#slug +[`time.Format`]: /functions/time/format/ +[`url`]: /content-management/urls/#url +[automatically]: /content-management/menus/#define-automatically +[config]: /configuration/ +[configuration directory]: /configuration/introduction/#configuration-directory +[example menu template]: /templates/menu/#example +[i18func]: /functions/lang/translate/ +[in front matter]: /content-management/menus/#define-in-front-matter +[in site configuration]: /content-management/menus/#define-in-site-configuration +[lang.FormatAccounting]: /functions/lang/formataccounting/ +[lang.FormatCurrency]: /functions/lang/formatcurrency/ +[lang.FormatNumber]: /functions/lang/formatnumber/ +[lang.FormatNumberCustom]: /functions/lang/formatnumbercustom/ +[lang.FormatPercent]: /functions/lang/formatpercent/ +[lang.Merge]: /functions/lang/merge/ diff --git a/docs/content/en/content-management/organization/1-featured-content-bundles.png b/docs/content/en/content-management/organization/1-featured-content-bundles.png deleted file mode 100644 index 501e671e2..000000000 Binary files a/docs/content/en/content-management/organization/1-featured-content-bundles.png and /dev/null differ diff --git a/docs/content/en/content-management/organization/index.md b/docs/content/en/content-management/organization/index.md index 9ed2dbff3..a7682bfad 100644 --- a/docs/content/en/content-management/organization/index.md +++ b/docs/content/en/content-management/organization/index.md @@ -1,91 +1,90 @@ --- -title: Content Organization -linktitle: Organization +title: Content organization +linkTitle: Organization description: Hugo assumes that the same structure that works to organize your source content is used to organize the rendered site. -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2017-02-01 -categories: [content management,fundamentals] -keywords: [sections,content,organization,bundle,resources] -menu: - docs: - parent: "content-management" - weight: 10 -weight: 10 #rem -draft: false +categories: [] +keywords: [] aliases: [/content/sections/] -toc: true --- -## Page Bundles +## Page bundles Hugo `0.32` announced page-relative images and other resources packaged into `Page Bundles`. -These terms are connected, and you also need to read about [Page Resources]({{< relref "/content-management/page-resources" >}}) and [Image Processing]({{< relref "/content-management/image-processing" >}}) to get the full picture. +These terms are connected, and you also need to read about [Page Resources](/content-management/page-resources) and [Image Processing](/content-management/image-processing) to get the full picture. -{{% imgproc 1-featured Resize "300x" %}} -The illustration shows 3 bundles. Note that the home page bundle cannot contain other content pages, but other files (images etc.) are fine. -{{% /imgproc %}} +```text +content/ +├── blog/ +│ ├── hugo-is-cool/ +│ │ ├── images/ +│ │ │ ├── funnier-cat.jpg +│ │ │ └── funny-cat.jpg +│ │ ├── cats-info.md +│ │ └── index.md +│ ├── posts/ +│ │ ├── post1.md +│ │ └── post2.md +│ ├── 1-landscape.jpg +│ ├── 2-sunset.jpg +│ ├── _index.md +│ ├── content-1.md +│ └── content-2.md +├── 1-logo.png +└── _index.md +``` +The file tree above shows three bundles. Note that the home page bundle cannot contain other content pages, although other files (images etc.) are allowed. -{{% note %}} -The bundle documentation is **work in progress**. We will publish more comprehensive docs about this soon. -{{% /note %}} - - -# Organization of Content Source - +## Organization of content source In Hugo, your content should be organized in a manner that reflects the rendered website. -While Hugo supports content nested at any level, the top levels (i.e. `content/`) are special in Hugo and are considered the content type used to determine layouts etc. To read more about sections, including how to nest them, see [sections][]. +While Hugo supports content nested at any level, the top levels (i.e. `content/`) are special in Hugo and are considered the content type used to determine layouts etc. To read more about sections, including how to nest them, see [sections]. -Without any additional configuration, the following will just work: +Without any additional configuration, the following will automatically work: -``` +```txt . └── content └── about - | └── index.md // <- https://example.com/about/ + | └── index.md // <- https://example.org/about/ ├── posts - | ├── firstpost.md // <- https://example.com/posts/firstpost/ + | ├── firstpost.md // <- https://example.org/posts/firstpost/ | ├── happy - | | └── ness.md // <- https://example.com/posts/happy/ness/ - | └── secondpost.md // <- https://example.com/posts/secondpost/ + | | └── ness.md // <- https://example.org/posts/happy/ness/ + | └── secondpost.md // <- https://example.org/posts/secondpost/ └── quote - ├── first.md // <- https://example.com/quote/first/ - └── second.md // <- https://example.com/quote/second/ + ├── first.md // <- https://example.org/quote/first/ + └── second.md // <- https://example.org/quote/second/ ``` -## Path Breakdown in Hugo +## Path breakdown in Hugo +The following demonstrates the relationships between your content organization and the output URL structure for your Hugo website when it renders. These examples assume you are [using pretty URLs][pretty], which is the default behavior for Hugo. The examples also assume a key-value of `baseURL = "https://example.org/"` in your [site's configuration file][config]. -The following demonstrates the relationships between your content organization and the output URL structure for your Hugo website when it renders. These examples assume you are [using pretty URLs][pretty], which is the default behavior for Hugo. The examples also assume a key-value of `baseURL = "https://example.com"` in your [site's configuration file][config]. +### Index pages: `_index.md` -### Index Pages: `_index.md` +`_index.md` has a special role in Hugo. It allows you to add front matter and content to `home`, `section`, `taxonomy`, and `term` pages. -`_index.md` has a special role in Hugo. It allows you to add front matter and content to your [list templates][lists]. These templates include those for [section templates][], [taxonomy templates][], [taxonomy terms templates][], and your [homepage template][]. +> [!note] +> Access the content and metadata within an `_index.md` file by invoking the `GetPage` method on a `Site` or `Page` object. -{{% note %}} -**Tip:** You can get a reference to the content and metadata in `_index.md` using the [`.Site.GetPage` function](/functions/getpage/). -{{% /note %}} +You can create one `_index.md` for your home page and one in each of your content sections, taxonomies, and terms. The following shows typical placement of an `_index.md` that would contain content and front matter for a `posts` section list page on a Hugo website: -You can keep one `_index.md` for your homepage and one in each of your content sections, taxonomies, and taxonomy terms. The following shows typical placement of an `_index.md` that would contain content and front matter for a `posts` section list page on a Hugo website: - - -``` +```txt . url . ⊢--^-⊣ . path slug . ⊢--^-⊣⊢---^---⊣ -. filepath +. file path . ⊢------^------⊣ content/posts/_index.md ``` At build, this will output to the following destination with the associated values: -``` +```txt url ("/posts/") ⊢-^-⊣ @@ -93,18 +92,16 @@ At build, this will output to the following destination with the associated valu ⊢--------^---------⊣⊢-^-⊣ permalink ⊢----------^-------------⊣ -https://example.com/posts/index.html +https://example.org/posts/index.html ``` -The [sections][] can be nested as deeply as you need. The important part to understand is, that to make the section tree fully navigational, at least the lower-most section needs a content file. (i.e. `_index.md`). +The [sections] can be nested as deeply as you want. The important thing to understand is that to make the section tree fully navigational, at least the lower-most section must include a content file. (i.e. `_index.md`). +### Single pages in sections -### Single Pages in Sections +Single content files in each of your sections will be rendered by a [single template]. Here is an example of a single `post` within `posts`: -Single content files in each of your sections are going to be rendered as [single page templates][singles]. Here is an example of a single `post` within `posts`: - - -``` +```txt path ("posts/my-first-hugo-post.md") . ⊢-----------^------------⊣ . section slug @@ -112,9 +109,9 @@ Single content files in each of your sections are going to be rendered as [singl content/posts/my-first-hugo-post.md ``` -At the time Hugo builds your site, the content will be output to the following destination: +When Hugo builds your site, the content will be output to the following destination: -``` +```txt url ("/posts/my-first-hugo-post/") ⊢------------^----------⊣ @@ -122,119 +119,33 @@ At the time Hugo builds your site, the content will be output to the following d ⊢--------^--------⊣⊢-^--⊣⊢-------^---------⊣ permalink ⊢--------------------^---------------------⊣ -https://example.com/posts/my-first-hugo-post/index.html +https://example.org/posts/my-first-hugo-post/index.html ``` +## Paths explained -## Paths Explained - -The following concepts will provide more insight into the relationship between your project's organization and the default behaviors of Hugo when building the output website. +The following concepts provide more insight into the relationship between your project's organization and the default Hugo behavior when building output for the website. ### `section` -A default content type is determined by a piece of content's section. `section` is determined by the location within the project's `content` directory. `section` *cannot* be specified or overridden in front matter. +A default content type is determined by the section in which a content item is stored. `section` is determined by the location within the project's `content` directory. `section` *cannot* be specified or overridden in front matter. ### `slug` -A content's `slug` is either `name.extension` or `name/`. The value for `slug` is determined by - -* the name of the content file (e.g., `lollapalooza.md`) OR -* front matter overrides +The `slug` is the last segment of the URL path, defined by the file name and optionally overridden by a `slug` value in front matter. See [URL Management](/content-management/urls/#slug) for details. ### `path` -A content's `path` is determined by the section's path to the file. The file `path` +A content's `path` is determined by the section's path to the file. The file `path`: -* is based on the path to the content's location AND -* does not include the slug +- Is based on the path to the content's location AND +- Does not include the slug ### `url` -The `url` is the relative URL for the piece of content. The `url` +The `url` is the entire URL path, defined by the file path and optionally overridden by a `url` value in front matter. See [URL Management](/content-management/urls/#slug) for details. -* is based on the content's location within the directory structure OR -* is defined in front matter and *overrides all the above* - -## Override Destination Paths via Front Matter - -Hugo believes that you organize your content with a purpose. The same structure that works to organize your source content is used to organize the rendered site. As displayed above, the organization of the source content will be mirrored in the destination. - -There are times where you may need more control over your content. In these cases, there are fields that can be specified in the front matter to determine the destination of a specific piece of content. - -The following items are defined in this order for a specific reason: items explained further down in the list will override earlier items, and not all of these items can be defined in front matter: - -### `filename` - -This isn't in the front matter, but is the actual name of the file minus the extension. This will be the name of the file in the destination (e.g., `content/posts/my-post.md` becomes `example.com/posts/my-post/`). - -### `slug` - -When defined in the front matter, the `slug` can take the place of the filename for the destination. - -{{< code file="content/posts/old-post.md" >}} ---- -title: New Post -slug: "new-post" ---- -{{< /code >}} - -This will render to the following destination according to Hugo's default behavior: - -``` -example.com/posts/new-post/ -``` - -### `section` - -`section` is determined by a content's location on disk and *cannot* be specified in the front matter. See [sections][] for more information. - -### `type` - -A content's `type` is also determined by its location on disk but, unlike `section`, it *can* be specified in the front matter. See [types][]. This can come in especially handy when you want a piece of content to render using a different layout. In the following example, you can create a layout at `layouts/new/mylayout.html` that Hugo will use to render this piece of content, even in the midst of many other posts. - -{{< code file="content/posts/my-post.md" >}} ---- -title: My Post -type: new -layout: mylayout ---- -{{< /code >}} - - - - - -### `url` - -A complete URL can be provided. This will override all the above as it pertains to the end destination. This must be the path from the baseURL (starting with a `/`). `url` will be used exactly as it provided in the front matter and will ignore the `--uglyURLs` setting in your site configuration: - -{{< code file="content/posts/old-url.md" >}} ---- -title: Old URL -url: /blog/new-url/ ---- -{{< /code >}} - -Assuming your `baseURL` is [configured][config] to `https://example.com`, the addition of `url` to the front matter will make `old-url.md` render to the following destination: - -``` -https://example.com/blog/new-url/ -``` - -You can see more information on how to control output paths in [URL Management][urls]. - -[config]: /getting-started/configuration/ -[formats]: /content-management/formats/ -[front matter]: /content-management/front-matter/ -[getpage]: /functions/getpage/ -[homepage template]: /templates/homepage/ -[homepage]: /templates/homepage/ -[lists]: /templates/lists/ -[pretty]: /content-management/urls/#pretty-urls -[section templates]: /templates/section-templates/ +[config]: /configuration/ +[pretty]: /content-management/urls/#appearance [sections]: /content-management/sections/ -[singles]: /templates/single-page-templates/ -[taxonomy templates]: /templates/taxonomy-templates/ -[taxonomy terms templates]: /templates/taxonomy-templates/ -[types]: /content-management/types/ -[urls]: /content-management/urls/ +[single template]: /templates/types/#single diff --git a/docs/content/en/content-management/page-bundles.md b/docs/content/en/content-management/page-bundles.md index 0d665759c..f6a5cf771 100644 --- a/docs/content/en/content-management/page-bundles.md +++ b/docs/content/en/content-management/page-bundles.md @@ -1,185 +1,145 @@ --- -title : "Page Bundles" -description : "Content organization using Page Bundles" -date : 2018-01-24T13:09:00-05:00 -lastmod : 2018-01-28T22:26:40-05:00 -linktitle : "Page Bundles" -keywords : ["page", "bundle", "leaf", "branch"] -categories : ["content management"] -toc : true -menu : - docs: - identifier : "page-bundles" - parent : "content-management" - weight : 11 +title: Page bundles +description: Use page bundles to logically associate one or more resources with content. +categories: [] +keywords: [] --- -Page Bundles are a way to group [Page Resources](/content-management/page-resources/). +## Introduction -A Page Bundle can be one of: +A page bundle is a directory that encapsulates both content and associated resources. -- Leaf Bundle (leaf means it has no children) -- Branch Bundle (home page, section, taxonomy terms, taxonomy list) +By way of example, this site has an "about" page and a "privacy" page: -| | Leaf Bundle | Branch Bundle | -|-------------------------------------|----------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Usage | Collection of content and attachments for single pages | Collection of attachments for section pages (home page, section, taxonomy terms, taxonomy list) | -| Index file name | `index.md` [^fn:1] | `_index.md` [^fn:1] | -| Allowed Resources | Page and non-page (like images, pdf, etc.) types | Only non-page (like images, pdf, etc.) types | -| Where can the Resources live? | At any directory level within the leaf bundle directory. | Only in the directory level **of** the branch bundle directory i.e. the directory containing the `_index.md` ([ref](https://discourse.gohugo.io/t/question-about-content-folder-structure/11822/4?u=kaushalmodi)). | -| Layout type | `single` | `list` | -| Nesting | Does not allow nesting of more bundles under it | Allows nesting of leaf or branch bundles under it | -| Example | `content/posts/my-post/index.md` | `content/posts/_index.md` | -| Content from non-index page files... | Accessed only as page resources | Accessed only as regular pages | +```text +content/ +├── about/ +│ ├── index.md +│ └── welcome.jpg +└── privacy.md +``` +The "about" page is a page bundle. It logically associates a resource with content by bundling them together. Resources within a page bundle are [page resources], accessible with the [`Resources`] method on the `Page` object. -## Leaf Bundles {#leaf-bundles} +Page bundles are either _leaf bundles_ or _branch bundles_. -A _Leaf Bundle_ is a directory at any hierarchy within the `content/` -directory, that contains an **`index.md`** file. +leaf bundle +: A _leaf bundle_ is a directory that contains an `index.md` file and zero or more resources. Analogous to a physical leaf, a leaf bundle is at the end of a branch. It has no descendants. -### Examples of Leaf Bundle organization {#examples-of-leaf-bundle-organization} +branch bundle +: A _branch bundle_ is a directory that contains an `_index.md` file and zero or more resources. Analogous to a physical branch, a branch bundle may have descendants including leaf bundles and other branch bundles. Top-level directories with or without `_index.md` files are also branch bundles. This includes the home page. + +> [!note] +> In the definitions above and the examples below, the extension of the index file depends on the [content format](g). For example, use `index.md` for Markdown content, `index.html` for HTML content, `index.adoc` for AsciiDoc content, etc. + +## Comparison + +Page bundle characteristics vary by bundle type. + +| | Leaf bundle | Branch bundle | +|---------------------|---------------------------------------------------------|---------------------------------------------------------| +| Index file | `index.md` | `_index.md` | +| Example | `content/about/index.md` | `content/posts/_index.md ` | +| [Page kinds](g) | `page` | `home`, `section`, `taxonomy`, or `term` | +| Template types | [single] | [home], [section], [taxonomy], or [term] | +| Descendant pages | None | Zero or more | +| Resource location | Adjacent to the index file or in a nested subdirectory | Same as a leaf bundles, but excludes descendant bundles | +| [Resource types](g) | `page`, `image`, `video`, etc. | all but `page` | + +Files with [resource type](g) `page` include content written in Markdown, HTML, AsciiDoc, Pandoc, reStructuredText, and Emacs Org Mode. In a leaf bundle, excluding the index file, these files are only accessible as page resources. In a branch bundle, these files are only accessible as content pages. + +## Leaf bundles + +A _leaf bundle_ is a directory that contains an `index.md` file and zero or more resources. Analogous to a physical leaf, a leaf bundle is at the end of a branch. It has no descendants. ```text content/ ├── about -│ ├── index.md +│ └── index.md ├── posts │ ├── my-post -│ │ ├── content1.md -│ │ ├── content2.md -│ │ ├── image1.jpg -│ │ ├── image2.png +│ │ ├── content-1.md +│ │ ├── content-2.md +│ │ ├── image-1.jpg +│ │ ├── image-2.png │ │ └── index.md │ └── my-other-post -│    └── index.md -│ +│ └── index.md └── another-section - ├── .. -    └── not-a-leaf-bundle - ├── .. -    └── another-leaf-bundle -    └── index.md + ├── foo.md + └── not-a-leaf-bundle + ├── bar.md + └── another-leaf-bundle + └── index.md ``` -In the above example `content/` directory, there are four leaf -bundles: +There are four leaf bundles in the example above: about -: This leaf bundle is at the root level (directly under - `content` directory) and has only the `index.md`. +: This leaf bundle does not contain any page resources. my-post -: This leaf bundle has the `index.md`, two other content - Markdown files and two image files. +: This leaf bundle contains an index file, two resources of [resource type](g) `page`, and two resources of resource type `image`. + + - content-1, content-2 + + These are resources of resource type `page`, accessible via the [`Resources`] method on the `Page` object. Hugo will not render these as individual pages. + + - image-1, image-2 + + These are resources of resource type `image`, accessible via the `Resources` method on the `Page` object my-other-post -: This leaf bundle has only the `index.md`. +: This leaf bundle does not contain any page resources. another-leaf-bundle -: This leaf bundle is nested under couple of - directories. This bundle also has only the `index.md`. +: This leaf bundle does not contain any page resources. -{{% note %}} -The hierarchy depth at which a leaf bundle is created does not matter, -as long as it is not inside another **leaf** bundle. -{{% /note %}} +> [!note] +> Create leaf bundles at any depth within the `content` directory, but a leaf bundle may not contain another bundle. Leaf bundles do not have descendants. +## Branch bundles -### Headless Bundle {#headless-bundle} - -A headless bundle is a bundle that is configured to not get published -anywhere: - -- It will have no `Permalink` and no rendered HTML in `public/`. -- It will not be part of `.Site.RegularPages`, etc. - -But you can get it by `.Site.GetPage`. Here is an example: - -```go-html-template -{{ $headless := .Site.GetPage "/some-headless-bundle" }} -{{ $reusablePages := $headless.Resources.Match "author*" }} -

    Authors

    -{{ range $reusablePages }} -

    {{ .Title }}

    - {{ .Content }} -{{ end }} -``` - -_In this example, we are assuming the `some-headless-bundle` to be a headless - bundle containing one or more **page** resources whose `.Name` matches - `"author*"`._ - -Explanation of the above example: - -1. Get the `some-headless-bundle` Page "object". -2. Collect a *slice* of resources in this *Page Bundle* that matches - `"author*"` using `.Resources.Match`. -3. Loop through that *slice* of nested pages, and output their `.Title` and - `.Content`. - ---- - -A leaf bundle can be made headless by adding below in the Front Matter -(in the `index.md`): - -```toml -headless = true -``` - -{{% note %}} -Only leaf bundles can be made headless. -{{% /note %}} - -There are many use cases of such headless page bundles: - -- Shared media galleries -- Reusable page content "snippets" - - -## Branch Bundles {#branch-bundles} - -A _Branch Bundle_ is any directory at any hierarchy within the -`content/` directory, that contains at least an **`_index.md`** file. - -This `_index.md` can also be directly under the `content/` directory. - -{{% note %}} -Here `md` (markdown) is used just as an example. You can use any file -type as a content resource as long as it is a content type recognized by Hugo. -{{% /note %}} - - -### Examples of Branch Bundle organization {#examples-of-branch-bundle-organization} +A _branch bundle_ is a directory that contains an `_index.md` file and zero or more resources. Analogous to a physical branch, a branch bundle may have descendants including leaf bundles and other branch bundles. Top-level directories with or without `_index.md` files are also branch bundles. This includes the home page. ```text content/ -├── branch-bundle-1 -│   ├── branch-content1.md -│   ├── branch-content2.md -│   ├── image1.jpg -│   ├── image2.png -│   └── _index.md -└── branch-bundle-2 - ├── _index.md - └── a-leaf-bundle - └── index.md +├── branch-bundle-1/ +│ ├── _index.md +│ ├── content-1.md +│ ├── content-2.md +│ ├── image-1.jpg +│ └── image-2.png +├── branch-bundle-2/ +│ ├── a-leaf-bundle/ +│ │ └── index.md +│ └── _index.md +└── _index.md ``` -In the above example `content/` directory, there are two branch -bundles (and a leaf bundle): +There are three branch bundles in the example above: -`branch-bundle-1` -: This branch bundle has the `_index.md`, two - other content Markdown files and two image files. +home page +: This branch bundle contains an index file, two descendant branch bundles, and no resources. -`branch-bundle-2` -: This branch bundle has the `_index.md` and a - nested leaf bundle. +branch-bundle-1 +: This branch bundle contains an index file, two resources of [resource type](g) `page`, and two resources of resource type `image`. -{{% note %}} -The hierarchy depth at which a branch bundle is created does not -matter. -{{% /note %}} +branch-bundle-2 +: This branch bundle contains an index file and a leaf bundle. -[^fn:1]: The `.md` extension is just an example. The extension can be `.html`, `.json` or any of any valid MIME type. +> [!note] +> Create branch bundles at any depth within the `content` directory. Branch bundles may have descendants. + +## Headless bundles + +Use [build options] in front matter to create an unpublished leaf or branch bundle whose content and resources you can include in other pages. + +[`Resources`]: /methods/page/resources/ +[build options]: /content-management/build-options/ +[home]: /templates/types/#home +[page resources]: /content-management/page-resources/ +[section]: /templates/types/#section +[single]: /templates/types/#single +[taxonomy]: /templates/types/#taxonomy +[term]: /templates/types/#term diff --git a/docs/content/en/content-management/page-resources.md b/docs/content/en/content-management/page-resources.md index 1a0ef1b2f..204ca5301 100644 --- a/docs/content/en/content-management/page-resources.md +++ b/docs/content/en/content-management/page-resources.md @@ -1,106 +1,131 @@ --- -title : "Page Resources" -description : "Page Resources -- images, other pages, documents etc. -- have page-relative URLs and their own metadata." -date: 2018-01-24 -categories: ["content management"] -keywords: [bundle,content,resources] -weight: 4003 -draft: false -toc: true -linktitle: "Page Resources" -menu: - docs: - parent: "content-management" - weight: 31 +title: Page resources +description: Use page resources to logically associate assets with a page. +categories: [] +keywords: [] --- -## Properties +Page resources are only accessible from [page bundles](/content-management/page-bundles), those directories with `index.md` or +`_index.md` files at their root. Page resources are only available to the +page with which they are bundled. -ResourceType -: The main type of the resource. For example, a file of MIME type `image/jpeg` has the ResourceType `image`. +In this example, `first-post` is a page bundle with access to 10 page resources including audio, data, documents, images, and video. Although `second-post` is also a page bundle, it has no page resources and is unable to directly access the page resources associated with `first-post`. -Name -: Default value is the filename (relative to the owning page). Can be set in front matter. - -Title -: Default value is the same as `.Name`. Can be set in front matter. - -Permalink -: The absolute URL to the resource. Resources of type `page` will have no value. - -RelPermalink -: The relative URL to the resource. Resources of type `page` will have no value. - -Content -: The content of the resource itself. For most resources, this returns a string with the contents of the file. This can be used to inline some resources, such as `` or ``. - -MediaType -: The MIME type of the resource, such as `image/jpeg`. - -MediaType.MainType -: The main type of the resource's MIME type. For example, a file of MIME type `application/pdf` has for MainType `application`. - -MediaType.SubType -: The subtype of the resource's MIME type. For example, a file of MIME type `application/pdf` has for SubType `pdf`. Note that this is not the same as the file extension - PowerPoint files have a subtype of `vnd.mspowerpoint`. - -MediaType.Suffixes -: A slice of possible suffixes for the resource's MIME type. - -## Methods -ByType -: Returns the page resources of the given type. - -```go -{{ .Resources.ByType "image" }} -``` -Match -: Returns all the page resources (as a slice) whose `Name` matches the given Glob pattern ([examples](https://github.com/gobwas/glob/blob/master/readme.md)). The matching is case-insensitive. - -```go -{{ .Resources.Match "images/*" }} +```text +content +└── post + ├── first-post + │ ├── images + │ │ ├── a.jpg + │ │ ├── b.jpg + │ │ └── c.jpg + │ ├── index.md (root of page bundle) + │ ├── latest.html + │ ├── manual.json + │ ├── notice.md + │ ├── office.mp3 + │ ├── pocket.mp4 + │ ├── rating.pdf + │ └── safety.txt + └── second-post + └── index.md (root of page bundle) ``` -GetMatch -: Same as `Match` but will return the first match. +## Examples -### Pattern Matching -```go -// Using Match/GetMatch to find this images/sunset.jpg ? -.Resources.Match "images/sun*" ✅ -.Resources.Match "**/Sunset.jpg" ✅ -.Resources.Match "images/*.jpg" ✅ -.Resources.Match "**.jpg" ✅ -.Resources.Match "*" 🚫 -.Resources.Match "sunset.jpg" 🚫 -.Resources.Match "*sunset.jpg" 🚫 +Use any of these methods on a `Page` object to capture page resources: + - [`Resources.ByType`] + - [`Resources.Get`] + - [`Resources.GetMatch`] + - [`Resources.Match`] + + Once you have captured a resource, use any of the applicable [`Resource`] methods to return a value or perform an action. + +The following examples assume this content structure: + +```text +content/ +└── example/ + ├── data/ + │ └── books.json <-- page resource + ├── images/ + │ ├── a.jpg <-- page resource + │ └── b.jpg <-- page resource + ├── snippets/ + │ └── text.md <-- page resource + └── index.md ``` -## Page Resources Metadata +Render a single image, and throw an error if the file does not exist: -Page Resources' metadata is managed from their page's front matter with an array/table parameter named `resources`. You can batch assign values using a [wildcards](http://tldp.org/LDP/GNU-Linux-Tools-Summary/html/x11655.htm). +```go-html-template +{{ $path := "images/a.jpg" }} +{{ with .Resources.Get $path }} + +{{ else }} + {{ errorf "Unable to get page resource %q" $path }} +{{ end }} +``` -{{% note %}} -Resources of type `page` get `Title` etc. from their own front matter. -{{% /note %}} +Render all images, resized to 300 px wide: + +```go-html-template +{{ range .Resources.ByType "image" }} + {{ with .Resize "300x" }} + + {{ end }} +{{ end }} +``` + +Render the markdown snippet: + +```go-html-template +{{ with .Resources.Get "snippets/text.md" }} + {{ .Content }} +{{ end }} +``` + +List the titles in the data file, and throw an error if the file does not exist. + +```go-html-template +{{ $path := "data/books.json" }} +{{ with .Resources.Get $path }} + {{ with . | transform.Unmarshal }} +

    Books:

    +
      + {{ range . }} +
    • {{ .title }}
    • + {{ end }} +
    + {{ end }} +{{ else }} + {{ errorf "Unable to get page resource %q" $path }} +{{ end }} +``` + +## Metadata + +The page resources' metadata is managed from the corresponding page's front matter with an array/table parameter named `resources`. You can batch assign values using [wildcards](https://tldp.org/LDP/GNU-Linux-Tools-Summary/html/x11655.htm). + +> [!note] +> Resources of type `page` get `Title` etc. from their own front matter. name -: Sets the value returned in `Name`. +: (`string`) Sets the value returned in `Name`. -{{% warning %}} -The methods `Match` and `GetMatch` use `Name` to match the resources. -{{%/ warning %}} +> [!note] +> The methods `Match`, `Get` and `GetMatch` use `Name` to match the resources. title -: Sets the value returned in `Title` +: (`string`) Sets the value returned in `Title` params -: A map of custom key/values. +: (`map`) A map of custom key-value pairs. +### Resources metadata example -### Resources metadata example - -{{< code-toggle copy="false">}} +{{< code-toggle file=content/example.md fm=true >}} title: Application date : 2018-01-25 resources : @@ -134,9 +159,8 @@ From the example above: - All `PDF` files will get a new `Name`. The `name` parameter contains a special placeholder [`:counter`](#the-counter-placeholder-in-name-and-title), so the `Name` will be `pdf-file-1`, `pdf-file-2`, `pdf-file-3`. - Every docx in the bundle will receive the `word` icon. -{{% warning %}} -The __order matters__ --- Only the **first set** values of the `title`, `name` and `params`-**keys** will be used. Consecutive parameters will be set only for the ones not already set. For example, in the above example, `.Params.icon` is already first set to `"photo"` in `src = "documents/photo_specs.pdf"`. So that would not get overridden to `"pdf"` by the later set `src = "**.pdf"` rule. -{{%/ warning %}} +> [!note] +> The order matters; only the first set values of the `title`, `name` and `params` keys will be used. Consecutive parameters will be set only for the ones not already set. In the above example, `.Params.icon` is first set to `"photo"` in `src = "documents/photo_specs.pdf"`. So that would not get overridden to `"pdf"` by the later set `src = "**.pdf"` rule. ### The `:counter` placeholder in `name` and `title` @@ -146,7 +170,8 @@ The counter starts at 1 the first time they are used in either `name` or `title` For example, if a bundle has the resources `photo_specs.pdf`, `other_specs.pdf`, `guide.pdf` and `checklist.pdf`, and the front matter has specified the `resources` as: -{{< code-toggle copy="false">}} +{{< code-toggle file=content/inspections/engine/index.md fm=true >}} +title = 'Engine inspections' [[resources]] src = "*specs.pdf" title = "Specification #:counter" @@ -163,3 +188,110 @@ the `Name` and `Title` will be assigned to the resource files as follows: | guide.pdf | `"pdf-file-2.pdf` | `"guide.pdf"` | | other\_specs.pdf | `"pdf-file-3.pdf` | `"Specification #1"` | | photo\_specs.pdf | `"pdf-file-4.pdf` | `"Specification #2"` | + +## Multilingual + +{{< new-in 0.123.0 />}} + +By default, with a multilingual single-host site, Hugo does not duplicate shared page resources when building the site. + +> [!note] +> This behavior is limited to Markdown content. Shared page resources for other [content formats] are copied into each language bundle. + +Consider this site configuration: + +{{< code-toggle file=hugo >}} +defaultContentLanguage = 'de' +defaultContentLanguageInSubdir = true + +[languages.de] +languageCode = 'de-DE' +languageName = 'Deutsch' +weight = 1 + +[languages.en] +languageCode = 'en-US' +languageName = 'English' +weight = 2 +{{< /code-toggle >}} + +And this content: + +```text +content/ +└── my-bundle/ + ├── a.jpg <-- shared page resource + ├── b.jpg <-- shared page resource + ├── c.de.jpg + ├── c.en.jpg + ├── index.de.md + └── index.en.md +``` + +With v0.122.0 and earlier, Hugo duplicated the shared page resources, creating copies for each language: + +```text +public/ +├── de/ +│ ├── my-bundle/ +│ │ ├── a.jpg <-- shared page resource +│ │ ├── b.jpg <-- shared page resource +│ │ ├── c.de.jpg +│ │ └── index.html +│ └── index.html +├── en/ +│ ├── my-bundle/ +│ │ ├── a.jpg <-- shared page resource (duplicate) +│ │ ├── b.jpg <-- shared page resource (duplicate) +│ │ ├── c.en.jpg +│ │ └── index.html +│ └── index.html +└── index.html + +``` + +With v0.123.0 and later, Hugo places the shared resources in the page bundle for the default content language: + +```text +public/ +├── de/ +│ ├── my-bundle/ +│ │ ├── a.jpg <-- shared page resource +│ │ ├── b.jpg <-- shared page resource +│ │ ├── c.de.jpg +│ │ └── index.html +│ └── index.html +├── en/ +│ ├── my-bundle/ +│ │ ├── c.en.jpg +│ │ └── index.html +│ └── index.html +└── index.html +``` + +This approach reduces build times, storage requirements, bandwidth consumption, and deployment times, ultimately reducing cost. + +> [!note] +> To resolve Markdown link and image destinations to the correct location, you must use link and image render hooks that capture the page resource with the [`Resources.Get`] method, and then invoke its [`RelPermalink`] method. +> +> By default, with multilingual single-host sites, Hugo enables its [embedded link render hook] and [embedded image render hook] to resolve Markdown link and image destinations. +> +> You may override the embedded render hooks as needed, provided they capture the resource as described above. + +Although duplicating shared page resources is inefficient, you can enable this feature in your site configuration if desired: + +{{< code-toggle file=hugo >}} +[markup.goldmark] +duplicateResourceFiles = true +{{< /code-toggle >}} + +[`RelPermalink`]: /methods/resource/relpermalink/ +[`Resource`]: /methods/resource +[`Resources.ByType`]: /methods/page/resources#bytype +[`Resources.Get`]: /methods/page/resources#get +[`Resources.Get`]: /methods/page/resources/#get +[`Resources.GetMatch`]: /methods/page/resources#getmatch +[`Resources.Match`]: /methods/page/resources#match +[content formats]: /content-management/formats/ +[embedded image render hook]: /render-hooks/images/#default +[embedded link render hook]: /render-hooks/links/#default diff --git a/docs/content/en/content-management/related-content.md b/docs/content/en/content-management/related-content.md new file mode 100644 index 000000000..d7b18dab0 --- /dev/null +++ b/docs/content/en/content-management/related-content.md @@ -0,0 +1,102 @@ +--- +title: Related content +description: List related content in "See Also" sections. +categories: [] +keywords: [] +aliases: [/content/related/,/related/,/content-management/related/] +--- + +Hugo uses a set of factors to identify a page's related content based on front matter parameters. This can be tuned to the desired set of indices and parameters or left to Hugo's default [related content configuration](/configuration/related-content/). + +## List related content + +To list up to 5 related pages (which share the same _date_ or _keyword_ parameters) is as simple as including something similar to this partial in your template: + +```go-html-template {file="layouts/partials/related.html" copy=true} +{{ with site.RegularPages.Related . | first 5 }} +

    Related content:

    + +{{ end }} +``` + +The `Related` method takes one argument which may be a `Page` or an options map. The options map has these options: + +indices +: (`slice`) The indices to search within. + +document +: (`page`) The page for which to find related content. Required when specifying an options map. + +namedSlices +: (`slice`) The keywords to search for, expressed as a slice of `KeyValues` using the [`keyVals`] function. + +fragments +: (`slice`) A list of special keywords that is used for indices configured as type "fragments". This will match the [fragment](g) identifiers of the documents. + +A fictional example using all of the above options: + +```go-html-template +{{ $page := . }} +{{ $opts := dict + "indices" (slice "tags" "keywords") + "document" $page + "namedSlices" (slice (keyVals "tags" "hugo" "rocks") (keyVals "date" $page.Date)) + "fragments" (slice "heading-1" "heading-2") +}} +``` + +> [!note] +> We improved and simplified this feature in Hugo 0.111.0. Before this we had 3 different methods: `Related`, `RelatedTo` and `RelatedIndices`. Now we have only one method: `Related`. The old methods are still available but deprecated. Also see [this blog article](https://regisphilibert.com/blog/2018/04/hugo-optmized-relashionships-with-related-content/) for a great explanation of more advanced usage of this feature. + +## Index content headings + +Hugo can index the headings in your content and use this to find related content. You can enable this by adding a index of type `fragments` to your `related` configuration: + +{{< code-toggle file=hugo >}} +[related] +threshold = 20 +includeNewer = true +toLower = false +[[related.indices]] +name = "fragmentrefs" +type = "fragments" +applyFilter = true +weight = 80 +{{< /code-toggle >}} + +- The `name` maps to a optional front matter slice attribute that can be used to link from the page level down to the fragment/heading level. +- If `applyFilter` is enabled, the `.HeadingsFiltered` on each page in the result will reflect the filtered headings. This is useful if you want to show the headings in the related content listing: + +```go-html-template +{{ $related := .Site.RegularPages.Related . | first 5 }} +{{ with $related }} +

    See Also

    +
      + {{ range $i, $p := . }} +
    • + {{ .LinkTitle }} + {{ with .HeadingsFiltered }} +
        + {{ range . }} + {{ $link := printf "%s#%s" $p.RelPermalink .ID | safeURL }} +
      • + {{ .Title }} +
      • + {{ end }} +
      + {{ end }} +
    • + {{ end }} +
    +{{ end }} +``` + +## Configuration + +See [configure related content](/configuration/related-content/). + +[`keyVals`]: /functions/collections/keyvals/ diff --git a/docs/content/en/content-management/related.md b/docs/content/en/content-management/related.md deleted file mode 100644 index 8c18052fd..000000000 --- a/docs/content/en/content-management/related.md +++ /dev/null @@ -1,135 +0,0 @@ ---- -title: Related Content -description: List related content in "See Also" sections. -date: 2017-09-05 -categories: [content management] -keywords: [content] -menu: - docs: - parent: "content-management" - weight: 40 -weight: 30 -draft: false -aliases: [/content/related/,/related/] -toc: true ---- - - -Hugo uses a set of factors to identify a page's related content based on Front Matter parameters. This can be tuned to the desired set of indices and parameters or left to Hugo's default [Related Content configuration](#configure-related-content). - -## List Related Content - - -To list up to 5 related pages (which share the same _date_ or _keyword_ parameters) is as simple as including something similar to this partial in your single page template: - -{{< code file="layouts/partials/related.html" >}} -{{ $related := .Site.RegularPages.Related . | first 5 }} -{{ with $related }} -

    See Also

    - -{{ end }} -{{< /code >}} - -### Methods - -Here is the list of "Related" methods available on a page collection such `.RegularPages`. - -#### .Related PAGE -Returns a collection of pages related the given one. - -``` -{{ $related := .Site.RegularPages.Related . }} -``` - -#### .RelatedIndices PAGE INDICE1 [INDICE2 ...] -Returns a collection of pages related to a given one restricted to a list of indices. - -``` -{{ $related := .Site.RegularPages.RelatedIndices . "tags" "date" }} -``` - -#### .RelatedTo KEYVALS [KEYVALS2 ...] -Returns a collection of pages related together by a set of indices and their match. - -In order to build those set and pass them as argument, one must use the `keyVals` function where the first argument would be the `indice` and the consecutive ones its potential `matches`. - -``` -{{ $related := .Site.RegularPages.RelatedTo ( keyVals "tags" "hugo" "rocks") ( keyVals "date" .Date ) }} -``` - -{{% note %}} -Read [this blog article](https://regisphilibert.com/blog/2018/04/hugo-optmized-relashionships-with-related-content/) for a great explanation of more advanced usage of this feature. -{{% /note %}} - -## Configure Related Content -Hugo provides a sensible default configuration of Related Content, but you can fine-tune this in your configuration, on the global or language level if needed. - -### Default configuration - -Without any `related` configuration set on the project, Hugo's Related Content methods will use the following. - -```yaml -related: - threshold: 80 - includeNewer: false - toLower: false - indices: - - name: keywords - weight: 100 - - name: date - weight: 10 -``` - -Custom configuration should be set using the same syntax. - -{{% note %}} -If you add a `related` config section, you need to add a complete configuration. It is not possible to just set, say, `includeNewer` and use the rest from the Hugo defaults. -{{% /note %}} - -### Top Level Config Options - -threshold -: A value between 0-100. Lower value will give more, but maybe not so relevant, matches. - -includeNewer -: Set to true to include **pages newer than the current page** in the related content listing. This will mean that the output for older posts may change as new related content gets added. - -toLower -: Set to true to lower case keywords in both the indexes and the queries. This may give more accurate results at a slight performance penalty. Note that this can also be set per index. - -### Config Options per Index - -name -: The index name. This value maps directly to a page param. Hugo supports string values (`author` in the example) and lists (`tags`, `keywords` etc.) and time and date objects. - -weight -: An integer weight that indicates _how important_ this parameter is relative to the other parameters. It can be 0, which has the effect of turning this index off, or even negative. Test with different values to see what fits your content best. - -pattern -: This is currently only relevant for dates. When listing related content, we may want to list content that is also close in time. Setting "2006" (default value for date indexes) as the pattern for a date index will add weight to pages published in the same year. For busier blogs, "200601" (year and month) may be a better default. - -toLower -: See above. - -## Performance Considerations - -**Fast is Hugo's middle name** and we would not have released this feature had it not been blistering fast. - -This feature has been in the back log and requested by many for a long time. The development got this recent kick start from this Twitter thread: - -{{< tweet 898398437527363585 >}} - -Scott S. Lowe removed the "Related Content" section built using the `intersect` template function on tags, and the build time dropped from 30 seconds to less than 2 seconds on his 1700 content page sized blog. - -He should now be able to add an improved version of that "Related Content" section without giving up the fast live-reloads. But it's worth noting that: - -* If you don't use any of the `Related` methods, you will not use the Relate Content feature, and performance will be the same as before. -* Calling `.RegularPages.Related` etc. will create one inverted index, also sometimes named posting list, that will be reused for any lookups in that same page collection. Doing that in addition to, as an example, calling `.Pages.Related` will work as expected, but will create one additional inverted index. This should still be very fast, but worth having in mind, especially for bigger sites. - -{{% note %}} -We currently do not index **Page content**. We thought we would release something that will make most people happy before we start solving [Sherlock's last case](https://github.com/joearms/sherlock). -{{% /note %}} diff --git a/docs/content/en/content-management/sections.md b/docs/content/en/content-management/sections.md index 79ae201d4..f7a2296f5 100644 --- a/docs/content/en/content-management/sections.md +++ b/docs/content/en/content-management/sections.md @@ -1,98 +1,139 @@ --- -title: Content Sections -linktitle: Sections -description: "Hugo generates a **section tree** that matches your content." -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2017-02-01 -categories: [content management] -keywords: [lists,sections,content types,organization] -menu: - docs: - parent: "content-management" - weight: 50 -weight: 50 #rem -draft: false +title: Sections +description: Organize content into sections. + +categories: [] +keywords: [] aliases: [/content/sections/] -toc: true --- -A **Section** is a collection of pages that gets defined based on the -organization structure under the `content/` directory. +## Overview -By default, all the **first-level** directories under `content/` form their own -sections (**root sections**). +{{% glossary-term "section" %}} -If a user needs to define a section `foo` at a deeper level, they need to create -a directory named `foo` with an `_index.md` file (see [Branch Bundles][branch bundles] -for more information). - - -{{% note %}} -A **section** cannot be defined or overridden by a front matter parameter -- it -is strictly derived from the content organization structure. -{{% /note %}} - -## Nested Sections - -The sections can be nested as deeply as you need. - -```bash -content -└── blog <-- Section, because first-level dir under content/ - ├── funny-cats - │   ├── mypost.md - │   └── kittens <-- Section, because contains _index.md - │   └── _index.md - └── tech <-- Section, because contains _index.md - └── _index.md +```text +content/ +├── articles/ <-- section (top-level directory) +│ ├── 2022/ +│ │ ├── article-1/ +│ │ │ ├── cover.jpg +│ │ │ └── index.md +│ │ └── article-2.md +│ └── 2023/ +│ ├── article-3.md +│ └── article-4.md +├── products/ <-- section (top-level directory) +│ ├── product-1/ <-- section (has _index.md file) +│ │ ├── benefits/ <-- section (has _index.md file) +│ │ │ ├── _index.md +│ │ │ ├── benefit-1.md +│ │ │ └── benefit-2.md +│ │ ├── features/ <-- section (has _index.md file) +│ │ │ ├── _index.md +│ │ │ ├── feature-1.md +│ │ │ └── feature-2.md +│ │ └── _index.md +│ └── product-2/ <-- section (has _index.md file) +│ ├── benefits/ <-- section (has _index.md file) +│ │ ├── _index.md +│ │ ├── benefit-1.md +│ │ └── benefit-2.md +│ ├── features/ <-- section (has _index.md file) +│ │ ├── _index.md +│ │ ├── feature-1.md +│ │ └── feature-2.md +│ └── _index.md +├── _index.md +└── about.md ``` -**The important part to understand is, that to make the section tree fully navigational, at least the lower-most section needs a content file. (e.g. `_index.md`).** +The example above has two top-level sections: articles and products. None of the directories under articles are sections, while all of the directories under products are sections. A section within a section is a known as a nested section or subsection. -{{% note %}} -When we talk about a **section** in correlation with template selection, it is -currently always the *root section* only (`/blog/funny-cats/mypost/ => blog`). +## Explanation -If you need a specific template for a sub-section, you need to adjust either the `type` or `layout` in front matter. -{{% /note %}} +Sections and non-sections behave differently. -## Example: Breadcrumb Navigation +||Sections|Non-sections +:--|:-:|:-: +Directory names become URL segments|:heavy_check_mark:|:heavy_check_mark: +Have logical ancestors and descendants|:heavy_check_mark:|:x: +Have list pages|:heavy_check_mark:|:x: -With the available [section variables and methods](#section-page-variables-and-methods) you can build powerful navigation. One common example would be a partial to show Breadcrumb navigation: +With the file structure from the [example above](#overview): -{{< code file="layouts/partials/breadcrumb.html" download="breadcrumb.html" >}} - -{{ define "breadcrumbnav" }} -{{ if .p1.Parent }} -{{ template "breadcrumbnav" (dict "p1" .p1.Parent "p2" .p2 ) }} -{{ else if not .p1.IsHome }} -{{ template "breadcrumbnav" (dict "p1" .p1.Site.Home "p2" .p2 ) }} -{{ end }} - - {{ .p1.Title }} - -{{ end }} -{{< /code >}} +1. The list page for the articles section includes all articles, regardless of directory structure; none of the subdirectories are sections. +1. The articles/2022 and articles/2023 directories do not have list pages; they are not sections. +1. The list page for the products section, by default, includes product-1 and product-2, but not their descendant pages. To include descendant pages, use the `RegularPagesRecursive` method instead of the `Pages` method in the list template. +1. All directories in the products section have list pages; each directory is a section. -## Section Page Variables and Methods +## Template selection -Also see [Page Variables](/variables/page/). +Hugo has a defined [lookup order] to determine which template to use when rendering a page. The [lookup rules] consider the top-level section name; subsection names are not considered when selecting a template. -{{< readfile file="/content/en/readfiles/sectionvars.md" markdown="true" >}} +With the file structure from the [example above](#overview): -## Content Section Lists +Content directory|Section template +:--|:-- +`content/products`|`layouts/products/list.html` +`content/products/product-1`|`layouts/products/list.html` +`content/products/product-1/benefits`|`layouts/products/list.html` -Hugo will automatically create pages for each *root section* that list all of the content in that section. See the documentation on [section templates][] for details on customizing the way these pages are rendered. +Content directory|Single template +:--|:-- +`content/products`|`layouts/products/single.html` +`content/products/product-1`|`layouts/products/single.html` +`content/products/product-1/benefits`|`layouts/products/single.html` -## Content *Section* vs Content *Type* +If you need to use a different template for a subsection, specify `type` and/or `layout` in front matter. -By default, everything created within a section will use the [content `type`][content type] that matches the *root section* name. For example, Hugo will assume that `posts/post-1.md` has a `posts` content `type`. If you are using an [archetype][] for your `posts` section, Hugo will generate front matter according to what it finds in `archetypes/posts.md`. +## Ancestors and descendants -[archetype]: /content-management/archetypes/ -[content type]: /content-management/types/ -[directory structure]: /getting-started/directory-structure/ -[section templates]: /templates/section-templates/ -[branch bundles]: /content-management/page-bundles/#branch-bundles +A section has one or more ancestors (including the home page), and zero or more descendants. With the file structure from the [example above](#overview): + +```text +content/products/product-1/benefits/benefit-1.md +``` + +The content file (benefit-1.md) has four ancestors: benefits, product-1, products, and the home page. This logical relationship allows us to use the `.Parent` and `.Ancestors` methods to traverse the site structure. + +For example, use the `.Ancestors` method to render breadcrumb navigation. + +```go-html-template {file="layouts/partials/breadcrumb.html"} + +``` + +With this CSS: + +```css +.breadcrumb ol { + padding-left: 0; +} + +.breadcrumb li { + display: inline; +} + +.breadcrumb li:not(:last-child)::after { + content: "»"; +} +``` + +Hugo renders this, where each breadcrumb is a link to the corresponding page: + +```text +Home » Products » Product 1 » Benefits » Benefit 1 +``` + +[lookup order]: /templates/lookup-order/ +[lookup rules]: /templates/lookup-order/#lookup-rules diff --git a/docs/content/en/content-management/shortcodes.md b/docs/content/en/content-management/shortcodes.md index 806ac9874..2de387f39 100644 --- a/docs/content/en/content-management/shortcodes.md +++ b/docs/content/en/content-management/shortcodes.md @@ -1,422 +1,230 @@ --- title: Shortcodes -linktitle: -description: Shortcodes are simple snippets inside your content files calling built-in or custom templates. -godocref: -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2019-11-07 -menu: - docs: - parent: "content-management" - weight: 35 -weight: 35 #rem -categories: [content management] -keywords: [markdown,content,shortcodes] -draft: false +description: Use embedded, custom, or inline shortcodes to insert elements such as videos, images, and social media embeds into your content. +categories: [] +keywords: [] aliases: [/extras/shortcodes/] -testparam: "Hugo Rocks!" -toc: true --- -## What a Shortcode is +## Introduction -Hugo loves Markdown because of its simple content format, but there are times when Markdown falls short. Often, content authors are forced to add raw HTML (e.g., video ``) to Markdown content. We think this contradicts the beautiful simplicity of Markdown's syntax. +{{% glossary-term shortcode %}} -Hugo created **shortcodes** to circumvent these limitations. +There are three types of shortcodes: embedded, custom, and inline. -A shortcode is a simple snippet inside a content file that Hugo will render using a predefined template. Note that shortcodes will not work in template files. If you need the type of drop-in functionality that shortcodes provide but in a template, you most likely want a [partial template][partials] instead. +## Embedded -In addition to cleaner Markdown, shortcodes can be updated any time to reflect new classes, techniques, or standards. At the point of site generation, Hugo shortcodes will easily merge in your changes. You avoid a possibly complicated search and replace operation. +Hugo's embedded shortcodes are pre-defined templates within the application. Refer to each shortcode's documentation for specific usage instructions and available arguments. -## Use Shortcodes +{{% list-pages-in-section path=/shortcodes %}} -{{< youtube 2xkNJL4gJ9E >}} +## Custom -In your content files, a shortcode can be called by calling `{{%/* shortcodename parameters */%}}`. Shortcode parameters are space delimited, and parameters with internal spaces can be quoted. +Create custom shortcodes to simplify and standardize content creation. For example, the following shortcode template generates an audio player using a [global resource](g): -The first word in the shortcode declaration is always the name of the shortcode. Parameters follow the name. Depending upon how the shortcode is defined, the parameters may be named, positional, or both, although you can't mix parameter types in a single call. The format for named parameters models that of HTML with the format `name="value"`. - -Some shortcodes use or require closing shortcodes. Again like HTML, the opening and closing shortcodes match (name only) with the closing declaration, which is prepended with a slash. - -Here are two examples of paired shortcodes: - -``` -{{%/* mdshortcode */%}}Stuff to `process` in the *center*.{{%/* /mdshortcode */%}} +```go-html-template {file="layouts/shortcodes/audio.html"} +{{ with resources.Get (.Get "src") }} + +{{ end }} ``` -``` -{{}} A bunch of code here {{}} +Then call the shortcode from within markup: + +```text {file="content/example.md"} +{{}} ``` -The examples above use two different delimiters, the difference being the `%` character in the first and the `<>` characters in the second. +Learn more about creating shortcodes in the [shortcode templates] section. -### Shortcodes with Markdown +## Inline -In Hugo `0.55` we changed how the `%` delimiter works. Shortcodes using the `%` as the outer-most delimiter will now be fully rendered when sent to the content renderer (e.g. Blackfriday for Markdown), meaning they can be part of the generated table of contents, footnotes, etc. +An inline shortcode is a shortcode template defined within content. -If you want the old behavior, you can put the following line in the start of your shortcode template: +Hugo's security model is based on the premise that template and configuration authors are trusted, but content authors are not. This model enables generation of HTML output safe against code injection. -``` -{{ $_hugo_config := `{ "version": 1 }` }} +To conform with this security model, creating shortcode templates within content is disabled by default. If you trust your content authors, you can enable this functionality in your site's configuration: + +{{< code-toggle file=hugo >}} +[security] +enableInlineShortcodes = true +{{< /code-toggle >}} + +For more information see [configure security](/configuration/security). + +The following example demonstrates an inline shortcode, `date.inline`, that accepts a single positional argument: a date/time [layout string]. + +```text {file="content/example.md"} +Today is +{{}} + {{- now | time.Format (.Get 0) -}} +{{}}. + +Today is {{}}. ``` +In the example above, the inline shortcode is executed twice: once upon definition and again when subsequently called. Hugo renders this to: -### Shortcodes Without Markdown - -The `<` character indicates that the shortcode's inner content does *not* need further rendering. Often shortcodes without markdown include internal HTML: - -``` -{{}}

    Hello World!

    {{}} +```html +

    Today is Jan 30, 2025.

    +

    Today is Thursday, January 30, 2025

    ``` -### Nested Shortcodes +Inline shortcodes process their inner content within the same context as regular shortcode templates, allowing you to use any available [shortcode method]. -You can call shortcodes within other shortcodes by creating your own templates that leverage the `.Parent` variable. `.Parent` allows you to check the context in which the shortcode is being called. See [Shortcode templates][sctemps]. +> [!note] +> You cannot [nest](#nesting) inline shortcodes. -## Use Hugo's Built-in Shortcodes +Learn more about creating shortcodes in the [shortcode templates] section. -Hugo ships with a set of predefined shortcodes that represent very common usage. These shortcodes are provided for author convenience and to keep your markdown content clean. +## Calling -### `figure` +Shortcode calls involve three syntactical elements: tags, arguments, and notation. -`figure` is an extension of the image syntax in markdown, which does not provide a shorthand for the more semantic [HTML5 `
    ` element][figureelement]. +### Tags -The `figure` shortcode can use the following named parameters: +Some shortcodes expect content between opening and closing tags. For example, the embedded [`details`] shortcode requires an opening and closing tag: -src -: URL of the image to be displayed. - -link -: If the image needs to be hyperlinked, URL of the destination. - -target -: Optional `target` attribute for the URL if `link` parameter is set. - -rel -: Optional `rel` attribute for the URL if `link` parameter is set. - -alt -: Alternate text for the image if the image cannot be displayed. - -title -: Image title. - -caption -: Image caption. Markdown within the value of `caption` will be rendered. - -class -: `class` attribute of the HTML `figure` tag. - -height -: `height` attribute of the image. - -width -: `width` attribute of the image. - -attr -: Image attribution text. Markdown within the value of `attr` will be rendered. - -attrlink -: If the attribution text needs to be hyperlinked, URL of the destination. - -#### Example `figure` Input - -{{< code file="figure-input-example.md" >}} -{{}} -{{< /code >}} - -#### Example `figure` Output - -{{< output file="figure-output-example.html" >}} -
    - -
    -

    Steve Francia

    -
    -
    -{{< /output >}} - -### `gist` - -Bloggers often want to include GitHub gists when writing posts. Let's suppose we want to use the [gist at the following url][examplegist]: - -``` -https://gist.github.com/spf13/7896402 +```text +{{}} +This is a **bold** word. +{{}} ``` -We can embed the gist in our content via username and gist ID pulled from the URL: +Some shortcodes do not accept content. For example, the embedded [`instagram`] shortcode requires a single _positional_ argument: -``` -{{}} +```text +{{}} ``` -#### Example `gist` Input +Some shortcodes optionally accept content. For example, you can call the embedded [`qr`] shortcode with content: -If the gist contains several files and you want to quote just one of them, you can pass the filename (quoted) as an optional third argument: - -{{< code file="gist-input.md" >}} -{{}} -{{< /code >}} - -#### Example `gist` Output - -{{< output file="gist-output.html" >}} -{{< gist spf13 7896402 >}} -{{< /output >}} - -#### Example `gist` Display - -To demonstrate the remarkably efficiency of Hugo's shortcode feature, we have embedded the `spf13` `gist` example in this page. The following simulates the experience for visitors to your website. Naturally, the final display will be contingent on your stylesheets and surrounding markup. - -{{< gist spf13 7896402 >}} - -### `highlight` - -This shortcode will convert the source code provided into syntax-highlighted HTML. Read more on [highlighting](/tools/syntax-highlighting/). `highlight` takes exactly one required `language` parameter and requires a closing shortcode. - -#### Example `highlight` Input - -{{< code file="content/tutorials/learn-html.md" >}} -{{}} -
    -
    -

    {{ .Title }}

    - {{ range .Pages }} - {{ .Render "summary"}} - {{ end }} -
    -
    -{{}} -{{< /code >}} - -#### Example `highlight` Output - -The `highlight` shortcode example above would produce the following HTML when the site is rendered: - -{{< output file="tutorials/learn-html/index.html" >}} -<section id="main"> - <div> - <h1 id="title">{{ .Title }}</h1> - {{ range .Pages }} - {{ .Render "summary"}} - {{ end }} - </div> -</section> -{{< /output >}} - -{{% note "More on Syntax Highlighting" %}} -To see even more options for adding syntax-highlighted code blocks to your website, see [Syntax Highlighting in Developer Tools](/tools/syntax-highlighting/). -{{% /note %}} - -### `instagram` - -If you'd like to embed a photo from [Instagram][], you only need the photo's ID. You can discern an Instagram photo ID from the URL: - -``` -https://www.instagram.com/p/BWNjjyYFxVx/ +```text +{{}} +https://gohugo.io +{{}} ``` -#### Example `instagram` Input +Or use the self-closing syntax with a trailing slash to pass the text as an argument: -{{< code file="instagram-input.md" >}} -{{}} -{{< /code >}} - -You also have the option to hide the caption: - -{{< code file="instagram-input-hide-caption.md" >}} -{{}} -{{< /code >}} - -#### Example `instagram` Output - -By adding the preceding `hidecaption` example, the following HTML will be added to your rendered website's markup: - -{{< output file="instagram-hide-caption-output.html" >}} -{{< instagram BWNjjyYFxVx hidecaption >}} -{{< /output >}} - -#### Example `instagram` Display - -Using the preceding `instagram` with `hidecaption` example above, the following simulates the displayed experience for visitors to your website. Naturally, the final display will be contingent on your stylesheets and surrounding markup. - -{{< instagram BWNjjyYFxVx hidecaption >}} - - -### `param` - -Gets a value from the current `Page's` params set in front matter, with a fall back to the site param value. It will log an `ERROR` if the param with the given key could not be found in either. - -```bash -{{}} +```text +{{}} ``` -Since `testparam` is a param defined in front matter of this page with the value `Hugo Rocks!`, the above will print: +Refer to each shortcode's documentation for specific usage instructions and available arguments. -{{< param testparam >}} +### Arguments -To access deeply nested params, use "dot syntax", e.g: +Shortcode arguments can be either _named_ or _positional_. -```bash -{{}} +Named arguments are passed as case-sensitive key-value pairs, as seen in this example with the embedded [`figure`] shortcode. The `src` argument, for instance, is required. + +```text +{{}} ``` -### `ref` and `relref` +Positional arguments, on the other hand, are determined by their position. The embedded `instagram` shortcode, for example, expects the first argument to be the Instagram post ID. -These shortcodes will look up the pages by their relative path (e.g., `blog/post.md`) or their logical name (`post.md`) and return the permalink (`ref`) or relative permalink (`relref`) for the found page. - -`ref` and `relref` also make it possible to make fragmentary links that work for the header links generated by Hugo. - -{{% note "More on Cross References" %}} -Read a more extensive description of `ref` and `relref` in the [cross references](/content-management/cross-references/) documentation. -{{% /note %}} - -`ref` and `relref` take exactly one required parameter of _reference_, quoted and in position `0`. - -#### Example `ref` and `relref` Input - -``` -[Neat]({{}}) -[Who]({{}}) +```text +{{}} ``` -#### Example `ref` and `relref` Output +Shortcode arguments are space-delimited, and arguments with internal spaces must be quoted. -Assuming that standard Hugo pretty URLs are turned on. - -``` -Neat -Who +```text +{{}} ``` -### `tweet` +Shortcodes accept [scalar](g) arguments, one of [string](g), [integer](g), [floating point](g), or [boolean](g). -You want to include a single tweet into your blog post? Everything you need is the URL of the tweet: - -``` -https://twitter.com/spf13/status/877500564405444608 +```text +{{}} ``` -#### Example `tweet` Input +You can optionally use multiple lines when providing several arguments to a shortcode for better readability: -Pass the tweet's ID from the URL as a parameter to the `tweet` shortcode: - -{{< code file="example-tweet-input.md" >}} -{{}} -{{< /code >}} - -#### Example `tweet` Output - -Using the preceding `tweet` example, the following HTML will be added to your rendered website's markup: - -{{< output file="example-tweet-output.html" >}} -{{< tweet 877500564405444608 >}} -{{< /output >}} - -#### Example `tweet` Display - -Using the preceding `tweet` example, the following simulates the displayed experience for visitors to your website. Naturally, the final display will be contingent on your stylesheets and surrounding markup. - -{{< tweet 877500564405444608 >}} - -### `vimeo` - -Adding a video from [Vimeo][] is equivalent to the YouTube shortcode above. - -``` -https://vimeo.com/channels/staffpicks/146022717 +```text +{{}} ``` -#### Example `vimeo` Input +Use a [raw string literal](g) if you need to pass a multiline string: -Extract the ID from the video's URL and pass it to the `vimeo` shortcode: - -{{< code file="example-vimeo-input.md" >}} -{{}} -{{< /code >}} - -#### Example `vimeo` Output - -Using the preceding `vimeo` example, the following HTML will be added to your rendered website's markup: - -{{< output file="example-vimeo-output.html" >}} -{{< vimeo 146022717 >}} -{{< /output >}} - -{{% tip %}} -If you want to further customize the visual styling of the YouTube or Vimeo output, add a `class` named parameter when calling the shortcode. The new `class` will be added to the `
    ` that wraps the ` -
    -{{< /code >}} - -{{< code file="youtube-embed.html" copy="false" >}} -
    - -
    -{{< /code >}} - -### Single Named Example: `image` - -Let's say you want to create your own `img` shortcode rather than use Hugo's built-in [`figure` shortcode][figure]. Your goal is to be able to call the shortcode as follows in your content files: - -{{< code file="content-image.md" >}} -{{}} -{{< /code >}} - -You have created the shortcode at `/layouts/shortcodes/img.html`, which loads the following shortcode template: - -{{< code file="/layouts/shortcodes/img.html" >}} - -
    - {{ with .Get "link"}}{{ end }} - - {{ if .Get "link"}}{{ end }} - {{ if or (or (.Get "title") (.Get "caption")) (.Get "attr")}} -
    {{ if isset .Params "title" }} -

    {{ .Get "title" }}

    {{ end }} - {{ if or (.Get "caption") (.Get "attr")}}

    - {{ .Get "caption" }} - {{ with .Get "attrlink"}} {{ end }} - {{ .Get "attr" }} - {{ if .Get "attrlink"}} {{ end }} -

    {{ end }} -
    - {{ end }} -
    - -{{< /code >}} - -Would be rendered as: - -{{< code file="img-output.html" copy="false" >}} -
    - -
    -

    Steve Francia

    -
    -
    -{{< /code >}} - -### Single Flexible Example: `vimeo` - -``` -{{}} -{{}} -``` - -Would load the template found at `/layouts/shortcodes/vimeo.html`: - -{{< code file="/layouts/shortcodes/vimeo.html" >}} -{{ if .IsNamedParams }} -
    - -
    -{{ else }} -
    - -
    -{{ end }} -{{< /code >}} - -Would be rendered as: - -{{< code file="vimeo-iframes.html" copy="false" >}} -
    - -
    -
    - -
    -{{< /code >}} - -### Paired Example: `highlight` - -The following is taken from `highlight`, which is a [built-in shortcode][] that ships with Hugo. - -{{< code file="highlight-example.md" >}} -{{}} - - This HTML - -{{}} -{{< /code >}} - -The template for the `highlight` shortcode uses the following code, which is already included in Hugo: - -``` -{{ .Get 0 | highlight .Inner }} -``` - -The rendered output of the HTML example code block will be as follows: - -{{< code file="syntax-highlighted.html" copy="false" >}} -
    <html>
    -    <body> This HTML </body>
    -</html>
    -
    -{{< /code >}} - -### Nested Shortcode: Image Gallery - -Hugo's [`.Parent` shortcode variable][parent] returns a boolean value depending on whether the shortcode in question is called within the context of a *parent* shortcode. This provides an inheritance model for common shortcode parameters. - -The following example is contrived but demonstrates the concept. Assume you have a `gallery` shortcode that expects one named `class` parameter: - -{{< code file="layouts/shortcodes/gallery.html" >}} -
    - {{.Inner}} -
    -{{< /code >}} - -You also have an `img` shortcode with a single named `src` parameter that you want to call inside of `gallery` and other shortcodes, so that the parent defines the context of each `img`: - -{{< code file="layouts/shortcodes/img.html" >}} -{{- $src := .Get "src" -}} -{{- with .Parent -}} - -{{- else -}} - -{{- end }} -{{< /code >}} - -You can then call your shortcode in your content as follows: - -``` -{{}} - {{}} - {{}} -{{}} -{{}} -``` - -This will output the following HTML. Note how the first two `img` shortcodes inherit the `class` value of `content-gallery` set with the call to the parent `gallery`, whereas the third `img` only uses `src`: - -``` - - -``` - - -## Error Handling in Shortcodes - -Use the [errorf](/functions/errorf) template func and [.Position](/variables/shortcodes/) variable to get useful error messages in shortcodes: - -```bash -{{ with .Get "name" }} -{{ else }} -{{ errorf "missing value for param 'name': %s" .Position }} -{{ end }} -``` - -When the above fails, you will see an `ERROR` log similar to the below: - -```bash -ERROR 2018/11/07 10:05:55 missing value for param name: "/Users/bep/dev/go/gohugoio/hugo/docs/content/en/variables/shortcodes.md:32:1" -``` - -## More Shortcode Examples - -More shortcode examples can be found in the [shortcodes directory for spf13.com][spfscs] and the [shortcodes directory for the Hugo docs][docsshortcodes]. - - -## Inline Shortcodes - -Since Hugo 0.52, you can implement your shortcodes inline -- e.g. where you use them in the content file. This can be useful for scripting that you only need in one place. - -This feature is disabled by default, but can be enabled in your site config: - -{{< code-toggle file="config">}} -enableInlineShortcodes = true -{{< /code-toggle >}} - -It is disabled by default for security reasons. The security model used by Hugo's template handling assumes that template authors are trusted, but that the content files are not, so the templates are injection-safe from malformed input data. But in most situations you have full control over the content, too, and then `enableInlineShortcodes = true` would be considered safe. But it's something to be aware of: It allows ad-hoc [Go Text templates](https://golang.org/pkg/text/template/) to be executed from the content files. - -And once enabled, you can do this in your content files: - - ```go-text-template - {{}}{{ now }}{{}} - ``` - -The above will print the current date and time. - - Note that an inline shortcode's inner content is parsed and executed as a Go text template with the same context as a regular shortcode template. - -This means that the current page can be accessed via `.Page.Title` etc. This also means that there are no concept of "nested inline shortcodes". - -The same inline shortcode can be reused later in the same content file, with different params if needed, using the self-closing syntax: - - ```go-text-template -{{}} -``` - - -[basic content files]: /content-management/formats/ "See how Hugo leverages markdown--and other supported formats--to create content for your website." -[built-in shortcode]: /content-management/shortcodes/ -[config]: /getting-started/configuration/ "Learn more about Hugo's built-in configuration variables as well as how to us your site's configuration file to include global key-values that can be used throughout your rendered website." -[Content Management: Shortcodes]: /content-management/shortcodes/#using-hugo-s-built-in-shortcodes "Check this section if you are not familiar with the definition of what a shortcode is or if you are unfamiliar with how to use Hugo's built-in shortcodes in your content files." -[source organization]: /getting-started/directory-structure/#directory-structure-explained "Learn how Hugo scaffolds new sites and what it expects to find in each of your directories." -[docsshortcodes]: https://github.com/gohugoio/hugo/tree/master/docs/layouts/shortcodes "See the shortcode source directory for the documentation site you're currently reading." -[figure]: /content-management/shortcodes/#figure -[hugosc]: /content-management/shortcodes/#using-hugo-s-built-in-shortcodes -[lookup order]: /templates/lookup-order/ "See the order in which Hugo traverses your template files to decide where and how to render your content at build time" -[pagevars]: /variables/page/ "See which variables you can leverage in your templating for page vs list templates." -[parent]: /variables/shortcodes/ -[shortcodesvars]: /variables/shortcodes/ "Certain variables are specific to shortcodes, although most .Page variables can be accessed within your shortcode template." -[spfscs]: https://github.com/spf13/spf13.com/tree/master/layouts/shortcodes "See more examples of shortcodes by visiting the shortcode directory of the source for spf13.com, the blog of Hugo's creator, Steve Francia." -[templates]: /templates/ "The templates section of the Hugo docs." -[vimeoexample]: #single-flexible-example-vimeo -[youtubeshortcode]: /content-management/shortcodes/#youtube "See how to use Hugo's built-in YouTube shortcode." diff --git a/docs/content/en/templates/shortcode.md b/docs/content/en/templates/shortcode.md new file mode 100644 index 000000000..3ed573651 --- /dev/null +++ b/docs/content/en/templates/shortcode.md @@ -0,0 +1,338 @@ +--- +title: Shortcode templates +description: Create custom shortcodes to simplify and standardize content creation. +categories: [] +keywords: [] +weight: 120 +aliases: [/templates/shortcode-templates/] +--- + +> [!note] +> Before creating custom shortcodes, please review the [shortcodes] page in the [content management] section. Understanding the usage details will help you design and create better templates. + +## Introduction + +Hugo provides [embedded shortcodes] for many common tasks, but you'll likely need to create your own for more specific needs. Some examples of custom shortcodes you might develop include: + +- Audio players +- Video players +- Image galleries +- Diagrams +- Maps +- Tables +- And many other custom elements + +## Directory structure + +Create shortcode templates within the `layouts/shortcodes` directory, either at its root or organized into subdirectories. + +```text +layouts/ +└── shortcodes/ + ├── diagrams/ + │ ├── kroki.html + │ └── plotly.html + ├── media/ + │ ├── audio.html + │ ├── gallery.html + │ └── video.html + ├── capture.html + ├── column.html + ├── include.html + └── row.html +``` + +When calling a shortcode in a subdirectory, specify its path relative to the `shortcode` directory, excluding the file extension. + +```text +{{}} +``` + +## Lookup order + +Hugo selects shortcode templates based on the shortcode name, the current output format, and the current language. The examples below are sorted by specificity in descending order. The least specific path is at the bottom of the list. + +Shortcode name|Output format|Language|Template path +:--|:--|:--|:-- +foo|html|en|`layouts/shortcodes/foo.en.html` +foo|html|en|`layouts/shortcodes/foo.html.html` +foo|html|en|`layouts/shortcodes/foo.html` +foo|html|en|`layouts/shortcodes/foo.html.en.html` + +Shortcode name|Output format|Language|Template path +:--|:--|:--|:-- +foo|json|en|`layouts/shortcodes/foo.en.json` +foo|json|en|`layouts/shortcodes/foo.json` +foo|json|en|`layouts/shortcodes/foo.json.json` +foo|json|en|`layouts/shortcodes/foo.json.en.json` + +## Methods + +Use these methods in your shortcode templates. Refer to each methods's documentation for details and examples. + +{{% list-pages-in-section path=/methods/shortcode %}} + +## Examples + +These examples range in complexity from simple to moderately advanced, with some simplified for clarity. + +### Insert year + +Create a shortcode to insert the current year: + +```go-html-template {file="layouts/shortcodes/year.html"} +{{- now.Format "2006" -}} +``` + +Then call the shortcode from within your markup: + +```text {file="content/example.md"} +This is {{}}, and look at how far we've come. +``` + +This shortcode can be used inline or as a block on its own line. If a shortcode might be used inline, remove the surrounding [whitespace] by using [template action](g) delimiters with hyphens. + +### Insert image + +This example assumes the following content structure, where `content/example/index.md` is a [page bundle](g) containing one or more [page resources](g). + +```text +content/ +├── example/ +│ ├── a.jpg +│ └── index.md +└── _index.md +``` + +Create a shortcode to capture an image as a page resource, resize it to the given width, convert it to the WebP format, and add an `alt` attribute: + +```go-html-template {file="layouts/shortcodes/image.html"} +{{- with .Page.Resources.Get (.Get "path") }} + {{- with .Process (printf "resize %dx wepb" ($.Get "width")) -}} + {{ $.Get + {{- end }} +{{- end -}} +``` + +Then call the shortcode from within your markup: + +```text {file="content/example/index.md"} +{{}} +``` + +The example above uses: + +- The [`with`] statement to rebind the [context](g) after each successful operation +- The [`Get`] method to retrieve arguments by name +- The `$` to access the template context + +> [!note] +> Make sure that you thoroughly understand the concept of context. The most common templating errors made by new users relate to context. +> +> Read more about context in the [introduction to templating]. + +### Insert image with error handling + +The previous example, while functional, silently fails if the image is missing, and does not gracefully exit if a required argument is missing. We'll add error handling to address these issues: + +```go-html-template {file="layouts/shortcodes/image.html"} +{{- with .Get "path" }} + {{- with $r := $.Page.Resources.Get ($.Get "path") }} + {{- with $.Get "width" }} + {{- with $r.Process (printf "resize %dx wepb" ($.Get "width" )) }} + {{- $alt := or ($.Get "alt") "" -}} + {{ $alt }} + {{- end }} + {{- else }} + {{- errorf "The %q shortcode requires a 'width' argument: see %s" $.Name $.Position }} + {{- end }} + {{- else }} + {{- warnf "The %q shortcode was unable to find %s: see %s" $.Name ($.Get "path") $.Position }} + {{- end }} +{{- else }} + {{- errorf "The %q shortcode requires a 'path' argument: see %s" .Name .Position }} +{{- end -}} +``` + +This template throws an error and gracefully fails the build if the author neglected to provide a `path` or `width` argument, and it emits a warning if it cannot find the image at the specified path. If the author does not provide an `alt` argument, the `alt` attribute is set to an empty string. + +The [`Name`] and [`Position`] methods provide helpful context for errors and warnings. For example, a missing `width` argument causes the shortcode to throw this error: + +```text +ERROR The "image" shortcode requires a 'width' argument: see "/home/user/project/content/example/index.md:7:1" +``` + +### Positional arguments + +Shortcode arguments can be [named or positional]. We used named arguments previously; let's explore positional arguments. Here's the named argument version of our example: + +```text {file="content/example/index.md"} +{{}} +``` + +Here's how to call it with positional arguments: + +```text {file="content/example/index.md"} +{{}} +``` + +Using the `Get` method with zero-indexed keys, we'll initialize variables with descriptive names in our template: + +```go-html-template {file="layouts/shortcodes/image.html"} +{{ $path := .Get 0 }} +{{ $width := .Get 1 }} +{{ $alt := .Get 2 }} +``` + +> [!note] +> Positional arguments work well for frequently used shortcodes with one or two arguments. Since you'll use them often, the argument order will be easy to remember. For less frequently used shortcodes, or those with more than two arguments, named arguments improve readability and reduce the chance of errors. + +### Named and positional arguments + +You can create a shortcode that will accept both named and positional arguments, but not at the same time. Use the [`IsNamedParams`] method to determine whether the shortcode call used named or positional arguments: + +```go-html-template {file="layouts/shortcodes/image.html"} +{{ $path := cond (.IsNamedParams) (.Get "path") (.Get 0) }} +{{ $width := cond (.IsNamedParams) (.Get "width") (.Get 1) }} +{{ $alt := cond (.IsNamedParams) (.Get "alt") (.Get 2) }} +``` + +This example uses the `cond` alias for the [`compare.Conditional`] function to get the argument by name if `IsNamedParams` returns `true`, otherwise get the argument by position. + +### Argument collection + +Use the [`Params`] method to access the arguments as a collection. + +When using named arguments, the `Params` method returns a map: + +```text {file="content/example/index.md"} +{{}} +``` + +```go-html-template {file="layouts/shortcodes/image.html"} +{{ .Params.path }} → a.jpg +{{ .Params.width }} → 300 +{{ .Params.alt }} → A white kitten +``` + + When using positional arguments, the `Params` method returns a slice: + +```text {file="content/example/index.md"} +{{}} +``` + +```go-html-template {file="layouts/shortcodes/image.html"} +{{ index .Params 0 }} → a.jpg +{{ index .Params 1 }} → 300 +{{ index .Params 1 }} → A white kitten +``` + +Combine the `Params` method with the [`collections.IsSet`] function to determine if a parameter is set, even if its value is falsy. + +### Inner content + +Extract the content enclosed within shortcode tags using the [`Inner`] method. This example demonstrates how to pass both content and a title to a shortcode. The shortcode then generates a `div` element containing an `h2` element (displaying the title) and the provided content. + +```text {file="content/example.md"} +{{}} +This is a **bold** word, and this is an _emphasized_ word. +{{}} +``` + +```go-html-template {file="layouts/shortcodes/contrived.html"} +
    +

    {{ .Get "title" }}

    + {{ .Inner | .Page.RenderString }} +
    +``` + +The preceding example called the shortcode using [standard notation], requiring us to process the inner content with the [`RenderString`] method to convert the Markdown to HTML. This conversion is unnecessary when calling a shortcode using [Markdown notation]. + +### Nesting + +The [`Parent`] method provides access to the parent shortcode context when the shortcode in question is called within the context of a parent shortcode. This provides an inheritance model. + +The following example is contrived but demonstrates the concept. Assume you have a `gallery` shortcode that expects one named `class` argument: + +```go-html-template {file="layouts/shortcodes/gallery.html"} +
    + {{ .Inner }} +
    +``` + +You also have an `img` shortcode with a single named `src` argument that you want to call inside of `gallery` and other shortcodes, so that the parent defines the context of each `img`: + +```go-html-template {file="layouts/shortcodes/img.html"} +{{ $src := .Get "src" }} +{{ with .Parent }} + +{{ else }} + +{{ end }} +``` + +You can then call your shortcode in your content as follows: + +```text {file="content/example.md"} +{{}} + {{}} + {{}} +{{}} +{{}} +``` + +This will output the following HTML. Note how the first two `img` shortcodes inherit the `class` value of `content-gallery` set with the call to the parent `gallery`, whereas the third `img` only uses `src`: + +```html + + +``` + +### Other examples + +For guidance, consider examining Hugo's embedded shortcodes. The source code, available on [GitHub], can provide a useful model. + +## Detection + +The [`HasShortcode`] method allows you to check if a specific shortcode has been called on a page. For example, consider a custom audio shortcode: + +```text {file="content/example.md"} +{{}} +``` + +You can use the `HasShortcode` method in your base template to conditionally load CSS if the audio shortcode was used on the page: + +```go-html-template {file="layouts/_default/baseof.html"} + + ... + {{ if .HasShortcode "audio" }} + + {{ end }} + ... + +``` + +[`collections.IsSet`]: /functions/collections/isset/ +[`compare.Conditional`]: /functions/compare/conditional/ +[`Get`]: /methods/shortcode/get/ +[`HasShortcode`]: /methods/page/hasshortcode/ +[`Inner`]: /methods/shortcode/inner/ +[`IsNamedParams`]: /methods/shortcode/isnamedparams/ +[`Name`]: /methods/shortcode/name/ +[`Params`]: /methods/shortcode/params/ +[`Parent`]: /methods/shortcode/parent/ +[`Position`]: /methods/shortcode/position/ +[`RenderString`]: /methods/page/renderstring/ +[`with`]: /functions/go-template/with/ +[content management]: /content-management/shortcodes/ +[embedded shortcodes]: /shortcodes/ +[GitHub]: https://github.com/gohugoio/hugo/tree/master/tpl/tplimpl/embedded/templates/_shortcodes +[introduction to templating]: /templates/introduction/ +[Markdown notation]: /content-management/shortcodes/#markdown-notation +[named or positional]: /content-management/shortcodes/#arguments +[shortcodes]: /content-management/shortcodes/ +[standard notation]: /content-management/shortcodes/#standard-notation +[whitespace]: /templates/introduction/#whitespace diff --git a/docs/content/en/templates/single-page-templates.md b/docs/content/en/templates/single-page-templates.md deleted file mode 100644 index 55e267d38..000000000 --- a/docs/content/en/templates/single-page-templates.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -title: Single Page Templates -linktitle: -description: The primary view of content in Hugo is the single view. Hugo will render every Markdown file provided with a corresponding single template. -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2017-04-06 -categories: [templates] -keywords: [page,templates] -menu: - docs: - parent: "templates" - weight: 60 -weight: 60 -sections_weight: 60 -draft: false -aliases: [/layout/content/] -toc: true ---- - -## Single Page Template Lookup Order - -See [Template Lookup](/templates/lookup-order/). - -## Example Single Page Templates - -Content pages are of the type `page` and will therefore have all the [page variables][pagevars] and [site variables][] available to use in their templates. - -### `posts/single.html` - -This single page template makes use of Hugo [base templates][], the [`.Format` function][] for dates, the [`.WordCount` page variable][pagevars], and ranges through the single content's specific [taxonomies][pagetaxonomy]. [`with`][] is also used to check whether the taxonomies are set in the front matter. - -{{< code file="layouts/posts/single.html" download="single.html" >}} -{{ define "main" }} -
    -

    {{ .Title }}

    -
    -
    - {{ .Content }} -
    -
    -
    - -{{ end }} -{{< /code >}} - -To easily generate new instances of a content type (e.g., new `.md` files in a section like `project/`) with preconfigured front matter, use [content archetypes][archetypes]. - -[archetypes]: /content-management/archetypes/ -[base templates]: /templates/base/ -[config]: /getting-started/configuration/ -[content type]: /content-management/types/ -[directory structure]: /getting-started/directory-structure/ -[dry]: https://en.wikipedia.org/wiki/Don%27t_repeat_yourself -[`.Format` function]: /functions/format/ -[front matter]: /content-management/front-matter/ -[pagetaxonomy]: /templates/taxonomy-templates/#displaying-a-single-piece-of-content-s-taxonomies -[pagevars]: /variables/page/ -[partials]: /templates/partials/ -[section]: /content-management/sections/ -[site variables]: /variables/site/ -[spf13]: https://spf13.com/ -[`with`]: /functions/with/ diff --git a/docs/content/en/templates/single.md b/docs/content/en/templates/single.md new file mode 100644 index 000000000..6f244ef10 --- /dev/null +++ b/docs/content/en/templates/single.md @@ -0,0 +1,51 @@ +--- +title: Single templates +description: Create a single template to render a single page. +categories: [] +keywords: [] +weight: 60 +aliases: [/layout/content/,/templates/single-page-templates/] +--- + +The single template below inherits the site's shell from the [base template]. + +[base template]: /templates/types/ + +```go-html-template {file="layouts/_default/single.html"} +{{ define "main" }} +

    {{ .Title }}

    + {{ .Content }} +{{ end }} +``` + +Review the [template lookup order] to select a template path that provides the desired level of specificity. + +[template lookup order]: /templates/lookup-order/#single-templates + +The single template below inherits the site's shell from the base template, and renders the page title, creation date, content, and a list of associated terms in the "tags" taxonomy. + +```go-html-template {file="layouts/_default/single.html"} +{{ define "main" }} +
    +

    {{ .Title }}

    + {{ with .Date }} + {{ $dateMachine := . | time.Format "2006-01-02T15:04:05-07:00" }} + {{ $dateHuman := . | time.Format ":date_long" }} + + {{ end }} +
    + {{ .Content }} +
    + +
    +{{ end }} +``` diff --git a/docs/content/en/templates/sitemap-template.md b/docs/content/en/templates/sitemap-template.md deleted file mode 100644 index 9afc0d389..000000000 --- a/docs/content/en/templates/sitemap-template.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -title: Sitemap Template -# linktitle: Sitemap -description: Hugo ships with a built-in template file observing the v0.9 of the Sitemap Protocol, but you can override this template if needed. -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2017-02-01 -categories: [templates] -keywords: [sitemap, xml, templates] -menu: - docs: - parent: "templates" - weight: 160 -weight: 160 -sections_weight: 160 -draft: false -aliases: [/layout/sitemap/,/templates/sitemap/] -toc: false ---- - -A single Sitemap template is used to generate the `sitemap.xml` file. -Hugo automatically comes with this template file. *No work is needed on -the users' part unless they want to customize `sitemap.xml`.* - -A sitemap is a `Page` and therefore has all the [page variables][pagevars] available to use in this template along with Sitemap-specific ones: - -`.Sitemap.ChangeFreq` -: The page change frequency - -`.Sitemap.Priority` -: The priority of the page - -`.Sitemap.Filename` -: The sitemap filename - -If provided, Hugo will use `/layouts/sitemap.xml` instead of the internal `sitemap.xml` template that ships with Hugo. - -## Sitemap Templates - -Hugo has built-on Sitemap templates, but you can provide your own if needed, in either `layouts/sitemap.xml` or `layouts/_default/sitemap.xml`. - -For multilingual sites, we also create a Sitemap index. You can provide a custom layout for that in either `layouts/sitemapindex.xml` or `layouts/_default/sitemapindex.xml`. - -## Hugo’s sitemap.xml - -This template respects the version 0.9 of the [Sitemap Protocol](http://www.sitemaps.org/protocol.html). - -```xml -{{ printf "" | safeHTML }} - - {{ range .Data.Pages }} - - {{ .Permalink }}{{ if not .Lastmod.IsZero }} - {{ safeHTML ( .Lastmod.Format "2006-01-02T15:04:05-07:00" ) }}{{ end }}{{ with .Sitemap.ChangeFreq }} - {{ . }}{{ end }}{{ if ge .Sitemap.Priority 0.0 }} - {{ .Sitemap.Priority }}{{ end }}{{ if .IsTranslated }}{{ range .Translations }} - {{ end }} - {{ end }} - - {{ end }} - -``` - -## Hugo's sitemapindex.xml - -This is used to create a Sitemap index in multilingual mode: - -```xml -{{ printf "" | safeHTML }} - - {{ range . }} - - {{ .SitemapAbsURL }} - {{ if not .LastChange.IsZero }} - {{ .LastChange.Format "2006-01-02T15:04:05-07:00" | safeHTML }} - {{ end }} - - {{ end }} - -``` - -## Configure `sitemap.xml` - -Defaults for ``, `` and `filename` values can be set in the site's config file, e.g.: - -{{< code-toggle file="config" >}} -[sitemap] - changefreq = "monthly" - priority = 0.5 - filename = "sitemap.xml" -{{}} - -The same fields can be specified in an individual content file's front matter in order to override the value assigned to that piece of content at render time. - - - -[pagevars]: /variables/page/ diff --git a/docs/content/en/templates/sitemap.md b/docs/content/en/templates/sitemap.md new file mode 100644 index 000000000..bf0850eef --- /dev/null +++ b/docs/content/en/templates/sitemap.md @@ -0,0 +1,62 @@ +--- +title: Sitemap templates +description: Hugo provides built-in sitemap templates. +categories: [] +keywords: [] +weight: 130 +aliases: [/layout/sitemap/,/templates/sitemap-template/] +--- + +## Overview + +Hugo's embedded sitemap templates conform to v0.9 of the [sitemap protocol]. + +With a monolingual project, Hugo generates a sitemap.xml file in the root of the [`publishDir`] using the [embedded sitemap template]. + +With a multilingual project, Hugo generates: + +- A sitemap.xml file in the root of each site (language) using the [embedded sitemap template] +- A sitemap.xml file in the root of the [`publishDir`] using the [embedded sitemapindex template] + +## Configuration + +See [configure sitemap](/configuration/sitemap). + +## Override default values + +Override the default values for a given page in front matter. + +{{< code-toggle file=news.md fm=true >}} +title = 'News' +[sitemap] + changefreq = 'weekly' + disable = true + priority = 0.8 +{{}} + +## Override built-in templates + +To override the built-in sitemap.xml template, create a new file in either of these locations: + +- `layouts/sitemap.xml` +- `layouts/_default/sitemap.xml` + +When ranging through the page collection, access the _change frequency_ and _priority_ with `.Sitemap.ChangeFreq` and `.Sitemap.Priority` respectively. + +To override the built-in sitemapindex.xml template, create a new file in either of these locations: + +- `layouts/sitemapindex.xml` +- `layouts/_default/sitemapindex.xml` + +## Disable sitemap generation + +You may disable sitemap generation in your site configuration: + +{{< code-toggle file=hugo >}} +disableKinds = ['sitemap'] +{{}} + +[`publishDir`]: /configuration/all/#publishdir +[embedded sitemap template]: {{% eturl sitemap %}} +[embedded sitemapindex template]: {{% eturl sitemapindex %}} +[sitemap protocol]: https://www.sitemaps.org/protocol.html diff --git a/docs/content/en/templates/taxonomy-templates.md b/docs/content/en/templates/taxonomy-templates.md deleted file mode 100644 index b82a5175c..000000000 --- a/docs/content/en/templates/taxonomy-templates.md +++ /dev/null @@ -1,375 +0,0 @@ ---- -title: Taxonomy Templates -# linktitle: -description: Taxonomy templating includes taxonomy list pages, taxonomy terms pages, and using taxonomies in your single page templates. -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2017-02-01 -categories: [templates] -keywords: [taxonomies,metadata,front matter,terms,templates] -menu: - docs: - parent: "templates" - weight: 50 -weight: 50 -sections_weight: 50 -draft: false -aliases: [/taxonomies/displaying/,/templates/terms/,/indexes/displaying/,/taxonomies/templates/,/indexes/ordering/, /templates/taxonomies/, /templates/taxonomy/] -toc: true ---- - - - -Hugo includes support for user-defined groupings of content called **taxonomies**. Taxonomies are classifications that demonstrate logical relationships between content. See [Taxonomies under Content Management](/content-management/taxonomies) if you are unfamiliar with how Hugo leverages this powerful feature. - -Hugo provides multiple ways to use taxonomies throughout your project templates: - -* Order the way content associated with a taxonomy term is displayed in a [taxonomy list template](#taxonomy-list-template) -* Order the way the terms for a taxonomy are displayed in a [taxonomy terms template](#taxonomy-terms-template) -* List a single content's taxonomy terms within a [single page template][] - -## Taxonomy List Templates - -Taxonomy list page templates are lists and therefore have all the variables and methods available to [list pages][lists]. - -### Taxonomy List Template Lookup Order - -See [Template Lookup](/templates/lookup-order/). - -## Taxonomy Terms Template - -### Taxonomy Terms Templates Lookup Order - -See [Template Lookup](/templates/lookup-order/). - -### Taxonomy Methods - -A Taxonomy is a `map[string]WeightedPages`. - -.Get(term) -: Returns the WeightedPages for a term. - -.Count(term) -: The number of pieces of content assigned to this term. - -.Alphabetical -: Returns an OrderedTaxonomy (slice) ordered by Term. - -.ByCount -: Returns an OrderedTaxonomy (slice) ordered by number of entries. - -.Reverse -: Returns an OrderedTaxonomy (slice) in reverse order. Must be used with an OrderedTaxonomy. - -### OrderedTaxonomy - -Since Maps are unordered, an OrderedTaxonomy is a special structure that has a defined order. - -```go -[]struct { - Name string - WeightedPages WeightedPages -} -``` - -Each element of the slice has: - -.Term -: The Term used. - -.WeightedPages -: A slice of Weighted Pages. - -.Count -: The number of pieces of content assigned to this term. - -.Pages -: All Pages assigned to this term. All [list methods][renderlists] are available to this. - -## WeightedPages - -WeightedPages is simply a slice of WeightedPage. - -```go -type WeightedPages []WeightedPage -``` - -.Count(term) -: The number of pieces of content assigned to this term. - -.Pages -: Returns a slice of pages, which then can be ordered using any of the [list methods][renderlists]. - -## Displaying custom metadata in Taxonomy Terms Templates - -If you need to display custom metadata for each taxonomy term, you will need to create a page for that term at `/content///_index.md` and add your metadata in its front matter, [as explained in the taxonomies documentation](/content-management/taxonomies/#add-custom-meta-data-to-a-taxonomy-term). Based on the Actors taxonomy example shown there, within your taxonomy terms template, you may access your custom fields by iterating through the variable `.Pages` as such: - -```go-html-template -
      - {{ range .Pages }} -
    • - {{ .Title }} - {{ .Params.wikipedia }} -
    • - {{ end }} -
    -``` - - - -## Order Taxonomies - -Taxonomies can be ordered by either alphabetical key or by the number of content pieces assigned to that key. - -### Order Alphabetically Example - -In Hugo 0.55 and later you can do: - -```go-html-template -
      - {{ range .Data.Terms.Alphabetical }} -
    • {{ .Page.Title }} {{ .Count }}
    • - {{ end }} -
    -``` - -Before that you would have to do something like: - -```go-html-template -
      - {{ $type := .Type }} - {{ range $key, $value := .Data.Terms.Alphabetical }} - {{ $name := .Name }} - {{ $count := .Count }} - {{ with $.Site.GetPage (printf "/%s/%s" $type $name) }} -
    • {{ $name }} {{ $count }}
    • - {{ end }} - {{ end }} -
    -``` - - - - -## Order Content within Taxonomies - -Hugo uses both `date` and `weight` to order content within taxonomies. - -Each piece of content in Hugo can optionally be assigned a date. It can also be assigned a weight for each taxonomy it is assigned to. - -When iterating over content within taxonomies, the default sort is the same as that used for [section and list pages]() first by weight then by date. This means that if the weights for two pieces of content are the same, than the more recent content will be displayed first. The default weight for any piece of content is 0. - -### Assign Weight - -Content can be assigned weight for each taxonomy that it's assigned to. - -``` -+++ -tags = [ "a", "b", "c" ] -tags_weight = 22 -categories = ["d"] -title = "foo" -categories_weight = 44 -+++ -Front Matter with weighted tags and categories -``` - -The convention is `taxonomyname_weight`. - -In the above example, this piece of content has a weight of 22 which applies to the sorting when rendering the pages assigned to the "a", "b" and "c" values of the 'tag' taxonomy. - -It has also been assigned the weight of 44 when rendering the 'd' category. - -With this the same piece of content can appear in different positions in different taxonomies. - -Currently taxonomies only support the default ordering of content which is weight -> date. - - - -There are two different templates that the use of taxonomies will require you to provide. - -Both templates are covered in detail in the templates section. - -A [list template](/templates/list/) is any template that will be used to render multiple pieces of content in a single html page. This template will be used to generate all the automatically created taxonomy pages. - -A [taxonomy terms template](/templates/terms/) is a template used to -generate the list of terms for a given template. - - - -There are four common ways you can display the data in your -taxonomies in addition to the automatic taxonomy pages created by hugo -using the [list templates](/templates/list/): - -1. For a given piece of content, you can list the terms attached -2. For a given piece of content, you can list other content with the same - term -3. You can list all terms for a taxonomy -4. You can list all taxonomies (with their terms) - -## Display a Single Piece of Content's Taxonomies - -Within your content templates, you may wish to display the taxonomies that piece of content is assigned to. - -Because we are leveraging the front matter system to define taxonomies for content, the taxonomies assigned to each content piece are located in the usual place (i.e., `.Params.`). - -### Example: List Tags in a Single Page Template - -```go-html-template -{{ $taxo := "tags" }} -
      - {{ range .Param $taxo }} - {{ $name := . }} - {{ with $.Site.GetPage (printf "/%s/%s" $taxo ($name | urlize)) }} -
    • {{ $name }}
    • - {{ end }} - {{ end }} -
    -``` - -If you want to list taxonomies inline, you will have to take care of optional plural endings in the title (if multiple taxonomies), as well as commas. Let's say we have a taxonomy "directors" such as `directors: [ "Joel Coen", "Ethan Coen" ]` in the TOML-format front matter. - -To list such taxonomies, use the following: - -### Example: Comma-delimit Tags in a Single Page Template - -```go-html-template -{{ $taxo := "directors" }} -{{ with .Param $taxo }} - Director{{ if gt (len .) 1 }}s{{ end }}: - {{ range $index, $director := . }} - {{- if gt $index 0 }}, {{ end -}} - {{ with $.Site.GetPage (printf "/%s/%s" $taxo $director) -}} - {{ $director }} - {{- end -}} - {{- end -}} -{{ end }} -``` - -Alternatively, you may use the [delimit template function][delimit] as a shortcut if the taxonomies should just be listed with a separator. See {{< gh 2143 >}} on GitHub for discussion. - -## List Content with the Same Taxonomy Term - -If you are using a taxonomy for something like a series of posts, you can list individual pages associated with the same taxonomy. This is also a quick and dirty method for showing related content: - -### Example: Showing Content in Same Series - -```go-html-template - -``` - -## List All content in a Given taxonomy - -This would be very useful in a sidebar as “featured content”. You could even have different sections of “featured content” by assigning different terms to the content. - -### Example: Grouping "Featured" Content - -```go-html-template - -``` - -## Render a Site's Taxonomies - -If you wish to display the list of all keys for your site's taxonomy, you can retrieve them from the [`.Site` variable][sitevars] available on every page. - -This may take the form of a tag cloud, a menu, or simply a list. - -The following example displays all terms in a site's tags taxonomy: - -### Example: List All Site Tags {#example-list-all-site-tags} - -In Hugo 0.55 and later you can simply do: - -```go-html-template - -``` - -Before that you would do something like this: - -{{< todo >}}Clean up rest of the taxonomy examples re Hugo 0.55.{{< /todo >}} - -```go-html-template -
      - {{ range $name, $taxonomy := .Site.Taxonomies.tags }} - {{ with $.Site.GetPage (printf "/tags/%s" $name) }} -
    • {{ $name }}
    • - {{ end }} - {{ end }} -
    -``` - -### Example: List All Taxonomies, Terms, and Assigned Content - -This example will list all taxonomies and their terms, as well as all the content assigned to each of the terms. - -{{< code file="layouts/partials/all-taxonomies.html" download="all-taxonomies.html" download="all-taxonomies.html" >}} -
    -
      - {{ range $taxonomy_term, $taxonomy := .Site.Taxonomies }} - {{ with $.Site.GetPage (printf "/%s" $taxonomy_term) }} -
    • {{ $taxonomy_term }} -
        - {{ range $key, $value := $taxonomy }} -
      • {{ $key }}
      • - - {{ end }} -
      -
    • - {{ end }} - {{ end }} -
    -
    -{{< /code >}} - -## `.Site.GetPage` for Taxonomies - -Because taxonomies are lists, the [`.GetPage` function][getpage] can be used to get all the pages associated with a particular taxonomy term using a terse syntax. The following ranges over the full list of tags on your site and links to each of the individual taxonomy pages for each term without having to use the more fragile URL construction of the ["List All Site Tags" example above]({{< relref "#example-list-all-site-tags" >}}): - -{{< code file="links-to-all-tags.html" >}} -{{ $taxo := "tags" }} -
      - {{ with ($.Site.GetPage (printf "/%s" $taxo)) }} - {{ range .Pages }} -
    • {{ .Title}}
    • - {{ end }} - {{ end }} -
    -{{< /code >}} - - - - - - -[delimit]: /functions/delimit/ -[getpage]: /functions/getpage/ -[lists]: /templates/lists/ -[renderlists]: /templates/lists/ -[single page template]: /templates/single-page-templates/ -[sitevars]: /variables/site/ diff --git a/docs/content/en/templates/taxonomy.md b/docs/content/en/templates/taxonomy.md new file mode 100644 index 000000000..96c93ec95 --- /dev/null +++ b/docs/content/en/templates/taxonomy.md @@ -0,0 +1,164 @@ +--- +title: Taxonomy templates +description: Create a taxonomy template to render a list of terms. +categories: [] +keywords: [] +weight: 80 +aliases: [/taxonomies/displaying/,/templates/terms/,/indexes/displaying/,/taxonomies/templates/,/indexes/ordering/, /templates/taxonomies/, /templates/taxonomy-templates/] +--- + +The [taxonomy](g) template below inherits the site's shell from the [base template], and renders a list of [terms](g) in the current taxonomy. + +[base template]: /templates/types/ + +```go-html-template {file="layouts/_default/taxonomy.html"} +{{ define "main" }} +

    {{ .Title }}

    + {{ .Content }} + {{ range .Pages }} +

    {{ .LinkTitle }}

    + {{ end }} +{{ end }} +``` + +Review the [template lookup order] to select a template path that provides the desired level of specificity. + +[template lookup order]: /templates/lookup-order/#taxonomy-templates + +In the example above, the taxonomy and term will be capitalized if their respective pages are not backed by files. You can disable this in your site configuration: + +{{< code-toggle file=hugo >}} +capitalizeListTitles = false +{{< /code-toggle >}} + +## Data object + +Use these methods on the `Data` object within a taxonomy template. + +Singular +: (`string`) Returns the singular name of the taxonomy. + +```go-html-template +{{ .Data.Singular }} → tag +``` + +Plural +: (`string`) Returns the plural name of the taxonomy. + +```go-html-template +{{ .Data.Plural }} → tags +``` + +Terms +: (`page.Taxonomy`) Returns the `Taxonomy` object, consisting of a map of terms and the [weighted pages](g) associated with each term. + +```go-html-template +{{ $taxonomyObject := .Data.Terms }} +``` + +Once we have the `Taxonomy` object, we can call any of its [methods], allowing us to sort alphabetically or by term count. + +[methods]: /methods/taxonomy/ + +## Sort alphabetically + +The taxonomy template below inherits the site's shell from the base template, and renders a list of terms in the current taxonomy. Hugo sorts the list alphabetically by term, and displays the number of pages associated with each term. + +```go-html-template {file="layouts/_default/taxonomy.html"} +{{ define "main" }} +

    {{ .Title }}

    + {{ .Content }} + {{ range .Data.Terms.Alphabetical }} +

    {{ .Page.LinkTitle }} ({{ .Count }})

    + {{ end }} +{{ end }} +``` + +## Sort by term count + +The taxonomy template below inherits the site's shell from the base template, and renders a list of terms in the current taxonomy. Hugo sorts the list by the number of pages associated with each term, and displays the number of pages associated with each term. + +```go-html-template {file="layouts/_default/taxonomy.html"} +{{ define "main" }} +

    {{ .Title }}

    + {{ .Content }} + {{ range .Data.Terms.ByCount }} +

    {{ .Page.LinkTitle }} ({{ .Count }})

    + {{ end }} +{{ end }} +``` + +## Include content links + +The [`Alphabetical`] and [`ByCount`] methods used in the previous examples return an [ordered taxonomy](g), so we can also list the content to which each term is assigned. + +[`Alphabetical`]: /methods/taxonomy/alphabetical/ +[`ByCount`]: /methods/taxonomy/bycount/ + +The taxonomy template below inherits the site's shell from the base template, and renders a list of terms in the current taxonomy. Hugo sorts the list by the number of pages associated with each term, displays the number of pages associated with each term, then lists the content to which each term is assigned. + +```go-html-template {file="layouts/_default/taxonomy.html"} +{{ define "main" }} +

    {{ .Title }}

    + {{ .Content }} + {{ range .Data.Terms.ByCount }} +

    {{ .Page.LinkTitle }} ({{ .Count }})

    + + {{ end }} +{{ end }} +``` + +## Display metadata + +Display metadata about each term by creating a corresponding branch bundle in the `content` directory. + +For example, create an "authors" taxonomy: + +{{< code-toggle file=hugo >}} +[taxonomies] +author = 'authors' +{{< /code-toggle >}} + +Then create content with one [branch bundle](g) for each term: + +```text +content/ +└── authors/ + ├── jsmith/ + │ ├── _index.md + │ └── portrait.jpg + └── rjones/ + ├── _index.md + └── portrait.jpg +``` + +Then add front matter to each term page: + +{{< code-toggle file=content/authors/jsmith/_index.md fm=true >}} +title = "John Smith" +affiliation = "University of Chicago" +{{< /code-toggle >}} + +Then create a taxonomy template specific to the "authors" taxonomy: + +```go-html-template {file="layouts/authors/taxonomy.html"} +{{ define "main" }} +

    {{ .Title }}

    + {{ .Content }} + {{ range .Data.Terms.Alphabetical }} +

    {{ .Page.LinkTitle }}

    +

    Affiliation: {{ .Page.Params.Affiliation }}

    + {{ with .Page.Resources.Get "portrait.jpg" }} + {{ with .Fill "100x100" }} + portrait + {{ end }} + {{ end }} + {{ end }} +{{ end }} +``` + +In the example above we list each author including their affiliation and portrait. diff --git a/docs/content/en/templates/template-debugging.md b/docs/content/en/templates/template-debugging.md deleted file mode 100644 index 419581b90..000000000 --- a/docs/content/en/templates/template-debugging.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -title: Template Debugging -# linktitle: Template Debugging -description: You can use Go templates' `printf` function to debug your Hugo templates. These snippets provide a quick and easy visualization of the variables available to you in different contexts. -godocref: https://golang.org/pkg/fmt/ -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2017-02-01 -categories: [templates] -keywords: [debugging,troubleshooting] -menu: - docs: - parent: "templates" - weight: 180 -weight: 180 -sections_weight: 180 -draft: false -aliases: [] -toc: false ---- - -Here are some snippets you can add to your template to answer some common questions. - -These snippets use the `printf` function available in all Go templates. This function is an alias to the Go function, [fmt.Printf](https://golang.org/pkg/fmt/). - -## What Variables are Available in this Context? - -You can use the template syntax, `$.`, to get the top-level template context from anywhere in your template. This will print out all the values under, `.Site`. - -``` -{{ printf "%#v" $.Site }} -``` - -This will print out the value of `.Permalink`: - - -``` -{{ printf "%#v" .Permalink }} -``` - - -This will print out a list of all the variables scoped to the current context -(`.`, aka ["the dot"][tempintro]). - - -``` -{{ printf "%#v" . }} -``` - - -When developing a [homepage][], what does one of the pages you're looping through look like? - -``` -{{ range .Pages }} - {{/* The context, ".", is now each one of the pages as it goes through the loop */}} - {{ printf "%#v" . }} -{{ end }} -``` - -## Why Am I Showing No Defined Variables? - -Check that you are passing variables in the `partial` function: - -``` -{{ partial "header" }} -``` - -This example will render the header partial, but the header partial will not have access to any contextual variables. You need to pass variables explicitly. For example, note the addition of ["the dot"][tempintro]. - -``` -{{ partial "header" . }} -``` - -The dot (`.`) is considered fundamental to understanding Hugo templating. For more information, see [Introduction to Hugo Templating][tempintro]. - -[homepage]: /templates/homepage/ -[tempintro]: /templates/introduction/ diff --git a/docs/content/en/templates/term.md b/docs/content/en/templates/term.md new file mode 100644 index 000000000..cf1097e86 --- /dev/null +++ b/docs/content/en/templates/term.md @@ -0,0 +1,107 @@ +--- +title: Term templates +description: Create a term template to render a list of pages associated with the current term. +categories: [] +keywords: [] +weight: 90 +--- + +The [term](g) template below inherits the site's shell from the [base template], and renders a list of pages associated with the current term. + +[base template]: /templates/types/ + +```go-html-template {file="layouts/_default/term.html"} +{{ define "main" }} +

    {{ .Title }}

    + {{ .Content }} + {{ range .Pages }} +

    {{ .LinkTitle }}

    + {{ end }} +{{ end }} +``` + +Review the [template lookup order] to select a template path that provides the desired level of specificity. + +[template lookup order]: /templates/lookup-order/#taxonomy-templates + +In the example above, the term will be capitalized if its respective page is not backed by a file. You can disable this in your site configuration: + +{{< code-toggle file=hugo >}} +capitalizeListTitles = false +{{< /code-toggle >}} + +## Data object + +Use these methods on the `Data` object within a term template. + +Singular +: (`string`) Returns the singular name of the taxonomy. + +```go-html-template +{{ .Data.Singular }} → tag +``` + +Plural +: (`string`) Returns the plural name of the taxonomy. + +```go-html-template +{{ .Data.Plural }} → tags +``` + +Term +: (`string`) Returns the name of the term. + +```go-html-template +{{ .Data.Term }} → fiction +``` + +## Display metadata + +Display metadata about each term by creating a corresponding branch bundle in the `content` directory. + +For example, create an "authors" taxonomy: + +{{< code-toggle file=hugo >}} +[taxonomies] +author = 'authors' +{{< /code-toggle >}} + +Then create content with one [branch bundle](g) for each term: + +```text +content/ +└── authors/ + ├── jsmith/ + │ ├── _index.md + │ └── portrait.jpg + └── rjones/ + ├── _index.md + └── portrait.jpg +``` + +Then add front matter to each term page: + +{{< code-toggle file=content/authors/jsmith/_index.md fm=true >}} +title = "John Smith" +affiliation = "University of Chicago" +{{< /code-toggle >}} + +Then create a term template specific to the "authors" taxonomy: + +```go-html-template {file="layouts/authors/term.html"} +{{ define "main" }} +

    {{ .Title }}

    +

    Affiliation: {{ .Params.affiliation }}

    + {{ with .Resources.Get "portrait.jpg" }} + {{ with .Fill "100x100" }} + portrait + {{ end }} + {{ end }} + {{ .Content }} + {{ range .Pages }} +

    {{ .LinkTitle }}

    + {{ end }} +{{ end }} +``` + +In the example above we display the author with their affiliation and portrait, then a list of associated content. diff --git a/docs/content/en/templates/types.md b/docs/content/en/templates/types.md new file mode 100644 index 000000000..b44d3eb47 --- /dev/null +++ b/docs/content/en/templates/types.md @@ -0,0 +1,261 @@ +--- +title: Template types +description: Create templates of different types to render your content, resources, and data. +categories: [] +keywords: [] +weight: 30 +aliases: ['/templates/lists/'] +--- + +## Structure + +Create templates in the `layouts` directory in the root of your project. + +Although your site may not require each of these templates, the example below is typical for a site of medium complexity. + +```text +layouts/ +├── _default/ +│ ├── _markup/ +│ │ ├── render-image.html <-- render hook +│ │ └── render-link.html <-- render hook +│ ├── baseof.html +│ ├── home.html +│ ├── section.html +│ ├── single.html +│ ├── taxonomy.html +│ └── term.html +├── articles/ +│ └── card.html <-- content view +├── partials/ +│ ├── footer.html +│ └── header.html +└── shortcodes/ + ├── audio.html + └── video.html +``` + +Hugo's [template lookup order] determines the template path, allowing you to create unique templates for any page. + +> [!note] +> You must have thorough understanding of the template lookup order when creating templates. Template selection is based on template type, page kind, content type, section, language, and output format. + +The purpose of each template type is described below. + +## Base + +Base templates reduce duplicate code by wrapping other templates within a shell. + +For example, the base template below calls the [partial] function to include partial templates for the `head`, `header`, and `footer` elements of each page, and it uses the [block] function to include `home`, `single`, `section`, `taxonomy`, and `term` templates within the `main` element of each page. + +```go-html-template {file="layouts/_default/baseof.html"} + + + + {{ partial "head.html" . }} + + +
    + {{ partial "header.html" . }} +
    +
    + {{ block "main" . }}{{ end }} +
    +
    + {{ partial "footer.html" . }} +
    + + +``` + +Learn more about [base templates](/templates/base/). + +## Home + +A home page template is used to render your site's home page, and is the only template required for a single-page website. For example, the home page template below inherits the site's shell from the base template and renders the home page content, such as a list of other pages. + +```go-html-template {file="layouts/_default/home.html"} +{{ define "main" }} + {{ .Content }} + {{ range site.RegularPages }} +

    {{ .LinkTitle }}

    + {{ end }} +{{ end }} +``` + +{{% include "/_common/filter-sort-group.md" %}} + +Learn more about [home page templates](/templates/home/). + +## Single + +A single template renders a single page. + +For example, the single template below inherits the site's shell from the base template, and renders the title and content of each page. + +```go-html-template {file="layouts/_default/single.html"} +{{ define "main" }} +

    {{ .Title }}

    + {{ .Content }} +{{ end }} +``` + +Learn more about [single templates](/templates/single/). + +## Section + +A section template typically renders a list of pages within a section. + +For example, the section template below inherits the site's shell from the base template, and renders a list of pages in the current section. + +```go-html-template {file="layouts/_default/section.html"} +{{ define "main" }} +

    {{ .Title }}

    + {{ .Content }} + {{ range .Pages }} +

    {{ .LinkTitle }}

    + {{ end }} +{{ end }} +``` + +{{% include "/_common/filter-sort-group.md" %}} + +Learn more about [section templates](/templates/section/). + +## Taxonomy + +A taxonomy template renders a list of terms in a [taxonomy](g). + +For example, the taxonomy template below inherits the site's shell from the base template, and renders a list of terms in the current taxonomy. + +```go-html-template {file="layouts/_default/taxonomy.html"} +{{ define "main" }} +

    {{ .Title }}

    + {{ .Content }} + {{ range .Pages }} +

    {{ .LinkTitle }}

    + {{ end }} +{{ end }} +``` + +{{% include "/_common/filter-sort-group.md" %}} + +Learn more about [taxonomy templates](/templates/taxonomy/). + +## Term + +A term template renders a list of pages associated with a [term](g). + +For example, the term template below inherits the site's shell from the base template, and renders a list of pages associated with the current term. + +```go-html-template {file="layouts/_default/term.html"} +{{ define "main" }} +

    {{ .Title }}

    + {{ .Content }} + {{ range .Pages }} +

    {{ .LinkTitle }}

    + {{ end }} +{{ end }} +``` + +{{% include "/_common/filter-sort-group.md" %}} + +Learn more about [term templates](/templates/term/). + +## Partial + +A partial template is typically used to render a component of your site, though you may also create partial templates that return values. + +> [!note] +> Unlike other template types, you cannot create partial templates to target a particular page kind, content type, section, language, or output format. Partial templates do not follow Hugo's [template lookup order]. + +For example, the partial template below renders copyright information. + +```go-html-template {file="layouts/partials/footer.html"} +

    Copyright {{ now.Year }}. All rights reserved.

    +``` + +Learn more about [partial templates](/templates/partial/). + +## Content view + +A content view template is similar to a partial template, invoked by calling the [`Render`] method on a `Page` object. Unlike partial templates, content view templates: + +- Automatically inherit the context of the current page +- Follow a lookup order allowing you to target a given content type or section + +For example, the home template below inherits the site's shell from the base template, and renders a card component for each page within the "articles" section of your site. + +```go-html-template {file="layouts/_default/home.html"} +{{ define "main" }} + {{ .Content }} +
      + {{ range where site.RegularPages "Section" "articles" }} + {{ .Render "card" }} + {{ end }} +
    +{{ end }} +``` + +```go-html-template {file="layouts/articles/card.html"} +
    +

    {{ .LinkTitle }}

    + {{ .Summary }} +
    +``` + +Learn more about [content view templates](/templates/content-view/). + +## Render hook + +A render hook template overrides the conversion of Markdown to HTML. + +For example, the render hook template below adds a `rel` attribute to external links. + +```go-html-template {file="layouts/_default/_markup/render-link.html"} +{{- $u := urls.Parse .Destination -}} + + {{- with .Text }}{{ . }}{{ end -}} + +{{- /* chomp trailing newline */ -}} +``` + +Learn more about [render hook templates](/render-hooks/). + +## Shortcode + +A shortcode template is used to render a component of your site. Unlike partial templates, shortcode templates are called from content pages. + +For example, the shortcode template below renders an audio element from a [global resource](g). + +```go-html-template {file="layouts/shortcodes/audio.html"} +{{ with resources.Get (.Get "src") }} + +{{ end }} +``` + +Then call the shortcode from within markup: + +```text {file="content/example.md"} +{{}} +``` + +Learn more about [shortcode templates](/templates/shortcode/). + +## Other + +Use other specialized templates to create: + +- [Sitemaps](/templates/sitemap) +- [RSS feeds](/templates/rss/) +- [404 error pages](/templates/404/) +- [robots.txt files](/templates/robots/) + +[`Render`]: /methods/page/render/ +[block]: /functions/go-template/block/ +[partial]: /functions/partials/include/ +[template lookup order]: /templates/lookup-order/ +[template lookup order]: /templates/lookup-order/ diff --git a/docs/content/en/templates/views.md b/docs/content/en/templates/views.md deleted file mode 100644 index 87f66afe0..000000000 --- a/docs/content/en/templates/views.md +++ /dev/null @@ -1,121 +0,0 @@ ---- -title: Content View Templates -# linktitle: Content Views -description: Hugo can render alternative views of your content, which is especially useful in list and summary views. -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2017-02-01 -categories: [templates] -keywords: [views] -menu: - docs: - parent: "templates" - weight: 70 -weight: 70 -sections_weight: 70 -draft: false -aliases: [] -toc: true ---- - -These alternative **content views** are especially useful in [list templates][lists]. - -The following are common use cases for content views: - -* You want content of every type to be shown on the homepage but only with limited [summary views][summaries]. -* You only want a bulleted list of your content on a [taxonomy list page][taxonomylists]. Views make this very straightforward by delegating the rendering of each different type of content to the content itself. - -## Create a Content View - -To create a new view, create a template in each of your different content type directories with the view name. The following example contains an "li" view and a "summary" view for the `posts` and `project` content types. As you can see, these sit next to the [single content view][single] template, `single.html`. You can even provide a specific view for a given type and continue to use the `_default/single.html` for the primary view. - -``` - ▾ layouts/ - ▾ posts/ - li.html - single.html - summary.html - ▾ project/ - li.html - single.html - summary.html -``` - -Hugo also has support for a default content template to be used in the event that a specific content view template has not been provided for that type. Content views can also be defined in the `_default` directory and will work the same as list and single templates who eventually trickle down to the `_default` directory as a matter of the lookup order. - - -``` -▾ layouts/ - ▾ _default/ - li.html - single.html - summary.html -``` - -## Which Template Will be Rendered? - -The following is the [lookup order][lookup] for content views: - -1. `/layouts//.html` -2. `/layouts/_default/.html` -3. `/themes//layouts//.html` -4. `/themes//layouts/_default/.html` - -## Example: Content View Inside a List - -The following example demonstrates how to use content views inside of your [list templates][lists]. - -### `list.html` - -In this example, `.Render` is passed into the template to call the [render function][render]. `.Render` is a special function that instructs content to render itself with the view template provided as the first argument. In this case, the template is going to render the `summary.html` view that follows: - -{{< code file="layouts/_default/list.html" download="list.html" >}} -
    -
    -

    {{ .Title }}

    - {{ range .Pages }} - {{ .Render "summary"}} - {{ end }} -
    -
    -{{< /code >}} - -### `summary.html` - -Hugo will pass the entire page object to the following `summary.html` view template. (See [Page Variables][pagevars] for a complete list.) - -{{< code file="layouts/_default/summary.html" download="summary.html" >}} - -{{< /code >}} - -### `li.html` - -Continuing on the previous example, we can change our render function to use a smaller `li.html` view by changing the argument in the call to the `.Render` function (i.e., `{{ .Render "li" }}`). - -{{< code file="layouts/_default/li.html" download="li.html" >}} -
  • - {{ .Title }} -
    {{ .Date.Format "Mon, Jan 2, 2006" }}
    -
  • -{{< /code >}} - -[lists]: /templates/lists/ -[lookup]: /templates/lookup-order/ -[pagevars]: /variables/page/ -[render]: /functions/render/ -[single]: /templates/single-page-templates/ -[spf]: https://spf13.com -[spfsourceli]: https://github.com/spf13/spf13.com/blob/master/layouts/_default/li.html -[spfsourcesection]: https://github.com/spf13/spf13.com/blob/master/layouts/_default/section.html -[spfsourcesummary]: https://github.com/spf13/spf13.com/blob/master/layouts/_default/summary.html -[summaries]: /content-management/summaries/ -[taxonomylists]: /templates/taxonomy-templates/ diff --git a/docs/content/en/tools/_index.md b/docs/content/en/tools/_index.md index a186ffb06..3acc287ae 100644 --- a/docs/content/en/tools/_index.md +++ b/docs/content/en/tools/_index.md @@ -1,24 +1,7 @@ --- -title: Developer Tools -linktitle: Developer Tools Overview -description: In addition to Hugo's powerful CLI, there is a large number of community-developed tool chains for Hugo developers. -date: 2016-12-05 -publishdate: 2016-12-05 -lastmod: 2017-02-26 -categories: [developer tools] +title: Developer tools +description: Third-party tools to help you create and manage sites. +categories: [] keywords: [] -menu: - docs: - parent: "tools" - weight: 01 -weight: 01 -sections_weight: 01 -draft: false +weight: 10 --- - -One of Hugo's greatest strengths is its passionate---and always evolving---developer community. With the exception of the `highlight` shortcode mentioned in [Syntax Highlighting][syntax], the tools and other projects featured in this section are offerings from both commercial services and open-source projects, many of which are developed by Hugo developers just like you. - -[See the popularity of Hugo compared with other static site generators.][staticgen] - -[staticgen]: https://staticgen.com -[syntax]: /tools/syntax-highlighting/ diff --git a/docs/content/en/tools/editors.md b/docs/content/en/tools/editors.md index f0d82d65d..c375fcba8 100644 --- a/docs/content/en/tools/editors.md +++ b/docs/content/en/tools/editors.md @@ -1,47 +1,58 @@ --- -title: Editor Plug-ins for Hugo -linktitle: Editor Plug-ins -description: The Hugo community uses a wide range of preferred tools and has developed plug-ins for some of the most popular text editors to help automate parts of your workflow. -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2017-02-01 -categories: [developer tools] -keywords: [editor, plug-ins] -menu: - docs: - parent: "tools" - weight: 50 -weight: 50 -sections_weight: 50 -draft: false -aliases: [] -toc: false +title: Editor plugins +description: The Hugo community uses a wide range of tools and has developed plugins for some of the most popular text editors to help automate parts of your workflow. +categories: [] +keywords: [] +weight: 10 --- -The Hugo community uses a wide range of preferred tools and has developed plug-ins for some of the most popular text editors to help automate parts of your workflow. - -## Sublime Text - -* [Hugofy](https://github.com/akmittal/Hugofy). Hugofy is a plugin for Sublime Text 3 to make life easier to use Hugo static site generator. - ## Visual Studio Code -* [Hugofy](https://marketplace.visualstudio.com/items?itemName=akmittal.hugofy). Hugofy is a plugin for Visual Studio Code to "make life easier" when developing with Hugo. The source code can be found [here](https://github.com/akmittal/hugofy-vscode). -* [Hugo Helper](https://marketplace.visualstudio.com/items?itemName=rusnasonov.vscode-hugo). Hugo Helper is a plugin for Visual Studio Code that has some useful commands for Hugo. The source code can be found [here](https://github.com/rusnasonov/vscode-hugo). -* [Hugo Language and Syntax Support](https://marketplace.visualstudio.com/items?itemName=budparr.language-hugo-vscode). Hugo Language and Syntax Support is a Visual Studio Code plugin for Hugo syntax highlighting and snippets. The source code can be found [here](https://github.com/budparr/language-hugo-vscode). +[Front Matter](https://marketplace.visualstudio.com/items?itemName=eliostruyf.vscode-front-matter) +: Once you go for a static site, you need to think about how you are going to manage your articles. Front matter is a tool that helps you maintain the metadata/front matter of your articles like: creation date, modified date, slug, tile, SEO check, and more. + +[Hugo Helper](https://marketplace.visualstudio.com/items?itemName=rusnasonov.vscode-hugo) +: Hugo Helper is a plugin for Visual Studio Code that has some useful commands for Hugo. The source code can be found [here](https://github.com/rusnasonov/vscode-hugo). + +[Hugo Language and Syntax Support](https://marketplace.visualstudio.com/items?itemName=budparr.language-hugo-vscode) +: Hugo Language and Syntax Support is a Visual Studio Code plugin for Hugo syntax highlighting and snippets. The source code can be found [here](https://github.com/budparr/language-hugo-vscode). + +[Hugo Themer](https://marketplace.visualstudio.com/items?itemName=eliostruyf.vscode-hugo-themer) +: Hugo Themer is an extension to help you while developing themes. It allows you to easily navigate through your theme files. + +[Hugofy](https://marketplace.visualstudio.com/items?itemName=akmittal.hugofy) +: Hugofy is a plugin for Visual Studio Code to "make life easier" when developing with Hugo. The source code can be found [here](https://github.com/akmittal/hugofy-vscode). + +[Prettier Plugin for Go Templates](https://github.com/NiklasPor/prettier-plugin-go-template) +: Format Hugo templates using this [Prettier](https://prettier.io/) plugin. See [installation instructions](https://discourse.gohugo.io/t/38403). + +[Syntax Highlighting for Hugo Shortcodes](https://marketplace.visualstudio.com/items?itemName=kaellarkin.hugo-shortcode-syntax) +: This extension adds some syntax highlighting for Shortcodes, making visual identification of individual pieces easier. ## Emacs -* [emacs-easy-hugo](https://github.com/masasam/emacs-easy-hugo). Emacs major mode for managing hugo blogs. Note that Hugo also supports [Org-mode][formats]. -* [ox-hugo.el](https://ox-hugo.scripter.co). Native Org-mode exporter that exports to Blackfriday Markdown with Hugo front-matter. `ox-hugo` supports two common Org blogging flows --- exporting multiple Org sub-trees in a single file to multiple Hugo posts, and exporting a single Org file to a single Hugo post. It also leverages the Org tag and property inheritance features. See [*Why ox-hugo?*](https://ox-hugo.scripter.co/doc/why-ox-hugo/) for more. +[emacs-easy-hugo](https://github.com/masasam/emacs-easy-hugo) +: Emacs major mode for managing hugo blogs. Note that Hugo also supports [Org-mode][formats]. + +[ox-hugo.el](https://ox-hugo.scripter.co) +: Native Org-mode exporter that exports to Blackfriday Markdown with Hugo front-matter. `ox-hugo` supports two common Org blogging flows --- exporting multiple Org subtrees in a single file to multiple Hugo posts, and exporting a single Org file to a single Hugo post. It also leverages the Org tag and property inheritance features. See [*Why ox-hugo?*](https://ox-hugo.scripter.co/doc/why-ox-hugo/) for more. + +## Sublime Text + +[Hugofy](https://github.com/akmittal/Hugofy) +: Hugofy is a plugin for Sublime Text 3 to make life easier to use Hugo static site generator. + +[Hugo Snippets](https://packagecontrol.io/packages/Hugo%20Snippets) +: Hugo Snippets is a useful plugin for adding automatic snippets to Sublime Text 3. ## Vim -* [Vim Hugo Helper](https://github.com/robertbasic/vim-hugo-helper). A small Vim plugin to help me with writing posts with Hugo. +[Vim Hugo Helper]: https://github.com/robertbasic/vim-hugo-helper -## Atom +[Vim Hugo Helper] +: A small Vim plugin that facilitates authoring pages and blog posts with Hugo. -* [Hugofy](https://atom.io/packages/hugofy). A Hugo Static Website Generator package for Atom. -* [language-hugo](https://atom.io/packages/language-hugo). Adds syntax highlighting to Hugo files. +[vim-hugo](https://github.com/phelipetls/vim-hugo) +: A Vim plugin with syntax highlighting for templates and a few other features. [formats]: /content-management/formats/ diff --git a/docs/content/en/tools/front-ends.md b/docs/content/en/tools/front-ends.md new file mode 100644 index 000000000..0c52a4687 --- /dev/null +++ b/docs/content/en/tools/front-ends.md @@ -0,0 +1,31 @@ +--- +title: Front-end interfaces +linkTitle: Front-ends +description: Do you prefer a graphical user interface over a text editor? Give these front-ends a try. +categories: [] +keywords: [] +weight: 20 +aliases: [/tools/frontends/] +--- + +## Commercial + +[CloudCannon](https://cloudcannon.com/hugo-cms/) +: The intuitive Git-based CMS for your Hugo website. CloudCannon syncs changes from your Git repository and pushes content changes back, so your development and content teams are always in sync. Edit all of your content on the page with visual editing, build entire pages with reusable custom components and then publish confidently. + +[DatoCMS](https://www.datocms.com) +: DatoCMS is a fully customizable administrative area for your static websites. Use your favorite website generator, let your clients publish new content independently, and the host the site anywhere you like. + +[PubCrank](https://www.pubcrank.com/) +: PubCrank is a static site editor which lets you define templates for different front matter layouts in your site. This gives writers an easy-to-use visual interface to create and edit content while maintaining the guardrails that the developer has created. PubCrank is free for local editing. + +## Open-source + +[Decap CMS](https://decapcms.org/) +: Decap CMS is an open-source, serverless solution for managing Git based content in static sites, and it works on any platform that can host static sites. A [Hugo/Decap CMS starter](https://github.com/decaporg/one-click-hugo-cms) is available to get new projects running quickly. + +[Quiqr Desktop](https://quiqr.org/) +: Quiqr Desktop is a open-source, cross-platform, offline desktop CMS for Hugo with built-in Git functionality for deploying static sites to any hosting server. + +[Sveltia CMS](https://github.com/sveltia/sveltia-cms/) +: Sveltia CMS is a drop-in replacement for Decap CMS which is built from the ground up with powerful and performant modern UI library Svelte. Sveltia CMS incorporates i18n into every corner of the product, while striving to radically improve UX, performance and productivity. diff --git a/docs/content/en/tools/frontends.md b/docs/content/en/tools/frontends.md deleted file mode 100644 index 7f8a92d34..000000000 --- a/docs/content/en/tools/frontends.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -title: Frontend Interfaces with Hugo -linktitle: Frontends -description: Do you prefer a graphical user interface over a text editor? Give these frontends a try. -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2017-02-01 -categories: [developer tools] -keywords: [frontend,gui] -menu: - docs: - parent: "tools" - weight: 40 -weight: 40 -sections_weight: 40 -draft: false -aliases: [] -toc: false ---- - -* [enwrite](https://github.com/zzamboni/enwrite). Enwrite enables evernote-powered, statically generated blogs and websites. Now posting to your blog or updating your website is as easy as writing a new note in Evernote! -* [Lipi](https://github.com/SohanChy/Lipi). Lipi is a native GUI frontend written in Java to manage your Hugo websites. -* [Netlify CMS](https://netlifycms.org). Netlify CMS is an open source, serverless solution for managing Git based content in static sites, and it works on any platform that can host static sites. A [Hugo/Netlify CMS starter](https://github.com/netlify-templates/one-click-hugo-cms) is available to get new projects running quickly. -* [Hokus CMS](https://github.com/julianoappelklein/hokus). Hokus CMS is an open source, multiplatform, easy to use, desktop application for Hugo. Build from simple to complex user interfaces for Hugo websites by choosing from a dozen ready-to-use components — all for free, with no vendor lock-in. - - -## Commercial Services - -* [Appernetic.io](https://appernetic.io) is a Hugo Static Site Generator as a Service that is easy to use for non-technical users. - * **Features:** inline PageDown editor, visual tree view, image upload and digital asset management with Cloudinary, site preview, continuous integration with GitHub, atomic deploy and hosting, Git and Hugo integration, autosave, custom domain, project syncing, theme cloning and management. Developers have complete control over the source code and can manage it with GitHub’s deceptively simple workflow. -* [DATOCMS](https://www.datocms.com) DatoCMS is a fully customizable administrative area for your static websites. Use your favorite website generator, let your clients publish new content independently, and the host the site anywhere you like. -* [Forestry.io](https://forestry.io/). Forestry is a git-backed CMS for Hugo, Gatsby, Jekyll and VuePress websites with support for GitHub, GitLab, Bitbucket and Azure Devops. Forestry provides a nice user interface to edit and model content for non technical editors. It supports S3, Cloudinary and Netlify Large Media integrations for storing media. Every time an update is made via the CMS, Forestry will commit changes back to your repo and vice-versa. -* [Netlify.com](https://www.netlify.com). Netlify builds, deploys, and hosts your static website or app (Hugo, Jekyll, etc). Netlify offers a drag-and-drop interface and automatic deployments from GitHub or Bitbucket. - * **Features:** global CDN, atomic deploys, ultra-fast DNS, instant cache invalidation, high availability, automated hosting, Git integration, form submission hooks, authentication providers, and custom domains. Developers have complete control over the source code and can manage it with GitHub or Bitbucket's deceptively simple workflow. diff --git a/docs/content/en/tools/migrations.md b/docs/content/en/tools/migrations.md index 9a48d1df0..103e28b9e 100644 --- a/docs/content/en/tools/migrations.md +++ b/docs/content/en/tools/migrations.md @@ -1,85 +1,100 @@ --- title: Migrate to Hugo -linktitle: Migrations +linkTitle: Migrations description: A list of community-developed tools for migrating from your existing static site generator or content management system to Hugo. -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2017-02-01 -keywords: [migrations,jekyll,wordpress,drupal,ghost,contentful] -menu: - docs: - parent: "tools" - weight: 10 -weight: 10 -sections_weight: 10 -draft: false -aliases: [/developer-tools/migrations/,/developer-tools/migrated/] -toc: true +categories: [] +keywords: [] +weight: 40 +aliases: [/developer-tools/migrations/, /developer-tools/migrated/] --- -This section highlights some projects around Hugo that are independently developed. These tools try to extend the functionality of our static site generator or help you to get started. +This section highlights some independently developed projects related to Hugo. These tools extend functionality or help you to get started. -{{% note %}} -Do you know or maintain a similar project around Hugo? Feel free to open a [pull request](https://github.com/gohugoio/hugo/pulls) on GitHub if you think it should be added. -{{% /note %}} - -Take a look at this list of migration tools if you currently use other blogging tools like Jekyll or WordPress but intend to switch to Hugo instead. They'll take care to export your content into Hugo-friendly formats. +Take a look at this list of migration tools if you currently use other blogging tools like Jekyll or WordPress but intend to switch to Hugo instead. They'll help you export your content into Hugo-friendly formats. ## Jekyll -Alternatively, you can use the new [Jekyll import command](/commands/hugo_import_jekyll/). +Alternatively, you can use the [Jekyll import command](/commands/hugo_import_jekyll/). -- [JekyllToHugo](https://github.com/SenjinDarashiva/JekyllToHugo) - A Small script for converting Jekyll blog posts to a Hugo site. -- [ConvertToHugo](https://github.com/coderzh/ConvertToHugo) - Convert your blog from Jekyll to Hugo. +[JekyllToHugo](https://github.com/fredrikloch/JekyllToHugo) +: A Small script for converting Jekyll blog posts to a Hugo site. -## Ghost - -- [ghostToHugo](https://github.com/jbarone/ghostToHugo) - Convert Ghost blog posts and export them to Hugo. +[ConvertToHugo](https://github.com/coderzh/ConvertToHugo) +: Convert your blog from Jekyll to Hugo. ## Octopress -- [octohug](https://github.com/codebrane/octohug) - Octopress to Hugo migrator. +[octohug](https://github.com/codebrane/octohug) +: Octopress to Hugo migrator. ## DokuWiki -- [dokuwiki-to-hugo](https://github.com/wgroeneveld/dokuwiki-to-hugo) - Migrates your dokuwiki source pages from [DokuWiki syntax](https://www.dokuwiki.org/wiki:syntax) to Hugo Markdown syntax. Includes extra's like the TODO plugin. Written with extensibility in mind using python 3. Also generates a TOML header for each page. Designed to copypaste the wiki directory into your /content directory. +[dokuwiki-to-hugo](https://github.com/wgroeneveld/dokuwiki-to-hugo) +: Migrates your DokuWiki source pages from [DokuWiki syntax](https://www.dokuwiki.org/wiki:syntax) to Hugo Markdown syntax. Includes extras like the TODO plugin. Written with extensibility in mind using Python 3. Also generates a TOML header for each page. Designed to copy-paste the wiki directory into your `content` directory. ## WordPress -- [wordpress-to-hugo-exporter](https://github.com/SchumacherFM/wordpress-to-hugo-exporter) - A one-click WordPress plugin that converts all posts, pages, taxonomies, metadata, and settings to Markdown and YAML which can be dropped into Hugo. (Note: If you have trouble using this plugin, you can [export your site for Jekyll](https://wordpress.org/plugins/jekyll-exporter/) and use Hugo's built in Jekyll converter listed above.) -- [exitwp-for-hugo](https://github.com/wooni005/exitwp-for-hugo) - A python script which works with the xml export from Wordpress and converts Wordpress pages and posts to Markdown and YAML for hugo. -- [blog2md](https://github.com/palaniraja/blog2md) - Works with [exported xml](https://en.support.wordpress.com/export/) file of your free YOUR-TLD.wordpress.com website. It also saves approved comments to `YOUR-POST-NAME-comments.md` file along with posts. +[wordpress-to-hugo-exporter](https://github.com/SchumacherFM/wordpress-to-hugo-exporter) +: A one-click WordPress plugin that converts all posts, pages, taxonomies, metadata, and settings to Markdown and YAML which can be dropped into Hugo. (Note: If you have trouble using this plugin, you can [export your site for Jekyll](https://wordpress.org/plugins/jekyll-exporter/) and use Hugo's built-in Jekyll converter listed above.) + +[blog2md](https://github.com/palaniraja/blog2md) +: Works with [exported xml](https://en.support.wordpress.com/export/) file of your free YOUR-TLD.wordpress.com website. It also saves approved comments to `YOUR-POST-NAME-comments.md` file along with posts. + +[wordhugopress](https://github.com/nantipov/wordhugopress) +: A small utility written in Java that exports the entire WordPress site from the database and resource (e.g., images) files stored locally or remotely. Therefore, migration from the backup files is possible. Supports merging multiple WordPress sites into a single Hugo site. + +[wp2hugo](https://github.com/ashishb/wp2hugo) +: A Go-based CLI tool to migrate WordPress website to Hugo while preserving original URLs, GUIDs (for feeds), image URLs, code highlights, table of contents, YouTube embeds, Google Maps embeds, and original WordPress navigation categories. ## Medium -- [medium2md](https://github.com/gautamdhameja/medium-2-md) - A simple Medium to Hugo exporter able to import stories in one command, including Front Matter. +[medium2md](https://github.com/gautamdhameja/medium-2-md) +: A simple Medium to Hugo exporter able to import stories in one command, including front matter. + +[medium-to-hugo](https://github.com/bgadrian/medium-to-hugo) +: A CLI tool written in Go to export medium posts into a Hugo-compatible Markdown format. Tags and images are included. All images will be downloaded locally and linked appropriately. ## Tumblr -- [tumblr-importr](https://github.com/carlmjohnson/tumblr-importr) - An importer that uses the Tumblr API to create a Hugo static site. -- [tumblr2hugomarkdown](https://github.com/Wysie/tumblr2hugomarkdown) - Export all your Tumblr content to Hugo Markdown files with preserved original formatting. -- [Tumblr to Hugo](https://github.com/jipiboily/tumblr-to-hugo) - A migration tool that converts each of your Tumblr posts to a content file with a proper title and path. Furthermore, "Tumblr to Hugo" creates a CSV file with the original URL and the new path on Hugo, to help you setup the redirections. +[tumblr-importr](https://github.com/carlmjohnson/tumblr-importr) +: An importer that uses the Tumblr API to create a Hugo static site. + +[tumblr2hugomarkdown](https://github.com/Wysie/tumblr2hugomarkdown) +: Export all your Tumblr content to Hugo Markdown files with preserved original formatting. + +[Tumblr to Hugo](https://github.com/jipiboily/tumblr-to-hugo) +: A migration tool that converts each of your Tumblr posts to a content file with a proper title and path. It also generates a CSV file to help you set up URL redirects. ## Drupal -- [drupal2hugo](https://github.com/danapsimer/drupal2hugo) - Convert a Drupal site to Hugo. +[drupal2hugo](https://github.com/danapsimer/drupal2hugo) +: Convert a Drupal site to Hugo. ## Joomla -- [hugojoomla](https://github.com/davetcc/hugojoomla) - This utility written in Java takes a Joomla database and converts all the content into Markdown files. It changes any URLs that are in Joomla's internal format and converts them to a suitable form. +[hugojoomla](https://github.com/davetcc/hugojoomla) +: This utility written in Java takes a Joomla database and converts all the content into Markdown files. It changes any URLs that are in Joomla's internal format and converts them to a suitable form. ## Blogger -- [blogimport](https://github.com/natefinch/blogimport) - A tool to import from Blogger posts to Hugo. -- [blogger-to-hugo](https://bitbucket.org/petraszd/blogger-to-hugo) - Another tool to import Blogger posts to Hugo. It also downloads embedded images so they will be stored locally. -- [blog2md](https://github.com/palaniraja/blog2md) - Works with [exported xml](https://support.google.com/blogger/answer/41387?hl=en) file of your YOUR-TLD.blogspot.com website. It also saves comments to `YOUR-POST-NAME-comments.md` file along with posts. -- [BloggerToHugo](https://github.com/huanlin/blogger-to-hugo) - Yet another tool to import Blogger posts to Hugo. For Windows platform only, and .NET Framework 4.5 is required. See README.md before using this tool. +[blogimport](https://github.com/natefinch/blogimport) +: A tool to import from Blogger posts to Hugo. + +[blogger-to-hugo](https://pypi.org/project/blogger-to-hugo/) +: Another tool to import Blogger posts to Hugo. It also downloads embedded images so they will be stored locally. + +[blog2md](https://github.com/palaniraja/blog2md) +: Works with [exported xml](https://support.google.com/blogger/answer/41387?hl=en) file of your YOUR-TLD.blogspot.com website. It also saves comments to `YOUR-POST-NAME-comments.md` file along with posts. + +[BloggerToHugo](https://github.com/huanlin/blogger-to-hugo) +: Yet another tool to import Blogger posts to Hugo. For Windows platform only, and .NET Framework 4.5 is required. See README.md before using this tool. ## Contentful -- [contentful2hugo](https://github.com/ArnoNuyts/contentful2hugo) - A tool to create content-files for Hugo from content on [Contentful](https://www.contentful.com/). - +[contentful-hugo](https://github.com/ModiiMedia/contentful-hugo) +: A tool to create content-files for Hugo from content on [Contentful](https://www.contentful.com/). ## BlogML -- [BlogML2Hugo](https://github.com/jijiechen/BlogML2Hugo) - A tool that helps you convert BlogML xml file to Hugo markdown files. Users need to take care of links to attachments and images by themselves. This helps the blogs that export BlogML files (e.g. BlogEngine.NET) tramsform to hugo sites easily. +[BlogML2Hugo](https://github.com/jijiechen/BlogML2Hugo) +: A tool that helps you convert BlogML xml file to Hugo Markdown files. Users need to take care of links to attachments and images by themselves. This helps the blogs that export BlogML files (e.g. BlogEngine.NET) transform to hugo sites easily. diff --git a/docs/content/en/tools/other.md b/docs/content/en/tools/other.md index 3afd7b96b..489d78506 100644 --- a/docs/content/en/tools/other.md +++ b/docs/content/en/tools/other.md @@ -1,29 +1,20 @@ --- -title: Other Hugo Community Projects -linktitle: Other Projects +title: Other community projects +linkTitle: Other projects description: Some interesting projects developed by the Hugo community that don't quite fit into our other developer tool categories. -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2017-02-01 -categories: [developer tools] -keywords: [frontend,gui] -menu: - docs: - parent: "tools" - weight: 70 -weight: 70 -sections_weight: 70 -draft: false -aliases: [] -toc: false +categories: [] +keywords: [] +weight: 50 --- -And for all the other small things around Hugo: +And for all the other community projects around Hugo: -* [hugo-gallery](https://github.com/icecreammatt/hugo-gallery) lets you create an image gallery for Hugo sites. -* [flickr-hugo-embed](https://github.com/nikhilm/flickr-hugo-embed) prints shortcodes to embed a set of images from an album on Flickr into Hugo. -* [hugo-openapispec-shortcode](https://github.com/tenfourty/hugo-openapispec-shortcode) A shortcode that allows you to include [Open API Spec](https://openapis.org) (formerly known as Swagger Spec) in a page. -* [HugoPhotoSwipe](https://github.com/GjjvdBurg/HugoPhotoSwipe) makes it easy to create image galleries using PhotoSwipe. -* [Hugo SFTP Upload](https://github.com/thomasmey/HugoSftpUpload) Syncs the local build of your Hugo website with your remote webserver via SFTP. -* [Emacs Easy Hugo](https://github.com/masasam/emacs-easy-hugo) Emacs package for writing blog posts in markdown or org-mode and building your site with Hugo. -* [JAMStack Themes](https://jamstackthemes.dev/ssg/hugo/). JAMStack themes is a collection of site themes filterable by static site generator and supported CMS to help build CMS-connected sites using Hugo (linking to Hugo-specific themes). +- [diego](https://github.com/ttybitnik/diego) - A CLI tool that integrates with Hugo to assist in importing and utilizing exported social media data from popular services on Hugo websites. +- [Emacs Easy Hugo](https://github.com/masasam/emacs-easy-hugo) - Emacs package for writing blog posts in Markdown or org-mode and building your site with Hugo. +- [Hugo SFTP Upload](https://github.com/thomasmey/HugoSftpUpload) - Sync the local build of your Hugo website with your remote web server via SFTP. +- [HugoPhotoSwipe](https://github.com/GjjvdBurg/HugoPhotoSwipe) - Make it easy to create image galleries using PhotoSwipe. +- [JAMStack Themes](https://jamstackthemes.dev/ssg/hugo/) - A collection of site themes filterable by static site generator and supported CMS to help build CMS-connected sites using Hugo (linking to Hugo-specific themes). +- [flickr-hugo-embed](https://github.com/nikhilm/flickr-hugo-embed) - Print shortcodes to embed a set of images from an album on Flickr into Hugo. +- [hugo-gallery](https://github.com/icecreammatt/hugo-gallery) - Create an image gallery for Hugo sites. +- [hugo-openapispec-shortcode](https://github.com/tenfourty/hugo-openapispec-shortcode) - A shortcode that allows you to include [Open API Spec](https://openapis.org) (formerly known as Swagger Spec) in a page. +- [plausible-hugo](https://github.com/divinerites/plausible-hugo) - Easy Hugo integration for Plausible Analytics, a simple, open-source, lightweight and privacy-friendly web analytics alternative to Google Analytics. diff --git a/docs/content/en/tools/search.md b/docs/content/en/tools/search.md index 4c6695976..2c392c75a 100644 --- a/docs/content/en/tools/search.md +++ b/docs/content/en/tools/search.md @@ -1,33 +1,53 @@ --- -title: Search for your Hugo Website -linktitle: Search +title: Search tools +linkTitle: Search description: See some of the open-source and commercial search options for your newly created Hugo website. -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2017-02-26 -categories: [developer tools] -keywords: [search,tools] -menu: - docs: - parent: "tools" - weight: 60 -weight: 60 -sections_weight: 60 -draft: false -aliases: [] -toc: true +categories: [] +keywords: [] +weight: 30 --- A static website with a dynamic search function? Yes, Hugo provides an alternative to embeddable scripts from Google or other search engines for static websites. Hugo allows you to provide your visitors with a custom search function by indexing your content files directly. -* [GitHub Gist for Hugo Workflow](https://gist.github.com/sebz/efddfc8fdcb6b480f567). This gist contains a simple workflow to create a search index for your static website. It uses a simple Grunt script to index all your content files and [lunr.js](https://lunrjs.com/) to serve the search results. -* [hugo-elasticsearch](https://www.npmjs.com/package/hugo-elasticsearch). Generate [Elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html) indexes for Hugo static sites by parsing front matter. Hugo-Elasticsearch will generate a newline delimited JSON (NDJSON) file that can be bulk uploaded into Elasticsearch using any one of the available [clients](https://www.elastic.co/guide/en/elasticsearch/client/index.html). -* [hugo-lunr](https://www.npmjs.com/package/hugo-lunr). A simple way to add site search to your static Hugo site using [lunr.js](https://lunrjs.com/). Hugo-lunr will create an index file of any html and markdown documents in your Hugo project. -* [hugo-lunr-zh](https://www.npmjs.com/package/hugo-lunr-zh). A bit like Hugo-lunr, but Hugo-lunr-zh can help you separate the Chinese keywords. -* [Github Gist for Fuse.js integration](https://gist.github.com/eddiewebb/735feb48f50f0ddd65ae5606a1cb41ae). This gist demonstrates how to leverage Hugo's existing build time processing to generate a searchable JSON index used by [Fuse.js](https://fusejs.io/) on the client side. Although this gist uses Fuse.js for fuzzy matching, any client side search tool capable of reading JSON indexes will work. Does not require npm, grunt or other build-time tools except Hugo! -* [hugo-search-index](https://www.npmjs.com/package/hugo-search-index). A library containing Gulp tasks and a prebuilt browser script that implements search. Gulp generates a search index from project markdown files. +## Open-source -## Commercial Search Services +[Pagefind](https://github.com/cloudcannon/pagefind) +: A fully static search library that aims to perform well on large sites, while using as little of your users' bandwidth as possible. -* [Algolia](https://www.algolia.com/)'s Search API makes it easy to deliver a great search experience in your apps and websites. Algolia Search provides hosted full-text, numerical, faceted, and geolocalized search. -* [Bonsai](https://www.bonsai.io) is a fully-managed hosted Elasticsearch service that is fast, reliable, and simple to set up. Easily ingest your docs from Hugo into Elasticsearch following [this guide from the docs](https://docs.bonsai.io/docs/hugo). +[GitHub Gist for Hugo Workflow](https://gist.github.com/sebz/efddfc8fdcb6b480f567) +: This gist contains a simple workflow to create a search index for your static website. It uses a simple Grunt script to index all your content files and [lunr.js](https://lunrjs.com/) to serve the search results. + +[hugo-lunr](https://www.npmjs.com/package/hugo-lunr) +: A simple way to add site search to your static Hugo site using [lunr.js](https://lunrjs.com/). Hugo-lunr will create an index file of any HTML and Markdown documents in your Hugo project. + +[hugo-lunr-zh](https://www.npmjs.com/package/hugo-lunr-zh) +: A bit like Hugo-lunr, but Hugo-lunr-zh can help you separate the Chinese keywords. + +[GitHub Gist for Fuse.js integration](https://gist.github.com/eddiewebb/735feb48f50f0ddd65ae5606a1cb41ae) +: This gist demonstrates how to leverage Hugo's existing build time processing to generate a searchable JSON index used by [Fuse.js](https://fusejs.io/) on the client side. Although this gist uses Fuse.js for fuzzy matching, any client-side search tool capable of reading JSON indexes will work. Does not require npm, grunt, or other build-time tools except Hugo! + +[hugo-search-index](https://www.npmjs.com/package/hugo-search-index) +: A library containing Gulp tasks and a prebuilt browser script that implements search. Gulp generates a search index from project Markdown files. + +[hugofastsearch](https://gist.github.com/cmod/5410eae147e4318164258742dd053993) +: A usability and speed update to "GitHub Gist for Fuse.js integration" — global, keyboard-optimized search. + +[JS & Fuse.js tutorial](https://makewithhugo.com/add-search-to-a-hugo-site/) +: A simple client-side search solution, using FuseJS (does not require jQuery). + +[Hugo Lyra](https://github.com/paolomainardi/hugo-lyra) +: Hugo-Lyra is a JavaScript module to integrate [Lyra](https://github.com/LyraSearch/lyra) into a Hugo website. It contains the server-side part to generate the index and the client-side library (optional) to bootstrap the search engine easily. + +[INFINI Pizza for WebAssembly](https://github.com/infinilabs/pizza-docsearch) +: Pizza is a super-lightweight yet fully featured search engine written in Rust. You can quickly add offline search functionality to your Hugo website in just five minutes with only three lines of code. For a step-by-step guide on integrating it with Hugo, check out [this blog tutorial](https://dev.to/medcl/adding-search-functionality-to-a-hugo-static-site-based-on-infini-pizza-for-webassembly-4h5e). + +## Commercial + +[Algolia DocSearch](https://docsearch.algolia.com/) +: Algolia DocSearch is free for public technical documentation sites and easy to set up. For other use cases, [Algolia's Search API](https://www.algolia.com) makes it easy to deliver a great search experience in your apps and websites. Algolia Search provides hosted full-text, numerical, faceted, and geolocalized search. + +[Bonsai](https://www.bonsai.io) +: Bonsai is a fully-managed hosted Elasticsearch service that is fast, reliable, and simple to set up. Easily ingest your docs from Hugo into Elasticsearch following [this guide from the docs](https://bonsai.io/docs/hugo). + +[ExpertRec](https://www.expertrec.com/) +: ExpertRec is a hosted search-as-a-service solution that is fast and scalable. Set-up and integration is extremely easy and takes only a few minutes. The search settings can be modified without coding using a dashboard. diff --git a/docs/content/en/tools/starter-kits.md b/docs/content/en/tools/starter-kits.md deleted file mode 100644 index e30de33d9..000000000 --- a/docs/content/en/tools/starter-kits.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -title: Starter Kits -linktitle: Starter Kits -description: A list of community-developed projects designed to help you get up and running with Hugo. -date: 2017-02-22 -publishdate: 2017-02-01 -lastmod: 2018-08-11 -keywords: [starters,assets,pipeline] -menu: - docs: - parent: "tools" - weight: 30 -weight: 30 -sections_weight: 30 -draft: false -aliases: [/developer-tools/migrations/,/developer-tools/migrated/] -toc: false ---- - -Know of a Hugo-related starter kit that isn't mentioned here? [Please add it to the list.][addkit] - -{{% note "Starter Kits are Not Maintained by the Hugo Team"%}} -The following starter kits are developed by active members of the Hugo community. If you find yourself having issues with any of the projects, it's best to file an issue directly with the project's maintainer(s). -{{% /note %}} - -* [Hugo Wrapper][hugow]. Hugo Wrapper is a POSIX-style shell script which acts as a wrapper to download and run Hugo binary for your platform. It can be executed in variety of [Operating Systems][hugow-test] and [Command Shells][hugow-test]. -* [Victor Hugo][]. Victor Hugo is a Hugo boilerplate for creating truly epic websites using Webpack as an asset pipeline. Victor Hugo uses post-css and Babel for CSS and JavaScript, respectively, and is actively maintained. -* [GOHUGO AMP][]. GoHugo AMP is a starter theme that aims to make it easy to adopt [Google's AMP Project][amp]. The starter kit comes with 40+ shortcodes and partials plus automatic structured data. The project also includes a [separate site with extensive documentation][gohugodocs]. -* [Blaupause][]. Blaupause is a developer-friendly Hugo starter kit based on Gulp tasks. It comes ES6-ready with several helpers for SVG and fonts and basic structure for HTML, SCSS, and JavaScript. -* [hugulp][]. hugulp is a tool to optimize the assets of a Hugo website. The main idea is to recreate the famous Ruby on Rails Asset Pipeline, which minifies, concatenates and fingerprints the assets used in your website. -* [Atlas][]. Atlas is a Hugo boilerplate designed to speed up development with support for Netlify, Hugo Pipes, SCSS & more. It's actively maintained and contributions are always welcome. - - -[addkit]: https://github.com/gohugoio/hugo/edit/master/docs/content/en/tools/starter-kits.md -[amp]: https://www.ampproject.org/ -[Blaupause]: https://github.com/fspoettel/blaupause -[GOHUGO AMP]: https://github.com/wildhaber/gohugo-amp -[gohugodocs]: https://gohugo-amp.gohugohq.com/ -[hugow]: https://github.com/khos2ow/hugo-wrapper -[hugow-test]: https://github.com/khos2ow/hugo-wrapper#tested-on -[hugulp]: https://github.com/jbrodriguez/hugulp -[Victor Hugo]: https://github.com/netlify/victor-hugo -[Atlas]: https://github.com/indigotree/atlas diff --git a/docs/content/en/troubleshooting/_index.md b/docs/content/en/troubleshooting/_index.md index 3b0e93725..fd51b4fc8 100644 --- a/docs/content/en/troubleshooting/_index.md +++ b/docs/content/en/troubleshooting/_index.md @@ -1,26 +1,8 @@ --- -title: Troubleshoot -linktitle: Troubleshoot -description: Frequently asked questions and known issues pulled from the Hugo Discuss forum. -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2017-02-01 -menu: - docs: - parent: "troubleshooting" - weight: 1 -weight: 1 -draft: false -hidesectioncontents: false -slug: -aliases: [/troubleshooting/faqs/,/faqs/] -toc: false -notesforauthors: +title: Troubleshooting +description: Use these techniques when troubleshooting your site. +categories: [] +keywords: [] +weight: 10 +aliases: [/templates/template-debugging/] --- - -The Troubleshooting section includes known issues, recent workarounds, and FAQs pulled from the [Hugo Discussion Forum][forum]. - - - - -[forum]: https://discourse.gohugo.io diff --git a/docs/content/en/troubleshooting/audit/index.md b/docs/content/en/troubleshooting/audit/index.md new file mode 100644 index 000000000..2efad55e3 --- /dev/null +++ b/docs/content/en/troubleshooting/audit/index.md @@ -0,0 +1,68 @@ +--- +title: Site audit +linkTitle: Audit +description: Run this audit before deploying your production site. +categories: [] +keywords: [] +--- + +There are several conditions that can produce errors in your published site which are not detected during the build. Run this audit before your final build. + +```text {copy=true} +HUGO_MINIFY_TDEWOLFF_HTML_KEEPCOMMENTS=true HUGO_ENABLEMISSINGTRANSLATIONPLACEHOLDERS=true hugo && grep -inorE "<\!-- raw HTML omitted -->|ZgotmplZ|\[i18n\]|\(\)|(<nil>)|hahahugo" public/ +``` + +_Tested with GNU Bash 5.1 and GNU grep 3.7._ + +## Example output + +![site audit terminal output](screen-capture.png) + +## Explanation + +### Environment variables + +`HUGO_MINIFY_TDEWOLFF_HTML_KEEPCOMMENTS=true` +: Retain HTML comments even if minification is enabled. This takes precedence over `minify.tdewolff.html.keepComments` in the site configuration. If you minify without keeping HTML comments when performing this audit, you will not be able to detect when raw HTML has been omitted. + +`HUGO_ENABLEMISSINGTRANSLATIONPLACEHOLDERS=true` +: Show a placeholder instead of the default value or an empty string if a translation is missing. This takes precedence over `enableMissingTranslationPlaceholders` in the site configuration. + +### Grep options + +`-i, --ignore-case` +: Ignore case distinctions in patterns and input data, so that characters that differ only in case match each other. + +`-n, --line-number` +: Prefix each line of output with the 1-based line number within its input file. + +`-o, --only-matching` +: Print only the matched (non-empty) parts of a matching line, with each such part on a separate output line. + +`-r, --recursive` +: Read all files under each directory, recursively, following symbolic links only if they are on the command line. + +`-E, --extended-regexp` +: Interpret PATTERNS as extended regular expressions. + +### Patterns + +`` +: By default, Hugo strips raw HTML from your Markdown prior to rendering, and leaves this HTML comment in its place. + +`ZgotmplZ` +: ZgotmplZ is a special value that indicates that unsafe content reached a CSS or URL context at runtime. See [details]. + +[details]: https://pkg.go.dev/html/template + +`[i18n]` +: This is the placeholder produced instead of the default value or an empty string if a translation is missing. + +`()` +: This string will appear in the rendered HTML when passing a nil value to the `printf` function. + +`(<nil>)` +: Same as above when the value returned from the `printf` function has not been passed through `safeHTML`. + +`HAHAHUGO` +: Under certain conditions a rendered shortcode may include all or a portion of the string HAHAHUGOSHORTCODE in either uppercase or lowercase. This is difficult to detect in all circumstances, but a case-insensitive search of the output for `HAHAHUGO` is likely to catch the majority of cases without producing false positives. diff --git a/docs/content/en/troubleshooting/audit/screen-capture.png b/docs/content/en/troubleshooting/audit/screen-capture.png new file mode 100644 index 000000000..221abfff0 Binary files /dev/null and b/docs/content/en/troubleshooting/audit/screen-capture.png differ diff --git a/docs/content/en/troubleshooting/build-performance.md b/docs/content/en/troubleshooting/build-performance.md deleted file mode 100644 index e0700f381..000000000 --- a/docs/content/en/troubleshooting/build-performance.md +++ /dev/null @@ -1,101 +0,0 @@ ---- -title: Build Performance -linktitle: Build Performance -description: An overview of features used for diagnosing and improving performance issues in site builds. -date: 2017-03-12 -publishdate: 2017-03-12 -lastmod: 2017-03-12 -keywords: [performance, build] -categories: [troubleshooting] -menu: - docs: - parent: "troubleshooting" -weight: 3 -slug: -aliases: [] -toc: true ---- - -{{% note %}} -The example site used below is from https://github.com/gohugoio/hugo/tree/master/examples/blog -{{% /note %}} - -## Template Metrics - -Hugo is a very fast static site generator, but it is possible to write -inefficient templates. Hugo's *template metrics* feature is extremely helpful -in pinpointing which templates are executed most often and how long those -executions take **in terms of CPU time**. - -| Metric Name | Description | -|---------------------|-------------| -| cumulative duration | The cumulative time spent executing a given template. | -| average duration | The average time spent executing a given template. | -| maximum duration | The maximum time a single execution took for a given template. | -| count | The number of times a template was executed. | -| template | The template name. | - -``` -▶ hugo --templateMetrics -Started building sites ... - -Built site for language en: -0 draft content -0 future content -0 expired content -2 regular pages created -22 other pages created -0 non-page files copied -0 paginator pages created -4 tags created -3 categories created -total in 18 ms - -Template Metrics: - - cumulative average maximum - duration duration duration count template - ---------- -------- -------- ----- -------- - 6.419663ms 583.605µs 994.374µs 11 _internal/_default/rss.xml - 4.718511ms 1.572837ms 3.880742ms 3 indexes/category.html - 4.642666ms 2.321333ms 3.282842ms 2 posts/single.html - 4.364445ms 396.767µs 2.451372ms 11 partials/header.html - 2.346069ms 586.517µs 903.343µs 4 indexes/tag.html - 2.330919ms 211.901µs 2.281342ms 11 partials/header.includes.html - 1.238976ms 103.248µs 446.084µs 12 posts/li.html - 972.16µs 972.16µs 972.16µs 1 _internal/_default/sitemap.xml - 953.597µs 953.597µs 953.597µs 1 index.html - 822.263µs 822.263µs 822.263µs 1 indexes/post.html - 567.498µs 51.59µs 112.205µs 11 partials/navbar.html - 348.22µs 31.656µs 88.249µs 11 partials/meta.html - 346.782µs 173.391µs 276.176µs 2 posts/summary.html - 235.184µs 21.38µs 124.383µs 11 partials/footer.copyright.html - 132.003µs 12µs 117.999µs 11 partials/menu.html - 72.547µs 6.595µs 63.764µs 11 partials/footer.html -``` - -{{% note %}} -**A Note About Parallelism** - -Hugo builds pages in parallel where multiple pages are generated -simultaneously. Because of this parallelism, the sum of "cumulative duration" -values is usually greater than the actual time it takes to build a site. -{{% /note %}} - - -## Cached Partials - -Some `partial` templates such as sidebars or menus are executed many times -during a site build. Depending on the content within the `partial` template and -the desired output, the template may benefit from caching to reduce the number -of executions. The [`partialCached`][partialCached] template function provides -caching capabilities for `partial` templates. - -{{% tip %}} -Note that you can create cached variants of each `partial` by passing additional -parameters to `partialCached` beyond the initial context. See the -`partialCached` documentation for more details. -{{% /tip %}} - - -[partialCached]:{{< ref "/functions/partialCached.md" >}} diff --git a/docs/content/en/troubleshooting/deprecation.md b/docs/content/en/troubleshooting/deprecation.md new file mode 100644 index 000000000..f2e5259a6 --- /dev/null +++ b/docs/content/en/troubleshooting/deprecation.md @@ -0,0 +1,51 @@ +--- +title: Deprecation +description: The Hugo project follows a formal and consistent process to deprecate functions, methods, and configuration settings. +categories: [] +keywords: [] +--- + +When a project _deprecates_ something, they are telling its users: + +1. Don't use Thing One anymore. +1. Use Thing Two instead. +1. We're going to remove Thing One at some point in the future. + +[reasons for deprecation]: https://en.wikipedia.org/wiki/Deprecation + +Common [reasons for deprecation]: + +- A feature has been replaced by a more powerful alternative. +- A feature contains a design flaw. +- A feature is considered extraneous, and will be removed in the future in order to simplify the system as a whole. +- A future version of the software will make major structural changes, making it impossible or impractical to support older features. +- Standardization or increased consistency in naming. +- A feature that once was available only independently is now combined with its co-feature. + +After the project team deprecates something in code, Hugo will: + +1. Log an INFO message for 3 minor releases[^1] +1. Log a WARN message for another 12 minor releases +1. Log an ERROR message and fail the build thereafter + +The project team will: + +1. On the deprecation date, update the documentation with a note describing the deprecation and any relevant alternatives. +1. Remove the code six or more minor releases after Hugo begins logging ERROR messages and failing the build. At that point, Hugo will throw an error, but the error message will no longer mention the deprecation. +1. Remove the corresponding documentation two years after the deprecation date. + +To see the INFO messages, you must use the `--logLevel` command line flag: + +```text +hugo --logLevel info +``` + +To limit the output to deprecation notices: + +```text +hugo --logLevel info | grep deprecate +``` + +Run the above command every time you upgrade Hugo. + +[^1]: For example, v0.1.1 => v0.2.0 is a minor release. diff --git a/docs/content/en/troubleshooting/faq.md b/docs/content/en/troubleshooting/faq.md index 46f3e8cd5..6992af5d3 100644 --- a/docs/content/en/troubleshooting/faq.md +++ b/docs/content/en/troubleshooting/faq.md @@ -1,56 +1,113 @@ --- -title: Frequently Asked Questions -linktitle: FAQ -description: Solutions to some common Hugo problems. -date: 2018-02-10 -categories: [troubleshooting] -menu: - docs: - parent: "troubleshooting" -keywords: [faqs] -weight: 2 -toc: true -aliases: [/faq/] +title: Frequently asked questions +linkTitle: FAQs +description: These questions are frequently asked by new users. +categories: [] +keywords: [] --- -{{% note %}} -**Note:** The answers/solutions presented below are short, and may not be enough to solve your problem. Visit [Hugo Discourse](https://discourse.gohugo.io/) and use the search. It that does not help, start a new topic and ask your questions. -{{% /note %}} +Hugo's [forum] is an active community of users and developers who answer questions, share knowledge, and provide examples. A quick search of over 20,000 topics will often answer your question. Please be sure to read about [requesting help] before asking your first question. -## I can't see my content! +These are just a few of the questions most frequently asked by new users. -Is your markdown file [in draft mode](https://gohugo.io/content-management/front-matter/#front-matter-variables)? When testing, run `hugo server` with the `-D` or `--buildDrafts` [switch](https://gohugo.io/getting-started/usage/#draft-future-and-expired-content). +An error message indicates that a feature is not available. Why? +: + {{% include "/_common/installation/01-editions.md" %}} -## Can I set configuration variables via OS environment? + When you attempt to use a feature that is not available in the edition that you installed, Hugo throws this error: -Yes you can! See [Configure with Environment Variables](/getting-started/configuration/#configure-with-environment-variables). + ```go-html-template + this feature is not available in this edition of Hugo + ``` -## How do I schedule posts? + To resolve, install a different edition based on the feature table above. See the [installation] section for details. -1. Set `publishDate` in the page [Front Matter](/content-management/front-matter/) to a date in the future. -2. Build and publish at intervals. +Why do I see "Page Not Found" when visiting the home page? +: In the `content/_index.md` file: -How to automate the "publish at intervals" part depends on your situation: + - Is `draft` set to `true`? + - Is the `date` in the future? + - Is the `publishDate` in the future? + - Is the `expiryDate` in the past? -* If you deploy from your own PC/server, you can automate with [Cron](https://en.wikipedia.org/wiki/Cron) or similar. -* If your site is hosted on a service similar to [Netlify](https://www.netlify.com/) you can use a service such as [ifttt](https://ifttt.com/date_and_time) to schedule the updates. + If the answer to any of these questions is yes, either change the field values, or use one of these command line flags: `--buildDrafts`, `--buildFuture`, or `--buildExpired`. -Also see this Twitter thread: +Why is a given page not published? +: In the `content/section/page.md` file, or in the `content/section/page/index.md` file: -{{< tweet 962380712027590657 >}} + - Is `draft` set to `true`? + - Is the `date` in the future? + - Is the `publishDate` in the future? + - Is the `expiryDate` in the past? -## Can I use the latest Hugo version on Netlify? + If the answer to any of these questions is yes, either change the field values, or use one of these command line flags: `--buildDrafts`, `--buildFuture`, or `--buildExpired`. -Yes you can! Read [this](/hosting-and-deployment/hosting-on-netlify/#configure-hugo-version-in-netlify). +Why can't I see any of a page's descendants? +: You may have an `index.md` file instead of an `_index.md` file. See [details](/content-management/page-bundles/). -## I get "TOCSS ... this feature is not available in your current Hugo version" +What is the difference between an `index.md` file and an `_index.md` file? +: A directory with an `index.md file` is a [leaf bundle](g). A directory with an `_index.md` file is a [branch bundle](g). See [details](/content-management/page-bundles/). -If you process `SCSS` or `SASS` to `CSS` in your Hugo project, you need the Hugo `extended` version, or else you may see this error message: +Why is my partial template not rendered as expected? +: You may have neglected to pass the required [context](g) when calling the partial. For example: -```bash -error: failed to transform resource: TOCSS: failed to transform "scss/main.scss" (text/x-scss): this feature is not available in your current Hugo version -``` + ```go-html-template + {{/* incorrect */}} + {{ partial "_internal/pagination.html" }} -We release two set of binaries for technical reasons. The extended version is not what you get by default for some installation methods. On the [release page](https://github.com/gohugoio/hugo/releases), look for archives with `extended` in the name. To build `hugo-extended`, use `go install --tags extended` + {{/* correct */}} + {{ partial "_internal/pagination.html" . }} + ``` -To confirm, run `hugo version` and look for the word `extended`. +In a template, what's the difference between `:=` and `=` when assigning values to variables? +: Use `:=` to initialize a variable, and use `=` to assign a value to a variable that has been previously initialized. See [details](https://pkg.go.dev/text/template#hdr-Variables). + +When I paginate a list page, why is the page collection not filtered as specified? +: You are probably invoking the [`Paginate`] or [`Paginator`] method more than once on the same page. See [details](/templates/pagination/). + +Why are there two ways to call a shortcode? +: Use the `{{%/* shortcode */%}}` notation if the shortcode template, or the content between the opening and closing shortcode tags, contains Markdown. Otherwise use the\ +`{{}}` notation. See [details](/content-management/shortcodes/#notation). + +Can I use environment variables to control configuration? +: Yes. See [details](/configuration/introduction/#environment-variables). + +Why am I seeing inconsistent output from one build to the next? +: The most common causes are page collisions (publishing two pages to the same path) and the effects of concurrency. Use the `--printPathWarnings` command line flag to check for page collisions, and create a topic on the [forum] if you suspect concurrency problems. + +Why isn't Hugo's development server detecting file changes? +: In its default configuration, Hugo's file watcher may not be able detect file changes when: + + - Running Hugo within Windows Subsystem for Linux (WSL/WSL2) with project files on a Windows partition + - Running Hugo locally with project files on a removable drive + - Running Hugo locally with project files on a storage server accessed via the NFS, SMB, or CIFS protocols + + In these cases, instead of monitoring native file system events, use the `--poll` command line flag. For example, to poll the project files every 700 milliseconds, use `--poll 700ms`. + +Why is my page Scratch or Store missing a value? +: The [`Scratch`] and [`Store`] methods on a `Page` object allow you to create a [scratch pad](g) on the given page to store and manipulate data. Values are often set within a shortcode, a partial template called by a shortcode, or by a Markdown render hook. In all three cases, the scratch pad values are not determinate until Hugo renders the page content. + + If you need to access a scratch pad value from a parent template, and the parent template has not yet rendered the page content, you can trigger content rendering by assigning the returned value to a [noop](g) variable: + + ```go-html-template + {{ $noop := .Content }} + {{ .Store.Get "mykey" }} + ``` + + You can trigger content rendering with other methods as well. See next FAQ. + +Which page methods trigger content rendering? +: The following methods on a `Page` object trigger content rendering: `Content`, `ContentWithoutSummary`, `FuzzyWordCount`, `Len`, `Plain`, `PlainWords`, `ReadingTime`, `Summary`, `Truncated`, and `WordCount`. + +> [!note] +> For other questions please visit the [forum]. A quick search of over 20,000 topics will often answer your question. Please be sure to read about [requesting help] before asking your first question. + +[`Paginate`]: /methods/page/paginate/ +[`Paginator`]: /methods/page/paginator/ +[`Scratch`]: /methods/page/scratch +[`Store`]: /methods/page/store +[forum]: https://discourse.gohugo.io +[forum]: https://discourse.gohugo.io +[installation]: /installation/ +[requesting help]: https://discourse.gohugo.io/t/requesting-help/9132 +[requesting help]: https://discourse.gohugo.io/t/requesting-help/9132 diff --git a/docs/content/en/troubleshooting/inspection.md b/docs/content/en/troubleshooting/inspection.md new file mode 100644 index 000000000..ea3c097f9 --- /dev/null +++ b/docs/content/en/troubleshooting/inspection.md @@ -0,0 +1,44 @@ +--- +title: Data inspection +linkTitle: Inspection +description: Use template functions to inspect values and data structures. +categories: [] +keywords: [] +--- + +Use the [`debug.Dump`] function to inspect a data structure: + +```go-html-template +
    {{ debug.Dump .Params }}
    +``` + +```text +{ + "date": "2023-11-10T15:10:42-08:00", + "draft": false, + "iscjklanguage": false, + "lastmod": "2023-11-10T15:10:42-08:00", + "publishdate": "2023-11-10T15:10:42-08:00", + "tags": [ + "foo", + "bar" + ], + "title": "My first post" +} +``` + +Use the [`printf`] function (render) or [`warnf`] function (log to console) to inspect simple data structures. The layout string below displays both value and data type. + +```go-html-template +{{ $value := 42 }} +{{ printf "%[1]v (%[1]T)" $value }} → 42 (int) +``` + +{{< new-in 0.146.0 />}} + +Use the [`templates.Current`] function to visually mark template execution boundaries or to display the template call stack. + +[`debug.Dump`]: /functions/debug/dump/ +[`printf`]: /functions/fmt/printf/ +[`warnf`]: /functions/fmt/warnf/ +[`templates.Current`]: /functions/templates/current/ diff --git a/docs/content/en/troubleshooting/logging.md b/docs/content/en/troubleshooting/logging.md new file mode 100644 index 000000000..0cd25d1ae --- /dev/null +++ b/docs/content/en/troubleshooting/logging.md @@ -0,0 +1,65 @@ +--- +title: Logging +description: Enable logging to inspect events while building your site. +categories: [] +keywords: [] +--- + +## Command line + +Enable console logging with the `--logLevel` command line flag. + +Hugo has four logging levels: + +error +: Display error messages only. + + ```sh + hugo --logLevel error + ``` + +warn +: Display warning and error messages. + + ```sh + hugo --logLevel warn + ``` + +info +: Display information, warning, and error messages. + + ```sh + hugo --logLevel info + ``` + +debug +: Display debug, information, warning, and error messages. + + ```sh + hugo --logLevel debug + ``` + +> [!note] +> If you do not specify a logging level with the `--logLevel` flag, warnings and errors are always displayed. + +## Template functions + +You can also use template functions to print warnings or errors to the console. These functions are typically used to report data validation errors, missing files, etc. + +{{% list-pages-in-section path=/functions/fmt filter=functions_fmt_logging filterType=include %}} + +## LiveReload + +To log Hugo's LiveReload requests in your browser, add this query string to the URL when running Hugo's development server: + +```text +debug=LR-verbose +``` + +For example: + +```text +http://localhost:1313/?debug=LR-verbose +``` + +Then monitor the reload requests in your browser's dev tools console. Make sure the dev tools "preserve log" option is enabled. diff --git a/docs/content/en/troubleshooting/performance.md b/docs/content/en/troubleshooting/performance.md new file mode 100644 index 000000000..e366eba81 --- /dev/null +++ b/docs/content/en/troubleshooting/performance.md @@ -0,0 +1,104 @@ +--- +title: Performance +description: Tools and suggestions for evaluating and improving performance. +categories: [] +keywords: [] +aliases: [/troubleshooting/build-performance/] +--- + +## Virus scanning + +Virus scanners are an essential component of system protection, but the performance impact can be severe for applications like Hugo that frequently read and write to disk. For example, with Microsoft Defender Antivirus, build times for some sites may increase by 400% or more. + +Before building a site, your virus scanner has already evaluated the files in your project directory. Scanning them again while building the site is superfluous. To improve performance, add Hugo's executable to your virus scanner's process exclusion list. + +For example, with Microsoft Defender Antivirus: + +**Start** > **Settings** > **Privacy & security** > **Windows Security** > **Open Windows Security** > **Virus & threat protection** > **Manage settings** > **Add or remove exclusions** > **Add an exclusion** > **Process** + +Then type `hugo.exe` add press the **Add** button. + +> [!note] +> Virus scanning exclusions are common, but use caution when changing these settings. See the [Microsoft Defender Antivirus documentation] for details. + +Other virus scanners have similar exclusion mechanisms. See their respective documentation. + +## Template metrics + +Hugo is fast, but inefficient templates impede performance. Enable template metrics to determine which templates take the most time, and to identify caching opportunities: + +```sh +hugo --templateMetrics --templateMetricsHints +``` + +The result will look something like this: + +```text +Template Metrics: + + cumulative average maximum cache percent cached total + duration duration duration potential cached count count template + ---------- -------- -------- --------- ------- ------ ----- -------- + 36.037476822s 135.990478ms 225.765245ms 11 0 0 265 partials/head.html + 35.920040902s 164.018451ms 233.475072ms 0 0 0 219 articles/single.html + 34.163268129s 128.917992ms 224.816751ms 23 0 0 265 partials/head/meta/opengraph.html + 1.041227437s 3.92916ms 186.303376ms 47 0 0 265 partials/head/meta/schema.html + 805.628827ms 27.780304ms 114.678523ms 0 0 0 29 _default/list.html + 624.08354ms 15.221549ms 108.420729ms 8 0 0 41 partials/utilities/render-page-collection.html + 545.968801ms 775.523µs 105.045775ms 0 0 0 704 _default/summary.html + 334.680981ms 1.262947ms 127.412027ms 100 0 0 265 partials/head/js.html + 272.763205ms 2.050851ms 24.371757ms 0 0 0 133 _default/_markup/render-codeblock.html + 230.490038ms 8.865001ms 177.4615ms 0 0 0 26 shortcodes/template.html + 176.921913ms 176.921913ms 176.921913ms 0 0 0 1 examples.tmpl + 163.951469ms 14.904679ms 70.267953ms 0 0 0 11 articles/list.html + 153.07021ms 577.623µs 73.593597ms 100 0 0 265 partials/head/init.html + 150.910984ms 150.910984ms 150.910984ms 0 0 0 1 _default/single.html + 146.785804ms 146.785804ms 146.785804ms 0 0 0 1 _default/contact.html + 115.364617ms 115.364617ms 115.364617ms 0 0 0 1 authors/term.html + 87.392071ms 329.781µs 10.687132ms 100 0 0 265 partials/head/css.html + 86.803122ms 86.803122ms 86.803122ms 0 0 0 1 _default/home.html +``` + +From left to right, the columns represent: + +cumulative duration +: The cumulative time spent executing the template. + +average duration +: The average time spent executing the template. + +maximum duration +: The maximum time spent executing the template. + +cache potential +: Displayed as a percentage, any partial template with a 100% cache potential should be called with the [`partialCached`] function instead of the [`partial`] function. See the [caching](#caching) section below. + +percent cached +: The number of times the rendered templated was cached divided by the number of times the template was executed. + +cached count +: The number of times the rendered templated was cached. + +total count +: The number of times the template was executed. + +template +: The path to the template, relative to the `layouts` directory. + +> [!note] +> Hugo builds pages in parallel where multiple pages are generated simultaneously. Because of this parallelism, the sum of "cumulative duration" values is usually greater than the actual time it takes to build a site. + +## Caching + +Some partial templates such as sidebars or menus are executed many times during a site build. Depending on the content within the partial template and the desired output, the template may benefit from caching to reduce the number of executions. The [`partialCached`] template function provides caching capabilities for partial templates. + +> [!note] +> Note that you can create cached variants of each partial by passing additional arguments to `partialCached` beyond the initial context. See the `partialCached` documentation for more details. + +## Timers + +Use the `debug.Timer` function to determine execution time for a block of code, useful for finding performance bottlenecks in templates. See [details](/functions/debug/timer/). + +[`partial`]: /functions/partials/include/ +[`partialCached`]: /functions/partials/includecached/ +[Microsoft Defender Antivirus documentation]: https://support.microsoft.com/en-us/topic/how-to-add-a-file-type-or-process-exclusion-to-windows-security-e524cbc2-3975-63c2-f9d1-7c2eb5331e53 diff --git a/docs/content/en/variables/_index.md b/docs/content/en/variables/_index.md deleted file mode 100644 index 382ee25d4..000000000 --- a/docs/content/en/variables/_index.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -title: Variables and Params -linktitle: Variables Overview -description: Page-, file-, taxonomy-, and site-level variables and parameters available in templates. -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2017-02-01 -categories: [variables and params] -keywords: [variables,params,values,globals] -draft: false -menu: - docs: - parent: "variables" - weight: 1 -weight: 01 #rem -sections_weight: 01 -aliases: [/templates/variables/] -toc: false ---- - -Hugo's templates are context aware and make a large number of values available to you as you're creating views for your website. - -[Go templates]: /templates/introduction/ "Understand context in Go templates by learning the language's fundamental templating functions." diff --git a/docs/content/en/variables/files.md b/docs/content/en/variables/files.md deleted file mode 100644 index eb5d404de..000000000 --- a/docs/content/en/variables/files.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -title: File Variables -linktitle: -description: "You can access filesystem-related data for a content file in the `.File` variable." -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2017-02-01 -categories: [variables and params] -keywords: [files] -draft: false -menu: - docs: - parent: "variables" - weight: 40 -weight: 40 -sections_weight: 40 -aliases: [/variables/file-variables/] -toc: false ---- - -{{% note "Rendering Local Files" %}} -For information on creating shortcodes and templates that tap into Hugo's file-related feature set, see [Local File Templates](/templates/files/). -{{% /note %}} - -The `.File` object contains the following fields: - -.File.Path -: the original relative path of the page, relative to the content dir (e.g., `posts/foo.en.md`) - -.File.LogicalName -: the name of the content file that represents a page (e.g., `foo.en.md`) - -.File.TranslationBaseName -: the filename without extension or optional language identifier (e.g., `foo`) - -.File.ContentBaseName -: is a either TranslationBaseName or name of containing folder if file is a leaf bundle. - -.File.BaseFileName -: the filename without extension (e.g., `foo.en`) - - -.File.Ext -: the file extension of the content file (e.g., `md`); this can also be called using `.File.Extension` as well. Note that it is *only* the extension without `.`. - -.File.Lang -: the language associated with the given file if Hugo's [Multilingual features][multilingual] are enabled (e.g., `en`) - -.File.Dir -: given the path `content/posts/dir1/dir2/`, the relative directory path of the content file will be returned (e.g., `posts/dir1/dir2/`). Note that the path separator (`\` or `/`) could be dependent on the operating system. - -[Multilingual]: /content-management/multilingual/ diff --git a/docs/content/en/variables/git.md b/docs/content/en/variables/git.md deleted file mode 100644 index 59ee9ac88..000000000 --- a/docs/content/en/variables/git.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -title: Git Info Variables -linktitle: Git Variables -description: Get the last Git revision information for every content file. -date: 2017-03-12 -publishdate: 2017-03-12 -lastmod: 2017-03-12 -categories: [variables and params] -keywords: [git] -draft: false -menu: - docs: - parent: "variables" - weight: 70 -weight: 70 -sections_weight: 70 -aliases: [/extras/gitinfo/] -toc: false -wip: false ---- - -{{% note "`.GitInfo` Performance Considerations" %}} -Hugo's Git integrations should be fairly performant but *can* increase your build time. This will depend on the size of your Git history. -{{% /note %}} - -## `.GitInfo` Prerequisites - -1. The Hugo site must be in a Git-enabled directory. -2. The Git executable must be installed and in your system `PATH`. -3. The `.GitInfo` feature must be enabled in your Hugo project by passing `--enableGitInfo` flag on the command line or by setting `enableGitInfo` to `true` in your [site's configuration file][configuration]. - -## The `.GitInfo` Object - -The `GitInfo` object contains the following fields: - -.AbbreviatedHash -: the abbreviated commit hash (e.g., `866cbcc`) - -.AuthorName -: the author's name, respecting `.mailmap` - -.AuthorEmail -: the author's email address, respecting `.mailmap` - -.AuthorDate -: the author date - -.Hash -: the commit hash (e.g., `866cbccdab588b9908887ffd3b4f2667e94090c3`) - -.Subject -: commit message subject (e.g., `tpl: Add custom index function`) - -## `.Lastmod` - -If the `.GitInfo` feature is enabled, `.Lastmod` (on `Page`) is fetched from Git i.e. `.GitInfo.AuthorDate`. This behaviour can be changed by adding your own [front matter configuration for dates](/getting-started/configuration/#configure-front-matter). - -[configuration]: /getting-started/configuration/ diff --git a/docs/content/en/variables/hugo.md b/docs/content/en/variables/hugo.md deleted file mode 100644 index 0de5bd794..000000000 --- a/docs/content/en/variables/hugo.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -title: Hugo-specific Variables -linktitle: Hugo Variables -description: The `.Hugo` variable provides easy access to Hugo-related data. -date: 2017-03-12 -publishdate: 2017-03-12 -lastmod: 2017-03-12 -categories: [variables and params] -keywords: [hugo,generator] -draft: false -menu: - docs: - parent: "variables" - weight: 60 -weight: 60 -sections_weight: 60 -aliases: [] -toc: false -wip: false ---- - -{{% warning "Deprecated" %}} -Page's `.Hugo` is deprecated and will be removed in a future release. Use the global `hugo` function. -For example: `hugo.Generator`. -{{% /warning %}} - -It contains the following fields: - -.Hugo.Generator -: `` tag for the version of Hugo that generated the site. `.Hugo.Generator` outputs a *complete* HTML tag; e.g. `` - -.Hugo.Version -: the current version of the Hugo binary you are using e.g. `0.13-DEV`
    - -.Hugo.Environment -: the current running environment as defined through the `--environment` cli tag. - -.Hugo.CommitHash -: the git commit hash of the current Hugo binary e.g. `0e8bed9ccffba0df554728b46c5bbf6d78ae5247` - -.Hugo.BuildDate -: the compile date of the current Hugo binary formatted with RFC 3339 e.g. `2002-10-02T10:00:00-05:00`
    - - - -{{% note "Use the Hugo Generator Tag" %}} -We highly recommend using `.Hugo.Generator` in your website's ``. `.Hugo.Generator` is included by default in all themes hosted on [themes.gohugo.io](https://themes.gohugo.io). The generator tag allows the Hugo team to track the usage and popularity of Hugo. -{{% /note %}} - diff --git a/docs/content/en/variables/menus.md b/docs/content/en/variables/menus.md deleted file mode 100644 index 6717fecbb..000000000 --- a/docs/content/en/variables/menus.md +++ /dev/null @@ -1,124 +0,0 @@ ---- -title: Menu Entry Properties -linktitle: Menu Entry Properties -description: A menu entry in a menu-template has specific variables and functions to make menu management easier. -date: 2017-03-12 -publishdate: 2017-03-12 -lastmod: 2017-03-12 -categories: [variables and params] -keywords: [menus] -draft: false -menu: - docs: - title: "variables defined by a menu entry" - parent: "variables" - weight: 50 -weight: 50 -sections_weight: 50 -aliases: [/variables/menu/] -toc: false ---- - -A **menu entry** has the following properties available that can be used in a -[menu template][menu-template]. - -## Menu Entry Variables - -.Menu -: _string_
    -Name of the **menu** that contains this **menu entry**. - -.URL -: _string_
    -URL that the menu entry points to. The `url` key, if set for the menu entry, -sets this value. If that key is not set, and if the menu entry is set in a page -front-matter, this value defaults to the page's `.RelPermalink`. - -.Page -: _\*Page_
    -Reference to the [page object][page-object] associated with the menu entry. This -will be non-nil if the menu entry is set via a page's front-matter and not via -the site config. - -.Name -: _string_
    -Name of the menu entry. The `name` key, if set for the menu entry, sets -this value. If that key is not set, and if the menu entry is set in a page -front-matter, this value defaults to the page's `.LinkTitle`. - -.Identifier -: _string_
    -Value of the `identifier` key if set for the menu entry. This value must be -unique for each menu entry. **It is necessary to set a unique identifier -manually if two or more menu entries have the same `.Name`.** - -.Pre -: _template.HTML_
    -Value of the `pre` key if set for the menu entry. This value typically contains -a string representing HTML. - -.Post -: _template.HTML_
    -Value of the `post` key if set for the menu entry. This value typically contains -a string representing HTML. - -.Weight -: _int_
    -Value of the `weight` key if set for the menu entry. If that key is not set, -and if the menu entry is set in a page front-matter, this value defaults to the -page's `.Weight`. - -.Parent -: _string_
    -Name (or Identifier if present) of this menu entry's parent **menu entry**. The -`parent` key, if set for the menu entry, sets this value. If this key is set, -this menu entry nests under that parent entry, else it nests directly under the -`.Menu`. - -.Children -: _Menu_
    -This value is auto-populated by Hugo. It is a collection of children menu -entries, if any, under the current menu entry. - -## Menu Entry Functions - -Menus also have the following functions available: - -.HasChildren -: _boolean_
    -Returns `true` if `.Children` is non-nil. - -.KeyName -: _string_
    -Returns the `.Identifier` if present, else returns the `.Name`. - -.IsEqual -: _boolean_
    -Returns `true` if the two compared menu entries represent the same menu entry. - -.IsSameResource -: _boolean_
    -Returns `true` if the two compared menu entries have the same `.URL`. - -.Title -: _string_
    -Link title, meant to be used in the `title` attribute of a menu entry's -``-tags. Returns the menu entry's `title` key if set. Else, if the menu -entry was created through a page's front-matter, it returns the page's -`.LinkTitle`. Else, it just returns an empty string. - -## Other Menu-related Functions - -Additionally, here are some relevant methods available to menus on a page: - -.IsMenuCurrent -: _(menu string, menuEntry *MenuEntry ) boolean_
    -See [`.IsMenuCurrent` method](/functions/ismenucurrent/). - -.HasMenuCurrent -: _(menu string, menuEntry *MenuEntry) boolean_
    -See [`.HasMenuCurrent` method](/functions/hasmenucurrent/). - - -[menu-template]: /templates/menu-templates/ -[page-object]: /variables/page/ diff --git a/docs/content/en/variables/page.md b/docs/content/en/variables/page.md deleted file mode 100644 index c426ca47c..000000000 --- a/docs/content/en/variables/page.md +++ /dev/null @@ -1,304 +0,0 @@ ---- -title: Page Variables -linktitle: -description: Page-level variables are defined in a content file's front matter, derived from the content's file location, or extracted from the content body itself. -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2017-02-01 -categories: [variables and params] -keywords: [pages] -draft: false -menu: - docs: - title: "variables defined by a page" - parent: "variables" - weight: 20 -weight: 20 -sections_weight: 20 -aliases: [] -toc: true ---- - -The following is a list of page-level variables. Many of these will be defined in the front matter, derived from file location, or extracted from the content itself. - -{{% note "`.Scratch`" %}} -See [`.Scratch`](/functions/scratch/) for page-scoped, writable variables. -{{% /note %}} - -## Page Variables - -.AlternativeOutputFormats -: contains all alternative formats for a given page; this variable is especially useful `link rel` list in your site's ``. (See [Output Formats](/templates/output-formats/).) - -.Aliases -: aliases of this page - -.Content -: the content itself, defined below the front matter. - -.Data -: the data specific to this type of page. - -.Date -: the date associated with the page; `.Date` pulls from the `date` field in a content's front matter. See also `.ExpiryDate`, `.PublishDate`, and `.Lastmod`. - -.Description -: the description for the page. - -.Dir -: the path of the folder containing this content file. The path is relative to the `content` folder. - -.Draft -: a boolean, `true` if the content is marked as a draft in the front matter. - -.ExpiryDate -: the date on which the content is scheduled to expire; `.ExpiryDate` pulls from the `expirydate` field in a content's front matter. See also `.PublishDate`, `.Date`, and `.Lastmod`. - -.File -: filesystem-related data for this content file. See also [File Variables][]. - -.FuzzyWordCount -: the approximate number of words in the content. - -.Hugo -: see [Hugo Variables](/variables/hugo/). - -.IsHome -: `true` in the context of the [homepage](/templates/homepage/). - -.IsNode -: always `false` for regular content pages. - -.IsPage -: always `true` for regular content pages. - -.IsTranslated -: `true` if there are translations to display. - -.Keywords -: the meta keywords for the content. - -.Kind -: the page's *kind*. Possible return values are `page`, `home`, `section`, `taxonomy`, or `taxonomyTerm`. Note that there are also `RSS`, `sitemap`, `robotsTXT`, and `404` kinds, but these are only available during the rendering of each of these respective page's kind and therefore *not* available in any of the `Pages` collections. - -.Language -: a language object that points to the language's definition in the site `config`. `.Language.Lang` gives you the language code. - -.Lastmod -: the date the content was last modified. `.Lastmod` pulls from the `lastmod` field in a content's front matter. - - - If `lastmod` is not set, and `.GitInfo` feature is disabled, the front matter `date` field will be used. - - If `lastmod` is not set, and `.GitInfo` feature is enabled, `.GitInfo.AuthorDate` will be used instead. - -See also `.ExpiryDate`, `.Date`, `.PublishDate`, and [`.GitInfo`][gitinfo]. - -.LinkTitle -: access when creating links to the content. If set, Hugo will use the `linktitle` from the front matter before `title`. - -.Next -: Points up to the next [regular page](/variables/site/#site-pages) (sorted by Hugo's [default sort](/templates/lists#default-weight-date-linktitle-filepath)). Example: `{{with .Next}}{{.Permalink}}{{end}}`. Calling `.Next` from the first page returns `nil`. - -.NextInSection -: Points up to the next [regular page](/variables/site/#site-pages) below the same top level section (e.g. in `/blog`)). Pages are sorted by Hugo's [default sort](/templates/lists#default-weight-date-linktitle-filepath). Example: `{{with .NextInSection}}{{.Permalink}}{{end}}`. Calling `.NextInSection` from the first page returns `nil`. - -.OutputFormats -: contains all formats, including the current format, for a given page. Can be combined the with [`.Get` function](/functions/get/) to grab a specific format. (See [Output Formats](/templates/output-formats/).) - -.Pages -: a collection of associated pages. This value will be `nil` within - the context of regular content pages. See [`.Pages`](#pages). - -.Permalink -: the Permanent link for this page; see [Permalinks](/content-management/urls/) - -.Plain -: the Page content stripped of HTML tags and presented as a string. - -.PlainWords -: the Page content stripped of HTML as a `[]string` using Go's [`strings.Fields`](https://golang.org/pkg/strings/#Fields) to split `.Plain` into a slice. - -.Prev -: Points down to the previous [regular page](/variables/site/#site-pages) (sorted by Hugo's [default sort](/templates/lists#default-weight-date-linktitle-filepath)). Example: `{{if .PrevPage}}{{.PrevPage.Permalink}}{{end}}`. Calling `.Prev` from the last page returns `nil`. - -.PrevInSection -: Points down to the previous [regular page](/variables/site/#site-pages) below the same top level section (e.g. `/blog`). Pages are sorted by Hugo's [default sort](/templates/lists#default-weight-date-linktitle-filepath). Example: `{{if .PrevInSection}}{{.PrevInSection.Permalink}}{{end}}`. Calling `.PrevInSection` from the last page returns `nil`. - -.PublishDate -: the date on which the content was or will be published; `.Publishdate` pulls from the `publishdate` field in a content's front matter. See also `.ExpiryDate`, `.Date`, and `.Lastmod`. - -.RSSLink (deprecated) -: link to the page's RSS feed. This is deprecated. You should instead do something like this: `{{ with .OutputFormats.Get "RSS" }}{{ .RelPermalink }}{{ end }}`. - -.RawContent -: raw markdown content without the front matter. Useful with [remarkjs.com]( -http://remarkjs.com) - -.ReadingTime -: the estimated time, in minutes, it takes to read the content. - -.Resources -: resources such as images and CSS that are associated with this page - -.Ref -: returns the permalink for a given reference (e.g., `.Ref "sample.md"`). `.Ref` does *not* handle in-page fragments correctly. See [Cross References](/content-management/cross-references/). - -.RelPermalink -: the relative permanent link for this page. - -.RelRef -: returns the relative permalink for a given reference (e.g., `RelRef -"sample.md"`). `.RelRef` does *not* handle in-page fragments correctly. See [Cross References](/content-management/cross-references/). - -.Site -: see [Site Variables](/variables/site/). - -.Sites -: returns all sites (languages). A typical use case would be to link back to the main language: `
    ...`. - -.Sites.First -: returns the site for the first language. If this is not a multilingual setup, it will return itself. - -.Summary -: a generated summary of the content for easily showing a snippet in a summary view. The breakpoint can be set manually by inserting <!--more--> at the appropriate place in the content page, or the summary can be written independent of the page text. See [Content Summaries](/content-management/summaries/) for more details. - -.TableOfContents -: the rendered [table of contents](/content-management/toc/) for the page. - -.Title -: the title for this page. - -.Translations -: a list of translated versions of the current page. See [Multilingual Mode](/content-management/multilingual/) for more information. - -.TranslationKey -: the key used to map language translations of the current page. See [Multilingual Mode](/content-management/multilingual/) for more information. - -.Truncated -: a boolean, `true` if the `.Summary` is truncated. Useful for showing a "Read more..." link only when necessary. See [Summaries](/content-management/summaries/) for more information. - -.Type -: the [content type](/content-management/types/) of the content (e.g., `posts`). - -.UniqueID -: the MD5-checksum of the content file's path. - -.Weight -: assigned weight (in the front matter) to this content, used in sorting. - -.WordCount -: the number of words in the content. - -## Section Variables and Methods - -Also see [Sections](/content-management/sections/). - -{{< readfile file="/content/en/readfiles/sectionvars.md" markdown="true" >}} - -## The `.Pages` Variable {#pages} - -`.Pages` is an alias to `.Data.Pages`. It is conventional to use the -aliased form `.Pages`. - -### `.Pages` compared to `.Site.Pages` - -{{< readfile file="/content/en/readfiles/pages-vs-site-pages.md" markdown="true" >}} - -## Page-level Params - -Any other value defined in the front matter in a content file, including taxonomies, will be made available as part of the `.Params` variable. - -``` ---- -title: My First Post -date: 2017-02-20T15:26:23-06:00 -categories: [one] -tags: [two,three,four] -``` - -With the above front matter, the `tags` and `categories` taxonomies are accessible via the following: - -* `.Params.tags` -* `.Params.categories` - -{{% note "Casing of Params" %}} -Page-level `.Params` are *only* accessible in lowercase. -{{% /note %}} - -The `.Params` variable is particularly useful for the introduction of user-defined front matter fields in content files. For example, a Hugo website on book reviews could have the following front matter in `/content/review/book01.md`: - -``` ---- -... -affiliatelink: "http://www.my-book-link.here" -recommendedby: "My Mother" -... ---- -``` - -These fields would then be accessible to the `/themes/yourtheme/layouts/review/single.html` template through `.Params.affiliatelink` and `.Params.recommendedby`, respectively. - -Two common situations where this type of front matter field could be introduced is as a value of a certain attribute like `href=""` or by itself to be displayed as text to the website's visitors. - -{{< code file="/themes/yourtheme/layouts/review/single.html" >}} -

    Buy this book

    -

    It was recommended by {{ .Params.recommendedby }}.

    -{{< /code >}} - -This template would render as follows, assuming you've set [`uglyURLs`](/content-management/urls/) to `false` in your [site `config`](/getting-started/configuration/): - -{{< output file="yourbaseurl/review/book01/index.html" >}} -

    Buy this book

    -

    It was recommended by my Mother.

    -{{< /output >}} - -{{% note %}} -See [Archetypes](/content-management/archetypes/) for consistency of `Params` across pieces of content. -{{% /note %}} - -### The `.Param` Method - -In Hugo, you can declare params in individual pages and globally for your entire website. A common use case is to have a general value for the site param and a more specific value for some of the pages (i.e., a header image): - -``` -{{ $.Param "header_image" }} -``` - -The `.Param` method provides a way to resolve a single value according to it's definition in a page parameter (i.e. in the content's front matter) or a site parameter (i.e., in your `config`). - -### Access Nested Fields in Front Matter - -When front matter contains nested fields like the following: - -``` ---- -author: - given_name: John - family_name: Feminella - display_name: John Feminella ---- -``` -`.Param` can access these fields by concatenating the field names together with a dot: - -``` -{{ $.Param "author.display_name" }} -``` - -If your front matter contains a top-level key that is ambiguous with a nested key, as in the following case: - -``` ---- -favorites.flavor: vanilla -favorites: - flavor: chocolate ---- -``` - -The top-level key will be preferred. Therefore, the following method, when applied to the previous example, will print `vanilla` and not `chocolate`: - -``` -{{ $.Param "favorites.flavor" }} -=> vanilla -``` - -[gitinfo]: /variables/git/ -[File Variables]: /variables/files/ diff --git a/docs/content/en/variables/pages.md b/docs/content/en/variables/pages.md deleted file mode 100644 index 79d39a158..000000000 --- a/docs/content/en/variables/pages.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -title: Pages Methods -linktitle: -description: Pages is the core page collection in Hugo and has many useful methods. -date: 2019-10-20 -categories: [variables and params] -keywords: [pages] -draft: false -menu: - docs: - title: "methods defined on a page collection" - parent: "variables" - weight: 21 -weight: 21 -sections_weight: 20 -aliases: [/pages] -toc: true ---- - -Also see [List templates](/templates/lists) for an overview of sort methods. - -## .Next PAGE - -`.Next` and `.Prev` on `Pages` work similar to the methods with the same names on `.Page`, but are more flexible (and slightly slower) as they can be used on any page collection. - -`.Next` points **up** to the next page relative to the page sent in as the argument. Example: `{{with .Site.RegularPages.Next . }}{{.RelPermalink}}{{end}}`. Calling `.Next` with the first page in the collection returns `nil`. - -## .Prev PAGE - -`.Prev` points **down** to the previous page relative to the page sent in as the argument. Example: `{{with .Site.RegularPages.Prev . }}{{.RelPermalink}}{{end}}`. Calling `.Prev` with the last page in the collection returns `nil`. \ No newline at end of file diff --git a/docs/content/en/variables/shortcodes.md b/docs/content/en/variables/shortcodes.md deleted file mode 100644 index 7462deec7..000000000 --- a/docs/content/en/variables/shortcodes.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -title: Shortcode Variables -linktitle: Shortcode Variables -description: Shortcodes can access page variables and also have their own specific built-in variables. -date: 2017-03-12 -publishdate: 2017-03-12 -lastmod: 2017-03-12 -categories: [variables and params] -keywords: [shortcodes] -draft: false -menu: - docs: - parent: "variables" - weight: 20 -weight: 20 -sections_weight: 20 -aliases: [] -toc: false ---- - -[Shortcodes][shortcodes] have access to parameters delimited in the shortcode declaration via [`.Get`][getfunction], page- and site-level variables, and also the following shortcode-specific fields: - -.Name -: Shortcode name. - -.Ordinal -: Zero-based ordinal in relation to its parent. If the parent is the page itself, this ordinal will represent the position of this shortcode in the page content. - -.Parent -: provides access to the parent shortcode context in nested shortcodes. This can be very useful for inheritance of common shortcode parameters from the root. - -.Position -: Contains [filename and position](https://godoc.org/github.com/gohugoio/hugo/common/text#Position) for the shortcode in a page. Note that this can be relatively expensive to calculate, and is meant for error reporting. See [Error Handling in Shortcodes](/templates/shortcode-templates/#error-handling-in-shortcodes). - - - - -.IsNamedParams -: boolean that returns `true` when the shortcode in question uses [named rather than positional parameters][shortcodes] - -.Inner -: represents the content between the opening and closing shortcode tags when a [closing shortcode][markdownshortcode] is used - -[getfunction]: /functions/get/ -[markdownshortcode]: /content-management/shortcodes/#shortcodes-with-markdown -[shortcodes]: /templates/shortcode-templates/ - - diff --git a/docs/content/en/variables/site.md b/docs/content/en/variables/site.md deleted file mode 100644 index 7d663b96b..000000000 --- a/docs/content/en/variables/site.md +++ /dev/null @@ -1,131 +0,0 @@ ---- -title: Site Variables -linktitle: Site Variables -description: Many, but not all, site-wide variables are defined in your site's configuration. However, Hugo provides a number of built-in variables for convenient access to global values in your templates. -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2017-02-01 -categories: [variables and params] -keywords: [global,site] -draft: false -menu: - docs: - parent: "variables" - weight: 10 -weight: 10 -sections_weight: 10 -aliases: [/variables/site-variables/] -toc: true ---- - -The following is a list of site-level (aka "global") variables. Many of these variables are defined in your site's [configuration file][config], whereas others are built into Hugo's core for convenient usage in your templates. - -## Site Variables List - -.Site.AllPages -: array of all pages, regardless of their translation. - -.Site.Author -: a map of the authors as defined in the site configuration. - -.Site.BaseURL -: the base URL for the site as defined in the site configuration. - -.Site.BuildDrafts -: a boolean (default: `false`) to indicate whether to build drafts as defined in the site configuration. - -.Site.Copyright -: a string representing the copyright of your website as defined in the site configuration. - -.Site.Data -: custom data, see [Data Templates](/templates/data-templates/). - -.Site.DisqusShortname -: a string representing the shortname of the Disqus shortcode as defined in the site configuration. - -.Site.GoogleAnalytics -: a string representing your tracking code for Google Analytics as defined in the site configuration. - -.Site.Home -: reference to the homepage's [page object](https://gohugo.io/variables/page/) - -.Site.IsMultiLingual -: whether there are more than one language in this site. See [Multilingual](/content-management/multilingual/) for more information. - -.Site.IsServer -: a boolean to indicate if the site is being served with Hugo's built-in server. See [`hugo server`](/commands/hugo_server/) for more information. - -.Site.Language.Lang -: the language code of the current locale (e.g., `en`). - -.Site.Language.LanguageName -: the full language name (e.g. `English`). - -.Site.Language.Weight -: the weight that defines the order in the `.Site.Languages` list. - -.Site.Language -: indicates the language currently being used to render the website. This object's attributes are set in site configurations' language definition. - -.Site.LanguageCode -: a string representing the language as defined in the site configuration. This is mostly used to populate the RSS feeds with the right language code. - -.Site.LanguagePrefix -: this can be used to prefix URLs to point to the correct language. It will even work when only one defined language. See also the functions [absLangURL](/functions/abslangurl/) and [relLangURL](/functions/rellangurl). - -.Site.Languages -: an ordered list (ordered by defined weight) of languages. - -.Site.LastChange -: a string representing the date/time of the most recent change to your site. This string is based on the [`date` variable in the front matter](/content-management/front-matter) of your content pages. - -.Site.Menus -: all of the menus in the site. - -.Site.Pages -: array of all content ordered by Date with the newest first. This array contains only the pages in the current language. See [`.Site.Pages`](#site-pages). - -.Site.RegularPages -: a shortcut to the *regular* page collection. `.Site.RegularPages` is equivalent to `where .Site.Pages "Kind" "page"`. See [`.Site.Pages`](#site-pages). - -.Site.Sections -: top-level directories of the site. - -.Site.Taxonomies -: the [taxonomies](/taxonomies/usage/) for the entire site. Replaces the now-obsolete `.Site.Indexes` since v0.11. Also see section [Taxonomies elsewhere](#taxonomies-elsewhere). - -.Site.Title -: a string representing the title of the site. - -## The `.Site.Params` Variable - -`.Site.Params` is a container holding the values from the `params` section of your site configuration. - -### Example: `.Site.Params` - -The following `config.[yaml|toml|json]` defines a site-wide param for `description`: - -{{< code-toggle file="config" >}} -baseURL = "https://yoursite.example.com/" - -[params] - description = "Tesla's Awesome Hugo Site" - author = "Nikola Tesla" -{{}} - -You can use `.Site.Params` in a [partial template](/templates/partials/) to call the default site description: - -{{< code file="layouts/partials/head.html" >}} - -{{< /code >}} - -## The `.Site.Pages` Variable {#site-pages} - -### `.Site.Pages` compared to `.Pages` - -{{< readfile file="/content/en/readfiles/pages-vs-site-pages.md" markdown="true" >}} - - - - -[config]: /getting-started/configuration/ diff --git a/docs/content/en/variables/sitemap.md b/docs/content/en/variables/sitemap.md deleted file mode 100644 index dd926f2b3..000000000 --- a/docs/content/en/variables/sitemap.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -title: Sitemap Variables -linktitle: Sitemap Variables -description: -date: 2017-03-12 -publishdate: 2017-03-12 -lastmod: 2017-03-12 -categories: [variables and params] -keywords: [sitemap] -draft: false -menu: - docs: - parent: "variables" - weight: 80 -weight: 80 -sections_weight: 80 -aliases: [] -toc: false ---- - -A sitemap is a `Page` and therefore has all the [page variables][pagevars] available to use sitemap templates. They also have the following sitemap-specific variables available to them: - -.Sitemap.ChangeFreq -: the page change frequency - -.Sitemap.Priority -: the priority of the page - -.Sitemap.Filename -: the sitemap filename - -[pagevars]: /variables/page/ \ No newline at end of file diff --git a/docs/content/en/variables/taxonomy.md b/docs/content/en/variables/taxonomy.md deleted file mode 100644 index 5bcdffee5..000000000 --- a/docs/content/en/variables/taxonomy.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -title: Taxonomy Variables -linktitle: -description: Taxonomy pages are of type `Page` and have all page-, site-, and list-level variables available to them. However, taxonomy terms templates have additional variables available to their templates. -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2017-02-01 -categories: [variables and params] -keywords: [taxonomies,terms] -draft: false -menu: - docs: - parent: "variables" - weight: 30 -weight: 30 -sections_weight: 30 -aliases: [] -toc: true ---- - -## Taxonomy Terms Page Variables - -[Taxonomy terms pages][taxonomytemplates] are of the type `Page` and have the following additional variables. - -For example, the following fields would be available in `layouts/_defaults/terms.html`, depending on how you organize your [taxonomy templates][taxonomytemplates]: - -.Data.Singular -: The singular name of the taxonomy (e.g., `tags => tag`) - -.Data.Plural -: The plural name of the taxonomy (e.g., `tags => tags`) - -.Data.Pages -: The list of pages in the taxonomy - -.Data.Terms -: The taxonomy itself - -.Data.Terms.Alphabetical -: The taxonomy terms alphabetized - -.Data.Terms.ByCount -: The Terms ordered by popularity - -Note that `.Data.Terms.Alphabetical` and `.Data.Terms.ByCount` can also be reversed: - -* `.Data.Terms.Alphabetical.Reverse` -* `.Data.Terms.ByCount.Reverse` - -## Use `.Site.Taxonomies` Outside of Taxonomy Templates - -The `.Site.Taxonomies` variable holds all the taxonomies defined site-wide. `.Site.Taxonomies` is a map of the taxonomy name to a list of its values (e.g., `"tags" -> ["tag1", "tag2", "tag3"]`). Each value, though, is not a string but rather a *Taxonomy variable*. - -## The `.Taxonomy` Variable - -The `.Taxonomy` variable, available, for example, as `.Site.Taxonomies.tags`, contains the list of tags (values) and, for each tag, their corresponding content pages. - -### Example Usage of `.Site.Taxonomies` - -The following [partial template][partials] will list all your site's taxonomies, each of their keys, and all the content assigned to each of the keys. For more examples of how to order and render your taxonomies, see [Taxonomy Templates][taxonomytemplates]. - -{{< code file="all-taxonomies-keys-and-pages.html" download="all-taxonomies-keys-and-pages.html" >}} -
    -
      - {{ range $taxonomyname, $taxonomy := .Site.Taxonomies }} -
    • {{ $taxonomyname }} -
        - {{ range $key, $value := $taxonomy }} -
      • {{ $key }}
      • - - {{ end }} -
      -
    • - {{ end }} -
    -
    -{{< /code >}} - -[partials]: /templates/partials/ -[taxonomytemplates]: /templates/taxonomy-templates/ diff --git a/docs/content/zh/_index.md b/docs/content/zh/_index.md deleted file mode 100644 index 78f9ef15f..000000000 --- a/docs/content/zh/_index.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -title: "世界上最快的网站构建框架 The world’s fastest framework for building websites" -date: 2017-03-02T12:00:00-05:00 -features: - - heading: Blistering Speed - image_path: /images/icon-fast.svg - tagline: What's modern about waiting for your site to build? - copy: Hugo is the fastest tool of its kind. At <1 ms per page, the average site builds in less than a second. - - - heading: Robust Content Management - image_path: /images/icon-content-management.svg - tagline: Flexibility rules. Hugo is a content strategist's dream. - copy: Hugo supports unlimited content types, taxonomies, menus, dynamic API-driven content, and more, all without plugins. - - - heading: Shortcodes - image_path: /images/icon-shortcodes.svg - tagline: Hugo's shortcodes are Markdown's hidden superpower. - copy: We love the beautiful simplicity of markdown’s syntax, but there are times when we want more flexibility. Hugo shortcodes allow for both beauty and flexibility. - - - heading: Built-in Templates - image_path: /images/icon-built-in-templates.svg - tagline: Hugo has common patterns to get your work done quickly. - copy: Hugo ships with pre-made templates to make quick work of SEO, commenting, analytics and other functions. One line of code, and you're done. - - - heading: Multilingual and i18n - image_path: /images/icon-multilingual2.svg - tagline: Polyglot baked in. - copy: Hugo provides full i18n support for multi-language sites with the same straightforward development experience Hugo users love in single-language sites. - - - heading: Custom Outputs - image_path: /images/icon-custom-outputs.svg - tagline: HTML not enough? - copy: Hugo allows you to output your content in multiple formats, including JSON or AMP, and makes it easy to create your own. -sections: - - heading: "100s of Themes" - cta: Check out the Hugo's themes. - link: http://themes.gohugo.io/ - color_classes: bg-accent-color white - image: /images/homepage-screenshot-hugo-themes.jpg - copy: "Hugo provides a robust theming system that is easy to implement but capable of producing even the most complicated websites." - - heading: "Capable Templating" - cta: Get Started. - link: templates/ - color_classes: bg-primary-color-light black - image: /images/home-page-templating-example.png - copy: "Hugo's Go-based templating provides just the right amount of logic to build anything from the simple to complex. If you prefer Jade/Pug-like syntax, you can also use Amber, Ace, or any combination of the three." ---- - -Hugo is one of the most popular open-source static site generators. With its amazing speed and flexibility, Hugo makes building websites fun again. diff --git a/docs/content/zh/about/_index.md b/docs/content/zh/about/_index.md deleted file mode 100644 index bf19807d9..000000000 --- a/docs/content/zh/about/_index.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: 关于 Hugo -linktitle: 概览 -description: Hugo 的特色、规划、许可和动力。 -date: 2018-04-26 -publishdate: 2018-04-26 -lastmod: 2018-04-26 -categories: [] -keywords: [] -menu: - docs: - parent: "about" - weight: 1 -weight: 1 -draft: false -aliases: [/about-hugo/,/docs/] -toc: false ---- - -Hugo 不是一般的静态网站生成器。 diff --git a/docs/content/zh/content-management/_index.md b/docs/content/zh/content-management/_index.md deleted file mode 100644 index 8c088dc57..000000000 --- a/docs/content/zh/content-management/_index.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -title: 内容管理 -linktitle: 内容管理概览 -description: Hugo 可以管理大型的静态网站,支持骨架、内容类型、菜单、引用、概要等等。 -date: 2018-04-23 -publishdate: 2018-04-23 -lastmod: 2018-04-23 -menu: - docs: - parent: "content-management" - weight: 1 -keywords: [source, organization] -categories: [content management] -weight: 01 #rem -draft: false -aliases: [/content/,/content/organization] -toc: false -isCJKLanguage: true ---- - -一个实用的静态网站生成器,需要超越“文件头” (front matter) 和模板的等基本功能,才能兼备可伸缩性和可管理性,满足用户所需。Hugo 不仅是给开发者设计的,也同样适用于内容管理员和写作人员。 diff --git a/docs/content/zh/documentation.md b/docs/content/zh/documentation.md deleted file mode 100644 index 1639bbcd2..000000000 --- a/docs/content/zh/documentation.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -title: Hugo 说明文档 -linktitle: Hugo -description: Hugo is the world's fastest static website engine. It's written in Go (aka Golang) and developed by bep, spf13 and friends. -date: 2017-02-01 -publishdate: 2017-02-01 -menu: - main: - parent: "section name" - weight: 01 -weight: 01 #rem -draft: false -slug: -aliases: [] -toc: false -layout: documentation-home -isCJKLanguage: true ---- -Hugo 号称**世界上最快的静态网站引擎**。它是以 Go (即 Golang) 编程语言所写成,并由 [bep](https://github.com/bep)、[spf13](https://github.com/spf13) 和[朋友们](https://github.com/gohugoio/hugo/graphs/contributors) 共同开发。 - -下面是我们说明文档中最常用和实用的章节: diff --git a/docs/content/zh/news/_index.md b/docs/content/zh/news/_index.md deleted file mode 100644 index 286d32e19..000000000 --- a/docs/content/zh/news/_index.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Hugo 新闻" -aliases: [/release-notes/] ---- diff --git a/docs/content/zh/templates/_index.md b/docs/content/zh/templates/_index.md deleted file mode 100644 index 3cd8df436..000000000 --- a/docs/content/zh/templates/_index.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -title: 模板 Templates -linktitle: 模板概览 -description: Go templating, template types and lookup order, shortcodes, and data. -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2017-02-01 -menu: - docs: - parent: "templates" - weight: 01 -weight: 01 #rem -categories: [templates] -keywords: [] -draft: false -aliases: [/templates/overview/,/templates/content] -toc: false -notesforauthors: ---- diff --git a/docs/content/zh/templates/base.md b/docs/content/zh/templates/base.md deleted file mode 100644 index 689a54408..000000000 --- a/docs/content/zh/templates/base.md +++ /dev/null @@ -1,132 +0,0 @@ ---- -title: Base 模板 and Blocks -linktitle: -description: The base and block constructs allow you to define the outer shell of your master templates (i.e., the chrome of the page). -godocref: https://golang.org/pkg/text/template/#example_Template_block -date: 2017-02-01 -publishdate: 2018-08-11 -lastmod: 2017-02-01 -categories: [templates,fundamentals] -keywords: [blocks,base] -menu: - docs: - parent: "templates" - weight: 20 -weight: 20 -sections_weight: 20 -draft: false -aliases: [/templates/blocks/,/templates/base-templates-and-blocks/] -toc: true ---- - -The `block` keyword allows you to define the outer shell of your pages' one or more master template(s) and then fill in or override portions as necessary. - -{{< youtube QVOMCYitLEc >}} - -## Base Template Lookup Order - -The [lookup order][lookup] for base templates is as follows: - -1. `/layouts/section/-baseof.html` -2. `/themes//layouts/section/-baseof.html` -3. `/layouts//baseof.html` -4. `/themes//layouts//baseof.html` -5. `/layouts/section/baseof.html` -6. `/themes//layouts/section/baseof.html` -7. `/layouts/_default/-baseof.html` -8. `/themes//layouts/_default/-baseof.html` -9. `/layouts/_default/baseof.html` -10. `/themes//layouts/_default/baseof.html` - -Variables are denoted by capitalized text set within `<>`. Note that Hugo's default behavior is for `type` to inherit from `section` unless otherwise specified. - -### Example Base Template Lookup Order - -As an example, let's assume your site is using a theme called "mytheme" when rendering the section list for a `posts` section. Hugo picks `layout/section/posts.html` as the template for [rendering the section][]. The `{{define}}` block in this template tells Hugo that the template is an extension of a base template. - -Here is the lookup order for the `posts` base template: - -1. `/layouts/section/posts-baseof.html` -2. `/themes/mytheme/layouts/section/posts-baseof.html` -3. `/layouts/posts/baseof.html` -4. `/themes/mytheme/layouts/posts/baseof.html` -5. `/layouts/section/baseof.html` -6. `/themes/mytheme/layouts/section/baseof.html` -7. `/layouts/_default/posts-baseof.html` -8. `/themes/mytheme/layouts/_default/posts-baseof.html` -9. `/layouts/_default/baseof.html` -10. `/themes/mytheme/layouts/_default/baseof.html` - -## Define the Base Template - -The following defines a simple base template at `_default/baseof.html`. As a default template, it is the shell from which all your pages will be rendered unless you specify another `*baseof.html` closer to the beginning of the lookup order. - -{{< code file="layouts/_default/baseof.html" download="baseof.html" >}} - - - - - {{ block "title" . }} - <!-- Blocks may include default content. --> - {{ .Site.Title }} - {{ end }} - - - - {{ block "main" . }} - - {{ end }} - {{ block "footer" . }} - - {{ end }} - - -{{< /code >}} - -## Override the Base Template - -From the above base template, you can define a [default list template][hugolists]. The default list template will inherit all of the code defined above and can then implement its own `"main"` block from: - -{{< code file="layouts/_default/list.html" download="list.html" >}} -{{ define "main" }} -

    Posts

    - {{ range .Pages }} -
    -

    {{ .Title }}

    - {{ .Content }} -
    - {{ end }} -{{ end }} -{{< /code >}} - -This replaces the contents of our (basically empty) "main" block with something useful for the list template. In this case, we didn't define a `"title"` block, so the contents from our base template remain unchanged in lists. - -{{% warning %}} -Code that you put outside the block definitions *can* break your layout. This even includes HTML comments. For example: - -``` - -{{ define "main" }} -...your code here -{{ end }} -``` -[See this thread from the Hugo discussion forums.](https://discourse.gohugo.io/t/baseof-html-block-templates-and-list-types-results-in-empty-pages/5612/6) -{{% /warning %}} - -The following shows how you can override both the `"main"` and `"title"` block areas from the base template with code unique to your [default single page template][singletemplate]: - -{{< code file="layouts/_default/single.html" download="single.html" >}} -{{ define "title" }} - - {{ .Title }} – {{ .Site.Title }} -{{ end }} -{{ define "main" }} -

    {{ .Title }}

    - {{ .Content }} -{{ end }} -{{< /code >}} - -[hugolists]: /templates/lists -[lookup]: /templates/lookup-order/ -[rendering the section]: /templates/section-templates/ -[singletemplate]: /templates/single-page-templates/ diff --git a/docs/data/articles.toml b/docs/data/articles.toml index 109810803..37b66928f 100644 --- a/docs/data/articles.toml +++ b/docs/data/articles.toml @@ -35,7 +35,7 @@ date = "2017-02-19" [[article]] - title = "Switching from Wordpress to Hugo" + title = "Switching from WordPress to Hugo" url = "http://schnuddelhuddel.de/switching-from-wordpress-to-hugo/" author = "Mario Martelli" date = "2017-02-19" @@ -119,7 +119,7 @@ date = "2016-09-21" [[article]] - title = "Building our site: From Django & Wordpress to a static generator (Part I)" + title = "Building our site: From Django & WordPress to a static generator (Part I)" url = "https://tryolabs.com/blog/2016/09/20/building-our-site-django-wordpress-to-static-part-i/" author = "Alan Descoins" date = "2016-09-20" @@ -143,7 +143,7 @@ date = "2016-02-23" [[article]] - title = "Hugo: A Modern WebSite Engine That Just Works" + title = "Hugo: A Modern Website Engine That Just Works" url = "https://github.com/shekhargulati/52-technologies-in-2016/blob/master/07-hugo/README.md" author = "Shekhar Gulati" date = "2016-02-14" @@ -491,7 +491,7 @@ date = "2014-08-19" [[article]] - title = "Going with hugo" + title = "Going with Hugo" url = "http://www.markuseliasson.se/article/going-with-hugo/" author = "Markus Eliasson" date = "2014-08-18" @@ -509,7 +509,7 @@ date = "2014-08-11" [[article]] - title = "Beautiful sites for Open Source projects" + title = "Beautiful sites for Open Source Projects" url = "http://beautifulopen.com/2014/08/09/hugo/" author = "Beautiful Open" date = "2014-08-09" @@ -545,7 +545,7 @@ date = "2014-08-03" [[article]] - title = "Hugo Is Friggin' Awesome" + title = "Hugo Is Freakin' Awesome" url = "http://npf.io/2014/08/hugo-is-awesome/" author = "Nate Finch" date = "2014-08-01" diff --git a/docs/data/docs.json b/docs/data/docs.json deleted file mode 100644 index 942f04fb0..000000000 --- a/docs/data/docs.json +++ /dev/null @@ -1,4696 +0,0 @@ -{ - "chroma": { - "lexers": [ - { - "Name": "ABAP", - "Aliases": [ - "ABAP", - "abap" - ] - }, - { - "Name": "ABNF", - "Aliases": [ - "abnf" - ] - }, - { - "Name": "ANTLR", - "Aliases": [ - "antlr" - ] - }, - { - "Name": "APL", - "Aliases": [ - "apl" - ] - }, - { - "Name": "ActionScript", - "Aliases": [ - "actionscript", - "as" - ] - }, - { - "Name": "ActionScript 3", - "Aliases": [ - "actionscript3", - "as", - "as3" - ] - }, - { - "Name": "Ada", - "Aliases": [ - "ada", - "ada2005", - "ada95", - "adb", - "ads" - ] - }, - { - "Name": "Angular2", - "Aliases": [ - "ng2" - ] - }, - { - "Name": "ApacheConf", - "Aliases": [ - "aconf", - "apache", - "apacheconf", - "conf", - "htaccess" - ] - }, - { - "Name": "AppleScript", - "Aliases": [ - "applescript" - ] - }, - { - "Name": "Arduino", - "Aliases": [ - "arduino", - "ino" - ] - }, - { - "Name": "Awk", - "Aliases": [ - "awk", - "gawk", - "mawk", - "nawk" - ] - }, - { - "Name": "BNF", - "Aliases": [ - "bnf" - ] - }, - { - "Name": "Ballerina", - "Aliases": [ - "bal", - "ballerina" - ] - }, - { - "Name": "Base Makefile", - "Aliases": [ - "*", - "bsdmake", - "mak", - "make", - "makefile", - "mf", - "mk" - ] - }, - { - "Name": "Bash", - "Aliases": [ - "bash", - "bash_*", - "bashrc", - "ebuild", - "eclass", - "exheres-0", - "exlib", - "ksh", - "sh", - "shell", - "zsh", - "zshrc" - ] - }, - { - "Name": "Batchfile", - "Aliases": [ - "bat", - "batch", - "cmd", - "dosbatch", - "winbatch" - ] - }, - { - "Name": "BibTeX", - "Aliases": [ - "bib", - "bibtex" - ] - }, - { - "Name": "BlitzBasic", - "Aliases": [ - "b3d", - "bb", - "blitzbasic", - "bplus", - "decls" - ] - }, - { - "Name": "Brainfuck", - "Aliases": [ - "b", - "bf", - "brainfuck" - ] - }, - { - "Name": "C", - "Aliases": [ - "c", - "h", - "idc" - ] - }, - { - "Name": "C#", - "Aliases": [ - "c#", - "cs", - "csharp" - ] - }, - { - "Name": "C++", - "Aliases": [ - "C", - "CPP", - "H", - "c++", - "cc", - "cp", - "cpp", - "cxx", - "h++", - "hh", - "hpp", - "hxx" - ] - }, - { - "Name": "CFEngine3", - "Aliases": [ - "cf", - "cf3", - "cfengine3" - ] - }, - { - "Name": "CMake", - "Aliases": [ - "cmake", - "txt" - ] - }, - { - "Name": "COBOL", - "Aliases": [ - "COB", - "CPY", - "cob", - "cobol", - "cpy" - ] - }, - { - "Name": "CSS", - "Aliases": [ - "css" - ] - }, - { - "Name": "Cap'n Proto", - "Aliases": [ - "capnp" - ] - }, - { - "Name": "Cassandra CQL", - "Aliases": [ - "cassandra", - "cql" - ] - }, - { - "Name": "Ceylon", - "Aliases": [ - "ceylon" - ] - }, - { - "Name": "ChaiScript", - "Aliases": [ - "chai", - "chaiscript" - ] - }, - { - "Name": "Cheetah", - "Aliases": [ - "cheetah", - "spitfire", - "spt", - "tmpl" - ] - }, - { - "Name": "Clojure", - "Aliases": [ - "clj", - "clojure" - ] - }, - { - "Name": "CoffeeScript", - "Aliases": [ - "coffee", - "coffee-script", - "coffeescript" - ] - }, - { - "Name": "Common Lisp", - "Aliases": [ - "cl", - "common-lisp", - "lisp" - ] - }, - { - "Name": "Coq", - "Aliases": [ - "coq", - "v" - ] - }, - { - "Name": "Crystal", - "Aliases": [ - "cr", - "crystal" - ] - }, - { - "Name": "Cython", - "Aliases": [ - "cython", - "pxd", - "pxi", - "pyrex", - "pyx" - ] - }, - { - "Name": "D", - "Aliases": [ - "d", - "di" - ] - }, - { - "Name": "DTD", - "Aliases": [ - "dtd" - ] - }, - { - "Name": "Dart", - "Aliases": [ - "dart" - ] - }, - { - "Name": "Diff", - "Aliases": [ - "diff", - "patch", - "udiff" - ] - }, - { - "Name": "Django/Jinja", - "Aliases": [ - "django", - "jinja" - ] - }, - { - "Name": "Docker", - "Aliases": [ - "docker", - "dockerfile" - ] - }, - { - "Name": "EBNF", - "Aliases": [ - "ebnf" - ] - }, - { - "Name": "Elixir", - "Aliases": [ - "elixir", - "ex", - "exs" - ] - }, - { - "Name": "Elm", - "Aliases": [ - "elm" - ] - }, - { - "Name": "EmacsLisp", - "Aliases": [ - "el", - "elisp", - "emacs", - "emacs-lisp" - ] - }, - { - "Name": "Erlang", - "Aliases": [ - "erl", - "erlang", - "es", - "escript", - "hrl" - ] - }, - { - "Name": "FSharp", - "Aliases": [ - "fs", - "fsharp", - "fsi" - ] - }, - { - "Name": "Factor", - "Aliases": [ - "factor" - ] - }, - { - "Name": "Fish", - "Aliases": [ - "fish", - "fishshell", - "load" - ] - }, - { - "Name": "Forth", - "Aliases": [ - "forth", - "frt", - "fs", - "fth" - ] - }, - { - "Name": "Fortran", - "Aliases": [ - "F03", - "F90", - "f03", - "f90", - "fortran" - ] - }, - { - "Name": "GAS", - "Aliases": [ - "S", - "asm", - "gas", - "s" - ] - }, - { - "Name": "GDScript", - "Aliases": [ - "gd", - "gdscript" - ] - }, - { - "Name": "GLSL", - "Aliases": [ - "frag", - "geo", - "glsl", - "vert" - ] - }, - { - "Name": "Genshi", - "Aliases": [ - "genshi", - "kid", - "xml+genshi", - "xml+kid" - ] - }, - { - "Name": "Genshi HTML", - "Aliases": [ - "html+genshi", - "html+kid" - ] - }, - { - "Name": "Genshi Text", - "Aliases": [ - "genshitext" - ] - }, - { - "Name": "Gnuplot", - "Aliases": [ - "gnuplot", - "plot", - "plt" - ] - }, - { - "Name": "Go", - "Aliases": [ - "go", - "golang" - ] - }, - { - "Name": "Go HTML Template", - "Aliases": [ - "go-html-template" - ] - }, - { - "Name": "Go Text Template", - "Aliases": [ - "go-text-template" - ] - }, - { - "Name": "GraphQL", - "Aliases": [ - "gql", - "graphql", - "graphqls" - ] - }, - { - "Name": "Groovy", - "Aliases": [ - "gradle", - "groovy" - ] - }, - { - "Name": "HCL", - "Aliases": [ - "hcl" - ] - }, - { - "Name": "HTML", - "Aliases": [ - "htm", - "html", - "xhtml", - "xslt" - ] - }, - { - "Name": "HTTP", - "Aliases": [ - "http" - ] - }, - { - "Name": "Handlebars", - "Aliases": [ - "handlebars" - ] - }, - { - "Name": "Haskell", - "Aliases": [ - "haskell", - "hs" - ] - }, - { - "Name": "Haxe", - "Aliases": [ - "haxe", - "hx", - "hxsl" - ] - }, - { - "Name": "Hexdump", - "Aliases": [ - "hexdump" - ] - }, - { - "Name": "Hy", - "Aliases": [ - "hy", - "hylang" - ] - }, - { - "Name": "INI", - "Aliases": [ - "cfg", - "dosini", - "gitconfig", - "inf", - "ini" - ] - }, - { - "Name": "Idris", - "Aliases": [ - "idr", - "idris" - ] - }, - { - "Name": "Igor", - "Aliases": [ - "igor", - "igorpro", - "ipf" - ] - }, - { - "Name": "Io", - "Aliases": [ - "io" - ] - }, - { - "Name": "J", - "Aliases": [ - "ijs", - "j" - ] - }, - { - "Name": "JSON", - "Aliases": [ - "json" - ] - }, - { - "Name": "Java", - "Aliases": [ - "java" - ] - }, - { - "Name": "JavaScript", - "Aliases": [ - "javascript", - "js", - "jsm" - ] - }, - { - "Name": "Julia", - "Aliases": [ - "jl", - "julia" - ] - }, - { - "Name": "Jungle", - "Aliases": [ - "jungle" - ] - }, - { - "Name": "Kotlin", - "Aliases": [ - "kotlin", - "kt" - ] - }, - { - "Name": "LLVM", - "Aliases": [ - "ll", - "llvm" - ] - }, - { - "Name": "Lighttpd configuration file", - "Aliases": [ - "lighttpd", - "lighty" - ] - }, - { - "Name": "Lua", - "Aliases": [ - "lua", - "wlua" - ] - }, - { - "Name": "MLIR", - "Aliases": [ - "mlir" - ] - }, - { - "Name": "Mako", - "Aliases": [ - "mako", - "mao" - ] - }, - { - "Name": "Mason", - "Aliases": [ - "m", - "mason", - "mc", - "mhtml", - "mi" - ] - }, - { - "Name": "Mathematica", - "Aliases": [ - "cdf", - "ma", - "mathematica", - "mma", - "nb", - "nbp" - ] - }, - { - "Name": "Matlab", - "Aliases": [ - "m", - "matlab" - ] - }, - { - "Name": "MiniZinc", - "Aliases": [ - "MZN", - "dzn", - "fzn", - "minizinc", - "mzn" - ] - }, - { - "Name": "Modula-2", - "Aliases": [ - "def", - "m2", - "mod", - "modula2" - ] - }, - { - "Name": "MonkeyC", - "Aliases": [ - "mc", - "monkeyc" - ] - }, - { - "Name": "MorrowindScript", - "Aliases": [ - "morrowind", - "mwscript" - ] - }, - { - "Name": "MySQL", - "Aliases": [ - "mysql", - "sql" - ] - }, - { - "Name": "Myghty", - "Aliases": [ - "myghty", - "myt" - ] - }, - { - "Name": "NASM", - "Aliases": [ - "ASM", - "asm", - "nasm" - ] - }, - { - "Name": "Newspeak", - "Aliases": [ - "newspeak", - "ns2" - ] - }, - { - "Name": "Nginx configuration file", - "Aliases": [ - "conf", - "nginx" - ] - }, - { - "Name": "Nim", - "Aliases": [ - "nim", - "nimrod" - ] - }, - { - "Name": "Nix", - "Aliases": [ - "nix", - "nixos" - ] - }, - { - "Name": "OCaml", - "Aliases": [ - "ml", - "mli", - "mll", - "mly", - "ocaml" - ] - }, - { - "Name": "Objective-C", - "Aliases": [ - "h", - "m", - "obj-c", - "objc", - "objective-c", - "objectivec" - ] - }, - { - "Name": "Octave", - "Aliases": [ - "m", - "octave" - ] - }, - { - "Name": "OpenSCAD", - "Aliases": [ - "openscad", - "scad" - ] - }, - { - "Name": "Org Mode", - "Aliases": [ - "org", - "orgmode" - ] - }, - { - "Name": "PHP", - "Aliases": [ - "inc", - "php", - "php3", - "php4", - "php5", - "php[345]" - ] - }, - { - "Name": "PL/pgSQL", - "Aliases": [ - "plpgsql" - ] - }, - { - "Name": "POVRay", - "Aliases": [ - "inc", - "pov" - ] - }, - { - "Name": "PacmanConf", - "Aliases": [ - "conf", - "pacmanconf" - ] - }, - { - "Name": "Perl", - "Aliases": [ - "perl", - "pl", - "pm", - "t" - ] - }, - { - "Name": "Pig", - "Aliases": [ - "pig" - ] - }, - { - "Name": "PkgConfig", - "Aliases": [ - "pc", - "pkgconfig" - ] - }, - { - "Name": "PostScript", - "Aliases": [ - "eps", - "postscr", - "postscript", - "ps" - ] - }, - { - "Name": "PostgreSQL SQL dialect", - "Aliases": [ - "postgres", - "postgresql" - ] - }, - { - "Name": "PowerShell", - "Aliases": [ - "posh", - "powershell", - "ps1", - "psm1" - ] - }, - { - "Name": "Prolog", - "Aliases": [ - "ecl", - "pl", - "pro", - "prolog" - ] - }, - { - "Name": "Protocol Buffer", - "Aliases": [ - "proto", - "protobuf" - ] - }, - { - "Name": "Puppet", - "Aliases": [ - "pp", - "puppet" - ] - }, - { - "Name": "Python", - "Aliases": [ - "py", - "python", - "pyw", - "sage", - "sc", - "tac" - ] - }, - { - "Name": "Python 3", - "Aliases": [ - "py3", - "python3" - ] - }, - { - "Name": "QBasic", - "Aliases": [ - "BAS", - "bas", - "basic", - "qbasic" - ] - }, - { - "Name": "R", - "Aliases": [ - "R", - "Renviron", - "Rhistory", - "Rprofile", - "S", - "r", - "s", - "splus" - ] - }, - { - "Name": "Racket", - "Aliases": [ - "racket", - "rkt", - "rktd", - "rktl" - ] - }, - { - "Name": "Ragel", - "Aliases": [ - "ragel" - ] - }, - { - "Name": "Rexx", - "Aliases": [ - "arexx", - "rex", - "rexx", - "rx" - ] - }, - { - "Name": "Ruby", - "Aliases": [ - "duby", - "gemspec", - "rake", - "rb", - "rbw", - "rbx", - "ruby" - ] - }, - { - "Name": "Rust", - "Aliases": [ - "in", - "rs", - "rust" - ] - }, - { - "Name": "SCSS", - "Aliases": [ - "scss" - ] - }, - { - "Name": "SPARQL", - "Aliases": [ - "rq", - "sparql" - ] - }, - { - "Name": "SQL", - "Aliases": [ - "sql" - ] - }, - { - "Name": "SYSTEMD", - "Aliases": [ - "service", - "systemd" - ] - }, - { - "Name": "Sass", - "Aliases": [ - "sass" - ] - }, - { - "Name": "Scala", - "Aliases": [ - "scala" - ] - }, - { - "Name": "Scheme", - "Aliases": [ - "scheme", - "scm", - "ss" - ] - }, - { - "Name": "Scilab", - "Aliases": [ - "sce", - "sci", - "scilab", - "tst" - ] - }, - { - "Name": "Smalltalk", - "Aliases": [ - "smalltalk", - "squeak", - "st" - ] - }, - { - "Name": "Smarty", - "Aliases": [ - "smarty", - "tpl" - ] - }, - { - "Name": "Snobol", - "Aliases": [ - "snobol" - ] - }, - { - "Name": "Solidity", - "Aliases": [ - "sol", - "solidity" - ] - }, - { - "Name": "SquidConf", - "Aliases": [ - "conf", - "squid", - "squid.conf", - "squidconf" - ] - }, - { - "Name": "Standard ML", - "Aliases": [ - "fun", - "sig", - "sml" - ] - }, - { - "Name": "Swift", - "Aliases": [ - "swift" - ] - }, - { - "Name": "TASM", - "Aliases": [ - "ASM", - "asm", - "tasm" - ] - }, - { - "Name": "TOML", - "Aliases": [ - "toml" - ] - }, - { - "Name": "TableGen", - "Aliases": [ - "tablegen", - "td" - ] - }, - { - "Name": "Tcl", - "Aliases": [ - "rvt", - "tcl" - ] - }, - { - "Name": "Tcsh", - "Aliases": [ - "csh", - "tcsh" - ] - }, - { - "Name": "TeX", - "Aliases": [ - "aux", - "latex", - "tex", - "toc" - ] - }, - { - "Name": "Termcap", - "Aliases": [ - "src", - "termcap" - ] - }, - { - "Name": "Terminfo", - "Aliases": [ - "src", - "terminfo" - ] - }, - { - "Name": "Terraform", - "Aliases": [ - "terraform", - "tf" - ] - }, - { - "Name": "Thrift", - "Aliases": [ - "thrift" - ] - }, - { - "Name": "TradingView", - "Aliases": [ - "tradingview", - "tv" - ] - }, - { - "Name": "Transact-SQL", - "Aliases": [ - "t-sql", - "tsql" - ] - }, - { - "Name": "Turing", - "Aliases": [ - "tu", - "turing" - ] - }, - { - "Name": "Turtle", - "Aliases": [ - "ttl", - "turtle" - ] - }, - { - "Name": "Twig", - "Aliases": [ - "twig" - ] - }, - { - "Name": "TypeScript", - "Aliases": [ - "ts", - "tsx", - "typescript" - ] - }, - { - "Name": "TypoScript", - "Aliases": [ - "ts", - "txt", - "typoscript" - ] - }, - { - "Name": "TypoScriptCssData", - "Aliases": [ - "typoscriptcssdata" - ] - }, - { - "Name": "TypoScriptHtmlData", - "Aliases": [ - "typoscripthtmldata" - ] - }, - { - "Name": "VB.net", - "Aliases": [ - "bas", - "vb", - "vb.net", - "vbnet" - ] - }, - { - "Name": "VHDL", - "Aliases": [ - "vhd", - "vhdl" - ] - }, - { - "Name": "VimL", - "Aliases": [ - "exrc", - "gvimrc", - "vim", - "vimrc" - ] - }, - { - "Name": "WDTE", - "Aliases": [ - "wdte" - ] - }, - { - "Name": "XML", - "Aliases": [ - "rss", - "svg", - "wsdl", - "wsf", - "xml", - "xsd", - "xsl", - "xslt" - ] - }, - { - "Name": "Xorg", - "Aliases": [ - "conf", - "xorg.conf" - ] - }, - { - "Name": "YAML", - "Aliases": [ - "yaml", - "yml" - ] - }, - { - "Name": "cfstatement", - "Aliases": [ - "cfs" - ] - }, - { - "Name": "markdown", - "Aliases": [ - "markdown", - "md", - "mkd" - ] - }, - { - "Name": "plaintext", - "Aliases": [ - "no-highlight", - "plain", - "text", - "txt" - ] - }, - { - "Name": "reStructuredText", - "Aliases": [ - "rest", - "restructuredtext", - "rst" - ] - }, - { - "Name": "react", - "Aliases": [ - "jsx", - "react" - ] - }, - { - "Name": "reg", - "Aliases": [ - "reg", - "registry" - ] - }, - { - "Name": "systemverilog", - "Aliases": [ - "sv", - "svh", - "systemverilog" - ] - }, - { - "Name": "verilog", - "Aliases": [ - "v", - "verilog" - ] - }, - { - "Name": "vue", - "Aliases": [ - "vue", - "vuejs" - ] - } - ] - }, - "config": { - "markup": { - "defaultMarkdownHandler": "goldmark", - "highlight": { - "style": "monokai", - "codeFences": true, - "noClasses": true, - "lineNos": false, - "lineNumbersInTable": true, - "lineNoStart": 1, - "hl_Lines": "", - "tabWidth": 4, - "guessSyntax": false - }, - "tableOfContents": { - "startLevel": 2, - "endLevel": 3, - "ordered": false - }, - "goldmark": { - "renderer": { - "hardWraps": false, - "xHTML": false, - "unsafe": false - }, - "parser": { - "autoHeadingID": true, - "autoHeadingIDType": "github", - "attribute": true - }, - "extensions": { - "typographer": true, - "footnote": true, - "definitionList": true, - "table": true, - "strikethrough": true, - "linkify": true, - "taskList": true - } - }, - "blackFriday": { - "smartypants": true, - "smartypantsQuotesNBSP": false, - "angledQuotes": false, - "fractions": true, - "hrefTargetBlank": false, - "nofollowLinks": false, - "noreferrerLinks": false, - "smartDashes": true, - "latexDashes": true, - "taskLists": true, - "plainIDAnchors": true, - "extensions": null, - "extensionsMask": null, - "skipHTML": false, - "footnoteAnchorPrefix": "", - "footnoteReturnLinkContents": "" - } - } - }, - "media": { - "types": [ - { - "type": "application/javascript", - "string": "application/javascript", - "mainType": "application", - "subType": "javascript", - "delimiter": ".", - "suffixes": [ - "js" - ] - }, - { - "type": "application/json", - "string": "application/json", - "mainType": "application", - "subType": "json", - "delimiter": ".", - "suffixes": [ - "json" - ] - }, - { - "type": "application/octet-stream", - "string": "application/octet-stream", - "mainType": "application", - "subType": "octet-stream", - "delimiter": "", - "suffixes": null - }, - { - "type": "application/rss+xml", - "string": "application/rss+xml", - "mainType": "application", - "subType": "rss", - "delimiter": ".", - "suffixes": [ - "xml" - ] - }, - { - "type": "application/toml", - "string": "application/toml", - "mainType": "application", - "subType": "toml", - "delimiter": ".", - "suffixes": [ - "toml" - ] - }, - { - "type": "application/xml", - "string": "application/xml", - "mainType": "application", - "subType": "xml", - "delimiter": ".", - "suffixes": [ - "xml" - ] - }, - { - "type": "application/yaml", - "string": "application/yaml", - "mainType": "application", - "subType": "yaml", - "delimiter": ".", - "suffixes": [ - "yaml", - "yml" - ] - }, - { - "type": "image/jpeg", - "string": "image/jpeg", - "mainType": "image", - "subType": "jpeg", - "delimiter": ".", - "suffixes": [ - "jpg", - "jpeg" - ] - }, - { - "type": "image/png", - "string": "image/png", - "mainType": "image", - "subType": "png", - "delimiter": ".", - "suffixes": [ - "png" - ] - }, - { - "type": "image/svg+xml", - "string": "image/svg+xml", - "mainType": "image", - "subType": "svg", - "delimiter": ".", - "suffixes": [ - "svg" - ] - }, - { - "type": "text/calendar", - "string": "text/calendar", - "mainType": "text", - "subType": "calendar", - "delimiter": ".", - "suffixes": [ - "ics" - ] - }, - { - "type": "text/css", - "string": "text/css", - "mainType": "text", - "subType": "css", - "delimiter": ".", - "suffixes": [ - "css" - ] - }, - { - "type": "text/csv", - "string": "text/csv", - "mainType": "text", - "subType": "csv", - "delimiter": ".", - "suffixes": [ - "csv" - ] - }, - { - "type": "text/html", - "string": "text/html", - "mainType": "text", - "subType": "html", - "delimiter": ".", - "suffixes": [ - "html" - ] - }, - { - "type": "text/plain", - "string": "text/plain", - "mainType": "text", - "subType": "plain", - "delimiter": ".", - "suffixes": [ - "txt" - ] - }, - { - "type": "text/x-sass", - "string": "text/x-sass", - "mainType": "text", - "subType": "x-sass", - "delimiter": ".", - "suffixes": [ - "sass" - ] - }, - { - "type": "text/x-scss", - "string": "text/x-scss", - "mainType": "text", - "subType": "x-scss", - "delimiter": ".", - "suffixes": [ - "scss" - ] - }, - { - "type": "video/3gpp", - "string": "video/3gpp", - "mainType": "video", - "subType": "3gpp", - "delimiter": ".", - "suffixes": [ - "3gpp", - "3gp" - ] - }, - { - "type": "video/mp4", - "string": "video/mp4", - "mainType": "video", - "subType": "mp4", - "delimiter": ".", - "suffixes": [ - "mp4" - ] - }, - { - "type": "video/mpeg", - "string": "video/mpeg", - "mainType": "video", - "subType": "mpeg", - "delimiter": ".", - "suffixes": [ - "mpg", - "mpeg" - ] - }, - { - "type": "video/ogg", - "string": "video/ogg", - "mainType": "video", - "subType": "ogg", - "delimiter": ".", - "suffixes": [ - "ogv" - ] - }, - { - "type": "video/webm", - "string": "video/webm", - "mainType": "video", - "subType": "webm", - "delimiter": ".", - "suffixes": [ - "webm" - ] - }, - { - "type": "video/x-msvideo", - "string": "video/x-msvideo", - "mainType": "video", - "subType": "x-msvideo", - "delimiter": ".", - "suffixes": [ - "avi" - ] - } - ] - }, - "output": { - "formats": [ - { - "MediaType": "text/html", - "name": "HTML", - "mediaType": { - "type": "text/html", - "string": "text/html", - "mainType": "text", - "subType": "html", - "delimiter": ".", - "suffixes": [ - "html" - ] - }, - "path": "", - "baseName": "index", - "rel": "canonical", - "protocol": "", - "isPlainText": false, - "isHTML": true, - "noUgly": false, - "notAlternative": false, - "permalinkable": true, - "weight": 10 - }, - { - "MediaType": "text/html", - "name": "AMP", - "mediaType": { - "type": "text/html", - "string": "text/html", - "mainType": "text", - "subType": "html", - "delimiter": ".", - "suffixes": [ - "html" - ] - }, - "path": "amp", - "baseName": "index", - "rel": "amphtml", - "protocol": "", - "isPlainText": false, - "isHTML": true, - "noUgly": false, - "notAlternative": false, - "permalinkable": true, - "weight": 0 - }, - { - "MediaType": "text/css", - "name": "CSS", - "mediaType": { - "type": "text/css", - "string": "text/css", - "mainType": "text", - "subType": "css", - "delimiter": ".", - "suffixes": [ - "css" - ] - }, - "path": "", - "baseName": "styles", - "rel": "stylesheet", - "protocol": "", - "isPlainText": true, - "isHTML": false, - "noUgly": false, - "notAlternative": true, - "permalinkable": false, - "weight": 0 - }, - { - "MediaType": "text/csv", - "name": "CSV", - "mediaType": { - "type": "text/csv", - "string": "text/csv", - "mainType": "text", - "subType": "csv", - "delimiter": ".", - "suffixes": [ - "csv" - ] - }, - "path": "", - "baseName": "index", - "rel": "alternate", - "protocol": "", - "isPlainText": true, - "isHTML": false, - "noUgly": false, - "notAlternative": false, - "permalinkable": false, - "weight": 0 - }, - { - "MediaType": "text/calendar", - "name": "Calendar", - "mediaType": { - "type": "text/calendar", - "string": "text/calendar", - "mainType": "text", - "subType": "calendar", - "delimiter": ".", - "suffixes": [ - "ics" - ] - }, - "path": "", - "baseName": "index", - "rel": "alternate", - "protocol": "webcal://", - "isPlainText": true, - "isHTML": false, - "noUgly": false, - "notAlternative": false, - "permalinkable": false, - "weight": 0 - }, - { - "MediaType": "application/json", - "name": "JSON", - "mediaType": { - "type": "application/json", - "string": "application/json", - "mainType": "application", - "subType": "json", - "delimiter": ".", - "suffixes": [ - "json" - ] - }, - "path": "", - "baseName": "index", - "rel": "alternate", - "protocol": "", - "isPlainText": true, - "isHTML": false, - "noUgly": false, - "notAlternative": false, - "permalinkable": false, - "weight": 0 - }, - { - "MediaType": "text/plain", - "name": "ROBOTS", - "mediaType": { - "type": "text/plain", - "string": "text/plain", - "mainType": "text", - "subType": "plain", - "delimiter": ".", - "suffixes": [ - "txt" - ] - }, - "path": "", - "baseName": "robots", - "rel": "alternate", - "protocol": "", - "isPlainText": true, - "isHTML": false, - "noUgly": false, - "notAlternative": false, - "permalinkable": false, - "weight": 0 - }, - { - "MediaType": "application/rss+xml", - "name": "RSS", - "mediaType": { - "type": "application/rss+xml", - "string": "application/rss+xml", - "mainType": "application", - "subType": "rss", - "delimiter": ".", - "suffixes": [ - "xml" - ] - }, - "path": "", - "baseName": "index", - "rel": "alternate", - "protocol": "", - "isPlainText": false, - "isHTML": false, - "noUgly": true, - "notAlternative": false, - "permalinkable": false, - "weight": 0 - }, - { - "MediaType": "application/xml", - "name": "Sitemap", - "mediaType": { - "type": "application/xml", - "string": "application/xml", - "mainType": "application", - "subType": "xml", - "delimiter": ".", - "suffixes": [ - "xml" - ] - }, - "path": "", - "baseName": "sitemap", - "rel": "sitemap", - "protocol": "", - "isPlainText": false, - "isHTML": false, - "noUgly": true, - "notAlternative": false, - "permalinkable": false, - "weight": 0 - } - ], - "layouts": [ - { - "Example": "Single page in \"posts\" section", - "Kind": "page", - "OutputFormat": "HTML", - "Suffix": "html", - "Template Lookup Order": [ - "layouts/posts/single.html.html", - "layouts/posts/single.html", - "layouts/_default/single.html.html", - "layouts/_default/single.html" - ] - }, - { - "Example": "Base template for single page in \"posts\" section", - "Kind": "page", - "OutputFormat": "HTML", - "Suffix": "html", - "Template Lookup Order": [ - "layouts/posts/single-baseof.html.html", - "layouts/posts/baseof.html.html", - "layouts/posts/single-baseof.html", - "layouts/posts/baseof.html", - "layouts/_default/single-baseof.html.html", - "layouts/_default/baseof.html.html", - "layouts/_default/single-baseof.html", - "layouts/_default/baseof.html" - ] - }, - { - "Example": "Single page in \"posts\" section with layout set", - "Kind": "page", - "OutputFormat": "HTML", - "Suffix": "html", - "Template Lookup Order": [ - "layouts/posts/demolayout.html.html", - "layouts/posts/single.html.html", - "layouts/posts/demolayout.html", - "layouts/posts/single.html", - "layouts/_default/demolayout.html.html", - "layouts/_default/single.html.html", - "layouts/_default/demolayout.html", - "layouts/_default/single.html" - ] - }, - { - "Example": "Base template for single page in \"posts\" section with layout set", - "Kind": "page", - "OutputFormat": "HTML", - "Suffix": "html", - "Template Lookup Order": [ - "layouts/posts/demolayout-baseof.html.html", - "layouts/posts/single-baseof.html.html", - "layouts/posts/baseof.html.html", - "layouts/posts/demolayout-baseof.html", - "layouts/posts/single-baseof.html", - "layouts/posts/baseof.html", - "layouts/_default/demolayout-baseof.html.html", - "layouts/_default/single-baseof.html.html", - "layouts/_default/baseof.html.html", - "layouts/_default/demolayout-baseof.html", - "layouts/_default/single-baseof.html", - "layouts/_default/baseof.html" - ] - }, - { - "Example": "AMP single page", - "Kind": "page", - "OutputFormat": "AMP", - "Suffix": "html", - "Template Lookup Order": [ - "layouts/posts/single.amp.html", - "layouts/posts/single.html", - "layouts/_default/single.amp.html", - "layouts/_default/single.html" - ] - }, - { - "Example": "AMP single page, French language", - "Kind": "page", - "OutputFormat": "AMP", - "Suffix": "html", - "Template Lookup Order": [ - "layouts/posts/single.fr.amp.html", - "layouts/posts/single.amp.html", - "layouts/posts/single.fr.html", - "layouts/posts/single.html", - "layouts/_default/single.fr.amp.html", - "layouts/_default/single.amp.html", - "layouts/_default/single.fr.html", - "layouts/_default/single.html" - ] - }, - { - "Example": "Home page", - "Kind": "home", - "OutputFormat": "HTML", - "Suffix": "html", - "Template Lookup Order": [ - "layouts/index.html.html", - "layouts/home.html.html", - "layouts/list.html.html", - "layouts/index.html", - "layouts/home.html", - "layouts/list.html", - "layouts/_default/index.html.html", - "layouts/_default/home.html.html", - "layouts/_default/list.html.html", - "layouts/_default/index.html", - "layouts/_default/home.html", - "layouts/_default/list.html" - ] - }, - { - "Example": "Base template for home page", - "Kind": "home", - "OutputFormat": "HTML", - "Suffix": "html", - "Template Lookup Order": [ - "layouts/index-baseof.html.html", - "layouts/home-baseof.html.html", - "layouts/list-baseof.html.html", - "layouts/baseof.html.html", - "layouts/index-baseof.html", - "layouts/home-baseof.html", - "layouts/list-baseof.html", - "layouts/baseof.html", - "layouts/_default/index-baseof.html.html", - "layouts/_default/home-baseof.html.html", - "layouts/_default/list-baseof.html.html", - "layouts/_default/baseof.html.html", - "layouts/_default/index-baseof.html", - "layouts/_default/home-baseof.html", - "layouts/_default/list-baseof.html", - "layouts/_default/baseof.html" - ] - }, - { - "Example": "Home page with type set", - "Kind": "home", - "OutputFormat": "HTML", - "Suffix": "html", - "Template Lookup Order": [ - "layouts/demotype/index.html.html", - "layouts/demotype/home.html.html", - "layouts/demotype/list.html.html", - "layouts/demotype/index.html", - "layouts/demotype/home.html", - "layouts/demotype/list.html", - "layouts/index.html.html", - "layouts/home.html.html", - "layouts/list.html.html", - "layouts/index.html", - "layouts/home.html", - "layouts/list.html", - "layouts/_default/index.html.html", - "layouts/_default/home.html.html", - "layouts/_default/list.html.html", - "layouts/_default/index.html", - "layouts/_default/home.html", - "layouts/_default/list.html" - ] - }, - { - "Example": "Base template for home page with type set", - "Kind": "home", - "OutputFormat": "HTML", - "Suffix": "html", - "Template Lookup Order": [ - "layouts/demotype/index-baseof.html.html", - "layouts/demotype/home-baseof.html.html", - "layouts/demotype/list-baseof.html.html", - "layouts/demotype/baseof.html.html", - "layouts/demotype/index-baseof.html", - "layouts/demotype/home-baseof.html", - "layouts/demotype/list-baseof.html", - "layouts/demotype/baseof.html", - "layouts/index-baseof.html.html", - "layouts/home-baseof.html.html", - "layouts/list-baseof.html.html", - "layouts/baseof.html.html", - "layouts/index-baseof.html", - "layouts/home-baseof.html", - "layouts/list-baseof.html", - "layouts/baseof.html", - "layouts/_default/index-baseof.html.html", - "layouts/_default/home-baseof.html.html", - "layouts/_default/list-baseof.html.html", - "layouts/_default/baseof.html.html", - "layouts/_default/index-baseof.html", - "layouts/_default/home-baseof.html", - "layouts/_default/list-baseof.html", - "layouts/_default/baseof.html" - ] - }, - { - "Example": "Home page with layout set", - "Kind": "home", - "OutputFormat": "HTML", - "Suffix": "html", - "Template Lookup Order": [ - "layouts/demolayout.html.html", - "layouts/index.html.html", - "layouts/home.html.html", - "layouts/list.html.html", - "layouts/demolayout.html", - "layouts/index.html", - "layouts/home.html", - "layouts/list.html", - "layouts/_default/demolayout.html.html", - "layouts/_default/index.html.html", - "layouts/_default/home.html.html", - "layouts/_default/list.html.html", - "layouts/_default/demolayout.html", - "layouts/_default/index.html", - "layouts/_default/home.html", - "layouts/_default/list.html" - ] - }, - { - "Example": "AMP home, French language\"", - "Kind": "home", - "OutputFormat": "AMP", - "Suffix": "html", - "Template Lookup Order": [ - "layouts/index.fr.amp.html", - "layouts/home.fr.amp.html", - "layouts/list.fr.amp.html", - "layouts/index.amp.html", - "layouts/home.amp.html", - "layouts/list.amp.html", - "layouts/index.fr.html", - "layouts/home.fr.html", - "layouts/list.fr.html", - "layouts/index.html", - "layouts/home.html", - "layouts/list.html", - "layouts/_default/index.fr.amp.html", - "layouts/_default/home.fr.amp.html", - "layouts/_default/list.fr.amp.html", - "layouts/_default/index.amp.html", - "layouts/_default/home.amp.html", - "layouts/_default/list.amp.html", - "layouts/_default/index.fr.html", - "layouts/_default/home.fr.html", - "layouts/_default/list.fr.html", - "layouts/_default/index.html", - "layouts/_default/home.html", - "layouts/_default/list.html" - ] - }, - { - "Example": "JSON home", - "Kind": "home", - "OutputFormat": "JSON", - "Suffix": "json", - "Template Lookup Order": [ - "layouts/index.json.json", - "layouts/home.json.json", - "layouts/list.json.json", - "layouts/index.json", - "layouts/home.json", - "layouts/list.json", - "layouts/_default/index.json.json", - "layouts/_default/home.json.json", - "layouts/_default/list.json.json", - "layouts/_default/index.json", - "layouts/_default/home.json", - "layouts/_default/list.json" - ] - }, - { - "Example": "RSS home", - "Kind": "home", - "OutputFormat": "RSS", - "Suffix": "xml", - "Template Lookup Order": [ - "layouts/index.rss.xml", - "layouts/home.rss.xml", - "layouts/rss.xml", - "layouts/list.rss.xml", - "layouts/index.xml", - "layouts/home.xml", - "layouts/list.xml", - "layouts/_default/index.rss.xml", - "layouts/_default/home.rss.xml", - "layouts/_default/rss.xml", - "layouts/_default/list.rss.xml", - "layouts/_default/index.xml", - "layouts/_default/home.xml", - "layouts/_default/list.xml", - "layouts/_internal/_default/rss.xml" - ] - }, - { - "Example": "RSS section posts", - "Kind": "section", - "OutputFormat": "RSS", - "Suffix": "xml", - "Template Lookup Order": [ - "layouts/posts/section.rss.xml", - "layouts/posts/rss.xml", - "layouts/posts/list.rss.xml", - "layouts/posts/section.xml", - "layouts/posts/list.xml", - "layouts/section/section.rss.xml", - "layouts/section/rss.xml", - "layouts/section/list.rss.xml", - "layouts/section/section.xml", - "layouts/section/list.xml", - "layouts/_default/section.rss.xml", - "layouts/_default/rss.xml", - "layouts/_default/list.rss.xml", - "layouts/_default/section.xml", - "layouts/_default/list.xml", - "layouts/_internal/_default/rss.xml" - ] - }, - { - "Example": "Taxonomy list in categories", - "Kind": "taxonomy", - "OutputFormat": "RSS", - "Suffix": "xml", - "Template Lookup Order": [ - "layouts/categories/category.rss.xml", - "layouts/categories/taxonomy.rss.xml", - "layouts/categories/rss.xml", - "layouts/categories/list.rss.xml", - "layouts/categories/category.xml", - "layouts/categories/taxonomy.xml", - "layouts/categories/list.xml", - "layouts/taxonomy/category.rss.xml", - "layouts/taxonomy/taxonomy.rss.xml", - "layouts/taxonomy/rss.xml", - "layouts/taxonomy/list.rss.xml", - "layouts/taxonomy/category.xml", - "layouts/taxonomy/taxonomy.xml", - "layouts/taxonomy/list.xml", - "layouts/category/category.rss.xml", - "layouts/category/taxonomy.rss.xml", - "layouts/category/rss.xml", - "layouts/category/list.rss.xml", - "layouts/category/category.xml", - "layouts/category/taxonomy.xml", - "layouts/category/list.xml", - "layouts/_default/category.rss.xml", - "layouts/_default/taxonomy.rss.xml", - "layouts/_default/rss.xml", - "layouts/_default/list.rss.xml", - "layouts/_default/category.xml", - "layouts/_default/taxonomy.xml", - "layouts/_default/list.xml", - "layouts/_internal/_default/rss.xml" - ] - }, - { - "Example": "Taxonomy terms in categories", - "Kind": "taxonomyTerm", - "OutputFormat": "RSS", - "Suffix": "xml", - "Template Lookup Order": [ - "layouts/categories/category.terms.rss.xml", - "layouts/categories/terms.rss.xml", - "layouts/categories/rss.xml", - "layouts/categories/list.rss.xml", - "layouts/categories/category.terms.xml", - "layouts/categories/terms.xml", - "layouts/categories/list.xml", - "layouts/taxonomy/category.terms.rss.xml", - "layouts/taxonomy/terms.rss.xml", - "layouts/taxonomy/rss.xml", - "layouts/taxonomy/list.rss.xml", - "layouts/taxonomy/category.terms.xml", - "layouts/taxonomy/terms.xml", - "layouts/taxonomy/list.xml", - "layouts/category/category.terms.rss.xml", - "layouts/category/terms.rss.xml", - "layouts/category/rss.xml", - "layouts/category/list.rss.xml", - "layouts/category/category.terms.xml", - "layouts/category/terms.xml", - "layouts/category/list.xml", - "layouts/_default/category.terms.rss.xml", - "layouts/_default/terms.rss.xml", - "layouts/_default/rss.xml", - "layouts/_default/list.rss.xml", - "layouts/_default/category.terms.xml", - "layouts/_default/terms.xml", - "layouts/_default/list.xml", - "layouts/_internal/_default/rss.xml" - ] - }, - { - "Example": "Section list for \"posts\" section", - "Kind": "section", - "OutputFormat": "HTML", - "Suffix": "html", - "Template Lookup Order": [ - "layouts/posts/posts.html.html", - "layouts/posts/section.html.html", - "layouts/posts/list.html.html", - "layouts/posts/posts.html", - "layouts/posts/section.html", - "layouts/posts/list.html", - "layouts/section/posts.html.html", - "layouts/section/section.html.html", - "layouts/section/list.html.html", - "layouts/section/posts.html", - "layouts/section/section.html", - "layouts/section/list.html", - "layouts/_default/posts.html.html", - "layouts/_default/section.html.html", - "layouts/_default/list.html.html", - "layouts/_default/posts.html", - "layouts/_default/section.html", - "layouts/_default/list.html" - ] - }, - { - "Example": "Section list for \"posts\" section with type set to \"blog\"", - "Kind": "section", - "OutputFormat": "HTML", - "Suffix": "html", - "Template Lookup Order": [ - "layouts/blog/posts.html.html", - "layouts/blog/section.html.html", - "layouts/blog/list.html.html", - "layouts/blog/posts.html", - "layouts/blog/section.html", - "layouts/blog/list.html", - "layouts/posts/posts.html.html", - "layouts/posts/section.html.html", - "layouts/posts/list.html.html", - "layouts/posts/posts.html", - "layouts/posts/section.html", - "layouts/posts/list.html", - "layouts/section/posts.html.html", - "layouts/section/section.html.html", - "layouts/section/list.html.html", - "layouts/section/posts.html", - "layouts/section/section.html", - "layouts/section/list.html", - "layouts/_default/posts.html.html", - "layouts/_default/section.html.html", - "layouts/_default/list.html.html", - "layouts/_default/posts.html", - "layouts/_default/section.html", - "layouts/_default/list.html" - ] - }, - { - "Example": "Section list for \"posts\" section with layout set to \"demoLayout\"", - "Kind": "section", - "OutputFormat": "HTML", - "Suffix": "html", - "Template Lookup Order": [ - "layouts/posts/demolayout.html.html", - "layouts/posts/posts.html.html", - "layouts/posts/section.html.html", - "layouts/posts/list.html.html", - "layouts/posts/demolayout.html", - "layouts/posts/posts.html", - "layouts/posts/section.html", - "layouts/posts/list.html", - "layouts/section/demolayout.html.html", - "layouts/section/posts.html.html", - "layouts/section/section.html.html", - "layouts/section/list.html.html", - "layouts/section/demolayout.html", - "layouts/section/posts.html", - "layouts/section/section.html", - "layouts/section/list.html", - "layouts/_default/demolayout.html.html", - "layouts/_default/posts.html.html", - "layouts/_default/section.html.html", - "layouts/_default/list.html.html", - "layouts/_default/demolayout.html", - "layouts/_default/posts.html", - "layouts/_default/section.html", - "layouts/_default/list.html" - ] - }, - { - "Example": "Taxonomy list in categories", - "Kind": "taxonomy", - "OutputFormat": "HTML", - "Suffix": "html", - "Template Lookup Order": [ - "layouts/categories/category.html.html", - "layouts/categories/taxonomy.html.html", - "layouts/categories/list.html.html", - "layouts/categories/category.html", - "layouts/categories/taxonomy.html", - "layouts/categories/list.html", - "layouts/taxonomy/category.html.html", - "layouts/taxonomy/taxonomy.html.html", - "layouts/taxonomy/list.html.html", - "layouts/taxonomy/category.html", - "layouts/taxonomy/taxonomy.html", - "layouts/taxonomy/list.html", - "layouts/category/category.html.html", - "layouts/category/taxonomy.html.html", - "layouts/category/list.html.html", - "layouts/category/category.html", - "layouts/category/taxonomy.html", - "layouts/category/list.html", - "layouts/_default/category.html.html", - "layouts/_default/taxonomy.html.html", - "layouts/_default/list.html.html", - "layouts/_default/category.html", - "layouts/_default/taxonomy.html", - "layouts/_default/list.html" - ] - }, - { - "Example": "Taxonomy term in categories", - "Kind": "taxonomyTerm", - "OutputFormat": "HTML", - "Suffix": "html", - "Template Lookup Order": [ - "layouts/categories/category.terms.html.html", - "layouts/categories/terms.html.html", - "layouts/categories/list.html.html", - "layouts/categories/category.terms.html", - "layouts/categories/terms.html", - "layouts/categories/list.html", - "layouts/taxonomy/category.terms.html.html", - "layouts/taxonomy/terms.html.html", - "layouts/taxonomy/list.html.html", - "layouts/taxonomy/category.terms.html", - "layouts/taxonomy/terms.html", - "layouts/taxonomy/list.html", - "layouts/category/category.terms.html.html", - "layouts/category/terms.html.html", - "layouts/category/list.html.html", - "layouts/category/category.terms.html", - "layouts/category/terms.html", - "layouts/category/list.html", - "layouts/_default/category.terms.html.html", - "layouts/_default/terms.html.html", - "layouts/_default/list.html.html", - "layouts/_default/category.terms.html", - "layouts/_default/terms.html", - "layouts/_default/list.html" - ] - } - ] - }, - "tpl": { - "funcs": { - "cast": { - "ToFloat": { - "Description": "ToFloat converts the given value to a float.", - "Args": [ - "v" - ], - "Aliases": [ - "float" - ], - "Examples": [ - [ - "{{ \"1234\" | float | printf \"%T\" }}", - "float64" - ] - ] - }, - "ToInt": { - "Description": "ToInt converts the given value to an int.", - "Args": [ - "v" - ], - "Aliases": [ - "int" - ], - "Examples": [ - [ - "{{ \"1234\" | int | printf \"%T\" }}", - "int" - ] - ] - }, - "ToString": { - "Description": "ToString converts the given value to a string.", - "Args": [ - "v" - ], - "Aliases": [ - "string" - ], - "Examples": [ - [ - "{{ 1234 | string | printf \"%T\" }}", - "string" - ] - ] - } - }, - "compare": { - "Conditional": { - "Description": "Conditional can be used as a ternary operator.\nIt returns a if condition, else b.", - "Args": [ - "condition", - "a", - "b" - ], - "Aliases": [ - "cond" - ], - "Examples": [ - [ - "{{ cond (eq (add 2 2) 4) \"2+2 is 4\" \"what?\" | safeHTML }}", - "2+2 is 4" - ] - ] - }, - "Default": { - "Description": "Default checks whether a given value is set and returns a default value if it\nis not. \"Set\" in this context means non-zero for numeric types and times;\nnon-zero length for strings, arrays, slices, and maps;\nany boolean or struct value; or non-nil for any other types.", - "Args": [ - "dflt", - "given" - ], - "Aliases": [ - "default" - ], - "Examples": [ - [ - "{{ \"Hugo Rocks!\" | default \"Hugo Rules!\" }}", - "Hugo Rocks!" - ], - [ - "{{ \"\" | default \"Hugo Rules!\" }}", - "Hugo Rules!" - ] - ] - }, - "Eq": { - "Description": "Eq returns the boolean truth of arg1 == arg2 || arg1 == arg3 || arg1 == arg4.", - "Args": [ - "first", - "others" - ], - "Aliases": [ - "eq" - ], - "Examples": [ - [ - "{{ if eq .Section \"blog\" }}current{{ end }}", - "current" - ] - ] - }, - "Ge": { - "Description": "Ge returns the boolean truth of arg1 \u003e= arg2 \u0026\u0026 arg1 \u003e= arg3 \u0026\u0026 arg1 \u003e= arg4.", - "Args": [ - "first", - "others" - ], - "Aliases": [ - "ge" - ], - "Examples": [ - [ - "{{ if ge .Hugo.Version \"0.36\" }}Reasonable new Hugo version!{{ end }}", - "Reasonable new Hugo version!" - ] - ] - }, - "Gt": { - "Description": "Gt returns the boolean truth of arg1 \u003e arg2 \u0026\u0026 arg1 \u003e arg3 \u0026\u0026 arg1 \u003e arg4.", - "Args": [ - "first", - "others" - ], - "Aliases": [ - "gt" - ], - "Examples": [] - }, - "Le": { - "Description": "Le returns the boolean truth of arg1 \u003c= arg2 \u0026\u0026 arg1 \u003c= arg3 \u0026\u0026 arg1 \u003c= arg4.", - "Args": [ - "first", - "others" - ], - "Aliases": [ - "le" - ], - "Examples": [] - }, - "Lt": { - "Description": "Lt returns the boolean truth of arg1 \u003c arg2 \u0026\u0026 arg1 \u003c arg3 \u0026\u0026 arg1 \u003c arg4.", - "Args": [ - "first", - "others" - ], - "Aliases": [ - "lt" - ], - "Examples": [] - }, - "Ne": { - "Description": "Ne returns the boolean truth of arg1 != arg2 \u0026\u0026 arg1 != arg3 \u0026\u0026 arg1 != arg4.", - "Args": [ - "first", - "others" - ], - "Aliases": [ - "ne" - ], - "Examples": [] - } - }, - "collections": { - "After": { - "Description": "After returns all the items after the first N in a rangeable list.", - "Args": [ - "index", - "seq" - ], - "Aliases": [ - "after" - ], - "Examples": [] - }, - "Append": { - "Description": "Append appends the arguments up to the last one to the slice in the last argument.\nThis construct allows template constructs like this:\n {{ $pages = $pages | append $p2 $p1 }}\nNote that with 2 arguments where both are slices of the same type,\nthe first slice will be appended to the second:\n {{ $pages = $pages | append .Site.RegularPages }}", - "Args": [ - "args" - ], - "Aliases": [ - "append" - ], - "Examples": [] - }, - "Apply": { - "Description": "Apply takes a map, array, or slice and returns a new slice with the function fname applied over it.", - "Args": [ - "seq", - "fname", - "args" - ], - "Aliases": [ - "apply" - ], - "Examples": [] - }, - "Complement": { - "Description": "Complement gives the elements in the last element of seqs that are not in\nany of the others.\nAll elements of seqs must be slices or arrays of comparable types.\n\nThe reasoning behind this rather clumsy API is so we can do this in the templates:\n {{ $c := .Pages | complement $last4 }}", - "Args": [ - "seqs" - ], - "Aliases": [ - "complement" - ], - "Examples": [ - [ - "{{ slice \"a\" \"b\" \"c\" \"d\" \"e\" \"f\" | complement (slice \"b\" \"c\") (slice \"d\" \"e\") }}", - "[a f]" - ] - ] - }, - "Delimit": { - "Description": "Delimit takes a given sequence and returns a delimited HTML string.\nIf last is passed to the function, it will be used as the final delimiter.", - "Args": [ - "seq", - "delimiter", - "last" - ], - "Aliases": [ - "delimit" - ], - "Examples": [ - [ - "{{ delimit (slice \"A\" \"B\" \"C\") \", \" \" and \" }}", - "A, B and C" - ] - ] - }, - "Dictionary": { - "Description": "Dictionary creates a map[string]interface{} from the given parameters by\nwalking the parameters and treating them as key-value pairs. The number\nof parameters must be even.\nThe keys can be string slices, which will create the needed nested structure.", - "Args": [ - "values" - ], - "Aliases": [ - "dict" - ], - "Examples": [] - }, - "EchoParam": { - "Description": "EchoParam returns a given value if it is set; otherwise, it returns an\nempty string.", - "Args": [ - "a", - "key" - ], - "Aliases": [ - "echoParam" - ], - "Examples": [ - [ - "{{ echoParam .Params \"langCode\" }}", - "en" - ] - ] - }, - "First": { - "Description": "First returns the first N items in a rangeable list.", - "Args": [ - "limit", - "seq" - ], - "Aliases": [ - "first" - ], - "Examples": [] - }, - "Group": { - "Description": "Group groups a set of elements by the given key.\nThis is currently only supported for Pages.", - "Args": [ - "key", - "items" - ], - "Aliases": [ - "group" - ], - "Examples": [] - }, - "In": { - "Description": "In returns whether v is in the set l. l may be an array or slice.", - "Args": [ - "l", - "v" - ], - "Aliases": [ - "in" - ], - "Examples": [ - [ - "{{ if in \"this string contains a substring\" \"substring\" }}Substring found!{{ end }}", - "Substring found!" - ] - ] - }, - "Index": { - "Description": "Index returns the result of indexing its first argument by the following\narguments. Thus \"index x 1 2 3\" is, in Go syntax, x[1][2][3]. Each\nindexed item must be a map, slice, or array.\n\nCopied from Go stdlib src/text/template/funcs.go.\n\nWe deviate from the stdlib due to https://github.com/golang/go/issues/14751.\n\nTODO(moorereason): merge upstream changes.", - "Args": [ - "item", - "args" - ], - "Aliases": [ - "index" - ], - "Examples": [] - }, - "Intersect": { - "Description": "Intersect returns the common elements in the given sets, l1 and l2. l1 and\nl2 must be of the same type and may be either arrays or slices.", - "Args": [ - "l1", - "l2" - ], - "Aliases": [ - "intersect" - ], - "Examples": [] - }, - "IsSet": { - "Description": "IsSet returns whether a given array, channel, slice, or map has a key\ndefined.", - "Args": [ - "a", - "key" - ], - "Aliases": [ - "isSet", - "isset" - ], - "Examples": [] - }, - "KeyVals": { - "Description": "KeyVals creates a key and values wrapper.", - "Args": [ - "key", - "vals" - ], - "Aliases": [ - "keyVals" - ], - "Examples": [ - [ - "{{ keyVals \"key\" \"a\" \"b\" }}", - "key: [a b]" - ] - ] - }, - "Last": { - "Description": "Last returns the last N items in a rangeable list.", - "Args": [ - "limit", - "seq" - ], - "Aliases": [ - "last" - ], - "Examples": [] - }, - "Merge": { - "Description": "Merge creates a copy of dst and merges src into it.\nCurrently only maps supported. Key handling is case insensitive.", - "Args": [ - "src", - "dst" - ], - "Aliases": [ - "merge" - ], - "Examples": [ - [ - "{{ dict \"title\" \"Hugo Rocks!\" | collections.Merge (dict \"title\" \"Default Title\" \"description\" \"Yes, Hugo Rocks!\") | sort }}", - "[Yes, Hugo Rocks! Hugo Rocks!]" - ], - [ - "{{ merge (dict \"title\" \"Default Title\" \"description\" \"Yes, Hugo Rocks!\") (dict \"title\" \"Hugo Rocks!\") | sort }}", - "[Yes, Hugo Rocks! Hugo Rocks!]" - ] - ] - }, - "NewScratch": { - "Description": "NewScratch creates a new Scratch which can be used to store values in a\nthread safe way.", - "Args": null, - "Aliases": [ - "newScratch" - ], - "Examples": [ - [ - "{{ $scratch := newScratch }}{{ $scratch.Add \"b\" 2 }}{{ $scratch.Add \"b\" 2 }}{{ $scratch.Get \"b\" }}", - "4" - ] - ] - }, - "Querify": { - "Description": "Querify encodes the given parameters in URL-encoded form (\"bar=baz\u0026foo=quux\") sorted by key.", - "Args": [ - "params" - ], - "Aliases": [ - "querify" - ], - "Examples": [ - [ - "{{ (querify \"foo\" 1 \"bar\" 2 \"baz\" \"with spaces\" \"qux\" \"this\u0026that=those\") | safeHTML }}", - "bar=2\u0026baz=with+spaces\u0026foo=1\u0026qux=this%26that%3Dthose" - ], - [ - "\u003ca href=\"https://www.google.com?{{ (querify \"q\" \"test\" \"page\" 3) | safeURL }}\"\u003eSearch\u003c/a\u003e", - "\u003ca href=\"https://www.google.com?page=3\u0026amp;q=test\"\u003eSearch\u003c/a\u003e" - ] - ] - }, - "Reverse": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Seq": { - "Description": "Seq creates a sequence of integers. It's named and used as GNU's seq.\n\nExamples:\n 3 =\u003e 1, 2, 3\n 1 2 4 =\u003e 1, 3\n -3 =\u003e -1, -2, -3\n 1 4 =\u003e 1, 2, 3, 4\n 1 -2 =\u003e 1, 0, -1, -2", - "Args": [ - "args" - ], - "Aliases": [ - "seq" - ], - "Examples": [ - [ - "{{ seq 3 }}", - "[1 2 3]" - ] - ] - }, - "Shuffle": { - "Description": "Shuffle returns the given rangeable list in a randomised order.", - "Args": [ - "seq" - ], - "Aliases": [ - "shuffle" - ], - "Examples": [] - }, - "Slice": { - "Description": "Slice returns a slice of all passed arguments.", - "Args": [ - "args" - ], - "Aliases": [ - "slice" - ], - "Examples": [ - [ - "{{ slice \"B\" \"C\" \"A\" | sort }}", - "[A B C]" - ] - ] - }, - "Sort": { - "Description": "Sort returns a sorted sequence.", - "Args": [ - "seq", - "args" - ], - "Aliases": [ - "sort" - ], - "Examples": [] - }, - "SymDiff": { - "Description": "SymDiff returns the symmetric difference of s1 and s2.\nArguments must be either a slice or an array of comparable types.", - "Args": [ - "s2", - "s1" - ], - "Aliases": [ - "symdiff" - ], - "Examples": [ - [ - "{{ slice 1 2 3 | symdiff (slice 3 4) }}", - "[1 2 4]" - ] - ] - }, - "Union": { - "Description": "Union returns the union of the given sets, l1 and l2. l1 and\nl2 must be of the same type and may be either arrays or slices.\nIf l1 and l2 aren't of the same type then l1 will be returned.\nIf either l1 or l2 is nil then the non-nil list will be returned.", - "Args": [ - "l1", - "l2" - ], - "Aliases": [ - "union" - ], - "Examples": [ - [ - "{{ union (slice 1 2 3) (slice 3 4 5) }}", - "[1 2 3 4 5]" - ] - ] - }, - "Uniq": { - "Description": "Uniq takes in a slice or array and returns a slice with subsequent\nduplicate elements removed.", - "Args": [ - "seq" - ], - "Aliases": [ - "uniq" - ], - "Examples": [ - [ - "{{ slice 1 2 3 2 | uniq }}", - "[1 2 3]" - ] - ] - }, - "Where": { - "Description": "Where returns a filtered subset of a given data type.", - "Args": [ - "seq", - "key", - "args" - ], - "Aliases": [ - "where" - ], - "Examples": [] - } - }, - "crypto": { - "MD5": { - "Description": "MD5 hashes the given input and returns its MD5 checksum.", - "Args": [ - "in" - ], - "Aliases": [ - "md5" - ], - "Examples": [ - [ - "{{ md5 \"Hello world, gophers!\" }}", - "b3029f756f98f79e7f1b7f1d1f0dd53b" - ], - [ - "{{ crypto.MD5 \"Hello world, gophers!\" }}", - "b3029f756f98f79e7f1b7f1d1f0dd53b" - ] - ] - }, - "SHA1": { - "Description": "SHA1 hashes the given input and returns its SHA1 checksum.", - "Args": [ - "in" - ], - "Aliases": [ - "sha1" - ], - "Examples": [ - [ - "{{ sha1 \"Hello world, gophers!\" }}", - "c8b5b0e33d408246e30f53e32b8f7627a7a649d4" - ] - ] - }, - "SHA256": { - "Description": "SHA256 hashes the given input and returns its SHA256 checksum.", - "Args": [ - "in" - ], - "Aliases": [ - "sha256" - ], - "Examples": [ - [ - "{{ sha256 \"Hello world, gophers!\" }}", - "6ec43b78da9669f50e4e422575c54bf87536954ccd58280219c393f2ce352b46" - ] - ] - } - }, - "data": { - "GetCSV": { - "Description": "GetCSV expects a data separator and one or n-parts of a URL to a resource which\ncan either be a local or a remote one.\nThe data separator can be a comma, semi-colon, pipe, etc, but only one character.\nIf you provide multiple parts for the URL they will be joined together to the final URL.\nGetCSV returns nil or a slice slice to use in a short code.", - "Args": [ - "sep", - "urlParts" - ], - "Aliases": [ - "getCSV" - ], - "Examples": [] - }, - "GetJSON": { - "Description": "GetJSON expects one or n-parts of a URL to a resource which can either be a local or a remote one.\nIf you provide multiple parts they will be joined together to the final URL.\nGetJSON returns nil or parsed JSON to use in a short code.", - "Args": [ - "urlParts" - ], - "Aliases": [ - "getJSON" - ], - "Examples": [] - } - }, - "encoding": { - "Base64Decode": { - "Description": "Base64Decode returns the base64 decoding of the given content.", - "Args": [ - "content" - ], - "Aliases": [ - "base64Decode" - ], - "Examples": [ - [ - "{{ \"SGVsbG8gd29ybGQ=\" | base64Decode }}", - "Hello world" - ], - [ - "{{ 42 | base64Encode | base64Decode }}", - "42" - ] - ] - }, - "Base64Encode": { - "Description": "Base64Encode returns the base64 encoding of the given content.", - "Args": [ - "content" - ], - "Aliases": [ - "base64Encode" - ], - "Examples": [ - [ - "{{ \"Hello world\" | base64Encode }}", - "SGVsbG8gd29ybGQ=" - ] - ] - }, - "Jsonify": { - "Description": "Jsonify encodes a given object to JSON.", - "Args": [ - "v" - ], - "Aliases": [ - "jsonify" - ], - "Examples": [ - [ - "{{ (slice \"A\" \"B\" \"C\") | jsonify }}", - "[\"A\",\"B\",\"C\"]" - ] - ] - } - }, - "fmt": { - "Errorf": { - "Description": "Errorf formats according to a format specifier and logs an ERROR.\nIt returns an empty string.", - "Args": [ - "format", - "a" - ], - "Aliases": [ - "errorf" - ], - "Examples": [ - [ - "{{ errorf \"%s.\" \"failed\" }}", - "" - ] - ] - }, - "Print": { - "Description": "Print returns string representation of the passed arguments.", - "Args": [ - "a" - ], - "Aliases": [ - "print" - ], - "Examples": [ - [ - "{{ print \"works!\" }}", - "works!" - ] - ] - }, - "Printf": { - "Description": "Printf returns a formatted string representation of the passed arguments.", - "Args": [ - "format", - "a" - ], - "Aliases": [ - "printf" - ], - "Examples": [ - [ - "{{ printf \"%s!\" \"works\" }}", - "works!" - ] - ] - }, - "Println": { - "Description": "Println returns string representation of the passed arguments ending with a newline.", - "Args": [ - "a" - ], - "Aliases": [ - "println" - ], - "Examples": [ - [ - "{{ println \"works!\" }}", - "works!\n" - ] - ] - }, - "Warnf": { - "Description": "Warnf formats according to a format specifier and logs a WARNING.\nIt returns an empty string.", - "Args": [ - "format", - "a" - ], - "Aliases": [ - "warnf" - ], - "Examples": [ - [ - "{{ warnf \"%s.\" \"warning\" }}", - "" - ] - ] - } - }, - "hugo": { - "Generator": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Version": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - } - }, - "images": { - "Brightness": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "ColorBalance": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Colorize": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Config": { - "Description": "Config returns the image.Config for the specified path relative to the\nworking directory.", - "Args": [ - "path" - ], - "Aliases": [ - "imageConfig" - ], - "Examples": [] - }, - "Contrast": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Filter": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Gamma": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "GaussianBlur": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Grayscale": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Hue": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Invert": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Pixelate": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Saturation": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Sepia": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Sigmoid": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "UnsharpMask": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - } - }, - "inflect": { - "Humanize": { - "Description": "Humanize returns the humanized form of a single parameter.\n\nIf the parameter is either an integer or a string containing an integer\nvalue, the behavior is to add the appropriate ordinal.\n\n Example: \"my-first-post\" -\u003e \"My first post\"\n Example: \"103\" -\u003e \"103rd\"\n Example: 52 -\u003e \"52nd\"", - "Args": [ - "in" - ], - "Aliases": [ - "humanize" - ], - "Examples": [ - [ - "{{ humanize \"my-first-post\" }}", - "My first post" - ], - [ - "{{ humanize \"myCamelPost\" }}", - "My camel post" - ], - [ - "{{ humanize \"52\" }}", - "52nd" - ], - [ - "{{ humanize 103 }}", - "103rd" - ] - ] - }, - "Pluralize": { - "Description": "Pluralize returns the plural form of a single word.", - "Args": [ - "in" - ], - "Aliases": [ - "pluralize" - ], - "Examples": [ - [ - "{{ \"cat\" | pluralize }}", - "cats" - ] - ] - }, - "Singularize": { - "Description": "Singularize returns the singular form of a single word.", - "Args": [ - "in" - ], - "Aliases": [ - "singularize" - ], - "Examples": [ - [ - "{{ \"cats\" | singularize }}", - "cat" - ] - ] - } - }, - "lang": { - "Merge": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "NumFmt": { - "Description": "NumFmt formats a number with the given precision using the\nnegative, decimal, and grouping options. The `options`\nparameter is a string consisting of `\u003cnegative\u003e \u003cdecimal\u003e \u003cgrouping\u003e`. The\ndefault `options` value is `- . ,`.\n\nNote that numbers are rounded up at 5 or greater.\nSo, with precision set to 0, 1.5 becomes `2`, and 1.4 becomes `1`.", - "Args": [ - "precision", - "number", - "options" - ], - "Aliases": null, - "Examples": [ - [ - "{{ lang.NumFmt 2 12345.6789 }}", - "12,345.68" - ], - [ - "{{ lang.NumFmt 2 12345.6789 \"- , .\" }}", - "12.345,68" - ], - [ - "{{ lang.NumFmt 6 -12345.6789 \"- .\" }}", - "-12345.678900" - ], - [ - "{{ lang.NumFmt 0 -12345.6789 \"- . ,\" }}", - "-12,346" - ], - [ - "{{ -98765.4321 | lang.NumFmt 2 }}", - "-98,765.43" - ] - ] - }, - "Translate": { - "Description": "Translate returns a translated string for id.", - "Args": [ - "id", - "args" - ], - "Aliases": [ - "i18n", - "T" - ], - "Examples": [] - } - }, - "math": { - "Add": { - "Description": "Add adds two numbers.", - "Args": [ - "a", - "b" - ], - "Aliases": [ - "add" - ], - "Examples": [ - [ - "{{add 1 2}}", - "3" - ] - ] - }, - "Ceil": { - "Description": "Ceil returns the least integer value greater than or equal to x.", - "Args": [ - "x" - ], - "Aliases": null, - "Examples": [ - [ - "{{math.Ceil 2.1}}", - "3" - ] - ] - }, - "Div": { - "Description": "Div divides two numbers.", - "Args": [ - "a", - "b" - ], - "Aliases": [ - "div" - ], - "Examples": [ - [ - "{{div 6 3}}", - "2" - ] - ] - }, - "Floor": { - "Description": "Floor returns the greatest integer value less than or equal to x.", - "Args": [ - "x" - ], - "Aliases": null, - "Examples": [ - [ - "{{math.Floor 1.9}}", - "1" - ] - ] - }, - "Log": { - "Description": "Log returns the natural logarithm of a number.", - "Args": [ - "a" - ], - "Aliases": null, - "Examples": [ - [ - "{{math.Log 1}}", - "0" - ] - ] - }, - "Mod": { - "Description": "Mod returns a % b.", - "Args": [ - "a", - "b" - ], - "Aliases": [ - "mod" - ], - "Examples": [ - [ - "{{mod 15 3}}", - "0" - ] - ] - }, - "ModBool": { - "Description": "ModBool returns the boolean of a % b. If a % b == 0, return true.", - "Args": [ - "a", - "b" - ], - "Aliases": [ - "modBool" - ], - "Examples": [ - [ - "{{modBool 15 3}}", - "true" - ] - ] - }, - "Mul": { - "Description": "Mul multiplies two numbers.", - "Args": [ - "a", - "b" - ], - "Aliases": [ - "mul" - ], - "Examples": [ - [ - "{{mul 2 3}}", - "6" - ] - ] - }, - "Round": { - "Description": "Round returns the nearest integer, rounding half away from zero.", - "Args": [ - "x" - ], - "Aliases": null, - "Examples": [ - [ - "{{math.Round 1.5}}", - "2" - ] - ] - }, - "Sub": { - "Description": "Sub subtracts two numbers.", - "Args": [ - "a", - "b" - ], - "Aliases": [ - "sub" - ], - "Examples": [ - [ - "{{sub 3 2}}", - "1" - ] - ] - } - }, - "os": { - "FileExists": { - "Description": "FileExists checks whether a file exists under the given path.", - "Args": [ - "i" - ], - "Aliases": [ - "fileExists" - ], - "Examples": [ - [ - "{{ fileExists \"foo.txt\" }}", - "false" - ] - ] - }, - "Getenv": { - "Description": "Getenv retrieves the value of the environment variable named by the key.\nIt returns the value, which will be empty if the variable is not present.", - "Args": [ - "key" - ], - "Aliases": [ - "getenv" - ], - "Examples": [] - }, - "ReadDir": { - "Description": "ReadDir lists the directory contents relative to the configured WorkingDir.", - "Args": [ - "i" - ], - "Aliases": [ - "readDir" - ], - "Examples": [ - [ - "{{ range (readDir \"files\") }}{{ .Name }}{{ end }}", - "README.txt" - ] - ] - }, - "ReadFile": { - "Description": "ReadFile reads the file named by filename relative to the configured WorkingDir.\nIt returns the contents as a string.\nThere is an upper size limit set at 1 megabytes.", - "Args": [ - "i" - ], - "Aliases": [ - "readFile" - ], - "Examples": [ - [ - "{{ readFile \"files/README.txt\" }}", - "Hugo Rocks!" - ] - ] - }, - "Stat": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - } - }, - "partials": { - "Include": { - "Description": "Include executes the named partial.\nIf the partial contains a return statement, that value will be returned.\nElse, the rendered output will be returned:\nA string if the partial is a text/template, or template.HTML when html/template.", - "Args": [ - "name", - "contextList" - ], - "Aliases": [ - "partial" - ], - "Examples": [ - [ - "{{ partial \"header.html\" . }}", - "\u003ctitle\u003eHugo Rocks!\u003c/title\u003e" - ] - ] - }, - "IncludeCached": { - "Description": "IncludeCached executes and caches partial templates. The cache is created with name+variants as the key.", - "Args": [ - "name", - "context", - "variants" - ], - "Aliases": [ - "partialCached" - ], - "Examples": [] - } - }, - "path": { - "Base": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Dir": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Ext": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Join": { - "Description": "Join joins any number of path elements into a single path, adding a\nseparating slash if necessary. All the input\npath elements are passed into filepath.ToSlash converting any Windows slashes\nto forward slashes.\nThe result is Cleaned; in particular,\nall empty strings are ignored.", - "Args": [ - "elements" - ], - "Aliases": null, - "Examples": [ - [ - "{{ slice \"my/path\" \"filename.txt\" | path.Join }}", - "my/path/filename.txt" - ], - [ - "{{ path.Join \"my\" \"path\" \"filename.txt\" }}", - "my/path/filename.txt" - ], - [ - "{{ \"my/path/filename.txt\" | path.Ext }}", - ".txt" - ], - [ - "{{ \"my/path/filename.txt\" | path.Base }}", - "filename.txt" - ], - [ - "{{ \"my/path/filename.txt\" | path.Dir }}", - "my/path" - ] - ] - }, - "Split": { - "Description": "Split splits path immediately following the final slash,\nseparating it into a directory and file name component.\nIf there is no slash in path, Split returns an empty dir and\nfile set to path.\nThe input path is passed into filepath.ToSlash converting any Windows slashes\nto forward slashes.\nThe returned values have the property that path = dir+file.", - "Args": [ - "path" - ], - "Aliases": null, - "Examples": [ - [ - "{{ \"/my/path/filename.txt\" | path.Split }}", - "/my/path/|filename.txt" - ], - [ - "{{ \"/my/path/filename.txt\" | path.Split }}", - "/my/path/|filename.txt" - ] - ] - } - }, - "reflect": { - "IsMap": { - "Description": "IsMap reports whether v is a map.", - "Args": [ - "v" - ], - "Aliases": null, - "Examples": [ - [ - "{{ if reflect.IsMap (dict \"a\" 1) }}Map{{ end }}", - "Map" - ] - ] - }, - "IsSlice": { - "Description": "IsSlice reports whether v is a slice.", - "Args": [ - "v" - ], - "Aliases": null, - "Examples": [ - [ - "{{ if reflect.IsSlice (slice 1 2 3) }}Slice{{ end }}", - "Slice" - ] - ] - } - }, - "resources": { - "Concat": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "ExecuteAsTemplate": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Fingerprint": { - "Description": "Fingerprint transforms the given Resource with a MD5 hash of the content in\nthe RelPermalink and Permalink.", - "Args": [ - "args" - ], - "Aliases": [ - "fingerprint" - ], - "Examples": [] - }, - "FromString": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Get": { - "Description": "Get locates the filename given in Hugo's assets filesystem\nand creates a Resource object that can be used for further transformations.", - "Args": [ - "filename" - ], - "Aliases": null, - "Examples": [] - }, - "GetMatch": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Match": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Minify": { - "Description": "Minify minifies the given Resource using the MediaType to pick the correct\nminifier.", - "Args": [ - "r" - ], - "Aliases": [ - "minify" - ], - "Examples": [] - }, - "PostCSS": { - "Description": "PostCSS processes the given Resource with PostCSS", - "Args": [ - "args" - ], - "Aliases": [ - "postCSS" - ], - "Examples": [] - }, - "ToCSS": { - "Description": "ToCSS converts the given Resource to CSS. You can optional provide an Options\nobject or a target path (string) as first argument.", - "Args": [ - "args" - ], - "Aliases": [ - "toCSS" - ], - "Examples": [] - } - }, - "safe": { - "CSS": { - "Description": "CSS returns a given string as html/template CSS content.", - "Args": [ - "a" - ], - "Aliases": [ - "safeCSS" - ], - "Examples": [ - [ - "{{ \"Bat\u0026Man\" | safeCSS | safeCSS }}", - "Bat\u0026amp;Man" - ] - ] - }, - "HTML": { - "Description": "HTML returns a given string as html/template HTML content.", - "Args": [ - "a" - ], - "Aliases": [ - "safeHTML" - ], - "Examples": [ - [ - "{{ \"Bat\u0026Man\" | safeHTML | safeHTML }}", - "Bat\u0026Man" - ], - [ - "{{ \"Bat\u0026Man\" | safeHTML }}", - "Bat\u0026Man" - ] - ] - }, - "HTMLAttr": { - "Description": "HTMLAttr returns a given string as html/template HTMLAttr content.", - "Args": [ - "a" - ], - "Aliases": [ - "safeHTMLAttr" - ], - "Examples": [] - }, - "JS": { - "Description": "JS returns the given string as a html/template JS content.", - "Args": [ - "a" - ], - "Aliases": [ - "safeJS" - ], - "Examples": [ - [ - "{{ \"(1*2)\" | safeJS | safeJS }}", - "(1*2)" - ] - ] - }, - "JSStr": { - "Description": "JSStr returns the given string as a html/template JSStr content.", - "Args": [ - "a" - ], - "Aliases": [ - "safeJSStr" - ], - "Examples": [] - }, - "SanitizeURL": { - "Description": "SanitizeURL returns a given string as html/template URL content.", - "Args": [ - "a" - ], - "Aliases": [ - "sanitizeURL", - "sanitizeurl" - ], - "Examples": [] - }, - "URL": { - "Description": "URL returns a given string as html/template URL content.", - "Args": [ - "a" - ], - "Aliases": [ - "safeURL" - ], - "Examples": [ - [ - "{{ \"http://gohugo.io\" | safeURL | safeURL }}", - "http://gohugo.io" - ] - ] - } - }, - "site": { - "BaseURL": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Data": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Hugo": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "IsServer": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Language": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "LastChange": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Menus": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Pages": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Params": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "RegularPages": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "ServerPort": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Sites": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Taxonomies": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Title": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - } - }, - "strings": { - "Chomp": { - "Description": "Chomp returns a copy of s with all trailing newline characters removed.", - "Args": [ - "s" - ], - "Aliases": [ - "chomp" - ], - "Examples": [ - [ - "{{chomp \"\u003cp\u003eBlockhead\u003c/p\u003e\\n\" | safeHTML }}", - "\u003cp\u003eBlockhead\u003c/p\u003e" - ] - ] - }, - "Contains": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "ContainsAny": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "CountRunes": { - "Description": "CountRunes returns the number of runes in s, excluding whitepace.", - "Args": [ - "s" - ], - "Aliases": [ - "countrunes" - ], - "Examples": [] - }, - "CountWords": { - "Description": "CountWords returns the approximate word count in s.", - "Args": [ - "s" - ], - "Aliases": [ - "countwords" - ], - "Examples": [] - }, - "FindRE": { - "Description": "FindRE returns a list of strings that match the regular expression. By default all matches\nwill be included. The number of matches can be limited with an optional third parameter.", - "Args": [ - "expr", - "content", - "limit" - ], - "Aliases": [ - "findRE" - ], - "Examples": [ - [ - "{{ findRE \"[G|g]o\" \"Hugo is a static side generator written in Go.\" \"1\" }}", - "[go]" - ] - ] - }, - "FirstUpper": { - "Description": "FirstUpper returns a string with the first character as upper case.", - "Args": [ - "s" - ], - "Aliases": null, - "Examples": [ - [ - "{{ \"hugo rocks!\" | strings.FirstUpper }}", - "Hugo rocks!" - ] - ] - }, - "HasPrefix": { - "Description": "HasPrefix tests whether the input s begins with prefix.", - "Args": [ - "s", - "prefix" - ], - "Aliases": [ - "hasPrefix" - ], - "Examples": [ - [ - "{{ hasPrefix \"Hugo\" \"Hu\" }}", - "true" - ], - [ - "{{ hasPrefix \"Hugo\" \"Fu\" }}", - "false" - ] - ] - }, - "HasSuffix": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Repeat": { - "Description": "Repeat returns a new string consisting of count copies of the string s.", - "Args": [ - "n", - "s" - ], - "Aliases": null, - "Examples": [ - [ - "{{ \"yo\" | strings.Repeat 4 }}", - "yoyoyoyo" - ] - ] - }, - "Replace": { - "Description": "Replace returns a copy of the string s with all occurrences of old replaced\nwith new.", - "Args": [ - "s", - "old", - "new" - ], - "Aliases": [ - "replace" - ], - "Examples": [ - [ - "{{ replace \"Batman and Robin\" \"Robin\" \"Catwoman\" }}", - "Batman and Catwoman" - ] - ] - }, - "ReplaceRE": { - "Description": "ReplaceRE returns a copy of s, replacing all matches of the regular\nexpression pattern with the replacement text repl.", - "Args": [ - "pattern", - "repl", - "s" - ], - "Aliases": [ - "replaceRE" - ], - "Examples": [] - }, - "RuneCount": { - "Description": "RuneCount returns the number of runes in s.", - "Args": [ - "s" - ], - "Aliases": null, - "Examples": [] - }, - "SliceString": { - "Description": "SliceString slices a string by specifying a half-open range with\ntwo indices, start and end. 1 and 4 creates a slice including elements 1 through 3.\nThe end index can be omitted, it defaults to the string's length.", - "Args": [ - "a", - "startEnd" - ], - "Aliases": [ - "slicestr" - ], - "Examples": [ - [ - "{{slicestr \"BatMan\" 0 3}}", - "Bat" - ], - [ - "{{slicestr \"BatMan\" 3}}", - "Man" - ] - ] - }, - "Split": { - "Description": "Split slices an input string into all substrings separated by delimiter.", - "Args": [ - "a", - "delimiter" - ], - "Aliases": [ - "split" - ], - "Examples": [] - }, - "Substr": { - "Description": "Substr extracts parts of a string, beginning at the character at the specified\nposition, and returns the specified number of characters.\n\nIt normally takes two parameters: start and length.\nIt can also take one parameter: start, i.e. length is omitted, in which case\nthe substring starting from start until the end of the string will be returned.\n\nTo extract characters from the end of the string, use a negative start number.\n\nIn addition, borrowing from the extended behavior described at http://php.net/substr,\nif length is given and is negative, then that many characters will be omitted from\nthe end of string.", - "Args": [ - "a", - "nums" - ], - "Aliases": [ - "substr" - ], - "Examples": [ - [ - "{{substr \"BatMan\" 0 -3}}", - "Bat" - ], - [ - "{{substr \"BatMan\" 3 3}}", - "Man" - ] - ] - }, - "Title": { - "Description": "Title returns a copy of the input s with all Unicode letters that begin words\nmapped to their title case.", - "Args": [ - "s" - ], - "Aliases": [ - "title" - ], - "Examples": [ - [ - "{{title \"Bat man\"}}", - "Bat Man" - ], - [ - "{{title \"somewhere over the rainbow\"}}", - "Somewhere Over the Rainbow" - ] - ] - }, - "ToLower": { - "Description": "ToLower returns a copy of the input s with all Unicode letters mapped to their\nlower case.", - "Args": [ - "s" - ], - "Aliases": [ - "lower" - ], - "Examples": [ - [ - "{{lower \"BatMan\"}}", - "batman" - ] - ] - }, - "ToUpper": { - "Description": "ToUpper returns a copy of the input s with all Unicode letters mapped to their\nupper case.", - "Args": [ - "s" - ], - "Aliases": [ - "upper" - ], - "Examples": [ - [ - "{{upper \"BatMan\"}}", - "BATMAN" - ] - ] - }, - "Trim": { - "Description": "Trim returns a string with all leading and trailing characters defined\ncontained in cutset removed.", - "Args": [ - "s", - "cutset" - ], - "Aliases": [ - "trim" - ], - "Examples": [ - [ - "{{ trim \"++Batman--\" \"+-\" }}", - "Batman" - ] - ] - }, - "TrimLeft": { - "Description": "TrimLeft returns a slice of the string s with all leading characters\ncontained in cutset removed.", - "Args": [ - "cutset", - "s" - ], - "Aliases": null, - "Examples": [ - [ - "{{ \"aabbaa\" | strings.TrimLeft \"a\" }}", - "bbaa" - ] - ] - }, - "TrimPrefix": { - "Description": "TrimPrefix returns s without the provided leading prefix string. If s doesn't\nstart with prefix, s is returned unchanged.", - "Args": [ - "prefix", - "s" - ], - "Aliases": null, - "Examples": [ - [ - "{{ \"aabbaa\" | strings.TrimPrefix \"a\" }}", - "abbaa" - ], - [ - "{{ \"aabbaa\" | strings.TrimPrefix \"aa\" }}", - "bbaa" - ] - ] - }, - "TrimRight": { - "Description": "TrimRight returns a slice of the string s with all trailing characters\ncontained in cutset removed.", - "Args": [ - "cutset", - "s" - ], - "Aliases": null, - "Examples": [ - [ - "{{ \"aabbaa\" | strings.TrimRight \"a\" }}", - "aabb" - ] - ] - }, - "TrimSuffix": { - "Description": "TrimSuffix returns s without the provided trailing suffix string. If s\ndoesn't end with suffix, s is returned unchanged.", - "Args": [ - "suffix", - "s" - ], - "Aliases": null, - "Examples": [ - [ - "{{ \"aabbaa\" | strings.TrimSuffix \"a\" }}", - "aabba" - ], - [ - "{{ \"aabbaa\" | strings.TrimSuffix \"aa\" }}", - "aabb" - ] - ] - }, - "Truncate": { - "Description": "Truncate truncates a given string to the specified length.", - "Args": [ - "a", - "options" - ], - "Aliases": [ - "truncate" - ], - "Examples": [ - [ - "{{ \"this is a very long text\" | truncate 10 \" ...\" }}", - "this is a ..." - ], - [ - "{{ \"With [Markdown](/markdown) inside.\" | markdownify | truncate 14 }}", - "With \u003ca href=\"/markdown\"\u003eMarkdown …\u003c/a\u003e" - ] - ] - } - }, - "templates": { - "Exists": { - "Description": "Exists returns whether the template with the given name exists.\nNote that this is the Unix-styled relative path including filename suffix,\ne.g. partials/header.html", - "Args": [ - "name" - ], - "Aliases": null, - "Examples": [ - [ - "{{ if (templates.Exists \"partials/header.html\") }}Yes!{{ end }}", - "Yes!" - ], - [ - "{{ if not (templates.Exists \"partials/doesnotexist.html\") }}No!{{ end }}", - "No!" - ] - ] - } - }, - "time": { - "AsTime": { - "Description": "AsTime converts the textual representation of the datetime string into\na time.Time interface.", - "Args": [ - "v" - ], - "Aliases": null, - "Examples": [ - [ - "{{ (time \"2015-01-21\").Year }}", - "2015" - ] - ] - }, - "Duration": { - "Description": "Duration converts the given number to a time.Duration.\nUnit is one of nanosecond/ns, microsecond/us/µs, millisecond/ms, second/s, minute/m or hour/h.", - "Args": [ - "unit", - "number" - ], - "Aliases": [ - "duration" - ], - "Examples": [ - [ - "{{ mul 60 60 | duration \"second\" }}", - "1h0m0s" - ] - ] - }, - "Format": { - "Description": "Format converts the textual representation of the datetime string into\nthe other form or returns it of the time.Time value. These are formatted\nwith the layout string", - "Args": [ - "layout", - "v" - ], - "Aliases": [ - "dateFormat" - ], - "Examples": [ - [ - "dateFormat: {{ dateFormat \"Monday, Jan 2, 2006\" \"2015-01-21\" }}", - "dateFormat: Wednesday, Jan 21, 2015" - ] - ] - }, - "Now": { - "Description": "Now returns the current local time.", - "Args": null, - "Aliases": [ - "now" - ], - "Examples": [] - }, - "ParseDuration": { - "Description": "ParseDuration parses a duration string.\nA duration string is a possibly signed sequence of\ndecimal numbers, each with optional fraction and a unit suffix,\nsuch as \"300ms\", \"-1.5h\" or \"2h45m\".\nValid time units are \"ns\", \"us\" (or \"µs\"), \"ms\", \"s\", \"m\", \"h\".\nSee https://golang.org/pkg/time/#ParseDuration", - "Args": [ - "in" - ], - "Aliases": null, - "Examples": [ - [ - "{{ \"1h12m10s\" | time.ParseDuration }}", - "1h12m10s" - ] - ] - } - }, - "transform": { - "Emojify": { - "Description": "Emojify returns a copy of s with all emoji codes replaced with actual emojis.\n\nSee http://www.emoji-cheat-sheet.com/", - "Args": [ - "s" - ], - "Aliases": [ - "emojify" - ], - "Examples": [ - [ - "{{ \"I :heart: Hugo\" | emojify }}", - "I ❤ Hugo" - ] - ] - }, - "HTMLEscape": { - "Description": "HTMLEscape returns a copy of s with reserved HTML characters escaped.", - "Args": [ - "s" - ], - "Aliases": [ - "htmlEscape" - ], - "Examples": [ - [ - "{{ htmlEscape \"Cathal Garvey \u0026 The Sunshine Band \u003ccathal@foo.bar\u003e\" | safeHTML}}", - "Cathal Garvey \u0026amp; The Sunshine Band \u0026lt;cathal@foo.bar\u0026gt;" - ], - [ - "{{ htmlEscape \"Cathal Garvey \u0026 The Sunshine Band \u003ccathal@foo.bar\u003e\"}}", - "Cathal Garvey \u0026amp;amp; The Sunshine Band \u0026amp;lt;cathal@foo.bar\u0026amp;gt;" - ], - [ - "{{ htmlEscape \"Cathal Garvey \u0026 The Sunshine Band \u003ccathal@foo.bar\u003e\" | htmlUnescape | safeHTML }}", - "Cathal Garvey \u0026 The Sunshine Band \u003ccathal@foo.bar\u003e" - ] - ] - }, - "HTMLUnescape": { - "Description": "HTMLUnescape returns a copy of with HTML escape requences converted to plain\ntext.", - "Args": [ - "s" - ], - "Aliases": [ - "htmlUnescape" - ], - "Examples": [ - [ - "{{ htmlUnescape \"Cathal Garvey \u0026amp; The Sunshine Band \u0026lt;cathal@foo.bar\u0026gt;\" | safeHTML}}", - "Cathal Garvey \u0026 The Sunshine Band \u003ccathal@foo.bar\u003e" - ], - [ - "{{\"Cathal Garvey \u0026amp;amp; The Sunshine Band \u0026amp;lt;cathal@foo.bar\u0026amp;gt;\" | htmlUnescape | htmlUnescape | safeHTML}}", - "Cathal Garvey \u0026 The Sunshine Band \u003ccathal@foo.bar\u003e" - ], - [ - "{{\"Cathal Garvey \u0026amp;amp; The Sunshine Band \u0026amp;lt;cathal@foo.bar\u0026amp;gt;\" | htmlUnescape | htmlUnescape }}", - "Cathal Garvey \u0026amp; The Sunshine Band \u0026lt;cathal@foo.bar\u0026gt;" - ], - [ - "{{ htmlUnescape \"Cathal Garvey \u0026amp; The Sunshine Band \u0026lt;cathal@foo.bar\u0026gt;\" | htmlEscape | safeHTML }}", - "Cathal Garvey \u0026amp; The Sunshine Band \u0026lt;cathal@foo.bar\u0026gt;" - ] - ] - }, - "Highlight": { - "Description": "Highlight returns a copy of s as an HTML string with syntax\nhighlighting applied.", - "Args": [ - "s", - "lang", - "opts" - ], - "Aliases": [ - "highlight" - ], - "Examples": [] - }, - "Markdownify": { - "Description": "Markdownify renders a given input from Markdown to HTML.", - "Args": [ - "s" - ], - "Aliases": [ - "markdownify" - ], - "Examples": [ - [ - "{{ .Title | markdownify}}", - "\u003cstrong\u003eBatMan\u003c/strong\u003e" - ] - ] - }, - "Plainify": { - "Description": "Plainify returns a copy of s with all HTML tags removed.", - "Args": [ - "s" - ], - "Aliases": [ - "plainify" - ], - "Examples": [ - [ - "{{ plainify \"Hello \u003cstrong\u003eworld\u003c/strong\u003e, gophers!\" }}", - "Hello world, gophers!" - ] - ] - }, - "Remarshal": { - "Description": "Remarshal is used in the Hugo documentation to convert configuration\nexamples from YAML to JSON, TOML (and possibly the other way around).\nThe is primarily a helper for the Hugo docs site.\nIt is not a general purpose YAML to TOML converter etc., and may\nchange without notice if it serves a purpose in the docs.\nFormat is one of json, yaml or toml.", - "Args": [ - "format", - "data" - ], - "Aliases": null, - "Examples": [ - [ - "{{ \"title = \\\"Hello World\\\"\" | transform.Remarshal \"json\" | safeHTML }}", - "{\n \"title\": \"Hello World\"\n}\n" - ] - ] - }, - "Unmarshal": { - "Description": "Unmarshal unmarshals the data given, which can be either a string\nor a Resource. Supported formats are JSON, TOML, YAML, and CSV.\nYou can optionally provide an options map as the first argument.", - "Args": [ - "args" - ], - "Aliases": [ - "unmarshal" - ], - "Examples": [ - [ - "{{ \"hello = \\\"Hello World\\\"\" | transform.Unmarshal }}", - "map[hello:Hello World]" - ], - [ - "{{ \"hello = \\\"Hello World\\\"\" | resources.FromString \"data/greetings.toml\" | transform.Unmarshal }}", - "map[hello:Hello World]" - ] - ] - } - }, - "urls": { - "AbsLangURL": { - "Description": "AbsLangURL takes a given string and converts it to an absolute URL according\nto a page's position in the project directory structure and the current\nlanguage.", - "Args": [ - "a" - ], - "Aliases": [ - "absLangURL" - ], - "Examples": [] - }, - "AbsURL": { - "Description": "AbsURL takes a given string and converts it to an absolute URL.", - "Args": [ - "a" - ], - "Aliases": [ - "absURL" - ], - "Examples": [] - }, - "Anchorize": { - "Description": "Anchorize creates sanitized anchor names that are compatible with Blackfriday.", - "Args": [ - "a" - ], - "Aliases": [ - "anchorize" - ], - "Examples": [ - [ - "{{ \"This is a title\" | anchorize }}", - "this-is-a-title" - ] - ] - }, - "Parse": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Ref": { - "Description": "Ref returns the absolute URL path to a given content item.", - "Args": [ - "in", - "args" - ], - "Aliases": [ - "ref" - ], - "Examples": [] - }, - "RelLangURL": { - "Description": "RelLangURL takes a given string and prepends the relative path according to a\npage's position in the project directory structure and the current language.", - "Args": [ - "a" - ], - "Aliases": [ - "relLangURL" - ], - "Examples": [] - }, - "RelRef": { - "Description": "RelRef returns the relative URL path to a given content item.", - "Args": [ - "in", - "args" - ], - "Aliases": [ - "relref" - ], - "Examples": [] - }, - "RelURL": { - "Description": "RelURL takes a given string and prepends the relative path according to a\npage's position in the project directory structure.", - "Args": [ - "a" - ], - "Aliases": [ - "relURL" - ], - "Examples": [] - }, - "URLize": { - "Description": "URLize returns the given argument formatted as URL.", - "Args": [ - "a" - ], - "Aliases": [ - "urlize" - ], - "Examples": [] - } - } - } - } -} diff --git a/docs/data/docs.yaml b/docs/data/docs.yaml new file mode 100644 index 000000000..0db4c7fe9 --- /dev/null +++ b/docs/data/docs.yaml @@ -0,0 +1,4914 @@ +chroma: + lexers: + - Aliases: + - abap + Name: ABAP + - Aliases: + - abnf + Name: ABNF + - Aliases: + - as + - actionscript + Name: ActionScript + - Aliases: + - as3 + - actionscript3 + Name: ActionScript 3 + - Aliases: + - ada + - ada95 + - ada2005 + Name: Ada + - Aliases: + - agda + Name: Agda + - Aliases: + - al + Name: AL + - Aliases: + - alloy + Name: Alloy + - Aliases: + - ng2 + Name: Angular2 + - Aliases: + - antlr + Name: ANTLR + - Aliases: + - apacheconf + - aconf + - apache + Name: ApacheConf + - Aliases: + - apl + Name: APL + - Aliases: + - applescript + Name: AppleScript + - Aliases: + - aql + Name: ArangoDB AQL + - Aliases: + - arduino + Name: Arduino + - Aliases: + - armasm + Name: ArmAsm + - Aliases: + - atl + Name: ATL + - Aliases: + - autohotkey + - ahk + Name: AutoHotkey + - Aliases: + - autoit + Name: AutoIt + - Aliases: + - awk + - gawk + - mawk + - nawk + Name: Awk + - Aliases: + - ballerina + Name: Ballerina + - Aliases: + - bash + - sh + - ksh + - zsh + - shell + Name: Bash + - Aliases: + - bash-session + - console + - shell-session + Name: Bash Session + - Aliases: + - bat + - batch + - dosbatch + - winbatch + Name: Batchfile + - Aliases: + - beef + Name: Beef + - Aliases: + - bib + - bibtex + Name: BibTeX + - Aliases: + - bicep + Name: Bicep + - Aliases: + - blitzbasic + - b3d + - bplus + Name: BlitzBasic + - Aliases: + - bnf + Name: BNF + - Aliases: + - bqn + Name: BQN + - Aliases: + - brainfuck + - bf + Name: Brainfuck + - Aliases: + - c + Name: C + - Aliases: + - csharp + - c# + Name: C# + - Aliases: + - cpp + - c++ + Name: C++ + - Aliases: + - caddyfile + - caddy + Name: Caddyfile + - Aliases: + - caddyfile-directives + - caddyfile-d + - caddy-d + Name: Caddyfile Directives + - Aliases: + - capnp + Name: Cap'n Proto + - Aliases: + - cassandra + - cql + Name: Cassandra CQL + - Aliases: + - ceylon + Name: Ceylon + - Aliases: + - cfengine3 + - cf3 + Name: CFEngine3 + - Aliases: + - cfs + Name: cfstatement + - Aliases: + - chai + - chaiscript + Name: ChaiScript + - Aliases: + - chapel + - chpl + Name: Chapel + - Aliases: + - cheetah + - spitfire + Name: Cheetah + - Aliases: + - clojure + - clj + - edn + Name: Clojure + - Aliases: + - cmake + Name: CMake + - Aliases: + - cobol + Name: COBOL + - Aliases: + - coffee-script + - coffeescript + - coffee + Name: CoffeeScript + - Aliases: + - common-lisp + - cl + - lisp + Name: Common Lisp + - Aliases: + - coq + Name: Coq + - Aliases: + - cr + - crystal + Name: Crystal + - Aliases: + - css + Name: CSS + - Aliases: + - csv + Name: CSV + - Aliases: + - cue + Name: CUE + - Aliases: + - cython + - pyx + - pyrex + Name: Cython + - Aliases: + - d + Name: D + - Aliases: + - dart + Name: Dart + - Aliases: + - dax + Name: Dax + - Aliases: + - desktop + - desktop_entry + Name: Desktop file + - Aliases: + - diff + - udiff + Name: Diff + - Aliases: + - django + - jinja + Name: Django/Jinja + - Aliases: + - zone + - bind + Name: dns + - Aliases: + - docker + - dockerfile + Name: Docker + - Aliases: + - dtd + Name: DTD + - Aliases: + - dylan + Name: Dylan + - Aliases: + - ebnf + Name: EBNF + - Aliases: + - elixir + - ex + - exs + Name: Elixir + - Aliases: + - elm + Name: Elm + - Aliases: + - emacs + - elisp + - emacs-lisp + Name: EmacsLisp + - Aliases: + - erlang + Name: Erlang + - Aliases: + - factor + Name: Factor + - Aliases: + - fennel + - fnl + Name: Fennel + - Aliases: + - fish + - fishshell + Name: Fish + - Aliases: + - forth + Name: Forth + - Aliases: + - fortran + - f90 + Name: Fortran + - Aliases: + - fortranfixed + Name: FortranFixed + - Aliases: + - fsharp + Name: FSharp + - Aliases: + - gas + - asm + Name: GAS + - Aliases: + - gdscript + - gd + Name: GDScript + - Aliases: + - gdscript3 + - gd3 + Name: GDScript3 + - Aliases: + - genshi + - kid + - xml+genshi + - xml+kid + Name: Genshi + - Aliases: + - html+genshi + - html+kid + Name: Genshi HTML + - Aliases: + - genshitext + Name: Genshi Text + - Aliases: + - cucumber + - Cucumber + - gherkin + - Gherkin + Name: Gherkin + - Aliases: + - gleam + Name: Gleam + - Aliases: + - glsl + Name: GLSL + - Aliases: + - gnuplot + Name: Gnuplot + - Aliases: + - go + - golang + Name: Go + - Aliases: + - go-html-template + Name: Go HTML Template + - Aliases: + - go-template + Name: Go Template + - Aliases: + - go-text-template + Name: Go Text Template + - Aliases: + - graphql + - graphqls + - gql + Name: GraphQL + - Aliases: + - groff + - nroff + - man + Name: Groff + - Aliases: + - groovy + Name: Groovy + - Aliases: + - handlebars + - hbs + Name: Handlebars + - Aliases: + - hare + Name: Hare + - Aliases: + - haskell + - hs + Name: Haskell + - Aliases: + - hx + - haxe + - hxsl + Name: Haxe + - Aliases: + - hcl + Name: HCL + - Aliases: + - hexdump + Name: Hexdump + - Aliases: + - hlb + Name: HLB + - Aliases: + - hlsl + Name: HLSL + - Aliases: + - holyc + Name: HolyC + - Aliases: + - html + Name: HTML + - Aliases: + - http + Name: HTTP + - Aliases: + - hylang + Name: Hy + - Aliases: + - idris + - idr + Name: Idris + - Aliases: + - igor + - igorpro + Name: Igor + - Aliases: + - ini + - cfg + - dosini + Name: INI + - Aliases: + - io + Name: Io + - Aliases: + - iscdhcpd + Name: ISCdhcpd + - Aliases: + - j + Name: J + - Aliases: + - java + Name: Java + - Aliases: + - js + - javascript + Name: JavaScript + - Aliases: + - json + Name: JSON + - Aliases: + - jsonata + Name: JSONata + - Aliases: + - jsonnet + Name: Jsonnet + - Aliases: + - julia + - jl + Name: Julia + - Aliases: + - jungle + Name: Jungle + - Aliases: + - kotlin + Name: Kotlin + - Aliases: + - lighty + - lighttpd + Name: Lighttpd configuration file + - Aliases: + - llvm + Name: LLVM + - Aliases: + - lua + Name: Lua + - Aliases: + - make + - makefile + - mf + - bsdmake + Name: Makefile + - Aliases: + - mako + Name: Mako + - Aliases: + - md + - mkd + Name: markdown + - Aliases: + - mason + Name: Mason + - Aliases: + - materialize + - mzsql + Name: Materialize SQL dialect + - Aliases: + - mathematica + - mma + - nb + Name: Mathematica + - Aliases: + - matlab + Name: Matlab + - Aliases: + - mcfunction + - mcf + Name: MCFunction + - Aliases: + - meson + - meson.build + Name: Meson + - Aliases: + - metal + Name: Metal + - Aliases: + - minizinc + - MZN + - mzn + Name: MiniZinc + - Aliases: + - mlir + Name: MLIR + - Aliases: + - modula2 + - m2 + Name: Modula-2 + - Aliases: + - monkeyc + Name: MonkeyC + - Aliases: + - morrowind + - mwscript + Name: MorrowindScript + - Aliases: + - myghty + Name: Myghty + - Aliases: + - mysql + - mariadb + Name: MySQL + - Aliases: + - nasm + Name: NASM + - Aliases: + - natural + Name: Natural + - Aliases: + - ndisasm + Name: NDISASM + - Aliases: + - newspeak + Name: Newspeak + - Aliases: + - nginx + Name: Nginx configuration file + - Aliases: + - nim + - nimrod + Name: Nim + - Aliases: + - nixos + - nix + Name: Nix + - Aliases: + - nsis + - nsi + - nsh + Name: NSIS + - Aliases: + - objective-c + - objectivec + - obj-c + - objc + Name: Objective-C + - Aliases: + - objectpascal + Name: ObjectPascal + - Aliases: + - ocaml + Name: OCaml + - Aliases: + - octave + Name: Octave + - Aliases: + - odin + Name: Odin + - Aliases: + - ones + - onesenterprise + - 1S + - 1S:Enterprise + Name: OnesEnterprise + - Aliases: + - openedge + - abl + - progress + - openedgeabl + Name: OpenEdge ABL + - Aliases: + - openscad + Name: OpenSCAD + - Aliases: + - org + - orgmode + Name: Org Mode + - Aliases: + - pacmanconf + Name: PacmanConf + - Aliases: + - perl + - pl + Name: Perl + - Aliases: + - php + - php3 + - php4 + - php5 + Name: PHP + - Aliases: + - phtml + Name: PHTML + - Aliases: + - pig + Name: Pig + - Aliases: + - pkgconfig + Name: PkgConfig + - Aliases: + - plpgsql + Name: PL/pgSQL + - Aliases: + - text + - plain + - no-highlight + Name: plaintext + - Aliases: + - plutus-core + - plc + Name: Plutus Core + - Aliases: + - pony + Name: Pony + - Aliases: + - postgresql + - postgres + Name: PostgreSQL SQL dialect + - Aliases: + - postscript + - postscr + Name: PostScript + - Aliases: + - pov + Name: POVRay + - Aliases: + - powerquery + - pq + Name: PowerQuery + - Aliases: + - powershell + - posh + - ps1 + - psm1 + - psd1 + - pwsh + Name: PowerShell + - Aliases: + - prolog + Name: Prolog + - Aliases: + - promela + Name: Promela + - Aliases: + - promql + Name: PromQL + - Aliases: + - java-properties + Name: properties + - Aliases: + - protobuf + - proto + Name: Protocol Buffer + - Aliases: + - prql + Name: PRQL + - Aliases: + - psl + Name: PSL + - Aliases: + - puppet + Name: Puppet + - Aliases: + - python + - py + - sage + - python3 + - py3 + Name: Python + - Aliases: + - python2 + - py2 + Name: Python 2 + - Aliases: + - qbasic + - basic + Name: QBasic + - Aliases: + - qml + - qbs + Name: QML + - Aliases: + - splus + - s + - r + Name: R + - Aliases: + - racket + - rkt + Name: Racket + - Aliases: + - ragel + Name: Ragel + - Aliases: + - perl6 + - pl6 + - raku + Name: Raku + - Aliases: + - jsx + - react + Name: react + - Aliases: + - reason + - reasonml + Name: ReasonML + - Aliases: + - registry + Name: reg + - Aliases: + - rego + Name: Rego + - Aliases: + - rst + - rest + - restructuredtext + Name: reStructuredText + - Aliases: + - rexx + - arexx + Name: Rexx + - Aliases: + - spec + Name: RPMSpec + - Aliases: + - rb + - ruby + - duby + Name: Ruby + - Aliases: + - rust + - rs + Name: Rust + - Aliases: + - sas + Name: SAS + - Aliases: + - sass + Name: Sass + - Aliases: + - scala + Name: Scala + - Aliases: + - scheme + - scm + Name: Scheme + - Aliases: + - scilab + Name: Scilab + - Aliases: + - scss + Name: SCSS + - Aliases: + - sed + - gsed + - ssed + Name: Sed + - Aliases: + - sieve + Name: Sieve + - Aliases: + - smali + Name: Smali + - Aliases: + - smalltalk + - squeak + - st + Name: Smalltalk + - Aliases: + - smarty + Name: Smarty + - Aliases: + - snbt + Name: SNBT + - Aliases: + - snobol + Name: Snobol + - Aliases: + - sol + - solidity + Name: Solidity + - Aliases: + - sp + Name: SourcePawn + - Aliases: + - sparql + Name: SPARQL + - Aliases: + - sql + Name: SQL + - Aliases: + - squidconf + - squid.conf + - squid + Name: SquidConf + - Aliases: + - sml + Name: Standard ML + - Aliases: null + Name: stas + - Aliases: + - stylus + Name: Stylus + - Aliases: + - svelte + Name: Svelte + - Aliases: + - swift + Name: Swift + - Aliases: + - systemd + Name: SYSTEMD + - Aliases: + - systemverilog + - sv + Name: systemverilog + - Aliases: + - tablegen + Name: TableGen + - Aliases: + - tal + - uxntal + Name: Tal + - Aliases: + - tasm + Name: TASM + - Aliases: + - tcl + Name: Tcl + - Aliases: + - tcsh + - csh + Name: Tcsh + - Aliases: + - termcap + Name: Termcap + - Aliases: + - terminfo + Name: Terminfo + - Aliases: + - terraform + - tf + Name: Terraform + - Aliases: + - tex + - latex + Name: TeX + - Aliases: + - thrift + Name: Thrift + - Aliases: + - toml + Name: TOML + - Aliases: + - tradingview + - tv + Name: TradingView + - Aliases: + - tsql + - t-sql + Name: Transact-SQL + - Aliases: + - turing + Name: Turing + - Aliases: + - turtle + Name: Turtle + - Aliases: + - twig + Name: Twig + - Aliases: + - ts + - tsx + - typescript + Name: TypeScript + - Aliases: + - typoscript + Name: TypoScript + - Aliases: + - typoscriptcssdata + Name: TypoScriptCssData + - Aliases: + - typoscripthtmldata + Name: TypoScriptHtmlData + - Aliases: + - typst + Name: Typst + - Aliases: null + Name: ucode + - Aliases: + - v + - vlang + Name: V + - Aliases: + - vsh + - vshell + Name: V shell + - Aliases: + - vala + - vapi + Name: Vala + - Aliases: + - vb.net + - vbnet + Name: VB.net + - Aliases: + - verilog + - v + Name: verilog + - Aliases: + - vhdl + Name: VHDL + - Aliases: + - vhs + - tape + - cassette + Name: VHS + - Aliases: + - vim + Name: VimL + - Aliases: + - vue + - vuejs + Name: vue + - Aliases: null + Name: WDTE + - Aliases: + - wgsl + Name: WebGPU Shading Language + - Aliases: + - vtt + Name: WebVTT + - Aliases: + - whiley + Name: Whiley + - Aliases: + - xml + Name: XML + - Aliases: + - xorg.conf + Name: Xorg + - Aliases: + - yaml + Name: YAML + - Aliases: + - yang + Name: YANG + - Aliases: + - z80 + Name: Z80 Assembly + - Aliases: + - zed + Name: Zed + - Aliases: + - zig + Name: Zig + styles: + - abap + - algol + - algol_nu + - arduino + - autumn + - average + - base16-snazzy + - borland + - bw + - catppuccin-frappe + - catppuccin-latte + - catppuccin-macchiato + - catppuccin-mocha + - colorful + - doom-one + - doom-one2 + - dracula + - emacs + - evergarden + - friendly + - fruity + - github + - github-dark + - gruvbox + - gruvbox-light + - hr_high_contrast + - hrdark + - igor + - lovelace + - manni + - modus-operandi + - modus-vivendi + - monokai + - monokailight + - murphy + - native + - nord + - nordic + - onedark + - onesenterprise + - paraiso-dark + - paraiso-light + - pastie + - perldoc + - pygments + - rainbow_dash + - rose-pine + - rose-pine-dawn + - rose-pine-moon + - rrt + - solarized-dark + - solarized-dark256 + - solarized-light + - swapoff + - tango + - tokyonight-day + - tokyonight-moon + - tokyonight-night + - tokyonight-storm + - trac + - vim + - vs + - vulcan + - witchhazel + - xcode + - xcode-dark +config: + HTTPCache: + cache: + for: + excludes: + - '**' + includes: null + polls: + - disable: true + for: + excludes: null + includes: + - '**' + high: 0s + low: 0s + archeTypeDir: archetypes + assetDir: assets + author: {} + baseURL: "" + build: + buildStats: + disableClasses: false + disableIDs: false + disableTags: false + enable: false + cacheBusters: + - source: (postcss|tailwind)\.config\.js + target: (css|styles|scss|sass) + noJSConfigInAssets: false + useResourceCacheWhen: fallback + buildDrafts: false + buildExpired: false + buildFuture: false + cacheDir: "" + caches: + assets: + dir: :resourceDir/_gen + maxAge: -1 + getcsv: + dir: :cacheDir/:project + maxAge: -1 + getjson: + dir: :cacheDir/:project + maxAge: -1 + getresource: + dir: :cacheDir/:project + maxAge: -1 + images: + dir: :resourceDir/_gen + maxAge: -1 + misc: + dir: :cacheDir/:project + maxAge: -1 + modules: + dir: :cacheDir/modules + maxAge: -1 + canonifyURLs: false + capitalizeListTitles: true + cascade: [] + cleanDestinationDir: false + contentDir: content + contentTypes: + text/asciidoc: {} + text/html: {} + text/markdown: {} + text/org: {} + text/pandoc: {} + text/rst: {} + copyright: "" + dataDir: data + defaultContentLanguage: en + defaultContentLanguageInSubdir: false + defaultOutputFormat: html + deployment: + confirm: false + dryRun: false + force: false + invalidateCDN: true + matchers: null + maxDeletes: 256 + order: null + target: "" + targets: null + workers: 10 + disableAliases: false + disableDefaultLanguageRedirect: false + disableHugoGeneratorInject: false + disableKinds: null + disableLanguages: null + disableLiveReload: false + disablePathToLower: false + enableEmoji: false + enableGitInfo: false + enableMissingTranslationPlaceholders: false + enableRobotsTXT: false + environment: production + frontmatter: + date: + - date + - publishdate + - pubdate + - published + - lastmod + - modified + expiryDate: + - expirydate + - unpublishdate + lastmod: + - :git + - lastmod + - modified + - date + - publishdate + - pubdate + - published + publishDate: + - publishdate + - pubdate + - published + - date + hasCJKLanguage: false + i18nDir: i18n + ignoreCache: false + ignoreFiles: [] + ignoreLogs: null + ignoreVendorPaths: "" + imaging: + bgColor: '#ffffff' + hint: photo + quality: 75 + resampleFilter: box + languageCode: "" + languages: + en: + disabled: false + languageCode: "" + languageDirection: "" + languageName: "" + title: "" + weight: 0 + layoutDir: layouts + mainSections: null + markup: + asciidocExt: + attributes: {} + backend: html5 + extensions: [] + failureLevel: fatal + noHeaderOrFooter: true + preserveTOC: false + safeMode: unsafe + sectionNumbers: false + trace: false + verbose: false + workingFolderCurrent: false + defaultMarkdownHandler: goldmark + goldmark: + duplicateResourceFiles: false + extensions: + cjk: + eastAsianLineBreaks: false + eastAsianLineBreaksStyle: simple + enable: false + escapedSpace: false + definitionList: true + extras: + delete: + enable: false + insert: + enable: false + mark: + enable: false + subscript: + enable: false + superscript: + enable: false + footnote: true + linkify: true + linkifyProtocol: https + passthrough: + delimiters: + block: [] + inline: [] + enable: false + strikethrough: true + table: true + taskList: true + typographer: + apostrophe: '’' + disable: false + ellipsis: '…' + emDash: '—' + enDash: '–' + leftAngleQuote: '«' + leftDoubleQuote: '“' + leftSingleQuote: '‘' + rightAngleQuote: '»' + rightDoubleQuote: '”' + rightSingleQuote: '’' + parser: + attribute: + block: false + title: true + autoDefinitionTermID: false + autoHeadingID: true + autoIDType: github + wrapStandAloneImageWithinParagraph: true + renderHooks: + image: + enableDefault: false + link: + enableDefault: false + renderer: + hardWraps: false + unsafe: false + xhtml: false + highlight: + anchorLineNos: false + codeFences: true + guessSyntax: false + hl_Lines: "" + hl_inline: false + lineAnchors: "" + lineNoStart: 1 + lineNos: false + lineNumbersInTable: true + noClasses: true + style: monokai + tabWidth: 4 + wrapperClass: highlight + tableOfContents: + endLevel: 3 + ordered: false + startLevel: 2 + mediaTypes: + application/json: + delimiter: . + suffixes: + - json + application/manifest+json: + delimiter: . + suffixes: + - webmanifest + application/octet-stream: + delimiter: . + application/pdf: + delimiter: . + suffixes: + - pdf + application/rss+xml: + delimiter: . + suffixes: + - xml + - rss + application/toml: + delimiter: . + suffixes: + - toml + application/wasm: + delimiter: . + suffixes: + - wasm + application/xml: + delimiter: . + suffixes: + - xml + application/yaml: + delimiter: . + suffixes: + - yaml + - yml + font/otf: + delimiter: . + suffixes: + - otf + font/ttf: + delimiter: . + suffixes: + - ttf + image/bmp: + delimiter: . + suffixes: + - bmp + image/gif: + delimiter: . + suffixes: + - gif + image/jpeg: + delimiter: . + suffixes: + - jpg + - jpeg + - jpe + - jif + - jfif + image/png: + delimiter: . + suffixes: + - png + image/svg+xml: + delimiter: . + suffixes: + - svg + image/tiff: + delimiter: . + suffixes: + - tif + - tiff + image/webp: + delimiter: . + suffixes: + - webp + text/asciidoc: + delimiter: . + suffixes: + - adoc + - asciidoc + - ad + text/calendar: + delimiter: . + suffixes: + - ics + text/css: + delimiter: . + suffixes: + - css + text/csv: + delimiter: . + suffixes: + - csv + text/html: + delimiter: . + suffixes: + - html + - htm + text/javascript: + delimiter: . + suffixes: + - js + - jsm + - mjs + text/jsx: + delimiter: . + suffixes: + - jsx + text/markdown: + delimiter: . + suffixes: + - md + - mdown + - markdown + text/org: + delimiter: . + suffixes: + - org + text/pandoc: + delimiter: . + suffixes: + - pandoc + - pdc + text/plain: + delimiter: . + suffixes: + - txt + text/rst: + delimiter: . + suffixes: + - rst + text/tsx: + delimiter: . + suffixes: + - tsx + text/typescript: + delimiter: . + suffixes: + - ts + text/x-sass: + delimiter: . + suffixes: + - sass + text/x-scss: + delimiter: . + suffixes: + - scss + video/3gpp: + delimiter: . + suffixes: + - 3gpp + - 3gp + video/mp4: + delimiter: . + suffixes: + - mp4 + video/mpeg: + delimiter: . + suffixes: + - mpg + - mpeg + video/ogg: + delimiter: . + suffixes: + - ogv + video/webm: + delimiter: . + suffixes: + - webm + video/x-msvideo: + delimiter: . + suffixes: + - avi + menus: {} + minify: + disableCSS: false + disableHTML: false + disableJS: false + disableJSON: false + disableSVG: false + disableXML: false + minifyOutput: false + tdewolff: + css: + inline: false + keepCSS2: true + precision: 0 + html: + keepComments: false + keepConditionalComments: false + keepDefaultAttrVals: true + keepDocumentTags: true + keepEndTags: true + keepQuotes: false + keepSpecialComments: true + keepWhitespace: false + templateDelims: + - "" + - "" + js: + keepVarNames: false + precision: 0 + version: 2022 + json: + keepNumbers: false + precision: 0 + svg: + inline: false + keepComments: false + precision: 0 + xml: + keepWhitespace: false + module: + auth: "" + hugoVersion: + extended: false + max: "" + min: "" + imports: null + mounts: + - disableWatch: false + excludeFiles: null + includeFiles: null + lang: "" + source: content + target: content + - disableWatch: false + excludeFiles: null + includeFiles: null + lang: "" + source: data + target: data + - disableWatch: false + excludeFiles: null + includeFiles: null + lang: "" + source: layouts + target: layouts + - disableWatch: false + excludeFiles: null + includeFiles: null + lang: "" + source: i18n + target: i18n + - disableWatch: false + excludeFiles: null + includeFiles: null + lang: "" + source: archetypes + target: archetypes + - disableWatch: false + excludeFiles: null + includeFiles: null + lang: "" + source: assets + target: assets + - disableWatch: false + excludeFiles: null + includeFiles: null + lang: "" + source: static + target: static + noProxy: none + noVendor: "" + params: null + private: '*.*' + proxy: direct + replacements: null + vendorClosest: false + workspace: "off" + newContentEditor: "" + noBuildLock: false + noChmod: false + noTimes: false + outputFormats: + amp: + baseName: index + isHTML: true + isPlainText: false + mediaType: text/html + noUgly: false + notAlternative: false + path: amp + permalinkable: true + protocol: "" + rel: amphtml + root: false + ugly: false + weight: 0 + calendar: + baseName: index + isHTML: false + isPlainText: true + mediaType: text/calendar + noUgly: false + notAlternative: false + path: "" + permalinkable: false + protocol: webcal:// + rel: alternate + root: false + ugly: false + weight: 0 + css: + baseName: styles + isHTML: false + isPlainText: true + mediaType: text/css + noUgly: false + notAlternative: true + path: "" + permalinkable: false + protocol: "" + rel: stylesheet + root: false + ugly: false + weight: 0 + csv: + baseName: index + isHTML: false + isPlainText: true + mediaType: text/csv + noUgly: false + notAlternative: false + path: "" + permalinkable: false + protocol: "" + rel: alternate + root: false + ugly: false + weight: 0 + html: + baseName: index + isHTML: true + isPlainText: false + mediaType: text/html + noUgly: false + notAlternative: false + path: "" + permalinkable: true + protocol: "" + rel: canonical + root: false + ugly: false + weight: 10 + json: + baseName: index + isHTML: false + isPlainText: true + mediaType: application/json + noUgly: false + notAlternative: false + path: "" + permalinkable: false + protocol: "" + rel: alternate + root: false + ugly: false + weight: 0 + markdown: + baseName: index + isHTML: false + isPlainText: true + mediaType: text/markdown + noUgly: false + notAlternative: false + path: "" + permalinkable: false + protocol: "" + rel: alternate + root: false + ugly: false + weight: 0 + robots: + baseName: robots + isHTML: false + isPlainText: true + mediaType: text/plain + noUgly: false + notAlternative: false + path: "" + permalinkable: false + protocol: "" + rel: alternate + root: true + ugly: false + weight: 0 + rss: + baseName: index + isHTML: false + isPlainText: false + mediaType: application/rss+xml + noUgly: true + notAlternative: false + path: "" + permalinkable: false + protocol: "" + rel: alternate + root: false + ugly: false + weight: 0 + sitemap: + baseName: sitemap + isHTML: false + isPlainText: false + mediaType: application/xml + noUgly: false + notAlternative: false + path: "" + permalinkable: false + protocol: "" + rel: sitemap + root: false + ugly: true + weight: 0 + webappmanifest: + baseName: manifest + isHTML: false + isPlainText: true + mediaType: application/manifest+json + noUgly: false + notAlternative: true + path: "" + permalinkable: false + protocol: "" + rel: manifest + root: false + ugly: false + weight: 0 + outputs: + home: + - html + - rss + page: + - html + rss: + - rss + section: + - html + - rss + taxonomy: + - html + - rss + term: + - html + - rss + page: + nextPrevInSectionSortOrder: desc + nextPrevSortOrder: desc + paginate: 0 + paginatePath: "" + pagination: + disableAliases: false + pagerSize: 10 + path: page + panicOnWarning: false + params: {} + permalinks: + page: {} + section: {} + taxonomy: {} + term: {} + pluralizeListTitles: true + printI18nWarnings: false + printPathWarnings: false + printUnusedTemplates: false + privacy: + disqus: + disable: false + googleAnalytics: + disable: false + respectDoNotTrack: false + instagram: + disable: false + simple: false + twitter: + disable: false + enableDNT: false + simple: false + vimeo: + disable: false + enableDNT: false + simple: false + x: + disable: false + enableDNT: false + simple: false + youTube: + disable: false + privacyEnhanced: false + publishDir: public + refLinksErrorLevel: "" + refLinksNotFoundURL: "" + related: + includeNewer: false + indices: + - applyFilter: false + cardinalityThreshold: 0 + name: keywords + pattern: "" + toLower: false + type: basic + weight: 100 + - applyFilter: false + cardinalityThreshold: 0 + name: date + pattern: "" + toLower: false + type: basic + weight: 10 + - applyFilter: false + cardinalityThreshold: 0 + name: tags + pattern: "" + toLower: false + type: basic + weight: 80 + threshold: 80 + toLower: false + relativeURLs: false + removePathAccents: false + renderSegments: null + resourceDir: resources + sectionPagesMenu: "" + security: + enableInlineShortcodes: false + exec: + allow: + - ^(dart-)?sass(-embedded)?$ + - ^go$ + - ^git$ + - ^npx$ + - ^postcss$ + - ^tailwindcss$ + osEnv: + - (?i)^((HTTPS?|NO)_PROXY|PATH(EXT)?|APPDATA|TE?MP|TERM|GO\w+|(XDG_CONFIG_)?HOME|USERPROFILE|SSH_AUTH_SOCK|DISPLAY|LANG|SYSTEMDRIVE)$ + funcs: + getenv: + - ^HUGO_ + - ^CI$ + http: + mediaTypes: null + methods: + - (?i)GET|POST + urls: + - .* + segments: {} + server: + headers: null + redirects: + - force: false + from: /** + fromHeaders: null + fromRe: "" + status: 404 + to: /404.html + services: + disqus: + shortname: "" + googleAnalytics: + id: "" + instagram: + accessToken: "" + disableInlineCSS: false + rss: + limit: -1 + twitter: + disableInlineCSS: false + x: + disableInlineCSS: false + sitemap: + changeFreq: "" + disable: false + filename: sitemap.xml + priority: -1 + social: null + staticDir: + - static + staticDir0: null + staticDir1: null + staticDir2: null + staticDir3: null + staticDir4: null + staticDir5: null + staticDir6: null + staticDir7: null + staticDir8: null + staticDir9: null + staticDir10: null + summaryLength: 70 + taxonomies: + category: categories + tag: tags + templateMetrics: false + templateMetricsHints: false + theme: null + themesDir: themes + timeZone: "" + timeout: 30s + title: "" + titleCaseStyle: AP + uglyURLs: false + workingDir: "" +config_helpers: + mergeStrategy: + build: + _merge: none + caches: + _merge: none + cascade: + _merge: none + contenttypes: + _merge: none + deployment: + _merge: none + frontmatter: + _merge: none + httpcache: + _merge: none + imaging: + _merge: none + languages: + _merge: none + en: + _merge: none + menus: + _merge: shallow + params: + _merge: deep + markup: + _merge: none + mediatypes: + _merge: shallow + menus: + _merge: shallow + minify: + _merge: none + module: + _merge: none + outputformats: + _merge: shallow + outputs: + _merge: none + page: + _merge: none + pagination: + _merge: none + params: + _merge: deep + permalinks: + _merge: none + privacy: + _merge: none + related: + _merge: none + security: + _merge: none + segments: + _merge: none + server: + _merge: none + services: + _merge: none + sitemap: + _merge: none + taxonomies: + _merge: none +output: + layouts: + - Example: Single page in "posts" section + Kind: page + OutputFormat: html + Suffix: html + Template Lookup Order: + - layouts/posts/single.html.html + - layouts/posts/single.html + - layouts/_default/single.html.html + - layouts/_default/single.html + - Example: Base template for single page in "posts" section + Kind: page + OutputFormat: html + Suffix: html + Template Lookup Order: + - layouts/posts/single-baseof.html.html + - layouts/posts/baseof.html.html + - layouts/posts/single-baseof.html + - layouts/posts/baseof.html + - layouts/_default/single-baseof.html.html + - layouts/_default/baseof.html.html + - layouts/_default/single-baseof.html + - layouts/_default/baseof.html + - Example: Single page in "posts" section with layout set to "demolayout" + Kind: page + OutputFormat: html + Suffix: html + Template Lookup Order: + - layouts/posts/demolayout.html.html + - layouts/posts/single.html.html + - layouts/posts/demolayout.html + - layouts/posts/single.html + - layouts/_default/demolayout.html.html + - layouts/_default/single.html.html + - layouts/_default/demolayout.html + - layouts/_default/single.html + - Example: Base template for single page in "posts" section with layout set to "demolayout" + Kind: page + OutputFormat: html + Suffix: html + Template Lookup Order: + - layouts/posts/demolayout-baseof.html.html + - layouts/posts/single-baseof.html.html + - layouts/posts/baseof.html.html + - layouts/posts/demolayout-baseof.html + - layouts/posts/single-baseof.html + - layouts/posts/baseof.html + - layouts/_default/demolayout-baseof.html.html + - layouts/_default/single-baseof.html.html + - layouts/_default/baseof.html.html + - layouts/_default/demolayout-baseof.html + - layouts/_default/single-baseof.html + - layouts/_default/baseof.html + - Example: AMP single page in "posts" section + Kind: page + OutputFormat: amp + Suffix: html + Template Lookup Order: + - layouts/posts/single.amp.html + - layouts/posts/single.html + - layouts/_default/single.amp.html + - layouts/_default/single.html + - Example: AMP single page in "posts" section, French language + Kind: page + OutputFormat: amp + Suffix: html + Template Lookup Order: + - layouts/posts/single.fr.amp.html + - layouts/posts/single.amp.html + - layouts/posts/single.fr.html + - layouts/posts/single.html + - layouts/_default/single.fr.amp.html + - layouts/_default/single.amp.html + - layouts/_default/single.fr.html + - layouts/_default/single.html + - Example: Home page + Kind: home + OutputFormat: html + Suffix: html + Template Lookup Order: + - layouts/index.html.html + - layouts/home.html.html + - layouts/list.html.html + - layouts/index.html + - layouts/home.html + - layouts/list.html + - layouts/_default/index.html.html + - layouts/_default/home.html.html + - layouts/_default/list.html.html + - layouts/_default/index.html + - layouts/_default/home.html + - layouts/_default/list.html + - Example: Base template for home page + Kind: home + OutputFormat: html + Suffix: html + Template Lookup Order: + - layouts/index-baseof.html.html + - layouts/home-baseof.html.html + - layouts/list-baseof.html.html + - layouts/baseof.html.html + - layouts/index-baseof.html + - layouts/home-baseof.html + - layouts/list-baseof.html + - layouts/baseof.html + - layouts/_default/index-baseof.html.html + - layouts/_default/home-baseof.html.html + - layouts/_default/list-baseof.html.html + - layouts/_default/baseof.html.html + - layouts/_default/index-baseof.html + - layouts/_default/home-baseof.html + - layouts/_default/list-baseof.html + - layouts/_default/baseof.html + - Example: Home page with type set to "demotype" + Kind: home + OutputFormat: html + Suffix: html + Template Lookup Order: + - layouts/demotype/index.html.html + - layouts/demotype/home.html.html + - layouts/demotype/list.html.html + - layouts/demotype/index.html + - layouts/demotype/home.html + - layouts/demotype/list.html + - layouts/index.html.html + - layouts/home.html.html + - layouts/list.html.html + - layouts/index.html + - layouts/home.html + - layouts/list.html + - layouts/_default/index.html.html + - layouts/_default/home.html.html + - layouts/_default/list.html.html + - layouts/_default/index.html + - layouts/_default/home.html + - layouts/_default/list.html + - Example: Base template for home page with type set to "demotype" + Kind: home + OutputFormat: html + Suffix: html + Template Lookup Order: + - layouts/demotype/index-baseof.html.html + - layouts/demotype/home-baseof.html.html + - layouts/demotype/list-baseof.html.html + - layouts/demotype/baseof.html.html + - layouts/demotype/index-baseof.html + - layouts/demotype/home-baseof.html + - layouts/demotype/list-baseof.html + - layouts/demotype/baseof.html + - layouts/index-baseof.html.html + - layouts/home-baseof.html.html + - layouts/list-baseof.html.html + - layouts/baseof.html.html + - layouts/index-baseof.html + - layouts/home-baseof.html + - layouts/list-baseof.html + - layouts/baseof.html + - layouts/_default/index-baseof.html.html + - layouts/_default/home-baseof.html.html + - layouts/_default/list-baseof.html.html + - layouts/_default/baseof.html.html + - layouts/_default/index-baseof.html + - layouts/_default/home-baseof.html + - layouts/_default/list-baseof.html + - layouts/_default/baseof.html + - Example: Home page with layout set to "demolayout" + Kind: home + OutputFormat: html + Suffix: html + Template Lookup Order: + - layouts/demolayout.html.html + - layouts/index.html.html + - layouts/home.html.html + - layouts/list.html.html + - layouts/demolayout.html + - layouts/index.html + - layouts/home.html + - layouts/list.html + - layouts/_default/demolayout.html.html + - layouts/_default/index.html.html + - layouts/_default/home.html.html + - layouts/_default/list.html.html + - layouts/_default/demolayout.html + - layouts/_default/index.html + - layouts/_default/home.html + - layouts/_default/list.html + - Example: AMP home, French language + Kind: home + OutputFormat: amp + Suffix: html + Template Lookup Order: + - layouts/index.fr.amp.html + - layouts/home.fr.amp.html + - layouts/list.fr.amp.html + - layouts/index.amp.html + - layouts/home.amp.html + - layouts/list.amp.html + - layouts/index.fr.html + - layouts/home.fr.html + - layouts/list.fr.html + - layouts/index.html + - layouts/home.html + - layouts/list.html + - layouts/_default/index.fr.amp.html + - layouts/_default/home.fr.amp.html + - layouts/_default/list.fr.amp.html + - layouts/_default/index.amp.html + - layouts/_default/home.amp.html + - layouts/_default/list.amp.html + - layouts/_default/index.fr.html + - layouts/_default/home.fr.html + - layouts/_default/list.fr.html + - layouts/_default/index.html + - layouts/_default/home.html + - layouts/_default/list.html + - Example: JSON home + Kind: home + OutputFormat: json + Suffix: json + Template Lookup Order: + - layouts/index.json.json + - layouts/home.json.json + - layouts/list.json.json + - layouts/index.json + - layouts/home.json + - layouts/list.json + - layouts/_default/index.json.json + - layouts/_default/home.json.json + - layouts/_default/list.json.json + - layouts/_default/index.json + - layouts/_default/home.json + - layouts/_default/list.json + - Example: RSS home + Kind: home + OutputFormat: rss + Suffix: xml + Template Lookup Order: + - layouts/index.rss.xml + - layouts/home.rss.xml + - layouts/rss.xml + - layouts/list.rss.xml + - layouts/index.xml + - layouts/home.xml + - layouts/list.xml + - layouts/_default/index.rss.xml + - layouts/_default/home.rss.xml + - layouts/_default/rss.xml + - layouts/_default/list.rss.xml + - layouts/_default/index.xml + - layouts/_default/home.xml + - layouts/_default/list.xml + - layouts/_internal/_default/rss.xml + - Example: Section list for "posts" + Kind: section + OutputFormat: html + Suffix: html + Template Lookup Order: + - layouts/posts/posts.html.html + - layouts/posts/section.html.html + - layouts/posts/list.html.html + - layouts/posts/posts.html + - layouts/posts/section.html + - layouts/posts/list.html + - layouts/section/posts.html.html + - layouts/section/section.html.html + - layouts/section/list.html.html + - layouts/section/posts.html + - layouts/section/section.html + - layouts/section/list.html + - layouts/_default/posts.html.html + - layouts/_default/section.html.html + - layouts/_default/list.html.html + - layouts/_default/posts.html + - layouts/_default/section.html + - layouts/_default/list.html + - Example: Section list for "posts" with type set to "blog" + Kind: section + OutputFormat: html + Suffix: html + Template Lookup Order: + - layouts/blog/posts.html.html + - layouts/blog/section.html.html + - layouts/blog/list.html.html + - layouts/blog/posts.html + - layouts/blog/section.html + - layouts/blog/list.html + - layouts/posts/posts.html.html + - layouts/posts/section.html.html + - layouts/posts/list.html.html + - layouts/posts/posts.html + - layouts/posts/section.html + - layouts/posts/list.html + - layouts/section/posts.html.html + - layouts/section/section.html.html + - layouts/section/list.html.html + - layouts/section/posts.html + - layouts/section/section.html + - layouts/section/list.html + - layouts/_default/posts.html.html + - layouts/_default/section.html.html + - layouts/_default/list.html.html + - layouts/_default/posts.html + - layouts/_default/section.html + - layouts/_default/list.html + - Example: Section list for "posts" with layout set to "demolayout" + Kind: section + OutputFormat: html + Suffix: html + Template Lookup Order: + - layouts/posts/demolayout.html.html + - layouts/posts/posts.html.html + - layouts/posts/section.html.html + - layouts/posts/list.html.html + - layouts/posts/demolayout.html + - layouts/posts/posts.html + - layouts/posts/section.html + - layouts/posts/list.html + - layouts/section/demolayout.html.html + - layouts/section/posts.html.html + - layouts/section/section.html.html + - layouts/section/list.html.html + - layouts/section/demolayout.html + - layouts/section/posts.html + - layouts/section/section.html + - layouts/section/list.html + - layouts/_default/demolayout.html.html + - layouts/_default/posts.html.html + - layouts/_default/section.html.html + - layouts/_default/list.html.html + - layouts/_default/demolayout.html + - layouts/_default/posts.html + - layouts/_default/section.html + - layouts/_default/list.html + - Example: Section list for "posts" + Kind: section + OutputFormat: rss + Suffix: xml + Template Lookup Order: + - layouts/posts/section.rss.xml + - layouts/posts/rss.xml + - layouts/posts/list.rss.xml + - layouts/posts/section.xml + - layouts/posts/list.xml + - layouts/section/section.rss.xml + - layouts/section/rss.xml + - layouts/section/list.rss.xml + - layouts/section/section.xml + - layouts/section/list.xml + - layouts/_default/section.rss.xml + - layouts/_default/rss.xml + - layouts/_default/list.rss.xml + - layouts/_default/section.xml + - layouts/_default/list.xml + - layouts/_internal/_default/rss.xml + - Example: Taxonomy list for "categories" + Kind: taxonomy + OutputFormat: html + Suffix: html + Template Lookup Order: + - layouts/categories/category.terms.html.html + - layouts/categories/terms.html.html + - layouts/categories/taxonomy.html.html + - layouts/categories/list.html.html + - layouts/categories/category.terms.html + - layouts/categories/terms.html + - layouts/categories/taxonomy.html + - layouts/categories/list.html + - layouts/category/category.terms.html.html + - layouts/category/terms.html.html + - layouts/category/taxonomy.html.html + - layouts/category/list.html.html + - layouts/category/category.terms.html + - layouts/category/terms.html + - layouts/category/taxonomy.html + - layouts/category/list.html + - layouts/taxonomy/category.terms.html.html + - layouts/taxonomy/terms.html.html + - layouts/taxonomy/taxonomy.html.html + - layouts/taxonomy/list.html.html + - layouts/taxonomy/category.terms.html + - layouts/taxonomy/terms.html + - layouts/taxonomy/taxonomy.html + - layouts/taxonomy/list.html + - layouts/_default/category.terms.html.html + - layouts/_default/terms.html.html + - layouts/_default/taxonomy.html.html + - layouts/_default/list.html.html + - layouts/_default/category.terms.html + - layouts/_default/terms.html + - layouts/_default/taxonomy.html + - layouts/_default/list.html + - Example: Taxonomy list for "categories" + Kind: taxonomy + OutputFormat: rss + Suffix: xml + Template Lookup Order: + - layouts/categories/category.terms.rss.xml + - layouts/categories/terms.rss.xml + - layouts/categories/taxonomy.rss.xml + - layouts/categories/rss.xml + - layouts/categories/list.rss.xml + - layouts/categories/category.terms.xml + - layouts/categories/terms.xml + - layouts/categories/taxonomy.xml + - layouts/categories/list.xml + - layouts/category/category.terms.rss.xml + - layouts/category/terms.rss.xml + - layouts/category/taxonomy.rss.xml + - layouts/category/rss.xml + - layouts/category/list.rss.xml + - layouts/category/category.terms.xml + - layouts/category/terms.xml + - layouts/category/taxonomy.xml + - layouts/category/list.xml + - layouts/taxonomy/category.terms.rss.xml + - layouts/taxonomy/terms.rss.xml + - layouts/taxonomy/taxonomy.rss.xml + - layouts/taxonomy/rss.xml + - layouts/taxonomy/list.rss.xml + - layouts/taxonomy/category.terms.xml + - layouts/taxonomy/terms.xml + - layouts/taxonomy/taxonomy.xml + - layouts/taxonomy/list.xml + - layouts/_default/category.terms.rss.xml + - layouts/_default/terms.rss.xml + - layouts/_default/taxonomy.rss.xml + - layouts/_default/rss.xml + - layouts/_default/list.rss.xml + - layouts/_default/category.terms.xml + - layouts/_default/terms.xml + - layouts/_default/taxonomy.xml + - layouts/_default/list.xml + - layouts/_internal/_default/rss.xml + - Example: Term list for "categories" + Kind: term + OutputFormat: html + Suffix: html + Template Lookup Order: + - layouts/categories/term.html.html + - layouts/categories/category.html.html + - layouts/categories/taxonomy.html.html + - layouts/categories/list.html.html + - layouts/categories/term.html + - layouts/categories/category.html + - layouts/categories/taxonomy.html + - layouts/categories/list.html + - layouts/term/term.html.html + - layouts/term/category.html.html + - layouts/term/taxonomy.html.html + - layouts/term/list.html.html + - layouts/term/term.html + - layouts/term/category.html + - layouts/term/taxonomy.html + - layouts/term/list.html + - layouts/taxonomy/term.html.html + - layouts/taxonomy/category.html.html + - layouts/taxonomy/taxonomy.html.html + - layouts/taxonomy/list.html.html + - layouts/taxonomy/term.html + - layouts/taxonomy/category.html + - layouts/taxonomy/taxonomy.html + - layouts/taxonomy/list.html + - layouts/category/term.html.html + - layouts/category/category.html.html + - layouts/category/taxonomy.html.html + - layouts/category/list.html.html + - layouts/category/term.html + - layouts/category/category.html + - layouts/category/taxonomy.html + - layouts/category/list.html + - layouts/_default/term.html.html + - layouts/_default/category.html.html + - layouts/_default/taxonomy.html.html + - layouts/_default/list.html.html + - layouts/_default/term.html + - layouts/_default/category.html + - layouts/_default/taxonomy.html + - layouts/_default/list.html + - Example: Term list for "categories" + Kind: term + OutputFormat: rss + Suffix: xml + Template Lookup Order: + - layouts/categories/term.rss.xml + - layouts/categories/category.rss.xml + - layouts/categories/taxonomy.rss.xml + - layouts/categories/rss.xml + - layouts/categories/list.rss.xml + - layouts/categories/term.xml + - layouts/categories/category.xml + - layouts/categories/taxonomy.xml + - layouts/categories/list.xml + - layouts/term/term.rss.xml + - layouts/term/category.rss.xml + - layouts/term/taxonomy.rss.xml + - layouts/term/rss.xml + - layouts/term/list.rss.xml + - layouts/term/term.xml + - layouts/term/category.xml + - layouts/term/taxonomy.xml + - layouts/term/list.xml + - layouts/taxonomy/term.rss.xml + - layouts/taxonomy/category.rss.xml + - layouts/taxonomy/taxonomy.rss.xml + - layouts/taxonomy/rss.xml + - layouts/taxonomy/list.rss.xml + - layouts/taxonomy/term.xml + - layouts/taxonomy/category.xml + - layouts/taxonomy/taxonomy.xml + - layouts/taxonomy/list.xml + - layouts/category/term.rss.xml + - layouts/category/category.rss.xml + - layouts/category/taxonomy.rss.xml + - layouts/category/rss.xml + - layouts/category/list.rss.xml + - layouts/category/term.xml + - layouts/category/category.xml + - layouts/category/taxonomy.xml + - layouts/category/list.xml + - layouts/_default/term.rss.xml + - layouts/_default/category.rss.xml + - layouts/_default/taxonomy.rss.xml + - layouts/_default/rss.xml + - layouts/_default/list.rss.xml + - layouts/_default/term.xml + - layouts/_default/category.xml + - layouts/_default/taxonomy.xml + - layouts/_default/list.xml + - layouts/_internal/_default/rss.xml +tpl: + funcs: + cast: + ToFloat: + Aliases: + - float + Args: + - v + Description: ToFloat converts v to a float. + Examples: + - - '{{ "1234" | float | printf "%T" }}' + - float64 + ToInt: + Aliases: + - int + Args: + - v + Description: ToInt converts v to an int. + Examples: + - - '{{ "1234" | int | printf "%T" }}' + - int + ToString: + Aliases: + - string + Args: + - v + Description: ToString converts v to a string. + Examples: + - - '{{ 1234 | string | printf "%T" }}' + - string + collections: + After: + Aliases: + - after + Args: + - "n" + - l + Description: After returns all the items after the first n items in list l. + Examples: [] + Append: + Aliases: + - append + Args: + - args + Description: "Append appends args up to the last one to the slice in the last + argument.\nThis construct allows template constructs like this:\n\n\t{{ + $pages = $pages | append $p2 $p1 }}\n\nNote that with 2 arguments where + both are slices of the same type,\nthe first slice will be appended to the + second:\n\n\t{{ $pages = $pages | append .Site.RegularPages }}" + Examples: [] + Apply: + Aliases: + - apply + Args: + - ctx + - c + - fname + - args + Description: Apply takes an array or slice c and returns a new slice with + the function fname applied over it. + Examples: [] + Complement: + Aliases: + - complement + Args: + - ls + Description: "Complement gives the elements in the last element of ls that + are not in\nany of the others.\n\nAll elements of ls must be slices or arrays + of comparable types.\n\nThe reasoning behind this rather clumsy API is so + we can do this in the templates:\n\n\t{{ $c := .Pages | complement $last4 + }}" + Examples: + - - '{{ slice "a" "b" "c" "d" "e" "f" | complement (slice "b" "c") (slice + "d" "e") }}' + - '[a f]' + Delimit: + Aliases: + - delimit + Args: + - ctx + - l + - sep + - last + Description: |- + Delimit takes a given list l and returns a string delimited by sep. + If last is passed to the function, it will be used as the final delimiter. + Examples: + - - '{{ delimit (slice "A" "B" "C") ", " " and " }}' + - A, B and C + Dictionary: + Aliases: + - dict + Args: + - values + Description: |- + Dictionary creates a new map from the given parameters by + treating values as key-value pairs. The number of values must be even. + The keys can be string slices, which will create the needed nested structure. + Examples: [] + First: + Aliases: + - first + Args: + - limit + - l + Description: First returns the first limit items in list l. + Examples: [] + Group: + Aliases: + - group + Args: + - key + - items + Description: |- + Group groups a set of items by the given key. + This is currently only supported for Pages. + Examples: [] + In: + Aliases: + - in + Args: + - l + - v + Description: In returns whether v is in the list l. l may be an array or + slice. + Examples: + - - '{{ if in "this string contains a substring" "substring" }}Substring found!{{ + end }}' + - Substring found! + Index: + Aliases: + - index + Args: + - item + - args + Description: |- + Index returns the result of indexing its first argument by the following + arguments. Thus "index x 1 2 3" is, in Go syntax, x[1][2][3]. Each + indexed item must be a map, slice, or array. + + Adapted from Go stdlib src/text/template/funcs.go. + + We deviate from the stdlib mostly because of https://github.com/golang/go/issues/14751. + Examples: [] + Intersect: + Aliases: + - intersect + Args: + - l1 + - l2 + Description: |- + Intersect returns the common elements in the given sets, l1 and l2. l1 and + l2 must be of the same type and may be either arrays or slices. + Examples: [] + IsSet: + Aliases: + - isSet + - isset + Args: + - c + - key + Description: |- + IsSet returns whether a given array, channel, slice, or map in c has the given key + defined. + Examples: [] + KeyVals: + Aliases: + - keyVals + Args: + - key + - values + Description: KeyVals creates a key and values wrapper. + Examples: + - - '{{ keyVals "key" "a" "b" }}' + - 'key: [a b]' + Last: + Aliases: + - last + Args: + - limit + - l + Description: Last returns the last limit items in the list l. + Examples: [] + Merge: + Aliases: + - merge + Args: + - params + Description: |- + Merge creates a copy of the final parameter in params and merges the preceding + parameters into it in reverse order. + + Currently only maps are supported. Key handling is case insensitive. + Examples: + - - '{{ dict "title" "Hugo Rocks!" | collections.Merge (dict "title" "Default + Title" "description" "Yes, Hugo Rocks!") | sort }}' + - '[Yes, Hugo Rocks! Hugo Rocks!]' + - - '{{ merge (dict "title" "Default Title" "description" "Yes, Hugo Rocks!") + (dict "title" "Hugo Rocks!") | sort }}' + - '[Yes, Hugo Rocks! Hugo Rocks!]' + - - '{{ merge (dict "title" "Default Title" "description" "Yes, Hugo Rocks!") + (dict "title" "Hugo Rocks!") (dict "extra" "For reals!") | sort }}' + - '[Yes, Hugo Rocks! For reals! Hugo Rocks!]' + NewScratch: + Aliases: + - newScratch + Args: null + Description: |- + NewScratch creates a new Scratch which can be used to store values in a + thread safe way. + Examples: + - - '{{ $scratch := newScratch }}{{ $scratch.Add "b" 2 }}{{ $scratch.Add "b" + 2 }}{{ $scratch.Get "b" }}' + - "4" + Querify: + Aliases: + - querify + Args: + - params + Description: |- + Querify returns a URL query string composed of the given key-value pairs, + encoded and sorted by key. + Examples: + - - '{{ (querify "foo" 1 "bar" 2 "baz" "with spaces" "qux" "this&that=those") + | safeHTML }}' + - bar=2&baz=with+spaces&foo=1&qux=this%26that%3Dthose + - - Search + - Search + - - '{{ slice "foo" 1 "bar" 2 | querify | safeHTML }}' + - bar=2&foo=1 + Reverse: + Aliases: null + Args: null + Description: "" + Examples: null + Seq: + Aliases: + - seq + Args: + - args + Description: "Seq creates a sequence of integers from args. It's named and + used as GNU's seq.\n\nExamples:\n\n\t3 => 1, 2, 3\n\t1 2 4 => 1, 3\n\t-3 + => -1, -2, -3\n\t1 4 => 1, 2, 3, 4\n\t1 -2 => 1, 0, -1, -2" + Examples: + - - '{{ seq 3 }}' + - '[1 2 3]' + Shuffle: + Aliases: + - shuffle + Args: + - l + Description: Shuffle returns list l in a randomized order. + Examples: [] + Slice: + Aliases: + - slice + Args: + - args + Description: Slice returns a slice of all passed arguments. + Examples: + - - '{{ slice "B" "C" "A" | sort }}' + - '[A B C]' + Sort: + Aliases: + - sort + Args: + - ctx + - l + - args + Description: Sort returns a sorted copy of the list l. + Examples: [] + SymDiff: + Aliases: + - symdiff + Args: + - s2 + - s1 + Description: |- + SymDiff returns the symmetric difference of s1 and s2. + Arguments must be either a slice or an array of comparable types. + Examples: + - - '{{ slice 1 2 3 | symdiff (slice 3 4) }}' + - '[1 2 4]' + Union: + Aliases: + - union + Args: + - l1 + - l2 + Description: |- + Union returns the union of the given sets, l1 and l2. l1 and + l2 must be of the same type and may be either arrays or slices. + If l1 and l2 aren't of the same type then l1 will be returned. + If either l1 or l2 is nil then the non-nil list will be returned. + Examples: + - - '{{ union (slice 1 2 3) (slice 3 4 5) }}' + - '[1 2 3 4 5]' + Uniq: + Aliases: + - uniq + Args: + - l + Description: Uniq returns a new list with duplicate elements in the list l + removed. + Examples: + - - '{{ slice 1 2 3 2 | uniq }}' + - '[1 2 3]' + Where: + Aliases: + - where + Args: + - ctx + - c + - key + - args + Description: Where returns a filtered subset of collection c. + Examples: [] + compare: + Conditional: + Aliases: + - cond + Args: + - cond + - v1 + - v2 + Description: |- + Conditional can be used as a ternary operator. + + It returns v1 if cond is true, else v2. + Examples: + - - '{{ cond (eq (add 2 2) 4) "2+2 is 4" "what?" | safeHTML }}' + - 2+2 is 4 + Default: + Aliases: + - default + Args: + - defaultv + - givenv + Description: |- + Default checks whether a givenv is set and returns the default value defaultv if it + is not. "Set" in this context means non-zero for numeric types and times; + non-zero length for strings, arrays, slices, and maps; + any boolean or struct value; or non-nil for any other types. + Examples: + - - '{{ "Hugo Rocks!" | default "Hugo Rules!" }}' + - Hugo Rocks! + - - '{{ "" | default "Hugo Rules!" }}' + - Hugo Rules! + Eq: + Aliases: + - eq + Args: + - first + - others + Description: Eq returns the boolean truth of arg1 == arg2 || arg1 == arg3 + || arg1 == arg4. + Examples: + - - '{{ if eq .Section "blog" }}current-section{{ end }}' + - current-section + Ge: + Aliases: + - ge + Args: + - first + - others + Description: Ge returns the boolean truth of arg1 >= arg2 && arg1 >= arg3 + && arg1 >= arg4. + Examples: + - - '{{ if ge hugo.Version "0.80" }}Reasonable new Hugo version!{{ end }}' + - Reasonable new Hugo version! + Gt: + Aliases: + - gt + Args: + - first + - others + Description: Gt returns the boolean truth of arg1 > arg2 && arg1 > arg3 && + arg1 > arg4. + Examples: [] + Le: + Aliases: + - le + Args: + - first + - others + Description: Le returns the boolean truth of arg1 <= arg2 && arg1 <= arg3 + && arg1 <= arg4. + Examples: [] + Lt: + Aliases: + - lt + Args: + - first + - others + Description: Lt returns the boolean truth of arg1 < arg2 && arg1 < arg3 && + arg1 < arg4. + Examples: [] + LtCollate: + Aliases: null + Args: null + Description: "" + Examples: null + Ne: + Aliases: + - ne + Args: + - first + - others + Description: Ne returns the boolean truth of arg1 != arg2 && arg1 != arg3 + && arg1 != arg4. + Examples: [] + crypto: + FNV32a: + Aliases: null + Args: null + Description: "" + Examples: null + HMAC: + Aliases: + - hmac + Args: + - h + - k + - m + - e + Description: HMAC returns a cryptographic hash that uses a key to sign a message. + Examples: + - - '{{ hmac "sha256" "Secret key" "Hello world, gophers!" }}' + - b6d11b6c53830b9d87036272ca9fe9d19306b8f9d8aa07b15da27d89e6e34f40 + MD5: + Aliases: + - md5 + Args: + - v + Description: MD5 hashes the v and returns its MD5 checksum. + Examples: + - - '{{ md5 "Hello world, gophers!" }}' + - b3029f756f98f79e7f1b7f1d1f0dd53b + - - '{{ crypto.MD5 "Hello world, gophers!" }}' + - b3029f756f98f79e7f1b7f1d1f0dd53b + SHA1: + Aliases: + - sha1 + Args: + - v + Description: SHA1 hashes v and returns its SHA1 checksum. + Examples: + - - '{{ sha1 "Hello world, gophers!" }}' + - c8b5b0e33d408246e30f53e32b8f7627a7a649d4 + SHA256: + Aliases: + - sha256 + Args: + - v + Description: SHA256 hashes v and returns its SHA256 checksum. + Examples: + - - '{{ sha256 "Hello world, gophers!" }}' + - 6ec43b78da9669f50e4e422575c54bf87536954ccd58280219c393f2ce352b46 + css: + PostCSS: + Aliases: + - postCSS + Args: + - args + Description: PostCSS processes the given Resource with PostCSS. + Examples: [] + Quoted: + Aliases: null + Args: null + Description: "" + Examples: null + Sass: + Aliases: + - toCSS + Args: + - args + Description: Sass processes the given Resource with SASS. + Examples: [] + TailwindCSS: + Aliases: null + Args: null + Description: "" + Examples: null + Unquoted: + Aliases: null + Args: null + Description: "" + Examples: null + data: + GetCSV: + Aliases: + - getCSV + Args: + - sep + - args + Description: |- + GetCSV expects the separator sep and one or n-parts of a URL to a resource which + can either be a local or a remote one. + The data separator can be a comma, semi-colon, pipe, etc, but only one character. + If you provide multiple parts for the URL they will be joined together to the final URL. + GetCSV returns nil or a slice slice to use in a short code. + Examples: [] + GetJSON: + Aliases: + - getJSON + Args: + - args + Description: |- + GetJSON expects one or n-parts of a URL in args to a resource which can either be a local or a remote one. + If you provide multiple parts they will be joined together to the final URL. + GetJSON returns nil or parsed JSON to use in a short code. + Examples: [] + debug: + Dump: + Aliases: null + Args: + - val + Description: |- + Dump returns a object dump of val as a string. + Note that not every value passed to Dump will print so nicely, but + we'll improve on that. + + We recommend using the "go" Chroma lexer to format the output + nicely. + + Also note that the output from Dump may change from Hugo version to the next, + so don't depend on a specific output. + Examples: + - - |- + {{ $m := newScratch }} + {{ $m.Set "Hugo" "Rocks!" }} + {{ $m.Values | debug.Dump | safeHTML }} + - |- + { + "Hugo": "Rocks!" + } + TestDeprecationErr: + Aliases: null + Args: null + Description: "" + Examples: null + TestDeprecationInfo: + Aliases: null + Args: null + Description: "" + Examples: null + TestDeprecationWarn: + Aliases: null + Args: null + Description: "" + Examples: null + Timer: + Aliases: null + Args: null + Description: "" + Examples: null + VisualizeSpaces: + Aliases: null + Args: null + Description: "" + Examples: null + diagrams: + Goat: + Aliases: null + Args: null + Description: "" + Examples: null + encoding: + Base64Decode: + Aliases: + - base64Decode + Args: + - content + Description: Base64Decode returns the base64 decoding of the given content. + Examples: + - - '{{ "SGVsbG8gd29ybGQ=" | base64Decode }}' + - Hello world + - - '{{ 42 | base64Encode | base64Decode }}' + - "42" + Base64Encode: + Aliases: + - base64Encode + Args: + - content + Description: Base64Encode returns the base64 encoding of the given content. + Examples: + - - '{{ "Hello world" | base64Encode }}' + - SGVsbG8gd29ybGQ= + Jsonify: + Aliases: + - jsonify + Args: + - args + Description: |- + Jsonify encodes a given object to JSON. To pretty print the JSON, pass a map + or dictionary of options as the first value in args. Supported options are + "prefix" and "indent". Each JSON element in the output will begin on a new + line beginning with prefix followed by one or more copies of indent according + to the indentation nesting. + Examples: + - - '{{ (slice "A" "B" "C") | jsonify }}' + - '["A","B","C"]' + - - '{{ (slice "A" "B" "C") | jsonify (dict "indent" " ") }}' + - |- + [ + "A", + "B", + "C" + ] + fmt: + Errorf: + Aliases: + - errorf + Args: + - format + - args + Description: |- + Errorf formats args according to a format specifier and logs an ERROR. + It returns an empty string. + Examples: + - - '{{ errorf "%s." "failed" }}' + - "" + Erroridf: + Aliases: + - erroridf + Args: + - id + - format + - args + Description: |- + Erroridf formats args according to a format specifier and logs an ERROR and + an information text that the error with the given id can be suppressed in config. + It returns an empty string. + Examples: + - - '{{ erroridf "my-err-id" "%s." "failed" }}' + - "" + Errormf: + Aliases: null + Args: null + Description: "" + Examples: null + Print: + Aliases: + - print + Args: + - args + Description: Print returns a string representation of args. + Examples: + - - '{{ print "works!" }}' + - works! + Printf: + Aliases: + - printf + Args: + - format + - args + Description: Printf returns string representation of args formatted with the + layout in format. + Examples: + - - '{{ printf "%s!" "works" }}' + - works! + Println: + Aliases: + - println + Args: + - args + Description: Println returns string representation of args ending with a + newline. + Examples: + - - '{{ println "works!" }}' + - | + works! + Warnf: + Aliases: + - warnf + Args: + - format + - args + Description: |- + Warnf formats args according to a format specifier and logs a WARNING. + It returns an empty string. + Examples: + - - '{{ warnf "%s." "warning" }}' + - "" + Warnidf: + Aliases: + - warnidf + Args: + - id + - format + - args + Description: |- + Warnidf formats args according to a format specifier and logs an WARNING and + an information text that the warning with the given id can be suppressed in config. + It returns an empty string. + Examples: + - - '{{ warnidf "my-warn-id" "%s." "warning" }}' + - "" + Warnmf: + Aliases: null + Args: null + Description: "" + Examples: null + hash: + FNV32a: + Aliases: null + Args: + - v + Description: FNV32a hashes v using fnv32a algorithm. + Examples: + - - '{{ hash.FNV32a "Hugo Rocks!!" }}' + - "1515779328" + XxHash: + Aliases: + - xxhash + Args: + - v + Description: XxHash returns the xxHash of the input string. + Examples: + - - '{{ hash.XxHash "The quick brown fox jumps over the lazy dog" }}' + - 0b242d361fda71bc + hugo: + Deps: + Aliases: null + Args: null + Description: "" + Examples: null + Generator: + Aliases: null + Args: null + Description: "" + Examples: null + IsDevelopment: + Aliases: null + Args: null + Description: "" + Examples: null + IsExtended: + Aliases: null + Args: null + Description: "" + Examples: null + IsMultiHost: + Aliases: null + Args: null + Description: "" + Examples: null + IsMultihost: + Aliases: null + Args: null + Description: "" + Examples: null + IsMultilingual: + Aliases: null + Args: null + Description: "" + Examples: null + IsProduction: + Aliases: null + Args: null + Description: "" + Examples: null + IsServer: + Aliases: null + Args: null + Description: "" + Examples: null + Store: + Aliases: null + Args: null + Description: "" + Examples: null + Version: + Aliases: null + Args: null + Description: "" + Examples: null + WorkingDir: + Aliases: null + Args: null + Description: "" + Examples: null + images: + AutoOrient: + Aliases: null + Args: null + Description: "" + Examples: null + Brightness: + Aliases: null + Args: null + Description: "" + Examples: null + ColorBalance: + Aliases: null + Args: null + Description: "" + Examples: null + Colorize: + Aliases: null + Args: null + Description: "" + Examples: null + Config: + Aliases: + - imageConfig + Args: + - path + Description: |- + Config returns the image.Config for the specified path relative to the + working directory. + Examples: [] + Contrast: + Aliases: null + Args: null + Description: "" + Examples: null + Dither: + Aliases: null + Args: null + Description: "" + Examples: null + Filter: + Aliases: null + Args: null + Description: "" + Examples: null + Gamma: + Aliases: null + Args: null + Description: "" + Examples: null + GaussianBlur: + Aliases: null + Args: null + Description: "" + Examples: null + Grayscale: + Aliases: null + Args: null + Description: "" + Examples: null + Hue: + Aliases: null + Args: null + Description: "" + Examples: null + Invert: + Aliases: null + Args: null + Description: "" + Examples: null + Mask: + Aliases: null + Args: null + Description: "" + Examples: null + Opacity: + Aliases: null + Args: null + Description: "" + Examples: null + Overlay: + Aliases: null + Args: null + Description: "" + Examples: null + Padding: + Aliases: null + Args: null + Description: "" + Examples: null + Pixelate: + Aliases: null + Args: null + Description: "" + Examples: null + Process: + Aliases: null + Args: null + Description: "" + Examples: null + QR: + Aliases: null + Args: null + Description: "" + Examples: null + Saturation: + Aliases: null + Args: null + Description: "" + Examples: null + Sepia: + Aliases: null + Args: null + Description: "" + Examples: null + Sigmoid: + Aliases: null + Args: null + Description: "" + Examples: null + Text: + Aliases: null + Args: null + Description: "" + Examples: null + UnsharpMask: + Aliases: null + Args: null + Description: "" + Examples: null + inflect: + Humanize: + Aliases: + - humanize + Args: + - v + Description: |- + Humanize returns the humanized form of v. + + If v is either an integer or a string containing an integer + value, the behavior is to add the appropriate ordinal. + Examples: + - - '{{ humanize "my-first-post" }}' + - My first post + - - '{{ humanize "myCamelPost" }}' + - My camel post + - - '{{ humanize "52" }}' + - 52nd + - - '{{ humanize 103 }}' + - 103rd + Pluralize: + Aliases: + - pluralize + Args: + - v + Description: Pluralize returns the plural form of the single word in v. + Examples: + - - '{{ "cat" | pluralize }}' + - cats + Singularize: + Aliases: + - singularize + Args: + - v + Description: Singularize returns the singular form of a single word in v. + Examples: + - - '{{ "cats" | singularize }}' + - cat + js: + Babel: + Aliases: + - babel + Args: + - args + Description: Babel processes the given Resource with Babel. + Examples: [] + Batch: + Aliases: null + Args: null + Description: "" + Examples: null + Build: + Aliases: null + Args: null + Description: "" + Examples: null + lang: + FormatAccounting: + Aliases: null + Args: + - precision + - currency + - number + Description: |- + FormatAccounting returns the currency representation of number for the given currency and precision + for the current language in accounting notation. + + The return value is formatted with at least two decimal places. + Examples: + - - '{{ 512.5032 | lang.FormatAccounting 2 "NOK" }}' + - NOK512.50 + FormatCurrency: + Aliases: null + Args: + - precision + - currency + - number + Description: |- + FormatCurrency returns the currency representation of number for the given currency and precision + for the current language. + + The return value is formatted with at least two decimal places. + Examples: + - - '{{ 512.5032 | lang.FormatCurrency 2 "USD" }}' + - $512.50 + FormatNumber: + Aliases: null + Args: + - precision + - number + Description: FormatNumber formats number with the given precision for the + current language. + Examples: + - - '{{ 512.5032 | lang.FormatNumber 2 }}' + - "512.50" + FormatNumberCustom: + Aliases: null + Args: + - precision + - number + - options + Description: |- + FormatNumberCustom formats a number with the given precision. The first + options parameter is a space-delimited string of characters to represent + negativity, the decimal point, and grouping. The default value is `- . ,`. + The second options parameter defines an alternate delimiting character. + + Note that numbers are rounded up at 5 or greater. + So, with precision set to 0, 1.5 becomes `2`, and 1.4 becomes `1`. + + For a simpler function that adapts to the current language, see FormatNumber. + Examples: + - - '{{ lang.FormatNumberCustom 2 12345.6789 }}' + - 12,345.68 + - - '{{ lang.FormatNumberCustom 2 12345.6789 "- , ." }}' + - 12.345,68 + - - '{{ lang.FormatNumberCustom 6 -12345.6789 "- ." }}' + - "-12345.678900" + - - '{{ lang.FormatNumberCustom 0 -12345.6789 "- . ," }}' + - -12,346 + - - '{{ lang.FormatNumberCustom 0 -12345.6789 "-|.| " "|" }}' + - -12 346 + - - '{{ -98765.4321 | lang.FormatNumberCustom 2 }}' + - -98,765.43 + FormatPercent: + Aliases: null + Args: + - precision + - number + Description: |- + FormatPercent formats number with the given precision for the current language. + Note that the number is assumed to be a percentage. + Examples: + - - '{{ 512.5032 | lang.FormatPercent 2 }}' + - 512.50% + Merge: + Aliases: null + Args: null + Description: "" + Examples: null + Translate: + Aliases: + - i18n + - T + Args: + - ctx + - id + - args + Description: Translate returns a translated string for id. + Examples: [] + math: + Abs: + Aliases: null + Args: + - "n" + Description: Abs returns the absolute value of n. + Examples: + - - '{{ math.Abs -2.1 }}' + - "2.1" + Acos: + Aliases: null + Args: + - "n" + Description: Acos returns the arccosine, in radians, of n. + Examples: + - - '{{ math.Acos 1 }}' + - "0" + Add: + Aliases: + - add + Args: + - inputs + Description: Add adds the multivalued addends n1 and n2 or more values. + Examples: + - - '{{ add 1 2 }}' + - "3" + Asin: + Aliases: null + Args: + - "n" + Description: Asin returns the arcsine, in radians, of n. + Examples: + - - '{{ math.Asin 1 }}' + - "1.5707963267948966" + Atan: + Aliases: null + Args: + - "n" + Description: Atan returns the arctangent, in radians, of n. + Examples: + - - '{{ math.Atan 1 }}' + - "0.7853981633974483" + Atan2: + Aliases: null + Args: + - "n" + - m + Description: Atan2 returns the arc tangent of n/m, using the signs of the + two to determine the quadrant of the return value. + Examples: + - - '{{ math.Atan2 1 2 }}' + - "0.4636476090008061" + Ceil: + Aliases: null + Args: + - "n" + Description: Ceil returns the least integer value greater than or equal to + n. + Examples: + - - '{{ math.Ceil 2.1 }}' + - "3" + Cos: + Aliases: null + Args: + - "n" + Description: Cos returns the cosine of the radian argument n. + Examples: + - - '{{ math.Cos 1 }}' + - "0.5403023058681398" + Counter: + Aliases: null + Args: null + Description: "" + Examples: null + Div: + Aliases: + - div + Args: + - inputs + Description: Div divides n1 by n2. + Examples: + - - '{{ div 6 3 }}' + - "2" + Floor: + Aliases: null + Args: + - "n" + Description: Floor returns the greatest integer value less than or equal to + n. + Examples: + - - '{{ math.Floor 1.9 }}' + - "1" + Log: + Aliases: null + Args: + - "n" + Description: Log returns the natural logarithm of the number n. + Examples: + - - '{{ math.Log 1 }}' + - "0" + Max: + Aliases: null + Args: + - inputs + Description: Max returns the greater of all numbers in inputs. Any slices + in inputs are flattened. + Examples: + - - '{{ math.Max 1 2 }}' + - "2" + Min: + Aliases: null + Args: + - inputs + Description: Min returns the smaller of all numbers in inputs. Any slices + in inputs are flattened. + Examples: + - - '{{ math.Min 1 2 }}' + - "1" + Mod: + Aliases: + - mod + Args: + - n1 + - n2 + Description: Mod returns n1 % n2. + Examples: + - - '{{ mod 15 3 }}' + - "0" + ModBool: + Aliases: + - modBool + Args: + - n1 + - n2 + Description: ModBool returns the boolean of n1 % n2. If n1 % n2 == 0, return + true. + Examples: + - - '{{ modBool 15 3 }}' + - "true" + Mul: + Aliases: + - mul + Args: + - inputs + Description: Mul multiplies the multivalued numbers n1 and n2 or more values. + Examples: + - - '{{ mul 2 3 }}' + - "6" + Pi: + Aliases: null + Args: null + Description: Pi returns the mathematical constant pi. + Examples: + - - '{{ math.Pi }}' + - "3.141592653589793" + Pow: + Aliases: + - pow + Args: + - n1 + - n2 + Description: Pow returns n1 raised to the power of n2. + Examples: + - - '{{ math.Pow 2 3 }}' + - "8" + Product: + Aliases: null + Args: null + Description: "" + Examples: null + Rand: + Aliases: null + Args: null + Description: Rand returns, as a float64, a pseudo-random number in the half-open + interval [0.0,1.0). + Examples: + - - '{{ math.Rand }}' + - "0.6312770459590062" + Round: + Aliases: null + Args: + - "n" + Description: Round returns the integer nearest to n, rounding half away from + zero. + Examples: + - - '{{ math.Round 1.5 }}' + - "2" + Sin: + Aliases: null + Args: + - "n" + Description: Sin returns the sine of the radian argument n. + Examples: + - - '{{ math.Sin 1 }}' + - "0.8414709848078965" + Sqrt: + Aliases: null + Args: + - "n" + Description: Sqrt returns the square root of the number n. + Examples: + - - '{{ math.Sqrt 81 }}' + - "9" + Sub: + Aliases: + - sub + Args: + - inputs + Description: Sub subtracts multivalued. + Examples: + - - '{{ sub 3 2 }}' + - "1" + Sum: + Aliases: null + Args: null + Description: "" + Examples: null + Tan: + Aliases: null + Args: + - "n" + Description: Tan returns the tangent of the radian argument n. + Examples: + - - '{{ math.Tan 1 }}' + - "1.557407724654902" + ToDegrees: + Aliases: null + Args: + - "n" + Description: ToDegrees converts radians into degrees. + Examples: + - - '{{ math.ToDegrees 1.5707963267948966 }}' + - "90" + ToRadians: + Aliases: null + Args: + - "n" + Description: ToRadians converts degrees into radians. + Examples: + - - '{{ math.ToRadians 90 }}' + - "1.5707963267948966" + openapi3: + Unmarshal: + Aliases: null + Args: null + Description: "" + Examples: [] + os: + FileExists: + Aliases: + - fileExists + Args: + - i + Description: FileExists checks whether a file exists under the given path. + Examples: + - - '{{ fileExists "foo.txt" }}' + - "false" + Getenv: + Aliases: + - getenv + Args: + - key + Description: |- + Getenv retrieves the value of the environment variable named by the key. + It returns the value, which will be empty if the variable is not present. + Examples: [] + ReadDir: + Aliases: + - readDir + Args: + - i + Description: ReadDir lists the directory contents relative to the configured + WorkingDir. + Examples: + - - '{{ range (readDir "files") }}{{ .Name }}{{ end }}' + - README.txt + ReadFile: + Aliases: + - readFile + Args: + - i + Description: |- + ReadFile reads the file named by filename relative to the configured WorkingDir. + It returns the contents as a string. + There is an upper size limit set at 1 megabytes. + Examples: + - - '{{ readFile "files/README.txt" }}' + - Hugo Rocks! + Stat: + Aliases: null + Args: null + Description: "" + Examples: null + partials: + Include: + Aliases: + - partial + Args: + - ctx + - name + - contextList + Description: |- + Include executes the named partial. + If the partial contains a return statement, that value will be returned. + Else, the rendered output will be returned: + A string if the partial is a text/template, or template.HTML when html/template. + Note that ctx is provided by Hugo, not the end user. + Examples: + - - '{{ partial "header.html" . }}' + - Hugo Rocks! + IncludeCached: + Aliases: + - partialCached + Args: + - ctx + - name + - context + - variants + Description: |- + IncludeCached executes and caches partial templates. The cache is created with name+variants as the key. + Note that ctx is provided by Hugo, not the end user. + Examples: [] + path: + Base: + Aliases: null + Args: null + Description: "" + Examples: null + BaseName: + Aliases: null + Args: null + Description: "" + Examples: null + Clean: + Aliases: null + Args: null + Description: "" + Examples: null + Dir: + Aliases: null + Args: null + Description: "" + Examples: null + Ext: + Aliases: null + Args: null + Description: "" + Examples: null + Join: + Aliases: null + Args: + - elements + Description: |- + Join joins any number of path elements into a single path, adding a + separating slash if necessary. All the input + path elements are passed into filepath.ToSlash converting any Windows slashes + to forward slashes. + The result is Cleaned; in particular, + all empty strings are ignored. + Examples: + - - '{{ slice "my/path" "filename.txt" | path.Join }}' + - my/path/filename.txt + - - '{{ path.Join "my" "path" "filename.txt" }}' + - my/path/filename.txt + - - '{{ "my/path/filename.txt" | path.Ext }}' + - .txt + - - '{{ "my/path/filename.txt" | path.Base }}' + - filename.txt + - - '{{ "my/path/filename.txt" | path.Dir }}' + - my/path + Split: + Aliases: null + Args: + - path + Description: |- + Split splits path immediately following the final slash, + separating it into a directory and file name component. + If there is no slash in path, Split returns an empty dir and + file set to path. + The input path is passed into filepath.ToSlash converting any Windows slashes + to forward slashes. + The returned values have the property that path = dir+file. + Examples: + - - '{{ "/my/path/filename.txt" | path.Split }}' + - /my/path/|filename.txt + - - '{{ "/my/path/filename.txt" | path.Split }}' + - /my/path/|filename.txt + reflect: + IsMap: + Aliases: null + Args: + - v + Description: IsMap reports whether v is a map. + Examples: + - - '{{ if reflect.IsMap (dict "a" 1) }}Map{{ end }}' + - Map + IsSlice: + Aliases: null + Args: + - v + Description: IsSlice reports whether v is a slice. + Examples: + - - '{{ if reflect.IsSlice (slice 1 2 3) }}Slice{{ end }}' + - Slice + resources: + Babel: + Aliases: null + Args: null + Description: "" + Examples: null + ByType: + Aliases: null + Args: null + Description: "" + Examples: null + Concat: + Aliases: null + Args: null + Description: "" + Examples: null + Copy: + Aliases: null + Args: null + Description: "" + Examples: null + ExecuteAsTemplate: + Aliases: null + Args: null + Description: "" + Examples: null + Fingerprint: + Aliases: + - fingerprint + Args: + - args + Description: |- + Fingerprint transforms the given Resource with a MD5 hash of the content in + the RelPermalink and Permalink. + Examples: [] + FromString: + Aliases: null + Args: null + Description: "" + Examples: null + Get: + Aliases: null + Args: + - filename + Description: |- + Get locates the filename given in Hugo's assets filesystem + and creates a Resource object that can be used for further transformations. + Examples: [] + GetMatch: + Aliases: null + Args: null + Description: "" + Examples: null + GetRemote: + Aliases: null + Args: + - args + Description: |- + GetRemote gets the URL (via HTTP(s)) in the first argument in args and creates Resource object that can be used for + further transformations. + + A second argument may be provided with an option map. + + Note: This method does not return any error as a second return value, + for any error situations the error can be checked in .Err. + Examples: [] + Match: + Aliases: null + Args: null + Description: "" + Examples: null + Minify: + Aliases: + - minify + Args: + - r + Description: |- + Minify minifies the given Resource using the MediaType to pick the correct + minifier. + Examples: [] + PostCSS: + Aliases: null + Args: null + Description: "" + Examples: null + PostProcess: + Aliases: null + Args: null + Description: "" + Examples: null + ToCSS: + Aliases: null + Args: null + Description: "" + Examples: null + safe: + CSS: + Aliases: + - safeCSS + Args: + - s + Description: CSS returns the string s as html/template CSS content. + Examples: + - - '{{ "Bat&Man" | safeCSS | safeCSS }}' + - Bat&Man + HTML: + Aliases: + - safeHTML + Args: + - s + Description: HTML returns the string s as html/template HTML content. + Examples: + - - '{{ "Bat&Man" | safeHTML | safeHTML }}' + - Bat&Man + - - '{{ "Bat&Man" | safeHTML }}' + - Bat&Man + HTMLAttr: + Aliases: + - safeHTMLAttr + Args: + - s + Description: HTMLAttr returns the string s as html/template HTMLAttr content. + Examples: [] + JS: + Aliases: + - safeJS + Args: + - s + Description: JS returns the given string as a html/template JS content. + Examples: + - - '{{ "(1*2)" | safeJS | safeJS }}' + - (1*2) + JSStr: + Aliases: + - safeJSStr + Args: + - s + Description: JSStr returns the given string as a html/template JSStr content. + Examples: [] + URL: + Aliases: + - safeURL + Args: + - s + Description: URL returns the string s as html/template URL content. + Examples: + - - '{{ "http://gohugo.io" | safeURL | safeURL }}' + - http://gohugo.io + site: + AllPages: + Aliases: null + Args: null + Description: "" + Examples: null + Author: + Aliases: null + Args: null + Description: "" + Examples: null + Authors: + Aliases: null + Args: null + Description: "" + Examples: null + BaseURL: + Aliases: null + Args: null + Description: "" + Examples: null + BuildDrafts: + Aliases: null + Args: null + Description: "" + Examples: null + CheckReady: + Aliases: null + Args: null + Description: "" + Examples: null + Config: + Aliases: null + Args: null + Description: "" + Examples: null + Copyright: + Aliases: null + Args: null + Description: "" + Examples: null + Current: + Aliases: null + Args: null + Description: "" + Examples: null + Data: + Aliases: null + Args: null + Description: "" + Examples: null + ForEeachIdentityByName: + Aliases: null + Args: null + Description: "" + Examples: null + GetPage: + Aliases: null + Args: null + Description: "" + Examples: null + Home: + Aliases: null + Args: null + Description: "" + Examples: null + Hugo: + Aliases: null + Args: null + Description: "" + Examples: null + IsMultiLingual: + Aliases: null + Args: null + Description: "" + Examples: null + Key: + Aliases: null + Args: null + Description: "" + Examples: null + Language: + Aliases: null + Args: null + Description: "" + Examples: null + LanguageCode: + Aliases: null + Args: null + Description: "" + Examples: null + LanguagePrefix: + Aliases: null + Args: null + Description: "" + Examples: null + Languages: + Aliases: null + Args: null + Description: "" + Examples: null + LastChange: + Aliases: null + Args: null + Description: "" + Examples: null + Lastmod: + Aliases: null + Args: null + Description: "" + Examples: null + MainSections: + Aliases: null + Args: null + Description: "" + Examples: null + Menus: + Aliases: null + Args: null + Description: "" + Examples: null + Pages: + Aliases: null + Args: null + Description: "" + Examples: null + Param: + Aliases: null + Args: null + Description: "" + Examples: null + Params: + Aliases: null + Args: null + Description: "" + Examples: null + RegularPages: + Aliases: null + Args: null + Description: "" + Examples: null + Sections: + Aliases: null + Args: null + Description: "" + Examples: null + ServerPort: + Aliases: null + Args: null + Description: "" + Examples: null + Sites: + Aliases: null + Args: null + Description: "" + Examples: null + Social: + Aliases: null + Args: null + Description: "" + Examples: null + Store: + Aliases: null + Args: null + Description: "" + Examples: null + Taxonomies: + Aliases: null + Args: null + Description: "" + Examples: null + Title: + Aliases: null + Args: null + Description: "" + Examples: null + strings: + Chomp: + Aliases: + - chomp + Args: + - s + Description: Chomp returns a copy of s with all trailing newline characters + removed. + Examples: + - - '{{ chomp "

    Blockhead

    \n" | safeHTML }}' + -

    Blockhead

    + Contains: + Aliases: null + Args: + - s + - substr + Description: Contains reports whether substr is in s. + Examples: + - - '{{ strings.Contains "abc" "b" }}' + - "true" + - - '{{ strings.Contains "abc" "d" }}' + - "false" + ContainsAny: + Aliases: null + Args: + - s + - chars + Description: ContainsAny reports whether any Unicode code points in chars + are within s. + Examples: + - - '{{ strings.ContainsAny "abc" "bcd" }}' + - "true" + - - '{{ strings.ContainsAny "abc" "def" }}' + - "false" + ContainsNonSpace: + Aliases: null + Args: null + Description: "" + Examples: null + Count: + Aliases: null + Args: + - substr + - s + Description: |- + Count counts the number of non-overlapping instances of substr in s. + If substr is an empty string, Count returns 1 + the number of Unicode code points in s. + Examples: + - - '{{ "aabab" | strings.Count "a" }}' + - "3" + CountRunes: + Aliases: + - countrunes + Args: + - s + Description: CountRunes returns the number of runes in s, excluding whitespace. + Examples: [] + CountWords: + Aliases: + - countwords + Args: + - s + Description: CountWords returns the approximate word count in s. + Examples: [] + Diff: + Aliases: null + Args: null + Description: "" + Examples: null + FindRE: + Aliases: + - findRE + Args: + - expr + - content + - limit + Description: |- + FindRE returns a list of strings that match the regular expression. By default all matches + will be included. The number of matches can be limited with an optional third parameter. + Examples: + - - '{{ findRE "[G|g]o" "Hugo is a static side generator written in Go." 1 + }}' + - '[go]' + FindRESubmatch: + Aliases: + - findRESubmatch + Args: + - expr + - content + - limit + Description: |- + FindRESubmatch returns a slice of all successive matches of the regular + expression in content. Each element is a slice of strings holding the text + of the leftmost match of the regular expression and the matches, if any, of + its subexpressions. + + By default all matches will be included. The number of matches can be + limited with the optional limit parameter. A return value of nil indicates + no match. + Examples: + - - '{{ findRESubmatch `(.+?)` `
  • Foo
  • +
  • Bar
  • ` | print | safeHTML }}' + - '[[Foo #foo Foo] [Bar #bar Bar]]' + FirstUpper: + Aliases: null + Args: + - s + Description: FirstUpper converts s making the first character upper case. + Examples: + - - '{{ "hugo rocks!" | strings.FirstUpper }}' + - Hugo rocks! + HasPrefix: + Aliases: + - hasPrefix + Args: + - s + - prefix + Description: HasPrefix tests whether the input s begins with prefix. + Examples: + - - '{{ hasPrefix "Hugo" "Hu" }}' + - "true" + - - '{{ hasPrefix "Hugo" "Fu" }}' + - "false" + HasSuffix: + Aliases: + - hasSuffix + Args: + - s + - suffix + Description: HasSuffix tests whether the input s begins with suffix. + Examples: + - - '{{ hasSuffix "Hugo" "go" }}' + - "true" + - - '{{ hasSuffix "Hugo" "du" }}' + - "false" + Repeat: + Aliases: null + Args: + - "n" + - s + Description: Repeat returns a new string consisting of n copies of the string + s. + Examples: + - - '{{ "yo" | strings.Repeat 4 }}' + - yoyoyoyo + Replace: + Aliases: + - replace + Args: + - s + - old + - new + - limit + Description: |- + Replace returns a copy of the string s with all occurrences of old replaced + with new. The number of replacements can be limited with an optional fourth + parameter. + Examples: + - - '{{ replace "Batman and Robin" "Robin" "Catwoman" }}' + - Batman and Catwoman + - - '{{ replace "aabbaabb" "a" "z" 2 }}' + - zzbbaabb + ReplaceRE: + Aliases: + - replaceRE + Args: + - pattern + - repl + - s + - "n" + Description: |- + ReplaceRE returns a copy of s, replacing all matches of the regular + expression pattern with the replacement text repl. The number of replacements + can be limited with an optional fourth parameter. + Examples: + - - '{{ replaceRE "a+b" "X" "aabbaabbab" }}' + - XbXbX + - - '{{ replaceRE "a+b" "X" "aabbaabbab" 1 }}' + - Xbaabbab + RuneCount: + Aliases: null + Args: + - s + Description: RuneCount returns the number of runes in s. + Examples: [] + SliceString: + Aliases: + - slicestr + Args: + - a + - startEnd + Description: |- + SliceString slices a string by specifying a half-open range with + two indices, start and end. 1 and 4 creates a slice including elements 1 through 3. + The end index can be omitted, it defaults to the string's length. + Examples: + - - '{{ slicestr "BatMan" 0 3 }}' + - Bat + - - '{{ slicestr "BatMan" 3 }}' + - Man + Split: + Aliases: + - split + Args: + - a + - delimiter + Description: Split slices an input string into all substrings separated by + delimiter. + Examples: [] + Substr: + Aliases: + - substr + Args: + - a + - nums + Description: |- + Substr extracts parts of a string, beginning at the character at the specified + position, and returns the specified number of characters. + + It normally takes two parameters: start and length. + It can also take one parameter: start, i.e. length is omitted, in which case + the substring starting from start until the end of the string will be returned. + + To extract characters from the end of the string, use a negative start number. + + In addition, borrowing from the extended behavior described at http://php.net/substr, + if length is given and is negative, then that many characters will be omitted from + the end of string. + Examples: + - - '{{ substr "BatMan" 0 -3 }}' + - Bat + - - '{{ substr "BatMan" 3 3 }}' + - Man + Title: + Aliases: + - title + Args: + - s + Description: |- + Title returns a copy of the input s with all Unicode letters that begin words + mapped to their title case. + Examples: + - - '{{ title "Bat man" }}' + - Bat Man + - - '{{ title "somewhere over the rainbow" }}' + - Somewhere Over the Rainbow + ToLower: + Aliases: + - lower + Args: + - s + Description: |- + ToLower returns a copy of the input s with all Unicode letters mapped to their + lower case. + Examples: + - - '{{ lower "BatMan" }}' + - batman + ToUpper: + Aliases: + - upper + Args: + - s + Description: |- + ToUpper returns a copy of the input s with all Unicode letters mapped to their + upper case. + Examples: + - - '{{ upper "BatMan" }}' + - BATMAN + Trim: + Aliases: + - trim + Args: + - s + - cutset + Description: |- + Trim returns converts the strings s removing all leading and trailing characters defined + contained. + Examples: + - - '{{ trim "++Batman--" "+-" }}' + - Batman + TrimLeft: + Aliases: null + Args: + - cutset + - s + Description: |- + TrimLeft returns a slice of the string s with all leading characters + contained in cutset removed. + Examples: + - - '{{ "aabbaa" | strings.TrimLeft "a" }}' + - bbaa + TrimPrefix: + Aliases: null + Args: + - prefix + - s + Description: |- + TrimPrefix returns s without the provided leading prefix string. If s doesn't + start with prefix, s is returned unchanged. + Examples: + - - '{{ "aabbaa" | strings.TrimPrefix "a" }}' + - abbaa + - - '{{ "aabbaa" | strings.TrimPrefix "aa" }}' + - bbaa + TrimRight: + Aliases: null + Args: + - cutset + - s + Description: |- + TrimRight returns a slice of the string s with all trailing characters + contained in cutset removed. + Examples: + - - '{{ "aabbaa" | strings.TrimRight "a" }}' + - aabb + TrimSpace: + Aliases: null + Args: null + Description: "" + Examples: null + TrimSuffix: + Aliases: null + Args: + - suffix + - s + Description: |- + TrimSuffix returns s without the provided trailing suffix string. If s + doesn't end with suffix, s is returned unchanged. + Examples: + - - '{{ "aabbaa" | strings.TrimSuffix "a" }}' + - aabba + - - '{{ "aabbaa" | strings.TrimSuffix "aa" }}' + - aabb + Truncate: + Aliases: + - truncate + Args: + - s + - options + Description: Truncate truncates the string in s to the specified length. + Examples: + - - '{{ "this is a very long text" | truncate 10 " ..." }}' + - this is a ... + - - '{{ "With [Markdown](/markdown) inside." | markdownify | truncate 14 }}' + - With Markdown … + templates: + Defer: + Aliases: null + Args: + - args + Description: Defer defers the execution of a template block. + Examples: [] + DoDefer: + Aliases: + - doDefer + Args: + - ctx + - id + - optsv + Description: |- + DoDefer defers the execution of a template block. + For internal use only. + Examples: [] + Exists: + Aliases: null + Args: + - name + Description: |- + Exists returns whether the template with the given name exists. + Note that this is the Unix-styled relative path including filename suffix, + e.g. partials/header.html + Examples: + - - '{{ if (templates.Exists "partials/header.html") }}Yes!{{ end }}' + - Yes! + - - '{{ if not (templates.Exists "partials/doesnotexist.html") }}No!{{ end + }}' + - No! + time: + AsTime: + Aliases: null + Args: + - v + - args + Description: |- + AsTime converts the textual representation of the datetime string into + a time.Time interface. + Examples: + - - '{{ (time "2015-01-21").Year }}' + - "2015" + Duration: + Aliases: + - duration + Args: + - unit + - number + Description: |- + Duration converts the given number to a time.Duration. + Unit is one of nanosecond/ns, microsecond/us/µs, millisecond/ms, second/s, minute/m or hour/h. + Examples: + - - '{{ mul 60 60 | duration "second" }}' + - 1h0m0s + Format: + Aliases: + - dateFormat + Args: + - layout + - v + Description: |- + Format converts the textual representation of the datetime string in v into + time.Time if needed and formats it with the given layout. + Examples: + - - 'dateFormat: {{ dateFormat "Monday, Jan 2, 2006" "2015-01-21" }}' + - 'dateFormat: Wednesday, Jan 21, 2015' + Now: + Aliases: + - now + Args: null + Description: Now returns the current local time or `clock` time + Examples: [] + ParseDuration: + Aliases: null + Args: + - s + Description: |- + ParseDuration parses the duration string s. + A duration string is a possibly signed sequence of + decimal numbers, each with optional fraction and a unit suffix, + such as "300ms", "-1.5h" or "2h45m". + Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h". + See https://golang.org/pkg/time/#ParseDuration + Examples: + - - '{{ "1h12m10s" | time.ParseDuration }}' + - 1h12m10s + transform: + CanHighlight: + Aliases: null + Args: null + Description: "" + Examples: null + Emojify: + Aliases: + - emojify + Args: + - s + Description: |- + Emojify returns a copy of s with all emoji codes replaced with actual emojis. + + See http://www.emoji-cheat-sheet.com/ + Examples: + - - '{{ "I :heart: Hugo" | emojify }}' + - I ❤️ Hugo + HTMLEscape: + Aliases: + - htmlEscape + Args: + - s + Description: HTMLEscape returns a copy of s with reserved HTML characters + escaped. + Examples: + - - '{{ htmlEscape "Cathal Garvey & The Sunshine Band " | + safeHTML }}' + - Cathal Garvey & The Sunshine Band <cathal@foo.bar> + - - '{{ htmlEscape "Cathal Garvey & The Sunshine Band " }}' + - Cathal Garvey &amp; The Sunshine Band &lt;cathal@foo.bar&gt; + - - '{{ htmlEscape "Cathal Garvey & The Sunshine Band " | + htmlUnescape | safeHTML }}' + - Cathal Garvey & The Sunshine Band + HTMLUnescape: + Aliases: + - htmlUnescape + Args: + - s + Description: |- + HTMLUnescape returns a copy of s with HTML escape requences converted to plain + text. + Examples: + - - '{{ htmlUnescape "Cathal Garvey & The Sunshine Band <cathal@foo.bar>" + | safeHTML }}' + - Cathal Garvey & The Sunshine Band + - - '{{ "Cathal Garvey &amp; The Sunshine Band &lt;cathal@foo.bar&gt;" + | htmlUnescape | htmlUnescape | safeHTML }}' + - Cathal Garvey & The Sunshine Band + - - '{{ "Cathal Garvey &amp; The Sunshine Band &lt;cathal@foo.bar&gt;" + | htmlUnescape | htmlUnescape }}' + - Cathal Garvey & The Sunshine Band <cathal@foo.bar> + - - '{{ htmlUnescape "Cathal Garvey & The Sunshine Band <cathal@foo.bar>" + | htmlEscape | safeHTML }}' + - Cathal Garvey & The Sunshine Band <cathal@foo.bar> + Highlight: + Aliases: + - highlight + Args: + - s + - lang + - opts + Description: |- + Highlight returns a copy of s as an HTML string with syntax + highlighting applied. + Examples: [] + HighlightCodeBlock: + Aliases: null + Args: null + Description: "" + Examples: null + Markdownify: + Aliases: + - markdownify + Args: + - ctx + - s + Description: Markdownify renders s from Markdown to HTML. + Examples: + - - '{{ .Title | markdownify }}' + - BatMan + Plainify: + Aliases: + - plainify + Args: + - s + Description: Plainify returns a copy of s with all HTML tags removed. + Examples: + - - '{{ plainify "Hello world, gophers!" }}' + - Hello world, gophers! + Remarshal: + Aliases: null + Args: + - format + - data + Description: |- + Remarshal is used in the Hugo documentation to convert configuration + examples from YAML to JSON, TOML (and possibly the other way around). + The is primarily a helper for the Hugo docs site. + It is not a general purpose YAML to TOML converter etc., and may + change without notice if it serves a purpose in the docs. + Format is one of json, yaml or toml. + Examples: + - - '{{ "title = \"Hello World\"" | transform.Remarshal "json" | safeHTML + }}' + - | + { + "title": "Hello World" + } + ToMath: + Aliases: null + Args: null + Description: "" + Examples: null + Unmarshal: + Aliases: + - unmarshal + Args: + - args + Description: |- + Unmarshal unmarshals the data given, which can be either a string, json.RawMessage + or a Resource. Supported formats are JSON, TOML, YAML, and CSV. + You can optionally provide an options map as the first argument. + Examples: + - - '{{ "hello = \"Hello World\"" | transform.Unmarshal }}' + - map[hello:Hello World] + - - '{{ "hello = \"Hello World\"" | resources.FromString "data/greetings.toml" + | transform.Unmarshal }}' + - map[hello:Hello World] + XMLEscape: + Aliases: null + Args: + - s + Description: |- + XMLEscape returns the given string, removing disallowed characters then + escaping the result to its XML equivalent. + Examples: + - - '{{ transform.XMLEscape "

    abc

    " }}' + - '<p>abc</p>' + urls: + AbsLangURL: + Aliases: + - absLangURL + Args: + - s + Description: |- + AbsLangURL the string s and converts it to an absolute URL according + to a page's position in the project directory structure and the current + language. + Examples: [] + AbsURL: + Aliases: + - absURL + Args: + - s + Description: AbsURL takes the string s and converts it to an absolute URL. + Examples: [] + Anchorize: + Aliases: + - anchorize + Args: + - s + Description: |- + Anchorize creates sanitized anchor name version of the string s that is compatible + with how your configured markdown renderer does it. + Examples: + - - '{{ "This is a title" | anchorize }}' + - this-is-a-title + JoinPath: + Aliases: null + Args: + - elements + Description: |- + JoinPath joins the provided elements into a URL string and cleans the result + of any ./ or ../ elements. If the argument list is empty, JoinPath returns + an empty string. + Examples: + - - '{{ urls.JoinPath "https://example.org" "foo" }}' + - https://example.org/foo + - - '{{ urls.JoinPath (slice "a" "b") }}' + - a/b + Parse: + Aliases: null + Args: null + Description: "" + Examples: null + Ref: + Aliases: + - ref + Args: + - p + - args + Description: Ref returns the absolute URL path to a given content item from + Page p. + Examples: [] + RelLangURL: + Aliases: + - relLangURL + Args: + - s + Description: |- + RelLangURL takes the string s and prepends the relative path according to a + page's position in the project directory structure and the current language. + Examples: [] + RelRef: + Aliases: + - relref + Args: + - p + - args + Description: RelRef returns the relative URL path to a given content item + from Page p. + Examples: [] + RelURL: + Aliases: + - relURL + Args: + - s + Description: |- + RelURL takes the string s and prepends the relative path according to a + page's position in the project directory structure. + Examples: [] + URLize: + Aliases: + - urlize + Args: + - s + Description: URLize returns the strings s formatted as an URL. + Examples: [] diff --git a/docs/data/embedded_template_urls.toml b/docs/data/embedded_template_urls.toml new file mode 100644 index 000000000..b2a796cd1 --- /dev/null +++ b/docs/data/embedded_template_urls.toml @@ -0,0 +1,42 @@ +# Used by the embedded template URL (eturl.html) shortcode. +# Quoted all keys because some are not valid identifiers. + +# BaseURL +'base_url' = 'https://github.com/gohugoio/hugo/blob/master/tpl/tplimpl/embedded/templates' + +# Partials +'disqus' = '_partials/disqus.html' +'google_analytics' = '_partials/google_analytics.html' +'opengraph' = '_partials/opengraph.html' +'pagination' = '_partials/pagination.html' +'schema' = '_partials/schema.html' +'twitter_cards' = '_partials/twitter_cards.html' + +# Render hooks +'render-codeblock-goat' = '_markup/render-codeblock-goat.html' +'render-image' = '_markup/render-image.html' +'render-link' = '_markup/render-link.html' +'render-table' = '_markup/render-table.html' + +# Shortcodes +'details' = '_shortcodes/details.html' +'figure' = '_shortcodes/figure.html' +'gist' = '_shortcodes/gist.html' +'highlight' = '_shortcodes/highlight.html' +'instagram' = '_shortcodes/instagram.html' +'param' = '_shortcodes/param.html' +'qr' = '_shortcodes/qr.html' +'ref' = '_shortcodes/ref.html' +'relref' = '_shortcodes/relref.html' +'vimeo' = '_shortcodes/vimeo.html' +'vimeo_simple' = '_shortcodes/vimeo_simple.html' +'x' = '_shortcodes/x.html' +'x_simple' = '_shortcodes/x_simple.html' +'youtube' = '_shortcodes/youtube.html' + +# Other +'alias' = 'alias.html' +'robots' = 'robots.txt' +'rss' = 'rss.xml' +'sitemap' = 'sitemap.xml' +'sitemapindex' = 'sitemapindex.xml' diff --git a/docs/data/homepagetweets.toml b/docs/data/homepagetweets.toml index f5a2b5dbe..b440c21df 100644 --- a/docs/data/homepagetweets.toml +++ b/docs/data/homepagetweets.toml @@ -2,278 +2,264 @@ name = "Heinrich Hartmann" twitter_handle = "@heinrichhartman" quote = "Working with @GoHugoIO is such a joy. Having worked with #Jekyll in the past, the near instant preview is a big win! Did not expect this to make such a huge difference." -link = "https://twitter.com/heinrichhartman/status/1199736512264462341" +link = "https://x.com/heinrichhartman/status/1199736512264462341" date = 2019-11-12T00:00:00Z [[tweet]] name = "Joshua Steven‏‏" twitter_handle = "@jscarto" -quote = "Can't overstate how much I enjoy @GoHugoIO. My site is relatively small, but *18 ms* to build the whole thing made template development and proofing a breeze." -link = "https://twitter.com/jscarto/status/1039648827815485440" +quote = "Can't overstate how much I enjoy @GoHugoIO. My site is relatively small, but *18 ms* to build the whole thing made template development and proofing a breeze." +link = "https://x.com/jscarto/status/1039648827815485440" date = 2018-09-12T00:00:00Z -[[tweet]] -name = "Jens Munch" -twitter_handle = "@jensamunch" -quote = "Hugo is really, really incredible... Now does resizing/resampling of images as well! Crazy that something so fast can be a static site generator... Amazing open-source project." -link = "https://twitter.com/jensamunch/status/948533063537086464" -date = 2018-01-03T04:00:00Z - -[[tweet]] -name = "STOQE" -twitter_handle = "@STOQE" -quote = "I fear @GoHugoIO v0.22 might be so fast it creates a code vortex that time-warps me back to a time I used Wordpress. #gasp" -link = "https://twitter.com/STOQE/status/874184881701494784" -date = 2017-06-12T00:00:00Z - [[tweet]] name = "Christophe Diericx" twitter_handle = "@spcrngr_" -quote = "The more I use gohugo.io, the more I really like it. Super intuitive/powerful static site generator...great job @GoHugoIO" -link = "https://twitter.com/spcrngr_/status/870863020905435136" +quote = "The more I use gohugo.io, the more I really like it. Super intuitive/powerful static site generator...great job @GoHugoIO" +link = "https://x.com/spcrngr_/status/870863020905435136" date = 2017-06-03T00:00:00Z [[tweet]] name = "marcoscan" twitter_handle = "@marcoscan" -quote = "Blog migrated from @WordPress to @GoHugoIO, with a little refresh of my theme, Vim shortcuts and a full featured deploy script #gohugo" -link = "https://twitter.com/marcoscan/status/869661175960752129" +quote = "Blog migrated from @WordPress to @GoHugoIO, with a little refresh of my theme, Vim shortcuts and a full featured deploy script #gohugo" +link = "https://x.com/marcoscan/status/869661175960752129" date = 2017-05-30T00:00:00Z [[tweet]] name = "Sandra Kuipers" twitter_handle = "@SKuipersDesign" -quote = "Who knew static site building could be fun 🤔 Learning #gohugo today" -link = "https://twitter.com/SKuipersDesign/status/868796256902029312" +quote = "Who knew static site building could be fun 🤔 Learning #gohugo today" +link = "https://x.com/SKuipersDesign/status/868796256902029312" date = 2017-05-28T00:00:00Z [[tweet]] name = "Netlify" twitter_handle = "@Netlify" -quote = "Top Ten Static Site Generators of 2017. Congrats to the top 3: 1. @Jekyllrb 2. @GoHugoIO 3. @hexojs" -link = "https://twitter.com/Netlify/status/868122279221362688" +quote = "Top Ten Static Site Generators of 2017. Congrats to the top 3: 1. @Jekyllrb 2. @GoHugoIO 3. @hexojs" +link = "https://x.com/Netlify/status/868122279221362688" date = 2017-05-26T00:00:00Z [[tweet]] name = "Phil Hawksworth" twitter_handle = "@philhawksworth" -quote = "I've been keen on #JAMStack for some time, but @GoHugoIO is wooing me all over again. Great fun to build with. And speeeeedy." -link = "https://twitter.com/philhawksworth/status/866684170512326657" +quote = "I've been keen on #JAMStack for some time, but @GoHugoIO is wooing me all over again. Great fun to build with. And speeeeedy." +link = "https://x.com/philhawksworth/status/866684170512326657" date = 2017-05-22T00:00:00Z [[tweet]] name = "Aras Pranckevicius" twitter_handle = "@aras_p" -quote = "I've probably said it before...but having Hugo rebuild the whole website in 300ms is amazing. gohugo.io, #gohugo" -link = "https://twitter.com/aras_p/status/861157286823288832" +quote = "I've probably said it before...but having Hugo rebuild the whole website in 300ms is amazing. gohugo.io, #gohugo" +link = "https://x.com/aras_p/status/861157286823288832" date = 2017-05-07T00:00:00Z [[tweet]] name = "Hans Beck" twitter_handle = "@EnrichedGamesHB" -quote = "Diving deeper into @GoHugoIO. A lot of docs there, top work! But I've the impressed that #gohugo is far easier than its feels from the docs!" -link = "https://twitter.com/EnrichedGamesHB/status/836854762440130560" +quote = "Diving deeper into @GoHugoIO. A lot of docs there, top work! But I've the impressed that #gohugo is far easier than its feels from the docs!" +link = "https://x.com/EnrichedGamesHB/status/836854762440130560" date = 2017-03-01T00:00:00Z [[tweet]] name = "Alan Richardson" twitter_handle = "@eviltester" -quote = "I migrated the @BlackOpsTesting .com website from docpad to Hugo last weekend. http://gohugo.io/ Super Fast HTML Generation @spf13 " -link = "https://twitter.com/eviltester/status/553520335115808768" +quote = "I migrated the @BlackOpsTesting .com website from docpad to Hugo last weekend. http://gohugo.io/ Super Fast HTML Generation @spf13 " +link = "https://x.com/eviltester/status/553520335115808768" date = 2015-01-09T00:00:00Z [[tweet]] name = "Janez Čadež‏" twitter_handle = "@jamziSLO" -quote = "Building @garazaFRI website in #hugo. This static site generator is soooo damn fast! #gohugo #golang" -link = "https://twitter.com/jamziSLO/status/817720283977183234" +quote = "Building @garazaFRI website in #hugo. This static site generator is soooo damn fast! #gohugo #golang" +link = "https://x.com/jamziSLO/status/817720283977183234" date = 2017-01-07T00:00:00Z [[tweet]] name = "Execute‏‏" twitter_handle = "@executerun" -quote = "Hah, #gohugo. I was working with #gohugo on #linux but now I realised how easy is to set-up it on #windows. Just need to add binary to #path!" -link = "https://twitter.com/executerun/status/809753145270272005" +quote = "Hah, #gohugo. I was working with #gohugo on #linux but now I realised how easy is to set-up it on #windows. Just need to add binary to #path!" +link = "https://x.com/executerun/status/809753145270272005" date = 2016-12-16T00:00:00Z [[tweet]] name = "Baron Schwartz" twitter_handle = "@xaprb" -quote = "Hugo is impressively capable. It's a static site generator by @spf13 written in #golang . Just upgraded to latest release; very powerful. " -link = "https://twitter.com/xaprb/status/556894866488455169" +quote = "Hugo is impressively capable. It's a static site generator by @spf13 written in #golang . Just upgraded to latest release; very powerful. " +link = "https://x.com/xaprb/status/556894866488455169" date = 2015-01-18T00:00:00Z [[tweet]] name = "Dave Cottlehuber" twitter_handle = "@dch__" quote = "I just fell in love with #hugo, a static site/blog engine written by @spf13 in #golang + stellar docs" -link = "https://twitter.com/dch__/status/460158115498176512" +link = "https://x.com/dch__/status/460158115498176512" date = 2014-04-26T00:00:00Z [[tweet]] name = "David Caunt" twitter_handle = "@dcaunt" quote = "I had a play with Hugo and it was good, uses Markdown files for content" -link = "https://twitter.com/dcaunt/statuses/406466996277374976" +link = "https://x.com/dcaunt/statuses/406466996277374976" date = 2013-11-29T00:00:00Z [[tweet]] name = "David Gay" twitter_handle = "@oddshocks" quote = "Hugo is super-rad." -link = "https://twitter.com/oddshocks/statuses/405083217893421056" +link = "https://x.com/oddshocks/statuses/405083217893421056" date = 2013-11-25T00:00:00Z [[tweet]] name = "Diti" twitter_handle = "@DitiPengi" quote = "The dev version of Hugo is AWESOME! <3 I promise, I will try to learn go ASAP and help contribute to the project! Just too great!" -link = "https://twitter.com/DitiPengi/status/472470974051676160" +link = "https://x.com/DitiPengi/status/472470974051676160" date = 2014-05-30T00:00:00Z [[tweet]] name = "Douglas Stephen " twitter_handle = "@DougStephenJr" quote = "Even as a long-time Octopress fan, I’ve gotta admit that this project Hugo looks very very cool" -link = "https://twitter.com/DougStephenJr/statuses/364512471660249088" +link = "https://x.com/DougStephenJr/statuses/364512471660249088" date = 2013-08-05T00:00:00Z [[tweet]] name = "Hugo Rodger-Brown" twitter_handle = "@hugorodgerbrown" quote = "Finally someone builds me my own static site generator" -link = "https://twitter.com/hugorodgerbrown/statuses/364417910153818112" +link = "https://x.com/hugorodgerbrown/statuses/364417910153818112" date = 2013-05-08T00:00:00Z [[tweet]] name = "Hugo Roy" twitter_handle = "@hugoroyd" quote = "Finally the answer to the question my parents have been asking: What does Hugo do?" -link = "https://twitter.com/hugoroyd/status/501704796727173120" +link = "https://x.com/hugoroyd/status/501704796727173120" date = 2014-08-19T00:00:00Z [[tweet]] name = "Daniel Miessler" twitter_handle = "@DanielMiessler" quote = "Websites for named vulnerabilities should run on static site generator platforms like Hugo. Read-only + burst traffic = static." -link = "https://twitter.com/DanielMiessler/status/704703841673957376" +link = "https://x.com/DanielMiessler/status/704703841673957376" date = 2016-03-01T00:00:00Z [[tweet]] name = "Javier Segura" twitter_handle = "@jsegura" quote = "Another site generated with Hugo here! I'm getting in love with it." -link = "https://twitter.com/jsegura/status/465978434154659841" +link = "https://x.com/jsegura/status/465978434154659841" date = 2014-05-12T00:00:00Z [[tweet]] name = "Jim Biancolo" twitter_handle = "@jimbiancolo" quote = "I’m loving the static site generator renaissance we are currently enjoying. Hugo is new, looks great, written in Go" -link = "https://twitter.com/jimbiancolo/statuses/408678420348813314" +link = "https://x.com/jimbiancolo/statuses/408678420348813314" date = 2013-05-12T00:00:00Z [[tweet]] name = "Jip J. Dekker" twitter_handle = "@jipjdekker" quote = "Building a personal website in Hugo. Works like a charm. And written in @golang!" -link = "https://twitter.com/jipjdekker/status/413783548735152131" +link = "https://x.com/jipjdekker/status/413783548735152131" date = 2013-12-19T00:00:00Z [[tweet]] name = "Jose Gonzalvo" twitter_handle = "@jgonzalvo" quote = "Checking out Hugo; Loving it so far. Like Jekyll but not so blog-oriented and written in go" -link = "https://twitter.com/jgonzalvo/statuses/408177855819173888" +link = "https://x.com/jgonzalvo/statuses/408177855819173888" date = 2013-12-04T00:00:00Z [[tweet]] name = "Josh Matz" twitter_handle = "@joshmatz" quote = "A static site generator without the long build times? Yes, please!" -link = "https://twitter.com/joshmatz/statuses/364437436870696960" +link = "https://x.com/joshmatz/statuses/364437436870696960" date = 2013-08-05T00:00:00Z [[tweet]] name = "Kieran Healy" twitter_handle = "@kjhealy" quote = "OK, so in today's speed battle of static site generators, @spf13's hugo is kicking everyone's ass, by miles." -link = "https://twitter.com/kjhealy/status/437349384809115648" +link = "https://x.com/kjhealy/status/437349384809115648" date = 2014-02-22T00:00:00Z [[tweet]] name = "Ludovic Chabant" twitter_handle = "@ludovicchabant" quote = "Good work on Hugo, I’m impressed with the speed!" -link = "https://twitter.com/ludovicchabant/statuses/408806199602053120" +link = "https://x.com/ludovicchabant/statuses/408806199602053120" date = 2013-12-06T00:00:00Z [[tweet]] name = "Luke Holder" twitter_handle = "@lukeholder" quote = "this is AWESOME. a single little executable and so fast." -link = "https://twitter.com/lukeholder/status/430352287936946176" +link = "https://x.com/lukeholder/status/430352287936946176" date = 2014-02-03T00:00:00Z [[tweet]] name = "Markus Eliasson" twitter_handle = "@markuseliasson" quote = "Hugo is fast, dead simple to setup and well documented" -link = "https://twitter.com/markuseliasson/status/501594865877008384" +link = "https://x.com/markuseliasson/status/501594865877008384" date = 2014-08-19T00:00:00Z [[tweet]] name = "mercime" twitter_handle = "@mercime_one" quote = "Hugo: Makes the Web Fun Again" -link = "https://twitter.com/mercime_one/status/500547145087205377" +link = "https://x.com/mercime_one/status/500547145087205377" date = 2014-08-16T00:00:00Z [[tweet]] name = "Michael Whatcott" twitter_handle = "@mdwhatcott" quote = "One more satisfied #Hugo blogger. Thanks @spf13 and friends!" -link = "https://twitter.com/mdwhatcott/status/469980686531571712" +link = "https://x.com/mdwhatcott/status/469980686531571712" date = 2014-05-23T00:00:00Z [[tweet]] name = "Nathan Toups" twitter_handle = "@rojoroboto" quote = "I love Hugo! My site is generated with it now http://rjrbt.io" -link = "https://twitter.com/rojoroboto/status/423439915620106242" +link = "https://x.com/rojoroboto/status/423439915620106242" date = 2014-01-15T00:00:00Z [[tweet]] name = "Ruben Solvang" twitter_handle = "@messo85" quote = "#Hugo is the new @jekyllrb / @middlemanapp! Faster, easier and runs everywhere." -link = "https://twitter.com/messo85/status/472825062027182081" +link = "https://x.com/messo85/status/472825062027182081" date = 2014-05-31T00:00:00Z [[tweet]] name = "Ryan Martinsen" twitter_handle = "@popthestack" quote = "Also, I re-launched my blog (it looks the same as before) using Hugo, a *fast* static engine. Very happy with it. gohugo.io" -link = "https://twitter.com/popthestack/status/549972754125307904" +link = "https://x.com/popthestack/status/549972754125307904" date = 2014-12-30T00:00:00Z [[tweet]] name = "The Lone Cuber" twitter_handle = "@TheLoneCuber" quote = "Jekyll is dead to me these days though... long live Hugo! Hugo is *by far* the best in its field. Thanks for making it happen." -link = "https://twitter.com/TheLoneCuber/status/495716684456398848" +link = "https://x.com/TheLoneCuber/status/495716684456398848" date = 2014-08-02T00:00:00Z [[tweet]] name = "The Lone Cuber" twitter_handle = "@TheLoneCuber" quote = "Finally, a publishing platform that's a joy to use. #NoMoreBarriers" -link = "https://twitter.com/TheLoneCuber/status/495731334711488512" +link = "https://x.com/TheLoneCuber/status/495731334711488512" date = 2014-08-02T00:00:00Z [[tweet]] name = "WorkHTML" twitter_handle = "@workhtml" -quote = " #Hugo A very good alternative for #wordpress !!! A fast and modern static website engine gohugo.io " -link = "https://twitter.com/workhtml/status/563064361301053440" +quote = " #Hugo A very good alternative for #wordpress !!! A fast and modern static website engine gohugo.io " +link = "https://x.com/workhtml/status/563064361301053440" date = 2015-02-04T00:00:00Z diff --git a/docs/data/keywords.yaml b/docs/data/keywords.yaml new file mode 100644 index 000000000..106929884 --- /dev/null +++ b/docs/data/keywords.yaml @@ -0,0 +1,12 @@ +# We use the front matter keywords field to determine related content. To +# ensure consistency, during site build we validate each keyword against the +# entries in data/keywords.yaml. + +# As of March 5, 2025, this feature is experimental, pending usability +# assessment. We anticipate that the number of additions to data/keywords.yaml +# will decrease over time, though the initial implementation will require some +# effort. + +- menu +- resource +- highlight diff --git a/docs/data/page_filters.yaml b/docs/data/page_filters.yaml new file mode 100644 index 000000000..82de6168e --- /dev/null +++ b/docs/data/page_filters.yaml @@ -0,0 +1,95 @@ +# Do not delete. Required for layouts/shortcodes/list-pages-in-section.html. +# +# When calling the list-pages-in-section shortcode, you can specify a page +# filter, and whether the pages in the filter should be included or excluded +# from the list. +# +# For example: +# +# {{% list-pages-in-section path=/functions/images filter=functions_images_no_filters filterType=exclude %}} + +functions_fmt_logging: + - /functions/fmt/errorf + - /functions/fmt/erroridf + - /functions/fmt/warnf + - /functions/fmt/warnidf +functions_images_no_filters: + - /functions/images/filter + - /functions/images/config +methods_site_multilingual: + - /methods/site/ismultilingual + - /methods/site/language + - /methods/site/languageprefix + - /methods/site/languages +methods_site_page_collections: + - /methods/site/allpages + - /methods/site/pages + - /methods/site/regularpages + - /methods/site/sections +methods_page_dates: + - /methods/page/date + - /methods/page/expirydate + - /methods/page/lastmod + - /methods/page/publishdate +methods_page_menu: + - /methods/page/hasmenucurrent + - /methods/page/ismenucurrent +methods_page_multilingual: + - /methods/page/alltranslations + - /methods/page/istranslated + - /methods/page/language + - /methods/page/translationkey + - /methods/page/translations +methods_page_page_collections: + - /methods/page/pages + - /methods/page/regularpages + - /methods/page/regularpagesrecursive + - /methods/page/sections +methods_page_parameters: + - /methods/page/param + - /methods/page/params +methods_page_sections: + - /methods/page/ancestors + - /methods/page/currentsection + - /methods/page/firstsection + - /methods/page/insection + - /methods/page/isancestor + - /methods/page/isdescendant + - /methods/page/parent + - /methods/page/sections + - /methods/page/section +methods_pages_sort: + - /methods/pages/bydate + - /methods/pages/byexpirydate + - /methods/pages/bylanguage + - /methods/pages/bylastmod + - /methods/pages/bylength + - /methods/pages/bylinktitle + - /methods/pages/byparam + - /methods/pages/bypublishdate + - /methods/pages/bytitle + - /methods/pages/byweight + - /methods/pages/reverse +methods_pages_group: + - /methods/pages/groupby + - /methods/pages/groupbydate + - /methods/pages/groupbyexpirydate + - /methods/pages/groupbylastmod + - /methods/pages/groupbyparam + - /methods/pages/groupbyparamdate + - /methods/pages/groupbypublishdate + - /methods/pages/groupbydate + - /methods/pages/groupbydate + - /methods/pages/groupbydate + - /methods/pages/groupbydate + - /methods/pages/groupbydate + - /methods/pages/groupbydate + - /methods/pages/reverse +methods_pages_navigation: + - /methods/pages/next + - /methods/pages/prev +methods_page_navigation: + - /methods/page/next + - /methods/page/nextinsection + - /methods/page/prev + - /methods/page/previnsection diff --git a/docs/data/sponsors.toml b/docs/data/sponsors.toml new file mode 100644 index 000000000..705ca9746 --- /dev/null +++ b/docs/data/sponsors.toml @@ -0,0 +1,22 @@ +[[banners]] + name = "Linode" + link = "https://www.linode.com/" + logo = "images/sponsors/linode-logo.svg" + utm_campaign = "hugosponsor" + bgcolor = "#ffffff" + +[[banners]] + name = "GoLand" + title = "The complete IDE crafted for professional Go developers." + no_query_params = true + link = "https://www.jetbrains.com/go/?utm_source=OSS&utm_medium=referral&utm_campaign=hugo" + logo = "images/sponsors/goland.svg" + bgcolor = "#f4f4f4" + +[[banners]] + name = "Your Company?" + link = "https://bep.is/en/hugo-sponsor-2023-01/" + utm_campaign = "hugosponsor" + show_on_hover = true + bgcolor = "#4e4f4f" + link_attr = "style='color: #ffffff; font-weight: bold; text-decoration: none; text-align: center'" diff --git a/docs/data/titles.toml b/docs/data/titles.toml deleted file mode 100644 index 2348c8561..000000000 --- a/docs/data/titles.toml +++ /dev/null @@ -1,2 +0,0 @@ -[Showcase] -title = "Site Showcase" diff --git a/docs/go.mod b/docs/go.mod index a87bb930e..4b9e0a369 100644 --- a/docs/go.mod +++ b/docs/go.mod @@ -1,5 +1,3 @@ module github.com/gohugoio/hugoDocs -go 1.12 - -require github.com/gohugoio/gohugoioTheme v0.0.0-20191021162625-2e7250ca437d // indirect +go 1.22.0 diff --git a/docs/go.sum b/docs/go.sum index 8f67936e0..af9b5febf 100644 --- a/docs/go.sum +++ b/docs/go.sum @@ -1,5 +1,2 @@ -github.com/gohugoio/gohugoioTheme v0.0.0-20190808163145-07b3c0f73b02/go.mod h1:kpw3SS48xZvLQGEXKu8u5XHgXkPvL8DX3oGa07+z8Bs= -github.com/gohugoio/gohugoioTheme v0.0.0-20191014144142-1f3a01deed7b h1:PWNjl46fvtz54PKO0BdiXOF6/4L/uCP0F3gtcCxGrJs= -github.com/gohugoio/gohugoioTheme v0.0.0-20191014144142-1f3a01deed7b/go.mod h1:kpw3SS48xZvLQGEXKu8u5XHgXkPvL8DX3oGa07+z8Bs= -github.com/gohugoio/gohugoioTheme v0.0.0-20191021162625-2e7250ca437d h1:D3DcaYkuJbotdWNNAQpQl37txX4HQ6R5uMHoxVmTw0w= -github.com/gohugoio/gohugoioTheme v0.0.0-20191021162625-2e7250ca437d/go.mod h1:kpw3SS48xZvLQGEXKu8u5XHgXkPvL8DX3oGa07+z8Bs= +github.com/gohugoio/gohugoioTheme v0.0.0-20250116152525-2d382cae7743 h1:gjoqq8+RnGwpuU/LQVYGGR/LsDplrfUjOabWwoROYsM= +github.com/gohugoio/gohugoioTheme v0.0.0-20250116152525-2d382cae7743/go.mod h1:GOYeAPQJ/ok8z7oz1cjfcSlsFpXrmx6VkzQ5RpnyhZM= diff --git a/docs/hugo.toml b/docs/hugo.toml new file mode 100644 index 000000000..e8373a87c --- /dev/null +++ b/docs/hugo.toml @@ -0,0 +1,171 @@ +baseURL = "https://gohugo.io/" +defaultContentLanguage = "en" +enableEmoji = true +pluralizeListTitles = false +timeZone = "Europe/Oslo" +title = "Hugo" + +# We do redirects via Netlify's _redirects file, generated by Hugo (see "outputs" below). +disableAliases = true + +[build] + [build.buildStats] + disableIDs = true + enable = true + [[build.cachebusters]] + source = "assets/notwatching/hugo_stats\\.json" + target = "css" + [[build.cachebusters]] + source = "(postcss|tailwind)\\.config\\.js" + target = "css" + +[caches] + [caches.images] + dir = ":cacheDir/images" + maxAge = "1440h" + [caches.getresource] + dir = ':cacheDir/:project' + maxAge = "1h" + +[cascade] + [cascade.params] + hide_in_this_section = true + show_publish_date = true + [cascade.target] + kind = 'page' + path = '{/news/**}' + +[frontmatter] + date = ['date'] # do not add publishdate; it will affect page sorting + expiryDate = ['expirydate'] + lastmod = [':git', 'lastmod', 'publishdate', 'date'] + publishDate = ['publishdate', 'date'] + +[languages] + [languages.en] + languageCode = "en-US" + languageName = "English" + weight = 1 + +[markup] + [markup.goldmark] + [markup.goldmark.extensions] + [markup.goldmark.extensions.typographer] + disable = false + [markup.goldmark.extensions.passthrough] + enable = true + [markup.goldmark.extensions.passthrough.delimiters] + block = [['\[', '\]'], ['$$', '$$']] + inline = [['\(', '\)']] + [markup.goldmark.parser] + autoDefinitionTermID = true + [markup.goldmark.parser.attribute] + block = true + [markup.highlight] + lineNumbersInTable = false + noClasses = false + style = 'solarized-dark' + wrapperClass = 'highlight not-prose' + +[mediaTypes] + [mediaTypes."text/netlify"] + delimiter = "" + +[module] + [module.hugoVersion] + min = "0.144.0" + [[module.mounts]] + source = "assets" + target = "assets" + [[module.mounts]] + lang = 'en' + source = 'content/en' + target = 'content' + [[module.mounts]] + disableWatch = true + source = "hugo_stats.json" + target = "assets/notwatching/hugo_stats.json" + +[outputFormats] + [outputFormats.redir] + baseName = "_redirects" + isPlainText = true + mediatype = "text/netlify" + [outputFormats.headers] + baseName = "_headers" + isPlainText = true + mediatype = "text/netlify" + notAlternative = true + +[outputs] + home = ["html", "rss", "redir", "headers"] + page = ["html"] + section = ["html"] + taxonomy = ["html"] + term = ["html"] + +[params] + description = "The world’s fastest framework for building websites" + ghrepo = "https://github.com/gohugoio/hugoDocs/" + [params.render_hooks.link] + errorLevel = 'warning' # ignore (default), warning, or error (fails the build) + +[related] + includeNewer = true + threshold = 80 + toLower = true + [[related.indices]] + name = 'keywords' + weight = 1 + +[security] + [security.funcs] + getenv = ['^HUGO_', '^REPOSITORY_URL$', '^BRANCH$'] + +[server] + [[server.headers]] + for = "/*" + [server.headers.values] + X-Frame-Options = "DENY" + X-XSS-Protection = "1; mode=block" + X-Content-Type-Options = "nosniff" + Referrer-Policy = "no-referrer" + [[server.headers]] + for = "/**.{css,js}" + +[services] + [services.googleAnalytics] + ID = 'G-MBZGKNMDWC' + +[taxonomies] +category = 'categories' + +######## GLOBAL ITEMS TO BE SHARED WITH THE HUGO SITES ######## +[menus] + [[menus.global]] + identifier = 'news' + name = 'News' + pageRef = '/news/' + weight = 1 + [[menus.global]] + identifier = 'docs' + name = 'Docs' + url = '/documentation/' + weight = 5 + [[menus.global]] + identifier = 'themes' + name = 'Themes' + url = 'https://themes.gohugo.io/' + weight = 10 + [[menus.global]] + identifier = 'community' + name = 'Community' + post = 'external' + url = 'https://discourse.gohugo.io/' + weight = 150 + [[menus.global]] + identifier = 'github' + name = 'GitHub' + post = 'external' + url = 'https://github.com/gohugoio/hugo' + weight = 200 diff --git a/docs/hugo.work b/docs/hugo.work new file mode 100644 index 000000000..02c0ba91f --- /dev/null +++ b/docs/hugo.work @@ -0,0 +1,4 @@ +go 1.22.0 + +use . + diff --git a/docs/hugoreleaser.yaml b/docs/hugoreleaser.yaml new file mode 100644 index 000000000..9f8671e06 --- /dev/null +++ b/docs/hugoreleaser.yaml @@ -0,0 +1,29 @@ +project: hugoDocs +release_settings: + name: ${HUGORELEASER_TAG} + type: github + repository: hugoDocs + repository_owner: gohugoio + draft: true + prerelease: false + release_notes_settings: + generate: true + generate_on_host: false + short_threshold: 10 + short_title: What's Changed + groups: + - regexp: snapcraft:|Merge commit|Merge branch|netlify:|release:|Squashed + ignore: true + - title: Typo fixes + regexp: typo + ordinal: 20 + - title: Dependency Updates + regexp: deps + ordinal: 30 + - title: Improvements + regexp: .* + ordinal: 10 +releases: + - paths: + - archives/** + path: myrelease diff --git a/docs/layouts/404.html b/docs/layouts/404.html new file mode 100644 index 000000000..6d962ffc0 --- /dev/null +++ b/docs/layouts/404.html @@ -0,0 +1,22 @@ +{{ define "main" }} +
    +
    +

    + Page not found + gopher +

    + + +
    +
    +{{ end }} diff --git a/docs/layouts/_markup/render-blockquote.html b/docs/layouts/_markup/render-blockquote.html new file mode 100644 index 000000000..98019e12d --- /dev/null +++ b/docs/layouts/_markup/render-blockquote.html @@ -0,0 +1,33 @@ +{{- if eq .Type "alert" }} + {{- $alerts := dict + "caution" (dict "color" "red" "icon" "exclamation-triangle") + "important" (dict "color" "blue" "icon" "exclamation-circle") + "note" (dict "color" "blue" "icon" "information-circle") + "tip" (dict "color" "green" "icon" "light-bulb") + "warning" (dict "color" "orange" "icon" "exclamation-triangle") + }} + + {{- $alertTypes := slice }} + {{- range $k, $_ := $alerts }} + {{- $alertTypes = $alertTypes | append $k }} + {{- end }} + {{- $alertTypes = $alertTypes | sort }} + + {{- $alertType := strings.ToLower .AlertType }} + {{- if in $alertTypes $alertType }} + {{- partial "layouts/blocks/alert.html" (dict + "color" (or ((index $alerts $alertType).color) "blue") + "icon" (or ((index $alerts $alertType).icon) "information-circle") + "text" .Text + "title" .AlertTitle + "class" .Attributes.class + ) + }} + {{- else }} + {{- errorf `Invalid blockquote alert type. Received %s. Expected one of %s (case-insensitive). See %s.` .AlertType (delimit $alertTypes ", " ", or ") .Page.String }} + {{- end }} +{{- else }} +
    + {{ .Text }} +
    +{{- end }} diff --git a/docs/layouts/_markup/render-codeblock.html b/docs/layouts/_markup/render-codeblock.html new file mode 100644 index 000000000..13725ffcd --- /dev/null +++ b/docs/layouts/_markup/render-codeblock.html @@ -0,0 +1,98 @@ +{{/* prettier-ignore-start */}} +{{/* +Renders a highlighted code block using the given options and attributes. + +In addition to the options available to the transform.Highlight function, you +may also specify the following parameters: + +@param {bool} [copy=false] Whether to display a copy-to-clipboard button. +@param {string} [file] The file name to display above the rendered code. +@param {bool} [details=false] Whether to wrap the highlighted code block within a details element. +@param {bool} [open=false] Whether to initially display the content of the details element. +@param {string} [summary=Details] The content of the details summary element rendered from Markdown to HTML. + +@returns {template.HTML} + +@examples + + ```go + fmt.Println("Hello world!") + ``` + + ```go {linenos=true file="layouts/index.html" copy=true} + fmt.Println("Hello world!") + ``` +*/}} +{{/* prettier-ignore-end */}} + +{{- $copy := false }} +{{- $file := or .Attributes.file "" }} +{{- $details := false }} +{{- $open := "" }} +{{- $summary := or .Attributes.summary "Details" | .Page.RenderString }} +{{- $ext := strings.TrimPrefix "." (path.Ext $file) }} +{{- $lang := or .Type $ext "text" }} +{{- if in (slice "html" "gotmpl") $lang }} + {{- $lang = "go-html-template" }} +{{- end }} +{{- if eq $lang "md" }} + {{- $lang = "text" }} +{{- end }} + +{{- with .Attributes.copy }} + {{- if in (slice true "true" 1) . }} + {{- $copy = true }} + {{- else if in (slice false "false" 0) . }} + {{- $copy = false }} + {{- end }} +{{- end }} + +{{- with .Attributes.details }} + {{- if in (slice true "true" 1) . }} + {{- $details = true }} + {{- else if in (slice false "false" 0) . }} + {{- $details = false }} + {{- end }} +{{- end }} + +{{- with .Attributes.open }} + {{- if in (slice true "true" 1) . }} + {{- $open = "open" }} + {{- else if in (slice false "false" 0) . }} + {{- $open = "" }} + {{- end }} +{{- end }} + +{{- if $details }} +
    + {{ $summary }} +{{- end }} + +
    + {{- $fileSelectClass := "select-none" }} + {{- if $copy }} + {{- $fileSelectClass = "select-text" }} + + + + {{- end }} + {{- with $file }} +
    + {{ . }} +
    + {{- end }} + +
    + {{- transform.Highlight (strings.TrimSpace .Inner) $lang .Options }} +
    +
    + +{{- if $details }} +
    +{{- end }} diff --git a/docs/layouts/_markup/render-link.html b/docs/layouts/_markup/render-link.html new file mode 100644 index 000000000..70011220e --- /dev/null +++ b/docs/layouts/_markup/render-link.html @@ -0,0 +1,320 @@ +{{/* prettier-ignore-start */ -}} +{{- /* Last modified: 2025-01-19T14:44:56-08:00 */}} + +{{- /* +Copyright 2025 Veriphor LLC + +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 + +https://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. +*/}} + +{{- /* +This render hook resolves internal destinations by looking for a matching: + + 1. Content page + 2. Page resource (a file in the current page bundle) + 3. Section resource (a file in the current section) + 4. Global resource (a file in the assets directory) + +It skips the section resource lookup if the current page is a leaf bundle. + +External destinations are not modified. + +You must place global resources in the assets directory. If you have placed +your resources in the static directory, and you are unable or unwilling to move +them, you must mount the static directory to the assets directory by including +both of these entries in your site configuration: + + [[module.mounts]] + source = 'assets' + target = 'assets' + + [[module.mounts]] + source = 'static' + target = 'assets' + +By default, if this render hook is unable to resolve a destination, including a +fragment if present, it passes the destination through without modification. To +emit a warning or error, set the error level in your site configuration: + + [params.render_hooks.link] + errorLevel = 'warning' # ignore (default), warning, or error (fails the build) + +When you set the error level to warning, and you are in a development +environment, you can visually highlight broken internal links: + + [params.render_hooks.link] + errorLevel = 'warning' # ignore (default), warning, or error (fails the build) + highlightBroken = true # true or false (default) + +This will add a "broken" class to anchor elements with invalid src attributes. +Add a rule to your CSS targeting the broken links: + + a.broken { + background: #ff0; + border: 2px solid #f00; + padding: 0.1em 0.2em; + } + +This render hook may be unable to resolve destinations created with the ref and +relref shortcodes. Unless you set the error level to ignore you should not use +either of these shortcodes in conjunction with this render hook. + +@context {string} Destination The link destination. +@context {page} Page A reference to the page containing the link. +@context {string} PlainText The link description as plain text. +@context {string} Text The link description. +@context {string} Title The link title. + +@returns {template.html} +*/ -}} +{{/* prettier-ignore-end */ -}} +{{- /* Initialize. */}} +{{- $renderHookName := "link" }} + +{{- /* Verify minimum required version. */}} +{{- $minHugoVersion := "0.141.0" }} +{{- if lt hugo.Version $minHugoVersion }} + {{- errorf "The %q render hook requires Hugo v%s or later." $renderHookName $minHugoVersion }} +{{- end }} + +{{- /* Error level when unable to resolve destination: ignore, warning, or error. */}} +{{- $errorLevel := or site.Params.render_hooks.link.errorLevel "ignore" | lower }} + +{{- /* If true, adds "broken" class to broken links. Applicable in development environment when errorLevel is warning. */}} +{{- $highlightBrokenLinks := or site.Params.render_hooks.link.highlightBroken false }} + +{{- /* Validate error level. */}} +{{- if not (in (slice "ignore" "warning" "error") $errorLevel) }} + {{- errorf "The %q render hook is misconfigured. The errorLevel %q is invalid. Please check your site configuration." $renderHookName $errorLevel }} +{{- end }} + +{{- /* Determine content path for warning and error messages. */}} +{{- $contentPath := .Page.String }} + +{{- /* Parse destination. */}} +{{- $u := urls.Parse .Destination }} + +{{- /* Set common message. */}} +{{- $msg := printf "The %q render hook was unable to resolve the destination %q in %s" $renderHookName $u.String $contentPath }} + +{{- /* Set attributes for anchor element. */}} +{{- $attrs := dict "href" $u.String }} +{{- if eq $u.String "g" }} + {{- /* Destination is a glossary term. */}} + {{- $ctx := dict + "contentPath" $contentPath + "errorLevel" $errorLevel + "renderHookName" $renderHookName + "text" .Text + }} + {{- $attrs = partial "inline/h-rh-l/get-glossary-link-attributes.html" $ctx }} +{{- else if $u.IsAbs }} + {{- /* Destination is a remote resource. */}} + {{- $attrs = merge $attrs (dict "rel" "external") }} +{{- else }} + {{- with $u.Path }} + {{- with $p := or ($.PageInner.GetPage .) ($.PageInner.GetPage (strings.TrimRight "/" .)) }} + {{- /* Destination is a page. */}} + {{- $href := .RelPermalink }} + {{- with $u.RawQuery }} + {{- $href = printf "%s?%s" $href . }} + {{- end }} + {{- with $u.Fragment }} + {{- $ctx := dict + "contentPath" $contentPath + "errorLevel" $errorLevel + "page" $p + "parsedURL" $u + "renderHookName" $renderHookName + }} + {{- partial "inline/h-rh-l/validate-fragment.html" $ctx }} + {{- $href = printf "%s#%s" $href . }} + {{- end }} + {{- $attrs = dict "href" $href }} + {{- else with $.PageInner.Resources.Get $u.Path }} + {{- /* Destination is a page resource; drop query and fragment. */}} + {{- $attrs = dict "href" .RelPermalink }} + {{- else with (and (ne $.Page.BundleType "leaf") ($.Page.CurrentSection.Resources.Get $u.Path)) }} + {{- /* Destination is a section resource, and current page is not a leaf bundle. */}} + {{- $attrs = dict "href" .RelPermalink }} + {{- else with resources.Get $u.Path }} + {{- /* Destination is a global resource; drop query and fragment. */}} + {{- $attrs = dict "href" .RelPermalink }} + {{- else }} + {{- if eq $errorLevel "warning" }} + {{- warnf $msg }} + {{- if and $highlightBrokenLinks hugo.IsDevelopment }} + {{- $attrs = merge $attrs (dict "class" "broken") }} + {{- end }} + {{- else if eq $errorLevel "error" }} + {{- errorf $msg }} + {{- end }} + {{- end }} + {{- else }} + {{- with $u.Fragment }} + {{- /* Destination is on the same page; prepend relative permalink. */}} + {{- $ctx := dict + "contentPath" $contentPath + "errorLevel" $errorLevel + "page" $.Page + "parsedURL" $u + "renderHookName" $renderHookName + }} + {{- partial "inline/h-rh-l/validate-fragment.html" $ctx }} + {{- $attrs = dict "href" (printf "%s#%s" $.Page.RelPermalink .) }} + {{- else }} + {{- if eq $errorLevel "warning" }} + {{- warnf $msg }} + {{- if and $highlightBrokenLinks hugo.IsDevelopment }} + {{- $attrs = merge $attrs (dict "class" "broken") }} + {{- end }} + {{- else if eq $errorLevel "error" }} + {{- errorf $msg }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} + +{{- /* Render anchor element. */ -}} +{{ .Text }} + +{{- define "_partials/inline/h-rh-l/validate-fragment.html" }} + {{- /* + Validates the fragment portion of a link destination. + + @context {string} contentPath The page containing the link. + @context {string} errorLevel The error level when unable to resolve destination; ignore (default), warning, or error. + @context {page} page The page corresponding to the link destination + @context {struct} parsedURL The link destination parsed by urls.Parse. + @context {string} renderHookName The name of the render hook. + */}} + + {{- /* Initialize. */}} + {{- $contentPath := .contentPath }} + {{- $errorLevel := .errorLevel }} + {{- $p := .page }} + {{- $u := .parsedURL }} + {{- $renderHookName := .renderHookName }} + + {{- /* Validate. */}} + {{- with $u.Fragment }} + {{- if $p.Fragments.Identifiers.Contains . }} + {{- if gt ($p.Fragments.Identifiers.Count .) 1 }} + {{- $msg := printf "The %q render hook detected duplicate heading IDs %q in %s" $renderHookName . $contentPath }} + {{- if eq $errorLevel "warning" }} + {{- warnf $msg }} + {{- else if eq $errorLevel "error" }} + {{- errorf $msg }} + {{- end }} + {{- end }} + {{- else }} + {{- /* Determine target path for warning and error message. */}} + {{- $targetPath := "" }} + {{- with $p.File }} + {{- $targetPath = .Path }} + {{- else }} + {{- $targetPath = .Path }} + {{- end }} + {{- /* Set common message. */}} + {{- $msg := printf "The %q render hook was unable to find heading ID %q in %s. See %s" $renderHookName . $targetPath $contentPath }} + {{- if eq $targetPath $contentPath }} + {{- $msg = printf "The %q render hook was unable to find heading ID %q in %s" $renderHookName . $targetPath }} + {{- end }} + {{- /* Throw warning or error. */}} + {{- if eq $errorLevel "warning" }} + {{- warnf $msg }} + {{- else if eq $errorLevel "error" }} + {{- errorf $msg }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} + +{{- define "_partials/inline/h-rh-l/get-glossary-link-attributes.html" }} + {{- /* + Returns the anchor element attributes for a link to the given glossary term. + + It first checks for the existence of a glossary page for the given term. If + no page is found, it then checks for a glossary page for the singular form of + the term. If neither page exists it throws a warning or error dependent on + the errorLevel setting + + The returned href attribute does not point to the glossary term page. + Instead, via its fragment, it points to an entry on the glossary page. + + @context {string} contentPath The page containing the link. + @context {string} errorLevel The error level when unable to resolve destination; ignore (default), warning, or error. + @context {string} renderHookName The name of the render hook. + @context {string} text The link text. + */}} + + {{- /* Get context.. */}} + {{- $contentPath := .contentPath }} + {{- $errorLevel := .errorLevel }} + {{- $renderHookName := .renderHookName }} + {{- $text := .text | transform.Plainify | strings.ToLower }} + + {{- /* Initialize. */}} + {{- $glossaryPath := "/quick-reference/glossary" }} + {{- $termGiven := $text }} + {{- $termActual := "" }} + {{- $termSingular := inflect.Singularize $termGiven }} + + {{- /* Verify that the glossary page exists. */}} + {{- $glossaryPage := site.GetPage $glossaryPath }} + {{- if not $glossaryPage }} + {{- errorf "The %q render hook was unable to find %s: see %s" $renderHookName $glossaryPath $contentPath }} + {{- end }} + + {{- /* There's a better way to handle this, but it works for now. */}} + {{- $cheating := dict + "chaining" "chain" + "localize" "localization" + "localized" "localization" + "paginating" "paginate" + "walking" "walk" + "ci/cd" "cicd" + }} + + {{- /* Verify that a glossary term page exists for the given term. */}} + {{- if site.GetPage (urls.JoinPath $glossaryPath ($termGiven | urlize)) }} + {{- $termActual = $termGiven }} + {{- else if site.GetPage (urls.JoinPath $glossaryPath ($termSingular | urlize)) }} + {{- $termActual = $termSingular }} + {{- else }} + {{- $termToTest := index $cheating $termGiven }} + {{- if site.GetPage (urls.JoinPath $glossaryPath ($termToTest | urlize)) }} + {{- $termActual = $termToTest }} + {{- end }} + {{- end }} + + {{- if not $termActual }} + {{- errorf "The %q render hook was unable to find a glossary page for either the singular or plural form of the term %q: see %s" $renderHookName $termGiven $contentPath }} + {{- end }} + + {{- /* Create the href attribute. */}} + {{- $href := "" }} + {{- if $termActual }} + {{- $href = fmt.Printf "%s#%s" $glossaryPage.RelPermalink (anchorize $termActual) }} + {{- end }} + + {{- return (dict "href" $href) }} +{{- end -}} diff --git a/docs/layouts/_markup/render-passthrough.html b/docs/layouts/_markup/render-passthrough.html new file mode 100644 index 000000000..0ed001133 --- /dev/null +++ b/docs/layouts/_markup/render-passthrough.html @@ -0,0 +1,9 @@ +{{- $opts := dict "output" "htmlAndMathml" "displayMode" (eq .Type "block") }} +{{- with try (transform.ToMath .Inner $opts) }} + {{- with .Err }} + {{ errorf "Unable to render mathematical markup to HTML using the transform.ToMath function. The KaTeX display engine threw the following error: %s: see %s." . $.Position }} + {{- else }} + {{- .Value }} + {{- $.Page.Store.Set "hasMath" true }} + {{- end }} +{{- end -}} diff --git a/docs/layouts/_markup/render-table.html b/docs/layouts/_markup/render-table.html new file mode 100644 index 000000000..7f3a88601 --- /dev/null +++ b/docs/layouts/_markup/render-table.html @@ -0,0 +1,31 @@ +
    + + + {{- range .THead }} + + {{- range . }} + + {{- end }} + + {{- end }} + + + {{- range .TBody }} + + {{- range . }} + + {{- end }} + + {{- end }} + +
    + {{- .Text -}} +
    + {{- .Text -}} +
    +
    diff --git a/docs/layouts/_partials/docs/functions-aliases.html b/docs/layouts/_partials/docs/functions-aliases.html new file mode 100644 index 000000000..b3a5a7607 --- /dev/null +++ b/docs/layouts/_partials/docs/functions-aliases.html @@ -0,0 +1,12 @@ +{{- with .Params.functions_and_methods.aliases }} + {{- $label := "Alias" }} + {{- if gt (len .) 1 }} + {{- $label = "Aliases" }} + {{- end }} +

    {{ $label }}

    + {{- range . }} +
    + {{- . -}} +
    + {{- end }} +{{- end -}} diff --git a/docs/layouts/_partials/docs/functions-return-type.html b/docs/layouts/_partials/docs/functions-return-type.html new file mode 100644 index 000000000..75c97f8d9 --- /dev/null +++ b/docs/layouts/_partials/docs/functions-return-type.html @@ -0,0 +1,6 @@ +{{- with .Params.functions_and_methods.returnType }} +

    Returns

    +
    + {{- . -}} +
    +{{- end -}} diff --git a/docs/layouts/_partials/docs/functions-signatures.html b/docs/layouts/_partials/docs/functions-signatures.html new file mode 100644 index 000000000..6fb61df8e --- /dev/null +++ b/docs/layouts/_partials/docs/functions-signatures.html @@ -0,0 +1,12 @@ +{{- with .Params.functions_and_methods.signatures }} +

    Syntax

    + {{- range . }} + {{- $signature := . }} + {{- if $.Params.function.returnType }} + {{- $signature = printf "%s ⟼ %s" . $.Params.function.returnType }} + {{- end }} +
    + {{- $signature -}} +
    + {{- end }} +{{- end -}} diff --git a/docs/layouts/_partials/helpers/debug/list-item-metadata.html b/docs/layouts/_partials/helpers/debug/list-item-metadata.html new file mode 100644 index 000000000..d027484b5 --- /dev/null +++ b/docs/layouts/_partials/helpers/debug/list-item-metadata.html @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + +
    weight: + {{ .Weight }} +
    keywords: + {{ delimit (or .Keywords "") ", " }} +
    categories: + {{ delimit (or .Params.categories "") ", " }} +
    diff --git a/docs/layouts/_partials/helpers/funcs/color-from-string.html b/docs/layouts/_partials/helpers/funcs/color-from-string.html new file mode 100644 index 000000000..cd599530b --- /dev/null +++ b/docs/layouts/_partials/helpers/funcs/color-from-string.html @@ -0,0 +1,25 @@ +{{ $colors := slice "slate" "green" "cyan" "blue" }} +{{ with .single }} + {{ $colors = slice . }} +{{ end }} + +{{ $shades := slice 300 400 500 }} +{{ if not .dark }} + {{ $shades = slice 700 800 }} +{{ end }} +{{ $hash := (hash.FNV32a .text) }} +{{ $i := mod $hash (len $colors) }} +{{ $j := mod $hash (len $shades) }} +{{ $color := index $colors $i }} +{{ $shade1 := index $shades $j }} +{{ $shade2 := 0 }} +{{ $shade3 := 0 }} +{{ if gt $shade1 500 }} + {{ $shade2 = math.Min (sub $shade1 500) 100 | int }} + {{ $shade3 = sub $shade1 100 }} +{{ else }} + {{ $shade2 = math.Max (add $shade1 500) 700 | int }} + {{ $shade3 = add $shade1 200 }} +{{ end }} +{{ $res := dict "color" $color "shade1" $shade1 "shade2" $shade2 "shade3" $shade3 }} +{{ return $res }} diff --git a/docs/layouts/_partials/helpers/funcs/get-github-info.html b/docs/layouts/_partials/helpers/funcs/get-github-info.html new file mode 100644 index 000000000..7e2dc89fa --- /dev/null +++ b/docs/layouts/_partials/helpers/funcs/get-github-info.html @@ -0,0 +1,28 @@ +{{ $url := "https://api.github.com/repos/gohugoio/hugo" }} +{{ $cacheKey := print $url (now.Format "2006-01-02") }} +{{ $headers := dict }} +{{ with os.Getenv "HUGO_GH_TOKEN" }} + {{ $headers = dict "Authorization" (printf "Bearer %s" .) }} +{{ end }} +{{ $opts := dict "headers" $headers "key" $cacheKey }} +{{ $githubRepoInfo := dict }} +{{ with try (resources.GetRemote $url $opts) }} + {{ with .Err }} + {{ warnf "Failed to get GitHub repo info: %s" . }} + {{ else with (.Value | transform.Unmarshal) }} + {{ $githubRepoInfo = dict + "html_url" .html_url + "stargazers_url" .stargazers_url + "watchers_count" .watchers_count + "stargazers_count" .stargazers_count + "forks_count" .forks_count + "contributors_url" .contributors_url + "releases_url" .releases_url + "forks_count" .forks_count + }} + {{ else }} + {{ errorf "Unable to get remote resource %q" $url }} + {{ end }} +{{ end }} + +{{ return $githubRepoInfo }} diff --git a/docs/layouts/_partials/helpers/funcs/get-remote-data.html b/docs/layouts/_partials/helpers/funcs/get-remote-data.html new file mode 100644 index 000000000..ed7043421 --- /dev/null +++ b/docs/layouts/_partials/helpers/funcs/get-remote-data.html @@ -0,0 +1,24 @@ +{{/* prettier-ignore-start */ -}} +{{/* +Parses the serialized data from the given URL and returns a map or an array. + +Supports CSV, JSON, TOML, YAML, and XML. + +@param {string} . The URL from which to retrieve the serialized data. +@returns {any} + +@example {{ partial "get-remote-data.html" "https://example.org/foo.json" }} +*/}} +{{/* prettier-ignore-end */ -}} +{{ $url := . }} +{{ $data := dict }} +{{ with try (resources.GetRemote $url) }} + {{ with .Err }} + {{ errorf "%s" . }} + {{ else with .Value }} + {{ $data = .Content | transform.Unmarshal }} + {{ else }} + {{ errorf "Unable to get remote resource %q" $url }} + {{ end }} +{{ end }} +{{ return $data }} diff --git a/docs/layouts/_partials/helpers/gtag.html b/docs/layouts/_partials/helpers/gtag.html new file mode 100644 index 000000000..59bf36ba2 --- /dev/null +++ b/docs/layouts/_partials/helpers/gtag.html @@ -0,0 +1,27 @@ +{{ with site.Config.Services.GoogleAnalytics.ID }} + + +{{ end }} diff --git a/docs/layouts/_partials/helpers/linkcss.html b/docs/layouts/_partials/helpers/linkcss.html new file mode 100644 index 000000000..814d2b52f --- /dev/null +++ b/docs/layouts/_partials/helpers/linkcss.html @@ -0,0 +1,28 @@ +{{ $r := .r }} +{{ $attr := .attributes | default dict }} + +{{ if hugo.IsDevelopment }} + +{{ else }} + {{ with $r | minify | fingerprint }} + + {{ end }} +{{ end }} + +{{ define "render-attributes" }} + {{- range $k, $v := . -}} + {{- if $v -}} + {{- printf ` %s=%q` $k $v | safeHTMLAttr -}} + {{- else -}} + {{- printf ` %s` $k | safeHTMLAttr -}} + {{- end -}} + {{- end -}} +{{ end }} diff --git a/docs/layouts/_partials/helpers/linkjs.html b/docs/layouts/_partials/helpers/linkjs.html new file mode 100644 index 000000000..00129ad38 --- /dev/null +++ b/docs/layouts/_partials/helpers/linkjs.html @@ -0,0 +1,15 @@ +{{ $r := .r }} +{{ $attr := .attributes | default dict }} +{{ if hugo.IsDevelopment }} + +{{ else }} + {{ with $r | fingerprint }} + + {{ end }} +{{ end }} diff --git a/docs/layouts/_partials/helpers/picture.html b/docs/layouts/_partials/helpers/picture.html new file mode 100644 index 000000000..4dc16c002 --- /dev/null +++ b/docs/layouts/_partials/helpers/picture.html @@ -0,0 +1,27 @@ +{{ $image := .image }} +{{ $width := .width | default 1000 }} +{{ $width1x := div $width 2 }} +{{ $imageWebp := $image.Resize (printf "%dx webp" $width) }} +{{ $image1x := $image.Resize (printf "%dx" $width1x) }} +{{ $image1xWebp := $image.Resize (printf "%dx webp" $width1x) }} +{{ $class := .class | default "h-64 tablet:h-96 lg:h-full w-full object-cover lg:absolute" }} +{{ $loading := .loading | default "eager" }} + + + + + + + diff --git a/docs/layouts/_partials/helpers/validation/validate-keywords.html b/docs/layouts/_partials/helpers/validation/validate-keywords.html new file mode 100644 index 000000000..3447ec4ef --- /dev/null +++ b/docs/layouts/_partials/helpers/validation/validate-keywords.html @@ -0,0 +1,22 @@ +{{/* prettier-ignore-start */ -}} +{{- /* +We use the front matter keywords field to determine related content. To ensure +consistency, during site build we validate each keyword against the entries in +data/keywords.yaml. + +As of March 5, 2025, this feature is experimental, pending usability +assessment. We anticipate that the number of additions to data/keywords.yaml +will decrease over time, though the initial implementation will require some +effort. +*/}} +{{/* prettier-ignore-end */ -}} +{{- $t := debug.Timer "validateKeywords" }} +{{- $allowedKeywords := collections.Apply site.Data.keywords "strings.ToLower" "." }} +{{- range $p := site.Pages }} + {{- range .Params.keywords }} + {{- if not (in $allowedKeywords (lower .)) }} + {{- warnf "The word or phrase %q is not in the keywords data file. See %s." . $p.Page.String }} + {{- end }} + {{- end }} +{{- end }} +{{- $t.Stop }} diff --git a/docs/layouts/_partials/layouts/blocks/alert.html b/docs/layouts/_partials/layouts/blocks/alert.html new file mode 100644 index 000000000..45f0044d9 --- /dev/null +++ b/docs/layouts/_partials/layouts/blocks/alert.html @@ -0,0 +1,25 @@ +{{- $title := .title | default "" }} +{{- $color := .color | default "yellow" }} +{{- $icon := .icon | default "exclamation-triangle" }} +{{- $text := .text | default "" }} +{{- $class := .class | default "mt-6 mb-8" }} +
    +
    +
    + + + +
    +
    + {{- with $title }} +

    + {{ . }} +

    + {{- end }} +
    + {{ $text }} +
    +
    +
    +
    diff --git a/docs/layouts/_partials/layouts/blocks/modal.html b/docs/layouts/_partials/layouts/blocks/modal.html new file mode 100644 index 000000000..7d825c06e --- /dev/null +++ b/docs/layouts/_partials/layouts/blocks/modal.html @@ -0,0 +1,30 @@ +
    +
    + {{ .modal_button }} +
    + +
    diff --git a/docs/layouts/_partials/layouts/breadcrumbs.html b/docs/layouts/_partials/layouts/breadcrumbs.html new file mode 100644 index 000000000..69bcf7bd5 --- /dev/null +++ b/docs/layouts/_partials/layouts/breadcrumbs.html @@ -0,0 +1,44 @@ +{{ $documentation := site.GetPage "/documentation" }} + + + + +{{ define "breadcrumbs-arrow" }} + + + +{{ end }} diff --git a/docs/layouts/_partials/layouts/date.html b/docs/layouts/_partials/layouts/date.html new file mode 100644 index 000000000..2ec1450a5 --- /dev/null +++ b/docs/layouts/_partials/layouts/date.html @@ -0,0 +1,5 @@ +{{ $humanDate := time.Format "January 2, 2006" . }} +{{ $machineDate := time.Format "2006-01-02T15:04:05-07:00" . }} + diff --git a/docs/layouts/_partials/layouts/docsheader.html b/docs/layouts/_partials/layouts/docsheader.html new file mode 100644 index 000000000..7e8e950f3 --- /dev/null +++ b/docs/layouts/_partials/layouts/docsheader.html @@ -0,0 +1,9 @@ +
    + {{ partial "layouts/breadcrumbs.html" . }} + {{ if and .IsPage (not (eq .Layout "list")) }} +

    + {{ .Title }} +

    + {{ end }} +
    diff --git a/docs/layouts/_partials/layouts/explorer.html b/docs/layouts/_partials/layouts/explorer.html new file mode 100644 index 000000000..bb6f8e96a --- /dev/null +++ b/docs/layouts/_partials/layouts/explorer.html @@ -0,0 +1,47 @@ +{{/* This is currently not in use, but kept in case I change my mind. */}} + + +{{ define "docs-explorer-section" }} + {{ $p := .p }} + {{ $level := .level }} + {{ $pleft := $level }} + {{ if gt $level 0 }} + {{ $pleft = add $level 1 }} + {{ end }} + {{ $pl := printf "pl-%d" $pleft }} + {{ $pages := $p.Sections }} + + {{ range $pages }} + {{ $hasChildren := gt (len .Pages) 0 }} + {{ $class := cond (eq $level 0) "text-primary hover:text-primary/70" "text-gray-900 dark:text-gray-400 hover:dark:text-gray-300" }} +
  • + + {{ .LinkTitle }} + + {{ if $hasChildren }} +
      + {{ template "docs-explorer-section" (dict "p" . "level" (add $level 1)) }} +
    + {{ end }} +
  • + {{ end }} + +{{ end }} diff --git a/docs/layouts/_partials/layouts/footer.html b/docs/layouts/_partials/layouts/footer.html new file mode 100644 index 000000000..1b17e44e4 --- /dev/null +++ b/docs/layouts/_partials/layouts/footer.html @@ -0,0 +1,73 @@ +
    +
    +
    + {{/* Column 1 */}} +
    +
    + By the + Hugo Authors
    +
    + + Hugo Logo + + +
    + + {{/* Sponsors */}} +
    + {{ partial "layouts/home/sponsors.html" (dict + "ctx" . + "gtag" "footer" + + ) + }} +
    +
    +
    +

    + The Hugo logos are copyright © Steve Francia 2013–{{ now.Year }}. The + Hugo Gopher is based on an original work by Renée French. +

    +
    +
    +
    diff --git a/docs/layouts/_partials/layouts/head/head-js.html b/docs/layouts/_partials/layouts/head/head-js.html new file mode 100644 index 000000000..d83efcd0f --- /dev/null +++ b/docs/layouts/_partials/layouts/head/head-js.html @@ -0,0 +1,11 @@ +{{ $githubInfo := partialCached "helpers/funcs/get-github-info.html" . "-" }} +{{ $opts := dict "minify" true }} +{{ with resources.Get "js/head-early.js" | js.Build $opts }} + {{ partial "helpers/linkjs.html" (dict "r" . "attributes" (dict "async" "")) }} +{{ end }} +{{ with resources.Get "js/main.js" | js.Build $opts }} + {{ partial "helpers/linkjs.html" (dict "r" . "attributes" (dict "defer" "")) }} +{{ end }} +{{ with resources.Get "js/turbo.js" | js.Build $opts }} + {{ partial "helpers/linkjs.html" (dict "r" . "attributes" (dict "defer" "")) }} +{{ end }} diff --git a/docs/layouts/_partials/layouts/head/head.html b/docs/layouts/_partials/layouts/head/head.html new file mode 100644 index 000000000..bb27f6a24 --- /dev/null +++ b/docs/layouts/_partials/layouts/head/head.html @@ -0,0 +1,49 @@ + +{{ hugo.Generator }} + +{{ if hugo.IsProduction }} + +{{ else }} + +{{ end }} + + + {{ with .Title }}{{ . }} |{{ end }} + {{ .Site.Title }} + + + + + + + + + + + +{{ range .AlternativeOutputFormats -}} + +{{ end -}} + + + + + + +{{ partial "opengraph/opengraph.html" . }} +{{- template "_internal/schema.html" . -}} +{{- template "_internal/twitter_cards.html" . -}} + +{{ if hugo.IsProduction }} + {{ partial "helpers/gtag.html" . }} +{{ end }} diff --git a/docs/layouts/_partials/layouts/header/githubstars.html b/docs/layouts/_partials/layouts/header/githubstars.html new file mode 100644 index 000000000..75db5682a --- /dev/null +++ b/docs/layouts/_partials/layouts/header/githubstars.html @@ -0,0 +1,16 @@ +{{ with partialCached "helpers/funcs/get-github-info.html" . "-" }} + + + + + + + + {{ printf "%0.1fk" (div .stargazers_count 1000) }} + + +{{ end }} diff --git a/docs/layouts/_partials/layouts/header/header.html b/docs/layouts/_partials/layouts/header/header.html new file mode 100644 index 000000000..0d2e720d7 --- /dev/null +++ b/docs/layouts/_partials/layouts/header/header.html @@ -0,0 +1,44 @@ +
    +
    + {{ with site.Home }} + HUGO + {{ end }} +
    +
    + {{ range .Site.Menus.global }} + {{ .Name }} + {{ end }} + +
    + +
    + {{/* Search. */}} + {{ partial "layouts/search/input.html" . }} +
    +
    + {{/* QR code. */}} + {{ partial "layouts/header/qr.html" . }} + {{/* Theme selector. */}} + {{ partial "layouts/header/theme.html" . }} + + {{/* Social. */}} + +
    +
    diff --git a/docs/layouts/_partials/layouts/header/qr.html b/docs/layouts/_partials/layouts/header/qr.html new file mode 100644 index 000000000..fea64f625 --- /dev/null +++ b/docs/layouts/_partials/layouts/header/qr.html @@ -0,0 +1,25 @@ +{{ $t := debug.Timer "qr" }} +{{ $qr := partial "_inline/qr" (dict + "page" $ + "img_class" "w-10 bg-white view-transition-qr" ) +}} +{{ $qrBig := partial "_inline/qr" (dict "page" $ "img_class" "w-64 p-4") }} +{{ $t.Stop }} + + +{{ define "_partials/_inline/qr" }} + {{ $img_class := .img_class | default "w-10" }} + {{ with images.QR $.page.Permalink (dict "targetDir" "images/qr") }} + + QR code linking to {{ $.page.Permalink }} + {{ end }} +{{ end }} diff --git a/docs/layouts/_partials/layouts/header/theme.html b/docs/layouts/_partials/layouts/header/theme.html new file mode 100644 index 000000000..e0b356d1d --- /dev/null +++ b/docs/layouts/_partials/layouts/header/theme.html @@ -0,0 +1,35 @@ +
    + +
    diff --git a/docs/layouts/_partials/layouts/home/features.html b/docs/layouts/_partials/layouts/home/features.html new file mode 100644 index 000000000..527c98cb1 --- /dev/null +++ b/docs/layouts/_partials/layouts/home/features.html @@ -0,0 +1,56 @@ +{{/* icons source: https://heroicons.com/ */}} +{{ $dataTOML := ` + [[features]] + heading = "Optimized for speed" + copy = "Written in Go, optimized for speed and designed for flexibility. With its advanced templating system and fast asset pipelines, Hugo renders a large site in seconds, often less." + icon = """ + + + """ + [[features]] + heading = "Flexible framework" + copy = "With its multilingual support, and powerful taxonomy system, Hugo is widely used to create documentation sites, landing pages, corporate, government, nonprofit, education, news, event, and project sites." + icon = """ + + + """ + [[features]] + heading = "Fast assets pipeline" + copy = "Image processing (convert, resize, crop, rotate, adjust colors, apply filters, overlay text and images, and extract EXIF data), JavaScript bundling (tree shake, code splitting), Sass processing, great TailwindCSS support." + icon = """ + + + """ + [[features]] + heading = "Embedded web server" + copy = "Use Hugo's embedded web server during development to instantly see changes to content, structure, behavior, and presentation. " + icon = """ + + + """ + ` +}} +{{ $data := $dataTOML | transform.Unmarshal }} +
    +
    +
    + {{ range $data.features }} +
    +
    +
    + {{ .icon | safeHTML }} +
    + {{ .heading }} +
    +
    + {{ .copy }} +
    +
    + {{ end }} + +
    +
    +
    diff --git a/docs/layouts/_partials/layouts/home/opensource.html b/docs/layouts/_partials/layouts/home/opensource.html new file mode 100644 index 000000000..153c0f4ff --- /dev/null +++ b/docs/layouts/_partials/layouts/home/opensource.html @@ -0,0 +1,111 @@ +{{ $githubInfo := partialCached "helpers/funcs/get-github-info.html" . "-" }} +
    +
    +
    +
    +

    + Open source +

    +

    + Hugo is open source and free to use. It is distributed under the + Apache 2.0 License. +

    +
    +
    +
    + + + + Popular. +
    +
    + Hugo has + {{ $githubInfo.stargazers_count | lang.FormatNumber 0 }} + stars on GitHub as of {{ now.Format "January 2, 2006" }}. + Join the crowd and hit the + Star button. +
    +
    +
    +
    + + + + Active. +
    +
    + Hugo has a large and active community. If you have questions or + need help, you can ask in the + Hugo forums. +
    +
    +
    +
    + + + + Frequent releases. +
    +
    + Hugo has a fast + release + cycle. The project is actively maintained and new features are + added regularly. +
    +
    +
    +
    +
    + {{ partial "helpers/picture.html" (dict + "image" (resources.Get "images/hugo-github-screenshot.png") + "alt" "Hugo GitHub Repository" + "width" 640 + "class" "w-full max-w-[38rem] ring-1 shadow-xl dark:shadow-gray-500 ring-gray-400/10") + }} +
    +
    diff --git a/docs/layouts/_partials/layouts/home/sponsors.html b/docs/layouts/_partials/layouts/home/sponsors.html new file mode 100644 index 000000000..1f391e1ec --- /dev/null +++ b/docs/layouts/_partials/layouts/home/sponsors.html @@ -0,0 +1,44 @@ +{{ $gtag := .gtag | default "unknown" }} +{{ $gtag := .gtag | default "unknown" }} +{{ $isFooter := (eq $gtag "footer") }} +{{ $utmSource := cond $isFooter "hugofooter" "hugohome" }} +{{ $containerClass := .containerClass | default "mx-auto max-w-7xl px-6 lg:px-8" }} +{{ with .ctx.Site.Data.sponsors }} +
    +

    Hugo Sponsors

    +
    + {{ range .banners }} +
    + {{ $query_params := .query_params | default "" }} + {{ $url := .link }} + {{ if not .no_query_params }} + {{ $url = printf "%s?%s%s" .link $query_params (querify "utm_source" (.utm_source | default $utmSource ) "utm_medium" (.utm_medium | default "banner") "utm_campaign" (.utm_campaign | default "hugosponsor") "utm_content" (.utm_content | default "gohugoio")) | safeURL }} + {{ end }} + {{ $logo := resources.Get .logo }} + {{ $gtagID := printf "Sponsor %s %s" .name $gtag | title }} + + + +
    + {{ end }} +
    +
    +{{ end }} diff --git a/docs/layouts/_partials/layouts/hooks/body-end.html b/docs/layouts/_partials/layouts/hooks/body-end.html new file mode 100644 index 000000000..9ffd93ba8 --- /dev/null +++ b/docs/layouts/_partials/layouts/hooks/body-end.html @@ -0,0 +1,3 @@ +{{- if .IsHome }} + {{- partial "helpers/validation/validate-keywords.html" }} +{{- end }} diff --git a/docs/layouts/_partials/layouts/hooks/body-main-start.html b/docs/layouts/_partials/layouts/hooks/body-main-start.html new file mode 100644 index 000000000..b28dd21c8 --- /dev/null +++ b/docs/layouts/_partials/layouts/hooks/body-main-start.html @@ -0,0 +1,8 @@ +{{ if or .IsSection .IsPage }} + +{{end }} diff --git a/docs/layouts/_partials/layouts/hooks/body-start.html b/docs/layouts/_partials/layouts/hooks/body-start.html new file mode 100644 index 000000000..3430bd846 --- /dev/null +++ b/docs/layouts/_partials/layouts/hooks/body-start.html @@ -0,0 +1,3 @@ +{{ with resources.Get "js/body-start.js" | js.Build (dict "minify" true) }} + {{ partial "helpers/linkjs.html" (dict "r" . "attributes" (dict "" "")) }} +{{ end }} diff --git a/docs/layouts/_partials/layouts/icons.html b/docs/layouts/_partials/layouts/icons.html new file mode 100644 index 000000000..b05adef00 --- /dev/null +++ b/docs/layouts/_partials/layouts/icons.html @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{{/* https://heroicons.com/mini exclamation-circle */}} + + + + +{{/* https://heroicons.com/mini exclamation-triangle */}} + + + + +{{/* https://heroicons.com/mini information-circle */}} + + + + +{{/* https://heroicons.com/mini light-bulb */}} + + + diff --git a/docs/layouts/_partials/layouts/in-this-section.html b/docs/layouts/_partials/layouts/in-this-section.html new file mode 100644 index 000000000..28e5ed7eb --- /dev/null +++ b/docs/layouts/_partials/layouts/in-this-section.html @@ -0,0 +1,34 @@ +{{- with .CurrentSection.RegularPages }} + {{ $hasTocOrRelated := or ($.Store.Get "hasToc") ($.Store.Get "hasRelated") }} +
    +

    + In this section +

    + + +
    +{{- end }} diff --git a/docs/layouts/_partials/layouts/page-edit.html b/docs/layouts/_partials/layouts/page-edit.html new file mode 100644 index 000000000..6a976d11a --- /dev/null +++ b/docs/layouts/_partials/layouts/page-edit.html @@ -0,0 +1,26 @@ +
    +
    + +
    + Last updated: + {{ .Lastmod.Format "January 2, 2006" }}{{ with .GitInfo }} + : + {{ .Subject }} ({{ .AbbreviatedHash }}) + {{ end }} +
    + + {{ with .File }} + {{ if not .IsContentAdapter }} + {{ $href := printf "%sedit/master/content/%s/%s" site.Params.ghrepo $.Lang .Path }} + + Improve this page + + {{ end }} + {{ end }} +
    diff --git a/docs/layouts/_partials/layouts/related.html b/docs/layouts/_partials/layouts/related.html new file mode 100644 index 000000000..0245ba771 --- /dev/null +++ b/docs/layouts/_partials/layouts/related.html @@ -0,0 +1,22 @@ +{{- $heading := "See also" }} +{{- $related := site.Pages.Related . }} +{{- $related = $related | complement .CurrentSection.Pages | first 7 }} + +{{- with $related }} + {{ $.Store.Set "hasRelated" true }} +

    + {{ $heading }} +

    + +{{- end }} diff --git a/docs/layouts/_partials/layouts/search/algolialogo.html b/docs/layouts/_partials/layouts/search/algolialogo.html new file mode 100644 index 000000000..a7fc6c0ae --- /dev/null +++ b/docs/layouts/_partials/layouts/search/algolialogo.html @@ -0,0 +1,45 @@ +
    + Search by + + + + + + + + + + +
    diff --git a/docs/layouts/_partials/layouts/search/button.html b/docs/layouts/_partials/layouts/search/button.html new file mode 100644 index 000000000..07c1f7335 --- /dev/null +++ b/docs/layouts/_partials/layouts/search/button.html @@ -0,0 +1,22 @@ +{{ $textColor := "text-gray-300" }} +{{ $fillColor := "fill-slate-400 dark:fill-slate-500" }} +{{ if .standalone }} + {{ $textColor = "text-gray-800 dark:text-gray-300 " }} + {{ $fillColor = "fill-slate-500 dark:fill-slate-400" }} +{{ end }} + + + diff --git a/docs/layouts/_partials/layouts/search/input.html b/docs/layouts/_partials/layouts/search/input.html new file mode 100644 index 000000000..5f5ff07b9 --- /dev/null +++ b/docs/layouts/_partials/layouts/search/input.html @@ -0,0 +1,4 @@ +
    + {{ partial "layouts/search/button.html" (dict "page" . "standalone" false) }} + {{ partial "layouts/search/results.html" . }} +
    diff --git a/docs/layouts/_partials/layouts/search/results.html b/docs/layouts/_partials/layouts/search/results.html new file mode 100644 index 000000000..cd9b88dc0 --- /dev/null +++ b/docs/layouts/_partials/layouts/search/results.html @@ -0,0 +1,90 @@ + diff --git a/docs/layouts/_partials/layouts/templates.html b/docs/layouts/_partials/layouts/templates.html new file mode 100644 index 000000000..72b71a3d9 --- /dev/null +++ b/docs/layouts/_partials/layouts/templates.html @@ -0,0 +1,7 @@ + diff --git a/docs/layouts/_partials/layouts/toc.html b/docs/layouts/_partials/layouts/toc.html new file mode 100644 index 000000000..774bc15c7 --- /dev/null +++ b/docs/layouts/_partials/layouts/toc.html @@ -0,0 +1,46 @@ +{{ with .Fragments }} + {{ with .Headings }} +
    +

    + On this page +

    + +
    + {{ end }} +{{ end }} + +{{ define "render-toc-level" }} + {{ range .h }} + {{ if and .ID (and (ge .Level 2) (le .Level 4)) }} + {{ $indentation := "ml-0" }} + {{ if eq .Level 3 }} + {{ $indentation = "ml-2 lg:ml-3" }} + {{ else if eq .Level 4 }} + {{ $indentation = "ml-4 lg:ml-6" }} + {{ end }} + {{ $.p.Store.Set "hasToc" true }} +
  • + + {{ .Title | safeHTML }} + +
  • + {{ end }} + {{ with .Headings }} +
      + {{ template "render-toc-level" (dict "h" . "p" $.p) }} +
    + {{ end }} + {{ end }} +{{ end }} diff --git a/docs/layouts/_partials/opengraph/get-featured-image.html b/docs/layouts/_partials/opengraph/get-featured-image.html new file mode 100644 index 000000000..50ee2a44d --- /dev/null +++ b/docs/layouts/_partials/opengraph/get-featured-image.html @@ -0,0 +1,26 @@ +{{ $images := $.Resources.ByType "image" }} +{{ $featured := $images.GetMatch "*feature*" }} +{{ if not $featured }} + {{ $featured = $images.GetMatch "{*cover*,*thumbnail*}" }} +{{ end }} +{{ if not $featured }} + {{ $featured = resources.Get "/opengraph/gohugoio-card-base-1.png" }} + {{ $size := 80 }} + {{ $title := $.LinkTitle }} + {{ if gt (len $title) 20 }} + {{ $size = 70 }} + {{ end }} + + {{ $text := $title }} + {{ $textOptions := dict + "color" "#FFF" + "size" $size + "lineSpacing" 10 + "x" 65 "y" 80 + "font" (resources.Get "/opengraph/mulish-black.ttf") + }} + + {{ $featured = $featured | images.Filter (images.Text $text $textOptions) }} +{{ end }} + +{{ return $featured }} diff --git a/docs/layouts/_partials/opengraph/opengraph.html b/docs/layouts/_partials/opengraph/opengraph.html new file mode 100644 index 000000000..e32e07298 --- /dev/null +++ b/docs/layouts/_partials/opengraph/opengraph.html @@ -0,0 +1,84 @@ + + + + + +{{- with $.Params.images -}} + {{- range first 6 . }} + + {{ end -}} +{{- else -}} + {{- $featured := partial "opengraph/get-featured-image.html" . }} + {{- with $featured -}} + + {{- else -}} + {{- with $.Site.Params.images }} + + {{ end -}} + {{- end -}} +{{- end -}} + +{{- if .IsPage }} + {{- $iso8601 := "2006-01-02T15:04:05-07:00" -}} + + {{ with .PublishDate }} + + {{ end }} + {{ with .Lastmod }} + + {{ end }} +{{- end -}} + +{{- with .Params.audio }}{{ end }} +{{- with .Params.locale }} + +{{ end }} +{{- with .Site.Params.title }} + +{{ end }} +{{- with .Params.videos }} + {{- range . }} + + {{ end }} + +{{ end }} + +{{- /* If it is part of a series, link to related articles */}} +{{- $permalink := .Permalink }} +{{- $siteSeries := .Site.Taxonomies.series }} +{{ with .Params.series }} + {{- range $name := . }} + {{- $series := index $siteSeries ($name | urlize) }} + {{- range $page := first 6 $series.Pages }} + {{- if ne $page.Permalink $permalink }} + + {{ end }} + {{- end }} + {{ end }} + +{{ end }} + +{{- /* Facebook Page Admin ID for Domain Insights */}} +{{- with site.Params.social.facebook_admin }} + +{{ end }} diff --git a/docs/layouts/_shortcodes/chroma-lexers.html b/docs/layouts/_shortcodes/chroma-lexers.html new file mode 100644 index 000000000..fd7130501 --- /dev/null +++ b/docs/layouts/_shortcodes/chroma-lexers.html @@ -0,0 +1,28 @@ +{{/* prettier-ignore-start */ -}} +{{- /* +Renders an HTML template of Chroma lexers and their aliases. + +@example {{< chroma-lexers >}} +*/ -}} +{{/* prettier-ignore-end */ -}} +
    + + + + + + + {{- range site.Data.docs.chroma.lexers }} + + + + + {{- end }} + +
    LanguageIdentifiers
    {{ .Name }} + {{- range $k, $_ := .Aliases }} + {{- if $k }},{{ end }} + {{ . }} + {{- end }} +
    +
    diff --git a/docs/layouts/_shortcodes/code-toggle.html b/docs/layouts/_shortcodes/code-toggle.html new file mode 100644 index 000000000..a22c378be --- /dev/null +++ b/docs/layouts/_shortcodes/code-toggle.html @@ -0,0 +1,120 @@ +{{/* prettier-ignore-start */ -}} +{{- /* +Renders syntax-highlighted configuration data in JSON, TOML, and YAML formats. + +@param {string} [config] The section of site.Data.docs.config to render. +@param {bool} [copy=false] Whether to display a copy-to-clipboard button. +@param {string} [dataKey] The section of site.Data.docs to render. +@param {string} [file] The file name to display above the rendered code. +@param {bool} [fm=false] Whether to render the code as front matter. +@param {bool} [skipHeader=false] Whether to omit top level key(s) when rendering a section of site.Data.docs.config. + +@example {{< code-toggle file=hugo config=build />}} + +@example {{< code-toggle file=content/example.md fm="true" }} + title='Example' + draft='false + {{< /code-toggle }} +*/ -}} +{{/* prettier-ignore-end */ -}} +{{- /* Initialize. */}} +{{- $config := "" }} +{{- $copy := false }} +{{- $dataKey := "" }} +{{- $file := "" }} +{{- $fm := false }} +{{- $skipHeader := false }} + +{{- /* Get parameters. */}} +{{- $config = .Get "config" }} +{{- $dataKey = .Get "dataKey" }} +{{- $file = .Get "file" }} +{{- if in (slice "false" false 0) (.Get "copy") }} + {{- $copy = false }} +{{- else if in (slice "true" true 1) (.Get "copy") }} + {{- $copy = true }} +{{- end }} +{{- if in (slice "false" false 0) (.Get "fm") }} + {{- $fm = false }} +{{- else if in (slice "true" true 1) (.Get "fm") }} + {{- $fm = true }} +{{- end }} +{{- if in (slice "false" false 0) (.Get "skipHeader") }} + {{- $skipHeader = false }} +{{- else if in (slice "true" true 1) (.Get "skipHeader") }} + {{- $skipHeader = true }} +{{- end }} + +{{- /* Define constants. */}} +{{- $delimiters := dict "toml" "+++" "yaml" "---" }} +{{- $langs := slice "yaml" "toml" "json" }} +{{- $placeHolder := "#-hugo-placeholder-#" }} + +{{- /* Render. */}} +{{- $code := "" }} +{{- if $config }} + {{- $file = $file | default "hugo" }} + {{- $sections := (split $config ".") }} + {{- $configSection := index $.Site.Data.docs.config $sections }} + {{- $code = dict $sections $configSection }} + {{- if $skipHeader }} + {{- $code = $configSection }} + {{- end }} +{{- else if $dataKey }} + {{- $file = $file | default $dataKey }} + {{- $sections := (split $dataKey ".") }} + {{- $code = index $.Site.Data.docs $sections }} +{{- else }} + {{- $code = $.Inner }} +{{- end }} + + +
    + {{- if $copy }} + + + + {{- end }} + + {{- if $code }} + {{- range $i, $lang := $langs }} +
    + {{- $hCode := $code | transform.Remarshal . }} + {{- if and $fm (in (slice "toml" "yaml") .) }} + {{- $hCode = printf "%s\n%s\n%s" $placeHolder $hCode $placeHolder }} + {{- end }} + {{- $hCode = $hCode | replaceRE `\n+` "\n" }} + {{- highlight $hCode . "" | replaceRE $placeHolder (index $delimiters .) | safeHTML }} +
    + {{- end }} + {{- end }} +
    diff --git a/docs/layouts/_shortcodes/datatable-filtered.html b/docs/layouts/_shortcodes/datatable-filtered.html new file mode 100644 index 000000000..de8b0cf55 --- /dev/null +++ b/docs/layouts/_shortcodes/datatable-filtered.html @@ -0,0 +1,47 @@ +{{ $package := (index .Params 0) }} +{{ $listname := (index .Params 1) }} +{{ $filter := split (index .Params 2) " " }} +{{ $filter1 := index $filter 0 }} +{{ $filter2 := index $filter 1 }} +{{ $filter3 := index $filter 2 }} + +{{ $list := (index (index .Site.Data.docs $package) $listname) }} +{{ $fields := after 3 .Params }} +{{ $list := where $list $filter1 $filter2 $filter3 }} + + +
    + + + + {{ range $fields }} + + {{ end }} + + + + {{ range $list }} + + {{ range $k, $v := . }} + {{ $.Scratch.Set $k $v }} + {{ end }} + {{ range $k, $v := $fields }} + + {{ end }} + + {{ end }} + +
    {{ . }}
    + {{ $tdContent := $.Scratch.Get . }} + {{ if eq $k 3 }} + {{ printf "%v" $tdContent | + strings.ReplaceRE `\[` "
    1. " | + strings.ReplaceRE `\s` "
    2. " | + strings.ReplaceRE `\]` "
    " | + safeHTML + }} + {{ else }} + {{ $tdContent }} + {{ end }} +
    +
    diff --git a/docs/layouts/_shortcodes/datatable.html b/docs/layouts/_shortcodes/datatable.html new file mode 100644 index 000000000..f135d841c --- /dev/null +++ b/docs/layouts/_shortcodes/datatable.html @@ -0,0 +1,39 @@ +{{ $package := (index .Params 0) }} +{{ $listname := (index .Params 1) }} +{{ $list := (index (index .Site.Data.docs $package) $listname) }} +{{ $fields := after 2 .Params }} + + +
    + + + + {{ range $fields }} + {{ $s := . }} + {{ if eq $s "_key" }} + {{ $s = "type" }} + {{ end }} + + {{ end }} + + + + {{ range $k1, $v1 := $list }} + + {{ range $k2, $v2 := . }} + {{ $.Scratch.Set $k2 $v2 }} + {{ end }} + {{ range $fields }} + {{ $s := "" }} + {{ if eq . "_key" }} + {{ $s = $k1 }} + {{ else }} + {{ $s = $.Scratch.Get . }} + {{ end }} + + {{ end }} + + {{ end }} + +
    {{ $s }}
    {{ $s }}
    +
    diff --git a/docs/layouts/_shortcodes/deprecated-in.html b/docs/layouts/_shortcodes/deprecated-in.html new file mode 100644 index 000000000..ce2ba389e --- /dev/null +++ b/docs/layouts/_shortcodes/deprecated-in.html @@ -0,0 +1,29 @@ +{{/* prettier-ignore-start */ -}} +{{- /* +Renders a callout indicating the version in which a feature was deprecated. + +Include descriptive text between the opening and closing tags, or omit the +descriptive text and call the shortcode with a self-closing tag. + +@param {string} 0 The semantic version string, with or without a leading v. + +@example {{< deprecated-in 0.144.0 />}} + +@example {{< deprecated-in 0.144.0 >}} + Some descriptive text here. + {{< /deprecated-in >}} +*/ -}} +{{/* prettier-ignore-end */ -}} +{{- with $version := .Get 0 | strings.TrimLeft "vV" }} + {{- $href := printf "https://github.com/gohugoio/hugo/releases/tag/v%s" $version }} + {{- $inner := strings.TrimSpace $.Inner }} + {{- $text := printf "Deprecated in [v%s](%s)\n\n%s" $version $href $inner | $.Page.RenderString (dict "display" "block") }} + {{- partial "layouts/blocks/alert.html" (dict + "color" "orange" + "icon" "exclamation" + "text" $text + ) + }} +{{- else }} + {{- errorf "The %q shortcode requires a single positional parameter indicating version. See %s" .Name .Position }} +{{- end }} diff --git a/docs/layouts/_shortcodes/eturl.html b/docs/layouts/_shortcodes/eturl.html new file mode 100644 index 000000000..a0237dbe0 --- /dev/null +++ b/docs/layouts/_shortcodes/eturl.html @@ -0,0 +1,26 @@ +{{/* prettier-ignore-start */ -}} +{{- /* +Renders an absolute URL to the source code for an embedded template. + +Accepts either positional or named parameters, and depends on the +embedded_templates.toml file in the data directory. + +@param {string} filename The embedded template's file name, excluding extension. + +@example {{% et robots.txt %}} +@example {{% et filename=robots.txt %}} +*/ -}} +{{/* prettier-ignore-end */ -}} +{{- with $filename := or (.Get "filename") (.Get 0) }} + {{- with site.Data.embedded_template_urls }} + {{- with index . $filename }} + {{- urls.JoinPath site.Data.embedded_template_urls.base_url . }} + {{- else }} + {{- errorf "The %q shortcode was unable to find a URL for the embedded template named %q. Check the name. See %s" $.Name $filename $.Position }} + {{- end }} + {{- else }} + {{- errorf "The %q shortcode was unable to find the embedded_template_urls data file in the site's data directory. See %s" $.Name $.Position }} + {{- end }} +{{- else }} + {{- errorf "The %q shortcodes requires a named or positional parameter, the file name of the embedded template, excluding its extension. See %s" .Name .Position }} +{{- end -}} diff --git a/docs/layouts/_shortcodes/glossary-term.html b/docs/layouts/_shortcodes/glossary-term.html new file mode 100644 index 000000000..2a45dc8cb --- /dev/null +++ b/docs/layouts/_shortcodes/glossary-term.html @@ -0,0 +1,18 @@ +{{- /* +Renders the definition of the given glossary term. + +@param {string} (.Get 0) The glossary term. + +@example {{% glossary-term float %}} +@example {{% glossary-term "floating point" %}} +*/ -}} +{{- with .Get 0 }} + {{- $path := printf "/quick-reference/glossary/%s" (urlize .) }} + {{- with site.GetPage $path }} +{{ .RenderShortcodes }}{{/* Do not indent. */}} + {{- else }} + {{- errorf "The glossary term (%s) shortcode was unable to find %s: see %s" $.Name $path $.Position }} + {{- end }} +{{- else }} + {{- errorf "The glossary term (%s) shortcode requires one positional parameter: see %s" $.Name $.Position }} +{{- end -}} diff --git a/docs/layouts/_shortcodes/glossary.html b/docs/layouts/_shortcodes/glossary.html new file mode 100644 index 000000000..7331d5c9f --- /dev/null +++ b/docs/layouts/_shortcodes/glossary.html @@ -0,0 +1,54 @@ +{{- /* +Renders the glossary of terms. + +When you call this shortcode using the {{% %}} notation, the glossary terms are +Markdown headings (level 6) which means they are members of .Fragments. This +allows the link render hook to verify links to glossary terms. + +Yes, the terms themselves are pages, but we don't want to link to the pages, at +least not right now. Instead, we want to link to the ids rendered by this +shortcode. + +@example {{% glossary %}} +*/ -}} +{{- $path := "/quick-reference/glossary" }} +{{- with site.GetPage $path }} + + {{- /* Build and render alphabetical index. */}} + {{- $m := dict }} + {{- range $p := .Pages.ByTitle }} + {{- $k := substr .Title 0 1 | strings.ToUpper }} + {{- if index $m $k }} + {{- continue }} + {{- end }} + {{- $anchor := path.BaseName .Path | anchorize }} + {{- $m = merge $m (dict $k $anchor) }} + {{- end }} + {{- range $k, $v := $m }} +[{{ $k }}](#{{ $v }}) {{/* Do not indent. */}} + {{- end }} + + {{/* Render glossary terms. */}} + {{- range $p := .Pages.ByTitle }} +{{ .Title }}{{/* Do not indent. */}} +: {{ .RawContent | strings.TrimSpace | safeHTML }}{{/* Do not indent. */}} + {{ with .Params.reference }} + {{- $destination := "" }} + {{- with $u := urls.Parse . }} + {{- if $u.IsAbs }} + {{- $destination = $u.String }} + {{- else }} + {{- with site.GetPage $u.Path }} + {{- $destination = .RelPermalink }} + {{- else }} + {{- errorf "The %q shortcode was unable to find the reference link %s: see %s" $.Name . $p.String }} + {{- end }} + {{- end }} + {{- end }} +: See [details]({{ $destination }}).{{/* Do not indent. */}} + {{- end }} + {{ end }} + +{{- else }} + {{- errorf "The %q shortcode was unable to get %s: see %s" .Name $path .Position}} +{{- end }} diff --git a/docs/layouts/_shortcodes/hl.html b/docs/layouts/_shortcodes/hl.html new file mode 100644 index 000000000..3fafcb5e8 --- /dev/null +++ b/docs/layouts/_shortcodes/hl.html @@ -0,0 +1,14 @@ +{{/* prettier-ignore-start */ -}} +{{- /* +Returns syntax-highlighted code from the given text. + +This is useful as a terse way to highlight inline code snippets. Calling the +highlight shortcode for inline snippets is verbose. + +@example This is {{< hl python >}}inline{{< /hl >}} code. +*/ -}} +{{/* prettier-ignore-end */ -}} +{{- $code := .Inner | strings.TrimSpace }} +{{- $lang := or (.Get 0) "go" }} +{{- $opts := dict "hl_inline" true "noClasses" true }} +{{- transform.Highlight $code $lang $opts }} diff --git a/docs/layouts/_shortcodes/img.html b/docs/layouts/_shortcodes/img.html new file mode 100644 index 000000000..e49afc57f --- /dev/null +++ b/docs/layouts/_shortcodes/img.html @@ -0,0 +1,391 @@ +{{/* prettier-ignore-start */ -}} +{{- /* +Renders the given image using the given filter, if any. + +When using the text filter, provide the arguments in this order: + +0. The text +1. The horizontal offset, in pixels, relative to the left of the image (default 20) +2. The vertical offset, in pixels, relative to the top of the image (default 20) +3. The font size in pixels (default 64) +4. The line height (default 1.2) +5. The font color (default #ffffff) + +When using the padding filter, provide all arguments in this order: + +0. Padding top +1. Padding right +2. Padding bottom +3. Padding right +4. Canvas color + +@param {string} src The path to the image which must be a remote, page, or global resource. +@param {string} [filter] The filter to apply to the image (case-insensitive). +@param {string} [filterArgs] A comma-delimited list of arguments to pass to the filter. +@param {bool} [example=false] If true, renders a before/after example. +@param {int} [exampleWidth=384] Image width, in pixels, when rendering a before/after example. + +@example {{< img src="zion-national-park.jpg" >}} +@example {{< img src="zion-national-park.jpg" alt="Zion National Park" >}} + +@example {{< img + src="zion-national-park.jpg" + alt="Zion National Park" + filter="grayscale" + >}} + +@example {{< img + src="zion-national-park.jpg" + alt="Zion National Park" + filter="process" + filterArgs="resize 400x webp" + >}} + +@example {{< img + src="zion-national-park.jpg" + alt="Zion National Park" + filter="colorize" + filterArgs="180,50,20" + >}} + +@example {{< img + src="zion-national-park.jpg" + alt="Zion National Park" + filter="grayscale" + example=true + >}} + +@example {{< img + src="zion-national-park.jpg" + alt="Zion National Park" + filter="grayscale" + example=true + exampleWidth=400 + >}} + +@example {{< img + src="images/examples/zion-national-park.jpg" + alt="Zion National Park" + filter="Text" + filterArgs="Zion National Park,25,250,56" + example=true + >}} + +@example {{< img + src="images/examples/zion-national-park.jpg" + alt="Zion National Park" + filter="Padding" + filterArgs="20,50,20,50,#0705" + example=true + >}} +*/ -}} +{{/* prettier-ignore-end */ -}} +{{- /* Initialize. */}} +{{- $alt := "" }} +{{- $src := "" }} +{{- $filter := "" }} +{{- $filterArgs := slice }} +{{- $example := false }} +{{- $exampleWidth := 384 }} + +{{- /* Default values to use with the text filter. */}} +{{ $textFilterOpts := dict + "xOffset" 20 + "yOffset" 20 + "fontSize" 64 + "lineHeight" 1.2 + "fontColor" "#ffffff" + "fontPath" "https://github.com/google/fonts/raw/refs/heads/main/ofl/lato/Lato-Regular.ttf" +}} + +{{- /* Get and validate parameters. */}} +{{- with .Get "alt" }} + {{- $alt = . }} +{{- end }} + +{{- with .Get "src" }} + {{- $src = . }} +{{- else }} + {{- errorf "The %q shortcode requires a file parameter. See %s" .Name .Position }} +{{- end }} + +{{- with .Get "filter" }} + {{- $filter = . | lower }} +{{- end }} + +{{- $validFilters := slice + "autoorient" "brightness" "colorbalance" "colorize" "contrast" "dither" + "gamma" "gaussianblur" "grayscale" "hue" "invert" "mask" "none" "opacity" + "overlay" "padding" "pixelate" "process" "saturation" "sepia" "sigmoid" "text" + "unsharpmask" +}} + +{{- with $filter }} + {{- if not (in $validFilters .) }} + {{- errorf "The filter passed to the %q shortcode is invalid. The filter must be one of %s. See %s" $.Name (delimit $validFilters ", " ", or ") $.Position }} + {{- end }} +{{- end }} + +{{- with .Get "filterArgs" }} + {{- $filterArgs = split . "," }} + {{- $filterArgs = apply $filterArgs "trim" "." " " }} +{{- end }} + +{{- if in (slice "false" false 0) (.Get "example") }} + {{- $example = false }} +{{- else if in (slice "true" true 1) (.Get "example") }} + {{- $example = true }} +{{- end }} + +{{- with .Get "exampleWidth" }} + {{- $exampleWidth = . | int }} +{{- end }} + +{{- /* Get image. */}} +{{- $ctx := dict "page" .Page "src" $src "name" .Name "position" .Position }} +{{- $i := partial "inline/get-resource.html" $ctx }} + +{{- /* Resize if rendering before/after examples. */}} +{{- if $example }} + {{- $i = $i.Resize (printf "%dx" $exampleWidth) }} +{{- end }} + +{{- /* Create filter. */}} +{{- $f := "" }} +{{- $ctx := dict "filter" $filter "args" $filterArgs "name" .Name "position" .Position }} +{{- if eq $filter "autoorient" }} + {{- $ctx = merge $ctx (dict "argsRequired" 0) }} + {{- template "validate-arg-count" $ctx }} + {{- $f = images.AutoOrient }} +{{- else if eq $filter "brightness" }} + {{- $ctx = merge $ctx (dict "argsRequired" 1) }} + {{- template "validate-arg-count" $ctx }} + {{- $filterArgs = apply $filterArgs "float" "." }} + {{- $ctx = merge $ctx (dict "argName" "percentage" "argValue" (index $filterArgs 0) "min" -100 "max" 100) }} + {{- template "validate-arg-value" $ctx }} + {{- $f = images.Brightness (index $filterArgs 0) }} +{{- else if eq $filter "colorbalance" }} + {{- $ctx = merge $ctx (dict "argsRequired" 3) }} + {{- template "validate-arg-count" $ctx }} + {{- $filterArgs = apply $filterArgs "float" "." }} + {{- $ctx = merge $ctx (dict "argName" "percentage red" "argValue" (index $filterArgs 0) "min" -100 "max" 500) }} + {{- template "validate-arg-value" $ctx }} + {{- $ctx = merge $ctx (dict "argName" "percentage green" "argValue" (index $filterArgs 1) "min" -100 "max" 500) }} + {{- template "validate-arg-value" $ctx }} + {{- $ctx = merge $ctx (dict "argName" "percentage blue" "argValue" (index $filterArgs 2) "min" -100 "max" 500) }} + {{- template "validate-arg-value" $ctx }} + {{- $f = images.ColorBalance (index $filterArgs 0) (index $filterArgs 1) (index $filterArgs 2) }} +{{- else if eq $filter "colorize" }} + {{- $ctx = merge $ctx (dict "argsRequired" 3) }} + {{- template "validate-arg-count" $ctx }} + {{- $filterArgs = apply $filterArgs "float" "." }} + {{- $ctx = merge $ctx (dict "argName" "hue" "argValue" (index $filterArgs 0) "min" 0 "max" 360) }} + {{- template "validate-arg-value" $ctx }} + {{- $ctx = merge $ctx (dict "argName" "saturation" "argValue" (index $filterArgs 1) "min" 0 "max" 100) }} + {{- template "validate-arg-value" $ctx }} + {{- $ctx = merge $ctx (dict "argName" "percentage" "argValue" (index $filterArgs 2) "min" 0 "max" 100) }} + {{- template "validate-arg-value" $ctx }} + {{- $f = images.Colorize (index $filterArgs 0) (index $filterArgs 1) (index $filterArgs 2) }} +{{- else if eq $filter "contrast" }} + {{- $ctx = merge $ctx (dict "argsRequired" 1) }} + {{- template "validate-arg-count" $ctx }} + {{- $filterArgs = apply $filterArgs "float" "." }} + {{- $ctx = merge $ctx (dict "argName" "percentage" "argValue" (index $filterArgs 0) "min" -100 "max" 100) }} + {{- template "validate-arg-value" $ctx }} + {{- $f = images.Contrast (index $filterArgs 0) }} +{{- else if eq $filter "dither" }} + {{- $f = images.Dither }} +{{- else if eq $filter "gamma" }} + {{- $ctx = merge $ctx (dict "argsRequired" 1) }} + {{- template "validate-arg-count" $ctx }} + {{- $filterArgs = apply $filterArgs "float" "." }} + {{- $ctx = merge $ctx (dict "argName" "gamma" "argValue" (index $filterArgs 0) "min" 0 "max" 100) }} + {{- template "validate-arg-value" $ctx }} + {{- $f = images.Gamma (index $filterArgs 0) }} +{{- else if eq $filter "gaussianblur" }} + {{- $ctx = merge $ctx (dict "argsRequired" 1) }} + {{- template "validate-arg-count" $ctx }} + {{- $filterArgs = apply $filterArgs "float" "." }} + {{- $ctx = merge $ctx (dict "argName" "sigma" "argValue" (index $filterArgs 0) "min" 0 "max" 1000) }} + {{- template "validate-arg-value" $ctx }} + {{- $f = images.GaussianBlur (index $filterArgs 0) }} +{{- else if eq $filter "grayscale" }} + {{- $ctx = merge $ctx (dict "argsRequired" 0) }} + {{- template "validate-arg-count" $ctx }} + {{- $f = images.Grayscale }} +{{- else if eq $filter "hue" }} + {{- $ctx = merge $ctx (dict "argsRequired" 1) }} + {{- template "validate-arg-count" $ctx }} + {{- $filterArgs = apply $filterArgs "float" "." }} + {{- $ctx = merge $ctx (dict "argName" "shift" "argValue" (index $filterArgs 0) "min" -180 "max" 180) }} + {{- template "validate-arg-value" $ctx }} + {{- $f = images.Hue (index $filterArgs 0) }} +{{- else if eq $filter "invert" }} + {{- $ctx = merge $ctx (dict "argsRequired" 0) }} + {{- template "validate-arg-count" $ctx }} + {{- $f = images.Invert }} +{{- else if eq $filter "mask" }} + {{- $ctx = merge $ctx (dict "argsRequired" 1) }} + {{- template "validate-arg-count" $ctx }} + {{- $ctx := dict "src" (index $filterArgs 0) "name" .Name "position" .Position }} + {{- $maskImage := partial "inline/get-resource.html" $ctx }} + {{- $f = images.Mask $maskImage }} +{{- else if eq $filter "opacity" }} + {{- $ctx = merge $ctx (dict "argsRequired" 1) }} + {{- template "validate-arg-count" $ctx }} + {{- $filterArgs = apply $filterArgs "float" "." }} + {{- $ctx = merge $ctx (dict "argName" "opacity" "argValue" (index $filterArgs 0) "min" 0 "max" 1) }} + {{- template "validate-arg-value" $ctx }} + {{- $f = images.Opacity (index $filterArgs 0) }} +{{- else if eq $filter "overlay" }} + {{- $ctx = merge $ctx (dict "argsRequired" 3) }} + {{- template "validate-arg-count" $ctx }} + {{- $ctx := dict "src" (index $filterArgs 0) "name" .Name "position" .Position }} + {{- $overlayImg := partial "inline/get-resource.html" $ctx }} + {{- $f = images.Overlay $overlayImg (index $filterArgs 1 | float ) (index $filterArgs 2 | float) }} +{{- else if eq $filter "padding" }} + {{- $ctx = merge $ctx (dict "argsRequired" 5) }} + {{- template "validate-arg-count" $ctx }} + {{- $f = images.Padding + (index $filterArgs 0 | int) + (index $filterArgs 1 | int) + (index $filterArgs 2 | int) + (index $filterArgs 3 | int) + (index $filterArgs 4) + }} +{{- else if eq $filter "pixelate" }} + {{- $ctx = merge $ctx (dict "argsRequired" 1) }} + {{- template "validate-arg-count" $ctx }} + {{- $filterArgs = apply $filterArgs "float" "." }} + {{- $ctx = merge $ctx (dict "argName" "size" "argValue" (index $filterArgs 0) "min" 0 "max" 1000) }} + {{- template "validate-arg-value" $ctx }} + {{- $f = images.Pixelate (index $filterArgs 0) }} +{{- else if eq $filter "process" }} + {{- $ctx = merge $ctx (dict "argsRequired" 1) }} + {{- template "validate-arg-count" $ctx }} + {{- $f = images.Process (index $filterArgs 0) }} +{{- else if eq $filter "saturation" }} + {{- $ctx = merge $ctx (dict "argsRequired" 1) }} + {{- template "validate-arg-count" $ctx }} + {{- $filterArgs = apply $filterArgs "float" "." }} + {{- $ctx = merge $ctx (dict "argName" "percentage" "argValue" (index $filterArgs 0) "min" -100 "max" 500) }} + {{- template "validate-arg-value" $ctx }} + {{- $f = images.Saturation (index $filterArgs 0) }} +{{- else if eq $filter "sepia" }} + {{- $ctx = merge $ctx (dict "argsRequired" 1) }} + {{- template "validate-arg-count" $ctx }} + {{- $filterArgs = apply $filterArgs "float" "." }} + {{- $ctx = merge $ctx (dict "argName" "percentage" "argValue" (index $filterArgs 0) "min" 0 "max" 100) }} + {{- template "validate-arg-value" $ctx }} + {{- $f = images.Sepia (index $filterArgs 0) }} +{{- else if eq $filter "sigmoid" }} + {{- $ctx = merge $ctx (dict "argsRequired" 2) }} + {{- template "validate-arg-count" $ctx }} + {{- $filterArgs = apply $filterArgs "float" "." }} + {{- $ctx = merge $ctx (dict "argName" "midpoint" "argValue" (index $filterArgs 0) "min" 0 "max" 1) }} + {{- template "validate-arg-value" $ctx }} + {{- $ctx = merge $ctx (dict "argName" "factor" "argValue" (index $filterArgs 1) "min" -10 "max" 10) }} + {{- template "validate-arg-value" $ctx }} + {{- $f = images.Sigmoid (index $filterArgs 0) (index $filterArgs 1) }} +{{- else if eq $filter "text" }} + {{- $ctx = merge $ctx (dict "argsRequired" 1) }} + {{- template "validate-arg-count" $ctx }} + {{- $ctx := dict "src" $textFilterOpts.fontPath "name" .Name "position" .Position }} + {{- $font := or (partial "inline/get-resource.html" $ctx) }} + {{- $fontSize := or (index $filterArgs 3 | int) $textFilterOpts.fontSize }} + {{- $lineHeight := math.Max (or (index $filterArgs 4 | float) $textFilterOpts.lineHeight) 1 }} + {{- $opts := dict + "x" (or (index $filterArgs 1 | int) $textFilterOpts.xOffset) + "y" (or (index $filterArgs 2 | int) $textFilterOpts.yOffset) + "size" $fontSize + "linespacing" (mul (sub $lineHeight 1) $fontSize) + "color" (or (index $filterArgs 5) $textFilterOpts.fontColor) + "font" $font + }} + {{- $f = images.Text (index $filterArgs 0) $opts }} +{{- else if eq $filter "unsharpmask" }} + {{- $ctx = merge $ctx (dict "argsRequired" 3) }} + {{- template "validate-arg-count" $ctx }} + {{- $filterArgs = apply $filterArgs "float" "." }} + {{- $ctx = merge $ctx (dict "argName" "sigma" "argValue" (index $filterArgs 0) "min" 0 "max" 500) }} + {{- template "validate-arg-value" $ctx }} + {{- $ctx = merge $ctx (dict "argName" "amount" "argValue" (index $filterArgs 1) "min" 0 "max" 100) }} + {{- template "validate-arg-value" $ctx }} + {{- $ctx = merge $ctx (dict "argName" "threshold" "argValue" (index $filterArgs 2) "min" 0 "max" 1) }} + {{- template "validate-arg-value" $ctx }} + {{- $f = images.UnsharpMask (index $filterArgs 0) (index $filterArgs 1) (index $filterArgs 2) }} +{{- end }} + +{{- /* Apply filter. */}} +{{- $fi := $i }} +{{- with $f }} + {{- $fi = $i.Filter . }} +{{- end }} + +{{- /* Render. */}} +{{- $class := "border-1 border-gray-300 dark:border-gray-500" }} +{{- if $example }} +

    Original

    + {{ $alt }} +

    Processed

    + {{ $alt }} +{{- else -}} + {{ $alt }} +{{- end }} + +{{- define "validate-arg-count" }} + {{- $msg := "When using the %q filter, the %q shortcode requires an args parameter with %d %s. See %s" }} + {{- if lt (len .args) .argsRequired }} + {{- $text := "values" }} + {{- if eq 1 .argsRequired }} + {{- $text = "value" }} + {{- end }} + {{- errorf $msg .filter .name .argsRequired $text .position }} + {{- end }} +{{- end }} + +{{- define "validate-arg-value" }} + {{- $msg := "The %q argument passed to the %q shortcode is invalid. Expected a value in the range [%v,%v], but received %v. See %s" }} + {{- if or (lt .argValue .min) (gt .argValue .max) }} + {{- errorf $msg .argName .name .min .max .argValue .position }} + {{- end }} +{{- end }} + +{{- define "_partials/inline/get-resource.html" }} + {{- $r := "" }} + {{- $u := urls.Parse .src }} + {{- $msg := "The %q shortcode was unable to resolve %s. See %s" }} + {{- if $u.IsAbs }} + {{- with try (resources.GetRemote $u.String) }} + {{- with .Err }} + {{- errorf "%s" . }} + {{- else with .Value }} + {{- /* This is a remote resource. */}} + {{- $r = . }} + {{- else }} + {{- errorf $msg $.name $u.String $.position }} + {{- end }} + {{- end }} + {{- else }} + {{- with .page.Resources.Get (strings.TrimPrefix "./" $u.Path) }} + {{- /* This is a page resource. */}} + {{- $r = . }} + {{- else }} + {{- with resources.Get $u.Path }} + {{- /* This is a global resource. */}} + {{- $r = . }} + {{- else }} + {{- errorf $msg $.name $u.Path $.position }} + {{- end }} + {{- end }} + {{- end }} + {{- return $r }} +{{- end -}} diff --git a/docs/layouts/_shortcodes/imgproc.html b/docs/layouts/_shortcodes/imgproc.html new file mode 100644 index 000000000..fee48525a --- /dev/null +++ b/docs/layouts/_shortcodes/imgproc.html @@ -0,0 +1,39 @@ +{{/* prettier-ignore-start */ -}} +{{- /* +Renders the given image using the given process specification. + +@param {string} path The path to the image, either a page resource or a global resource. +@param {string} spec The image processing specification. +@param {string} alt The alt attribute of the img element. + +@example {{< imgproc path="sunset.jpg" spec="resize 300x" alt="A sunset" >}} +*/ -}} +{{/* prettier-ignore-end */ -}} +{{- with $.Get "path" }} + {{- with $i := or ($.Page.Resources.Get .) (resources.Get .) }} + {{- with $spec := $.Get "spec" }} + {{- with $i.Process . }} +
    + {{ $.Get `alt` }} +
    + {{- with $.Inner }} + {{ . }} + {{- else }} + {{ $spec }} + {{- end }} +
    +
    + {{- end }} + {{- else }} + {{- errorf "The %q shortcode requires a 'spec' argument containing the image processing specification. See %s" $.Name $.Position }} + {{- end }} + {{- else }} + {{- errorf "The %q shortcode was unable to find %q. See %s" $.Name . $.Position }} + {{- end }} +{{- else }} + {{- errorf "The %q shortcode requires a 'path' argument indicating the image path. The image must be a page resource or a global resource. See %s" $.Name $.Position }} +{{- end }} diff --git a/docs/layouts/_shortcodes/include.html b/docs/layouts/_shortcodes/include.html new file mode 100644 index 000000000..81b0c1d8f --- /dev/null +++ b/docs/layouts/_shortcodes/include.html @@ -0,0 +1,20 @@ +{{/* prettier-ignore-start */ -}} +{{- /* +Renders the page using the RenderShortcode method on the Page object. + +You must call this shortcode using the {{% %}} notation. + +@param {string} (positional parameter 0) The path to the page, relative to the content directory. + +@example {{% include "functions/_common/glob-patterns" %}} +*/ -}} +{{/* prettier-ignore-end */ -}} +{{- with .Get 0 }} + {{- with or ($.Page.GetPage .) (site.GetPage .) }} + {{- .RenderShortcodes }} + {{- else }} + {{- errorf "The %q shortcode was unable to find %q. See %s" $.Name . $.Position }} + {{- end }} +{{- else }} + {{- errorf "The %q shortcode requires a positional parameter indicating the path of the file to include. See %s" .Name .Position }} +{{- end }} diff --git a/docs/layouts/_shortcodes/list-pages-in-section.html b/docs/layouts/_shortcodes/list-pages-in-section.html new file mode 100644 index 000000000..f6dfe7275 --- /dev/null +++ b/docs/layouts/_shortcodes/list-pages-in-section.html @@ -0,0 +1,69 @@ +{{- /* +Renders a description list of the pages in the given section. + +Render a subset of the pages in the section by specifying a predefined filter, +and whether to include those pages. + +Filters are defined in the data directory, in the file named page_filters. Each +filter is an array of paths to a file, relative to the root of the content +directory. Hugo will throw an error if the specified filter does not exist, or +if any of the pages in the filter do not exist. + +@param {string} path The path to the section. +@param {string} [filter=""] The name of filter list. +@param {string} [filterType=""] The type of filter, either include or exclude. +@param {string} [titlePrefix=""] The string to prepend to the link title. + +@example {{< list-pages-in-section path=/methods/resources >}} +@example {{< list-pages-in-section path=/functions/images filter=some_filter filterType=exclude >}} +@example {{< list-pages-in-section path=/functions/images filter=some_filter filterType=exclude titlePrefix=foo >}} +*/}} + +{{/* Initialize. */}} +{{ $filter := or "" (.Get "filter" | lower) }} +{{ $filterType := or (.Get "filterType") "none" | lower }} +{{ $filteredPages := slice }} +{{ $titlePrefix := or (.Get "titlePrefix") "" }} + +{{/* Build slice of filtered pages. */}} +{{ with $filter }} + {{ with index site.Data.page_filters . }} + {{ range . }} + {{ with site.GetPage . }} + {{ $filteredPages = $filteredPages | append . }} + {{ else }} + {{ errorf "The %q shortcode was unable to find %q as specified in the page_filters data file. See %s" $.Name . $.Position }} + {{ end }} + {{ end }} + {{ else }} + {{ errorf "The %q shortcode was unable to find the %q filter in the page_filters data file. See %s" $.Name . $.Position }} + {{ end }} +{{ end }} + +{{/* Render. */}} +{{ with $sectionPath := .Get "path" }} + {{ with site.GetPage . }} + {{ with .RegularPages }} + {{ range $page := .ByTitle }} + {{ if or + (and (eq $filterType "include") (in $filteredPages $page)) + (and (eq $filterType "exclude") (not (in $filteredPages $page))) + (eq $filterType "none") + }} + {{ $linkTitle := .LinkTitle }} + {{ with $titlePrefix }} + {{ $linkTitle = printf "%s%s" . $linkTitle }} + {{ end }} +[{{ $linkTitle }}]({{ $page.RelPermalink }}){{/* Do not indent. */}} +: {{ $page.Description }}{{/* Do not indent. */}} + {{ end }} + {{ end }} + {{ else }} + {{ warnf "The %q shortcode found no pages in the %q section. See %s" $.Name $sectionPath $.Position }} + {{ end }} + {{ else }} + {{ errorf "The %q shortcode was unable to find %q. See %s" $.Name $sectionPath $.Position }} + {{ end }} +{{ else }} + {{ errorf "The %q shortcode requires a 'path' parameter indicating the path to the section. See %s" $.Name $.Position }} +{{ end }} diff --git a/docs/layouts/_shortcodes/module-mounts-note.html b/docs/layouts/_shortcodes/module-mounts-note.html new file mode 100644 index 000000000..ba89abcbf --- /dev/null +++ b/docs/layouts/_shortcodes/module-mounts-note.html @@ -0,0 +1,2 @@ +For a more flexible approach to configuring this directory, consult the section +on [module mounts](/configuration/module/#mounts). diff --git a/docs/layouts/_shortcodes/new-in.html b/docs/layouts/_shortcodes/new-in.html new file mode 100644 index 000000000..955d0a710 --- /dev/null +++ b/docs/layouts/_shortcodes/new-in.html @@ -0,0 +1,64 @@ +{{/* prettier-ignore-start */ -}} +{{- /* +Renders a callout or badge indicating the version in which a feature was added. + +To render a callout, include descriptive text between the opening and closing +tags. To render a badge,omit the descriptive text and call the shortcode with a +self-closing tag. + +When comparing the current version to the specified version, the "new in" +button will be hidden if any of the following conditions is true: + +- The major version difference exceeds the majorVersionDiffThreshold +- The minor version difference exceeds the minorVersionDiffThreshold + +@param {string} 0 The semantic version string, with or without a leading v. + +@example {{< new-in 0.100.0 />}} + +@example {{{< new-in 0.100.0 >}} + Some descriptive text here. + {{< /new-in >}} +*/ -}} +{{/* prettier-ignore-end */ -}} +{{- $majorVersionDiffThreshold := 0 }} +{{- $minorVersionDiffThreshold := 30 }} +{{- $displayExpirationWarning := true }} + +{{- with $version := .Get 0 | strings.TrimLeft "vV" }} + {{- $majorVersionDiff := sub (index (split hugo.Version ".") 0 | int) (index (split $version ".") 0 | int) }} + {{- $minorVersionDiff := sub (index (split hugo.Version ".") 1 | int) (index (split $version ".") 1 | int) }} + {{- if or (gt $majorVersionDiff $majorVersionDiffThreshold) (gt $minorVersionDiff $minorVersionDiffThreshold) }} + {{- if $displayExpirationWarning }} + {{- warnf "This call to the %q shortcode should be removed: %s. The button is now hidden because the specified version (%s) is older than the display threshold." $.Name $.Position $version }} + {{- end }} + {{- else }} + {{- $href := printf "https://github.com/gohugoio/hugo/releases/tag/v%s" $version }} + {{- with $.Inner }} + {{- $inner := strings.TrimSpace . }} + {{- $text := printf "New in [v%s](%s)\n\n%s" $version $href $inner | $.Page.RenderString (dict "display" "block") }} + {{ partial "layouts/blocks/alert.html" (dict + "color" "green" + "icon" "exclamation" + "text" $text + ) + }} + {{- else }} + + + + + + New in + v{{ $version }} + + + {{- end }} + {{- end }} +{{- else }} + {{- errorf "The %q shortcode requires a single positional parameter indicating version. See %s" .Name .Position }} +{{- end }} diff --git a/docs/layouts/_shortcodes/per-lang-config-keys.html b/docs/layouts/_shortcodes/per-lang-config-keys.html new file mode 100644 index 000000000..31d7daf6a --- /dev/null +++ b/docs/layouts/_shortcodes/per-lang-config-keys.html @@ -0,0 +1,71 @@ +{{/* prettier-ignore-start */ -}} +{{- /* +Renders a responsive grid of the configuration keys that can be defined +separately for each language. +*/ -}} +{{/* prettier-ignore-end */ -}} +{{- $siteConfigKeys := slice + (dict "baseURL" "/configuration/all/#baseurl") + (dict "buildDrafts" "/configuration/all/#builddrafts") + (dict "buildExpired" "/configuration/all/#buildexpired") + (dict "buildFuture" "/configuration/all/#buildfuture") + (dict "canonifyURLs" "/configuration/all/#canonifyurls") + (dict "capitalizeListTitles" "/configuration/all/#capitalizelisttitles") + (dict "contentDir" "/configuration/all/#contentdir") + (dict "copyright" "/configuration/all/#copyright") + (dict "disableAliases" "/configuration/all/#disablealiases") + (dict "disableHugoGeneratorInject" "/configuration/all/#disablehugogeneratorinject") + (dict "disableKinds" "/configuration/all/#disablekinds") + (dict "disableLiveReload" "/configuration/all/#disablelivereload") + (dict "disablePathToLower" "/configuration/all/#disablepathtolower") + (dict "enableEmoji " "/configuration/all/#enableemoji") + (dict "frontmatter" "/configuration/front-matter/") + (dict "hasCJKLanguage" "/configuration/all/#hascjklanguage") + (dict "languageCode" "/configuration/all/#languagecode") + (dict "mainSections" "/configuration/all/#mainsections") + (dict "markup" "/configuration/markup/") + (dict "mediaTypes" "/configuration/media-types/") + (dict "menus" "/configuration/menus/") + (dict "outputFormats" "/configuration/output-formats") + (dict "outputs" "/configuration/outputs/") + (dict "page" "/configuration/page/") + (dict "pagination" "/configuration/pagination/") + (dict "params" "/configuration/params/") + (dict "permalinks" "/configuration/permalinks/") + (dict "pluralizeListTitles" "/configuration/all/#pluralizelisttitles") + (dict "privacy" "/configuration/privacy/") + (dict "refLinksErrorLevel" "/configuration/all/#reflinkserrorlevel") + (dict "refLinksNotFoundURL" "/configuration/all/#reflinksnotfoundurl") + (dict "related" "/configuration/related-content/") + (dict "relativeURLs" "/configuration/all/#relativeurls") + (dict "removePathAccents" "/configuration/all/#removepathaccents") + (dict "renderSegments" "/configuration/all/#rendersegments") + (dict "sectionPagesMenu" "/configuration/all/#sectionpagesmenu") + (dict "security" "/configuration/security/") + (dict "services" "/configuration/services/") + (dict "sitemap" "/configuration/sitemap/") + (dict "staticDir" "/configuration/all/#staticdir") + (dict "summaryLength" "/configuration/all/#summarylength") + (dict "taxonomies" "/configuration/taxonomies/") + (dict "timeZone" "/configuration/all/#timezone") + (dict "title" "/configuration/all/#title") + (dict "titleCaseStyle" "/configuration/all/#titlecasestyle") +}} + +{{- $a := len $siteConfigKeys }} +{{- $b := math.Ceil (div $a 2.) }} +{{- $c := math.Ceil (div $a 3.) }} + + +
    + {{- range $siteConfigKeys }} + {{ range $k, $v := . }} + {{ $u := urls.Parse $v }} + {{ if not (site.GetPage $u.Path) }} + {{ errorf "The %q shorcode was unable to find %s. See %s." $.Name $u.Path $.Position }} + {{ end }} + {{ $k }} + {{ end }} + {{- end }} +
    diff --git a/docs/layouts/_shortcodes/quick-reference.html b/docs/layouts/_shortcodes/quick-reference.html new file mode 100644 index 000000000..0ac544036 --- /dev/null +++ b/docs/layouts/_shortcodes/quick-reference.html @@ -0,0 +1,30 @@ +{{- /* +Renders the child sections of the given top-level section, listing each child's +immediate descendants. + +@param {string} section The top-level section to render. + +@example {{% quick-reference section="/functions" %}} +*/ -}} +{{ $section := "" }} +{{ with .Get "section" }} + {{ $section = . }} +{{ else }} + {{ errorf "The %q shortcode requires a 'section' parameter. See %s" .Name .Position }} +{{ end }} + +{{ with site.GetPage $section }} + {{ range .Sections }} +## {{ .LinkTitle }}{{/* Do not indent. */}} +{{ .Description }}{{/* Do not indent. */}} + {{ .Content }} + {{ with .Pages }} + {{ range . }} +[{{ .LinkTitle }}]({{ .RelPermalink }}){{/* Do not indent. */}} +: {{ .Description }}{{/* Do not indent. */}} + {{ end }} + {{ end }} + {{ end }} +{{ else }} + {{ errorf "The %q shortcodes was unable to find the %q section. See %s" .Name $section .Position }} +{{ end }} diff --git a/docs/layouts/_shortcodes/root-configuration-keys.html b/docs/layouts/_shortcodes/root-configuration-keys.html new file mode 100644 index 000000000..46a6e074f --- /dev/null +++ b/docs/layouts/_shortcodes/root-configuration-keys.html @@ -0,0 +1,45 @@ +{{/* prettier-ignore-start */ -}} +{{/* +Renders a comma-separated list of links to the root key configuration pages. + +@example {{< root-configuration-keys >}} +*/ -}} +{{/* prettier-ignore-end */ -}} +{{- /* Create scratch map of key:filename. */}} +{{- $s := newScratch }} +{{- range $k, $v := site.Data.docs.config }} + {{- if or (reflect.IsMap .) (reflect.IsSlice .) }} + {{- $s.Set $k ($k | humanize | anchorize) }} + {{- end }} +{{- end }} + +{{/* Deprecated. */}} +{{- $s.Delete "author" }} + +{{/* Use mounts instead. */}} +{{- $s.Delete "staticDir" }} +{{- $s.Delete "ignoreFiles" }} + +{{/* This key is "HTTPCache" not "httpCache". */}} +{{- $s.Set "HTTPCache" "http-cache" }} + +{{/* This key is "frontmatter" not "frontMatter" */}} +{{- $s.Set "frontmatter" "front-matter" }} + +{{/* The page title is "Related content" not "related". */}} +{{- $s.Set "related" "related-content" }} + +{{/* It can be configured as bool or map; we want to show map. */}} +{{- $s.Set "uglyURLs" "ugly-urls" }} + +{{- $links := slice }} +{{- range $k, $v := $s.Values }} + {{- $path := printf "/configuration/%s" $v }} + {{- with site.GetPage $path }} + {{- $links = $links | append (printf "%s" .RelPermalink $k) }} + {{- else }} + {{- errorf "The %q shortcode was unable to find the page %s. See %s." $.Name $path $.Position }} + {{- end }} +{{- end }} + +{{- delimit $links ", " ", and " | safeHTML -}} diff --git a/docs/layouts/_shortcodes/syntax-highlighting-styles.html b/docs/layouts/_shortcodes/syntax-highlighting-styles.html new file mode 100644 index 000000000..297849cef --- /dev/null +++ b/docs/layouts/_shortcodes/syntax-highlighting-styles.html @@ -0,0 +1,70 @@ +{{- /* +Renders a gallery a Chroma syntax highlighting styles. + +@example {{% syntax-highlighting-styles %}} +*/ -}} +{{- $examples := slice }} + +{{- /* Example: css */}} +{{- $example := dict "lang" "css" "code" ` +body { + font-size: 16px; /* comment */ +} +`}} +{{- $examples = $examples | append $example }} + +{{- /* Example: html */}} +{{- $example = dict "lang" "html" "code" ` +Example +`}} +{{- $examples = $examples | append $example }} + +{{- /* Example: go-html-template */}} +{{- $example = dict "lang" "go-html-template" "code" ` +{{ with $.Page.Params.content }} + {{ . | $.Page.RenderString }} {{/* comment */}} +{{ end }} +`}} +{{- $examples = $examples | append $example }} + +{{- /* Example: javascript */}} +{{- $example = dict "lang" "javascript" "code" ` +if ([1,"one",2,"two"].includes(value)){ + console.log("Number is either 1 or 2."); // comment +} +`}} +{{- $examples := $examples | append $example }} + +{{- /* Example: markdown */}} +{{- $example = dict "lang" "markdown" "code" ` +{{< figure src="kitten.jpg" >}} +[example](https://example.org "An example") +`}} +{{- $examples := $examples | append $example }} + +{{- /* Example: toml */}} +{{- $example = dict "lang" "toml" "code" ` +[params] +bool = true # comment +string = 'foo' +`}} +{{- $examples := $examples | append $example }} + +{{- /* Render */}} +{{- with site.Data.docs.chroma.styles }} + {{- range $style := . }} + +### {{ $style }} {class="!mt-7 !mb-6"}{{/* Do not indent. */}} + + {{- range $examples }} + +{{ .lang }}{{/* Do not indent. */}} +{class="text-sm !-mt-3 !-mb-5"}{{/* Do not indent. */}} + +```{{ .lang }} {noClasses=true style="{{ $style }}"}{{/* Do not indent. */}} +{{- .code | safeHTML -}}{{/* Do not indent. */}} +```{{/* Do not indent. */}} + + {{- end }} + {{- end }} +{{- end }} diff --git a/docs/layouts/baseof.html b/docs/layouts/baseof.html new file mode 100644 index 000000000..4c14a6b6d --- /dev/null +++ b/docs/layouts/baseof.html @@ -0,0 +1,74 @@ + + + + + + {{ .Title }} + + + + {{ partial "layouts/head/head-js.html" . }} + {{ with (templates.Defer (dict "key" "global")) }} + {{ $t := debug.Timer "tailwindcss" }} + {{ with resources.Get "css/styles.css" }} + {{ $opts := dict + "inlineImports" true + "minify" (not hugo.IsDevelopment) + }} + {{ with . | css.TailwindCSS $opts }} + {{ partial "helpers/linkcss.html" (dict "r" .) }} + {{ end }} + {{ end }} + {{ $t.Stop }} + {{ end }} + {{ $noop := .WordCount }} + {{ if .Page.Store.Get "hasMath" }} + + {{ end }} + {{ partial "layouts/head/head.html" . }} + + + {{ partial "layouts/hooks/body-start.html" . }} + {{/* Layout. */}} + {{ block "header" . }} + {{ partial "layouts/header/header.html" . }} + {{ end }} + {{ block "subheader" . }} + {{ end }} + {{ block "hero" . }} + {{ end }} +
    +
    + {{ partial "layouts/hooks/body-main-start.html" . }} + {{ block "main" . }}{{ end }} +
    + {{ block "rightsidebar" . }} + + {{ end }} +
    + {{/* Common icons. */}} + {{ partial "layouts/icons.html" . }} + {{/* Common templates. */}} + {{ partial "layouts/templates.html" . }} + {{/* Footer. */}} + {{ block "footer" . }} + {{ partial "layouts/footer.html" . }} + {{ end }} + {{ partial "layouts/hooks/body-end.html" . }} + + diff --git a/docs/layouts/home.headers b/docs/layouts/home.headers new file mode 100644 index 000000000..1216e42d4 --- /dev/null +++ b/docs/layouts/home.headers @@ -0,0 +1,5 @@ +/* + X-Frame-Options: DENY + X-XSS-Protection: 1; mode=block + X-Content-Type-Options: nosniff + Referrer-Policy: origin-when-cross-origin diff --git a/docs/layouts/home.html b/docs/layouts/home.html new file mode 100644 index 000000000..392f66cd8 --- /dev/null +++ b/docs/layouts/home.html @@ -0,0 +1,52 @@ +{{ define "main" }} +
    + {{ partial "layouts/home/opensource.html" . }} +
    + {{ partial "layouts/home/sponsors.html" (dict "ctx" . "gtag" "home" ) }} +
    + {{ partial "layouts/home/features.html" . }} +
    +{{ end }} + +{{ define "hero" }} +
    +
    +
    + Hugo Logo +

    + The world’s fastest framework for building websites +

    +
    + Hugo is one of the most popular open-source static site generators. + With its amazing speed and flexibility, Hugo makes building websites + fun again. +
    +
    + {{ with site.GetPage "/getting-started" }} + {{ .LinkTitle }} + {{ end }} +
    + {{ partial "layouts/search/button.html" (dict "page" . "standalone" true) }} +
    +
    +
    +
    +
    +{{ end }} + +{{ define "rightsidebar" }} + {{ printf "%c" '\u00A0' }} +{{ end }} + +{{ define "leftsidebar" }} + {{ printf "%c" '\u00A0' }} +{{ end }} diff --git a/docs/layouts/home.redir b/docs/layouts/home.redir new file mode 100644 index 000000000..bb72f96e5 --- /dev/null +++ b/docs/layouts/home.redir @@ -0,0 +1,6 @@ +# Netlify redirects. See https://www.netlify.com/docs/redirects/ +{{ range $p := .Site.Pages -}} +{{ range .Aliases }} +{{ . | printf "%-35s" }} {{ $p.RelPermalink -}} +{{ end -}} +{{- end -}} diff --git a/docs/layouts/index.rss.xml b/docs/layouts/index.rss.xml deleted file mode 100644 index 1d3498a1e..000000000 --- a/docs/layouts/index.rss.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - {{ .Site.Title }} – {{ .Title }} - {{ .Permalink }} - Recent Hugo news from gohugo.io - Hugo -- gohugo.io{{ with .Site.LanguageCode }} - {{.}}{{end}}{{ with .Site.Author.email }} - {{.}}{{ with $.Site.Author.name }} ({{.}}){{end}}{{end}}{{ with .Site.Author.email }} - {{.}}{{ with $.Site.Author.name }} ({{.}}){{end}}{{end}}{{ with .Site.Copyright }} - {{.}}{{end}}{{ if not .Date.IsZero }} - {{ .Date.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}{{ end }} - - {{ "img/hugo.png" | absURL }} - GoHugo.io - {{ .Permalink }} - - {{ with .OutputFormats.Get "RSS" }} - {{ printf "" .Permalink .MediaType | safeHTML }} - {{ end }} - {{ range first 50 (where .Site.RegularPages "Type" "in" (slice "news" "showcase")) }} - - {{ .Section | title }}: {{ .Title }} - {{ .Permalink }} - {{ .Date.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }} - {{ with .Site.Author.email }}{{.}}{{ with $.Site.Author.name }} ({{.}}){{end}}{{end}} - {{ .Permalink }} - - {{ $img := (.Resources.ByType "image").GetMatch "*featured*" }} - {{ with $img }} - {{ $img := .Resize "640x" }} - {{ printf "]]>" $img.Permalink $img.Width $img.Height | safeHTML }} - {{ end }} - {{ .Content | html }} - - - {{ end }} - - \ No newline at end of file diff --git a/docs/layouts/list.html b/docs/layouts/list.html new file mode 100644 index 000000000..b049b6da9 --- /dev/null +++ b/docs/layouts/list.html @@ -0,0 +1,69 @@ +{{ define "main" }} + {{ $pages := "" }} + {{ if .IsPage }} + {{/* We currently have a slightly odd content structure with no top level /docs section. */}} + {{ $pages = .CurrentSection.Pages }} + {{ else }} + {{ $pages = .Pages }} + {{ if eq .Section "news" }} + {{ $pages = $pages.ByPublishDate.Reverse }} + {{ end }} + {{ end }} + + +
    + {{ partial "layouts/docsheader.html" . }} + +
    +{{ end }} + +{{ define "rightsidebar" }} + {{ printf "%c" '\u00A0' }} +{{ end }} diff --git a/docs/layouts/list.rss.xml b/docs/layouts/list.rss.xml new file mode 100644 index 000000000..90fa22148 --- /dev/null +++ b/docs/layouts/list.rss.xml @@ -0,0 +1,33 @@ +{{- printf "" | safeHTML }} + + + Hugo News + Recent news about Hugo, a static site generator written in Go, optimized for speed and designed for flexibility. + {{ .Permalink }} + Hugo {{ hugo.Version }} + {{ or site.Language.LanguageCode site.Language.Lang }} + {{- with site.Copyright }} + {{ . }} + {{- end }} + {{- with .OutputFormats.Get "rss" }} + {{ printf "" .Permalink .MediaType | safeHTML }} + {{- end }} + {{- $limit := cond (gt site.Config.Services.RSS.Limit 0) site.Config.Services.RSS.Limit 999 }} + {{- $pages := "" }} + {{- with site.GetPage "/news" }} + {{- $pages = .Pages.ByPublishDate.Reverse | first $limit }} + {{- else }} + {{- errorf "The list.rss.xml layout was unable to find the 'news' page." }} + {{- end }} + {{ (index $pages 0).PublishDate.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }} + {{- range $pages }} + + {{ .Title }} + {{ or .Params.permalink .Permalink }} + {{ .PublishDate.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }} + {{ or .Params.permalink .Permalink }} + {{ .Summary | transform.XMLEscape | safeHTML }} + + {{- end }} + + diff --git a/docs/layouts/maintenance/list.html b/docs/layouts/maintenance/list.html deleted file mode 100644 index 50059ad9e..000000000 --- a/docs/layouts/maintenance/list.html +++ /dev/null @@ -1,36 +0,0 @@ -{{ define "main" }} -
    -
    -
    - -
    -
    - {{ $byLastMod := .Site.RegularPages.ByLastmod }} - {{ $recent := ($byLastMod | last 30).Reverse }} - {{ $leastRecent := $byLastMod | first 10 }} -

    Last Updated

    - {{ partial "maintenance-pages-table" $recent }} -

    Least Recently Updated

    - {{ partial "maintenance-pages-table" $leastRecent }} - - {{/* Don't think this is possible with where directly. Should investigate. */}} - {{ .Scratch.Set "todos" slice }} - {{ range .Site.RegularPages }} - {{ if .HasShortcode "todo" }} - {{ $.Scratch.Add "todos" . }} - {{ end }} - {{ end }} -

    Pages marked with TODO

    - {{ partial "maintenance-pages-table" (.Scratch.Get "todos") }} - -
    -
    -
    -{{ end }} \ No newline at end of file diff --git a/docs/layouts/partials/maintenance-pages-table.html b/docs/layouts/partials/maintenance-pages-table.html deleted file mode 100644 index 8538e2104..000000000 --- a/docs/layouts/partials/maintenance-pages-table.html +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - {{ range . }} - - - - - - {{ end }} - -
    LastModLinkGitHub
    {{ .Lastmod.Format "2006-01-02" }} - {{ .Title }} - - - {{ with .GitInfo }}{{ .Subject }}{{ else }}Source{{ end }} - -
    \ No newline at end of file diff --git a/docs/layouts/shortcodes/asciicast.html b/docs/layouts/shortcodes/asciicast.html deleted file mode 100644 index ee23adc2d..000000000 --- a/docs/layouts/shortcodes/asciicast.html +++ /dev/null @@ -1,2 +0,0 @@ -{{ $id := .Get 0 }} - diff --git a/docs/layouts/shortcodes/chroma-lexers.html b/docs/layouts/shortcodes/chroma-lexers.html deleted file mode 100644 index 0df2b868f..000000000 --- a/docs/layouts/shortcodes/chroma-lexers.html +++ /dev/null @@ -1,6 +0,0 @@ -
    -{{ range .Site.Data.docs.chroma.lexers }} -
    {{ .Name }}
    -
    {{ delimit .Aliases ", " }}
    -{{ end }} -
    \ No newline at end of file diff --git a/docs/layouts/shortcodes/code-toggle.html b/docs/layouts/shortcodes/code-toggle.html deleted file mode 100644 index da4b00719..000000000 --- a/docs/layouts/shortcodes/code-toggle.html +++ /dev/null @@ -1,34 +0,0 @@ -{{ $file := .Get "file" }} -{{ $code := "" }} -{{ with .Get "config" }} -{{ $file = $file | default "config" }} -{{ $sections := (split . ".") }} -{{ $configSection := index $.Site.Data.docs.config $sections }} -{{ $code = dict $sections $configSection }} -{{ else }} -{{ $code = $.Inner }} -{{ end }} -{{ $langs := (slice "yaml" "toml" "json") }} -
    -
    - {{- with $file -}} -
    {{ . }}.
    - {{- end -}} - {{ range $langs }} -   - {{ end }} -
    -
    - {{ range $langs }} -
    - {{ highlight ($code | transform.Remarshal . | safeHTML) . ""}} -
    - {{ if ne ($.Get "copy") "false" }} - - {{/* Functionality located within filesaver.js The copy here is located in the css with .copy class so it can be replaced with JS on success */}} - {{end}} - {{ end }} -
    - -
    diff --git a/docs/layouts/shortcodes/code.html b/docs/layouts/shortcodes/code.html deleted file mode 100644 index eafc02e6b..000000000 --- a/docs/layouts/shortcodes/code.html +++ /dev/null @@ -1,25 +0,0 @@ -{{ $file := .Get "file" }} -{{ $codeLang := "" }} -{{ $suffix := findRE "(\\.[^.]+)$" $file 1 }} -{{ with $suffix }} -{{ $codeLang = (index . 0 | strings.TrimPrefix ".") }} -{{ end }} -{{ with .Get "codeLang" }}{{ $codeLang = . }}{{ end }} -{{ if eq $codeLang "html"}} -{{ $codeLang = "go-html-template" }} -{{ end }} -
    - {{- with $file -}} -
    {{.}}
    - {{- end -}} - - {{ if ne (.Get "copy") "false" }} - - {{/* Functionality located within filesaver.js The copy here is located in the css with .copy class so it can be replaced with JS on success */}} - {{end}} -
    - {{ if .Get "nocode" }}{{ $.Inner }}{{ else }}{{ with $codeLang }}{{- highlight $.Inner . "" | -}}{{ else }}
    {{- .Inner | string -}}
    {{ end }}{{ end }} -
    - -
    diff --git a/docs/layouts/shortcodes/datatable-filtered.html b/docs/layouts/shortcodes/datatable-filtered.html deleted file mode 100644 index 576ddab6f..000000000 --- a/docs/layouts/shortcodes/datatable-filtered.html +++ /dev/null @@ -1,28 +0,0 @@ -{{ $package := (index .Params 0) }} -{{ $listname := (index .Params 1) }} -{{ $filter := split (index .Params 2) " " }} -{{ $filter1 := index $filter 0 }} -{{ $filter2 := index $filter 1 }} -{{ $filter3 := index $filter 2 }} - -{{ $list := (index (index .Site.Data.docs $package) $listname) }} -{{ $fields := after 3 .Params }} -{{ $list := where $list $filter1 $filter2 $filter3 }} - - - - {{ range $fields }} - - {{ end }} - - {{ range $list }} - - {{ range $k, $v := . }} - {{ $.Scratch.Set $k $v }} - {{ end }} - {{ range $fields }} - - {{ end }} - - {{ end }} -
    {{ . }}
    {{ $.Scratch.Get . }}
    diff --git a/docs/layouts/shortcodes/datatable.html b/docs/layouts/shortcodes/datatable.html deleted file mode 100644 index 4e2814f5a..000000000 --- a/docs/layouts/shortcodes/datatable.html +++ /dev/null @@ -1,22 +0,0 @@ -{{ $package := (index .Params 0) }} -{{ $listname := (index .Params 1) }} -{{ $list := (index (index .Site.Data.docs $package) $listname) }} -{{ $fields := after 2 .Params }} - - - - {{ range $fields }} - - {{ end }} - - {{ range $list }} - - {{ range $k, $v := . }} - {{ $.Scratch.Set $k $v }} - {{ end }} - {{ range $fields }} - - {{ end }} - - {{ end }} -
    {{ . }}
    {{ $.Scratch.Get . }}
    diff --git a/docs/layouts/shortcodes/directoryindex.html b/docs/layouts/shortcodes/directoryindex.html deleted file mode 100644 index 37e7d3ad1..000000000 --- a/docs/layouts/shortcodes/directoryindex.html +++ /dev/null @@ -1,13 +0,0 @@ -{{- $pathURL := .Get "pathURL" -}} -{{- $path := .Get "path" -}} -{{- $files := readDir $path -}} - - - -{{- range $files }} - - - - -{{- end }} -
    Size in bytesName
    {{ .Size }} {{ .Name }}
    diff --git a/docs/layouts/shortcodes/docfile.html b/docs/layouts/shortcodes/docfile.html deleted file mode 100644 index 2f982aae8..000000000 --- a/docs/layouts/shortcodes/docfile.html +++ /dev/null @@ -1,11 +0,0 @@ -{{ $file := .Get 0}} -{{ $filepath := $file }} -{{ $syntax := index (split $file ".") 1 }} -{{ $syntaxoverride := eq (len .Params) 2 }} -
    -
    {{$filepath}}
    - -
    {{- readFile $file -}}
    -
    diff --git a/docs/layouts/shortcodes/exfile.html b/docs/layouts/shortcodes/exfile.html deleted file mode 100644 index 226782957..000000000 --- a/docs/layouts/shortcodes/exfile.html +++ /dev/null @@ -1,12 +0,0 @@ -{{ $file := .Get 0}} -{{ $filepath := replace $file "static/" ""}} -{{ $syntax := index (split $file ".") 1 }} -{{ $syntaxoverride := eq (len .Params) 2 }} -
    -
    {{$filepath}}
    - -
    {{- readFile $file -}}
    - Source -
    diff --git a/docs/layouts/shortcodes/exfm.html b/docs/layouts/shortcodes/exfm.html deleted file mode 100644 index c0429bbe1..000000000 --- a/docs/layouts/shortcodes/exfm.html +++ /dev/null @@ -1,13 +0,0 @@ - -{{ $file := .Get 0}} -{{ $filepath := replace $file "static/" ""}} -{{ $syntax := index (split $file ".") 1 }} -{{ $syntaxoverride := eq (len .Params) 2 }} -
    -
    {{$filepath}}
    - -
    {{- readFile $file -}}
    - Source -
    \ No newline at end of file diff --git a/docs/layouts/shortcodes/funcsig.html b/docs/layouts/shortcodes/funcsig.html deleted file mode 100644 index 1709c60b0..000000000 --- a/docs/layouts/shortcodes/funcsig.html +++ /dev/null @@ -1,4 +0,0 @@ -

    Syntax

    -
    -    {{- .Inner -}}
    -
    \ No newline at end of file diff --git a/docs/layouts/shortcodes/gh.html b/docs/layouts/shortcodes/gh.html deleted file mode 100644 index 981f4b838..000000000 --- a/docs/layouts/shortcodes/gh.html +++ /dev/null @@ -1,9 +0,0 @@ -{{ range .Params }} - {{ if eq (substr . 0 1) "@" }} - {{ . }} - {{ else if eq (substr . 0 2) "0x" }} - {{ substr . 2 6 }} - {{ else }} - #{{ . }} - {{ end }} -{{ end }} diff --git a/docs/layouts/shortcodes/ghrepo.html b/docs/layouts/shortcodes/ghrepo.html deleted file mode 100644 index e9df40d6a..000000000 --- a/docs/layouts/shortcodes/ghrepo.html +++ /dev/null @@ -1 +0,0 @@ -GitHub repository \ No newline at end of file diff --git a/docs/layouts/shortcodes/gomodules-info.html b/docs/layouts/shortcodes/gomodules-info.html deleted file mode 100644 index 3c9d486ae..000000000 --- a/docs/layouts/shortcodes/gomodules-info.html +++ /dev/null @@ -1,17 +0,0 @@ -{{ $text := ` -Most of the commands for **Hugo Modules** requires a newer version of Go installed (see https://golang.org/dl/) and the relevant VCS client (e.g. Git, see https://git-scm.com/downloads). If you have an "older" site running on Netlify, you may have to set GO_VERSION to 1.12 in your Environment settings. - -For more information about Go Modules, see: - -* https://github.com/golang/go/wiki/Modules -* https://blog.golang.org/using-go-modules -` }} - - diff --git a/docs/layouts/shortcodes/imgproc.html b/docs/layouts/shortcodes/imgproc.html deleted file mode 100644 index 5e02317c6..000000000 --- a/docs/layouts/shortcodes/imgproc.html +++ /dev/null @@ -1,25 +0,0 @@ -{{ $original := .Page.Resources.GetMatch (printf "*%s*" (.Get 0)) }} -{{ $command := .Get 1 }} -{{ $options := .Get 2 }} -{{ if eq $command "Fit"}} -{{ .Scratch.Set "image" ($original.Fit $options) }} -{{ else if eq $command "Resize"}} -{{ .Scratch.Set "image" ($original.Resize $options) }} -{{ else if eq $command "Fill"}} -{{ .Scratch.Set "image" ($original.Fill $options) }} -{{ else }} -{{ errorf "Invalid image processing command: Must be one of Fit, Fill or Resize."}} -{{ end }} -{{ $image := .Scratch.Get "image" }} -
    - -
    - - {{ with .Inner }} - {{ . }} - {{ else }} - .{{ $command }} "{{ $options }}" - {{ end }} - -
    -
    \ No newline at end of file diff --git a/docs/layouts/shortcodes/module-mounts-note.html b/docs/layouts/shortcodes/module-mounts-note.html deleted file mode 100644 index 654aafef4..000000000 --- a/docs/layouts/shortcodes/module-mounts-note.html +++ /dev/null @@ -1 +0,0 @@ -Also see [Module Mounts Config](/hugo-modules/configuration/#module-config-mounts) for an alternative way to configure this directory (from Hugo 0.56). \ No newline at end of file diff --git a/docs/layouts/shortcodes/new-in.html b/docs/layouts/shortcodes/new-in.html deleted file mode 100644 index ab0abb273..000000000 --- a/docs/layouts/shortcodes/new-in.html +++ /dev/null @@ -1,8 +0,0 @@ -{{ $version := .Get 0 }} -{{ if not $version }} -{{ errorf "Missing version in new-in shortcode "}} -{{ end }} -{{ $version = $version | strings.TrimPrefix "v" }} - \ No newline at end of file diff --git a/docs/layouts/shortcodes/nohighlight.html b/docs/layouts/shortcodes/nohighlight.html deleted file mode 100644 index 238234f17..000000000 --- a/docs/layouts/shortcodes/nohighlight.html +++ /dev/null @@ -1 +0,0 @@ -
    {{ .Inner }}
    \ No newline at end of file diff --git a/docs/layouts/shortcodes/note.html b/docs/layouts/shortcodes/note.html deleted file mode 100644 index 24d2cd0b2..000000000 --- a/docs/layouts/shortcodes/note.html +++ /dev/null @@ -1,9 +0,0 @@ -{{ $_hugo_config := `{ "version": 1 }` }} - diff --git a/docs/layouts/shortcodes/output.html b/docs/layouts/shortcodes/output.html deleted file mode 100644 index e51d284bb..000000000 --- a/docs/layouts/shortcodes/output.html +++ /dev/null @@ -1,8 +0,0 @@ -{{$file := .Get "file"}} -{{$icon := index (split $file ".") 1 }} -
    -
    {{$file}}
    -
    -
    {{- .Inner | string -}}
    -
    -
    \ No newline at end of file diff --git a/docs/layouts/shortcodes/readfile.html b/docs/layouts/shortcodes/readfile.html deleted file mode 100644 index 36400ac55..000000000 --- a/docs/layouts/shortcodes/readfile.html +++ /dev/null @@ -1,8 +0,0 @@ -{{$file := .Get "file"}} -{{- if eq (.Get "markdown") "true" -}} -{{- $file | readFile | markdownify -}} -{{- else if (.Get "highlight") -}} -{{- highlight ($file | readFile) (.Get "highlight") "" -}} -{{- else -}} -{{ $file | readFile | safeHTML }} -{{- end -}} \ No newline at end of file diff --git a/docs/layouts/shortcodes/tip.html b/docs/layouts/shortcodes/tip.html deleted file mode 100644 index 139e3376b..000000000 --- a/docs/layouts/shortcodes/tip.html +++ /dev/null @@ -1,9 +0,0 @@ -{{ $_hugo_config := `{ "version": 1 }` }} - diff --git a/docs/layouts/shortcodes/todo.html b/docs/layouts/shortcodes/todo.html deleted file mode 100644 index 50a099267..000000000 --- a/docs/layouts/shortcodes/todo.html +++ /dev/null @@ -1 +0,0 @@ -{{ if .Inner }}{{ end }} \ No newline at end of file diff --git a/docs/layouts/shortcodes/warning.html b/docs/layouts/shortcodes/warning.html deleted file mode 100644 index c9147be64..000000000 --- a/docs/layouts/shortcodes/warning.html +++ /dev/null @@ -1,9 +0,0 @@ -{{ $_hugo_config := `{ "version": 1 }` }} - diff --git a/docs/layouts/shortcodes/yt.html b/docs/layouts/shortcodes/yt.html deleted file mode 100644 index 6915cec5f..000000000 --- a/docs/layouts/shortcodes/yt.html +++ /dev/null @@ -1,11 +0,0 @@ -
    - - {{if (.Get "thumbnail")}} -
    - {{else}} -
    - {{end}} -
    -{{ if (.Get "description") }} -
    {{ .Get "description" | markdownify }}
    -{{ end }} \ No newline at end of file diff --git a/docs/layouts/single.html b/docs/layouts/single.html new file mode 100644 index 000000000..2e9e4f379 --- /dev/null +++ b/docs/layouts/single.html @@ -0,0 +1,80 @@ +{{ define "main" }} + {{ $ttop := debug.Timer "single" }} +
    + {{ partial "layouts/docsheader.html" . }} +
    + {{ with .Params.description }} +
    + {{ . | markdownify }} +
    + {{ end }} + {{ if .Params.show_publish_date }} + {{ with .PublishDate }} +

    + {{ partial "layouts/date.html" . }} +

    + {{ end }} + {{ end }} + {{ $t := debug.Timer "single.categories" }} + {{ $categories := .GetTerms "categories" }} + {{ with $categories }} +
    + {{ range . }} + {{ $text := .LinkTitle }} + {{ $class := "" }} + {{ range (slice true false ) }} + {{ $color := partial "helpers/funcs/color-from-string.html" (dict "text" $text "dark" . "--single" "green" ) }} + + {{ $prefix := "" }} + {{ if . }} + {{ $prefix = "dark:" }} + {{ end }} + {{ $class = printf "%sbg-%s-%d %stext-%s-%d border %sborder-%s-%d" + $prefix $color.color $color.shade1 + $prefix $color.color $color.shade2 + $prefix $color.color $color.shade3 + }} + {{ end }} + + + + {{ .LinkTitle }} + + {{ end }} +
    + {{ end }} + {{ $t.Stop }} + + {{ if .Params.functions_and_methods.signatures }} +
    + {{- partial "docs/functions-signatures.html" . -}} + {{- partial "docs/functions-return-type.html" . -}} + {{- partial "docs/functions-aliases.html" . -}} +
    + {{ end }} + {{ $t := debug.Timer "single.content" }} + {{ .Content }} + {{ $t.Stop }} + {{ $t := debug.Timer "single.page-edit" }} + {{ partial "layouts/page-edit.html" . }} + {{ $t.Stop }} +
    +
    + {{ $ttop.Stop }} +{{ end }} + +{{ define "rightsidebar_content" }} + {{/* in-this-section.html depends on these being reneredc first. */}} + {{ $related := partial "layouts/related.html" . }} + {{ $toc := partial "layouts/toc.html" . }} + {{ if not .Params.hide_in_this_section }} + {{ partial "layouts/in-this-section.html" . }} + {{ end }} + {{ $related }} + {{ if $.Store.Get "hasToc" }} + {{ $toc }} + {{ end }} +{{ end }} diff --git a/docs/netlify.toml b/docs/netlify.toml index afecd1444..c24a32a60 100644 --- a/docs/netlify.toml +++ b/docs/netlify.toml @@ -1,31 +1,55 @@ [build] -publish = "public" -command = "hugo --gc --minify" + publish = "public" + command = "hugo --gc --minify" + + [build.environment] + HUGO_VERSION = "0.146.7" [context.production.environment] -HUGO_VERSION = "0.62.1" -HUGO_ENV = "production" -HUGO_ENABLEGITINFO = "true" + HUGO_ENV = "production" + HUGO_ENABLEGITINFO = "true" [context.split1] -command = "hugo --gc --minify --enableGitInfo" + command = "hugo --gc --minify --enableGitInfo" -[context.split1.environment] -HUGO_VERSION = "0.62.1" -HUGO_ENV = "production" + [context.split1.environment] + HUGO_ENV = "production" [context.deploy-preview] -command = "hugo --gc --minify --buildFuture -b $DEPLOY_PRIME_URL" - -[context.deploy-preview.environment] -HUGO_VERSION = "0.62.1" + command = "hugo --gc --minify --buildFuture -b $DEPLOY_PRIME_URL --enableGitInfo" [context.branch-deploy] -command = "hugo --gc --minify -b $DEPLOY_PRIME_URL" - -[context.branch-deploy.environment] -HUGO_VERSION = "0.62.1" + command = "hugo --gc --minify -b $DEPLOY_PRIME_URL" [context.next.environment] -HUGO_ENABLEGITINFO = "true" + HUGO_ENABLEGITINFO = "true" +[[headers]] + for = "/*.jpg" + + [headers.values] + Cache-Control = "public, max-age=31536000" + +[[headers]] + for = "/*.png" + + [headers.values] + Cache-Control = "public, max-age=31536000" + +[[headers]] + for = "/*.css" + + [headers.values] + Cache-Control = "public, max-age=31536000" + +[[headers]] + for = "/*.js" + + [headers.values] + Cache-Control = "public, max-age=31536000" + +[[headers]] + for = "/*.ttf" + + [headers.values] + Cache-Control = "public, max-age=31536000" diff --git a/docs/package.hugo.json b/docs/package.hugo.json new file mode 100644 index 000000000..24ffc7ff5 --- /dev/null +++ b/docs/package.hugo.json @@ -0,0 +1,25 @@ +{ + "name": "hugoDocs", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "", + "devDependencies": { + "@awmottaz/prettier-plugin-void-html": "~1.8.0", + "@tailwindcss/cli": "~4.1.0", + "@tailwindcss/typography": "~0.5.16", + "prettier": "~3.5.3", + "prettier-plugin-go-template": "~0.0.15", + "tailwindcss": "~4.1.0" + }, + "dependencies": { + "@alpinejs/focus": "~3.14.9", + "@alpinejs/persist": "~3.14.9", + "@hotwired/turbo": "~8.0.13", + "alpinejs": "~3.14.9" + } +} diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 000000000..24ffc7ff5 --- /dev/null +++ b/docs/package.json @@ -0,0 +1,25 @@ +{ + "name": "hugoDocs", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "", + "devDependencies": { + "@awmottaz/prettier-plugin-void-html": "~1.8.0", + "@tailwindcss/cli": "~4.1.0", + "@tailwindcss/typography": "~0.5.16", + "prettier": "~3.5.3", + "prettier-plugin-go-template": "~0.0.15", + "tailwindcss": "~4.1.0" + }, + "dependencies": { + "@alpinejs/focus": "~3.14.9", + "@alpinejs/persist": "~3.14.9", + "@hotwired/turbo": "~8.0.13", + "alpinejs": "~3.14.9" + } +} diff --git a/docs/pull-theme.sh b/docs/pull-theme.sh deleted file mode 100755 index 828b6cfb4..000000000 --- a/docs/pull-theme.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash - -git subtree pull --prefix=themes/gohugoioTheme/ git@github.com:gohugoio/gohugoioTheme.git master --squash - diff --git a/docs/resources/.gitattributes b/docs/resources/.gitattributes deleted file mode 100644 index a205a8e9d..000000000 --- a/docs/resources/.gitattributes +++ /dev/null @@ -1,2 +0,0 @@ -*.* linguist-generated=true -*.* -diff -merge \ No newline at end of file diff --git a/docs/resources/_gen/assets/css/output/css/app.css_d11fe7b62c27961c87ecd0f2490357b9.content b/docs/resources/_gen/assets/css/output/css/app.css_d11fe7b62c27961c87ecd0f2490357b9.content deleted file mode 100644 index 42d7140c5..000000000 --- a/docs/resources/_gen/assets/css/output/css/app.css_d11fe7b62c27961c87ecd0f2490357b9.content +++ /dev/null @@ -1 +0,0 @@ -@font-face{font-family:muli;font-style:normal;font-display:swap;font-weight:200;src:local('Muli Extra Light '),local('Muli-Extra Light'),url(/fonts/muli-latin-200.woff2) format('woff2'),url(/fonts/muli-latin-200.woff) format('woff')}@font-face{font-family:muli;font-style:italic;font-display:swap;font-weight:200;src:local('Muli Extra Light italic'),local('Muli-Extra Lightitalic'),url(/fonts/muli-latin-200italic.woff2) format('woff2'),url(/fonts/muli-latin-200italic.woff) format('woff')}@font-face{font-family:muli;font-style:normal;font-display:swap;font-weight:300;src:local('Muli Light '),local(Muli-Light),url(/fonts/muli-latin-300.woff2) format('woff2'),url(/fonts/muli-latin-300.woff) format('woff')}@font-face{font-family:muli;font-style:italic;font-display:swap;font-weight:300;src:local('Muli Light italic'),local(Muli-Lightitalic),url(/fonts/muli-latin-300italic.woff2) format('woff2'),url(/fonts/muli-latin-300italic.woff) format('woff')}@font-face{font-family:muli;font-style:normal;font-display:swap;font-weight:400;src:local('Muli Regular '),local(Muli-Regular),url(/fonts/muli-latin-400.woff2) format('woff2'),url(/fonts/muli-latin-400.woff) format('woff')}@font-face{font-family:muli;font-style:italic;font-display:swap;font-weight:400;src:local('Muli Regular italic'),local(Muli-Regularitalic),url(/fonts/muli-latin-400italic.woff2) format('woff2'),url(/fonts/muli-latin-400italic.woff) format('woff')}@font-face{font-family:muli;font-style:normal;font-display:swap;font-weight:600;src:local('Muli SemiBold '),local(Muli-SemiBold),url(/fonts/muli-latin-600.woff2) format('woff2'),url(/fonts/muli-latin-600.woff) format('woff')}@font-face{font-family:muli;font-style:italic;font-display:swap;font-weight:600;src:local('Muli SemiBold italic'),local(Muli-SemiBolditalic),url(/fonts/muli-latin-600italic.woff2) format('woff2'),url(/fonts/muli-latin-600italic.woff) format('woff')}@font-face{font-family:muli;font-style:normal;font-display:swap;font-weight:700;src:local('Muli Bold '),local(Muli-Bold),url(/fonts/muli-latin-700.woff2) format('woff2'),url(/fonts/muli-latin-700.woff) format('woff')}@font-face{font-family:muli;font-style:italic;font-display:swap;font-weight:700;src:local('Muli Bold italic'),local(Muli-Bolditalic),url(/fonts/muli-latin-700italic.woff2) format('woff2'),url(/fonts/muli-latin-700italic.woff) format('woff')}@font-face{font-family:muli;font-style:normal;font-display:swap;font-weight:800;src:local('Muli ExtraBold '),local(Muli-ExtraBold),url(/fonts/muli-latin-800.woff2) format('woff2'),url(/fonts/muli-latin-800.woff) format('woff')}@font-face{font-family:muli;font-style:italic;font-display:swap;font-weight:800;src:local('Muli ExtraBold italic'),local(Muli-ExtraBolditalic),url(/fonts/muli-latin-800italic.woff2) format('woff2'),url(/fonts/muli-latin-800italic.woff) format('woff')}@font-face{font-family:muli;font-style:normal;font-display:swap;font-weight:900;src:local('Muli Black '),local(Muli-Black),url(/fonts/muli-latin-900.woff2) format('woff2'),url(/fonts/muli-latin-900.woff) format('woff')}@font-face{font-family:muli;font-style:italic;font-display:swap;font-weight:900;src:local('Muli Black italic'),local(Muli-Blackitalic),url(/fonts/muli-latin-900italic.woff2) format('woff2'),url(/fonts/muli-latin-900italic.woff) format('woff')}/*!TACHYONS v4.7.0 | http://tachyons.io*//*!normalize.css v8.0.0 | MIT License | github.com/necolas/normalize.css*/html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}h1{font-size:2em;margin:.67em 0}hr{-webkit-box-sizing:content-box;box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button}button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{border-style:none;padding:0}button:-moz-focusring,[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{-webkit-box-sizing:border-box;box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{-webkit-box-sizing:border-box;box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}template{display:none}[hidden]{display:none}html,body,div,article,aside,section,main,nav,footer,header,form,fieldset,legend,pre,code,a,h1,h2,h3,h4,h5,h6,p,ul,ol,li,dl,dt,dd,blockquote,figcaption,figure,textarea,table,td,th,tr,input[type=email],input[type=number],input[type=password],input[type=tel],input[type=text],input[type=url],.border-box{-webkit-box-sizing:border-box;box-sizing:border-box}img{max-width:100%}.cover{background-size:cover!important}.contain{background-size:contain!important}@media screen and (min-width:30em){.cover-ns{background-size:cover!important}.contain-ns{background-size:contain!important}}@media screen and (min-width:30em) and (max-width:60em){.cover-m{background-size:cover!important}.contain-m{background-size:contain!important}}@media screen and (min-width:60em){.cover-l{background-size:cover!important}.contain-l{background-size:contain!important}}.bg-center{background-repeat:no-repeat;background-position:50%}.bg-top{background-repeat:no-repeat;background-position:50% 0}.bg-right{background-repeat:no-repeat;background-position:50% 100%}.bg-bottom{background-repeat:no-repeat;background-position:50% 100%}.bg-left{background-repeat:no-repeat;background-position:50% 0}@media screen and (min-width:30em){.bg-center-ns{background-repeat:no-repeat;background-position:50%}.bg-top-ns{background-repeat:no-repeat;background-position:50% 0}.bg-right-ns{background-repeat:no-repeat;background-position:50% 100%}.bg-bottom-ns{background-repeat:no-repeat;background-position:50% 100%}.bg-left-ns{background-repeat:no-repeat;background-position:50% 0}}@media screen and (min-width:30em) and (max-width:60em){.bg-center-m{background-repeat:no-repeat;background-position:50%}.bg-top-m{background-repeat:no-repeat;background-position:50% 0}.bg-right-m{background-repeat:no-repeat;background-position:50% 100%}.bg-bottom-m{background-repeat:no-repeat;background-position:50% 100%}.bg-left-m{background-repeat:no-repeat;background-position:50% 0}}@media screen and (min-width:60em){.bg-center-l{background-repeat:no-repeat;background-position:50%}.bg-top-l{background-repeat:no-repeat;background-position:50% 0}.bg-right-l{background-repeat:no-repeat;background-position:50% 100%}.bg-bottom-l{background-repeat:no-repeat;background-position:50% 100%}.bg-left-l{background-repeat:no-repeat;background-position:50% 0}}.ba{border-style:solid;border-width:1px}.bt{border-top-style:solid;border-top-width:1px}.br{border-right-style:solid;border-right-width:1px}.bb{border-bottom-style:solid;border-bottom-width:1px}.bl{border-left-style:solid;border-left-width:1px}.bn{border-style:none;border-width:0}@media screen and (min-width:30em){.ba-ns{border-style:solid;border-width:1px}.bt-ns{border-top-style:solid;border-top-width:1px}.br-ns{border-right-style:solid;border-right-width:1px}.bb-ns{border-bottom-style:solid;border-bottom-width:1px}.bl-ns{border-left-style:solid;border-left-width:1px}.bn-ns{border-style:none;border-width:0}}@media screen and (min-width:30em) and (max-width:60em){.ba-m{border-style:solid;border-width:1px}.bt-m{border-top-style:solid;border-top-width:1px}.br-m{border-right-style:solid;border-right-width:1px}.bb-m{border-bottom-style:solid;border-bottom-width:1px}.bl-m{border-left-style:solid;border-left-width:1px}.bn-m{border-style:none;border-width:0}}@media screen and (min-width:60em){.ba-l{border-style:solid;border-width:1px}.bt-l{border-top-style:solid;border-top-width:1px}.br-l{border-right-style:solid;border-right-width:1px}.bb-l{border-bottom-style:solid;border-bottom-width:1px}.bl-l{border-left-style:solid;border-left-width:1px}.bn-l{border-style:none;border-width:0}}.b--black{border-color:#000}.b--near-black{border-color:#111}.b--dark-gray{border-color:#333}.b--mid-gray{border-color:#555}.b--gray{border-color:#777}.b--silver{border-color:#999}.b--light-silver{border-color:#aaa}.b--moon-gray{border-color:#ccc}.b--light-gray{border-color:#eee}.b--near-white{border-color:#f4f4f4}.b--white{border-color:#fff}.b--white-90{border-color:rgba(255,255,255,.9)}.b--white-80{border-color:rgba(255,255,255,.8)}.b--white-70{border-color:rgba(255,255,255,.7)}.b--white-60{border-color:rgba(255,255,255,.6)}.b--white-50{border-color:rgba(255,255,255,.5)}.b--white-40{border-color:rgba(255,255,255,.4)}.b--white-30{border-color:rgba(255,255,255,.3)}.b--white-20{border-color:rgba(255,255,255,.2)}.b--white-10{border-color:rgba(255,255,255,.1)}.b--white-05{border-color:rgba(255,255,255,.05)}.b--white-025{border-color:rgba(255,255,255,.025)}.b--white-0125{border-color:rgba(255,255,255,.0125)}.b--black-90{border-color:rgba(0,0,0,.9)}.b--black-80{border-color:rgba(0,0,0,.8)}.b--black-70{border-color:rgba(0,0,0,.7)}.b--black-60{border-color:rgba(0,0,0,.6)}.b--black-50{border-color:rgba(0,0,0,.5)}.b--black-40{border-color:rgba(0,0,0,.4)}.b--black-30{border-color:rgba(0,0,0,.3)}.b--black-20{border-color:rgba(0,0,0,.2)}.b--black-10{border-color:rgba(0,0,0,.1)}.b--black-05{border-color:rgba(0,0,0,.05)}.b--black-025{border-color:rgba(0,0,0,.025)}.b--black-0125{border-color:rgba(0,0,0,.0125)}.b--dark-red{border-color:#e7040f}.b--red{border-color:#ff4136}.b--light-red{border-color:#ff725c}.b--orange{border-color:#ff6300}.b--gold{border-color:#ffb700}.b--yellow{border-color:gold}.b--light-yellow{border-color:#fbf1a9}.b--purple{border-color:#5e2ca5}.b--light-purple{border-color:#a463f2}.b--dark-pink{border-color:#d5008f}.b--hot-pink{border-color:#ff41b4}.b--pink{border-color:#ff80cc}.b--light-pink{border-color:#ffa3d7}.b--dark-green{border-color:#137752}.b--green{border-color:#19a974}.b--light-green{border-color:#9eebcf}.b--navy{border-color:#001b44}.b--dark-blue{border-color:#00449e}.b--blue{border-color:#0594cb}.b--light-blue{border-color:#96ccff}.b--lightest-blue{border-color:#cdecff}.b--washed-blue{border-color:#f6fffe}.b--washed-green{border-color:#e8fdf5}.b--washed-yellow{border-color:#fffceb}.b--washed-red{border-color:#ffdfdf}.b--transparent{border-color:transparent}.b--inherit{border-color:inherit}.br0{border-radius:0}.br1{border-radius:.125rem}.br2{border-radius:.25rem}.br3{border-radius:.5rem}.br4{border-radius:1rem}.br-100{border-radius:100%}.br-pill{border-radius:9999px}.br--bottom{border-top-left-radius:0;border-top-right-radius:0}.br--top{border-bottom-left-radius:0;border-bottom-right-radius:0}.br--right{border-top-left-radius:0;border-bottom-left-radius:0}.br--left{border-top-right-radius:0;border-bottom-right-radius:0}@media screen and (min-width:30em){.br0-ns{border-radius:0}.br1-ns{border-radius:.125rem}.br2-ns{border-radius:.25rem}.br3-ns{border-radius:.5rem}.br4-ns{border-radius:1rem}.br-100-ns{border-radius:100%}.br-pill-ns{border-radius:9999px}.br--bottom-ns{border-top-left-radius:0;border-top-right-radius:0}.br--top-ns{border-bottom-left-radius:0;border-bottom-right-radius:0}.br--right-ns{border-top-left-radius:0;border-bottom-left-radius:0}.br--left-ns{border-top-right-radius:0;border-bottom-right-radius:0}}@media screen and (min-width:30em) and (max-width:60em){.br0-m{border-radius:0}.br1-m{border-radius:.125rem}.br2-m{border-radius:.25rem}.br3-m{border-radius:.5rem}.br4-m{border-radius:1rem}.br-100-m{border-radius:100%}.br-pill-m{border-radius:9999px}.br--bottom-m{border-top-left-radius:0;border-top-right-radius:0}.br--top-m{border-bottom-left-radius:0;border-bottom-right-radius:0}.br--right-m{border-top-left-radius:0;border-bottom-left-radius:0}.br--left-m{border-top-right-radius:0;border-bottom-right-radius:0}}@media screen and (min-width:60em){.br0-l{border-radius:0}.br1-l{border-radius:.125rem}.br2-l{border-radius:.25rem}.br3-l{border-radius:.5rem}.br4-l{border-radius:1rem}.br-100-l{border-radius:100%}.br-pill-l{border-radius:9999px}.br--bottom-l{border-top-left-radius:0;border-top-right-radius:0}.br--top-l{border-bottom-left-radius:0;border-bottom-right-radius:0}.br--right-l{border-top-left-radius:0;border-bottom-left-radius:0}.br--left-l{border-top-right-radius:0;border-bottom-right-radius:0}}.b--dotted{border-style:dotted}.b--dashed{border-style:dashed}.b--solid{border-style:solid}.b--none{border-style:none}@media screen and (min-width:30em){.b--dotted-ns{border-style:dotted}.b--dashed-ns{border-style:dashed}.b--solid-ns{border-style:solid}.b--none-ns{border-style:none}}@media screen and (min-width:30em) and (max-width:60em){.b--dotted-m{border-style:dotted}.b--dashed-m{border-style:dashed}.b--solid-m{border-style:solid}.b--none-m{border-style:none}}@media screen and (min-width:60em){.b--dotted-l{border-style:dotted}.b--dashed-l{border-style:dashed}.b--solid-l{border-style:solid}.b--none-l{border-style:none}}.bw0{border-width:0}.bw1{border-width:.125rem}.bw2{border-width:.25rem}.bw3{border-width:.5rem}.bw4{border-width:1rem}.bw5{border-width:2rem}.bt-0{border-top-width:0}.br-0{border-right-width:0}.bb-0{border-bottom-width:0}.bl-0{border-left-width:0}@media screen and (min-width:30em){.bw0-ns{border-width:0}.bw1-ns{border-width:.125rem}.bw2-ns{border-width:.25rem}.bw3-ns{border-width:.5rem}.bw4-ns{border-width:1rem}.bw5-ns{border-width:2rem}.bt-0-ns{border-top-width:0}.br-0-ns{border-right-width:0}.bb-0-ns{border-bottom-width:0}.bl-0-ns{border-left-width:0}}@media screen and (min-width:30em) and (max-width:60em){.bw0-m{border-width:0}.bw1-m{border-width:.125rem}.bw2-m{border-width:.25rem}.bw3-m{border-width:.5rem}.bw4-m{border-width:1rem}.bw5-m{border-width:2rem}.bt-0-m{border-top-width:0}.br-0-m{border-right-width:0}.bb-0-m{border-bottom-width:0}.bl-0-m{border-left-width:0}}@media screen and (min-width:60em){.bw0-l{border-width:0}.bw1-l{border-width:.125rem}.bw2-l{border-width:.25rem}.bw3-l{border-width:.5rem}.bw4-l{border-width:1rem}.bw5-l{border-width:2rem}.bt-0-l{border-top-width:0}.br-0-l{border-right-width:0}.bb-0-l{border-bottom-width:0}.bl-0-l{border-left-width:0}}.shadow-1{-webkit-box-shadow:0 0 4px 2px rgba(0,0,0,.2);box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.shadow-2{-webkit-box-shadow:0 0 8px 2px rgba(0,0,0,.2);box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.shadow-3{-webkit-box-shadow:2px 2px 4px 2px rgba(0,0,0,.2);box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.shadow-4{-webkit-box-shadow:2px 2px 8px 0 rgba(0,0,0,.2);box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.shadow-5{-webkit-box-shadow:4px 4px 8px 0 rgba(0,0,0,.2);box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}@media screen and (min-width:30em){.shadow-1-ns{-webkit-box-shadow:0 0 4px 2px rgba(0,0,0,.2);box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.shadow-2-ns{-webkit-box-shadow:0 0 8px 2px rgba(0,0,0,.2);box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.shadow-3-ns{-webkit-box-shadow:2px 2px 4px 2px rgba(0,0,0,.2);box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.shadow-4-ns{-webkit-box-shadow:2px 2px 8px 0 rgba(0,0,0,.2);box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.shadow-5-ns{-webkit-box-shadow:4px 4px 8px 0 rgba(0,0,0,.2);box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}}@media screen and (min-width:30em) and (max-width:60em){.shadow-1-m{-webkit-box-shadow:0 0 4px 2px rgba(0,0,0,.2);box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.shadow-2-m{-webkit-box-shadow:0 0 8px 2px rgba(0,0,0,.2);box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.shadow-3-m{-webkit-box-shadow:2px 2px 4px 2px rgba(0,0,0,.2);box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.shadow-4-m{-webkit-box-shadow:2px 2px 8px 0 rgba(0,0,0,.2);box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.shadow-5-m{-webkit-box-shadow:4px 4px 8px 0 rgba(0,0,0,.2);box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}}@media screen and (min-width:60em){.shadow-1-l{-webkit-box-shadow:0 0 4px 2px rgba(0,0,0,.2);box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.shadow-2-l{-webkit-box-shadow:0 0 8px 2px rgba(0,0,0,.2);box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.shadow-3-l{-webkit-box-shadow:2px 2px 4px 2px rgba(0,0,0,.2);box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.shadow-4-l{-webkit-box-shadow:2px 2px 8px 0 rgba(0,0,0,.2);box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.shadow-5-l{-webkit-box-shadow:4px 4px 8px 0 rgba(0,0,0,.2);box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}}.top-0{top:0}.right-0{right:0}.bottom-0{bottom:0}.left-0{left:0}.top-1{top:1rem}.right-1{right:1rem}.bottom-1{bottom:1rem}.left-1{left:1rem}.top-2{top:2rem}.right-2{right:2rem}.bottom-2{bottom:2rem}.left-2{left:2rem}.top--1{top:-1rem}.right--1{right:-1rem}.bottom--1{bottom:-1rem}.left--1{left:-1rem}.top--2{top:-2rem}.right--2{right:-2rem}.bottom--2{bottom:-2rem}.left--2{left:-2rem}.absolute--fill{top:0;right:0;bottom:0;left:0}@media screen and (min-width:30em){.top-0-ns{top:0}.left-0-ns{left:0}.right-0-ns{right:0}.bottom-0-ns{bottom:0}.top-1-ns{top:1rem}.left-1-ns{left:1rem}.right-1-ns{right:1rem}.bottom-1-ns{bottom:1rem}.top-2-ns{top:2rem}.left-2-ns{left:2rem}.right-2-ns{right:2rem}.bottom-2-ns{bottom:2rem}.top--1-ns{top:-1rem}.right--1-ns{right:-1rem}.bottom--1-ns{bottom:-1rem}.left--1-ns{left:-1rem}.top--2-ns{top:-2rem}.right--2-ns{right:-2rem}.bottom--2-ns{bottom:-2rem}.left--2-ns{left:-2rem}.absolute--fill-ns{top:0;right:0;bottom:0;left:0}}@media screen and (min-width:30em) and (max-width:60em){.top-0-m{top:0}.left-0-m{left:0}.right-0-m{right:0}.bottom-0-m{bottom:0}.top-1-m{top:1rem}.left-1-m{left:1rem}.right-1-m{right:1rem}.bottom-1-m{bottom:1rem}.top-2-m{top:2rem}.left-2-m{left:2rem}.right-2-m{right:2rem}.bottom-2-m{bottom:2rem}.top--1-m{top:-1rem}.right--1-m{right:-1rem}.bottom--1-m{bottom:-1rem}.left--1-m{left:-1rem}.top--2-m{top:-2rem}.right--2-m{right:-2rem}.bottom--2-m{bottom:-2rem}.left--2-m{left:-2rem}.absolute--fill-m{top:0;right:0;bottom:0;left:0}}@media screen and (min-width:60em){.top-0-l{top:0}.left-0-l{left:0}.right-0-l{right:0}.bottom-0-l{bottom:0}.top-1-l{top:1rem}.left-1-l{left:1rem}.right-1-l{right:1rem}.bottom-1-l{bottom:1rem}.top-2-l{top:2rem}.left-2-l{left:2rem}.right-2-l{right:2rem}.bottom-2-l{bottom:2rem}.top--1-l{top:-1rem}.right--1-l{right:-1rem}.bottom--1-l{bottom:-1rem}.left--1-l{left:-1rem}.top--2-l{top:-2rem}.right--2-l{right:-2rem}.bottom--2-l{bottom:-2rem}.left--2-l{left:-2rem}.absolute--fill-l{top:0;right:0;bottom:0;left:0}}.cf:before,.cf:after{content:" ";display:table}.cf:after{clear:both}.cf{*zoom:1}.cl{clear:left}.cr{clear:right}.cb{clear:both}.cn{clear:none}@media screen and (min-width:30em){.cl-ns{clear:left}.cr-ns{clear:right}.cb-ns{clear:both}.cn-ns{clear:none}}@media screen and (min-width:30em) and (max-width:60em){.cl-m{clear:left}.cr-m{clear:right}.cb-m{clear:both}.cn-m{clear:none}}@media screen and (min-width:60em){.cl-l{clear:left}.cr-l{clear:right}.cb-l{clear:both}.cn-l{clear:none}}.dn{display:none}.di{display:inline}.db{display:block}.dib{display:inline-block}.dit{display:inline-table}.dt{display:table}.dtc{display:table-cell}.dt-row{display:table-row}.dt-row-group{display:table-row-group}.dt-column{display:table-column}.dt-column-group{display:table-column-group}.dt--fixed{table-layout:fixed;width:100%}@media screen and (min-width:30em){.dn-ns{display:none}.di-ns{display:inline}.db-ns{display:block}.dib-ns{display:inline-block}.dit-ns{display:inline-table}.dt-ns{display:table}.dtc-ns{display:table-cell}.dt-row-ns{display:table-row}.dt-row-group-ns{display:table-row-group}.dt-column-ns{display:table-column}.dt-column-group-ns{display:table-column-group}.dt--fixed-ns{table-layout:fixed;width:100%}}@media screen and (min-width:30em) and (max-width:60em){.dn-m{display:none}.di-m{display:inline}.db-m{display:block}.dib-m{display:inline-block}.dit-m{display:inline-table}.dt-m{display:table}.dtc-m{display:table-cell}.dt-row-m{display:table-row}.dt-row-group-m{display:table-row-group}.dt-column-m{display:table-column}.dt-column-group-m{display:table-column-group}.dt--fixed-m{table-layout:fixed;width:100%}}@media screen and (min-width:60em){.dn-l{display:none}.di-l{display:inline}.db-l{display:block}.dib-l{display:inline-block}.dit-l{display:inline-table}.dt-l{display:table}.dtc-l{display:table-cell}.dt-row-l{display:table-row}.dt-row-group-l{display:table-row-group}.dt-column-l{display:table-column}.dt-column-group-l{display:table-column-group}.dt--fixed-l{table-layout:fixed;width:100%}}.flex{display:-webkit-box;display:-ms-flexbox;display:flex}.inline-flex{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex}.flex-auto{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;min-width:0;min-height:0}.flex-none{-webkit-box-flex:0;-ms-flex:none;flex:none}.flex-column{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.flex-row{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.flex-wrap{-ms-flex-wrap:wrap;flex-wrap:wrap}.flex-nowrap{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.flex-wrap-reverse{-ms-flex-wrap:wrap-reverse;flex-wrap:wrap-reverse}.flex-column-reverse{-webkit-box-orient:vertical;-webkit-box-direction:reverse;-ms-flex-direction:column-reverse;flex-direction:column-reverse}.flex-row-reverse{-webkit-box-orient:horizontal;-webkit-box-direction:reverse;-ms-flex-direction:row-reverse;flex-direction:row-reverse}.items-start{-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start}.items-end{-webkit-box-align:end;-ms-flex-align:end;align-items:flex-end}.items-center{-webkit-box-align:center;-ms-flex-align:center;align-items:center}.items-baseline{-webkit-box-align:baseline;-ms-flex-align:baseline;align-items:baseline}.items-stretch{-webkit-box-align:stretch;-ms-flex-align:stretch;align-items:stretch}.self-start{-ms-flex-item-align:start;align-self:flex-start}.self-end{-ms-flex-item-align:end;align-self:flex-end}.self-center{-ms-flex-item-align:center;align-self:center}.self-baseline{-ms-flex-item-align:baseline;align-self:baseline}.self-stretch{-ms-flex-item-align:stretch;align-self:stretch}.justify-start{-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.justify-end{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end}.justify-center{-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.justify-between{-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between}.justify-around{-ms-flex-pack:distribute;justify-content:space-around}.content-start{-ms-flex-line-pack:start;align-content:flex-start}.content-end{-ms-flex-line-pack:end;align-content:flex-end}.content-center{-ms-flex-line-pack:center;align-content:center}.content-between{-ms-flex-line-pack:justify;align-content:space-between}.content-around{-ms-flex-line-pack:distribute;align-content:space-around}.content-stretch{-ms-flex-line-pack:stretch;align-content:stretch}.order-0{-webkit-box-ordinal-group:1;-ms-flex-order:0;order:0}.order-1{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}.order-2{-webkit-box-ordinal-group:3;-ms-flex-order:2;order:2}.order-3{-webkit-box-ordinal-group:4;-ms-flex-order:3;order:3}.order-4{-webkit-box-ordinal-group:5;-ms-flex-order:4;order:4}.order-5{-webkit-box-ordinal-group:6;-ms-flex-order:5;order:5}.order-6{-webkit-box-ordinal-group:7;-ms-flex-order:6;order:6}.order-7{-webkit-box-ordinal-group:8;-ms-flex-order:7;order:7}.order-8{-webkit-box-ordinal-group:9;-ms-flex-order:8;order:8}.order-last{-webkit-box-ordinal-group:100000;-ms-flex-order:99999;order:99999}.flex-grow-0{-webkit-box-flex:0;-ms-flex-positive:0;flex-grow:0}.flex-grow-1{-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1}.flex-shrink-0{-ms-flex-negative:0;flex-shrink:0}.flex-shrink-1{-ms-flex-negative:1;flex-shrink:1}@media screen and (min-width:30em){.flex-ns{display:-webkit-box;display:-ms-flexbox;display:flex}.inline-flex-ns{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex}.flex-auto-ns{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;min-width:0;min-height:0}.flex-none-ns{-webkit-box-flex:0;-ms-flex:none;flex:none}.flex-column-ns{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.flex-row-ns{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.flex-wrap-ns{-ms-flex-wrap:wrap;flex-wrap:wrap}.flex-nowrap-ns{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.flex-wrap-reverse-ns{-ms-flex-wrap:wrap-reverse;flex-wrap:wrap-reverse}.flex-column-reverse-ns{-webkit-box-orient:vertical;-webkit-box-direction:reverse;-ms-flex-direction:column-reverse;flex-direction:column-reverse}.flex-row-reverse-ns{-webkit-box-orient:horizontal;-webkit-box-direction:reverse;-ms-flex-direction:row-reverse;flex-direction:row-reverse}.items-start-ns{-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start}.items-end-ns{-webkit-box-align:end;-ms-flex-align:end;align-items:flex-end}.items-center-ns{-webkit-box-align:center;-ms-flex-align:center;align-items:center}.items-baseline-ns{-webkit-box-align:baseline;-ms-flex-align:baseline;align-items:baseline}.items-stretch-ns{-webkit-box-align:stretch;-ms-flex-align:stretch;align-items:stretch}.self-start-ns{-ms-flex-item-align:start;align-self:flex-start}.self-end-ns{-ms-flex-item-align:end;align-self:flex-end}.self-center-ns{-ms-flex-item-align:center;align-self:center}.self-baseline-ns{-ms-flex-item-align:baseline;align-self:baseline}.self-stretch-ns{-ms-flex-item-align:stretch;align-self:stretch}.justify-start-ns{-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.justify-end-ns{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end}.justify-center-ns{-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.justify-between-ns{-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between}.justify-around-ns{-ms-flex-pack:distribute;justify-content:space-around}.content-start-ns{-ms-flex-line-pack:start;align-content:flex-start}.content-end-ns{-ms-flex-line-pack:end;align-content:flex-end}.content-center-ns{-ms-flex-line-pack:center;align-content:center}.content-between-ns{-ms-flex-line-pack:justify;align-content:space-between}.content-around-ns{-ms-flex-line-pack:distribute;align-content:space-around}.content-stretch-ns{-ms-flex-line-pack:stretch;align-content:stretch}.order-0-ns{-webkit-box-ordinal-group:1;-ms-flex-order:0;order:0}.order-1-ns{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}.order-2-ns{-webkit-box-ordinal-group:3;-ms-flex-order:2;order:2}.order-3-ns{-webkit-box-ordinal-group:4;-ms-flex-order:3;order:3}.order-4-ns{-webkit-box-ordinal-group:5;-ms-flex-order:4;order:4}.order-5-ns{-webkit-box-ordinal-group:6;-ms-flex-order:5;order:5}.order-6-ns{-webkit-box-ordinal-group:7;-ms-flex-order:6;order:6}.order-7-ns{-webkit-box-ordinal-group:8;-ms-flex-order:7;order:7}.order-8-ns{-webkit-box-ordinal-group:9;-ms-flex-order:8;order:8}.order-last-ns{-webkit-box-ordinal-group:100000;-ms-flex-order:99999;order:99999}.flex-grow-0-ns{-webkit-box-flex:0;-ms-flex-positive:0;flex-grow:0}.flex-grow-1-ns{-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1}.flex-shrink-0-ns{-ms-flex-negative:0;flex-shrink:0}.flex-shrink-1-ns{-ms-flex-negative:1;flex-shrink:1}}@media screen and (min-width:30em) and (max-width:60em){.flex-m{display:-webkit-box;display:-ms-flexbox;display:flex}.inline-flex-m{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex}.flex-auto-m{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;min-width:0;min-height:0}.flex-none-m{-webkit-box-flex:0;-ms-flex:none;flex:none}.flex-column-m{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.flex-row-m{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.flex-wrap-m{-ms-flex-wrap:wrap;flex-wrap:wrap}.flex-nowrap-m{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.flex-wrap-reverse-m{-ms-flex-wrap:wrap-reverse;flex-wrap:wrap-reverse}.flex-column-reverse-m{-webkit-box-orient:vertical;-webkit-box-direction:reverse;-ms-flex-direction:column-reverse;flex-direction:column-reverse}.flex-row-reverse-m{-webkit-box-orient:horizontal;-webkit-box-direction:reverse;-ms-flex-direction:row-reverse;flex-direction:row-reverse}.items-start-m{-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start}.items-end-m{-webkit-box-align:end;-ms-flex-align:end;align-items:flex-end}.items-center-m{-webkit-box-align:center;-ms-flex-align:center;align-items:center}.items-baseline-m{-webkit-box-align:baseline;-ms-flex-align:baseline;align-items:baseline}.items-stretch-m{-webkit-box-align:stretch;-ms-flex-align:stretch;align-items:stretch}.self-start-m{-ms-flex-item-align:start;align-self:flex-start}.self-end-m{-ms-flex-item-align:end;align-self:flex-end}.self-center-m{-ms-flex-item-align:center;align-self:center}.self-baseline-m{-ms-flex-item-align:baseline;align-self:baseline}.self-stretch-m{-ms-flex-item-align:stretch;align-self:stretch}.justify-start-m{-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.justify-end-m{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end}.justify-center-m{-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.justify-between-m{-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between}.justify-around-m{-ms-flex-pack:distribute;justify-content:space-around}.content-start-m{-ms-flex-line-pack:start;align-content:flex-start}.content-end-m{-ms-flex-line-pack:end;align-content:flex-end}.content-center-m{-ms-flex-line-pack:center;align-content:center}.content-between-m{-ms-flex-line-pack:justify;align-content:space-between}.content-around-m{-ms-flex-line-pack:distribute;align-content:space-around}.content-stretch-m{-ms-flex-line-pack:stretch;align-content:stretch}.order-0-m{-webkit-box-ordinal-group:1;-ms-flex-order:0;order:0}.order-1-m{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}.order-2-m{-webkit-box-ordinal-group:3;-ms-flex-order:2;order:2}.order-3-m{-webkit-box-ordinal-group:4;-ms-flex-order:3;order:3}.order-4-m{-webkit-box-ordinal-group:5;-ms-flex-order:4;order:4}.order-5-m{-webkit-box-ordinal-group:6;-ms-flex-order:5;order:5}.order-6-m{-webkit-box-ordinal-group:7;-ms-flex-order:6;order:6}.order-7-m{-webkit-box-ordinal-group:8;-ms-flex-order:7;order:7}.order-8-m{-webkit-box-ordinal-group:9;-ms-flex-order:8;order:8}.order-last-m{-webkit-box-ordinal-group:100000;-ms-flex-order:99999;order:99999}.flex-grow-0-m{-webkit-box-flex:0;-ms-flex-positive:0;flex-grow:0}.flex-grow-1-m{-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1}.flex-shrink-0-m{-ms-flex-negative:0;flex-shrink:0}.flex-shrink-1-m{-ms-flex-negative:1;flex-shrink:1}}@media screen and (min-width:60em){.flex-l{display:-webkit-box;display:-ms-flexbox;display:flex}.inline-flex-l{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex}.flex-auto-l{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;min-width:0;min-height:0}.flex-none-l{-webkit-box-flex:0;-ms-flex:none;flex:none}.flex-column-l{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.flex-row-l{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.flex-wrap-l{-ms-flex-wrap:wrap;flex-wrap:wrap}.flex-nowrap-l{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.flex-wrap-reverse-l{-ms-flex-wrap:wrap-reverse;flex-wrap:wrap-reverse}.flex-column-reverse-l{-webkit-box-orient:vertical;-webkit-box-direction:reverse;-ms-flex-direction:column-reverse;flex-direction:column-reverse}.flex-row-reverse-l{-webkit-box-orient:horizontal;-webkit-box-direction:reverse;-ms-flex-direction:row-reverse;flex-direction:row-reverse}.items-start-l{-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start}.items-end-l{-webkit-box-align:end;-ms-flex-align:end;align-items:flex-end}.items-center-l{-webkit-box-align:center;-ms-flex-align:center;align-items:center}.items-baseline-l{-webkit-box-align:baseline;-ms-flex-align:baseline;align-items:baseline}.items-stretch-l{-webkit-box-align:stretch;-ms-flex-align:stretch;align-items:stretch}.self-start-l{-ms-flex-item-align:start;align-self:flex-start}.self-end-l{-ms-flex-item-align:end;align-self:flex-end}.self-center-l{-ms-flex-item-align:center;align-self:center}.self-baseline-l{-ms-flex-item-align:baseline;align-self:baseline}.self-stretch-l{-ms-flex-item-align:stretch;align-self:stretch}.justify-start-l{-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.justify-end-l{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end}.justify-center-l{-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.justify-between-l{-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between}.justify-around-l{-ms-flex-pack:distribute;justify-content:space-around}.content-start-l{-ms-flex-line-pack:start;align-content:flex-start}.content-end-l{-ms-flex-line-pack:end;align-content:flex-end}.content-center-l{-ms-flex-line-pack:center;align-content:center}.content-between-l{-ms-flex-line-pack:justify;align-content:space-between}.content-around-l{-ms-flex-line-pack:distribute;align-content:space-around}.content-stretch-l{-ms-flex-line-pack:stretch;align-content:stretch}.order-0-l{-webkit-box-ordinal-group:1;-ms-flex-order:0;order:0}.order-1-l{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}.order-2-l{-webkit-box-ordinal-group:3;-ms-flex-order:2;order:2}.order-3-l{-webkit-box-ordinal-group:4;-ms-flex-order:3;order:3}.order-4-l{-webkit-box-ordinal-group:5;-ms-flex-order:4;order:4}.order-5-l{-webkit-box-ordinal-group:6;-ms-flex-order:5;order:5}.order-6-l{-webkit-box-ordinal-group:7;-ms-flex-order:6;order:6}.order-7-l{-webkit-box-ordinal-group:8;-ms-flex-order:7;order:7}.order-8-l{-webkit-box-ordinal-group:9;-ms-flex-order:8;order:8}.order-last-l{-webkit-box-ordinal-group:100000;-ms-flex-order:99999;order:99999}.flex-grow-0-l{-webkit-box-flex:0;-ms-flex-positive:0;flex-grow:0}.flex-grow-1-l{-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1}.flex-shrink-0-l{-ms-flex-negative:0;flex-shrink:0}.flex-shrink-1-l{-ms-flex-negative:1;flex-shrink:1}}.fl{float:left;_display:inline}.fr{float:right;_display:inline}.fn{float:none}@media screen and (min-width:30em){.fl-ns{float:left;_display:inline}.fr-ns{float:right;_display:inline}.fn-ns{float:none}}@media screen and (min-width:30em) and (max-width:60em){.fl-m{float:left;_display:inline}.fr-m{float:right;_display:inline}.fn-m{float:none}}@media screen and (min-width:60em){.fl-l{float:left;_display:inline}.fr-l{float:right;_display:inline}.fn-l{float:none}}.i{font-style:italic}.fs-normal{font-style:normal}@media screen and (min-width:30em){.i-ns{font-style:italic}.fs-normal-ns{font-style:normal}}@media screen and (min-width:30em) and (max-width:60em){.i-m{font-style:italic}.fs-normal-m{font-style:normal}}@media screen and (min-width:60em){.i-l{font-style:italic}.fs-normal-l{font-style:normal}}.normal{font-weight:400}.b{font-weight:700}.fw1{font-weight:100}.fw2{font-weight:200}.fw3{font-weight:300}.fw4{font-weight:400}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fw9{font-weight:900}@media screen and (min-width:30em){.normal-ns{font-weight:400}.b-ns{font-weight:700}.fw1-ns{font-weight:100}.fw2-ns{font-weight:200}.fw3-ns{font-weight:300}.fw4-ns{font-weight:400}.fw5-ns{font-weight:500}.fw6-ns{font-weight:600}.fw7-ns{font-weight:700}.fw8-ns{font-weight:800}.fw9-ns{font-weight:900}}@media screen and (min-width:30em) and (max-width:60em){.normal-m{font-weight:400}.b-m{font-weight:700}.fw1-m{font-weight:100}.fw2-m{font-weight:200}.fw3-m{font-weight:300}.fw4-m{font-weight:400}.fw5-m{font-weight:500}.fw6-m{font-weight:600}.fw7-m{font-weight:700}.fw8-m{font-weight:800}.fw9-m{font-weight:900}}@media screen and (min-width:60em){.normal-l{font-weight:400}.b-l{font-weight:700}.fw1-l{font-weight:100}.fw2-l{font-weight:200}.fw3-l{font-weight:300}.fw4-l{font-weight:400}.fw5-l{font-weight:500}.fw6-l{font-weight:600}.fw7-l{font-weight:700}.fw8-l{font-weight:800}.fw9-l{font-weight:900}}.input-reset{-webkit-appearance:none;-moz-appearance:none}.button-reset::-moz-focus-inner,.input-reset::-moz-focus-inner{border:0;padding:0}.h1{height:1rem}.h2{height:2rem}.h3{height:4rem}.h4{height:8rem}.h5{height:16rem}.h-25{height:25%}.h-50{height:50%}.h-75{height:75%}.h-100{height:100%}.min-h-100{min-height:100%}.vh-25{height:25vh}.vh-50{height:50vh}.vh-75{height:75vh}.vh-100{height:100vh}.min-vh-100{min-height:100vh}.h-auto{height:auto}.h-inherit{height:inherit}@media screen and (min-width:30em){.h1-ns{height:1rem}.h2-ns{height:2rem}.h3-ns{height:4rem}.h4-ns{height:8rem}.h5-ns{height:16rem}.h-25-ns{height:25%}.h-50-ns{height:50%}.h-75-ns{height:75%}.h-100-ns{height:100%}.min-h-100-ns{min-height:100%}.vh-25-ns{height:25vh}.vh-50-ns{height:50vh}.vh-75-ns{height:75vh}.vh-100-ns{height:100vh}.min-vh-100-ns{min-height:100vh}.h-auto-ns{height:auto}.h-inherit-ns{height:inherit}}@media screen and (min-width:30em) and (max-width:60em){.h1-m{height:1rem}.h2-m{height:2rem}.h3-m{height:4rem}.h4-m{height:8rem}.h5-m{height:16rem}.h-25-m{height:25%}.h-50-m{height:50%}.h-75-m{height:75%}.h-100-m{height:100%}.min-h-100-m{min-height:100%}.vh-25-m{height:25vh}.vh-50-m{height:50vh}.vh-75-m{height:75vh}.vh-100-m{height:100vh}.min-vh-100-m{min-height:100vh}.h-auto-m{height:auto}.h-inherit-m{height:inherit}}@media screen and (min-width:60em){.h1-l{height:1rem}.h2-l{height:2rem}.h3-l{height:4rem}.h4-l{height:8rem}.h5-l{height:16rem}.h-25-l{height:25%}.h-50-l{height:50%}.h-75-l{height:75%}.h-100-l{height:100%}.min-h-100-l{min-height:100%}.vh-25-l{height:25vh}.vh-50-l{height:50vh}.vh-75-l{height:75vh}.vh-100-l{height:100vh}.min-vh-100-l{min-height:100vh}.h-auto-l{height:auto}.h-inherit-l{height:inherit}}.tracked{letter-spacing:.1em}.tracked-tight{letter-spacing:-.05em}.tracked-mega{letter-spacing:.25em}@media screen and (min-width:30em){.tracked-ns{letter-spacing:.1em}.tracked-tight-ns{letter-spacing:-.05em}.tracked-mega-ns{letter-spacing:.25em}}@media screen and (min-width:30em) and (max-width:60em){.tracked-m{letter-spacing:.1em}.tracked-tight-m{letter-spacing:-.05em}.tracked-mega-m{letter-spacing:.25em}}@media screen and (min-width:60em){.tracked-l{letter-spacing:.1em}.tracked-tight-l{letter-spacing:-.05em}.tracked-mega-l{letter-spacing:.25em}}.lh-solid{line-height:1}.lh-title{line-height:1.25}.lh-copy{line-height:1.5}@media screen and (min-width:30em){.lh-solid-ns{line-height:1}.lh-title-ns{line-height:1.25}.lh-copy-ns{line-height:1.5}}@media screen and (min-width:30em) and (max-width:60em){.lh-solid-m{line-height:1}.lh-title-m{line-height:1.25}.lh-copy-m{line-height:1.5}}@media screen and (min-width:60em){.lh-solid-l{line-height:1}.lh-title-l{line-height:1.25}.lh-copy-l{line-height:1.5}}.link{text-decoration:none;-webkit-transition:color .15s ease-in;transition:color .15s ease-in}.link:link,.link:visited{-webkit-transition:color .15s ease-in;transition:color .15s ease-in}.link:hover{-webkit-transition:color .15s ease-in;transition:color .15s ease-in}.link:active{-webkit-transition:color .15s ease-in;transition:color .15s ease-in}.link:focus{-webkit-transition:color .15s ease-in;transition:color .15s ease-in;outline:1px dotted currentColor}.list{list-style-type:none}.mw-100{max-width:100%}.mw1{max-width:1rem}.mw2{max-width:2rem}.mw3{max-width:4rem}.mw4{max-width:8rem}.mw5{max-width:16rem}.mw6{max-width:32rem}.mw7{max-width:48rem}.mw8{max-width:64rem}.mw9{max-width:96rem}.mw-none{max-width:none}@media screen and (min-width:30em){.mw-100-ns{max-width:100%}.mw1-ns{max-width:1rem}.mw2-ns{max-width:2rem}.mw3-ns{max-width:4rem}.mw4-ns{max-width:8rem}.mw5-ns{max-width:16rem}.mw6-ns{max-width:32rem}.mw7-ns{max-width:48rem}.mw8-ns{max-width:64rem}.mw9-ns{max-width:96rem}.mw-none-ns{max-width:none}}@media screen and (min-width:30em) and (max-width:60em){.mw-100-m{max-width:100%}.mw1-m{max-width:1rem}.mw2-m{max-width:2rem}.mw3-m{max-width:4rem}.mw4-m{max-width:8rem}.mw5-m{max-width:16rem}.mw6-m{max-width:32rem}.mw7-m{max-width:48rem}.mw8-m{max-width:64rem}.mw9-m{max-width:96rem}.mw-none-m{max-width:none}}@media screen and (min-width:60em){.mw-100-l{max-width:100%}.mw1-l{max-width:1rem}.mw2-l{max-width:2rem}.mw3-l{max-width:4rem}.mw4-l{max-width:8rem}.mw5-l{max-width:16rem}.mw6-l{max-width:32rem}.mw7-l{max-width:48rem}.mw8-l{max-width:64rem}.mw9-l{max-width:96rem}.mw-none-l{max-width:none}}.w1{width:1rem}.w2{width:2rem}.w3{width:4rem}.w4{width:8rem}.w5{width:16rem}.w-10{width:10%}.w-20{width:20%}.w-25{width:25%}.w-30{width:30%}.w-33{width:33%}.w-34{width:34%}.w-40{width:40%}.w-50{width:50%}.w-60{width:60%}.w-70{width:70%}.w-75{width:75%}.w-80{width:80%}.w-90{width:90%}.w-100{width:100%}.w-third{width:33.33333%}.w-two-thirds{width:66.66667%}.w-auto{width:auto}@media screen and (min-width:30em){.w1-ns{width:1rem}.w2-ns{width:2rem}.w3-ns{width:4rem}.w4-ns{width:8rem}.w5-ns{width:16rem}.w-10-ns{width:10%}.w-20-ns{width:20%}.w-25-ns{width:25%}.w-30-ns{width:30%}.w-33-ns{width:33%}.w-34-ns{width:34%}.w-40-ns{width:40%}.w-50-ns{width:50%}.w-60-ns{width:60%}.w-70-ns{width:70%}.w-75-ns{width:75%}.w-80-ns{width:80%}.w-90-ns{width:90%}.w-100-ns{width:100%}.w-third-ns{width:33.33333%}.w-two-thirds-ns{width:66.66667%}.w-auto-ns{width:auto}}@media screen and (min-width:30em) and (max-width:60em){.w1-m{width:1rem}.w2-m{width:2rem}.w3-m{width:4rem}.w4-m{width:8rem}.w5-m{width:16rem}.w-10-m{width:10%}.w-20-m{width:20%}.w-25-m{width:25%}.w-30-m{width:30%}.w-33-m{width:33%}.w-34-m{width:34%}.w-40-m{width:40%}.w-50-m{width:50%}.w-60-m{width:60%}.w-70-m{width:70%}.w-75-m{width:75%}.w-80-m{width:80%}.w-90-m{width:90%}.w-100-m{width:100%}.w-third-m{width:33.33333%}.w-two-thirds-m{width:66.66667%}.w-auto-m{width:auto}}@media screen and (min-width:60em){.w1-l{width:1rem}.w2-l{width:2rem}.w3-l{width:4rem}.w4-l{width:8rem}.w5-l{width:16rem}.w-10-l{width:10%}.w-20-l{width:20%}.w-25-l{width:25%}.w-30-l{width:30%}.w-33-l{width:33%}.w-34-l{width:34%}.w-40-l{width:40%}.w-50-l{width:50%}.w-60-l{width:60%}.w-70-l{width:70%}.w-75-l{width:75%}.w-80-l{width:80%}.w-90-l{width:90%}.w-100-l{width:100%}.w-third-l{width:33.33333%}.w-two-thirds-l{width:66.66667%}.w-auto-l{width:auto}}.overflow-visible{overflow:visible}.overflow-hidden{overflow:hidden}.overflow-scroll{overflow:scroll}.overflow-auto{overflow:auto}.overflow-x-visible{overflow-x:visible}.overflow-x-hidden{overflow-x:hidden}.overflow-x-scroll{overflow-x:scroll}.overflow-x-auto{overflow-x:auto}.overflow-y-visible{overflow-y:visible}.overflow-y-hidden{overflow-y:hidden}.overflow-y-scroll{overflow-y:scroll}.overflow-y-auto{overflow-y:auto}@media screen and (min-width:30em){.overflow-visible-ns{overflow:visible}.overflow-hidden-ns{overflow:hidden}.overflow-scroll-ns{overflow:scroll}.overflow-auto-ns{overflow:auto}.overflow-x-visible-ns{overflow-x:visible}.overflow-x-hidden-ns{overflow-x:hidden}.overflow-x-scroll-ns{overflow-x:scroll}.overflow-x-auto-ns{overflow-x:auto}.overflow-y-visible-ns{overflow-y:visible}.overflow-y-hidden-ns{overflow-y:hidden}.overflow-y-scroll-ns{overflow-y:scroll}.overflow-y-auto-ns{overflow-y:auto}}@media screen and (min-width:30em) and (max-width:60em){.overflow-visible-m{overflow:visible}.overflow-hidden-m{overflow:hidden}.overflow-scroll-m{overflow:scroll}.overflow-auto-m{overflow:auto}.overflow-x-visible-m{overflow-x:visible}.overflow-x-hidden-m{overflow-x:hidden}.overflow-x-scroll-m{overflow-x:scroll}.overflow-x-auto-m{overflow-x:auto}.overflow-y-visible-m{overflow-y:visible}.overflow-y-hidden-m{overflow-y:hidden}.overflow-y-scroll-m{overflow-y:scroll}.overflow-y-auto-m{overflow-y:auto}}@media screen and (min-width:60em){.overflow-visible-l{overflow:visible}.overflow-hidden-l{overflow:hidden}.overflow-scroll-l{overflow:scroll}.overflow-auto-l{overflow:auto}.overflow-x-visible-l{overflow-x:visible}.overflow-x-hidden-l{overflow-x:hidden}.overflow-x-scroll-l{overflow-x:scroll}.overflow-x-auto-l{overflow-x:auto}.overflow-y-visible-l{overflow-y:visible}.overflow-y-hidden-l{overflow-y:hidden}.overflow-y-scroll-l{overflow-y:scroll}.overflow-y-auto-l{overflow-y:auto}}.static{position:static}.relative{position:relative}.absolute{position:absolute}.fixed{position:fixed}@media screen and (min-width:30em){.static-ns{position:static}.relative-ns{position:relative}.absolute-ns{position:absolute}.fixed-ns{position:fixed}}@media screen and (min-width:30em) and (max-width:60em){.static-m{position:static}.relative-m{position:relative}.absolute-m{position:absolute}.fixed-m{position:fixed}}@media screen and (min-width:60em){.static-l{position:static}.relative-l{position:relative}.absolute-l{position:absolute}.fixed-l{position:fixed}}.o-100{opacity:1}.o-90{opacity:.9}.o-80{opacity:.8}.o-70{opacity:.7}.o-60{opacity:.6}.o-50{opacity:.5}.o-40{opacity:.4}.o-30{opacity:.3}.o-20{opacity:.2}.o-10{opacity:.1}.o-05{opacity:.05}.o-025{opacity:.025}.o-0{opacity:0}.black-90{color:rgba(0,0,0,.9)}.black-80{color:rgba(0,0,0,.8)}.black-70{color:rgba(0,0,0,.7)}.black-60{color:rgba(0,0,0,.6)}.black-50{color:rgba(0,0,0,.5)}.black-40{color:rgba(0,0,0,.4)}.black-30{color:rgba(0,0,0,.3)}.black-20{color:rgba(0,0,0,.2)}.black-10{color:rgba(0,0,0,.1)}.black-05{color:rgba(0,0,0,.05)}.white-90{color:rgba(255,255,255,.9)}.white-80{color:rgba(255,255,255,.8)}.white-70{color:rgba(255,255,255,.7)}.white-60{color:rgba(255,255,255,.6)}.white-50{color:rgba(255,255,255,.5)}.white-40{color:rgba(255,255,255,.4)}.white-30{color:rgba(255,255,255,.3)}.white-20{color:rgba(255,255,255,.2)}.white-10{color:rgba(255,255,255,.1)}.black{color:#000}.near-black{color:#111}.dark-gray{color:#333}.mid-gray{color:#555}.gray{color:#777}.silver{color:#999}.light-silver{color:#aaa}.moon-gray{color:#ccc}.light-gray{color:#eee}.near-white{color:#f4f4f4}.white{color:#fff}.dark-red{color:#e7040f}.red{color:#ff4136}.light-red{color:#ff725c}.orange{color:#ff6300}.gold{color:#ffb700}.yellow{color:gold}.light-yellow{color:#fbf1a9}.purple{color:#5e2ca5}.light-purple{color:#a463f2}.dark-pink{color:#d5008f}.hot-pink{color:#ff41b4}.pink{color:#ff80cc}.light-pink{color:#ffa3d7}.dark-green{color:#137752}.green{color:#19a974}.light-green{color:#9eebcf}.navy{color:#001b44}.dark-blue{color:#00449e}.blue{color:#0594cb}.light-blue{color:#96ccff}.lightest-blue{color:#cdecff}.washed-blue{color:#f6fffe}.washed-green{color:#e8fdf5}.washed-yellow{color:#fffceb}.washed-red{color:#ffdfdf}.color-inherit{color:inherit}.bg-black-90{background-color:rgba(0,0,0,.9)}.bg-black-80{background-color:rgba(0,0,0,.8)}.bg-black-70{background-color:rgba(0,0,0,.7)}.bg-black-60{background-color:rgba(0,0,0,.6)}.bg-black-50{background-color:rgba(0,0,0,.5)}.bg-black-40{background-color:rgba(0,0,0,.4)}.bg-black-30{background-color:rgba(0,0,0,.3)}.bg-black-20{background-color:rgba(0,0,0,.2)}.bg-black-10{background-color:rgba(0,0,0,.1)}.bg-black-05{background-color:rgba(0,0,0,.05)}.bg-white-90{background-color:rgba(255,255,255,.9)}.bg-white-80{background-color:rgba(255,255,255,.8)}.bg-white-70{background-color:rgba(255,255,255,.7)}.bg-white-60{background-color:rgba(255,255,255,.6)}.bg-white-50{background-color:rgba(255,255,255,.5)}.bg-white-40{background-color:rgba(255,255,255,.4)}.bg-white-30{background-color:rgba(255,255,255,.3)}.bg-white-20{background-color:rgba(255,255,255,.2)}.bg-white-10{background-color:rgba(255,255,255,.1)}.bg-black{background-color:#000}.bg-near-black{background-color:#111}.bg-dark-gray{background-color:#333}.bg-mid-gray{background-color:#555}.bg-gray{background-color:#777}.bg-silver{background-color:#999}.bg-light-silver{background-color:#aaa}.bg-moon-gray{background-color:#ccc}.bg-light-gray{background-color:#eee}.bg-near-white{background-color:#f4f4f4}.bg-white{background-color:#fff}.bg-transparent{background-color:transparent}.bg-dark-red{background-color:#e7040f}.bg-red{background-color:#ff4136}.bg-light-red{background-color:#ff725c}.bg-orange{background-color:#ff6300}.bg-gold{background-color:#ffb700}.bg-yellow{background-color:gold}.bg-light-yellow{background-color:#fbf1a9}.bg-purple{background-color:#5e2ca5}.bg-light-purple{background-color:#a463f2}.bg-dark-pink{background-color:#d5008f}.bg-hot-pink{background-color:#ff41b4}.bg-pink{background-color:#ff80cc}.bg-light-pink{background-color:#ffa3d7}.bg-dark-green{background-color:#137752}.bg-green{background-color:#19a974}.bg-light-green{background-color:#9eebcf}.bg-navy{background-color:#001b44}.bg-dark-blue{background-color:#00449e}.bg-blue{background-color:#0594cb}.bg-light-blue{background-color:#96ccff}.bg-lightest-blue{background-color:#cdecff}.bg-washed-blue{background-color:#f6fffe}.bg-washed-green{background-color:#e8fdf5}.bg-washed-yellow{background-color:#fffceb}.bg-washed-red{background-color:#ffdfdf}.bg-inherit{background-color:inherit}.hover-black:hover,.hover-black:focus{color:#000}.hover-near-black:hover,.hover-near-black:focus{color:#111}.hover-dark-gray:hover,.hover-dark-gray:focus{color:#333}.hover-mid-gray:hover,.hover-mid-gray:focus{color:#555}.hover-gray:hover,.hover-gray:focus{color:#777}.hover-silver:hover,.hover-silver:focus{color:#999}.hover-light-silver:hover,.hover-light-silver:focus{color:#aaa}.hover-moon-gray:hover,.hover-moon-gray:focus{color:#ccc}.hover-light-gray:hover,.hover-light-gray:focus{color:#eee}.hover-near-white:hover,.hover-near-white:focus{color:#f4f4f4}.hover-white:hover,.hover-white:focus{color:#fff}.hover-black-90:hover,.hover-black-90:focus{color:rgba(0,0,0,.9)}.hover-black-80:hover,.hover-black-80:focus{color:rgba(0,0,0,.8)}.hover-black-70:hover,.hover-black-70:focus{color:rgba(0,0,0,.7)}.hover-black-60:hover,.hover-black-60:focus{color:rgba(0,0,0,.6)}.hover-black-50:hover,.hover-black-50:focus{color:rgba(0,0,0,.5)}.hover-black-40:hover,.hover-black-40:focus{color:rgba(0,0,0,.4)}.hover-black-30:hover,.hover-black-30:focus{color:rgba(0,0,0,.3)}.hover-black-20:hover,.hover-black-20:focus{color:rgba(0,0,0,.2)}.hover-black-10:hover,.hover-black-10:focus{color:rgba(0,0,0,.1)}.hover-white-90:hover,.hover-white-90:focus{color:rgba(255,255,255,.9)}.hover-white-80:hover,.hover-white-80:focus{color:rgba(255,255,255,.8)}.hover-white-70:hover,.hover-white-70:focus{color:rgba(255,255,255,.7)}.hover-white-60:hover,.hover-white-60:focus{color:rgba(255,255,255,.6)}.hover-white-50:hover,.hover-white-50:focus{color:rgba(255,255,255,.5)}.hover-white-40:hover,.hover-white-40:focus{color:rgba(255,255,255,.4)}.hover-white-30:hover,.hover-white-30:focus{color:rgba(255,255,255,.3)}.hover-white-20:hover,.hover-white-20:focus{color:rgba(255,255,255,.2)}.hover-white-10:hover,.hover-white-10:focus{color:rgba(255,255,255,.1)}.hover-inherit:hover,.hover-inherit:focus{color:inherit}.hover-bg-black:hover,.hover-bg-black:focus{background-color:#000}.hover-bg-near-black:hover,.hover-bg-near-black:focus{background-color:#111}.hover-bg-dark-gray:hover,.hover-bg-dark-gray:focus{background-color:#333}.hover-bg-mid-gray:hover,.hover-bg-mid-gray:focus{background-color:#555}.hover-bg-gray:hover,.hover-bg-gray:focus{background-color:#777}.hover-bg-silver:hover,.hover-bg-silver:focus{background-color:#999}.hover-bg-light-silver:hover,.hover-bg-light-silver:focus{background-color:#aaa}.hover-bg-moon-gray:hover,.hover-bg-moon-gray:focus{background-color:#ccc}.hover-bg-light-gray:hover,.hover-bg-light-gray:focus{background-color:#eee}.hover-bg-near-white:hover,.hover-bg-near-white:focus{background-color:#f4f4f4}.hover-bg-white:hover,.hover-bg-white:focus{background-color:#fff}.hover-bg-transparent:hover,.hover-bg-transparent:focus{background-color:transparent}.hover-bg-black-90:hover,.hover-bg-black-90:focus{background-color:rgba(0,0,0,.9)}.hover-bg-black-80:hover,.hover-bg-black-80:focus{background-color:rgba(0,0,0,.8)}.hover-bg-black-70:hover,.hover-bg-black-70:focus{background-color:rgba(0,0,0,.7)}.hover-bg-black-60:hover,.hover-bg-black-60:focus{background-color:rgba(0,0,0,.6)}.hover-bg-black-50:hover,.hover-bg-black-50:focus{background-color:rgba(0,0,0,.5)}.hover-bg-black-40:hover,.hover-bg-black-40:focus{background-color:rgba(0,0,0,.4)}.hover-bg-black-30:hover,.hover-bg-black-30:focus{background-color:rgba(0,0,0,.3)}.hover-bg-black-20:hover,.hover-bg-black-20:focus{background-color:rgba(0,0,0,.2)}.hover-bg-black-10:hover,.hover-bg-black-10:focus{background-color:rgba(0,0,0,.1)}.hover-bg-white-90:hover,.hover-bg-white-90:focus{background-color:rgba(255,255,255,.9)}.hover-bg-white-80:hover,.hover-bg-white-80:focus{background-color:rgba(255,255,255,.8)}.hover-bg-white-70:hover,.hover-bg-white-70:focus{background-color:rgba(255,255,255,.7)}.hover-bg-white-60:hover,.hover-bg-white-60:focus{background-color:rgba(255,255,255,.6)}.hover-bg-white-50:hover,.hover-bg-white-50:focus{background-color:rgba(255,255,255,.5)}.hover-bg-white-40:hover,.hover-bg-white-40:focus{background-color:rgba(255,255,255,.4)}.hover-bg-white-30:hover,.hover-bg-white-30:focus{background-color:rgba(255,255,255,.3)}.hover-bg-white-20:hover,.hover-bg-white-20:focus{background-color:rgba(255,255,255,.2)}.hover-bg-white-10:hover,.hover-bg-white-10:focus{background-color:rgba(255,255,255,.1)}.hover-dark-red:hover,.hover-dark-red:focus{color:#e7040f}.hover-red:hover,.hover-red:focus{color:#ff4136}.hover-light-red:hover,.hover-light-red:focus{color:#ff725c}.hover-orange:hover,.hover-orange:focus{color:#ff6300}.hover-gold:hover,.hover-gold:focus{color:#ffb700}.hover-yellow:hover,.hover-yellow:focus{color:gold}.hover-light-yellow:hover,.hover-light-yellow:focus{color:#fbf1a9}.hover-purple:hover,.hover-purple:focus{color:#5e2ca5}.hover-light-purple:hover,.hover-light-purple:focus{color:#a463f2}.hover-dark-pink:hover,.hover-dark-pink:focus{color:#d5008f}.hover-hot-pink:hover,.hover-hot-pink:focus{color:#ff41b4}.hover-pink:hover,.hover-pink:focus{color:#ff80cc}.hover-light-pink:hover,.hover-light-pink:focus{color:#ffa3d7}.hover-dark-green:hover,.hover-dark-green:focus{color:#137752}.hover-green:hover,.hover-green:focus{color:#19a974}.hover-light-green:hover,.hover-light-green:focus{color:#9eebcf}.hover-navy:hover,.hover-navy:focus{color:#001b44}.hover-dark-blue:hover,.hover-dark-blue:focus{color:#00449e}.hover-blue:hover,.hover-blue:focus{color:#0594cb}.hover-light-blue:hover,.hover-light-blue:focus{color:#96ccff}.hover-lightest-blue:hover,.hover-lightest-blue:focus{color:#cdecff}.hover-washed-blue:hover,.hover-washed-blue:focus{color:#f6fffe}.hover-washed-green:hover,.hover-washed-green:focus{color:#e8fdf5}.hover-washed-yellow:hover,.hover-washed-yellow:focus{color:#fffceb}.hover-washed-red:hover,.hover-washed-red:focus{color:#ffdfdf}.hover-bg-dark-red:hover,.hover-bg-dark-red:focus{background-color:#e7040f}.hover-bg-red:hover,.hover-bg-red:focus{background-color:#ff4136}.hover-bg-light-red:hover,.hover-bg-light-red:focus{background-color:#ff725c}.hover-bg-orange:hover,.hover-bg-orange:focus{background-color:#ff6300}.hover-bg-gold:hover,.hover-bg-gold:focus{background-color:#ffb700}.hover-bg-yellow:hover,.hover-bg-yellow:focus{background-color:gold}.hover-bg-light-yellow:hover,.hover-bg-light-yellow:focus{background-color:#fbf1a9}.hover-bg-purple:hover,.hover-bg-purple:focus{background-color:#5e2ca5}.hover-bg-light-purple:hover,.hover-bg-light-purple:focus{background-color:#a463f2}.hover-bg-dark-pink:hover,.hover-bg-dark-pink:focus{background-color:#d5008f}.hover-bg-hot-pink:hover,.hover-bg-hot-pink:focus{background-color:#ff41b4}.hover-bg-pink:hover,.hover-bg-pink:focus{background-color:#ff80cc}.hover-bg-light-pink:hover,.hover-bg-light-pink:focus{background-color:#ffa3d7}.hover-bg-dark-green:hover,.hover-bg-dark-green:focus{background-color:#137752}.hover-bg-green:hover,.hover-bg-green:focus{background-color:#19a974}.hover-bg-light-green:hover,.hover-bg-light-green:focus{background-color:#9eebcf}.hover-bg-navy:hover,.hover-bg-navy:focus{background-color:#001b44}.hover-bg-dark-blue:hover,.hover-bg-dark-blue:focus{background-color:#00449e}.hover-bg-blue:hover,.hover-bg-blue:focus{background-color:#0594cb}.hover-bg-light-blue:hover,.hover-bg-light-blue:focus{background-color:#96ccff}.hover-bg-lightest-blue:hover,.hover-bg-lightest-blue:focus{background-color:#cdecff}.hover-bg-washed-blue:hover,.hover-bg-washed-blue:focus{background-color:#f6fffe}.hover-bg-washed-green:hover,.hover-bg-washed-green:focus{background-color:#e8fdf5}.hover-bg-washed-yellow:hover,.hover-bg-washed-yellow:focus{background-color:#fffceb}.hover-bg-washed-red:hover,.hover-bg-washed-red:focus{background-color:#ffdfdf}.hover-bg-inherit:hover,.hover-bg-inherit:focus{background-color:inherit}.pa0{padding:0}.pa1{padding:.25rem}.pa2{padding:.5rem}.pa3{padding:1rem}.pa4{padding:2rem}.pa5{padding:4rem}.pa6{padding:8rem}.pa7{padding:16rem}.pl0{padding-left:0}.pl1{padding-left:.25rem}.pl2{padding-left:.5rem}.pl3{padding-left:1rem}.pl4{padding-left:2rem}.pl5{padding-left:4rem}.pl6{padding-left:8rem}.pl7{padding-left:16rem}.pr0{padding-right:0}.pr1{padding-right:.25rem}.pr2{padding-right:.5rem}.pr3{padding-right:1rem}.pr4{padding-right:2rem}.pr5{padding-right:4rem}.pr6{padding-right:8rem}.pr7{padding-right:16rem}.pb0{padding-bottom:0}.pb1{padding-bottom:.25rem}.pb2{padding-bottom:.5rem}.pb3{padding-bottom:1rem}.pb4{padding-bottom:2rem}.pb5{padding-bottom:4rem}.pb6{padding-bottom:8rem}.pb7{padding-bottom:16rem}.pt0{padding-top:0}.pt1{padding-top:.25rem}.pt2{padding-top:.5rem}.pt3{padding-top:1rem}.pt4{padding-top:2rem}.pt5{padding-top:4rem}.pt6{padding-top:8rem}.pt7{padding-top:16rem}.pv0{padding-top:0;padding-bottom:0}.pv1{padding-top:.25rem;padding-bottom:.25rem}.pv2{padding-top:.5rem;padding-bottom:.5rem}.pv3{padding-top:1rem;padding-bottom:1rem}.pv4{padding-top:2rem;padding-bottom:2rem}.pv5{padding-top:4rem;padding-bottom:4rem}.pv6{padding-top:8rem;padding-bottom:8rem}.pv7{padding-top:16rem;padding-bottom:16rem}.ph0{padding-left:0;padding-right:0}.ph1{padding-left:.25rem;padding-right:.25rem}.ph2{padding-left:.5rem;padding-right:.5rem}.ph3{padding-left:1rem;padding-right:1rem}.ph4{padding-left:2rem;padding-right:2rem}.ph5{padding-left:4rem;padding-right:4rem}.ph6{padding-left:8rem;padding-right:8rem}.ph7{padding-left:16rem;padding-right:16rem}.ma0{margin:0}.ma1{margin:.25rem}.ma2{margin:.5rem}.ma3{margin:1rem}.ma4{margin:2rem}.ma5{margin:4rem}.ma6{margin:8rem}.ma7{margin:16rem}.ml0{margin-left:0}.ml1{margin-left:.25rem}.ml2{margin-left:.5rem}.ml3{margin-left:1rem}.ml4{margin-left:2rem}.ml5{margin-left:4rem}.ml6{margin-left:8rem}.ml7{margin-left:16rem}.mr0{margin-right:0}.mr1{margin-right:.25rem}.mr2{margin-right:.5rem}.mr3{margin-right:1rem}.mr4{margin-right:2rem}.mr5{margin-right:4rem}.mr6{margin-right:8rem}.mr7{margin-right:16rem}.mb0{margin-bottom:0}.mb1{margin-bottom:.25rem}.mb2{margin-bottom:.5rem}.mb3{margin-bottom:1rem}.mb4{margin-bottom:2rem}.mb5{margin-bottom:4rem}.mb6{margin-bottom:8rem}.mb7{margin-bottom:16rem}.mt0{margin-top:0}.mt1{margin-top:.25rem}.mt2{margin-top:.5rem}.mt3{margin-top:1rem}.mt4{margin-top:2rem}.mt5{margin-top:4rem}.mt6{margin-top:8rem}.mt7{margin-top:16rem}.mv0{margin-top:0;margin-bottom:0}.mv1{margin-top:.25rem;margin-bottom:.25rem}.mv2{margin-top:.5rem;margin-bottom:.5rem}.mv3{margin-top:1rem;margin-bottom:1rem}.mv4{margin-top:2rem;margin-bottom:2rem}.mv5{margin-top:4rem;margin-bottom:4rem}.mv6{margin-top:8rem;margin-bottom:8rem}.mv7{margin-top:16rem;margin-bottom:16rem}.mh0{margin-left:0;margin-right:0}.mh1{margin-left:.25rem;margin-right:.25rem}.mh2{margin-left:.5rem;margin-right:.5rem}.mh3{margin-left:1rem;margin-right:1rem}.mh4{margin-left:2rem;margin-right:2rem}.mh5{margin-left:4rem;margin-right:4rem}.mh6{margin-left:8rem;margin-right:8rem}.mh7{margin-left:16rem;margin-right:16rem}@media screen and (min-width:30em){.pa0-ns{padding:0}.pa1-ns{padding:.25rem}.pa2-ns{padding:.5rem}.pa3-ns{padding:1rem}.pa4-ns{padding:2rem}.pa5-ns{padding:4rem}.pa6-ns{padding:8rem}.pa7-ns{padding:16rem}.pl0-ns{padding-left:0}.pl1-ns{padding-left:.25rem}.pl2-ns{padding-left:.5rem}.pl3-ns{padding-left:1rem}.pl4-ns{padding-left:2rem}.pl5-ns{padding-left:4rem}.pl6-ns{padding-left:8rem}.pl7-ns{padding-left:16rem}.pr0-ns{padding-right:0}.pr1-ns{padding-right:.25rem}.pr2-ns{padding-right:.5rem}.pr3-ns{padding-right:1rem}.pr4-ns{padding-right:2rem}.pr5-ns{padding-right:4rem}.pr6-ns{padding-right:8rem}.pr7-ns{padding-right:16rem}.pb0-ns{padding-bottom:0}.pb1-ns{padding-bottom:.25rem}.pb2-ns{padding-bottom:.5rem}.pb3-ns{padding-bottom:1rem}.pb4-ns{padding-bottom:2rem}.pb5-ns{padding-bottom:4rem}.pb6-ns{padding-bottom:8rem}.pb7-ns{padding-bottom:16rem}.pt0-ns{padding-top:0}.pt1-ns{padding-top:.25rem}.pt2-ns{padding-top:.5rem}.pt3-ns{padding-top:1rem}.pt4-ns{padding-top:2rem}.pt5-ns{padding-top:4rem}.pt6-ns{padding-top:8rem}.pt7-ns{padding-top:16rem}.pv0-ns{padding-top:0;padding-bottom:0}.pv1-ns{padding-top:.25rem;padding-bottom:.25rem}.pv2-ns{padding-top:.5rem;padding-bottom:.5rem}.pv3-ns{padding-top:1rem;padding-bottom:1rem}.pv4-ns{padding-top:2rem;padding-bottom:2rem}.pv5-ns{padding-top:4rem;padding-bottom:4rem}.pv6-ns{padding-top:8rem;padding-bottom:8rem}.pv7-ns{padding-top:16rem;padding-bottom:16rem}.ph0-ns{padding-left:0;padding-right:0}.ph1-ns{padding-left:.25rem;padding-right:.25rem}.ph2-ns{padding-left:.5rem;padding-right:.5rem}.ph3-ns{padding-left:1rem;padding-right:1rem}.ph4-ns{padding-left:2rem;padding-right:2rem}.ph5-ns{padding-left:4rem;padding-right:4rem}.ph6-ns{padding-left:8rem;padding-right:8rem}.ph7-ns{padding-left:16rem;padding-right:16rem}.ma0-ns{margin:0}.ma1-ns{margin:.25rem}.ma2-ns{margin:.5rem}.ma3-ns{margin:1rem}.ma4-ns{margin:2rem}.ma5-ns{margin:4rem}.ma6-ns{margin:8rem}.ma7-ns{margin:16rem}.ml0-ns{margin-left:0}.ml1-ns{margin-left:.25rem}.ml2-ns{margin-left:.5rem}.ml3-ns{margin-left:1rem}.ml4-ns{margin-left:2rem}.ml5-ns{margin-left:4rem}.ml6-ns{margin-left:8rem}.ml7-ns{margin-left:16rem}.mr0-ns{margin-right:0}.mr1-ns{margin-right:.25rem}.mr2-ns{margin-right:.5rem}.mr3-ns{margin-right:1rem}.mr4-ns{margin-right:2rem}.mr5-ns{margin-right:4rem}.mr6-ns{margin-right:8rem}.mr7-ns{margin-right:16rem}.mb0-ns{margin-bottom:0}.mb1-ns{margin-bottom:.25rem}.mb2-ns{margin-bottom:.5rem}.mb3-ns{margin-bottom:1rem}.mb4-ns{margin-bottom:2rem}.mb5-ns{margin-bottom:4rem}.mb6-ns{margin-bottom:8rem}.mb7-ns{margin-bottom:16rem}.mt0-ns{margin-top:0}.mt1-ns{margin-top:.25rem}.mt2-ns{margin-top:.5rem}.mt3-ns{margin-top:1rem}.mt4-ns{margin-top:2rem}.mt5-ns{margin-top:4rem}.mt6-ns{margin-top:8rem}.mt7-ns{margin-top:16rem}.mv0-ns{margin-top:0;margin-bottom:0}.mv1-ns{margin-top:.25rem;margin-bottom:.25rem}.mv2-ns{margin-top:.5rem;margin-bottom:.5rem}.mv3-ns{margin-top:1rem;margin-bottom:1rem}.mv4-ns{margin-top:2rem;margin-bottom:2rem}.mv5-ns{margin-top:4rem;margin-bottom:4rem}.mv6-ns{margin-top:8rem;margin-bottom:8rem}.mv7-ns{margin-top:16rem;margin-bottom:16rem}.mh0-ns{margin-left:0;margin-right:0}.mh1-ns{margin-left:.25rem;margin-right:.25rem}.mh2-ns{margin-left:.5rem;margin-right:.5rem}.mh3-ns{margin-left:1rem;margin-right:1rem}.mh4-ns{margin-left:2rem;margin-right:2rem}.mh5-ns{margin-left:4rem;margin-right:4rem}.mh6-ns{margin-left:8rem;margin-right:8rem}.mh7-ns{margin-left:16rem;margin-right:16rem}}@media screen and (min-width:30em) and (max-width:60em){.pa0-m{padding:0}.pa1-m{padding:.25rem}.pa2-m{padding:.5rem}.pa3-m{padding:1rem}.pa4-m{padding:2rem}.pa5-m{padding:4rem}.pa6-m{padding:8rem}.pa7-m{padding:16rem}.pl0-m{padding-left:0}.pl1-m{padding-left:.25rem}.pl2-m{padding-left:.5rem}.pl3-m{padding-left:1rem}.pl4-m{padding-left:2rem}.pl5-m{padding-left:4rem}.pl6-m{padding-left:8rem}.pl7-m{padding-left:16rem}.pr0-m{padding-right:0}.pr1-m{padding-right:.25rem}.pr2-m{padding-right:.5rem}.pr3-m{padding-right:1rem}.pr4-m{padding-right:2rem}.pr5-m{padding-right:4rem}.pr6-m{padding-right:8rem}.pr7-m{padding-right:16rem}.pb0-m{padding-bottom:0}.pb1-m{padding-bottom:.25rem}.pb2-m{padding-bottom:.5rem}.pb3-m{padding-bottom:1rem}.pb4-m{padding-bottom:2rem}.pb5-m{padding-bottom:4rem}.pb6-m{padding-bottom:8rem}.pb7-m{padding-bottom:16rem}.pt0-m{padding-top:0}.pt1-m{padding-top:.25rem}.pt2-m{padding-top:.5rem}.pt3-m{padding-top:1rem}.pt4-m{padding-top:2rem}.pt5-m{padding-top:4rem}.pt6-m{padding-top:8rem}.pt7-m{padding-top:16rem}.pv0-m{padding-top:0;padding-bottom:0}.pv1-m{padding-top:.25rem;padding-bottom:.25rem}.pv2-m{padding-top:.5rem;padding-bottom:.5rem}.pv3-m{padding-top:1rem;padding-bottom:1rem}.pv4-m{padding-top:2rem;padding-bottom:2rem}.pv5-m{padding-top:4rem;padding-bottom:4rem}.pv6-m{padding-top:8rem;padding-bottom:8rem}.pv7-m{padding-top:16rem;padding-bottom:16rem}.ph0-m{padding-left:0;padding-right:0}.ph1-m{padding-left:.25rem;padding-right:.25rem}.ph2-m{padding-left:.5rem;padding-right:.5rem}.ph3-m{padding-left:1rem;padding-right:1rem}.ph4-m{padding-left:2rem;padding-right:2rem}.ph5-m{padding-left:4rem;padding-right:4rem}.ph6-m{padding-left:8rem;padding-right:8rem}.ph7-m{padding-left:16rem;padding-right:16rem}.ma0-m{margin:0}.ma1-m{margin:.25rem}.ma2-m{margin:.5rem}.ma3-m{margin:1rem}.ma4-m{margin:2rem}.ma5-m{margin:4rem}.ma6-m{margin:8rem}.ma7-m{margin:16rem}.ml0-m{margin-left:0}.ml1-m{margin-left:.25rem}.ml2-m{margin-left:.5rem}.ml3-m{margin-left:1rem}.ml4-m{margin-left:2rem}.ml5-m{margin-left:4rem}.ml6-m{margin-left:8rem}.ml7-m{margin-left:16rem}.mr0-m{margin-right:0}.mr1-m{margin-right:.25rem}.mr2-m{margin-right:.5rem}.mr3-m{margin-right:1rem}.mr4-m{margin-right:2rem}.mr5-m{margin-right:4rem}.mr6-m{margin-right:8rem}.mr7-m{margin-right:16rem}.mb0-m{margin-bottom:0}.mb1-m{margin-bottom:.25rem}.mb2-m{margin-bottom:.5rem}.mb3-m{margin-bottom:1rem}.mb4-m{margin-bottom:2rem}.mb5-m{margin-bottom:4rem}.mb6-m{margin-bottom:8rem}.mb7-m{margin-bottom:16rem}.mt0-m{margin-top:0}.mt1-m{margin-top:.25rem}.mt2-m{margin-top:.5rem}.mt3-m{margin-top:1rem}.mt4-m{margin-top:2rem}.mt5-m{margin-top:4rem}.mt6-m{margin-top:8rem}.mt7-m{margin-top:16rem}.mv0-m{margin-top:0;margin-bottom:0}.mv1-m{margin-top:.25rem;margin-bottom:.25rem}.mv2-m{margin-top:.5rem;margin-bottom:.5rem}.mv3-m{margin-top:1rem;margin-bottom:1rem}.mv4-m{margin-top:2rem;margin-bottom:2rem}.mv5-m{margin-top:4rem;margin-bottom:4rem}.mv6-m{margin-top:8rem;margin-bottom:8rem}.mv7-m{margin-top:16rem;margin-bottom:16rem}.mh0-m{margin-left:0;margin-right:0}.mh1-m{margin-left:.25rem;margin-right:.25rem}.mh2-m{margin-left:.5rem;margin-right:.5rem}.mh3-m{margin-left:1rem;margin-right:1rem}.mh4-m{margin-left:2rem;margin-right:2rem}.mh5-m{margin-left:4rem;margin-right:4rem}.mh6-m{margin-left:8rem;margin-right:8rem}.mh7-m{margin-left:16rem;margin-right:16rem}}@media screen and (min-width:60em){.pa0-l{padding:0}.pa1-l{padding:.25rem}.pa2-l{padding:.5rem}.pa3-l{padding:1rem}.pa4-l{padding:2rem}.pa5-l{padding:4rem}.pa6-l{padding:8rem}.pa7-l{padding:16rem}.pl0-l{padding-left:0}.pl1-l{padding-left:.25rem}.pl2-l{padding-left:.5rem}.pl3-l{padding-left:1rem}.pl4-l{padding-left:2rem}.pl5-l{padding-left:4rem}.pl6-l{padding-left:8rem}.pl7-l{padding-left:16rem}.pr0-l{padding-right:0}.pr1-l{padding-right:.25rem}.pr2-l{padding-right:.5rem}.pr3-l{padding-right:1rem}.pr4-l{padding-right:2rem}.pr5-l{padding-right:4rem}.pr6-l{padding-right:8rem}.pr7-l{padding-right:16rem}.pb0-l{padding-bottom:0}.pb1-l{padding-bottom:.25rem}.pb2-l{padding-bottom:.5rem}.pb3-l{padding-bottom:1rem}.pb4-l{padding-bottom:2rem}.pb5-l{padding-bottom:4rem}.pb6-l{padding-bottom:8rem}.pb7-l{padding-bottom:16rem}.pt0-l{padding-top:0}.pt1-l{padding-top:.25rem}.pt2-l{padding-top:.5rem}.pt3-l{padding-top:1rem}.pt4-l{padding-top:2rem}.pt5-l{padding-top:4rem}.pt6-l{padding-top:8rem}.pt7-l{padding-top:16rem}.pv0-l{padding-top:0;padding-bottom:0}.pv1-l{padding-top:.25rem;padding-bottom:.25rem}.pv2-l{padding-top:.5rem;padding-bottom:.5rem}.pv3-l{padding-top:1rem;padding-bottom:1rem}.pv4-l{padding-top:2rem;padding-bottom:2rem}.pv5-l{padding-top:4rem;padding-bottom:4rem}.pv6-l{padding-top:8rem;padding-bottom:8rem}.pv7-l{padding-top:16rem;padding-bottom:16rem}.ph0-l{padding-left:0;padding-right:0}.ph1-l{padding-left:.25rem;padding-right:.25rem}.ph2-l{padding-left:.5rem;padding-right:.5rem}.ph3-l{padding-left:1rem;padding-right:1rem}.ph4-l{padding-left:2rem;padding-right:2rem}.ph5-l{padding-left:4rem;padding-right:4rem}.ph6-l{padding-left:8rem;padding-right:8rem}.ph7-l{padding-left:16rem;padding-right:16rem}.ma0-l{margin:0}.ma1-l{margin:.25rem}.ma2-l{margin:.5rem}.ma3-l{margin:1rem}.ma4-l{margin:2rem}.ma5-l{margin:4rem}.ma6-l{margin:8rem}.ma7-l{margin:16rem}.ml0-l{margin-left:0}.ml1-l{margin-left:.25rem}.ml2-l{margin-left:.5rem}.ml3-l{margin-left:1rem}.ml4-l{margin-left:2rem}.ml5-l{margin-left:4rem}.ml6-l{margin-left:8rem}.ml7-l{margin-left:16rem}.mr0-l{margin-right:0}.mr1-l{margin-right:.25rem}.mr2-l{margin-right:.5rem}.mr3-l{margin-right:1rem}.mr4-l{margin-right:2rem}.mr5-l{margin-right:4rem}.mr6-l{margin-right:8rem}.mr7-l{margin-right:16rem}.mb0-l{margin-bottom:0}.mb1-l{margin-bottom:.25rem}.mb2-l{margin-bottom:.5rem}.mb3-l{margin-bottom:1rem}.mb4-l{margin-bottom:2rem}.mb5-l{margin-bottom:4rem}.mb6-l{margin-bottom:8rem}.mb7-l{margin-bottom:16rem}.mt0-l{margin-top:0}.mt1-l{margin-top:.25rem}.mt2-l{margin-top:.5rem}.mt3-l{margin-top:1rem}.mt4-l{margin-top:2rem}.mt5-l{margin-top:4rem}.mt6-l{margin-top:8rem}.mt7-l{margin-top:16rem}.mv0-l{margin-top:0;margin-bottom:0}.mv1-l{margin-top:.25rem;margin-bottom:.25rem}.mv2-l{margin-top:.5rem;margin-bottom:.5rem}.mv3-l{margin-top:1rem;margin-bottom:1rem}.mv4-l{margin-top:2rem;margin-bottom:2rem}.mv5-l{margin-top:4rem;margin-bottom:4rem}.mv6-l{margin-top:8rem;margin-bottom:8rem}.mv7-l{margin-top:16rem;margin-bottom:16rem}.mh0-l{margin-left:0;margin-right:0}.mh1-l{margin-left:.25rem;margin-right:.25rem}.mh2-l{margin-left:.5rem;margin-right:.5rem}.mh3-l{margin-left:1rem;margin-right:1rem}.mh4-l{margin-left:2rem;margin-right:2rem}.mh5-l{margin-left:4rem;margin-right:4rem}.mh6-l{margin-left:8rem;margin-right:8rem}.mh7-l{margin-left:16rem;margin-right:16rem}}.na1{margin:-.25rem}.na2{margin:-.5rem}.na3{margin:-1rem}.na4{margin:-2rem}.na5{margin:-4rem}.na6{margin:-8rem}.na7{margin:-16rem}.nl1{margin-left:-.25rem}.nl2{margin-left:-.5rem}.nl3{margin-left:-1rem}.nl4{margin-left:-2rem}.nl5{margin-left:-4rem}.nl6{margin-left:-8rem}.nl7{margin-left:-16rem}.nr1{margin-right:-.25rem}.nr2{margin-right:-.5rem}.nr3{margin-right:-1rem}.nr4{margin-right:-2rem}.nr5{margin-right:-4rem}.nr6{margin-right:-8rem}.nr7{margin-right:-16rem}.nb1{margin-bottom:-.25rem}.nb2{margin-bottom:-.5rem}.nb3{margin-bottom:-1rem}.nb4{margin-bottom:-2rem}.nb5{margin-bottom:-4rem}.nb6{margin-bottom:-8rem}.nb7{margin-bottom:-16rem}.nt1{margin-top:-.25rem}.nt2{margin-top:-.5rem}.nt3{margin-top:-1rem}.nt4{margin-top:-2rem}.nt5{margin-top:-4rem}.nt6{margin-top:-8rem}.nt7{margin-top:-16rem}@media screen and (min-width:30em){.na1-ns{margin:-.25rem}.na2-ns{margin:-.5rem}.na3-ns{margin:-1rem}.na4-ns{margin:-2rem}.na5-ns{margin:-4rem}.na6-ns{margin:-8rem}.na7-ns{margin:-16rem}.nl1-ns{margin-left:-.25rem}.nl2-ns{margin-left:-.5rem}.nl3-ns{margin-left:-1rem}.nl4-ns{margin-left:-2rem}.nl5-ns{margin-left:-4rem}.nl6-ns{margin-left:-8rem}.nl7-ns{margin-left:-16rem}.nr1-ns{margin-right:-.25rem}.nr2-ns{margin-right:-.5rem}.nr3-ns{margin-right:-1rem}.nr4-ns{margin-right:-2rem}.nr5-ns{margin-right:-4rem}.nr6-ns{margin-right:-8rem}.nr7-ns{margin-right:-16rem}.nb1-ns{margin-bottom:-.25rem}.nb2-ns{margin-bottom:-.5rem}.nb3-ns{margin-bottom:-1rem}.nb4-ns{margin-bottom:-2rem}.nb5-ns{margin-bottom:-4rem}.nb6-ns{margin-bottom:-8rem}.nb7-ns{margin-bottom:-16rem}.nt1-ns{margin-top:-.25rem}.nt2-ns{margin-top:-.5rem}.nt3-ns{margin-top:-1rem}.nt4-ns{margin-top:-2rem}.nt5-ns{margin-top:-4rem}.nt6-ns{margin-top:-8rem}.nt7-ns{margin-top:-16rem}}@media screen and (min-width:30em) and (max-width:60em){.na1-m{margin:-.25rem}.na2-m{margin:-.5rem}.na3-m{margin:-1rem}.na4-m{margin:-2rem}.na5-m{margin:-4rem}.na6-m{margin:-8rem}.na7-m{margin:-16rem}.nl1-m{margin-left:-.25rem}.nl2-m{margin-left:-.5rem}.nl3-m{margin-left:-1rem}.nl4-m{margin-left:-2rem}.nl5-m{margin-left:-4rem}.nl6-m{margin-left:-8rem}.nl7-m{margin-left:-16rem}.nr1-m{margin-right:-.25rem}.nr2-m{margin-right:-.5rem}.nr3-m{margin-right:-1rem}.nr4-m{margin-right:-2rem}.nr5-m{margin-right:-4rem}.nr6-m{margin-right:-8rem}.nr7-m{margin-right:-16rem}.nb1-m{margin-bottom:-.25rem}.nb2-m{margin-bottom:-.5rem}.nb3-m{margin-bottom:-1rem}.nb4-m{margin-bottom:-2rem}.nb5-m{margin-bottom:-4rem}.nb6-m{margin-bottom:-8rem}.nb7-m{margin-bottom:-16rem}.nt1-m{margin-top:-.25rem}.nt2-m{margin-top:-.5rem}.nt3-m{margin-top:-1rem}.nt4-m{margin-top:-2rem}.nt5-m{margin-top:-4rem}.nt6-m{margin-top:-8rem}.nt7-m{margin-top:-16rem}}@media screen and (min-width:60em){.na1-l{margin:-.25rem}.na2-l{margin:-.5rem}.na3-l{margin:-1rem}.na4-l{margin:-2rem}.na5-l{margin:-4rem}.na6-l{margin:-8rem}.na7-l{margin:-16rem}.nl1-l{margin-left:-.25rem}.nl2-l{margin-left:-.5rem}.nl3-l{margin-left:-1rem}.nl4-l{margin-left:-2rem}.nl5-l{margin-left:-4rem}.nl6-l{margin-left:-8rem}.nl7-l{margin-left:-16rem}.nr1-l{margin-right:-.25rem}.nr2-l{margin-right:-.5rem}.nr3-l{margin-right:-1rem}.nr4-l{margin-right:-2rem}.nr5-l{margin-right:-4rem}.nr6-l{margin-right:-8rem}.nr7-l{margin-right:-16rem}.nb1-l{margin-bottom:-.25rem}.nb2-l{margin-bottom:-.5rem}.nb3-l{margin-bottom:-1rem}.nb4-l{margin-bottom:-2rem}.nb5-l{margin-bottom:-4rem}.nb6-l{margin-bottom:-8rem}.nb7-l{margin-bottom:-16rem}.nt1-l{margin-top:-.25rem}.nt2-l{margin-top:-.5rem}.nt3-l{margin-top:-1rem}.nt4-l{margin-top:-2rem}.nt5-l{margin-top:-4rem}.nt6-l{margin-top:-8rem}.nt7-l{margin-top:-16rem}}.collapse{border-collapse:collapse;border-spacing:0}.striped--light-silver:nth-child(odd){background-color:#aaa}.striped--moon-gray:nth-child(odd){background-color:#ccc}.striped--light-gray:nth-child(odd){background-color:#eee}.striped--near-white:nth-child(odd){background-color:#f4f4f4}.stripe-light:nth-child(odd){background-color:rgba(255,255,255,.1)}.stripe-dark:nth-child(odd){background-color:rgba(0,0,0,.1)}.strike{text-decoration:line-through}.underline{text-decoration:underline}.no-underline{text-decoration:none}@media screen and (min-width:30em){.strike-ns{text-decoration:line-through}.underline-ns{text-decoration:underline}.no-underline-ns{text-decoration:none}}@media screen and (min-width:30em) and (max-width:60em){.strike-m{text-decoration:line-through}.underline-m{text-decoration:underline}.no-underline-m{text-decoration:none}}@media screen and (min-width:60em){.strike-l{text-decoration:line-through}.underline-l{text-decoration:underline}.no-underline-l{text-decoration:none}}.tl{text-align:left}.tr{text-align:right}.tc{text-align:center}.tj{text-align:justify}@media screen and (min-width:30em){.tl-ns{text-align:left}.tr-ns{text-align:right}.tc-ns{text-align:center}.tj-ns{text-align:justify}}@media screen and (min-width:30em) and (max-width:60em){.tl-m{text-align:left}.tr-m{text-align:right}.tc-m{text-align:center}.tj-m{text-align:justify}}@media screen and (min-width:60em){.tl-l{text-align:left}.tr-l{text-align:right}.tc-l{text-align:center}.tj-l{text-align:justify}}.ttc{text-transform:capitalize}.ttl{text-transform:lowercase}.ttu{text-transform:uppercase}.ttn{text-transform:none}@media screen and (min-width:30em){.ttc-ns{text-transform:capitalize}.ttl-ns{text-transform:lowercase}.ttu-ns{text-transform:uppercase}.ttn-ns{text-transform:none}}@media screen and (min-width:30em) and (max-width:60em){.ttc-m{text-transform:capitalize}.ttl-m{text-transform:lowercase}.ttu-m{text-transform:uppercase}.ttn-m{text-transform:none}}@media screen and (min-width:60em){.ttc-l{text-transform:capitalize}.ttl-l{text-transform:lowercase}.ttu-l{text-transform:uppercase}.ttn-l{text-transform:none}}.f-6,.f-headline{font-size:6rem}.f-5,.f-subheadline{font-size:5rem}.f1{font-size:3rem}.f2{font-size:2.25rem}.f3{font-size:1.5rem}.f4{font-size:1.25rem}.f5{font-size:1rem}.f6{font-size:.875rem}.f7{font-size:.75rem}@media screen and (min-width:30em){.f-6-ns,.f-headline-ns{font-size:6rem}.f-5-ns,.f-subheadline-ns{font-size:5rem}.f1-ns{font-size:3rem}.f2-ns{font-size:2.25rem}.f3-ns{font-size:1.5rem}.f4-ns{font-size:1.25rem}.f5-ns{font-size:1rem}.f6-ns{font-size:.875rem}.f7-ns{font-size:.75rem}}@media screen and (min-width:30em) and (max-width:60em){.f-6-m,.f-headline-m{font-size:6rem}.f-5-m,.f-subheadline-m{font-size:5rem}.f1-m{font-size:3rem}.f2-m{font-size:2.25rem}.f3-m{font-size:1.5rem}.f4-m{font-size:1.25rem}.f5-m{font-size:1rem}.f6-m{font-size:.875rem}.f7-m{font-size:.75rem}}@media screen and (min-width:60em){.f-6-l,.f-headline-l{font-size:6rem}.f-5-l,.f-subheadline-l{font-size:5rem}.f1-l{font-size:3rem}.f2-l{font-size:2.25rem}.f3-l{font-size:1.5rem}.f4-l{font-size:1.25rem}.f5-l{font-size:1rem}.f6-l{font-size:.875rem}.f7-l{font-size:.75rem}}.measure{max-width:30em}.measure-wide{max-width:34em}.measure-narrow{max-width:20em}.indent{text-indent:1em;margin-top:0;margin-bottom:0}.small-caps{-webkit-font-feature-settings:"c2sc";font-feature-settings:"c2sc";font-variant:small-caps}.truncate{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}@media screen and (min-width:30em){.measure-ns{max-width:30em}.measure-wide-ns{max-width:34em}.measure-narrow-ns{max-width:20em}.indent-ns{text-indent:1em;margin-top:0;margin-bottom:0}.small-caps-ns{-webkit-font-feature-settings:"c2sc";font-feature-settings:"c2sc";font-variant:small-caps}.truncate-ns{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}}@media screen and (min-width:30em) and (max-width:60em){.measure-m{max-width:30em}.measure-wide-m{max-width:34em}.measure-narrow-m{max-width:20em}.indent-m{text-indent:1em;margin-top:0;margin-bottom:0}.small-caps-m{-webkit-font-feature-settings:"c2sc";font-feature-settings:"c2sc";font-variant:small-caps}.truncate-m{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}}@media screen and (min-width:60em){.measure-l{max-width:30em}.measure-wide-l{max-width:34em}.measure-narrow-l{max-width:20em}.indent-l{text-indent:1em;margin-top:0;margin-bottom:0}.small-caps-l{-webkit-font-feature-settings:"c2sc";font-feature-settings:"c2sc";font-variant:small-caps}.truncate-l{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}}.overflow-container{overflow-y:scroll}.center{margin-right:auto;margin-left:auto}.mr-auto{margin-right:auto}.ml-auto{margin-left:auto}@media screen and (min-width:30em){.center-ns{margin-right:auto;margin-left:auto}.mr-auto-ns{margin-right:auto}.ml-auto-ns{margin-left:auto}}@media screen and (min-width:30em) and (max-width:60em){.center-m{margin-right:auto;margin-left:auto}.mr-auto-m{margin-right:auto}.ml-auto-m{margin-left:auto}}@media screen and (min-width:60em){.center-l{margin-right:auto;margin-left:auto}.mr-auto-l{margin-right:auto}.ml-auto-l{margin-left:auto}}.clip{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}@media screen and (min-width:30em){.clip-ns{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}}@media screen and (min-width:30em) and (max-width:60em){.clip-m{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}}@media screen and (min-width:60em){.clip-l{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}}.ws-normal{white-space:normal}.nowrap{white-space:nowrap}.pre{white-space:pre}@media screen and (min-width:30em){.ws-normal-ns{white-space:normal}.nowrap-ns{white-space:nowrap}.pre-ns{white-space:pre}}@media screen and (min-width:30em) and (max-width:60em){.ws-normal-m{white-space:normal}.nowrap-m{white-space:nowrap}.pre-m{white-space:pre}}@media screen and (min-width:60em){.ws-normal-l{white-space:normal}.nowrap-l{white-space:nowrap}.pre-l{white-space:pre}}.v-base{vertical-align:baseline}.v-mid{vertical-align:middle}.v-top{vertical-align:top}.v-btm{vertical-align:bottom}@media screen and (min-width:30em){.v-base-ns{vertical-align:baseline}.v-mid-ns{vertical-align:middle}.v-top-ns{vertical-align:top}.v-btm-ns{vertical-align:bottom}}@media screen and (min-width:30em) and (max-width:60em){.v-base-m{vertical-align:baseline}.v-mid-m{vertical-align:middle}.v-top-m{vertical-align:top}.v-btm-m{vertical-align:bottom}}@media screen and (min-width:60em){.v-base-l{vertical-align:baseline}.v-mid-l{vertical-align:middle}.v-top-l{vertical-align:top}.v-btm-l{vertical-align:bottom}}.dim{opacity:1;-webkit-transition:opacity .15s ease-in;transition:opacity .15s ease-in}.dim:hover,.dim:focus{opacity:.5;-webkit-transition:opacity .15s ease-in;transition:opacity .15s ease-in}.dim:active{opacity:.8;-webkit-transition:opacity .15s ease-out;transition:opacity .15s ease-out}.glow{-webkit-transition:opacity .15s ease-in;transition:opacity .15s ease-in}.glow:hover,.glow:focus{opacity:1;-webkit-transition:opacity .15s ease-in;transition:opacity .15s ease-in}.hide-child .child{opacity:0;-webkit-transition:opacity .15s ease-in;transition:opacity .15s ease-in}.hide-child:hover .child,.hide-child:focus .child,.hide-child:active .child{opacity:1;-webkit-transition:opacity .15s ease-in;transition:opacity .15s ease-in}.underline-hover:hover,.underline-hover:focus{text-decoration:underline}.grow{-moz-osx-font-smoothing:grayscale;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-transform:translateZ(0);transform:translateZ(0);-webkit-transition:-webkit-transform .25s ease-out;transition:-webkit-transform .25s ease-out;transition:transform .25s ease-out;transition:transform .25s ease-out,-webkit-transform .25s ease-out}.grow:hover,.grow:focus{-webkit-transform:scale(1.05);transform:scale(1.05)}.grow:active{-webkit-transform:scale(.90);transform:scale(.90)}.grow-large{-moz-osx-font-smoothing:grayscale;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-transform:translateZ(0);transform:translateZ(0);-webkit-transition:-webkit-transform .25s ease-in-out;transition:-webkit-transform .25s ease-in-out;transition:transform .25s ease-in-out;transition:transform .25s ease-in-out,-webkit-transform .25s ease-in-out}.grow-large:hover,.grow-large:focus{-webkit-transform:scale(1.2);transform:scale(1.2)}.grow-large:active{-webkit-transform:scale(.95);transform:scale(.95)}.pointer:hover{cursor:pointer}.shadow-hover{cursor:pointer;position:relative;-webkit-transition:all .5s cubic-bezier(0.165,0.84,0.44,1);transition:all .5s cubic-bezier(0.165,0.84,0.44,1)}.shadow-hover::after{content:'';-webkit-box-shadow:0 0 16px 2px rgba(0,0,0,.2);box-shadow:0 0 16px 2px rgba(0,0,0,.2);border-radius:inherit;opacity:0;position:absolute;top:0;left:0;width:100%;height:100%;z-index:-1;-webkit-transition:opacity .5s cubic-bezier(0.165,0.84,0.44,1);transition:opacity .5s cubic-bezier(0.165,0.84,0.44,1)}.shadow-hover:hover::after,.shadow-hover:focus::after{opacity:1}.bg-animate,.bg-animate:hover,.bg-animate:focus{-webkit-transition:background-color .15s ease-in-out;transition:background-color .15s ease-in-out}.z-0{z-index:0}.z-1{z-index:1}.z-2{z-index:2}.z-3{z-index:3}.z-4{z-index:4}.z-5{z-index:5}.z-999{z-index:999}.z-9999{z-index:9999}.z-max{z-index:2147483647}.z-inherit{z-index:inherit}.z-initial{z-index:auto;z-index:initial}.z-unset{z-index:unset}.nested-copy-line-height p,.nested-copy-line-height ul,.nested-copy-line-height ol{line-height:1.5}.nested-headline-line-height h1,.nested-headline-line-height h2,.nested-headline-line-height h3,.nested-headline-line-height h4,.nested-headline-line-height h5,.nested-headline-line-height h6{line-height:1.25}.nested-list-reset ul,.nested-list-reset ol{padding-left:0;margin-left:0;list-style-type:none}.nested-copy-indent p+p{text-indent:1em;margin-top:0;margin-bottom:0}.nested-copy-separator p+p{margin-top:1.5em}.nested-img img{width:100%;max-width:100%;display:block}.nested-links a{color:#0594cb;-webkit-transition:color .15s ease-in;transition:color .15s ease-in}.nested-links a:hover,.nested-links a:focus{color:#96ccff;-webkit-transition:color .15s ease-in;transition:color .15s ease-in}.header-link:after{position:relative;left:.5em;opacity:0;font-size:.8em;-moz-transition:opacity .2s ease-in-out .1s;-ms-transition:opacity .2s ease-in-out .1s}h2:hover .header-link,h3:hover .header-link,h4:hover .header-link,h5:hover .header-link,h6:hover .header-link{opacity:1}.animated{-webkit-animation-duration:.5s;animation-duration:.5s;-webkit-animation-fill-mode:forwards;animation-fill-mode:forwards;-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}@-webkit-keyframes fadeIn{from{opacity:0}to{opacity:1}}@keyframes fadeIn{from{opacity:0}to{opacity:1}}.fadeIn{-webkit-animation-name:fadeIn;animation-name:fadeIn}.animated-delay-1{-webkit-animation-delay:.5s;animation-delay:.5s}.note,.warning{border-left-width:4px;border-left-style:solid;position:relative;border-color:#0594cb;display:block}.note #exclamation-icon,.warning #exclamation-icon{fill:#0594cb;position:absolute;top:35%;left:-12px}.admonition-content{display:block;margin:0;padding:.125em 1em;margin-top:2em;margin-bottom:2em;overflow-x:auto;background-color:rgba(0,0,0,.05)}.hide-child-menu .child-menu{display:none}.hide-child-menu:hover .child-menu,.hide-child-menu:focus .child-menu,.hide-child-menu:active .child-menu{display:block}.documentation-copy h2{margin-top:3em}.documentation-copy h2.minor{font-size:inherit;margin-top:inherit;border-bottom:none}.searchbox{display:inline-block;position:relative;width:200px;height:32px!important;white-space:nowrap;-webkit-box-sizing:border-box;box-sizing:border-box;visibility:visible!important}.searchbox .algolia-autocomplete{display:block;width:100%;height:100%}.searchbox__wrapper{width:100%;height:100%;z-index:999;position:relative}.searchbox__input{display:inline-block;-webkit-box-sizing:border-box;box-sizing:border-box;-webkit-transition:background .4s ease,-webkit-box-shadow .4s ease;transition:background .4s ease,-webkit-box-shadow .4s ease;transition:box-shadow .4s ease,background .4s ease;transition:box-shadow .4s ease,background .4s ease,-webkit-box-shadow .4s ease;border:0;border-radius:16px;-webkit-box-shadow:inset 0 0 0 1px #ccc;box-shadow:inset 0 0 0 1px #ccc;background:#fff!important;padding:0 26px 0 32px;width:100%;height:100%;vertical-align:middle;white-space:normal;font-size:12px;-webkit-appearance:none;-moz-appearance:none;appearance:none}.searchbox__input::-webkit-search-cancel-button,.searchbox__input::-webkit-search-decoration,.searchbox__input::-webkit-search-results-button,.searchbox__input::-webkit-search-results-decoration{display:none}.searchbox__input:hover{-webkit-box-shadow:inset 0 0 0 1px #b3b3b3;box-shadow:inset 0 0 0 1px #b3b3b3}.searchbox__input:active,.searchbox__input:focus{outline:0;-webkit-box-shadow:inset 0 0 0 1px #aaa;box-shadow:inset 0 0 0 1px #aaa;background:#fff}.searchbox__input::-webkit-input-placeholder{color:#aaa}.searchbox__input:-ms-input-placeholder{color:#aaa}.searchbox__input::-ms-input-placeholder{color:#aaa}.searchbox__input::placeholder{color:#aaa}.searchbox__submit{position:absolute;top:0;margin:0;border:0;border-radius:16px 0 0 16px;background-color:rgba(69,142,225,0);padding:0;width:32px;height:100%;vertical-align:middle;text-align:center;font-size:inherit;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;right:inherit;left:0}.searchbox__submit:before{display:inline-block;margin-right:-4px;height:100%;vertical-align:middle;content:""}.searchbox__submit:active,.searchbox__submit:hover{cursor:pointer}.searchbox__submit:focus{outline:0}.searchbox__submit svg{width:14px;height:14px;vertical-align:middle;fill:#6d7e96}.searchbox__reset{display:block;position:absolute;top:8px;right:8px;margin:0;border:0;background:0 0;cursor:pointer;padding:0;font-size:inherit;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;fill:rgba(0,0,0,.5)}.searchbox__reset.hide{display:none}.searchbox__reset:focus{outline:0}.searchbox__reset svg{display:block;margin:4px;width:8px;height:8px}.searchbox__input:valid~.searchbox__reset{display:block;-webkit-animation-name:sbx-reset-in;animation-name:sbx-reset-in;-webkit-animation-duration:.15s;animation-duration:.15s}@-webkit-keyframes sbx-reset-in{0%{-webkit-transform:translate3d(-20%,0,0);transform:translate3d(-20%,0,0);opacity:0}to{-webkit-transform:none;transform:none;opacity:1}}@keyframes sbx-reset-in{0%{-webkit-transform:translate3d(-20%,0,0);transform:translate3d(-20%,0,0);opacity:0}to{-webkit-transform:none;transform:none;opacity:1}}.algolia-autocomplete.algolia-autocomplete-right .ds-dropdown-menu{right:0!important;left:inherit!important}.algolia-autocomplete.algolia-autocomplete-right .ds-dropdown-menu:before{right:48px}.algolia-autocomplete.algolia-autocomplete-left .ds-dropdown-menu{left:0!important;right:inherit!important}.algolia-autocomplete.algolia-autocomplete-left .ds-dropdown-menu:before{left:48px}.algolia-autocomplete .ds-dropdown-menu{top:-6px;border-radius:4px;margin:6px 0 0;padding:0;text-align:left;height:auto;position:relative;background:transparent;border:none;z-index:999;max-width:600px;min-width:500px;-webkit-box-shadow:0 1px 0 0 rgba(0,0,0,.2),0 2px 3px 0 rgba(0,0,0,.1);box-shadow:0 1px 0 0 rgba(0,0,0,.2),0 2px 3px 0 rgba(0,0,0,.1)}.algolia-autocomplete .ds-dropdown-menu:before{display:block;position:absolute;content:"";width:14px;height:14px;background:#fff;z-index:1000;top:-7px;border-top:1px solid #d9d9d9;border-right:1px solid #d9d9d9;-webkit-transform:rotate(-45deg);transform:rotate(-45deg);border-radius:2px}.algolia-autocomplete .ds-dropdown-menu .ds-suggestions{position:relative;z-index:1000;margin-top:8px}.algolia-autocomplete .ds-dropdown-menu .ds-suggestions a:hover{text-decoration:none}.algolia-autocomplete .ds-dropdown-menu .ds-suggestion{cursor:pointer}.algolia-autocomplete .ds-dropdown-menu .ds-suggestion.ds-cursor .algolia-docsearch-suggestion.suggestion-layout-simple,.algolia-autocomplete .ds-dropdown-menu .ds-suggestion.ds-cursor .algolia-docsearch-suggestion:not(.suggestion-layout-simple) .algolia-docsearch-suggestion--content{background-color:rgba(69,142,225,.05)}.algolia-autocomplete .ds-dropdown-menu [class^=ds-dataset-]{position:relative;border:1px solid #d9d9d9;background:#fff;border-radius:4px;overflow:auto;padding:0 8px 8px}.algolia-autocomplete .ds-dropdown-menu *{-webkit-box-sizing:border-box;box-sizing:border-box}.algolia-autocomplete .algolia-docsearch-suggestion{display:block;position:relative;padding:0 8px;background:#fff;color:#02060c;overflow:hidden}.algolia-autocomplete .algolia-docsearch-suggestion--highlight{color:#174d8c;background:rgba(143,187,237,.1);padding:.1em .05em}.algolia-autocomplete .algolia-docsearch-suggestion--category-header .algolia-docsearch-suggestion--category-header-lvl0 .algolia-docsearch-suggestion--highlight,.algolia-autocomplete .algolia-docsearch-suggestion--category-header .algolia-docsearch-suggestion--category-header-lvl1 .algolia-docsearch-suggestion--highlight,.algolia-autocomplete .algolia-docsearch-suggestion--text .algolia-docsearch-suggestion--highlight{padding:0 0 1px;background:inherit;-webkit-box-shadow:inset 0 -2px 0 0 rgba(69,142,225,.8);box-shadow:inset 0 -2px 0 0 rgba(69,142,225,.8);color:inherit}.algolia-autocomplete .algolia-docsearch-suggestion--content{display:block;float:right;width:70%;position:relative;padding:5.33333px 0 5.33333px 10.66667px;cursor:pointer}.algolia-autocomplete .algolia-docsearch-suggestion--content:before{content:"";position:absolute;display:block;top:0;height:100%;width:1px;background:#ddd;left:-1px}.algolia-autocomplete .algolia-docsearch-suggestion--category-header{position:relative;border-bottom:1px solid #ddd;display:none;margin-top:8px;padding:4px 0;font-size:1em;color:#33363d}.algolia-autocomplete .algolia-docsearch-suggestion--wrapper{width:100%;float:left;padding:8px 0 0}.algolia-autocomplete .algolia-docsearch-suggestion--subcategory-column{float:left;width:30%;text-align:right;position:relative;padding:5.33333px 10.66667px;color:#a4a7ae;font-size:.9em;word-wrap:break-word}.algolia-autocomplete .algolia-docsearch-suggestion--subcategory-column:before{content:"";position:absolute;display:block;top:0;height:100%;width:1px;background:#ddd;right:0}.algolia-autocomplete .algolia-docsearch-suggestion--subcategory-inline{display:none}.algolia-autocomplete .algolia-docsearch-suggestion--title{margin-bottom:4px;color:#02060c;font-size:.9em;font-weight:700}.algolia-autocomplete .algolia-docsearch-suggestion--text{display:block;line-height:1.2em;font-size:.85em;color:#63676d}.algolia-autocomplete .algolia-docsearch-suggestion--no-results{width:100%;padding:8px 0;text-align:center;font-size:1.2em}.algolia-autocomplete .algolia-docsearch-suggestion--no-results:before{display:none}.algolia-autocomplete .algolia-docsearch-suggestion code{padding:1px 5px;font-size:90%;border:none;color:#222;background-color:#ebebeb;border-radius:3px;font-family:Menlo,Monaco,Consolas,Courier New,monospace}.algolia-autocomplete .algolia-docsearch-suggestion code .algolia-docsearch-suggestion--highlight{background:0 0}.algolia-autocomplete .algolia-docsearch-suggestion.algolia-docsearch-suggestion__main .algolia-docsearch-suggestion--category-header,.algolia-autocomplete .algolia-docsearch-suggestion.algolia-docsearch-suggestion__secondary{display:block}@media(min-width:768px){.algolia-autocomplete .algolia-docsearch-suggestion .algolia-docsearch-suggestion--subcategory-column{display:block}}@media(max-width:768px){.algolia-autocomplete .algolia-docsearch-suggestion .algolia-docsearch-suggestion--subcategory-column{display:inline-block;width:auto;float:left;padding:0;color:#02060c;font-size:.9em;font-weight:700;text-align:left;opacity:.5}.algolia-autocomplete .algolia-docsearch-suggestion .algolia-docsearch-suggestion--subcategory-column:before{display:none}.algolia-autocomplete .algolia-docsearch-suggestion .algolia-docsearch-suggestion--subcategory-column:after{content:"|"}.algolia-autocomplete .algolia-docsearch-suggestion .algolia-docsearch-suggestion--content{display:inline-block;width:auto;text-align:left;float:left;padding:0}.algolia-autocomplete .algolia-docsearch-suggestion .algolia-docsearch-suggestion--content:before{display:none}}.algolia-autocomplete .suggestion-layout-simple.algolia-docsearch-suggestion{border-bottom:1px solid #eee;padding:8px;margin:0}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--content{width:100%;padding:0}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--content:before{display:none}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--category-header{margin:0;padding:0;display:block;width:100%;border:none}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--category-header-lvl0,.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--category-header-lvl1{opacity:.6;font-size:.85em}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--category-header-lvl1:before{background-image:url(data:image/svg+xml;utf8;base64,PHN2ZyB3aWR0aD0iMTAiIGhlaWdodD0iMTAiIHZpZXdCb3g9IjAgMCAyMCAzOCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMS40OSA0LjMxbDE0IDE2LjEyNi4wMDItMi42MjQtMTQgMTYuMDc0LTEuMzE0IDEuNTEgMy4wMTcgMi42MjYgMS4zMTMtMS41MDggMTQtMTYuMDc1IDEuMTQyLTEuMzEzLTEuMTQtMS4zMTMtMTQtMTYuMTI1TDMuMi4xOC4xOCAyLjhsMS4zMSAxLjUxeiIgZmlsbC1ydWxlPSJldmVub2RkIiBmaWxsPSIjMWQzNjU3IiAvPjwvc3ZnPg==);content:"";width:10px;height:10px;display:inline-block}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--wrapper{width:100%;float:left;margin:0;padding:0}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--duplicate-content,.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--subcategory-inline{display:none!important}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--title{margin:0;color:#458ee1;font-size:.9em;font-weight:400}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--title:before{content:"#";font-weight:700;color:#458ee1;display:inline-block}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--text{margin:4px 0 0;display:block;line-height:1.4em;padding:5.33333px 8px;background:#f8f8f8;font-size:.85em;opacity:.8}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--text .algolia-docsearch-suggestion--highlight{color:#3f4145;font-weight:700;-webkit-box-shadow:none;box-shadow:none}.algolia-autocomplete .algolia-docsearch-footer{width:134px;height:20px;z-index:2000;margin-top:10.66667px;float:right;font-size:0;line-height:0}.algolia-autocomplete .algolia-docsearch-footer--logo{background-image:url(data:image/svg+xml;charset=utf-8;base64,PHN2ZyB3aWR0aD0iMTY4IiBoZWlnaHQ9IjI0IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+PHBhdGggZD0iTTc4Ljk4OC45MzhoMTYuNTk0YTIuOTY4IDIuOTY4LjAgMCAxIDIuOTY2IDIuOTY2VjIwLjVhMi45NjcgMi45NjcuMCAwIDEtMi45NjYgMi45NjRINzguOTg4YTIuOTY3IDIuOTY3LjAgMCAxLTIuOTY2LTIuOTY0VjMuODk3QTIuOTYxIDIuOTYxLjAgMCAxIDc4Ljk4OC45Mzh6bTQxLjkzNyAxNy44NjZjLTQuMzg2LjAyLTQuMzg2LTMuNTQtNC4zODYtNC4xMDZsLS4wMDctMTMuMzM2IDIuNjc1LS40MjR2MTMuMjU0YzAgLjMyMi4wIDIuMzU4IDEuNzE4IDIuMzY0djIuMjQ4em0tMTAuODQ2LTIuMThjLjgyMS4wIDEuNDMtLjA0NyAxLjg1NS0uMTI5di0yLjcxOWE2LjMzNCA2LjMzNC4wIDAgMC0xLjU3NC0uMTk5IDUuNyA1LjcuMCAwIDAtLjg5Ny4wNjkgMi42OTkgMi42OTkuMCAwIDAtLjgxNC4yNGMtLjI0LjExNi0uNDM5LjI4LS41ODIuNDkxLS4xNS4yMTItLjIxOS4zMzUtLjIxOS42NTYuMC42MjguMjE5Ljk5MS42MTYgMS4yM3MuOTM4LjM2MiAxLjYxNS4zNjJ6bS0uMjMzLTkuN2MuODgzLjAgMS42MjkuMTA5IDIuMjMxLjMyOC42MDIuMjE4IDEuMDg4LjUyNSAxLjQ0NC45MTUuMzYzLjM5Ni42MDkuOTIyLjc2IDEuNDgzLjE1Ny41Ni4yMzIgMS4xNzUuMjMyIDEuODV2Ni44NzRhMzIuNSAzMi41LjAgMCAxLTEuODY4LjMxNGMtLjgzNC4xMjMtMS43NzIuMTg1LTIuODEzLjE4NS0uNjkuMC0xLjMyNy0uMDY5LTEuODk1LS4xOThhNC4wMDEgNC4wMDEuMCAwIDEtMS40NzEtLjYzNiAzLjA4NSAzLjA4NS4wIDAgMS0uOTUxLTEuMTM0Yy0uMjI2LS40NjUtLjM0My0xLjEyLS4zNDMtMS44MDMuMC0uNjU2LjEzLTEuMDczLjM4NC0xLjUyNWEzLjI0IDMuMjQuMCAwIDEgMS4wNDctMS4xMDZjLjQ0NS0uMjg3Ljk1LS40OTIgMS41MzItLjYxNWE4LjggOC44LjAgMCAxIDEuODItLjE4NSA4LjQwNCA4LjQwNC4wIDAgMSAxLjk3Mi4yNHYtLjQzOGMwLS4zMDctLjAzNS0uNi0uMTEtLjg3NGExLjg4IDEuODguMCAwIDAtLjM4NC0uNzMgMS43ODQgMS43ODQuMCAwIDAtLjcyNC0uNDkzIDMuMTY0IDMuMTY0LjAgMCAwLTEuMTQzLS4yMDVjLS42MTYuMC0xLjE3Ny4wNzUtMS42OS4xNjRhNy43MzUgNy43MzUuMCAwIDAtMS4yNi4zMDdsLS4zMjEtMi4xOTJjLjMzNS0uMTE3LjgzNC0uMjMzIDEuNDc4LS4zNDlhMTAuOTggMTAuOTguMCAwIDEgMi4wNzMtLjE3OHptNTIuODQyIDkuNjI2Yy44MjIuMCAxLjQzLS4wNDggMS44NTQtLjEzVjEzLjdhNi4zNDcgNi4zNDcuMCAwIDAtMS41NzQtLjE5OWMtLjI5NC4wLS41OTUuMDIxLS44OTYuMDY5YTIuNyAyLjcuMCAwIDAtLjgxNC4yNCAxLjQ2IDEuNDYuMCAwIDAtLjU4Mi40OTFjLS4xNS4yMTItLjIxOC4zMzUtLjIxOC42NTYuMC42MjguMjE4Ljk5MS42MTUgMS4yMy40MDQuMjQ1LjkzOC4zNjIgMS42MTUuMzYyem0tLjIyNi05LjY5NGMuODgzLjAgMS42MjkuMTA4IDIuMjMxLjMyNy42MDIuMjE5IDEuMDg4LjUyNiAxLjQ0NC45MTUuMzU1LjM5LjYwOS45MjMuNzU5IDEuNDgzYTYuOCA2LjguMCAwIDEgLjIzMyAxLjg1MnY2Ljg3M2MtLjQxLjA4OC0xLjAzNC4xOS0xLjg2OC4zMTQtLjgzNC4xMjMtMS43NzIuMTg0LTIuODEzLjE4NC0uNjkuMC0xLjMyNy0uMDY4LTEuODk1LS4xOThhNC4wMDEgNC4wMDEuMCAwIDEtMS40NzEtLjYzNSAzLjA4NSAzLjA4NS4wIDAgMS0uOTUxLTEuMTM0Yy0uMjI2LS40NjUtLjM0My0xLjEyLS4zNDMtMS44MDQuMC0uNjU2LjEzLTEuMDczLjM4NC0xLjUyNC4yNi0uNDUuNjA4LS44MiAxLjA0Ny0xLjEwNy40NDUtLjI4Ni45NS0uNDkxIDEuNTMyLS42MTRhOC44MDMgOC44MDMuMCAwIDEgMi43NTEtLjEzYy4zMjkuMDM0LjY3MS4wOTYgMS4wNC4xODV2LS40MzdhMy4zIDMuMy4wIDAgMC0uMTA5LS44NzUgMS44NzMgMS44NzMuMCAwIDAtLjM4NC0uNzMxIDEuNzg0IDEuNzg0LjAgMCAwLS43MjQtLjQ5MiAzLjE2NSAzLjE2NS4wIDAgMC0xLjE0My0uMjA1Yy0uNjE2LjAtMS4xNzcuMDc1LTEuNjkuMTY0YTcuNzUgNy43NS4wIDAgMC0xLjI2LjMwN2wtLjMyMS0yLjE5M2MuMzM1LS4xMTYuODM0LS4yMzIgMS40NzgtLjM0OGExMS42MzMgMTEuNjMzLjAgMCAxIDIuMDczLS4xNzd6bS04LjAzNC0xLjI3MWExLjYyNiAxLjYyNi4wIDAgMS0xLjYyOC0xLjYyYzAtLjg5NS43MjUtMS42MiAxLjYyOC0xLjYyLjkwNC4wIDEuNjMuNzI1IDEuNjMgMS42Mi4wLjg5NS0uNzMzIDEuNjItMS42MyAxLjYyem0xLjM0OCAxMy4yMmgtMi42ODlWNy4yN2wyLjY5LS40MjN2MTEuOTU2em0tNC43MTQuMGMtNC4zODYuMDItNC4zODYtMy41NC00LjM4Ni00LjEwN2wtLjAwOC0xMy4zMzYgMi42NzYtLjQyNHYxMy4yNTRjMCAuMzIyLjAgMi4zNTggMS43MTggMi4zNjR2Mi4yNDh6bS04LjY5OC01LjkwM2MwLTEuMTU2LS4yNTMtMi4xMTktLjc0Ni0yLjc4OC0uNDkzLS42NzctMS4xODMtMS4wMS0yLjA2Ny0xLjAxLS44ODIuMC0xLjU3NC4zMzMtMi4wNjUgMS4wMS0uNDkzLjY3Ni0uNzMzIDEuNjMyLS43MzMgMi43ODguMCAxLjE2OC4yNDYgMS45NTMuNzQgMi42My40OTIuNjgzIDEuMTgzIDEuMDE4IDIuMDY2IDEuMDE4Ljg4Mi4wIDEuNTc0LS4zNDIgMi4wNjctMS4wMTkuNDkyLS42ODMuNzM4LTEuNDYuNzM4LTIuNjN6bTIuNzM3LS4wMDdjMCAuOTAyLS4xMyAxLjU4NC0uMzk3IDIuMzNhNS41MiA1LjUyLjAgMCAxLTEuMTI4IDEuOTA2IDQuOTg2IDQuOTg2LjAgMCAxLTEuNzUyIDEuMjIzYy0uNjg1LjI4Ni0xLjczOS40NS0yLjI2NS40NS0uNTI4LS4wMDYtMS41NzQtLjE1Ny0yLjI1Mi0uNDVhNS4wOTYgNS4wOTYuMCAwIDEtMS43NDQtMS4yMjNjLS40ODctLjUyNy0uODYzLTEuMTYyLTEuMTM3LTEuOTA2YTYuMzQ1IDYuMzQ1LjAgMCAxLS40MS0yLjMzYzAtLjkwMi4xMjMtMS43Ny4zOTctMi41MDhhNS41NTQgNS41NTQuMCAwIDEgMS4xNS0xLjg5MiA1LjEzMyA1LjEzMy4wIDAgMSAxLjc1LTEuMjE2Yy42NzktLjI4NyAxLjQyNS0uNDIzIDIuMjMyLS40MjMuODA4LjAgMS41NTMuMTQyIDIuMjM3LjQyM2E0Ljg4IDQuODguMCAwIDEgMS43NTMgMS4yMTYgNS42NDQgNS42NDQuMCAwIDEgMS4xMzUgMS44OTJjLjI4Ny43MzguNDMxIDEuNjA2LjQzMSAyLjUwOHptLTIwLjEzOC4wYzAgMS4xMi4yNDYgMi4zNjMuNzM4IDIuODgyLjQ5My41MiAxLjEzLjc4IDEuOTEuNzguNDI0LjAuODI4LS4wNjIgMS4yMDQtLjE3OC4zNzctLjExNi42NzctLjI1My45MTctLjQxN1Y5LjMzYTEwLjQ3NiAxMC40NzYuMCAwIDAtMS43NjYtLjIyNmMtLjk3MS0uMDI4LTEuNzEuMzctMi4yMyAxLjAwNC0uNTEzLjYzNi0uNzczIDEuNzUtLjc3MyAyLjc4OHptNy40MzggNS4yNzRjMCAxLjgyNC0uNDY2IDMuMTU2LTEuNDA0IDQuMDA0LS45MzYuODQ2LTIuMzY3IDEuMjctNC4yOTYgMS4yNy0uNzA1LjAtMi4xNy0uMTM3LTMuMzQtLjM5NmwuNDMxLTIuMTE4Yy45OC4yMDUgMi4yNzIuMjYgMi45NS4yNiAxLjA3NC4wIDEuODQtLjIxOSAyLjI5OS0uNjU2LjQ1OS0uNDM3LjY4NC0xLjA4Ni42ODQtMS45NDh2LS40MzdhOC4wNyA4LjA3LjAgMCAxLTEuMDQ3LjM5N2MtLjQzLjEzLS45My4xOTgtMS40OTIuMTk4LS43MzkuMC0xLjQxLS4xMTYtMi4wMTgtLjM0OWE0LjIwNiA0LjIwNi4wIDAgMS0xLjU2Ny0xLjAyNWMtLjQzMS0uNDUtLjc3NC0xLjAxNy0xLjAxMy0xLjY5NC0uMjQtLjY3Ny0uMzYzLTEuODg1LS4zNjMtMi43NzMuMC0uODM0LjEzLTEuODguMzg0LTIuNTc3LjI2LS42OTYuNjI5LTEuMjk4IDEuMTI5LTEuNzk2LjQ5My0uNDk4IDEuMDk1LS44ODEgMS44LTEuMTYyYTYuNjA1IDYuNjA1LjAgMCAxIDIuNDI4LS40NTdjLjg3LjAgMS42Ny4xMDkgMi40NS4yNC43OC4xMjkgMS40NDQuMjY1IDEuOTg1LjQxNVYxOC4xN3oiIGZpbGw9IiM1NDY4ZmYiLz48cGF0aCBkPSJNNi45NzIgNi42Nzd2MS42MjdjLS43MTItLjQ0Ni0xLjUyLS42Ny0yLjQyNS0uNjctLjU4NS4wLTEuMDQ1LjEzLTEuMzguMzkxYTEuMjQgMS4yNC4wIDAgMC0uNTAyIDEuMDNjMCAuNDI1LjE2NC43NjUuNDk0IDEuMDIuMzMuMjU2LjgzNS41MzIgMS41MTYuODMuNDQ3LjE5Mi43OTUuMzU2IDEuMDQ1LjQ5NS4yNS4xMzguNTM3LjMzMi44NjIuNTgyLjMyNC4yNS41NjMuNTQ4LjcxOC44OTQuMTU0LjM0NS4yMy43NDEuMjMgMS4xODguMC45NDctLjMzNCAxLjY5MS0xLjAwNCAyLjIzNC0uNjcuNTQyLTEuNTM3LjgxNC0yLjYwMS44MTQtMS4xOC4wLTIuMTYtLjIyOS0yLjkzNi0uNjg2di0xLjcwOGMuODQuNjI4IDEuODE0Ljk0MiAyLjkyLjk0Mi41ODUuMCAxLjA0OC0uMTM2IDEuMzg4LS40MDcuMzQtLjI3MS41MS0uNjQ2LjUxLTEuMTI1LjAtLjI4Ny0uMS0uNTUtLjMwMi0uNzktLjIwMy0uMjQtLjQyLS40Mi0uNjU1LS41NDItLjIzNC0uMTIzLS41ODUtLjI5LTEuMDUzLS41MDNhNjEuMjcgNjEuMjcuMCAwIDEtLjU4Mi0uMjcxIDEzLjY3IDEzLjY3LjAgMCAxLS41NS0uMjg3IDQuMjc1IDQuMjc1LjAgMCAxLS41NjctLjM1MSA2LjkyIDYuOTIuMCAwIDEtLjQ1NS0uNGMtLjE4LS4xNy0uMzEtLjM0LS4zOS0uNTEtLjA4LS4xNy0uMTU1LS4zNy0uMjI0LS41OThhMi41NTMgMi41NTMuMCAwIDEtLjEwNC0uNzQyYzAtLjkxNS4zMzMtMS42MzguOTk4LTIuMTcuNjY0LS41MzIgMS41MjMtLjc5OCAyLjU3Ni0uNzk4Ljk2OC4wIDEuNzkzLjE3IDIuNDczLjUxem03LjQ2OCA1LjY5NnYtLjI4N2MtLjAyMi0uNjA3LS4xODctMS4wODgtLjQ5NS0xLjQ0NC0uMzA5LS4zNTctLjc1LS41MzUtMS4zMjQtLjUzNS0uNTMyLjAtLjk5LjE5NC0xLjM3My41ODMtLjM4Mi4zODgtLjYyMi45NDktLjcxNyAxLjY4M2gzLjkwOXptMS4wMDUgMi43OTJ2MS40MDRjLS41OTYuMzQtMS4zODMuNTEtMi4zNjIuNTEtMS4yNTUuMC0yLjI1NS0uMzc3LTMtMS4xMzItLjc0NC0uNzU1LTEuMTE2LTEuNzQ0LTEuMTE2LTIuOTY4LjAtMS4yOTcuMzQtMi4zMTYgMS4wMjEtMy4wNTUuNjgtLjc0IDEuNTQ4LTEuMTEgMi42LTEuMTEgMS4wMzMuMCAxLjg1Mi4zMjMgMi40NTguOTY2LjYwNi42NDQuOTEgMS41NzIuOTEgMi43ODQuMC4zMy0uMDMzLjY3Ni0uMDk2IDEuMDM4aC01LjMxNGMuMTA3LjcwMi40MDUgMS4yMzkuODk0IDEuNjExLjQ5LjM3MiAxLjEwNi41NTggMS44NS41NTguODYyLjAgMS41OC0uMjAyIDIuMTU1LS42MDZ6bTYuNjA1LTEuNzdoLTEuMjEyYy0uNTk2LjAtMS4wNDUuMTE2LTEuMzQ5LjM1LS4zMDMuMjM0LS40NTQuNTMyLS40NTQuODk0LjAuMzcyLjExNy42NjQuMzUuODc3LjIzNS4yMTMuNTc1LjMyIDEuMDIyLjMyLjUxLjAuOTEyLS4xNDIgMS4yMDQtLjQyNC4yOTMtLjI4MS40NC0uNjUxLjQ0LTEuMTA4di0uOTF6bS00LjA2OC0yLjU1NFY5LjMyNWMuNjI3LS4zNjEgMS40NTctLjU0MiAyLjQ4OS0uNTQyIDIuMTE2LjAgMy4xNzUgMS4wMjYgMy4xNzUgMy4wOFYxN2gtMS41NDh2LS45NTdjLS40MTUuNjgtMS4xNDMgMS4wMi0yLjE4NiAxLjAyLS43NjYuMC0xLjM4LS4yMi0xLjg0My0uNjYxLS40NjItLjQ0Mi0uNjk0LTEuMDAzLS42OTQtMS42ODQuMC0uNzc2LjI5My0xLjM4Ljg3OC0xLjgxLjU4NS0uNDMxIDEuNDA0LS42NDcgMi40NTctLjY0N2gxLjM0VjExLjhjMC0uNTU0LS4xMzMtLjk3MS0uMzk5LTEuMjUzLS4yNjYtLjI4Mi0uNzA3LS40MjMtMS4zMjQtLjQyM2E0LjA3IDQuMDcuMCAwIDAtMi4zNDUuNzE4em05LjMzMy0xLjkzdjEuNDJjLjM5NC0xIDEuMTAxLTEuNSAyLjEyMy0xLjUuMTQ4LjAuMzEzLjAxNi40OTQuMDQ4djEuNTMxYTEuODg1IDEuODg1LjAgMCAwLS43NS0uMTQzYy0uNTQyLjAtLjk4OS4yNC0xLjM0LjcxOC0uMzUxLjQ3OS0uNTI3IDEuMDQ4LS41MjcgMS43MDdWMTdoLTEuNTYzVjguOTFoMS41NjN6bTUuMDEgNC4wODRjLjAyMi44Mi4yNzIgMS40OTIuNzUgMi4wMTkuNDc5LjUyNiAxLjE1Ljc5IDIuMDEuNzkuNjM5LjAgMS4yMzUtLjE3NiAxLjc4OC0uNTI3djEuNDA0Yy0uNTIxLjMxOS0xLjE4Ni40NzktMS45OTUuNDc5LTEuMjY1LjAtMi4yNzYtLjQtMy4wMzEtMS4xOTctLjc1NS0uNzk4LTEuMTMzLTEuNzkyLTEuMTMzLTIuOTg0LjAtMS4xNi4zOC0yLjE1MSAxLjE0LTIuOTc1Ljc2MS0uODI1IDEuNzktMS4yMzcgMy4wODgtMS4yMzcuNzAyLjAgMS4zNDYuMTQ5IDEuOTMuNDQ3djEuNDM2YTMuMjQyIDMuMjQyLjAgMCAwLTEuNzctLjQ5NWMtLjg0LjAtMS41MTMuMjY2LTIuMDE5Ljc5OC0uNTA1LjUzMi0uNzU4IDEuMjEzLS43NTggMi4wNDJ6TTQwLjI0IDUuNzJ2NC41NzljLjQ1OC0xIDEuMjkzLTEuNSAyLjUwNS0xLjUuNzg3LjAgMS40Mi4yNDUgMS44OTkuNzM0LjQ3OS40OS43MTggMS4xNy43MTggMi4wNDJWMTdoLTEuNTY0di01LjEwNmMwLS41NTMtLjE0LS45OC0uNDIyLTEuMjg0LS4yODItLjMwMy0uNjUyLS40NTUtMS4xMS0uNDU1LS41MzEuMC0xLjAwMi4yMDItMS40MTEuNjA2LS40MS40MDUtLjYxNSAxLjAyMi0uNjE1IDEuODUxVjE3aC0xLjU2M1Y1LjcyaDEuNTYzem0xNC45NjYgMTAuMDJjLjU5Ni4wIDEuMDk2LS4yNTMgMS41LS43NTguNDA0LS41MDYuNjA2LTEuMTU3LjYwNi0xLjk1NS4wLS45MTUtLjIwMi0xLjYyLS42MDYtMi4xMTQtLjQwNC0uNDk1LS45Mi0uNzQyLTEuNTQ4LS43NDItLjU1My4wLTEuMDUuMjI0LTEuNDkxLjY3LS40NDIuNDQ3LS42NjIgMS4xMzMtLjY2MiAyLjA1OC4wLjk1OC4yMTIgMS42Ny42MzggMi4xMzguNDI1LjQ2OS45NDYuNzAzIDEuNTYzLjcwM3pNNTMuMDA0IDUuNzJ2NC40MmMuNTc0LS44OTQgMS4zODgtMS4zNDEgMi40NC0xLjM0MSAxLjAyMi4wIDEuODU3LjM4MyAyLjUwNiAxLjE0OS42NDkuNzY2Ljk3MyAxLjc4MS45NzMgMy4wNDcuMCAxLjEzOC0uMzA5IDIuMTA5LS45MjUgMi45MTItLjYxNy44MDMtMS40NjMgMS4yMDUtMi41MzcgMS4yMDUtMS4wNzUuMC0xLjg5NC0uNDQ3LTIuNDU3LTEuMzRWMTdoLTEuNThWNS43MmgxLjU4em05LjkwOCAxMS4xMDQtMy4yMjMtNy45MTNoMS43MzlsMS4wMDUgMi42MzIgMS4yNiAzLjQxNWMuMDk2LS4zMi40OC0xLjQ1OCAxLjE1LTMuNDE1bC45MDktMi42MzJoMS42NmwtMi45MiA3Ljg2NmMtLjc3NyAyLjA3NC0xLjk2MyAzLjExLTMuNTU5IDMuMTFhMi45MiAyLjkyLjAgMCAxLS43MzQtLjA3OXYtMS4zNGMuMTcuMDQyLjM1MS4wNjQuNTQzLjA2NCAxLjAzMi4wIDEuNzU1LS41NyAyLjE3LTEuNzA4eiIgZmlsbD0iIzVkNjQ5NCIvPjxwYXRoIGQ9Ik04OS42MzIgNS45Njd2LS43NzJhLjk3OC45NzguMCAwIDAtLjk3OC0uOTc3aC0yLjI4YS45NzguOTc4LjAgMCAwLS45NzguOTc3di43OTNjMCAuMDg4LjA4Mi4xNS4xNzEuMTNhNy4xMjcgNy4xMjcuMCAwIDEgMS45ODQtLjI4Yy42NS4wIDEuMjk1LjA4OCAxLjkxNy4yNTkuMDgyLjAyLjE2NC0uMDQuMTY0LS4xM20tNi4yNDggMS4wMS0uMzktLjM4OWEuOTc3Ljk3Ny4wIDAgMC0xLjM4Mi4wbC0uNDY1LjQ2NWEuOTczLjk3My4wIDAgMCAwIDEuMzhsLjM4My4zODNjLjA2Mi4wNjEuMTUuMDQ3LjIwNS0uMDE0LjIyNi0uMzA3LjQ3Mi0uNjAxLjc0Ni0uODc0LjI4MS0uMjguNTY4LS41MjYuODgzLS43NTEuMDY4LS4wNDIuMDc1LS4xMzcuMDItLjJtNC4xNiAyLjQ1M3YzLjM0MWMwIC4wOTYuMTA0LjE2NS4xOTIuMTE3bDIuOTctMS41MzdjLjA2OC0uMDM0LjA4OS0uMTE3LjA1NS0uMTg0YTMuNjk1IDMuNjk1LjAgMCAwLTMuMDgtMS44NjZjLS4wNjguMC0uMTM2LjA1NC0uMTM2LjEzbTAgOC4wNDhhNC40ODkgNC40ODkuMCAwIDEtNC40OS00LjQ4MiA0LjQ4OCA0LjQ4OC4wIDAgMSA0LjQ5LTQuNDgyIDQuNDg4IDQuNDg4LjAgMCAxIDQuNDg5IDQuNDgyIDQuNDg0IDQuNDg0LjAgMCAxLTQuNDkgNC40ODJtMC0xMC44NWE2LjM2MyA2LjM2My4wIDEgMCAwIDEyLjcyOSA2LjM3IDYuMzcuMCAwIDAgNi4zNzItNi4zNjggNi4zNTggNi4zNTguMCAwIDAtNi4zNzEtNi4zNiIgZmlsbD0iI2ZmZiIvPjwvZz48L3N2Zz4=);background-repeat:no-repeat;background-position:50%;background-size:100%;overflow:hidden;text-indent:-9000px;padding:0!important;width:100%;height:100%;display:block}.overflow-x-scroll{-webkit-overflow-scrolling:touch}.row{-webkit-transition:450ms -webkit-transform;transition:450ms -webkit-transform;transition:450ms transform;transition:450ms transform,450ms -webkit-transform;font-size:0}.tile{-webkit-transition:450ms all;transition:450ms all}.details{background:-webkit-gradient(linear,left bottom,left top,from(rgba(0,0,0,.9)),to(rgba(0,0,0,0)));background:linear-gradient(to top,rgba(0,0,0,.9) 0%,rgba(0,0,0,0) 100%);-webkit-transition:450ms opacity;transition:450ms opacity}.tile:hover .details{opacity:1}.row:hover .tile{opacity:.3}.row:hover .tile:hover{opacity:1}.chroma .lntable pre{padding:0;margin:0;border:0}.chroma .lntable pre code{padding:0;margin:0}pre,.pre{overflow-x:auto;overflow-y:hidden;overflow:scroll}code{padding:.2em;margin:0;font-size:85%;background-color:rgba(27,31,35,.05);border-radius:3px}pre code{display:block;padding:1.5em;font-size:.875rem;line-height:2;overflow-x:auto}pre{background-color:#fff;color:#333;white-space:pre;-webkit-hyphens:none;-ms-hyphens:none;hyphens:none;position:relative;border-width:1px;border-color:#ccc;border-style:solid}.highlight pre{background-color:inherit;color:inherit;padding:.5em;font-size:.875rem}.copy:after{content:"Copy"}.copied:after{content:"Copied"}@media screen and (min-width:60em){.full-width,pre.expand:hover{margin-right:-30vw;max-width:100vw}}.code-block .line-numbers-rows{background:#2f3a46;border:none;bottom:-50px;color:#98a4b3;left:-178px;padding:50px 0;top:-50px;width:138px}.code-block .line-numbers-rows>span:before{color:inherit;padding-right:30px}.tab-button{margin-bottom:1px;position:relative;z-index:1;color:#333;border-color:#ccc;outline:none;background-color:#fff}.tab-pane code{background:#f1f2f2;border-radius:0}.tab-pane .chroma{background:0 0;padding:0}.tab-button.active{border-bottom-color:#f1f2f2;background-color:#f1f2f2}.tab-content .tab-pane{display:none}.tab-content .tab-pane.active{display:block}.tab-content .copy,.tab-content .copied{display:none}.tab-content .tab-pane.active+.copy,.tab-content .tab-pane.active+.copied{display:block}.primary-color{color:#0594cb}.bg-primary-color{background-color:#0594cb}.hover-bg-primary-color:hover{background-color:#0594cb}.primary-color-dark{color:#0a1922}.bg-primary-color-dark{background-color:#0a1922}.hover-bg-primary-color-dark:hover{background-color:#0a1922}.primary-color-light{color:#f9f9f9}.bg-primary-color-light{background-color:#f9f9f9}.hover-bg-primary-color-light:hover{background-color:#f9f9f9}.accent-color{color:#ebb951}.bg-accent-color{background-color:#ebb951}.hover-bg-accent-color:hover{background-color:#ebb951}.accent-color-light{color:#ff4088}.hover-accent-color-light:hover{color:#ff4088}.bg-accent-color-light{background-color:#ff4088}.hover-bg-accent-color-light:hover{background-color:#ff4088}.accent-color-dark{color:#33ba91}.bg-accent-color-dark{background-color:#33ba91}.hover-bg-accent-color-dark:hover{background-color:#33ba91}.text-color-primary{color:#373737}.text-on-primary-color{color:#fff}.text-color-secondary{color:#ccc}.text-color-disabled{color:#f7f7f7}.divider-color{color:#f6f6f6}.warn-color{color:red}.nested-links a{color:#0594cb;text-decoration:none}.column-count-2{-webkit-column-count:1;column-count:1}.column-gap-1{-webkit-column-gap:0;column-gap:0}.break-inside-avoid{-webkit-column-break-inside:auto;break-inside:auto}@media screen and (min-width:60em){.column-count-3-l{-webkit-column-count:3;column-count:3}.column-count-2-l{-webkit-column-count:2;column-count:2}.column-gap-1-l{-webkit-column-gap:1;column-gap:1}.break-inside-avoid-l{-webkit-column-break-inside:avoid;break-inside:avoid}}.prose ul,.prose ol{margin-bottom:2em}.prose ul li,.prose ol li{margin-bottom:.5em}.prose li:hover{background-color:#eee}.prose ::selection{background:#0594cb;color:#fff}body{line-height:1.45}p{margin-bottom:1.3em}h1,h2,h3,h4{margin:1.414em 0 .5em;line-height:1.2}h1{margin-top:0;font-size:2.441em}h2{font-size:1.953em}h3{font-size:1.563em}h4{font-size:1.25em}small,.font_small{font-size:.8em}.prose table{width:100%;margin-bottom:3em;border-collapse:collapse;border-spacing:0;font-size:1em;border:1px solid #eee}.prose table th{background-color:#0594cb;border-bottom:1px solid #0594cb;color:#fff;font-weight:400;text-align:left;padding:.375em .5em}.prose table td,.prose table tc{padding:.75em .5em;text-align:left;border-right:1px solid #eee}.prose table tr:nth-child(even){background-color:#eee}dl dt{font-weight:700;font-size:1.125rem}dd{margin:.5em 0 2em;padding:0}.f2-fluid{font-size:2.25rem}@media screen and (min-width:60em){.f2-fluid{font-size:1.25rem;font-size:calc(0.70833rem + 0.83333vw)}}code,.code,pre code,.highlight pre{font-family:inconsolata,Menlo,Monaco,courier new,monospace}.sans-serif{font-family:muli,avenir,helvetica neue,helvetica,ubuntu,roboto,noto,segoe ui,arial,sans-serif}.serif{font-family:Palatino,palatino linotype,palatino lt std,book antiqua,Georgia,serif}.courier{font-family:courier next,courier,monospace}.helvetica{font-family:helvetica neue,helvetica,sans-serif}.avenir{font-family:avenir next,avenir,sans-serif}.athelas{font-family:athelas,georgia,serif}.georgia{font-family:georgia,serif}.times{font-family:times,serif}.bodoni{font-family:bodoni mt,serif}.calisto{font-family:calisto mt,serif}.garamond{font-family:garamond,serif}.baskerville{font-family:baskerville,serif}.pagination{margin:3rem 0}.pagination li{display:inline-block;margin-right:.375rem;font-size:.875rem;margin-bottom:2.5em}.pagination li a{padding:.5rem .625rem;background-color:#fff;color:#333;border:1px solid #ddd;border-radius:3px;text-decoration:none}.pagination li.disabled{display:none}.pagination li.active a:link,.pagination li.active a:active,.pagination li.active a:visited{background-color:#ddd}#TableOfContents ul li ul li ul li{display:none}#TableOfContents ul li{color:#000;display:block;margin-bottom:.375em;line-height:1.375}#TableOfContents ul li a{width:100%;padding:.25em .375em;margin-left:-.375em}#TableOfContents ul li a:hover{background-color:#999;color:#fff}.no-js .needs-js{opacity:0}.js .needs-js{opacity:1;-webkit-transition:opacity .15s ease-in;transition:opacity .15s ease-in}.facebook,.twitter,.instagram,.youtube{fill:#bababa}.facebook:hover{fill:#3b5998}.twitter{fill:#55acee}.twitter:hover{fill:#bababa}.instagram:hover{fill:#e95950}.youtube:hover{fill:#b00}@media(min-width:75em){[data-scrolldir=down] .sticky{position:fixed;top:100px;right:0}[data-scrolldir=up] .sticky{position:fixed;top:100px;right:0}}.fill-current{fill:currentColor}.chroma{background-color:#fff}.chroma .err{color:#a61717;background-color:#e3d2d2}.chroma .lntd{vertical-align:top;padding:0;margin:0;border:0}.chroma .lntable{border-spacing:0;padding:0;margin:0;border:0;width:auto;overflow:auto;display:block}.chroma .hl{display:block;width:100%;background-color:#ffc}.chroma .lnt{margin-right:.4em;padding:0 .4em}.chroma .ln{margin-right:.4em;padding:0 .4em}.chroma .k{font-weight:700}.chroma .kc{font-weight:700}.chroma .kd{font-weight:700}.chroma .kn{font-weight:700}.chroma .kp{font-weight:700}.chroma .kr{font-weight:700}.chroma .kt{color:#458;font-weight:700}.chroma .na{color:teal}.chroma .nb{color:#999}.chroma .nc{color:#458;font-weight:700}.chroma .no{color:teal}.chroma .ni{color:purple}.chroma .ne{color:#900;font-weight:700}.chroma .nf{color:#900;font-weight:700}.chroma .nn{color:#555}.chroma .nt{color:navy}.chroma .nv{color:teal}.chroma .s{color:#b84}.chroma .sa{color:#b84}.chroma .sb{color:#b84}.chroma .sc{color:#b84}.chroma .dl{color:#b84}.chroma .sd{color:#b84}.chroma .s2{color:#b84}.chroma .se{color:#b84}.chroma .sh{color:#b84}.chroma .si{color:#b84}.chroma .sx{color:#b84}.chroma .sr{color:olive}.chroma .s1{color:#b84}.chroma .ss{color:#b84}.chroma .m{color:#099}.chroma .mb{color:#099}.chroma .mf{color:#099}.chroma .mh{color:#099}.chroma .mi{color:#099}.chroma .il{color:#099}.chroma .mo{color:#099}.chroma .o{font-weight:700}.chroma .ow{font-weight:700}.chroma .c{color:#998;font-style:italic}.chroma .ch{color:#998;font-style:italic}.chroma .cm{color:#998;font-style:italic}.chroma .c1{color:#998;font-style:italic}.chroma .cs{color:#999;font-weight:700;font-style:italic}.chroma .cp{color:#999;font-weight:700}.chroma .cpf{color:#999;font-weight:700}.chroma .gd{color:#000;background-color:#fdd}.chroma .ge{font-style:italic}.chroma .gr{color:#a00}.chroma .gh{color:#999}.chroma .gi{color:#000;background-color:#dfd}.chroma .go{color:#888}.chroma .gp{color:#555}.chroma .gs{font-weight:700}.chroma .gu{color:#aaa}.chroma .gt{color:#a00}.chroma .w{color:#bbb}.nested-blockquote blockquote{border-left:4px solid #0594cb;padding-left:1em}.mw-90{max-width:90%} \ No newline at end of file diff --git a/docs/resources/_gen/assets/css/output/css/app.css_d11fe7b62c27961c87ecd0f2490357b9.json b/docs/resources/_gen/assets/css/output/css/app.css_d11fe7b62c27961c87ecd0f2490357b9.json deleted file mode 100644 index 91f089a79..000000000 --- a/docs/resources/_gen/assets/css/output/css/app.css_d11fe7b62c27961c87ecd0f2490357b9.json +++ /dev/null @@ -1 +0,0 @@ -{"Target":"output/css/app.min.2ac9b5935f7ff7709fe13c2b042a4a2d49fa96fb508e3e8870019ee9b72cf329.css","MediaType":"text/css","Data":{"Integrity":"sha256-Ksm1k19/93Cf4TwrBCpKLUn6lvtQjj6IcAGe6bcs8yk="}} \ No newline at end of file diff --git a/docs/resources/_gen/assets/js/output/js/app.js_8848f55d07695b7ff7188138f23d69e3.content b/docs/resources/_gen/assets/js/output/js/app.js_8848f55d07695b7ff7188138f23d69e3.content deleted file mode 100644 index 3097ec5a6..000000000 --- a/docs/resources/_gen/assets/js/output/js/app.js_8848f55d07695b7ff7188138f23d69e3.content +++ /dev/null @@ -1,18 +0,0 @@ -!function(e){var t={};function n(r){if(t[r])return t[r].exports;var i=t[r]={i:r,l:!1,exports:{}};return e[r].call(i.exports,i,i.exports,n),i.l=!0,i.exports}n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var i in e)n.d(r,i,function(t){return e[t]}.bind(null,i));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=1)}([function(e,t,n){!function(t,n){var r=function(e,t){"use strict";if(!t.getElementsByClassName)return;var n,r,i=t.documentElement,s=e.Date,o=e.HTMLPictureElement,a=e.addEventListener,c=e.setTimeout,u=e.requestAnimationFrame||c,l=e.requestIdleCallback,h=/^picture$/i,d=["load","error","lazyincluded","_lazyloaded"],f={},p=Array.prototype.forEach,g=function(e,t){return f[t]||(f[t]=new RegExp("(\\s|^)"+t+"(\\s|$)")),f[t].test(e.getAttribute("class")||"")&&f[t]},m=function(e,t){g(e,t)||e.setAttribute("class",(e.getAttribute("class")||"").trim()+" "+t)},v=function(e,t){var n;(n=g(e,t))&&e.setAttribute("class",(e.getAttribute("class")||"").replace(n," "))},y=function(e,t,n){var r=n?"addEventListener":"removeEventListener";n&&y(e,t),d.forEach(function(n){e[r](n,t)})},b=function(e,r,i,s,o){var a=t.createEvent("Event");return i||(i={}),i.instance=n,a.initEvent(r,!s,!o),a.detail=i,e.dispatchEvent(a),a},w=function(t,n){var i;!o&&(i=e.picturefill||r.pf)?(n&&n.src&&!t.getAttribute("srcset")&&t.setAttribute("srcset",n.src),i({reevaluate:!0,elements:[t]})):n&&n.src&&(t.src=n.src)},_=function(e,t){return(getComputedStyle(e,null)||{})[t]},E=function(e,t,n){for(n=n||e.offsetWidth;n0)&&"visible"!=_(s,"overflow")&&(r=s.getBoundingClientRect(),o=R>r.left&&kr.top-1&&T500&&i.clientWidth>500?500:370),L=r.expand,I=L*r.expFactor),H2&&f>2&&!t.hidden?(H=I,q=0):H=f>1&&q>1&&B<6?L:0;for(;s=d&&(T=a.top)<=O&&(R=a.right)>=d*D&&(k=a.left)<=A&&(M||R||k||T)&&(r.loadHidden||"hidden"!=_(m[s],"visibility"))&&(u&&B<3&&!p&&(f<3||q<4)||F(m[s],h))){if(X(m[s]),l=!0,B>9)break}else!l&&u&&!c&&B<4&&q<4&&f>2&&(o[0]||r.preloadAfterLoad)&&(o[0]||!p&&(M||R||k||T||"auto"!=m[s].getAttribute(r.sizesAttr)))&&(c=o[0]||m[s]);else X(m[s]);c&&!l&&X(c)}},K=function(e){var t,n=0,i=r.throttleDelay,o=r.ricTimeout,a=function(){t=!1,n=s.now(),e()},u=l&&o>49?function(){l(a,{timeout:o}),o!==r.ricTimeout&&(o=r.ricTimeout)}:S(function(){c(a)},!0);return function(e){var r;(e=!0===e)&&(o=33),t||(t=!0,(r=i-(s.now()-n))<0&&(r=0),e||r<9?u():c(u,r))}}(U),V=function(e){m(e.target,r.loadedClass),v(e.target,r.loadingClass),y(e.target,W),b(e.target,"lazyloaded")},J=S(V),W=function(e){J({target:e.target})},G=function(e){var t,n=e.getAttribute(r.srcsetAttr);(t=r.customMedia[e.getAttribute("data-media")||e.getAttribute("media")])&&e.setAttribute("media",t),n&&e.setAttribute("srcset",n)},Q=S(function(e,t,n,i,s){var o,a,u,l,f,g;(f=b(e,"lazybeforeunveil",t)).defaultPrevented||(i&&(n?m(e,r.autosizesClass):e.setAttribute("sizes",i)),a=e.getAttribute(r.srcsetAttr),o=e.getAttribute(r.srcAttr),s&&(u=e.parentNode,l=u&&h.test(u.nodeName||"")),g=t.firesLoad||"src"in e&&(a||o||l),f={target:e},g&&(y(e,z,!0),clearTimeout(d),d=c(z,2500),m(e,r.loadingClass),y(e,W,!0)),l&&p.call(u.getElementsByTagName("source"),G),a?e.setAttribute("srcset",a):o&&!l&&(j.test(e.nodeName)?function(e,t){try{e.contentWindow.location.replace(t)}catch(n){e.src=t}}(e,o):e.src=o),s&&(a||l)&&w(e,{src:o})),e._lazyRace&&delete e._lazyRace,v(e,r.lazyClass),x(function(){(!g||e.complete&&e.naturalWidth>1)&&(g?z(f):B--,V(f))},!0)}),X=function(e){var t,n=P.test(e.nodeName),i=n&&(e.getAttribute(r.sizesAttr)||e.getAttribute("sizes")),s="auto"==i;(!s&&u||!n||!e.getAttribute("src")&&!e.srcset||e.complete||g(e,r.errorClass)||!g(e,r.lazyClass))&&(t=b(e,"lazyunveilread").detail,s&&N.updateElem(e,!0,e.offsetWidth),e._lazyRace=!0,B++,Q(e,t,s,i,n))},Z=function(){if(!u)if(s.now()-E<999)c(Z,999);else{var e=C(function(){r.loadMode=3,K()});u=!0,r.loadMode=3,K(),a("scroll",function(){3==r.loadMode&&(r.loadMode=2),e()},!0)}};return{_:function(){E=s.now(),n.elements=t.getElementsByClassName(r.lazyClass),o=t.getElementsByClassName(r.lazyClass+" "+r.preloadClass),D=r.hFac,a("scroll",K,!0),a("resize",K,!0),e.MutationObserver?new MutationObserver(K).observe(i,{childList:!0,subtree:!0,attributes:!0}):(i.addEventListener("DOMNodeInserted",K,!0),i.addEventListener("DOMAttrModified",K,!0),setInterval(K,999)),a("hashchange",K,!0),["focus","mouseover","click","load","transitionend","animationend","webkitAnimationEnd"].forEach(function(e){t.addEventListener(e,K,!0)}),/d$|^c/.test(t.readyState)?Z():(a("load",Z),t.addEventListener("DOMContentLoaded",K),c(Z,2e4)),n.elements.length?(U(),x._lsFlush()):K()},checkElems:K,unveil:X}}(),N=function(){var e,n=S(function(e,t,n,r){var i,s,o;if(e._lazysizesWidth=r,r+="px",e.setAttribute("sizes",r),h.test(t.nodeName||""))for(i=t.getElementsByTagName("source"),s=0,o=i.length;s0&&void 0!==arguments[0]?arguments[0]:{};this.action="function"==typeof e.action?e.action:this.defaultAction,this.target="function"==typeof e.target?e.target:this.defaultTarget,this.text="function"==typeof e.text?e.text:this.defaultText,this.container="object"===r(e.container)?e.container:document.body}},{key:"listenClick",value:function(e){var t=this;this.listener=(0,a.default)(e,"click",function(e){return t.onClick(e)})}},{key:"onClick",value:function(e){var t=e.delegateTarget||e.currentTarget;this.clipboardAction&&(this.clipboardAction=null),this.clipboardAction=new s.default({action:this.action(t),target:this.target(t),text:this.text(t),container:this.container,trigger:t,emitter:this})}},{key:"defaultAction",value:function(e){return l("action",e)}},{key:"defaultTarget",value:function(e){var t=l("target",e);if(t)return document.querySelector(t)}},{key:"defaultText",value:function(e){return l("text",e)}},{key:"destroy",value:function(){this.listener.destroy(),this.clipboardAction&&(this.clipboardAction.destroy(),this.clipboardAction=null)}}],[{key:"isSupported",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:["copy","cut"],t="string"==typeof e?[e]:e,n=!!document.queryCommandSupported;return t.forEach(function(e){n=n&&!!document.queryCommandSupported(e)}),n}}]),t}();function l(e,t){var n="data-clipboard-"+e;if(t.hasAttribute(n))return t.getAttribute(n)}e.exports=u},function(e,t,n){"use strict";var r="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},i=function(){function e(e,t){for(var n=0;n0&&void 0!==arguments[0]?arguments[0]:{};this.action=e.action,this.container=e.container,this.emitter=e.emitter,this.target=e.target,this.text=e.text,this.trigger=e.trigger,this.selectedText=""}},{key:"initSelection",value:function(){this.text?this.selectFake():this.target&&this.selectTarget()}},{key:"selectFake",value:function(){var e=this,t="rtl"==document.documentElement.getAttribute("dir");this.removeFake(),this.fakeHandlerCallback=function(){return e.removeFake()},this.fakeHandler=this.container.addEventListener("click",this.fakeHandlerCallback)||!0,this.fakeElem=document.createElement("textarea"),this.fakeElem.style.fontSize="12pt",this.fakeElem.style.border="0",this.fakeElem.style.padding="0",this.fakeElem.style.margin="0",this.fakeElem.style.position="absolute",this.fakeElem.style[t?"right":"left"]="-9999px";var n=window.pageYOffset||document.documentElement.scrollTop;this.fakeElem.style.top=n+"px",this.fakeElem.setAttribute("readonly",""),this.fakeElem.value=this.text,this.container.appendChild(this.fakeElem),this.selectedText=(0,s.default)(this.fakeElem),this.copyText()}},{key:"removeFake",value:function(){this.fakeHandler&&(this.container.removeEventListener("click",this.fakeHandlerCallback),this.fakeHandler=null,this.fakeHandlerCallback=null),this.fakeElem&&(this.container.removeChild(this.fakeElem),this.fakeElem=null)}},{key:"selectTarget",value:function(){this.selectedText=(0,s.default)(this.target),this.copyText()}},{key:"copyText",value:function(){var e=void 0;try{e=document.execCommand(this.action)}catch(t){e=!1}this.handleResult(e)}},{key:"handleResult",value:function(e){this.emitter.emit(e?"success":"error",{action:this.action,text:this.selectedText,trigger:this.trigger,clearSelection:this.clearSelection.bind(this)})}},{key:"clearSelection",value:function(){this.trigger&&this.trigger.focus(),window.getSelection().removeAllRanges()}},{key:"destroy",value:function(){this.removeFake()}},{key:"action",set:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"copy";if(this._action=e,"copy"!==this._action&&"cut"!==this._action)throw new Error('Invalid "action" value, use either "copy" or "cut"')},get:function(){return this._action}},{key:"target",set:function(e){if(void 0!==e){if(!e||"object"!==(void 0===e?"undefined":r(e))||1!==e.nodeType)throw new Error('Invalid "target" value, use a valid Element');if("copy"===this.action&&e.hasAttribute("disabled"))throw new Error('Invalid "target" attribute. Please use "readonly" instead of "disabled" attribute');if("cut"===this.action&&(e.hasAttribute("readonly")||e.hasAttribute("disabled")))throw new Error('Invalid "target" attribute. You can\'t cut text from elements with "readonly" or "disabled" attributes');this._target=e}},get:function(){return this._target}}]),e}();e.exports=o},function(e,t){e.exports=function(e){var t;if("SELECT"===e.nodeName)e.focus(),t=e.value;else if("INPUT"===e.nodeName||"TEXTAREA"===e.nodeName){var n=e.hasAttribute("readonly");n||e.setAttribute("readonly",""),e.select(),e.setSelectionRange(0,e.value.length),n||e.removeAttribute("readonly"),t=e.value}else{e.hasAttribute("contenteditable")&&e.focus();var r=window.getSelection(),i=document.createRange();i.selectNodeContents(e),r.removeAllRanges(),r.addRange(i),t=r.toString()}return t}},function(e,t){function n(){}n.prototype={on:function(e,t,n){var r=this.e||(this.e={});return(r[e]||(r[e]=[])).push({fn:t,ctx:n}),this},once:function(e,t,n){var r=this;function i(){r.off(e,i),t.apply(n,arguments)}return i._=t,this.on(e,i,n)},emit:function(e){for(var t=[].slice.call(arguments,1),n=((this.e||(this.e={}))[e]||[]).slice(),r=0,i=n.length;r0&&n.parentNode.classList.add("expand")}}},function(e,t,n){n(9)({apiKey:"167e7998590aebda7f9fedcf86bc4a55",indexName:"hugodocs",inputSelector:"#search-input",debug:!0})},function(e,t,n){ -/*! docsearch 2.6.1 | © Algolia | github.com/algolia/docsearch */ -!function(t,n){e.exports=n()}("undefined"!=typeof self&&self,function(){return function(e){var t={};function n(r){if(t[r])return t[r].exports;var i=t[r]={i:r,l:!1,exports:{}};return e[r].call(i.exports,i,i.exports,n),i.l=!0,i.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{configurable:!1,enumerable:!0,get:r})},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=22)}([function(e,t,n){"use strict";var r=n(1);function i(e){return e.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,"\\$&")}e.exports={isArray:null,isFunction:null,isObject:null,bind:null,each:null,map:null,mixin:null,isMsie:function(e){if(void 0===e&&(e=navigator.userAgent),/(msie|trident)/i.test(e)){var t=e.match(/(msie |rv:)(\d+(.\d+)?)/i);if(t)return t[2]}return!1},escapeRegExChars:function(e){return e.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,"\\$&")},isNumber:function(e){return"number"==typeof e},toStr:function(e){return void 0===e||null===e?"":e+""},cloneDeep:function(e){var t=this.mixin({},e),n=this;return this.each(t,function(e,r){e&&(n.isArray(e)?t[r]=[].concat(e):n.isObject(e)&&(t[r]=n.cloneDeep(e)))}),t},error:function(e){throw new Error(e)},every:function(e,t){var n=!0;return e?(this.each(e,function(r,i){if(!(n=t.call(null,r,i,e)))return!1}),!!n):n},any:function(e,t){var n=!1;return e?(this.each(e,function(r,i){if(t.call(null,r,i,e))return n=!0,!1}),n):n},getUniqueId:function(){var e=0;return function(){return e++}}(),templatify:function(e){if(this.isFunction(e))return e;var t=r.element(e);return"SCRIPT"===t.prop("tagName")?function(){return t.text()}:function(){return String(e)}},defer:function(e){setTimeout(e,0)},noop:function(){},formatPrefix:function(e,t){return t?"":e+"-"},className:function(e,t,n){return(n?"":".")+e+t},escapeHighlightedString:function(e,t,n){t=t||"";var r=document.createElement("div");r.appendChild(document.createTextNode(t)),n=n||"";var s=document.createElement("div");s.appendChild(document.createTextNode(n));var o=document.createElement("div");return o.appendChild(document.createTextNode(e)),o.innerHTML.replace(RegExp(i(r.innerHTML),"g"),t).replace(RegExp(i(s.innerHTML),"g"),n)}}},function(e,t,n){"use strict";e.exports={element:null}},function(e,t){var n=Object.prototype.hasOwnProperty,r=Object.prototype.toString;e.exports=function(e,t,i){if("[object Function]"!==r.call(t))throw new TypeError("iterator must be a function");var s=e.length;if(s===+s)for(var o=0;o was loaded but did not call our provided callback"),JSONPScriptError:s("JSONPScriptError"," - - - diff --git a/examples/blog/layouts/partials/header.html b/examples/blog/layouts/partials/header.html deleted file mode 100644 index 94de4c123..000000000 --- a/examples/blog/layouts/partials/header.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - {{ partial "meta.html" . }} - - {{ .Title }} - {{ .Site.BaseURL }} - - {{ partial "header.includes.html" . }} - {{ with .OutputFormats.Get "RSS" -}} - {{ printf "" .Permalink .MediaType $.Site.Title | safeHTML }} - {{- end }} - \ No newline at end of file diff --git a/examples/blog/layouts/partials/header.includes.html b/examples/blog/layouts/partials/header.includes.html deleted file mode 100644 index 767e3eee1..000000000 --- a/examples/blog/layouts/partials/header.includes.html +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/examples/blog/layouts/partials/menu.html b/examples/blog/layouts/partials/menu.html deleted file mode 100644 index 61ce0c6b5..000000000 --- a/examples/blog/layouts/partials/menu.html +++ /dev/null @@ -1,15 +0,0 @@ -
    -
    -

    Connect. Socialize.

    -
    -
    - - - -
    - - Hey, listen!
    - You should modify the layouts/partials/menu.html template and include your own profile links. -
    -
    -
    diff --git a/examples/blog/layouts/partials/meta.html b/examples/blog/layouts/partials/meta.html deleted file mode 100644 index 95fd2a711..000000000 --- a/examples/blog/layouts/partials/meta.html +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/examples/blog/layouts/partials/navbar.html b/examples/blog/layouts/partials/navbar.html deleted file mode 100644 index b15c24630..000000000 --- a/examples/blog/layouts/partials/navbar.html +++ /dev/null @@ -1,22 +0,0 @@ - diff --git a/examples/blog/layouts/post/li.html b/examples/blog/layouts/post/li.html deleted file mode 100644 index d57be9c80..000000000 --- a/examples/blog/layouts/post/li.html +++ /dev/null @@ -1,4 +0,0 @@ -
  • -
    {{ .Title}}
    - posted on {{ .Date.Format "January 2, 2006" }}
    -
  • \ No newline at end of file diff --git a/examples/blog/layouts/post/list.html b/examples/blog/layouts/post/list.html deleted file mode 100644 index b3a835ccd..000000000 --- a/examples/blog/layouts/post/list.html +++ /dev/null @@ -1,24 +0,0 @@ -{{ partial "header.html" . }} - -{{ partial "navbar.html" . }} -
    -
    -
    -
    - Blog Post Archive -
      - {{ range .Data.Pages }} - {{ .Render "li" }} - {{ end}} -
    -
    -
    - - -
    - {{ partial "menu.html" . }} -
    -
    -{{ partial "footer.copyright.html" . }} -
    -{{ partial "footer.html" . }} diff --git a/examples/blog/layouts/post/single.html b/examples/blog/layouts/post/single.html deleted file mode 100644 index 4b792ebe2..000000000 --- a/examples/blog/layouts/post/single.html +++ /dev/null @@ -1,35 +0,0 @@ -{{ partial "header.html" . }} - -{{ partial "navbar.html" . }} -
    -
    -
    -
    -

    {{ .Title }}
    {{ .Description }}

    -
    - {{ .Content }} -
    -
    - - -
    -
    -

    {{ .Date.Format "January 2, 2006" }}
    - {{ .WordCount }} words

    -
    - Categories -
      - {{ range .Params.categories }} -
    • {{ . }}
    • - {{ end }} -
    -
    - Tags
    - {{ range .Params.tags }}{{ . }} {{ end }} -
    - {{ partial "menu.html" . }} -
    -
    -{{ partial "footer.copyright.html" . }} -
    -{{ partial "footer.html" . }} diff --git a/examples/blog/layouts/post/summary.html b/examples/blog/layouts/post/summary.html deleted file mode 100644 index f70b6827a..000000000 --- a/examples/blog/layouts/post/summary.html +++ /dev/null @@ -1,9 +0,0 @@ -
    -

    - {{ .Title }} Posted on {{ .Date.Format "Jan 2, 2006" }}
    - {{ .Description }} -

    -
    -

    {{ .Summary }}

    - Read More -
    \ No newline at end of file diff --git a/examples/blog/layouts/tags/list.html b/examples/blog/layouts/tags/list.html deleted file mode 100644 index f59b76715..000000000 --- a/examples/blog/layouts/tags/list.html +++ /dev/null @@ -1,24 +0,0 @@ -{{ partial "header.html" . }} - -{{ partial "navbar.html" . }} -
    -
    -
    -
    - Items with tag {{ .Title | lower }} -
      - {{ range .Data.Pages }} - {{ .Render "li" }} - {{ end}} -
    -
    -
    - - -
    - {{ partial "menu.html" . }} -
    -
    -{{ partial "footer.copyright.html" . }} -
    -{{ partial "footer.html" . }} diff --git a/examples/blog/static/css/bootstrap.min.css b/examples/blog/static/css/bootstrap.min.css deleted file mode 100644 index 70829d0d3..000000000 --- a/examples/blog/static/css/bootstrap.min.css +++ /dev/null @@ -1,11 +0,0 @@ -@import url("https://fonts.googleapis.com/css?family=Open+Sans:300italic,400italic,700italic,400,300,700");/*! - * bootswatch v3.3.6 - * Homepage: http://bootswatch.com - * Copyright 2012-2015 Thomas Park - * Licensed under MIT - * Based on Bootstrap -*//*! - * Bootstrap v3.3.6 (http://getbootstrap.com) - * Copyright 2011-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - *//*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:bold}dfn{font-style:italic}h1{font-size:2em;margin:0.67em 0}mark{background:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-0.5em}sub{bottom:-0.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;height:0}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace, monospace;font-size:1em}button,input,optgroup,select,textarea{color:inherit;font:inherit;margin:0}button{overflow:visible}button,select{text-transform:none}button,html input[type="button"],input[type="reset"],input[type="submit"]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}input{line-height:normal}input[type="checkbox"],input[type="radio"]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;padding:0}input[type="number"]::-webkit-inner-spin-button,input[type="number"]::-webkit-outer-spin-button{height:auto}input[type="search"]{-webkit-appearance:textfield;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box}input[type="search"]::-webkit-search-cancel-button,input[type="search"]::-webkit-search-decoration{-webkit-appearance:none}fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:0.35em 0.625em 0.75em}legend{border:0;padding:0}textarea{overflow:auto}optgroup{font-weight:bold}table{border-collapse:collapse;border-spacing:0}td,th{padding:0}/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */@media print{*,*:before,*:after{background:transparent !important;color:#000 !important;-webkit-box-shadow:none !important;box-shadow:none !important;text-shadow:none !important}a,a:visited{text-decoration:underline}a[href]:after{content:" (" attr(href) ")"}abbr[title]:after{content:" (" attr(title) ")"}a[href^="#"]:after,a[href^="javascript:"]:after{content:""}pre,blockquote{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}tr,img{page-break-inside:avoid}img{max-width:100% !important}p,h2,h3{orphans:3;widows:3}h2,h3{page-break-after:avoid}.navbar{display:none}.btn>.caret,.dropup>.btn>.caret{border-top-color:#000 !important}.label{border:1px solid #000}.table{border-collapse:collapse !important}.table td,.table th{background-color:#fff !important}.table-bordered th,.table-bordered td{border:1px solid #ddd !important}}@font-face{font-family:'Glyphicons Halflings';src:url('../fonts/glyphicons-halflings-regular.eot');src:url('../fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'),url('../fonts/glyphicons-halflings-regular.woff2') format('woff2'),url('../fonts/glyphicons-halflings-regular.woff') format('woff'),url('../fonts/glyphicons-halflings-regular.ttf') format('truetype'),url('../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg')}.glyphicon{position:relative;top:1px;display:inline-block;font-family:'Glyphicons Halflings';font-style:normal;font-weight:normal;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.glyphicon-asterisk:before{content:"\002a"}.glyphicon-plus:before{content:"\002b"}.glyphicon-euro:before,.glyphicon-eur:before{content:"\20ac"}.glyphicon-minus:before{content:"\2212"}.glyphicon-cloud:before{content:"\2601"}.glyphicon-envelope:before{content:"\2709"}.glyphicon-pencil:before{content:"\270f"}.glyphicon-glass:before{content:"\e001"}.glyphicon-music:before{content:"\e002"}.glyphicon-search:before{content:"\e003"}.glyphicon-heart:before{content:"\e005"}.glyphicon-star:before{content:"\e006"}.glyphicon-star-empty:before{content:"\e007"}.glyphicon-user:before{content:"\e008"}.glyphicon-film:before{content:"\e009"}.glyphicon-th-large:before{content:"\e010"}.glyphicon-th:before{content:"\e011"}.glyphicon-th-list:before{content:"\e012"}.glyphicon-ok:before{content:"\e013"}.glyphicon-remove:before{content:"\e014"}.glyphicon-zoom-in:before{content:"\e015"}.glyphicon-zoom-out:before{content:"\e016"}.glyphicon-off:before{content:"\e017"}.glyphicon-signal:before{content:"\e018"}.glyphicon-cog:before{content:"\e019"}.glyphicon-trash:before{content:"\e020"}.glyphicon-home:before{content:"\e021"}.glyphicon-file:before{content:"\e022"}.glyphicon-time:before{content:"\e023"}.glyphicon-road:before{content:"\e024"}.glyphicon-download-alt:before{content:"\e025"}.glyphicon-download:before{content:"\e026"}.glyphicon-upload:before{content:"\e027"}.glyphicon-inbox:before{content:"\e028"}.glyphicon-play-circle:before{content:"\e029"}.glyphicon-repeat:before{content:"\e030"}.glyphicon-refresh:before{content:"\e031"}.glyphicon-list-alt:before{content:"\e032"}.glyphicon-lock:before{content:"\e033"}.glyphicon-flag:before{content:"\e034"}.glyphicon-headphones:before{content:"\e035"}.glyphicon-volume-off:before{content:"\e036"}.glyphicon-volume-down:before{content:"\e037"}.glyphicon-volume-up:before{content:"\e038"}.glyphicon-qrcode:before{content:"\e039"}.glyphicon-barcode:before{content:"\e040"}.glyphicon-tag:before{content:"\e041"}.glyphicon-tags:before{content:"\e042"}.glyphicon-book:before{content:"\e043"}.glyphicon-bookmark:before{content:"\e044"}.glyphicon-print:before{content:"\e045"}.glyphicon-camera:before{content:"\e046"}.glyphicon-font:before{content:"\e047"}.glyphicon-bold:before{content:"\e048"}.glyphicon-italic:before{content:"\e049"}.glyphicon-text-height:before{content:"\e050"}.glyphicon-text-width:before{content:"\e051"}.glyphicon-align-left:before{content:"\e052"}.glyphicon-align-center:before{content:"\e053"}.glyphicon-align-right:before{content:"\e054"}.glyphicon-align-justify:before{content:"\e055"}.glyphicon-list:before{content:"\e056"}.glyphicon-indent-left:before{content:"\e057"}.glyphicon-indent-right:before{content:"\e058"}.glyphicon-facetime-video:before{content:"\e059"}.glyphicon-picture:before{content:"\e060"}.glyphicon-map-marker:before{content:"\e062"}.glyphicon-adjust:before{content:"\e063"}.glyphicon-tint:before{content:"\e064"}.glyphicon-edit:before{content:"\e065"}.glyphicon-share:before{content:"\e066"}.glyphicon-check:before{content:"\e067"}.glyphicon-move:before{content:"\e068"}.glyphicon-step-backward:before{content:"\e069"}.glyphicon-fast-backward:before{content:"\e070"}.glyphicon-backward:before{content:"\e071"}.glyphicon-play:before{content:"\e072"}.glyphicon-pause:before{content:"\e073"}.glyphicon-stop:before{content:"\e074"}.glyphicon-forward:before{content:"\e075"}.glyphicon-fast-forward:before{content:"\e076"}.glyphicon-step-forward:before{content:"\e077"}.glyphicon-eject:before{content:"\e078"}.glyphicon-chevron-left:before{content:"\e079"}.glyphicon-chevron-right:before{content:"\e080"}.glyphicon-plus-sign:before{content:"\e081"}.glyphicon-minus-sign:before{content:"\e082"}.glyphicon-remove-sign:before{content:"\e083"}.glyphicon-ok-sign:before{content:"\e084"}.glyphicon-question-sign:before{content:"\e085"}.glyphicon-info-sign:before{content:"\e086"}.glyphicon-screenshot:before{content:"\e087"}.glyphicon-remove-circle:before{content:"\e088"}.glyphicon-ok-circle:before{content:"\e089"}.glyphicon-ban-circle:before{content:"\e090"}.glyphicon-arrow-left:before{content:"\e091"}.glyphicon-arrow-right:before{content:"\e092"}.glyphicon-arrow-up:before{content:"\e093"}.glyphicon-arrow-down:before{content:"\e094"}.glyphicon-share-alt:before{content:"\e095"}.glyphicon-resize-full:before{content:"\e096"}.glyphicon-resize-small:before{content:"\e097"}.glyphicon-exclamation-sign:before{content:"\e101"}.glyphicon-gift:before{content:"\e102"}.glyphicon-leaf:before{content:"\e103"}.glyphicon-fire:before{content:"\e104"}.glyphicon-eye-open:before{content:"\e105"}.glyphicon-eye-close:before{content:"\e106"}.glyphicon-warning-sign:before{content:"\e107"}.glyphicon-plane:before{content:"\e108"}.glyphicon-calendar:before{content:"\e109"}.glyphicon-random:before{content:"\e110"}.glyphicon-comment:before{content:"\e111"}.glyphicon-magnet:before{content:"\e112"}.glyphicon-chevron-up:before{content:"\e113"}.glyphicon-chevron-down:before{content:"\e114"}.glyphicon-retweet:before{content:"\e115"}.glyphicon-shopping-cart:before{content:"\e116"}.glyphicon-folder-close:before{content:"\e117"}.glyphicon-folder-open:before{content:"\e118"}.glyphicon-resize-vertical:before{content:"\e119"}.glyphicon-resize-horizontal:before{content:"\e120"}.glyphicon-hdd:before{content:"\e121"}.glyphicon-bullhorn:before{content:"\e122"}.glyphicon-bell:before{content:"\e123"}.glyphicon-certificate:before{content:"\e124"}.glyphicon-thumbs-up:before{content:"\e125"}.glyphicon-thumbs-down:before{content:"\e126"}.glyphicon-hand-right:before{content:"\e127"}.glyphicon-hand-left:before{content:"\e128"}.glyphicon-hand-up:before{content:"\e129"}.glyphicon-hand-down:before{content:"\e130"}.glyphicon-circle-arrow-right:before{content:"\e131"}.glyphicon-circle-arrow-left:before{content:"\e132"}.glyphicon-circle-arrow-up:before{content:"\e133"}.glyphicon-circle-arrow-down:before{content:"\e134"}.glyphicon-globe:before{content:"\e135"}.glyphicon-wrench:before{content:"\e136"}.glyphicon-tasks:before{content:"\e137"}.glyphicon-filter:before{content:"\e138"}.glyphicon-briefcase:before{content:"\e139"}.glyphicon-fullscreen:before{content:"\e140"}.glyphicon-dashboard:before{content:"\e141"}.glyphicon-paperclip:before{content:"\e142"}.glyphicon-heart-empty:before{content:"\e143"}.glyphicon-link:before{content:"\e144"}.glyphicon-phone:before{content:"\e145"}.glyphicon-pushpin:before{content:"\e146"}.glyphicon-usd:before{content:"\e148"}.glyphicon-gbp:before{content:"\e149"}.glyphicon-sort:before{content:"\e150"}.glyphicon-sort-by-alphabet:before{content:"\e151"}.glyphicon-sort-by-alphabet-alt:before{content:"\e152"}.glyphicon-sort-by-order:before{content:"\e153"}.glyphicon-sort-by-order-alt:before{content:"\e154"}.glyphicon-sort-by-attributes:before{content:"\e155"}.glyphicon-sort-by-attributes-alt:before{content:"\e156"}.glyphicon-unchecked:before{content:"\e157"}.glyphicon-expand:before{content:"\e158"}.glyphicon-collapse-down:before{content:"\e159"}.glyphicon-collapse-up:before{content:"\e160"}.glyphicon-log-in:before{content:"\e161"}.glyphicon-flash:before{content:"\e162"}.glyphicon-log-out:before{content:"\e163"}.glyphicon-new-window:before{content:"\e164"}.glyphicon-record:before{content:"\e165"}.glyphicon-save:before{content:"\e166"}.glyphicon-open:before{content:"\e167"}.glyphicon-saved:before{content:"\e168"}.glyphicon-import:before{content:"\e169"}.glyphicon-export:before{content:"\e170"}.glyphicon-send:before{content:"\e171"}.glyphicon-floppy-disk:before{content:"\e172"}.glyphicon-floppy-saved:before{content:"\e173"}.glyphicon-floppy-remove:before{content:"\e174"}.glyphicon-floppy-save:before{content:"\e175"}.glyphicon-floppy-open:before{content:"\e176"}.glyphicon-credit-card:before{content:"\e177"}.glyphicon-transfer:before{content:"\e178"}.glyphicon-cutlery:before{content:"\e179"}.glyphicon-header:before{content:"\e180"}.glyphicon-compressed:before{content:"\e181"}.glyphicon-earphone:before{content:"\e182"}.glyphicon-phone-alt:before{content:"\e183"}.glyphicon-tower:before{content:"\e184"}.glyphicon-stats:before{content:"\e185"}.glyphicon-sd-video:before{content:"\e186"}.glyphicon-hd-video:before{content:"\e187"}.glyphicon-subtitles:before{content:"\e188"}.glyphicon-sound-stereo:before{content:"\e189"}.glyphicon-sound-dolby:before{content:"\e190"}.glyphicon-sound-5-1:before{content:"\e191"}.glyphicon-sound-6-1:before{content:"\e192"}.glyphicon-sound-7-1:before{content:"\e193"}.glyphicon-copyright-mark:before{content:"\e194"}.glyphicon-registration-mark:before{content:"\e195"}.glyphicon-cloud-download:before{content:"\e197"}.glyphicon-cloud-upload:before{content:"\e198"}.glyphicon-tree-conifer:before{content:"\e199"}.glyphicon-tree-deciduous:before{content:"\e200"}.glyphicon-cd:before{content:"\e201"}.glyphicon-save-file:before{content:"\e202"}.glyphicon-open-file:before{content:"\e203"}.glyphicon-level-up:before{content:"\e204"}.glyphicon-copy:before{content:"\e205"}.glyphicon-paste:before{content:"\e206"}.glyphicon-alert:before{content:"\e209"}.glyphicon-equalizer:before{content:"\e210"}.glyphicon-king:before{content:"\e211"}.glyphicon-queen:before{content:"\e212"}.glyphicon-pawn:before{content:"\e213"}.glyphicon-bishop:before{content:"\e214"}.glyphicon-knight:before{content:"\e215"}.glyphicon-baby-formula:before{content:"\e216"}.glyphicon-tent:before{content:"\26fa"}.glyphicon-blackboard:before{content:"\e218"}.glyphicon-bed:before{content:"\e219"}.glyphicon-apple:before{content:"\f8ff"}.glyphicon-erase:before{content:"\e221"}.glyphicon-hourglass:before{content:"\231b"}.glyphicon-lamp:before{content:"\e223"}.glyphicon-duplicate:before{content:"\e224"}.glyphicon-piggy-bank:before{content:"\e225"}.glyphicon-scissors:before{content:"\e226"}.glyphicon-bitcoin:before{content:"\e227"}.glyphicon-btc:before{content:"\e227"}.glyphicon-xbt:before{content:"\e227"}.glyphicon-yen:before{content:"\00a5"}.glyphicon-jpy:before{content:"\00a5"}.glyphicon-ruble:before{content:"\20bd"}.glyphicon-rub:before{content:"\20bd"}.glyphicon-scale:before{content:"\e230"}.glyphicon-ice-lolly:before{content:"\e231"}.glyphicon-ice-lolly-tasted:before{content:"\e232"}.glyphicon-education:before{content:"\e233"}.glyphicon-option-horizontal:before{content:"\e234"}.glyphicon-option-vertical:before{content:"\e235"}.glyphicon-menu-hamburger:before{content:"\e236"}.glyphicon-modal-window:before{content:"\e237"}.glyphicon-oil:before{content:"\e238"}.glyphicon-grain:before{content:"\e239"}.glyphicon-sunglasses:before{content:"\e240"}.glyphicon-text-size:before{content:"\e241"}.glyphicon-text-color:before{content:"\e242"}.glyphicon-text-background:before{content:"\e243"}.glyphicon-object-align-top:before{content:"\e244"}.glyphicon-object-align-bottom:before{content:"\e245"}.glyphicon-object-align-horizontal:before{content:"\e246"}.glyphicon-object-align-left:before{content:"\e247"}.glyphicon-object-align-vertical:before{content:"\e248"}.glyphicon-object-align-right:before{content:"\e249"}.glyphicon-triangle-right:before{content:"\e250"}.glyphicon-triangle-left:before{content:"\e251"}.glyphicon-triangle-bottom:before{content:"\e252"}.glyphicon-triangle-top:before{content:"\e253"}.glyphicon-console:before{content:"\e254"}.glyphicon-superscript:before{content:"\e255"}.glyphicon-subscript:before{content:"\e256"}.glyphicon-menu-left:before{content:"\e257"}.glyphicon-menu-right:before{content:"\e258"}.glyphicon-menu-down:before{content:"\e259"}.glyphicon-menu-up:before{content:"\e260"}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}*:before,*:after{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{font-size:10px;-webkit-tap-highlight-color:rgba(0,0,0,0)}body{font-family:"Open Sans","Helvetica Neue",Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#222222;background-color:#ffffff}input,button,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit}a{color:#008cba;text-decoration:none}a:hover,a:focus{color:#008cba;text-decoration:underline}a:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}figure{margin:0}img{vertical-align:middle}.img-responsive,.thumbnail>img,.thumbnail a>img,.carousel-inner>.item>img,.carousel-inner>.item>a>img{display:block;max-width:100%;height:auto}.img-rounded{border-radius:0}.img-thumbnail{padding:4px;line-height:1.4;background-color:#ffffff;border:1px solid #dddddd;border-radius:0;-webkit-transition:all .2s ease-in-out;-o-transition:all .2s ease-in-out;transition:all .2s ease-in-out;display:inline-block;max-width:100%;height:auto}.img-circle{border-radius:50%}hr{margin-top:21px;margin-bottom:21px;border:0;border-top:1px solid #dddddd}.sr-only{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}[role="button"]{cursor:pointer}h1,h2,h3,h4,h5,h6,.h1,.h2,.h3,.h4,.h5,.h6{font-family:"Open Sans","Helvetica Neue",Helvetica,Arial,sans-serif;font-weight:300;line-height:1.1;color:inherit}h1 small,h2 small,h3 small,h4 small,h5 small,h6 small,.h1 small,.h2 small,.h3 small,.h4 small,.h5 small,.h6 small,h1 .small,h2 .small,h3 .small,h4 .small,h5 .small,h6 .small,.h1 .small,.h2 .small,.h3 .small,.h4 .small,.h5 .small,.h6 .small{font-weight:normal;line-height:1;color:#999999}h1,.h1,h2,.h2,h3,.h3{margin-top:21px;margin-bottom:10.5px}h1 small,.h1 small,h2 small,.h2 small,h3 small,.h3 small,h1 .small,.h1 .small,h2 .small,.h2 .small,h3 .small,.h3 .small{font-size:65%}h4,.h4,h5,.h5,h6,.h6{margin-top:10.5px;margin-bottom:10.5px}h4 small,.h4 small,h5 small,.h5 small,h6 small,.h6 small,h4 .small,.h4 .small,h5 .small,.h5 .small,h6 .small,.h6 .small{font-size:75%}h1,.h1{font-size:39px}h2,.h2{font-size:32px}h3,.h3{font-size:26px}h4,.h4{font-size:19px}h5,.h5{font-size:15px}h6,.h6{font-size:13px}p{margin:0 0 10.5px}.lead{margin-bottom:21px;font-size:17px;font-weight:300;line-height:1.4}@media (min-width:768px){.lead{font-size:22.5px}}small,.small{font-size:80%}mark,.mark{background-color:#fcf8e3;padding:.2em}.text-left{text-align:left}.text-right{text-align:right}.text-center{text-align:center}.text-justify{text-align:justify}.text-nowrap{white-space:nowrap}.text-lowercase{text-transform:lowercase}.text-uppercase{text-transform:uppercase}.text-capitalize{text-transform:capitalize}.text-muted{color:#999999}.text-primary{color:#008cba}a.text-primary:hover,a.text-primary:focus{color:#006687}.text-success{color:#43ac6a}a.text-success:hover,a.text-success:focus{color:#358753}.text-info{color:#5bc0de}a.text-info:hover,a.text-info:focus{color:#31b0d5}.text-warning{color:#e99002}a.text-warning:hover,a.text-warning:focus{color:#b67102}.text-danger{color:#f04124}a.text-danger:hover,a.text-danger:focus{color:#d32a0e}.bg-primary{color:#fff;background-color:#008cba}a.bg-primary:hover,a.bg-primary:focus{background-color:#006687}.bg-success{background-color:#dff0d8}a.bg-success:hover,a.bg-success:focus{background-color:#c1e2b3}.bg-info{background-color:#d9edf7}a.bg-info:hover,a.bg-info:focus{background-color:#afd9ee}.bg-warning{background-color:#fcf8e3}a.bg-warning:hover,a.bg-warning:focus{background-color:#f7ecb5}.bg-danger{background-color:#f2dede}a.bg-danger:hover,a.bg-danger:focus{background-color:#e4b9b9}.page-header{padding-bottom:9.5px;margin:42px 0 21px;border-bottom:1px solid #dddddd}ul,ol{margin-top:0;margin-bottom:10.5px}ul ul,ol ul,ul ol,ol ol{margin-bottom:0}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none;margin-left:-5px}.list-inline>li{display:inline-block;padding-left:5px;padding-right:5px}dl{margin-top:0;margin-bottom:21px}dt,dd{line-height:1.4}dt{font-weight:bold}dd{margin-left:0}@media (min-width:768px){.dl-horizontal dt{float:left;width:160px;clear:left;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dl-horizontal dd{margin-left:180px}}abbr[title],abbr[data-original-title]{cursor:help;border-bottom:1px dotted #999999}.initialism{font-size:90%;text-transform:uppercase}blockquote{padding:10.5px 21px;margin:0 0 21px;font-size:18.75px;border-left:5px solid #dddddd}blockquote p:last-child,blockquote ul:last-child,blockquote ol:last-child{margin-bottom:0}blockquote footer,blockquote small,blockquote .small{display:block;font-size:80%;line-height:1.4;color:#6f6f6f}blockquote footer:before,blockquote small:before,blockquote .small:before{content:'\2014 \00A0'}.blockquote-reverse,blockquote.pull-right{padding-right:15px;padding-left:0;border-right:5px solid #dddddd;border-left:0;text-align:right}.blockquote-reverse footer:before,blockquote.pull-right footer:before,.blockquote-reverse small:before,blockquote.pull-right small:before,.blockquote-reverse .small:before,blockquote.pull-right .small:before{content:''}.blockquote-reverse footer:after,blockquote.pull-right footer:after,.blockquote-reverse small:after,blockquote.pull-right small:after,.blockquote-reverse .small:after,blockquote.pull-right .small:after{content:'\00A0 \2014'}address{margin-bottom:21px;font-style:normal;line-height:1.4}code,kbd,pre,samp{font-family:Menlo,Monaco,Consolas,"Courier New",monospace}code{padding:2px 4px;font-size:90%;color:#c7254e;background-color:#f9f2f4;border-radius:0}kbd{padding:2px 4px;font-size:90%;color:#ffffff;background-color:#333333;border-radius:0;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,0.25);box-shadow:inset 0 -1px 0 rgba(0,0,0,0.25)}kbd kbd{padding:0;font-size:100%;font-weight:bold;-webkit-box-shadow:none;box-shadow:none}pre{display:block;padding:10px;margin:0 0 10.5px;font-size:14px;line-height:1.4;word-break:break-all;word-wrap:break-word;color:#333333;background-color:#f5f5f5;border:1px solid #cccccc;border-radius:0}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border-radius:0}.pre-scrollable{max-height:340px;overflow-y:scroll}.container{margin-right:auto;margin-left:auto;padding-left:15px;padding-right:15px}@media (min-width:768px){.container{width:750px}}@media (min-width:992px){.container{width:970px}}@media (min-width:1200px){.container{width:1170px}}.container-fluid{margin-right:auto;margin-left:auto;padding-left:15px;padding-right:15px}.row{margin-left:-15px;margin-right:-15px}.col-xs-1,.col-sm-1,.col-md-1,.col-lg-1,.col-xs-2,.col-sm-2,.col-md-2,.col-lg-2,.col-xs-3,.col-sm-3,.col-md-3,.col-lg-3,.col-xs-4,.col-sm-4,.col-md-4,.col-lg-4,.col-xs-5,.col-sm-5,.col-md-5,.col-lg-5,.col-xs-6,.col-sm-6,.col-md-6,.col-lg-6,.col-xs-7,.col-sm-7,.col-md-7,.col-lg-7,.col-xs-8,.col-sm-8,.col-md-8,.col-lg-8,.col-xs-9,.col-sm-9,.col-md-9,.col-lg-9,.col-xs-10,.col-sm-10,.col-md-10,.col-lg-10,.col-xs-11,.col-sm-11,.col-md-11,.col-lg-11,.col-xs-12,.col-sm-12,.col-md-12,.col-lg-12{position:relative;min-height:1px;padding-left:15px;padding-right:15px}.col-xs-1,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9,.col-xs-10,.col-xs-11,.col-xs-12{float:left}.col-xs-12{width:100%}.col-xs-11{width:91.66666667%}.col-xs-10{width:83.33333333%}.col-xs-9{width:75%}.col-xs-8{width:66.66666667%}.col-xs-7{width:58.33333333%}.col-xs-6{width:50%}.col-xs-5{width:41.66666667%}.col-xs-4{width:33.33333333%}.col-xs-3{width:25%}.col-xs-2{width:16.66666667%}.col-xs-1{width:8.33333333%}.col-xs-pull-12{right:100%}.col-xs-pull-11{right:91.66666667%}.col-xs-pull-10{right:83.33333333%}.col-xs-pull-9{right:75%}.col-xs-pull-8{right:66.66666667%}.col-xs-pull-7{right:58.33333333%}.col-xs-pull-6{right:50%}.col-xs-pull-5{right:41.66666667%}.col-xs-pull-4{right:33.33333333%}.col-xs-pull-3{right:25%}.col-xs-pull-2{right:16.66666667%}.col-xs-pull-1{right:8.33333333%}.col-xs-pull-0{right:auto}.col-xs-push-12{left:100%}.col-xs-push-11{left:91.66666667%}.col-xs-push-10{left:83.33333333%}.col-xs-push-9{left:75%}.col-xs-push-8{left:66.66666667%}.col-xs-push-7{left:58.33333333%}.col-xs-push-6{left:50%}.col-xs-push-5{left:41.66666667%}.col-xs-push-4{left:33.33333333%}.col-xs-push-3{left:25%}.col-xs-push-2{left:16.66666667%}.col-xs-push-1{left:8.33333333%}.col-xs-push-0{left:auto}.col-xs-offset-12{margin-left:100%}.col-xs-offset-11{margin-left:91.66666667%}.col-xs-offset-10{margin-left:83.33333333%}.col-xs-offset-9{margin-left:75%}.col-xs-offset-8{margin-left:66.66666667%}.col-xs-offset-7{margin-left:58.33333333%}.col-xs-offset-6{margin-left:50%}.col-xs-offset-5{margin-left:41.66666667%}.col-xs-offset-4{margin-left:33.33333333%}.col-xs-offset-3{margin-left:25%}.col-xs-offset-2{margin-left:16.66666667%}.col-xs-offset-1{margin-left:8.33333333%}.col-xs-offset-0{margin-left:0%}@media (min-width:768px){.col-sm-1,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-10,.col-sm-11,.col-sm-12{float:left}.col-sm-12{width:100%}.col-sm-11{width:91.66666667%}.col-sm-10{width:83.33333333%}.col-sm-9{width:75%}.col-sm-8{width:66.66666667%}.col-sm-7{width:58.33333333%}.col-sm-6{width:50%}.col-sm-5{width:41.66666667%}.col-sm-4{width:33.33333333%}.col-sm-3{width:25%}.col-sm-2{width:16.66666667%}.col-sm-1{width:8.33333333%}.col-sm-pull-12{right:100%}.col-sm-pull-11{right:91.66666667%}.col-sm-pull-10{right:83.33333333%}.col-sm-pull-9{right:75%}.col-sm-pull-8{right:66.66666667%}.col-sm-pull-7{right:58.33333333%}.col-sm-pull-6{right:50%}.col-sm-pull-5{right:41.66666667%}.col-sm-pull-4{right:33.33333333%}.col-sm-pull-3{right:25%}.col-sm-pull-2{right:16.66666667%}.col-sm-pull-1{right:8.33333333%}.col-sm-pull-0{right:auto}.col-sm-push-12{left:100%}.col-sm-push-11{left:91.66666667%}.col-sm-push-10{left:83.33333333%}.col-sm-push-9{left:75%}.col-sm-push-8{left:66.66666667%}.col-sm-push-7{left:58.33333333%}.col-sm-push-6{left:50%}.col-sm-push-5{left:41.66666667%}.col-sm-push-4{left:33.33333333%}.col-sm-push-3{left:25%}.col-sm-push-2{left:16.66666667%}.col-sm-push-1{left:8.33333333%}.col-sm-push-0{left:auto}.col-sm-offset-12{margin-left:100%}.col-sm-offset-11{margin-left:91.66666667%}.col-sm-offset-10{margin-left:83.33333333%}.col-sm-offset-9{margin-left:75%}.col-sm-offset-8{margin-left:66.66666667%}.col-sm-offset-7{margin-left:58.33333333%}.col-sm-offset-6{margin-left:50%}.col-sm-offset-5{margin-left:41.66666667%}.col-sm-offset-4{margin-left:33.33333333%}.col-sm-offset-3{margin-left:25%}.col-sm-offset-2{margin-left:16.66666667%}.col-sm-offset-1{margin-left:8.33333333%}.col-sm-offset-0{margin-left:0%}}@media (min-width:992px){.col-md-1,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-10,.col-md-11,.col-md-12{float:left}.col-md-12{width:100%}.col-md-11{width:91.66666667%}.col-md-10{width:83.33333333%}.col-md-9{width:75%}.col-md-8{width:66.66666667%}.col-md-7{width:58.33333333%}.col-md-6{width:50%}.col-md-5{width:41.66666667%}.col-md-4{width:33.33333333%}.col-md-3{width:25%}.col-md-2{width:16.66666667%}.col-md-1{width:8.33333333%}.col-md-pull-12{right:100%}.col-md-pull-11{right:91.66666667%}.col-md-pull-10{right:83.33333333%}.col-md-pull-9{right:75%}.col-md-pull-8{right:66.66666667%}.col-md-pull-7{right:58.33333333%}.col-md-pull-6{right:50%}.col-md-pull-5{right:41.66666667%}.col-md-pull-4{right:33.33333333%}.col-md-pull-3{right:25%}.col-md-pull-2{right:16.66666667%}.col-md-pull-1{right:8.33333333%}.col-md-pull-0{right:auto}.col-md-push-12{left:100%}.col-md-push-11{left:91.66666667%}.col-md-push-10{left:83.33333333%}.col-md-push-9{left:75%}.col-md-push-8{left:66.66666667%}.col-md-push-7{left:58.33333333%}.col-md-push-6{left:50%}.col-md-push-5{left:41.66666667%}.col-md-push-4{left:33.33333333%}.col-md-push-3{left:25%}.col-md-push-2{left:16.66666667%}.col-md-push-1{left:8.33333333%}.col-md-push-0{left:auto}.col-md-offset-12{margin-left:100%}.col-md-offset-11{margin-left:91.66666667%}.col-md-offset-10{margin-left:83.33333333%}.col-md-offset-9{margin-left:75%}.col-md-offset-8{margin-left:66.66666667%}.col-md-offset-7{margin-left:58.33333333%}.col-md-offset-6{margin-left:50%}.col-md-offset-5{margin-left:41.66666667%}.col-md-offset-4{margin-left:33.33333333%}.col-md-offset-3{margin-left:25%}.col-md-offset-2{margin-left:16.66666667%}.col-md-offset-1{margin-left:8.33333333%}.col-md-offset-0{margin-left:0%}}@media (min-width:1200px){.col-lg-1,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-10,.col-lg-11,.col-lg-12{float:left}.col-lg-12{width:100%}.col-lg-11{width:91.66666667%}.col-lg-10{width:83.33333333%}.col-lg-9{width:75%}.col-lg-8{width:66.66666667%}.col-lg-7{width:58.33333333%}.col-lg-6{width:50%}.col-lg-5{width:41.66666667%}.col-lg-4{width:33.33333333%}.col-lg-3{width:25%}.col-lg-2{width:16.66666667%}.col-lg-1{width:8.33333333%}.col-lg-pull-12{right:100%}.col-lg-pull-11{right:91.66666667%}.col-lg-pull-10{right:83.33333333%}.col-lg-pull-9{right:75%}.col-lg-pull-8{right:66.66666667%}.col-lg-pull-7{right:58.33333333%}.col-lg-pull-6{right:50%}.col-lg-pull-5{right:41.66666667%}.col-lg-pull-4{right:33.33333333%}.col-lg-pull-3{right:25%}.col-lg-pull-2{right:16.66666667%}.col-lg-pull-1{right:8.33333333%}.col-lg-pull-0{right:auto}.col-lg-push-12{left:100%}.col-lg-push-11{left:91.66666667%}.col-lg-push-10{left:83.33333333%}.col-lg-push-9{left:75%}.col-lg-push-8{left:66.66666667%}.col-lg-push-7{left:58.33333333%}.col-lg-push-6{left:50%}.col-lg-push-5{left:41.66666667%}.col-lg-push-4{left:33.33333333%}.col-lg-push-3{left:25%}.col-lg-push-2{left:16.66666667%}.col-lg-push-1{left:8.33333333%}.col-lg-push-0{left:auto}.col-lg-offset-12{margin-left:100%}.col-lg-offset-11{margin-left:91.66666667%}.col-lg-offset-10{margin-left:83.33333333%}.col-lg-offset-9{margin-left:75%}.col-lg-offset-8{margin-left:66.66666667%}.col-lg-offset-7{margin-left:58.33333333%}.col-lg-offset-6{margin-left:50%}.col-lg-offset-5{margin-left:41.66666667%}.col-lg-offset-4{margin-left:33.33333333%}.col-lg-offset-3{margin-left:25%}.col-lg-offset-2{margin-left:16.66666667%}.col-lg-offset-1{margin-left:8.33333333%}.col-lg-offset-0{margin-left:0%}}table{background-color:transparent}caption{padding-top:8px;padding-bottom:8px;color:#999999;text-align:left}th{text-align:left}.table{width:100%;max-width:100%;margin-bottom:21px}.table>thead>tr>th,.table>tbody>tr>th,.table>tfoot>tr>th,.table>thead>tr>td,.table>tbody>tr>td,.table>tfoot>tr>td{padding:8px;line-height:1.4;vertical-align:top;border-top:1px solid #dddddd}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #dddddd}.table>caption+thead>tr:first-child>th,.table>colgroup+thead>tr:first-child>th,.table>thead:first-child>tr:first-child>th,.table>caption+thead>tr:first-child>td,.table>colgroup+thead>tr:first-child>td,.table>thead:first-child>tr:first-child>td{border-top:0}.table>tbody+tbody{border-top:2px solid #dddddd}.table .table{background-color:#ffffff}.table-condensed>thead>tr>th,.table-condensed>tbody>tr>th,.table-condensed>tfoot>tr>th,.table-condensed>thead>tr>td,.table-condensed>tbody>tr>td,.table-condensed>tfoot>tr>td{padding:5px}.table-bordered{border:1px solid #dddddd}.table-bordered>thead>tr>th,.table-bordered>tbody>tr>th,.table-bordered>tfoot>tr>th,.table-bordered>thead>tr>td,.table-bordered>tbody>tr>td,.table-bordered>tfoot>tr>td{border:1px solid #dddddd}.table-bordered>thead>tr>th,.table-bordered>thead>tr>td{border-bottom-width:2px}.table-striped>tbody>tr:nth-of-type(odd){background-color:#f9f9f9}.table-hover>tbody>tr:hover{background-color:#f5f5f5}table col[class*="col-"]{position:static;float:none;display:table-column}table td[class*="col-"],table th[class*="col-"]{position:static;float:none;display:table-cell}.table>thead>tr>td.active,.table>tbody>tr>td.active,.table>tfoot>tr>td.active,.table>thead>tr>th.active,.table>tbody>tr>th.active,.table>tfoot>tr>th.active,.table>thead>tr.active>td,.table>tbody>tr.active>td,.table>tfoot>tr.active>td,.table>thead>tr.active>th,.table>tbody>tr.active>th,.table>tfoot>tr.active>th{background-color:#f5f5f5}.table-hover>tbody>tr>td.active:hover,.table-hover>tbody>tr>th.active:hover,.table-hover>tbody>tr.active:hover>td,.table-hover>tbody>tr:hover>.active,.table-hover>tbody>tr.active:hover>th{background-color:#e8e8e8}.table>thead>tr>td.success,.table>tbody>tr>td.success,.table>tfoot>tr>td.success,.table>thead>tr>th.success,.table>tbody>tr>th.success,.table>tfoot>tr>th.success,.table>thead>tr.success>td,.table>tbody>tr.success>td,.table>tfoot>tr.success>td,.table>thead>tr.success>th,.table>tbody>tr.success>th,.table>tfoot>tr.success>th{background-color:#dff0d8}.table-hover>tbody>tr>td.success:hover,.table-hover>tbody>tr>th.success:hover,.table-hover>tbody>tr.success:hover>td,.table-hover>tbody>tr:hover>.success,.table-hover>tbody>tr.success:hover>th{background-color:#d0e9c6}.table>thead>tr>td.info,.table>tbody>tr>td.info,.table>tfoot>tr>td.info,.table>thead>tr>th.info,.table>tbody>tr>th.info,.table>tfoot>tr>th.info,.table>thead>tr.info>td,.table>tbody>tr.info>td,.table>tfoot>tr.info>td,.table>thead>tr.info>th,.table>tbody>tr.info>th,.table>tfoot>tr.info>th{background-color:#d9edf7}.table-hover>tbody>tr>td.info:hover,.table-hover>tbody>tr>th.info:hover,.table-hover>tbody>tr.info:hover>td,.table-hover>tbody>tr:hover>.info,.table-hover>tbody>tr.info:hover>th{background-color:#c4e3f3}.table>thead>tr>td.warning,.table>tbody>tr>td.warning,.table>tfoot>tr>td.warning,.table>thead>tr>th.warning,.table>tbody>tr>th.warning,.table>tfoot>tr>th.warning,.table>thead>tr.warning>td,.table>tbody>tr.warning>td,.table>tfoot>tr.warning>td,.table>thead>tr.warning>th,.table>tbody>tr.warning>th,.table>tfoot>tr.warning>th{background-color:#fcf8e3}.table-hover>tbody>tr>td.warning:hover,.table-hover>tbody>tr>th.warning:hover,.table-hover>tbody>tr.warning:hover>td,.table-hover>tbody>tr:hover>.warning,.table-hover>tbody>tr.warning:hover>th{background-color:#faf2cc}.table>thead>tr>td.danger,.table>tbody>tr>td.danger,.table>tfoot>tr>td.danger,.table>thead>tr>th.danger,.table>tbody>tr>th.danger,.table>tfoot>tr>th.danger,.table>thead>tr.danger>td,.table>tbody>tr.danger>td,.table>tfoot>tr.danger>td,.table>thead>tr.danger>th,.table>tbody>tr.danger>th,.table>tfoot>tr.danger>th{background-color:#f2dede}.table-hover>tbody>tr>td.danger:hover,.table-hover>tbody>tr>th.danger:hover,.table-hover>tbody>tr.danger:hover>td,.table-hover>tbody>tr:hover>.danger,.table-hover>tbody>tr.danger:hover>th{background-color:#ebcccc}.table-responsive{overflow-x:auto;min-height:0.01%}@media screen and (max-width:767px){.table-responsive{width:100%;margin-bottom:15.75px;overflow-y:hidden;-ms-overflow-style:-ms-autohiding-scrollbar;border:1px solid #dddddd}.table-responsive>.table{margin-bottom:0}.table-responsive>.table>thead>tr>th,.table-responsive>.table>tbody>tr>th,.table-responsive>.table>tfoot>tr>th,.table-responsive>.table>thead>tr>td,.table-responsive>.table>tbody>tr>td,.table-responsive>.table>tfoot>tr>td{white-space:nowrap}.table-responsive>.table-bordered{border:0}.table-responsive>.table-bordered>thead>tr>th:first-child,.table-responsive>.table-bordered>tbody>tr>th:first-child,.table-responsive>.table-bordered>tfoot>tr>th:first-child,.table-responsive>.table-bordered>thead>tr>td:first-child,.table-responsive>.table-bordered>tbody>tr>td:first-child,.table-responsive>.table-bordered>tfoot>tr>td:first-child{border-left:0}.table-responsive>.table-bordered>thead>tr>th:last-child,.table-responsive>.table-bordered>tbody>tr>th:last-child,.table-responsive>.table-bordered>tfoot>tr>th:last-child,.table-responsive>.table-bordered>thead>tr>td:last-child,.table-responsive>.table-bordered>tbody>tr>td:last-child,.table-responsive>.table-bordered>tfoot>tr>td:last-child{border-right:0}.table-responsive>.table-bordered>tbody>tr:last-child>th,.table-responsive>.table-bordered>tfoot>tr:last-child>th,.table-responsive>.table-bordered>tbody>tr:last-child>td,.table-responsive>.table-bordered>tfoot>tr:last-child>td{border-bottom:0}}fieldset{padding:0;margin:0;border:0;min-width:0}legend{display:block;width:100%;padding:0;margin-bottom:21px;font-size:22.5px;line-height:inherit;color:#333333;border:0;border-bottom:1px solid #e5e5e5}label{display:inline-block;max-width:100%;margin-bottom:5px;font-weight:bold}input[type="search"]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}input[type="radio"],input[type="checkbox"]{margin:4px 0 0;margin-top:1px \9;line-height:normal}input[type="file"]{display:block}input[type="range"]{display:block;width:100%}select[multiple],select[size]{height:auto}input[type="file"]:focus,input[type="radio"]:focus,input[type="checkbox"]:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}output{display:block;padding-top:9px;font-size:15px;line-height:1.4;color:#6f6f6f}.form-control{display:block;width:100%;height:39px;padding:8px 12px;font-size:15px;line-height:1.4;color:#6f6f6f;background-color:#ffffff;background-image:none;border:1px solid #cccccc;border-radius:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);-webkit-transition:border-color ease-in-out .15s,-webkit-box-shadow ease-in-out .15s;-o-transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s;transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s}.form-control:focus{border-color:#66afe9;outline:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 8px rgba(102,175,233,0.6);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 8px rgba(102,175,233,0.6)}.form-control::-moz-placeholder{color:#999999;opacity:1}.form-control:-ms-input-placeholder{color:#999999}.form-control::-webkit-input-placeholder{color:#999999}.form-control::-ms-expand{border:0;background-color:transparent}.form-control[disabled],.form-control[readonly],fieldset[disabled] .form-control{background-color:#eeeeee;opacity:1}.form-control[disabled],fieldset[disabled] .form-control{cursor:not-allowed}textarea.form-control{height:auto}input[type="search"]{-webkit-appearance:none}@media screen and (-webkit-min-device-pixel-ratio:0){input[type="date"].form-control,input[type="time"].form-control,input[type="datetime-local"].form-control,input[type="month"].form-control{line-height:39px}input[type="date"].input-sm,input[type="time"].input-sm,input[type="datetime-local"].input-sm,input[type="month"].input-sm,.input-group-sm input[type="date"],.input-group-sm input[type="time"],.input-group-sm input[type="datetime-local"],.input-group-sm input[type="month"]{line-height:36px}input[type="date"].input-lg,input[type="time"].input-lg,input[type="datetime-local"].input-lg,input[type="month"].input-lg,.input-group-lg input[type="date"],.input-group-lg input[type="time"],.input-group-lg input[type="datetime-local"],.input-group-lg input[type="month"]{line-height:60px}}.form-group{margin-bottom:15px}.radio,.checkbox{position:relative;display:block;margin-top:10px;margin-bottom:10px}.radio label,.checkbox label{min-height:21px;padding-left:20px;margin-bottom:0;font-weight:normal;cursor:pointer}.radio input[type="radio"],.radio-inline input[type="radio"],.checkbox input[type="checkbox"],.checkbox-inline input[type="checkbox"]{position:absolute;margin-left:-20px;margin-top:4px \9}.radio+.radio,.checkbox+.checkbox{margin-top:-5px}.radio-inline,.checkbox-inline{position:relative;display:inline-block;padding-left:20px;margin-bottom:0;vertical-align:middle;font-weight:normal;cursor:pointer}.radio-inline+.radio-inline,.checkbox-inline+.checkbox-inline{margin-top:0;margin-left:10px}input[type="radio"][disabled],input[type="checkbox"][disabled],input[type="radio"].disabled,input[type="checkbox"].disabled,fieldset[disabled] input[type="radio"],fieldset[disabled] input[type="checkbox"]{cursor:not-allowed}.radio-inline.disabled,.checkbox-inline.disabled,fieldset[disabled] .radio-inline,fieldset[disabled] .checkbox-inline{cursor:not-allowed}.radio.disabled label,.checkbox.disabled label,fieldset[disabled] .radio label,fieldset[disabled] .checkbox label{cursor:not-allowed}.form-control-static{padding-top:9px;padding-bottom:9px;margin-bottom:0;min-height:36px}.form-control-static.input-lg,.form-control-static.input-sm{padding-left:0;padding-right:0}.input-sm{height:36px;padding:8px 12px;font-size:12px;line-height:1.5;border-radius:0}select.input-sm{height:36px;line-height:36px}textarea.input-sm,select[multiple].input-sm{height:auto}.form-group-sm .form-control{height:36px;padding:8px 12px;font-size:12px;line-height:1.5;border-radius:0}.form-group-sm select.form-control{height:36px;line-height:36px}.form-group-sm textarea.form-control,.form-group-sm select[multiple].form-control{height:auto}.form-group-sm .form-control-static{height:36px;min-height:33px;padding:9px 12px;font-size:12px;line-height:1.5}.input-lg{height:60px;padding:16px 20px;font-size:19px;line-height:1.3333333;border-radius:0}select.input-lg{height:60px;line-height:60px}textarea.input-lg,select[multiple].input-lg{height:auto}.form-group-lg .form-control{height:60px;padding:16px 20px;font-size:19px;line-height:1.3333333;border-radius:0}.form-group-lg select.form-control{height:60px;line-height:60px}.form-group-lg textarea.form-control,.form-group-lg select[multiple].form-control{height:auto}.form-group-lg .form-control-static{height:60px;min-height:40px;padding:17px 20px;font-size:19px;line-height:1.3333333}.has-feedback{position:relative}.has-feedback .form-control{padding-right:48.75px}.form-control-feedback{position:absolute;top:0;right:0;z-index:2;display:block;width:39px;height:39px;line-height:39px;text-align:center;pointer-events:none}.input-lg+.form-control-feedback,.input-group-lg+.form-control-feedback,.form-group-lg .form-control+.form-control-feedback{width:60px;height:60px;line-height:60px}.input-sm+.form-control-feedback,.input-group-sm+.form-control-feedback,.form-group-sm .form-control+.form-control-feedback{width:36px;height:36px;line-height:36px}.has-success .help-block,.has-success .control-label,.has-success .radio,.has-success .checkbox,.has-success .radio-inline,.has-success .checkbox-inline,.has-success.radio label,.has-success.checkbox label,.has-success.radio-inline label,.has-success.checkbox-inline label{color:#43ac6a}.has-success .form-control{border-color:#43ac6a;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075)}.has-success .form-control:focus{border-color:#358753;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #85d0a1;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #85d0a1}.has-success .input-group-addon{color:#43ac6a;border-color:#43ac6a;background-color:#dff0d8}.has-success .form-control-feedback{color:#43ac6a}.has-warning .help-block,.has-warning .control-label,.has-warning .radio,.has-warning .checkbox,.has-warning .radio-inline,.has-warning .checkbox-inline,.has-warning.radio label,.has-warning.checkbox label,.has-warning.radio-inline label,.has-warning.checkbox-inline label{color:#e99002}.has-warning .form-control{border-color:#e99002;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075)}.has-warning .form-control:focus{border-color:#b67102;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #febc53;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #febc53}.has-warning .input-group-addon{color:#e99002;border-color:#e99002;background-color:#fcf8e3}.has-warning .form-control-feedback{color:#e99002}.has-error .help-block,.has-error .control-label,.has-error .radio,.has-error .checkbox,.has-error .radio-inline,.has-error .checkbox-inline,.has-error.radio label,.has-error.checkbox label,.has-error.radio-inline label,.has-error.checkbox-inline label{color:#f04124}.has-error .form-control{border-color:#f04124;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075)}.has-error .form-control:focus{border-color:#d32a0e;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #f79483;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #f79483}.has-error .input-group-addon{color:#f04124;border-color:#f04124;background-color:#f2dede}.has-error .form-control-feedback{color:#f04124}.has-feedback label~.form-control-feedback{top:26px}.has-feedback label.sr-only~.form-control-feedback{top:0}.help-block{display:block;margin-top:5px;margin-bottom:10px;color:#626262}@media (min-width:768px){.form-inline .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-static{display:inline-block}.form-inline .input-group{display:inline-table;vertical-align:middle}.form-inline .input-group .input-group-addon,.form-inline .input-group .input-group-btn,.form-inline .input-group .form-control{width:auto}.form-inline .input-group>.form-control{width:100%}.form-inline .control-label{margin-bottom:0;vertical-align:middle}.form-inline .radio,.form-inline .checkbox{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.form-inline .radio label,.form-inline .checkbox label{padding-left:0}.form-inline .radio input[type="radio"],.form-inline .checkbox input[type="checkbox"]{position:relative;margin-left:0}.form-inline .has-feedback .form-control-feedback{top:0}}.form-horizontal .radio,.form-horizontal .checkbox,.form-horizontal .radio-inline,.form-horizontal .checkbox-inline{margin-top:0;margin-bottom:0;padding-top:9px}.form-horizontal .radio,.form-horizontal .checkbox{min-height:30px}.form-horizontal .form-group{margin-left:-15px;margin-right:-15px}@media (min-width:768px){.form-horizontal .control-label{text-align:right;margin-bottom:0;padding-top:9px}}.form-horizontal .has-feedback .form-control-feedback{right:15px}@media (min-width:768px){.form-horizontal .form-group-lg .control-label{padding-top:17px;font-size:19px}}@media (min-width:768px){.form-horizontal .form-group-sm .control-label{padding-top:9px;font-size:12px}}.btn{display:inline-block;margin-bottom:0;font-weight:normal;text-align:center;vertical-align:middle;-ms-touch-action:manipulation;touch-action:manipulation;cursor:pointer;background-image:none;border:1px solid transparent;white-space:nowrap;padding:8px 12px;font-size:15px;line-height:1.4;border-radius:0;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.btn:focus,.btn:active:focus,.btn.active:focus,.btn.focus,.btn:active.focus,.btn.active.focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}.btn:hover,.btn:focus,.btn.focus{color:#333333;text-decoration:none}.btn:active,.btn.active{outline:0;background-image:none;-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,0.125);box-shadow:inset 0 3px 5px rgba(0,0,0,0.125)}.btn.disabled,.btn[disabled],fieldset[disabled] .btn{cursor:not-allowed;opacity:0.65;filter:alpha(opacity=65);-webkit-box-shadow:none;box-shadow:none}a.btn.disabled,fieldset[disabled] a.btn{pointer-events:none}.btn-default{color:#333333;background-color:#e7e7e7;border-color:#cccccc}.btn-default:focus,.btn-default.focus{color:#333333;background-color:#cecece;border-color:#8c8c8c}.btn-default:hover{color:#333333;background-color:#cecece;border-color:#adadad}.btn-default:active,.btn-default.active,.open>.dropdown-toggle.btn-default{color:#333333;background-color:#cecece;border-color:#adadad}.btn-default:active:hover,.btn-default.active:hover,.open>.dropdown-toggle.btn-default:hover,.btn-default:active:focus,.btn-default.active:focus,.open>.dropdown-toggle.btn-default:focus,.btn-default:active.focus,.btn-default.active.focus,.open>.dropdown-toggle.btn-default.focus{color:#333333;background-color:#bcbcbc;border-color:#8c8c8c}.btn-default:active,.btn-default.active,.open>.dropdown-toggle.btn-default{background-image:none}.btn-default.disabled:hover,.btn-default[disabled]:hover,fieldset[disabled] .btn-default:hover,.btn-default.disabled:focus,.btn-default[disabled]:focus,fieldset[disabled] .btn-default:focus,.btn-default.disabled.focus,.btn-default[disabled].focus,fieldset[disabled] .btn-default.focus{background-color:#e7e7e7;border-color:#cccccc}.btn-default .badge{color:#e7e7e7;background-color:#333333}.btn-primary{color:#ffffff;background-color:#008cba;border-color:#0079a1}.btn-primary:focus,.btn-primary.focus{color:#ffffff;background-color:#006687;border-color:#001921}.btn-primary:hover{color:#ffffff;background-color:#006687;border-color:#004b63}.btn-primary:active,.btn-primary.active,.open>.dropdown-toggle.btn-primary{color:#ffffff;background-color:#006687;border-color:#004b63}.btn-primary:active:hover,.btn-primary.active:hover,.open>.dropdown-toggle.btn-primary:hover,.btn-primary:active:focus,.btn-primary.active:focus,.open>.dropdown-toggle.btn-primary:focus,.btn-primary:active.focus,.btn-primary.active.focus,.open>.dropdown-toggle.btn-primary.focus{color:#ffffff;background-color:#004b63;border-color:#001921}.btn-primary:active,.btn-primary.active,.open>.dropdown-toggle.btn-primary{background-image:none}.btn-primary.disabled:hover,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary:hover,.btn-primary.disabled:focus,.btn-primary[disabled]:focus,fieldset[disabled] .btn-primary:focus,.btn-primary.disabled.focus,.btn-primary[disabled].focus,fieldset[disabled] .btn-primary.focus{background-color:#008cba;border-color:#0079a1}.btn-primary .badge{color:#008cba;background-color:#ffffff}.btn-success{color:#ffffff;background-color:#43ac6a;border-color:#3c9a5f}.btn-success:focus,.btn-success.focus{color:#ffffff;background-color:#358753;border-color:#183e26}.btn-success:hover{color:#ffffff;background-color:#358753;border-color:#2b6e44}.btn-success:active,.btn-success.active,.open>.dropdown-toggle.btn-success{color:#ffffff;background-color:#358753;border-color:#2b6e44}.btn-success:active:hover,.btn-success.active:hover,.open>.dropdown-toggle.btn-success:hover,.btn-success:active:focus,.btn-success.active:focus,.open>.dropdown-toggle.btn-success:focus,.btn-success:active.focus,.btn-success.active.focus,.open>.dropdown-toggle.btn-success.focus{color:#ffffff;background-color:#2b6e44;border-color:#183e26}.btn-success:active,.btn-success.active,.open>.dropdown-toggle.btn-success{background-image:none}.btn-success.disabled:hover,.btn-success[disabled]:hover,fieldset[disabled] .btn-success:hover,.btn-success.disabled:focus,.btn-success[disabled]:focus,fieldset[disabled] .btn-success:focus,.btn-success.disabled.focus,.btn-success[disabled].focus,fieldset[disabled] .btn-success.focus{background-color:#43ac6a;border-color:#3c9a5f}.btn-success .badge{color:#43ac6a;background-color:#ffffff}.btn-info{color:#ffffff;background-color:#5bc0de;border-color:#46b8da}.btn-info:focus,.btn-info.focus{color:#ffffff;background-color:#31b0d5;border-color:#1b6d85}.btn-info:hover{color:#ffffff;background-color:#31b0d5;border-color:#269abc}.btn-info:active,.btn-info.active,.open>.dropdown-toggle.btn-info{color:#ffffff;background-color:#31b0d5;border-color:#269abc}.btn-info:active:hover,.btn-info.active:hover,.open>.dropdown-toggle.btn-info:hover,.btn-info:active:focus,.btn-info.active:focus,.open>.dropdown-toggle.btn-info:focus,.btn-info:active.focus,.btn-info.active.focus,.open>.dropdown-toggle.btn-info.focus{color:#ffffff;background-color:#269abc;border-color:#1b6d85}.btn-info:active,.btn-info.active,.open>.dropdown-toggle.btn-info{background-image:none}.btn-info.disabled:hover,.btn-info[disabled]:hover,fieldset[disabled] .btn-info:hover,.btn-info.disabled:focus,.btn-info[disabled]:focus,fieldset[disabled] .btn-info:focus,.btn-info.disabled.focus,.btn-info[disabled].focus,fieldset[disabled] .btn-info.focus{background-color:#5bc0de;border-color:#46b8da}.btn-info .badge{color:#5bc0de;background-color:#ffffff}.btn-warning{color:#ffffff;background-color:#e99002;border-color:#d08002}.btn-warning:focus,.btn-warning.focus{color:#ffffff;background-color:#b67102;border-color:#513201}.btn-warning:hover{color:#ffffff;background-color:#b67102;border-color:#935b01}.btn-warning:active,.btn-warning.active,.open>.dropdown-toggle.btn-warning{color:#ffffff;background-color:#b67102;border-color:#935b01}.btn-warning:active:hover,.btn-warning.active:hover,.open>.dropdown-toggle.btn-warning:hover,.btn-warning:active:focus,.btn-warning.active:focus,.open>.dropdown-toggle.btn-warning:focus,.btn-warning:active.focus,.btn-warning.active.focus,.open>.dropdown-toggle.btn-warning.focus{color:#ffffff;background-color:#935b01;border-color:#513201}.btn-warning:active,.btn-warning.active,.open>.dropdown-toggle.btn-warning{background-image:none}.btn-warning.disabled:hover,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning:hover,.btn-warning.disabled:focus,.btn-warning[disabled]:focus,fieldset[disabled] .btn-warning:focus,.btn-warning.disabled.focus,.btn-warning[disabled].focus,fieldset[disabled] .btn-warning.focus{background-color:#e99002;border-color:#d08002}.btn-warning .badge{color:#e99002;background-color:#ffffff}.btn-danger{color:#ffffff;background-color:#f04124;border-color:#ea2f10}.btn-danger:focus,.btn-danger.focus{color:#ffffff;background-color:#d32a0e;border-color:#731708}.btn-danger:hover{color:#ffffff;background-color:#d32a0e;border-color:#b1240c}.btn-danger:active,.btn-danger.active,.open>.dropdown-toggle.btn-danger{color:#ffffff;background-color:#d32a0e;border-color:#b1240c}.btn-danger:active:hover,.btn-danger.active:hover,.open>.dropdown-toggle.btn-danger:hover,.btn-danger:active:focus,.btn-danger.active:focus,.open>.dropdown-toggle.btn-danger:focus,.btn-danger:active.focus,.btn-danger.active.focus,.open>.dropdown-toggle.btn-danger.focus{color:#ffffff;background-color:#b1240c;border-color:#731708}.btn-danger:active,.btn-danger.active,.open>.dropdown-toggle.btn-danger{background-image:none}.btn-danger.disabled:hover,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger:hover,.btn-danger.disabled:focus,.btn-danger[disabled]:focus,fieldset[disabled] .btn-danger:focus,.btn-danger.disabled.focus,.btn-danger[disabled].focus,fieldset[disabled] .btn-danger.focus{background-color:#f04124;border-color:#ea2f10}.btn-danger .badge{color:#f04124;background-color:#ffffff}.btn-link{color:#008cba;font-weight:normal;border-radius:0}.btn-link,.btn-link:active,.btn-link.active,.btn-link[disabled],fieldset[disabled] .btn-link{background-color:transparent;-webkit-box-shadow:none;box-shadow:none}.btn-link,.btn-link:hover,.btn-link:focus,.btn-link:active{border-color:transparent}.btn-link:hover,.btn-link:focus{color:#008cba;text-decoration:underline;background-color:transparent}.btn-link[disabled]:hover,fieldset[disabled] .btn-link:hover,.btn-link[disabled]:focus,fieldset[disabled] .btn-link:focus{color:#999999;text-decoration:none}.btn-lg,.btn-group-lg>.btn{padding:16px 20px;font-size:19px;line-height:1.3333333;border-radius:0}.btn-sm,.btn-group-sm>.btn{padding:8px 12px;font-size:12px;line-height:1.5;border-radius:0}.btn-xs,.btn-group-xs>.btn{padding:4px 6px;font-size:12px;line-height:1.5;border-radius:0}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:5px}input[type="submit"].btn-block,input[type="reset"].btn-block,input[type="button"].btn-block{width:100%}.fade{opacity:0;-webkit-transition:opacity 0.15s linear;-o-transition:opacity 0.15s linear;transition:opacity 0.15s linear}.fade.in{opacity:1}.collapse{display:none}.collapse.in{display:block}tr.collapse.in{display:table-row}tbody.collapse.in{display:table-row-group}.collapsing{position:relative;height:0;overflow:hidden;-webkit-transition-property:height, visibility;-o-transition-property:height, visibility;transition-property:height, visibility;-webkit-transition-duration:0.35s;-o-transition-duration:0.35s;transition-duration:0.35s;-webkit-transition-timing-function:ease;-o-transition-timing-function:ease;transition-timing-function:ease}.caret{display:inline-block;width:0;height:0;margin-left:2px;vertical-align:middle;border-top:4px dashed;border-top:4px solid \9;border-right:4px solid transparent;border-left:4px solid transparent}.dropup,.dropdown{position:relative}.dropdown-toggle:focus{outline:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:160px;padding:5px 0;margin:2px 0 0;list-style:none;font-size:15px;text-align:left;background-color:#ffffff;border:1px solid #cccccc;border:1px solid rgba(0,0,0,0.15);border-radius:0;-webkit-box-shadow:0 6px 12px rgba(0,0,0,0.175);box-shadow:0 6px 12px rgba(0,0,0,0.175);-webkit-background-clip:padding-box;background-clip:padding-box}.dropdown-menu.pull-right{right:0;left:auto}.dropdown-menu .divider{height:1px;margin:9.5px 0;overflow:hidden;background-color:rgba(0,0,0,0.2)}.dropdown-menu>li>a{display:block;padding:3px 20px;clear:both;font-weight:normal;line-height:1.4;color:#555555;white-space:nowrap}.dropdown-menu>li>a:hover,.dropdown-menu>li>a:focus{text-decoration:none;color:#262626;background-color:#eeeeee}.dropdown-menu>.active>a,.dropdown-menu>.active>a:hover,.dropdown-menu>.active>a:focus{color:#ffffff;text-decoration:none;outline:0;background-color:#008cba}.dropdown-menu>.disabled>a,.dropdown-menu>.disabled>a:hover,.dropdown-menu>.disabled>a:focus{color:#999999}.dropdown-menu>.disabled>a:hover,.dropdown-menu>.disabled>a:focus{text-decoration:none;background-color:transparent;background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);cursor:not-allowed}.open>.dropdown-menu{display:block}.open>a{outline:0}.dropdown-menu-right{left:auto;right:0}.dropdown-menu-left{left:0;right:auto}.dropdown-header{display:block;padding:3px 20px;font-size:12px;line-height:1.4;color:#999999;white-space:nowrap}.dropdown-backdrop{position:fixed;left:0;right:0;bottom:0;top:0;z-index:990}.pull-right>.dropdown-menu{right:0;left:auto}.dropup .caret,.navbar-fixed-bottom .dropdown .caret{border-top:0;border-bottom:4px dashed;border-bottom:4px solid \9;content:""}.dropup .dropdown-menu,.navbar-fixed-bottom .dropdown .dropdown-menu{top:auto;bottom:100%;margin-bottom:2px}@media (min-width:768px){.navbar-right .dropdown-menu{left:auto;right:0}.navbar-right .dropdown-menu-left{left:0;right:auto}}.btn-group,.btn-group-vertical{position:relative;display:inline-block;vertical-align:middle}.btn-group>.btn,.btn-group-vertical>.btn{position:relative;float:left}.btn-group>.btn:hover,.btn-group-vertical>.btn:hover,.btn-group>.btn:focus,.btn-group-vertical>.btn:focus,.btn-group>.btn:active,.btn-group-vertical>.btn:active,.btn-group>.btn.active,.btn-group-vertical>.btn.active{z-index:2}.btn-group .btn+.btn,.btn-group .btn+.btn-group,.btn-group .btn-group+.btn,.btn-group .btn-group+.btn-group{margin-left:-1px}.btn-toolbar{margin-left:-5px}.btn-toolbar .btn,.btn-toolbar .btn-group,.btn-toolbar .input-group{float:left}.btn-toolbar>.btn,.btn-toolbar>.btn-group,.btn-toolbar>.input-group{margin-left:5px}.btn-group>.btn:not(:first-child):not(:last-child):not(.dropdown-toggle){border-radius:0}.btn-group>.btn:first-child{margin-left:0}.btn-group>.btn:first-child:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-top-right-radius:0}.btn-group>.btn:last-child:not(:first-child),.btn-group>.dropdown-toggle:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0}.btn-group>.btn-group{float:left}.btn-group>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-bottom-right-radius:0;border-top-right-radius:0}.btn-group>.btn-group:last-child:not(:first-child)>.btn:first-child{border-bottom-left-radius:0;border-top-left-radius:0}.btn-group .dropdown-toggle:active,.btn-group.open .dropdown-toggle{outline:0}.btn-group>.btn+.dropdown-toggle{padding-left:8px;padding-right:8px}.btn-group>.btn-lg+.dropdown-toggle{padding-left:12px;padding-right:12px}.btn-group.open .dropdown-toggle{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,0.125);box-shadow:inset 0 3px 5px rgba(0,0,0,0.125)}.btn-group.open .dropdown-toggle.btn-link{-webkit-box-shadow:none;box-shadow:none}.btn .caret{margin-left:0}.btn-lg .caret{border-width:5px 5px 0;border-bottom-width:0}.dropup .btn-lg .caret{border-width:0 5px 5px}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group,.btn-group-vertical>.btn-group>.btn{display:block;float:none;width:100%;max-width:100%}.btn-group-vertical>.btn-group>.btn{float:none}.btn-group-vertical>.btn+.btn,.btn-group-vertical>.btn+.btn-group,.btn-group-vertical>.btn-group+.btn,.btn-group-vertical>.btn-group+.btn-group{margin-top:-1px;margin-left:0}.btn-group-vertical>.btn:not(:first-child):not(:last-child){border-radius:0}.btn-group-vertical>.btn:first-child:not(:last-child){border-top-right-radius:0;border-top-left-radius:0;border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn:last-child:not(:first-child){border-top-right-radius:0;border-top-left-radius:0;border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group-vertical>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group-vertical>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-right-radius:0;border-top-left-radius:0}.btn-group-justified{display:table;width:100%;table-layout:fixed;border-collapse:separate}.btn-group-justified>.btn,.btn-group-justified>.btn-group{float:none;display:table-cell;width:1%}.btn-group-justified>.btn-group .btn{width:100%}.btn-group-justified>.btn-group .dropdown-menu{left:auto}[data-toggle="buttons"]>.btn input[type="radio"],[data-toggle="buttons"]>.btn-group>.btn input[type="radio"],[data-toggle="buttons"]>.btn input[type="checkbox"],[data-toggle="buttons"]>.btn-group>.btn input[type="checkbox"]{position:absolute;clip:rect(0, 0, 0, 0);pointer-events:none}.input-group{position:relative;display:table;border-collapse:separate}.input-group[class*="col-"]{float:none;padding-left:0;padding-right:0}.input-group .form-control{position:relative;z-index:2;float:left;width:100%;margin-bottom:0}.input-group .form-control:focus{z-index:3}.input-group-lg>.form-control,.input-group-lg>.input-group-addon,.input-group-lg>.input-group-btn>.btn{height:60px;padding:16px 20px;font-size:19px;line-height:1.3333333;border-radius:0}select.input-group-lg>.form-control,select.input-group-lg>.input-group-addon,select.input-group-lg>.input-group-btn>.btn{height:60px;line-height:60px}textarea.input-group-lg>.form-control,textarea.input-group-lg>.input-group-addon,textarea.input-group-lg>.input-group-btn>.btn,select[multiple].input-group-lg>.form-control,select[multiple].input-group-lg>.input-group-addon,select[multiple].input-group-lg>.input-group-btn>.btn{height:auto}.input-group-sm>.form-control,.input-group-sm>.input-group-addon,.input-group-sm>.input-group-btn>.btn{height:36px;padding:8px 12px;font-size:12px;line-height:1.5;border-radius:0}select.input-group-sm>.form-control,select.input-group-sm>.input-group-addon,select.input-group-sm>.input-group-btn>.btn{height:36px;line-height:36px}textarea.input-group-sm>.form-control,textarea.input-group-sm>.input-group-addon,textarea.input-group-sm>.input-group-btn>.btn,select[multiple].input-group-sm>.form-control,select[multiple].input-group-sm>.input-group-addon,select[multiple].input-group-sm>.input-group-btn>.btn{height:auto}.input-group-addon,.input-group-btn,.input-group .form-control{display:table-cell}.input-group-addon:not(:first-child):not(:last-child),.input-group-btn:not(:first-child):not(:last-child),.input-group .form-control:not(:first-child):not(:last-child){border-radius:0}.input-group-addon,.input-group-btn{width:1%;white-space:nowrap;vertical-align:middle}.input-group-addon{padding:8px 12px;font-size:15px;font-weight:normal;line-height:1;color:#6f6f6f;text-align:center;background-color:#eeeeee;border:1px solid #cccccc;border-radius:0}.input-group-addon.input-sm{padding:8px 12px;font-size:12px;border-radius:0}.input-group-addon.input-lg{padding:16px 20px;font-size:19px;border-radius:0}.input-group-addon input[type="radio"],.input-group-addon input[type="checkbox"]{margin-top:0}.input-group .form-control:first-child,.input-group-addon:first-child,.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group>.btn,.input-group-btn:first-child>.dropdown-toggle,.input-group-btn:last-child>.btn:not(:last-child):not(.dropdown-toggle),.input-group-btn:last-child>.btn-group:not(:last-child)>.btn{border-bottom-right-radius:0;border-top-right-radius:0}.input-group-addon:first-child{border-right:0}.input-group .form-control:last-child,.input-group-addon:last-child,.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group>.btn,.input-group-btn:last-child>.dropdown-toggle,.input-group-btn:first-child>.btn:not(:first-child),.input-group-btn:first-child>.btn-group:not(:first-child)>.btn{border-bottom-left-radius:0;border-top-left-radius:0}.input-group-addon:last-child{border-left:0}.input-group-btn{position:relative;font-size:0;white-space:nowrap}.input-group-btn>.btn{position:relative}.input-group-btn>.btn+.btn{margin-left:-1px}.input-group-btn>.btn:hover,.input-group-btn>.btn:focus,.input-group-btn>.btn:active{z-index:2}.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group{margin-right:-1px}.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group{z-index:2;margin-left:-1px}.nav{margin-bottom:0;padding-left:0;list-style:none}.nav>li{position:relative;display:block}.nav>li>a{position:relative;display:block;padding:10px 15px}.nav>li>a:hover,.nav>li>a:focus{text-decoration:none;background-color:#eeeeee}.nav>li.disabled>a{color:#999999}.nav>li.disabled>a:hover,.nav>li.disabled>a:focus{color:#999999;text-decoration:none;background-color:transparent;cursor:not-allowed}.nav .open>a,.nav .open>a:hover,.nav .open>a:focus{background-color:#eeeeee;border-color:#008cba}.nav .nav-divider{height:1px;margin:9.5px 0;overflow:hidden;background-color:#e5e5e5}.nav>li>a>img{max-width:none}.nav-tabs{border-bottom:1px solid #dddddd}.nav-tabs>li{float:left;margin-bottom:-1px}.nav-tabs>li>a{margin-right:2px;line-height:1.4;border:1px solid transparent;border-radius:0 0 0 0}.nav-tabs>li>a:hover{border-color:#eeeeee #eeeeee #dddddd}.nav-tabs>li.active>a,.nav-tabs>li.active>a:hover,.nav-tabs>li.active>a:focus{color:#6f6f6f;background-color:#ffffff;border:1px solid #dddddd;border-bottom-color:transparent;cursor:default}.nav-tabs.nav-justified{width:100%;border-bottom:0}.nav-tabs.nav-justified>li{float:none}.nav-tabs.nav-justified>li>a{text-align:center;margin-bottom:5px}.nav-tabs.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-tabs.nav-justified>li{display:table-cell;width:1%}.nav-tabs.nav-justified>li>a{margin-bottom:0}}.nav-tabs.nav-justified>li>a{margin-right:0;border-radius:0}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:hover,.nav-tabs.nav-justified>.active>a:focus{border:1px solid #dddddd}@media (min-width:768px){.nav-tabs.nav-justified>li>a{border-bottom:1px solid #dddddd;border-radius:0 0 0 0}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:hover,.nav-tabs.nav-justified>.active>a:focus{border-bottom-color:#ffffff}}.nav-pills>li{float:left}.nav-pills>li>a{border-radius:0}.nav-pills>li+li{margin-left:2px}.nav-pills>li.active>a,.nav-pills>li.active>a:hover,.nav-pills>li.active>a:focus{color:#ffffff;background-color:#008cba}.nav-stacked>li{float:none}.nav-stacked>li+li{margin-top:2px;margin-left:0}.nav-justified{width:100%}.nav-justified>li{float:none}.nav-justified>li>a{text-align:center;margin-bottom:5px}.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-justified>li{display:table-cell;width:1%}.nav-justified>li>a{margin-bottom:0}}.nav-tabs-justified{border-bottom:0}.nav-tabs-justified>li>a{margin-right:0;border-radius:0}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:hover,.nav-tabs-justified>.active>a:focus{border:1px solid #dddddd}@media (min-width:768px){.nav-tabs-justified>li>a{border-bottom:1px solid #dddddd;border-radius:0 0 0 0}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:hover,.nav-tabs-justified>.active>a:focus{border-bottom-color:#ffffff}}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-right-radius:0;border-top-left-radius:0}.navbar{position:relative;min-height:45px;margin-bottom:21px;border:1px solid transparent}@media (min-width:768px){.navbar{border-radius:0}}@media (min-width:768px){.navbar-header{float:left}}.navbar-collapse{overflow-x:visible;padding-right:15px;padding-left:15px;border-top:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,0.1);box-shadow:inset 0 1px 0 rgba(255,255,255,0.1);-webkit-overflow-scrolling:touch}.navbar-collapse.in{overflow-y:auto}@media (min-width:768px){.navbar-collapse{width:auto;border-top:0;-webkit-box-shadow:none;box-shadow:none}.navbar-collapse.collapse{display:block !important;height:auto !important;padding-bottom:0;overflow:visible !important}.navbar-collapse.in{overflow-y:visible}.navbar-fixed-top .navbar-collapse,.navbar-static-top .navbar-collapse,.navbar-fixed-bottom .navbar-collapse{padding-left:0;padding-right:0}}.navbar-fixed-top .navbar-collapse,.navbar-fixed-bottom .navbar-collapse{max-height:340px}@media (max-device-width:480px) and (orientation:landscape){.navbar-fixed-top .navbar-collapse,.navbar-fixed-bottom .navbar-collapse{max-height:200px}}.container>.navbar-header,.container-fluid>.navbar-header,.container>.navbar-collapse,.container-fluid>.navbar-collapse{margin-right:-15px;margin-left:-15px}@media (min-width:768px){.container>.navbar-header,.container-fluid>.navbar-header,.container>.navbar-collapse,.container-fluid>.navbar-collapse{margin-right:0;margin-left:0}}.navbar-static-top{z-index:1000;border-width:0 0 1px}@media (min-width:768px){.navbar-static-top{border-radius:0}}.navbar-fixed-top,.navbar-fixed-bottom{position:fixed;right:0;left:0;z-index:1030}@media (min-width:768px){.navbar-fixed-top,.navbar-fixed-bottom{border-radius:0}}.navbar-fixed-top{top:0;border-width:0 0 1px}.navbar-fixed-bottom{bottom:0;margin-bottom:0;border-width:1px 0 0}.navbar-brand{float:left;padding:12px 15px;font-size:19px;line-height:21px;height:45px}.navbar-brand:hover,.navbar-brand:focus{text-decoration:none}.navbar-brand>img{display:block}@media (min-width:768px){.navbar>.container .navbar-brand,.navbar>.container-fluid .navbar-brand{margin-left:-15px}}.navbar-toggle{position:relative;float:right;margin-right:15px;padding:9px 10px;margin-top:5.5px;margin-bottom:5.5px;background-color:transparent;background-image:none;border:1px solid transparent;border-radius:0}.navbar-toggle:focus{outline:0}.navbar-toggle .icon-bar{display:block;width:22px;height:2px;border-radius:1px}.navbar-toggle .icon-bar+.icon-bar{margin-top:4px}@media (min-width:768px){.navbar-toggle{display:none}}.navbar-nav{margin:6px -15px}.navbar-nav>li>a{padding-top:10px;padding-bottom:10px;line-height:21px}@media (max-width:767px){.navbar-nav .open .dropdown-menu{position:static;float:none;width:auto;margin-top:0;background-color:transparent;border:0;-webkit-box-shadow:none;box-shadow:none}.navbar-nav .open .dropdown-menu>li>a,.navbar-nav .open .dropdown-menu .dropdown-header{padding:5px 15px 5px 25px}.navbar-nav .open .dropdown-menu>li>a{line-height:21px}.navbar-nav .open .dropdown-menu>li>a:hover,.navbar-nav .open .dropdown-menu>li>a:focus{background-image:none}}@media (min-width:768px){.navbar-nav{float:left;margin:0}.navbar-nav>li{float:left}.navbar-nav>li>a{padding-top:12px;padding-bottom:12px}}.navbar-form{margin-left:-15px;margin-right:-15px;padding:10px 15px;border-top:1px solid transparent;border-bottom:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,0.1),0 1px 0 rgba(255,255,255,0.1);box-shadow:inset 0 1px 0 rgba(255,255,255,0.1),0 1px 0 rgba(255,255,255,0.1);margin-top:3px;margin-bottom:3px}@media (min-width:768px){.navbar-form .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.navbar-form .form-control{display:inline-block;width:auto;vertical-align:middle}.navbar-form .form-control-static{display:inline-block}.navbar-form .input-group{display:inline-table;vertical-align:middle}.navbar-form .input-group .input-group-addon,.navbar-form .input-group .input-group-btn,.navbar-form .input-group .form-control{width:auto}.navbar-form .input-group>.form-control{width:100%}.navbar-form .control-label{margin-bottom:0;vertical-align:middle}.navbar-form .radio,.navbar-form .checkbox{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.navbar-form .radio label,.navbar-form .checkbox label{padding-left:0}.navbar-form .radio input[type="radio"],.navbar-form .checkbox input[type="checkbox"]{position:relative;margin-left:0}.navbar-form .has-feedback .form-control-feedback{top:0}}@media (max-width:767px){.navbar-form .form-group{margin-bottom:5px}.navbar-form .form-group:last-child{margin-bottom:0}}@media (min-width:768px){.navbar-form{width:auto;border:0;margin-left:0;margin-right:0;padding-top:0;padding-bottom:0;-webkit-box-shadow:none;box-shadow:none}}.navbar-nav>li>.dropdown-menu{margin-top:0;border-top-right-radius:0;border-top-left-radius:0}.navbar-fixed-bottom .navbar-nav>li>.dropdown-menu{margin-bottom:0;border-top-right-radius:0;border-top-left-radius:0;border-bottom-right-radius:0;border-bottom-left-radius:0}.navbar-btn{margin-top:3px;margin-bottom:3px}.navbar-btn.btn-sm{margin-top:4.5px;margin-bottom:4.5px}.navbar-btn.btn-xs{margin-top:11.5px;margin-bottom:11.5px}.navbar-text{margin-top:12px;margin-bottom:12px}@media (min-width:768px){.navbar-text{float:left;margin-left:15px;margin-right:15px}}@media (min-width:768px){.navbar-left{float:left !important}.navbar-right{float:right !important;margin-right:-15px}.navbar-right~.navbar-right{margin-right:0}}.navbar-default{background-color:#333333;border-color:#222222}.navbar-default .navbar-brand{color:#ffffff}.navbar-default .navbar-brand:hover,.navbar-default .navbar-brand:focus{color:#ffffff;background-color:transparent}.navbar-default .navbar-text{color:#ffffff}.navbar-default .navbar-nav>li>a{color:#ffffff}.navbar-default .navbar-nav>li>a:hover,.navbar-default .navbar-nav>li>a:focus{color:#ffffff;background-color:#272727}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.active>a:hover,.navbar-default .navbar-nav>.active>a:focus{color:#ffffff;background-color:#272727}.navbar-default .navbar-nav>.disabled>a,.navbar-default .navbar-nav>.disabled>a:hover,.navbar-default .navbar-nav>.disabled>a:focus{color:#cccccc;background-color:transparent}.navbar-default .navbar-toggle{border-color:transparent}.navbar-default .navbar-toggle:hover,.navbar-default .navbar-toggle:focus{background-color:transparent}.navbar-default .navbar-toggle .icon-bar{background-color:#ffffff}.navbar-default .navbar-collapse,.navbar-default .navbar-form{border-color:#222222}.navbar-default .navbar-nav>.open>a,.navbar-default .navbar-nav>.open>a:hover,.navbar-default .navbar-nav>.open>a:focus{background-color:#272727;color:#ffffff}@media (max-width:767px){.navbar-default .navbar-nav .open .dropdown-menu>li>a{color:#ffffff}.navbar-default .navbar-nav .open .dropdown-menu>li>a:hover,.navbar-default .navbar-nav .open .dropdown-menu>li>a:focus{color:#ffffff;background-color:#272727}.navbar-default .navbar-nav .open .dropdown-menu>.active>a,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:hover,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:focus{color:#ffffff;background-color:#272727}.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:hover,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:focus{color:#cccccc;background-color:transparent}}.navbar-default .navbar-link{color:#ffffff}.navbar-default .navbar-link:hover{color:#ffffff}.navbar-default .btn-link{color:#ffffff}.navbar-default .btn-link:hover,.navbar-default .btn-link:focus{color:#ffffff}.navbar-default .btn-link[disabled]:hover,fieldset[disabled] .navbar-default .btn-link:hover,.navbar-default .btn-link[disabled]:focus,fieldset[disabled] .navbar-default .btn-link:focus{color:#cccccc}.navbar-inverse{background-color:#008cba;border-color:#006687}.navbar-inverse .navbar-brand{color:#ffffff}.navbar-inverse .navbar-brand:hover,.navbar-inverse .navbar-brand:focus{color:#ffffff;background-color:transparent}.navbar-inverse .navbar-text{color:#ffffff}.navbar-inverse .navbar-nav>li>a{color:#ffffff}.navbar-inverse .navbar-nav>li>a:hover,.navbar-inverse .navbar-nav>li>a:focus{color:#ffffff;background-color:#006687}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.active>a:hover,.navbar-inverse .navbar-nav>.active>a:focus{color:#ffffff;background-color:#006687}.navbar-inverse .navbar-nav>.disabled>a,.navbar-inverse .navbar-nav>.disabled>a:hover,.navbar-inverse .navbar-nav>.disabled>a:focus{color:#444444;background-color:transparent}.navbar-inverse .navbar-toggle{border-color:transparent}.navbar-inverse .navbar-toggle:hover,.navbar-inverse .navbar-toggle:focus{background-color:transparent}.navbar-inverse .navbar-toggle .icon-bar{background-color:#ffffff}.navbar-inverse .navbar-collapse,.navbar-inverse .navbar-form{border-color:#007196}.navbar-inverse .navbar-nav>.open>a,.navbar-inverse .navbar-nav>.open>a:hover,.navbar-inverse .navbar-nav>.open>a:focus{background-color:#006687;color:#ffffff}@media (max-width:767px){.navbar-inverse .navbar-nav .open .dropdown-menu>.dropdown-header{border-color:#006687}.navbar-inverse .navbar-nav .open .dropdown-menu .divider{background-color:#006687}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a{color:#ffffff}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:hover,.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:focus{color:#ffffff;background-color:#006687}.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:hover,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:focus{color:#ffffff;background-color:#006687}.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:hover,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:focus{color:#444444;background-color:transparent}}.navbar-inverse .navbar-link{color:#ffffff}.navbar-inverse .navbar-link:hover{color:#ffffff}.navbar-inverse .btn-link{color:#ffffff}.navbar-inverse .btn-link:hover,.navbar-inverse .btn-link:focus{color:#ffffff}.navbar-inverse .btn-link[disabled]:hover,fieldset[disabled] .navbar-inverse .btn-link:hover,.navbar-inverse .btn-link[disabled]:focus,fieldset[disabled] .navbar-inverse .btn-link:focus{color:#444444}.breadcrumb{padding:8px 15px;margin-bottom:21px;list-style:none;background-color:#f5f5f5;border-radius:0}.breadcrumb>li{display:inline-block}.breadcrumb>li+li:before{content:"/\00a0";padding:0 5px;color:#999999}.breadcrumb>.active{color:#333333}.pagination{display:inline-block;padding-left:0;margin:21px 0;border-radius:0}.pagination>li{display:inline}.pagination>li>a,.pagination>li>span{position:relative;float:left;padding:8px 12px;line-height:1.4;text-decoration:none;color:#008cba;background-color:transparent;border:1px solid transparent;margin-left:-1px}.pagination>li:first-child>a,.pagination>li:first-child>span{margin-left:0;border-bottom-left-radius:0;border-top-left-radius:0}.pagination>li:last-child>a,.pagination>li:last-child>span{border-bottom-right-radius:0;border-top-right-radius:0}.pagination>li>a:hover,.pagination>li>span:hover,.pagination>li>a:focus,.pagination>li>span:focus{z-index:2;color:#008cba;background-color:#eeeeee;border-color:transparent}.pagination>.active>a,.pagination>.active>span,.pagination>.active>a:hover,.pagination>.active>span:hover,.pagination>.active>a:focus,.pagination>.active>span:focus{z-index:3;color:#ffffff;background-color:#008cba;border-color:transparent;cursor:default}.pagination>.disabled>span,.pagination>.disabled>span:hover,.pagination>.disabled>span:focus,.pagination>.disabled>a,.pagination>.disabled>a:hover,.pagination>.disabled>a:focus{color:#999999;background-color:#ffffff;border-color:transparent;cursor:not-allowed}.pagination-lg>li>a,.pagination-lg>li>span{padding:16px 20px;font-size:19px;line-height:1.3333333}.pagination-lg>li:first-child>a,.pagination-lg>li:first-child>span{border-bottom-left-radius:0;border-top-left-radius:0}.pagination-lg>li:last-child>a,.pagination-lg>li:last-child>span{border-bottom-right-radius:0;border-top-right-radius:0}.pagination-sm>li>a,.pagination-sm>li>span{padding:8px 12px;font-size:12px;line-height:1.5}.pagination-sm>li:first-child>a,.pagination-sm>li:first-child>span{border-bottom-left-radius:0;border-top-left-radius:0}.pagination-sm>li:last-child>a,.pagination-sm>li:last-child>span{border-bottom-right-radius:0;border-top-right-radius:0}.pager{padding-left:0;margin:21px 0;list-style:none;text-align:center}.pager li{display:inline}.pager li>a,.pager li>span{display:inline-block;padding:5px 14px;background-color:transparent;border:1px solid transparent;border-radius:3px}.pager li>a:hover,.pager li>a:focus{text-decoration:none;background-color:#eeeeee}.pager .next>a,.pager .next>span{float:right}.pager .previous>a,.pager .previous>span{float:left}.pager .disabled>a,.pager .disabled>a:hover,.pager .disabled>a:focus,.pager .disabled>span{color:#999999;background-color:transparent;cursor:not-allowed}.label{display:inline;padding:.2em .6em .3em;font-size:75%;font-weight:bold;line-height:1;color:#ffffff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25em}a.label:hover,a.label:focus{color:#ffffff;text-decoration:none;cursor:pointer}.label:empty{display:none}.btn .label{position:relative;top:-1px}.label-default{background-color:#999999}.label-default[href]:hover,.label-default[href]:focus{background-color:#808080}.label-primary{background-color:#008cba}.label-primary[href]:hover,.label-primary[href]:focus{background-color:#006687}.label-success{background-color:#43ac6a}.label-success[href]:hover,.label-success[href]:focus{background-color:#358753}.label-info{background-color:#5bc0de}.label-info[href]:hover,.label-info[href]:focus{background-color:#31b0d5}.label-warning{background-color:#e99002}.label-warning[href]:hover,.label-warning[href]:focus{background-color:#b67102}.label-danger{background-color:#f04124}.label-danger[href]:hover,.label-danger[href]:focus{background-color:#d32a0e}.badge{display:inline-block;min-width:10px;padding:3px 7px;font-size:12px;font-weight:bold;color:#ffffff;line-height:1;vertical-align:middle;white-space:nowrap;text-align:center;background-color:#008cba;border-radius:10px}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.btn-xs .badge,.btn-group-xs>.btn .badge{top:0;padding:1px 5px}a.badge:hover,a.badge:focus{color:#ffffff;text-decoration:none;cursor:pointer}.list-group-item.active>.badge,.nav-pills>.active>a>.badge{color:#008cba;background-color:#ffffff}.list-group-item>.badge{float:right}.list-group-item>.badge+.badge{margin-right:5px}.nav-pills>li>a>.badge{margin-left:3px}.jumbotron{padding-top:30px;padding-bottom:30px;margin-bottom:30px;color:inherit;background-color:#fafafa}.jumbotron h1,.jumbotron .h1{color:inherit}.jumbotron p{margin-bottom:15px;font-size:23px;font-weight:200}.jumbotron>hr{border-top-color:#e1e1e1}.container .jumbotron,.container-fluid .jumbotron{border-radius:0;padding-left:15px;padding-right:15px}.jumbotron .container{max-width:100%}@media screen and (min-width:768px){.jumbotron{padding-top:48px;padding-bottom:48px}.container .jumbotron,.container-fluid .jumbotron{padding-left:60px;padding-right:60px}.jumbotron h1,.jumbotron .h1{font-size:68px}}.thumbnail{display:block;padding:4px;margin-bottom:21px;line-height:1.4;background-color:#ffffff;border:1px solid #dddddd;border-radius:0;-webkit-transition:border .2s ease-in-out;-o-transition:border .2s ease-in-out;transition:border .2s ease-in-out}.thumbnail>img,.thumbnail a>img{margin-left:auto;margin-right:auto}a.thumbnail:hover,a.thumbnail:focus,a.thumbnail.active{border-color:#008cba}.thumbnail .caption{padding:9px;color:#222222}.alert{padding:15px;margin-bottom:21px;border:1px solid transparent;border-radius:0}.alert h4{margin-top:0;color:inherit}.alert .alert-link{font-weight:bold}.alert>p,.alert>ul{margin-bottom:0}.alert>p+p{margin-top:5px}.alert-dismissable,.alert-dismissible{padding-right:35px}.alert-dismissable .close,.alert-dismissible .close{position:relative;top:-2px;right:-21px;color:inherit}.alert-success{background-color:#43ac6a;border-color:#3c9a5f;color:#ffffff}.alert-success hr{border-top-color:#358753}.alert-success .alert-link{color:#e6e6e6}.alert-info{background-color:#5bc0de;border-color:#3db5d8;color:#ffffff}.alert-info hr{border-top-color:#2aabd2}.alert-info .alert-link{color:#e6e6e6}.alert-warning{background-color:#e99002;border-color:#d08002;color:#ffffff}.alert-warning hr{border-top-color:#b67102}.alert-warning .alert-link{color:#e6e6e6}.alert-danger{background-color:#f04124;border-color:#ea2f10;color:#ffffff}.alert-danger hr{border-top-color:#d32a0e}.alert-danger .alert-link{color:#e6e6e6}@-webkit-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@-o-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}.progress{overflow:hidden;height:21px;margin-bottom:21px;background-color:#f5f5f5;border-radius:0;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,0.1);box-shadow:inset 0 1px 2px rgba(0,0,0,0.1)}.progress-bar{float:left;width:0%;height:100%;font-size:12px;line-height:21px;color:#ffffff;text-align:center;background-color:#008cba;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,0.15);box-shadow:inset 0 -1px 0 rgba(0,0,0,0.15);-webkit-transition:width 0.6s ease;-o-transition:width 0.6s ease;transition:width 0.6s ease}.progress-striped .progress-bar,.progress-bar-striped{background-image:-webkit-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);-webkit-background-size:40px 40px;background-size:40px 40px}.progress.active .progress-bar,.progress-bar.active{-webkit-animation:progress-bar-stripes 2s linear infinite;-o-animation:progress-bar-stripes 2s linear infinite;animation:progress-bar-stripes 2s linear infinite}.progress-bar-success{background-color:#43ac6a}.progress-striped .progress-bar-success{background-image:-webkit-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent)}.progress-bar-info{background-color:#5bc0de}.progress-striped .progress-bar-info{background-image:-webkit-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent)}.progress-bar-warning{background-color:#e99002}.progress-striped .progress-bar-warning{background-image:-webkit-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent)}.progress-bar-danger{background-color:#f04124}.progress-striped .progress-bar-danger{background-image:-webkit-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent)}.media{margin-top:15px}.media:first-child{margin-top:0}.media,.media-body{zoom:1;overflow:hidden}.media-body{width:10000px}.media-object{display:block}.media-object.img-thumbnail{max-width:none}.media-right,.media>.pull-right{padding-left:10px}.media-left,.media>.pull-left{padding-right:10px}.media-left,.media-right,.media-body{display:table-cell;vertical-align:top}.media-middle{vertical-align:middle}.media-bottom{vertical-align:bottom}.media-heading{margin-top:0;margin-bottom:5px}.media-list{padding-left:0;list-style:none}.list-group{margin-bottom:20px;padding-left:0}.list-group-item{position:relative;display:block;padding:10px 15px;margin-bottom:-1px;background-color:#ffffff;border:1px solid #dddddd}.list-group-item:first-child{border-top-right-radius:0;border-top-left-radius:0}.list-group-item:last-child{margin-bottom:0;border-bottom-right-radius:0;border-bottom-left-radius:0}a.list-group-item,button.list-group-item{color:#555555}a.list-group-item .list-group-item-heading,button.list-group-item .list-group-item-heading{color:#333333}a.list-group-item:hover,button.list-group-item:hover,a.list-group-item:focus,button.list-group-item:focus{text-decoration:none;color:#555555;background-color:#f5f5f5}button.list-group-item{width:100%;text-align:left}.list-group-item.disabled,.list-group-item.disabled:hover,.list-group-item.disabled:focus{background-color:#eeeeee;color:#999999;cursor:not-allowed}.list-group-item.disabled .list-group-item-heading,.list-group-item.disabled:hover .list-group-item-heading,.list-group-item.disabled:focus .list-group-item-heading{color:inherit}.list-group-item.disabled .list-group-item-text,.list-group-item.disabled:hover .list-group-item-text,.list-group-item.disabled:focus .list-group-item-text{color:#999999}.list-group-item.active,.list-group-item.active:hover,.list-group-item.active:focus{z-index:2;color:#ffffff;background-color:#008cba;border-color:#008cba}.list-group-item.active .list-group-item-heading,.list-group-item.active:hover .list-group-item-heading,.list-group-item.active:focus .list-group-item-heading,.list-group-item.active .list-group-item-heading>small,.list-group-item.active:hover .list-group-item-heading>small,.list-group-item.active:focus .list-group-item-heading>small,.list-group-item.active .list-group-item-heading>.small,.list-group-item.active:hover .list-group-item-heading>.small,.list-group-item.active:focus .list-group-item-heading>.small{color:inherit}.list-group-item.active .list-group-item-text,.list-group-item.active:hover .list-group-item-text,.list-group-item.active:focus .list-group-item-text{color:#87e1ff}.list-group-item-success{color:#43ac6a;background-color:#dff0d8}a.list-group-item-success,button.list-group-item-success{color:#43ac6a}a.list-group-item-success .list-group-item-heading,button.list-group-item-success .list-group-item-heading{color:inherit}a.list-group-item-success:hover,button.list-group-item-success:hover,a.list-group-item-success:focus,button.list-group-item-success:focus{color:#43ac6a;background-color:#d0e9c6}a.list-group-item-success.active,button.list-group-item-success.active,a.list-group-item-success.active:hover,button.list-group-item-success.active:hover,a.list-group-item-success.active:focus,button.list-group-item-success.active:focus{color:#fff;background-color:#43ac6a;border-color:#43ac6a}.list-group-item-info{color:#5bc0de;background-color:#d9edf7}a.list-group-item-info,button.list-group-item-info{color:#5bc0de}a.list-group-item-info .list-group-item-heading,button.list-group-item-info .list-group-item-heading{color:inherit}a.list-group-item-info:hover,button.list-group-item-info:hover,a.list-group-item-info:focus,button.list-group-item-info:focus{color:#5bc0de;background-color:#c4e3f3}a.list-group-item-info.active,button.list-group-item-info.active,a.list-group-item-info.active:hover,button.list-group-item-info.active:hover,a.list-group-item-info.active:focus,button.list-group-item-info.active:focus{color:#fff;background-color:#5bc0de;border-color:#5bc0de}.list-group-item-warning{color:#e99002;background-color:#fcf8e3}a.list-group-item-warning,button.list-group-item-warning{color:#e99002}a.list-group-item-warning .list-group-item-heading,button.list-group-item-warning .list-group-item-heading{color:inherit}a.list-group-item-warning:hover,button.list-group-item-warning:hover,a.list-group-item-warning:focus,button.list-group-item-warning:focus{color:#e99002;background-color:#faf2cc}a.list-group-item-warning.active,button.list-group-item-warning.active,a.list-group-item-warning.active:hover,button.list-group-item-warning.active:hover,a.list-group-item-warning.active:focus,button.list-group-item-warning.active:focus{color:#fff;background-color:#e99002;border-color:#e99002}.list-group-item-danger{color:#f04124;background-color:#f2dede}a.list-group-item-danger,button.list-group-item-danger{color:#f04124}a.list-group-item-danger .list-group-item-heading,button.list-group-item-danger .list-group-item-heading{color:inherit}a.list-group-item-danger:hover,button.list-group-item-danger:hover,a.list-group-item-danger:focus,button.list-group-item-danger:focus{color:#f04124;background-color:#ebcccc}a.list-group-item-danger.active,button.list-group-item-danger.active,a.list-group-item-danger.active:hover,button.list-group-item-danger.active:hover,a.list-group-item-danger.active:focus,button.list-group-item-danger.active:focus{color:#fff;background-color:#f04124;border-color:#f04124}.list-group-item-heading{margin-top:0;margin-bottom:5px}.list-group-item-text{margin-bottom:0;line-height:1.3}.panel{margin-bottom:21px;background-color:#ffffff;border:1px solid transparent;border-radius:0;-webkit-box-shadow:0 1px 1px rgba(0,0,0,0.05);box-shadow:0 1px 1px rgba(0,0,0,0.05)}.panel-body{padding:15px}.panel-heading{padding:10px 15px;border-bottom:1px solid transparent;border-top-right-radius:-1;border-top-left-radius:-1}.panel-heading>.dropdown .dropdown-toggle{color:inherit}.panel-title{margin-top:0;margin-bottom:0;font-size:17px;color:inherit}.panel-title>a,.panel-title>small,.panel-title>.small,.panel-title>small>a,.panel-title>.small>a{color:inherit}.panel-footer{padding:10px 15px;background-color:#f5f5f5;border-top:1px solid #dddddd;border-bottom-right-radius:-1;border-bottom-left-radius:-1}.panel>.list-group,.panel>.panel-collapse>.list-group{margin-bottom:0}.panel>.list-group .list-group-item,.panel>.panel-collapse>.list-group .list-group-item{border-width:1px 0;border-radius:0}.panel>.list-group:first-child .list-group-item:first-child,.panel>.panel-collapse>.list-group:first-child .list-group-item:first-child{border-top:0;border-top-right-radius:-1;border-top-left-radius:-1}.panel>.list-group:last-child .list-group-item:last-child,.panel>.panel-collapse>.list-group:last-child .list-group-item:last-child{border-bottom:0;border-bottom-right-radius:-1;border-bottom-left-radius:-1}.panel>.panel-heading+.panel-collapse>.list-group .list-group-item:first-child{border-top-right-radius:0;border-top-left-radius:0}.panel-heading+.list-group .list-group-item:first-child{border-top-width:0}.list-group+.panel-footer{border-top-width:0}.panel>.table,.panel>.table-responsive>.table,.panel>.panel-collapse>.table{margin-bottom:0}.panel>.table caption,.panel>.table-responsive>.table caption,.panel>.panel-collapse>.table caption{padding-left:15px;padding-right:15px}.panel>.table:first-child,.panel>.table-responsive:first-child>.table:first-child{border-top-right-radius:-1;border-top-left-radius:-1}.panel>.table:first-child>thead:first-child>tr:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child{border-top-left-radius:-1;border-top-right-radius:-1}.panel>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table:first-child>thead:first-child>tr:first-child th:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:first-child{border-top-left-radius:-1}.panel>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table:first-child>thead:first-child>tr:first-child th:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:last-child{border-top-right-radius:-1}.panel>.table:last-child,.panel>.table-responsive:last-child>.table:last-child{border-bottom-right-radius:-1;border-bottom-left-radius:-1}.panel>.table:last-child>tbody:last-child>tr:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child{border-bottom-left-radius:-1;border-bottom-right-radius:-1}.panel>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:first-child{border-bottom-left-radius:-1}.panel>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:last-child{border-bottom-right-radius:-1}.panel>.panel-body+.table,.panel>.panel-body+.table-responsive,.panel>.table+.panel-body,.panel>.table-responsive+.panel-body{border-top:1px solid #dddddd}.panel>.table>tbody:first-child>tr:first-child th,.panel>.table>tbody:first-child>tr:first-child td{border-top:0}.panel>.table-bordered,.panel>.table-responsive>.table-bordered{border:0}.panel>.table-bordered>thead>tr>th:first-child,.panel>.table-responsive>.table-bordered>thead>tr>th:first-child,.panel>.table-bordered>tbody>tr>th:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:first-child,.panel>.table-bordered>tfoot>tr>th:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:first-child,.panel>.table-bordered>thead>tr>td:first-child,.panel>.table-responsive>.table-bordered>thead>tr>td:first-child,.panel>.table-bordered>tbody>tr>td:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:first-child,.panel>.table-bordered>tfoot>tr>td:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:first-child{border-left:0}.panel>.table-bordered>thead>tr>th:last-child,.panel>.table-responsive>.table-bordered>thead>tr>th:last-child,.panel>.table-bordered>tbody>tr>th:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:last-child,.panel>.table-bordered>tfoot>tr>th:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:last-child,.panel>.table-bordered>thead>tr>td:last-child,.panel>.table-responsive>.table-bordered>thead>tr>td:last-child,.panel>.table-bordered>tbody>tr>td:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:last-child,.panel>.table-bordered>tfoot>tr>td:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:last-child{border-right:0}.panel>.table-bordered>thead>tr:first-child>td,.panel>.table-responsive>.table-bordered>thead>tr:first-child>td,.panel>.table-bordered>tbody>tr:first-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>td,.panel>.table-bordered>thead>tr:first-child>th,.panel>.table-responsive>.table-bordered>thead>tr:first-child>th,.panel>.table-bordered>tbody>tr:first-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>th{border-bottom:0}.panel>.table-bordered>tbody>tr:last-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>td,.panel>.table-bordered>tfoot>tr:last-child>td,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>td,.panel>.table-bordered>tbody>tr:last-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>th,.panel>.table-bordered>tfoot>tr:last-child>th,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}.panel>.table-responsive{border:0;margin-bottom:0}.panel-group{margin-bottom:21px}.panel-group .panel{margin-bottom:0;border-radius:0}.panel-group .panel+.panel{margin-top:5px}.panel-group .panel-heading{border-bottom:0}.panel-group .panel-heading+.panel-collapse>.panel-body,.panel-group .panel-heading+.panel-collapse>.list-group{border-top:1px solid #dddddd}.panel-group .panel-footer{border-top:0}.panel-group .panel-footer+.panel-collapse .panel-body{border-bottom:1px solid #dddddd}.panel-default{border-color:#dddddd}.panel-default>.panel-heading{color:#333333;background-color:#f5f5f5;border-color:#dddddd}.panel-default>.panel-heading+.panel-collapse>.panel-body{border-top-color:#dddddd}.panel-default>.panel-heading .badge{color:#f5f5f5;background-color:#333333}.panel-default>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#dddddd}.panel-primary{border-color:#008cba}.panel-primary>.panel-heading{color:#ffffff;background-color:#008cba;border-color:#008cba}.panel-primary>.panel-heading+.panel-collapse>.panel-body{border-top-color:#008cba}.panel-primary>.panel-heading .badge{color:#008cba;background-color:#ffffff}.panel-primary>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#008cba}.panel-success{border-color:#3c9a5f}.panel-success>.panel-heading{color:#ffffff;background-color:#43ac6a;border-color:#3c9a5f}.panel-success>.panel-heading+.panel-collapse>.panel-body{border-top-color:#3c9a5f}.panel-success>.panel-heading .badge{color:#43ac6a;background-color:#ffffff}.panel-success>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#3c9a5f}.panel-info{border-color:#3db5d8}.panel-info>.panel-heading{color:#ffffff;background-color:#5bc0de;border-color:#3db5d8}.panel-info>.panel-heading+.panel-collapse>.panel-body{border-top-color:#3db5d8}.panel-info>.panel-heading .badge{color:#5bc0de;background-color:#ffffff}.panel-info>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#3db5d8}.panel-warning{border-color:#d08002}.panel-warning>.panel-heading{color:#ffffff;background-color:#e99002;border-color:#d08002}.panel-warning>.panel-heading+.panel-collapse>.panel-body{border-top-color:#d08002}.panel-warning>.panel-heading .badge{color:#e99002;background-color:#ffffff}.panel-warning>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#d08002}.panel-danger{border-color:#ea2f10}.panel-danger>.panel-heading{color:#ffffff;background-color:#f04124;border-color:#ea2f10}.panel-danger>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ea2f10}.panel-danger>.panel-heading .badge{color:#f04124;background-color:#ffffff}.panel-danger>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ea2f10}.embed-responsive{position:relative;display:block;height:0;padding:0;overflow:hidden}.embed-responsive .embed-responsive-item,.embed-responsive iframe,.embed-responsive embed,.embed-responsive object,.embed-responsive video{position:absolute;top:0;left:0;bottom:0;height:100%;width:100%;border:0}.embed-responsive-16by9{padding-bottom:56.25%}.embed-responsive-4by3{padding-bottom:75%}.well{min-height:20px;padding:19px;margin-bottom:20px;background-color:#fafafa;border:1px solid #e8e8e8;border-radius:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.05);box-shadow:inset 0 1px 1px rgba(0,0,0,0.05)}.well blockquote{border-color:#ddd;border-color:rgba(0,0,0,0.15)}.well-lg{padding:24px;border-radius:0}.well-sm{padding:9px;border-radius:0}.close{float:right;font-size:22.5px;font-weight:bold;line-height:1;color:#ffffff;text-shadow:0 1px 0 #ffffff;opacity:0.2;filter:alpha(opacity=20)}.close:hover,.close:focus{color:#ffffff;text-decoration:none;cursor:pointer;opacity:0.5;filter:alpha(opacity=50)}button.close{padding:0;cursor:pointer;background:transparent;border:0;-webkit-appearance:none}.modal-open{overflow:hidden}.modal{display:none;overflow:hidden;position:fixed;top:0;right:0;bottom:0;left:0;z-index:1050;-webkit-overflow-scrolling:touch;outline:0}.modal.fade .modal-dialog{-webkit-transform:translate(0, -25%);-ms-transform:translate(0, -25%);-o-transform:translate(0, -25%);transform:translate(0, -25%);-webkit-transition:-webkit-transform .3s ease-out;-o-transition:-o-transform .3s ease-out;transition:transform .3s ease-out}.modal.in .modal-dialog{-webkit-transform:translate(0, 0);-ms-transform:translate(0, 0);-o-transform:translate(0, 0);transform:translate(0, 0)}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal-dialog{position:relative;width:auto;margin:10px}.modal-content{position:relative;background-color:#ffffff;border:1px solid #999999;border:1px solid rgba(0,0,0,0.2);border-radius:0;-webkit-box-shadow:0 3px 9px rgba(0,0,0,0.5);box-shadow:0 3px 9px rgba(0,0,0,0.5);-webkit-background-clip:padding-box;background-clip:padding-box;outline:0}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1040;background-color:#000000}.modal-backdrop.fade{opacity:0;filter:alpha(opacity=0)}.modal-backdrop.in{opacity:0.5;filter:alpha(opacity=50)}.modal-header{padding:15px;border-bottom:1px solid #e5e5e5}.modal-header .close{margin-top:-2px}.modal-title{margin:0;line-height:1.4}.modal-body{position:relative;padding:20px}.modal-footer{padding:20px;text-align:right;border-top:1px solid #e5e5e5}.modal-footer .btn+.btn{margin-left:5px;margin-bottom:0}.modal-footer .btn-group .btn+.btn{margin-left:-1px}.modal-footer .btn-block+.btn-block{margin-left:0}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width:768px){.modal-dialog{width:600px;margin:30px auto}.modal-content{-webkit-box-shadow:0 5px 15px rgba(0,0,0,0.5);box-shadow:0 5px 15px rgba(0,0,0,0.5)}.modal-sm{width:300px}}@media (min-width:992px){.modal-lg{width:900px}}.tooltip{position:absolute;z-index:1070;display:block;font-family:"Open Sans","Helvetica Neue",Helvetica,Arial,sans-serif;font-style:normal;font-weight:normal;letter-spacing:normal;line-break:auto;line-height:1.4;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;white-space:normal;word-break:normal;word-spacing:normal;word-wrap:normal;font-size:12px;opacity:0;filter:alpha(opacity=0)}.tooltip.in{opacity:0.9;filter:alpha(opacity=90)}.tooltip.top{margin-top:-3px;padding:5px 0}.tooltip.right{margin-left:3px;padding:0 5px}.tooltip.bottom{margin-top:3px;padding:5px 0}.tooltip.left{margin-left:-3px;padding:0 5px}.tooltip-inner{max-width:200px;padding:3px 8px;color:#ffffff;text-align:center;background-color:#333333;border-radius:0}.tooltip-arrow{position:absolute;width:0;height:0;border-color:transparent;border-style:solid}.tooltip.top .tooltip-arrow{bottom:0;left:50%;margin-left:-5px;border-width:5px 5px 0;border-top-color:#333333}.tooltip.top-left .tooltip-arrow{bottom:0;right:5px;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#333333}.tooltip.top-right .tooltip-arrow{bottom:0;left:5px;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#333333}.tooltip.right .tooltip-arrow{top:50%;left:0;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#333333}.tooltip.left .tooltip-arrow{top:50%;right:0;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#333333}.tooltip.bottom .tooltip-arrow{top:0;left:50%;margin-left:-5px;border-width:0 5px 5px;border-bottom-color:#333333}.tooltip.bottom-left .tooltip-arrow{top:0;right:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#333333}.tooltip.bottom-right .tooltip-arrow{top:0;left:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#333333}.popover{position:absolute;top:0;left:0;z-index:1060;display:none;max-width:276px;padding:1px;font-family:"Open Sans","Helvetica Neue",Helvetica,Arial,sans-serif;font-style:normal;font-weight:normal;letter-spacing:normal;line-break:auto;line-height:1.4;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;white-space:normal;word-break:normal;word-spacing:normal;word-wrap:normal;font-size:15px;background-color:#333333;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #333333;border:1px solid transparent;border-radius:0;-webkit-box-shadow:0 5px 10px rgba(0,0,0,0.2);box-shadow:0 5px 10px rgba(0,0,0,0.2)}.popover.top{margin-top:-10px}.popover.right{margin-left:10px}.popover.bottom{margin-top:10px}.popover.left{margin-left:-10px}.popover-title{margin:0;padding:8px 14px;font-size:15px;background-color:#333333;border-bottom:1px solid #262626;border-radius:-1 -1 0 0}.popover-content{padding:9px 14px}.popover>.arrow,.popover>.arrow:after{position:absolute;display:block;width:0;height:0;border-color:transparent;border-style:solid}.popover>.arrow{border-width:11px}.popover>.arrow:after{border-width:10px;content:""}.popover.top>.arrow{left:50%;margin-left:-11px;border-bottom-width:0;border-top-color:#000000;border-top-color:rgba(0,0,0,0.05);bottom:-11px}.popover.top>.arrow:after{content:" ";bottom:1px;margin-left:-10px;border-bottom-width:0;border-top-color:#333333}.popover.right>.arrow{top:50%;left:-11px;margin-top:-11px;border-left-width:0;border-right-color:#000000;border-right-color:rgba(0,0,0,0.05)}.popover.right>.arrow:after{content:" ";left:1px;bottom:-10px;border-left-width:0;border-right-color:#333333}.popover.bottom>.arrow{left:50%;margin-left:-11px;border-top-width:0;border-bottom-color:#000000;border-bottom-color:rgba(0,0,0,0.05);top:-11px}.popover.bottom>.arrow:after{content:" ";top:1px;margin-left:-10px;border-top-width:0;border-bottom-color:#333333}.popover.left>.arrow{top:50%;right:-11px;margin-top:-11px;border-right-width:0;border-left-color:#000000;border-left-color:rgba(0,0,0,0.05)}.popover.left>.arrow:after{content:" ";right:1px;border-right-width:0;border-left-color:#333333;bottom:-10px}.carousel{position:relative}.carousel-inner{position:relative;overflow:hidden;width:100%}.carousel-inner>.item{display:none;position:relative;-webkit-transition:.6s ease-in-out left;-o-transition:.6s ease-in-out left;transition:.6s ease-in-out left}.carousel-inner>.item>img,.carousel-inner>.item>a>img{line-height:1}@media all and (transform-3d),(-webkit-transform-3d){.carousel-inner>.item{-webkit-transition:-webkit-transform .6s ease-in-out;-o-transition:-o-transform .6s ease-in-out;transition:transform .6s ease-in-out;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-perspective:1000px;perspective:1000px}.carousel-inner>.item.next,.carousel-inner>.item.active.right{-webkit-transform:translate3d(100%, 0, 0);transform:translate3d(100%, 0, 0);left:0}.carousel-inner>.item.prev,.carousel-inner>.item.active.left{-webkit-transform:translate3d(-100%, 0, 0);transform:translate3d(-100%, 0, 0);left:0}.carousel-inner>.item.next.left,.carousel-inner>.item.prev.right,.carousel-inner>.item.active{-webkit-transform:translate3d(0, 0, 0);transform:translate3d(0, 0, 0);left:0}}.carousel-inner>.active,.carousel-inner>.next,.carousel-inner>.prev{display:block}.carousel-inner>.active{left:0}.carousel-inner>.next,.carousel-inner>.prev{position:absolute;top:0;width:100%}.carousel-inner>.next{left:100%}.carousel-inner>.prev{left:-100%}.carousel-inner>.next.left,.carousel-inner>.prev.right{left:0}.carousel-inner>.active.left{left:-100%}.carousel-inner>.active.right{left:100%}.carousel-control{position:absolute;top:0;left:0;bottom:0;width:15%;opacity:0.5;filter:alpha(opacity=50);font-size:20px;color:#ffffff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,0.6);background-color:rgba(0,0,0,0)}.carousel-control.left{background-image:-webkit-linear-gradient(left, rgba(0,0,0,0.5) 0, rgba(0,0,0,0.0001) 100%);background-image:-o-linear-gradient(left, rgba(0,0,0,0.5) 0, rgba(0,0,0,0.0001) 100%);background-image:-webkit-gradient(linear, left top, right top, from(rgba(0,0,0,0.5)), to(rgba(0,0,0,0.0001)));background-image:linear-gradient(to right, rgba(0,0,0,0.5) 0, rgba(0,0,0,0.0001) 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1)}.carousel-control.right{left:auto;right:0;background-image:-webkit-linear-gradient(left, rgba(0,0,0,0.0001) 0, rgba(0,0,0,0.5) 100%);background-image:-o-linear-gradient(left, rgba(0,0,0,0.0001) 0, rgba(0,0,0,0.5) 100%);background-image:-webkit-gradient(linear, left top, right top, from(rgba(0,0,0,0.0001)), to(rgba(0,0,0,0.5)));background-image:linear-gradient(to right, rgba(0,0,0,0.0001) 0, rgba(0,0,0,0.5) 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1)}.carousel-control:hover,.carousel-control:focus{outline:0;color:#ffffff;text-decoration:none;opacity:0.9;filter:alpha(opacity=90)}.carousel-control .icon-prev,.carousel-control .icon-next,.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right{position:absolute;top:50%;margin-top:-10px;z-index:5;display:inline-block}.carousel-control .icon-prev,.carousel-control .glyphicon-chevron-left{left:50%;margin-left:-10px}.carousel-control .icon-next,.carousel-control .glyphicon-chevron-right{right:50%;margin-right:-10px}.carousel-control .icon-prev,.carousel-control .icon-next{width:20px;height:20px;line-height:1;font-family:serif}.carousel-control .icon-prev:before{content:'\2039'}.carousel-control .icon-next:before{content:'\203a'}.carousel-indicators{position:absolute;bottom:10px;left:50%;z-index:15;width:60%;margin-left:-30%;padding-left:0;list-style:none;text-align:center}.carousel-indicators li{display:inline-block;width:10px;height:10px;margin:1px;text-indent:-999px;border:1px solid #ffffff;border-radius:10px;cursor:pointer;background-color:#000 \9;background-color:rgba(0,0,0,0)}.carousel-indicators .active{margin:0;width:12px;height:12px;background-color:#ffffff}.carousel-caption{position:absolute;left:15%;right:15%;bottom:20px;z-index:10;padding-top:20px;padding-bottom:20px;color:#ffffff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,0.6)}.carousel-caption .btn{text-shadow:none}@media screen and (min-width:768px){.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-prev,.carousel-control .icon-next{width:30px;height:30px;margin-top:-10px;font-size:30px}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{margin-left:-10px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{margin-right:-10px}.carousel-caption{left:20%;right:20%;padding-bottom:30px}.carousel-indicators{bottom:20px}}.clearfix:before,.clearfix:after,.dl-horizontal dd:before,.dl-horizontal dd:after,.container:before,.container:after,.container-fluid:before,.container-fluid:after,.row:before,.row:after,.form-horizontal .form-group:before,.form-horizontal .form-group:after,.btn-toolbar:before,.btn-toolbar:after,.btn-group-vertical>.btn-group:before,.btn-group-vertical>.btn-group:after,.nav:before,.nav:after,.navbar:before,.navbar:after,.navbar-header:before,.navbar-header:after,.navbar-collapse:before,.navbar-collapse:after,.pager:before,.pager:after,.panel-body:before,.panel-body:after,.modal-header:before,.modal-header:after,.modal-footer:before,.modal-footer:after{content:" ";display:table}.clearfix:after,.dl-horizontal dd:after,.container:after,.container-fluid:after,.row:after,.form-horizontal .form-group:after,.btn-toolbar:after,.btn-group-vertical>.btn-group:after,.nav:after,.navbar:after,.navbar-header:after,.navbar-collapse:after,.pager:after,.panel-body:after,.modal-header:after,.modal-footer:after{clear:both}.center-block{display:block;margin-left:auto;margin-right:auto}.pull-right{float:right !important}.pull-left{float:left !important}.hide{display:none !important}.show{display:block !important}.invisible{visibility:hidden}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.hidden{display:none !important}.affix{position:fixed}@-ms-viewport{width:device-width}.visible-xs,.visible-sm,.visible-md,.visible-lg{display:none !important}.visible-xs-block,.visible-xs-inline,.visible-xs-inline-block,.visible-sm-block,.visible-sm-inline,.visible-sm-inline-block,.visible-md-block,.visible-md-inline,.visible-md-inline-block,.visible-lg-block,.visible-lg-inline,.visible-lg-inline-block{display:none !important}@media (max-width:767px){.visible-xs{display:block !important}table.visible-xs{display:table !important}tr.visible-xs{display:table-row !important}th.visible-xs,td.visible-xs{display:table-cell !important}}@media (max-width:767px){.visible-xs-block{display:block !important}}@media (max-width:767px){.visible-xs-inline{display:inline !important}}@media (max-width:767px){.visible-xs-inline-block{display:inline-block !important}}@media (min-width:768px) and (max-width:991px){.visible-sm{display:block !important}table.visible-sm{display:table !important}tr.visible-sm{display:table-row !important}th.visible-sm,td.visible-sm{display:table-cell !important}}@media (min-width:768px) and (max-width:991px){.visible-sm-block{display:block !important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline{display:inline !important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline-block{display:inline-block !important}}@media (min-width:992px) and (max-width:1199px){.visible-md{display:block !important}table.visible-md{display:table !important}tr.visible-md{display:table-row !important}th.visible-md,td.visible-md{display:table-cell !important}}@media (min-width:992px) and (max-width:1199px){.visible-md-block{display:block !important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline{display:inline !important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline-block{display:inline-block !important}}@media (min-width:1200px){.visible-lg{display:block !important}table.visible-lg{display:table !important}tr.visible-lg{display:table-row !important}th.visible-lg,td.visible-lg{display:table-cell !important}}@media (min-width:1200px){.visible-lg-block{display:block !important}}@media (min-width:1200px){.visible-lg-inline{display:inline !important}}@media (min-width:1200px){.visible-lg-inline-block{display:inline-block !important}}@media (max-width:767px){.hidden-xs{display:none !important}}@media (min-width:768px) and (max-width:991px){.hidden-sm{display:none !important}}@media (min-width:992px) and (max-width:1199px){.hidden-md{display:none !important}}@media (min-width:1200px){.hidden-lg{display:none !important}}.visible-print{display:none !important}@media print{.visible-print{display:block !important}table.visible-print{display:table !important}tr.visible-print{display:table-row !important}th.visible-print,td.visible-print{display:table-cell !important}}.visible-print-block{display:none !important}@media print{.visible-print-block{display:block !important}}.visible-print-inline{display:none !important}@media print{.visible-print-inline{display:inline !important}}.visible-print-inline-block{display:none !important}@media print{.visible-print-inline-block{display:inline-block !important}}@media print{.hidden-print{display:none !important}}.navbar{border:none;font-size:13px;font-weight:300}.navbar .navbar-toggle:hover .icon-bar{background-color:#b3b3b3}.navbar-collapse{border-top-color:rgba(0,0,0,0.2);-webkit-box-shadow:none;box-shadow:none}.navbar .btn{padding-top:6px;padding-bottom:6px}.navbar-form{margin-top:7px;margin-bottom:5px}.navbar-form .form-control{height:auto;padding:4px 6px}.navbar .dropdown-menu{border:none}.navbar .dropdown-menu>li>a,.navbar .dropdown-menu>li>a:focus{background-color:transparent;font-size:13px;font-weight:300}.navbar .dropdown-header{color:rgba(255,255,255,0.5)}.navbar-default .dropdown-menu{background-color:#333333}.navbar-default .dropdown-menu>li>a,.navbar-default .dropdown-menu>li>a:focus{color:#ffffff}.navbar-default .dropdown-menu>li>a:hover,.navbar-default .dropdown-menu>.active>a,.navbar-default .dropdown-menu>.active>a:hover{background-color:#272727}.navbar-inverse .dropdown-menu{background-color:#008cba}.navbar-inverse .dropdown-menu>li>a,.navbar-inverse .dropdown-menu>li>a:focus{color:#ffffff}.navbar-inverse .dropdown-menu>li>a:hover,.navbar-inverse .dropdown-menu>.active>a,.navbar-inverse .dropdown-menu>.active>a:hover{background-color:#006687}.btn{padding:8px 12px}.btn-lg{padding:16px 20px}.btn-sm{padding:8px 12px}.btn-xs{padding:4px 6px}.btn-group .btn~.dropdown-toggle{padding-left:16px;padding-right:16px}.btn-group .dropdown-menu{border-top-width:0}.btn-group.dropup .dropdown-menu{border-top-width:1px;border-bottom-width:0;margin-bottom:0}.btn-group .dropdown-toggle.btn-default~.dropdown-menu{background-color:#e7e7e7;border-color:#cccccc}.btn-group .dropdown-toggle.btn-default~.dropdown-menu>li>a{color:#333333}.btn-group .dropdown-toggle.btn-default~.dropdown-menu>li>a:hover{background-color:#d3d3d3}.btn-group .dropdown-toggle.btn-primary~.dropdown-menu{background-color:#008cba;border-color:#0079a1}.btn-group .dropdown-toggle.btn-primary~.dropdown-menu>li>a{color:#ffffff}.btn-group .dropdown-toggle.btn-primary~.dropdown-menu>li>a:hover{background-color:#006d91}.btn-group .dropdown-toggle.btn-success~.dropdown-menu{background-color:#43ac6a;border-color:#3c9a5f}.btn-group .dropdown-toggle.btn-success~.dropdown-menu>li>a{color:#ffffff}.btn-group .dropdown-toggle.btn-success~.dropdown-menu>li>a:hover{background-color:#388f58}.btn-group .dropdown-toggle.btn-info~.dropdown-menu{background-color:#5bc0de;border-color:#46b8da}.btn-group .dropdown-toggle.btn-info~.dropdown-menu>li>a{color:#ffffff}.btn-group .dropdown-toggle.btn-info~.dropdown-menu>li>a:hover{background-color:#39b3d7}.btn-group .dropdown-toggle.btn-warning~.dropdown-menu{background-color:#e99002;border-color:#d08002}.btn-group .dropdown-toggle.btn-warning~.dropdown-menu>li>a{color:#ffffff}.btn-group .dropdown-toggle.btn-warning~.dropdown-menu>li>a:hover{background-color:#c17702}.btn-group .dropdown-toggle.btn-danger~.dropdown-menu{background-color:#f04124;border-color:#ea2f10}.btn-group .dropdown-toggle.btn-danger~.dropdown-menu>li>a{color:#ffffff}.btn-group .dropdown-toggle.btn-danger~.dropdown-menu>li>a:hover{background-color:#dc2c0f}.lead{color:#6f6f6f}cite{font-style:italic}blockquote{border-left-width:1px;color:#6f6f6f}blockquote.pull-right{border-right-width:1px}blockquote small{font-size:12px;font-weight:300}table{font-size:12px}label,.control-label,.help-block,.checkbox,.radio{font-size:12px;font-weight:normal}input[type="radio"],input[type="checkbox"]{margin-top:1px}.nav .open>a,.nav .open>a:hover,.nav .open>a:focus{border-color:transparent}.nav-tabs>li>a{background-color:#e7e7e7;color:#222222}.nav-tabs .caret{border-top-color:#222222;border-bottom-color:#222222}.nav-pills{font-weight:300}.breadcrumb{border:1px solid #dddddd;border-radius:3px;font-size:10px;font-weight:300;text-transform:uppercase}.pagination{font-size:12px;font-weight:300;color:#999999}.pagination>li>a,.pagination>li>span{margin-left:4px;color:#999999}.pagination>.active>a,.pagination>.active>span{color:#fff}.pagination>li>a,.pagination>li:first-child>a,.pagination>li:last-child>a,.pagination>li>span,.pagination>li:first-child>span,.pagination>li:last-child>span{border-radius:3px}.pagination-lg>li>a,.pagination-lg>li>span{padding-left:22px;padding-right:22px}.pagination-sm>li>a,.pagination-sm>li>span{padding:0 5px}.pager{font-size:12px;font-weight:300;color:#999999}.list-group{font-size:12px;font-weight:300}.close{opacity:0.4;text-decoration:none;text-shadow:none}.close:hover,.close:focus{opacity:1}.alert{font-size:12px;font-weight:300}.alert .alert-link{font-weight:normal;color:#fff;text-decoration:underline}.label{padding-left:1em;padding-right:1em;border-radius:0;font-weight:300}.label-default{background-color:#e7e7e7;color:#333333}.badge{font-weight:300}.progress{height:22px;padding:2px;background-color:#f6f6f6;border:1px solid #ccc;-webkit-box-shadow:none;box-shadow:none}.dropdown-menu{padding:0;margin-top:0;font-size:12px}.dropdown-menu>li>a{padding:12px 15px}.dropdown-header{padding-left:15px;padding-right:15px;font-size:9px;text-transform:uppercase}.popover{color:#fff;font-size:12px;font-weight:300}.panel-heading,.panel-footer{border-top-right-radius:0;border-top-left-radius:0}.panel-default .close{color:#222222}.modal .close{color:#222222} \ No newline at end of file diff --git a/examples/blog/static/css/custom.css b/examples/blog/static/css/custom.css deleted file mode 100644 index a9bb3c03b..000000000 --- a/examples/blog/static/css/custom.css +++ /dev/null @@ -1,7 +0,0 @@ -body { - margin-top: 75px; /* 100px is double the height of the navbar - I made it a big larger for some more space - keep it at 50px at least if you want to use the fixed top nav */ -} - -footer { - margin: 50px 0; -} \ No newline at end of file diff --git a/examples/blog/static/css/font-awesome.css b/examples/blog/static/css/font-awesome.css deleted file mode 100644 index b2a5fe2f2..000000000 --- a/examples/blog/static/css/font-awesome.css +++ /dev/null @@ -1,2086 +0,0 @@ -/*! - * Font Awesome 4.5.0 by @davegandy - http://fontawesome.io - @fontawesome - * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) - */ -/* FONT PATH - * -------------------------- */ -@font-face { - font-family: 'FontAwesome'; - src: url('../fonts/fontawesome-webfont.eot?v=4.5.0'); - src: url('../fonts/fontawesome-webfont.eot?#iefix&v=4.5.0') format('embedded-opentype'), url('../fonts/fontawesome-webfont.woff2?v=4.5.0') format('woff2'), url('../fonts/fontawesome-webfont.woff?v=4.5.0') format('woff'), url('../fonts/fontawesome-webfont.ttf?v=4.5.0') format('truetype'), url('../fonts/fontawesome-webfont.svg?v=4.5.0#fontawesomeregular') format('svg'); - font-weight: normal; - font-style: normal; -} -.fa { - display: inline-block; - font: normal normal normal 14px/1 FontAwesome; - font-size: inherit; - text-rendering: auto; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} -/* makes the font 33% larger relative to the icon container */ -.fa-lg { - font-size: 1.33333333em; - line-height: 0.75em; - vertical-align: -15%; -} -.fa-2x { - font-size: 2em; -} -.fa-3x { - font-size: 3em; -} -.fa-4x { - font-size: 4em; -} -.fa-5x { - font-size: 5em; -} -.fa-fw { - width: 1.28571429em; - text-align: center; -} -.fa-ul { - padding-left: 0; - margin-left: 2.14285714em; - list-style-type: none; -} -.fa-ul > li { - position: relative; -} -.fa-li { - position: absolute; - left: -2.14285714em; - width: 2.14285714em; - top: 0.14285714em; - text-align: center; -} -.fa-li.fa-lg { - left: -1.85714286em; -} -.fa-border { - padding: .2em .25em .15em; - border: solid 0.08em #eeeeee; - border-radius: .1em; -} -.fa-pull-left { - float: left; -} -.fa-pull-right { - float: right; -} -.fa.fa-pull-left { - margin-right: .3em; -} -.fa.fa-pull-right { - margin-left: .3em; -} -/* Deprecated as of 4.4.0 */ -.pull-right { - float: right; -} -.pull-left { - float: left; -} -.fa.pull-left { - margin-right: .3em; -} -.fa.pull-right { - margin-left: .3em; -} -.fa-spin { - -webkit-animation: fa-spin 2s infinite linear; - animation: fa-spin 2s infinite linear; -} -.fa-pulse { - -webkit-animation: fa-spin 1s infinite steps(8); - animation: fa-spin 1s infinite steps(8); -} -@-webkit-keyframes fa-spin { - 0% { - -webkit-transform: rotate(0deg); - transform: rotate(0deg); - } - 100% { - -webkit-transform: rotate(359deg); - transform: rotate(359deg); - } -} -@keyframes fa-spin { - 0% { - -webkit-transform: rotate(0deg); - transform: rotate(0deg); - } - 100% { - -webkit-transform: rotate(359deg); - transform: rotate(359deg); - } -} -.fa-rotate-90 { - filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=1); - -webkit-transform: rotate(90deg); - -ms-transform: rotate(90deg); - transform: rotate(90deg); -} -.fa-rotate-180 { - filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=2); - -webkit-transform: rotate(180deg); - -ms-transform: rotate(180deg); - transform: rotate(180deg); -} -.fa-rotate-270 { - filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=3); - -webkit-transform: rotate(270deg); - -ms-transform: rotate(270deg); - transform: rotate(270deg); -} -.fa-flip-horizontal { - filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1); - -webkit-transform: scale(-1, 1); - -ms-transform: scale(-1, 1); - transform: scale(-1, 1); -} -.fa-flip-vertical { - filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1); - -webkit-transform: scale(1, -1); - -ms-transform: scale(1, -1); - transform: scale(1, -1); -} -:root .fa-rotate-90, -:root .fa-rotate-180, -:root .fa-rotate-270, -:root .fa-flip-horizontal, -:root .fa-flip-vertical { - filter: none; -} -.fa-stack { - position: relative; - display: inline-block; - width: 2em; - height: 2em; - line-height: 2em; - vertical-align: middle; -} -.fa-stack-1x, -.fa-stack-2x { - position: absolute; - left: 0; - width: 100%; - text-align: center; -} -.fa-stack-1x { - line-height: inherit; -} -.fa-stack-2x { - font-size: 2em; -} -.fa-inverse { - color: #ffffff; -} -/* Font Awesome uses the Unicode Private Use Area (PUA) to ensure screen - readers do not read off random characters that represent icons */ -.fa-glass:before { - content: "\f000"; -} -.fa-music:before { - content: "\f001"; -} -.fa-search:before { - content: "\f002"; -} -.fa-envelope-o:before { - content: "\f003"; -} -.fa-heart:before { - content: "\f004"; -} -.fa-star:before { - content: "\f005"; -} -.fa-star-o:before { - content: "\f006"; -} -.fa-user:before { - content: "\f007"; -} -.fa-film:before { - content: "\f008"; -} -.fa-th-large:before { - content: "\f009"; -} -.fa-th:before { - content: "\f00a"; -} -.fa-th-list:before { - content: "\f00b"; -} -.fa-check:before { - content: "\f00c"; -} -.fa-remove:before, -.fa-close:before, -.fa-times:before { - content: "\f00d"; -} -.fa-search-plus:before { - content: "\f00e"; -} -.fa-search-minus:before { - content: "\f010"; -} -.fa-power-off:before { - content: "\f011"; -} -.fa-signal:before { - content: "\f012"; -} -.fa-gear:before, -.fa-cog:before { - content: "\f013"; -} -.fa-trash-o:before { - content: "\f014"; -} -.fa-home:before { - content: "\f015"; -} -.fa-file-o:before { - content: "\f016"; -} -.fa-clock-o:before { - content: "\f017"; -} -.fa-road:before { - content: "\f018"; -} -.fa-download:before { - content: "\f019"; -} -.fa-arrow-circle-o-down:before { - content: "\f01a"; -} -.fa-arrow-circle-o-up:before { - content: "\f01b"; -} -.fa-inbox:before { - content: "\f01c"; -} -.fa-play-circle-o:before { - content: "\f01d"; -} -.fa-rotate-right:before, -.fa-repeat:before { - content: "\f01e"; -} -.fa-refresh:before { - content: "\f021"; -} -.fa-list-alt:before { - content: "\f022"; -} -.fa-lock:before { - content: "\f023"; -} -.fa-flag:before { - content: "\f024"; -} -.fa-headphones:before { - content: "\f025"; -} -.fa-volume-off:before { - content: "\f026"; -} -.fa-volume-down:before { - content: "\f027"; -} -.fa-volume-up:before { - content: "\f028"; -} -.fa-qrcode:before { - content: "\f029"; -} -.fa-barcode:before { - content: "\f02a"; -} -.fa-tag:before { - content: "\f02b"; -} -.fa-tags:before { - content: "\f02c"; -} -.fa-book:before { - content: "\f02d"; -} -.fa-bookmark:before { - content: "\f02e"; -} -.fa-print:before { - content: "\f02f"; -} -.fa-camera:before { - content: "\f030"; -} -.fa-font:before { - content: "\f031"; -} -.fa-bold:before { - content: "\f032"; -} -.fa-italic:before { - content: "\f033"; -} -.fa-text-height:before { - content: "\f034"; -} -.fa-text-width:before { - content: "\f035"; -} -.fa-align-left:before { - content: "\f036"; -} -.fa-align-center:before { - content: "\f037"; -} -.fa-align-right:before { - content: "\f038"; -} -.fa-align-justify:before { - content: "\f039"; -} -.fa-list:before { - content: "\f03a"; -} -.fa-dedent:before, -.fa-outdent:before { - content: "\f03b"; -} -.fa-indent:before { - content: "\f03c"; -} -.fa-video-camera:before { - content: "\f03d"; -} -.fa-photo:before, -.fa-image:before, -.fa-picture-o:before { - content: "\f03e"; -} -.fa-pencil:before { - content: "\f040"; -} -.fa-map-marker:before { - content: "\f041"; -} -.fa-adjust:before { - content: "\f042"; -} -.fa-tint:before { - content: "\f043"; -} -.fa-edit:before, -.fa-pencil-square-o:before { - content: "\f044"; -} -.fa-share-square-o:before { - content: "\f045"; -} -.fa-check-square-o:before { - content: "\f046"; -} -.fa-arrows:before { - content: "\f047"; -} -.fa-step-backward:before { - content: "\f048"; -} -.fa-fast-backward:before { - content: "\f049"; -} -.fa-backward:before { - content: "\f04a"; -} -.fa-play:before { - content: "\f04b"; -} -.fa-pause:before { - content: "\f04c"; -} -.fa-stop:before { - content: "\f04d"; -} -.fa-forward:before { - content: "\f04e"; -} -.fa-fast-forward:before { - content: "\f050"; -} -.fa-step-forward:before { - content: "\f051"; -} -.fa-eject:before { - content: "\f052"; -} -.fa-chevron-left:before { - content: "\f053"; -} -.fa-chevron-right:before { - content: "\f054"; -} -.fa-plus-circle:before { - content: "\f055"; -} -.fa-minus-circle:before { - content: "\f056"; -} -.fa-times-circle:before { - content: "\f057"; -} -.fa-check-circle:before { - content: "\f058"; -} -.fa-question-circle:before { - content: "\f059"; -} -.fa-info-circle:before { - content: "\f05a"; -} -.fa-crosshairs:before { - content: "\f05b"; -} -.fa-times-circle-o:before { - content: "\f05c"; -} -.fa-check-circle-o:before { - content: "\f05d"; -} -.fa-ban:before { - content: "\f05e"; -} -.fa-arrow-left:before { - content: "\f060"; -} -.fa-arrow-right:before { - content: "\f061"; -} -.fa-arrow-up:before { - content: "\f062"; -} -.fa-arrow-down:before { - content: "\f063"; -} -.fa-mail-forward:before, -.fa-share:before { - content: "\f064"; -} -.fa-expand:before { - content: "\f065"; -} -.fa-compress:before { - content: "\f066"; -} -.fa-plus:before { - content: "\f067"; -} -.fa-minus:before { - content: "\f068"; -} -.fa-asterisk:before { - content: "\f069"; -} -.fa-exclamation-circle:before { - content: "\f06a"; -} -.fa-gift:before { - content: "\f06b"; -} -.fa-leaf:before { - content: "\f06c"; -} -.fa-fire:before { - content: "\f06d"; -} -.fa-eye:before { - content: "\f06e"; -} -.fa-eye-slash:before { - content: "\f070"; -} -.fa-warning:before, -.fa-exclamation-triangle:before { - content: "\f071"; -} -.fa-plane:before { - content: "\f072"; -} -.fa-calendar:before { - content: "\f073"; -} -.fa-random:before { - content: "\f074"; -} -.fa-comment:before { - content: "\f075"; -} -.fa-magnet:before { - content: "\f076"; -} -.fa-chevron-up:before { - content: "\f077"; -} -.fa-chevron-down:before { - content: "\f078"; -} -.fa-retweet:before { - content: "\f079"; -} -.fa-shopping-cart:before { - content: "\f07a"; -} -.fa-folder:before { - content: "\f07b"; -} -.fa-folder-open:before { - content: "\f07c"; -} -.fa-arrows-v:before { - content: "\f07d"; -} -.fa-arrows-h:before { - content: "\f07e"; -} -.fa-bar-chart-o:before, -.fa-bar-chart:before { - content: "\f080"; -} -.fa-twitter-square:before { - content: "\f081"; -} -.fa-facebook-square:before { - content: "\f082"; -} -.fa-camera-retro:before { - content: "\f083"; -} -.fa-key:before { - content: "\f084"; -} -.fa-gears:before, -.fa-cogs:before { - content: "\f085"; -} -.fa-comments:before { - content: "\f086"; -} -.fa-thumbs-o-up:before { - content: "\f087"; -} -.fa-thumbs-o-down:before { - content: "\f088"; -} -.fa-star-half:before { - content: "\f089"; -} -.fa-heart-o:before { - content: "\f08a"; -} -.fa-sign-out:before { - content: "\f08b"; -} -.fa-linkedin-square:before { - content: "\f08c"; -} -.fa-thumb-tack:before { - content: "\f08d"; -} -.fa-external-link:before { - content: "\f08e"; -} -.fa-sign-in:before { - content: "\f090"; -} -.fa-trophy:before { - content: "\f091"; -} -.fa-github-square:before { - content: "\f092"; -} -.fa-upload:before { - content: "\f093"; -} -.fa-lemon-o:before { - content: "\f094"; -} -.fa-phone:before { - content: "\f095"; -} -.fa-square-o:before { - content: "\f096"; -} -.fa-bookmark-o:before { - content: "\f097"; -} -.fa-phone-square:before { - content: "\f098"; -} -.fa-twitter:before { - content: "\f099"; -} -.fa-facebook-f:before, -.fa-facebook:before { - content: "\f09a"; -} -.fa-github:before { - content: "\f09b"; -} -.fa-unlock:before { - content: "\f09c"; -} -.fa-credit-card:before { - content: "\f09d"; -} -.fa-feed:before, -.fa-rss:before { - content: "\f09e"; -} -.fa-hdd-o:before { - content: "\f0a0"; -} -.fa-bullhorn:before { - content: "\f0a1"; -} -.fa-bell:before { - content: "\f0f3"; -} -.fa-certificate:before { - content: "\f0a3"; -} -.fa-hand-o-right:before { - content: "\f0a4"; -} -.fa-hand-o-left:before { - content: "\f0a5"; -} -.fa-hand-o-up:before { - content: "\f0a6"; -} -.fa-hand-o-down:before { - content: "\f0a7"; -} -.fa-arrow-circle-left:before { - content: "\f0a8"; -} -.fa-arrow-circle-right:before { - content: "\f0a9"; -} -.fa-arrow-circle-up:before { - content: "\f0aa"; -} -.fa-arrow-circle-down:before { - content: "\f0ab"; -} -.fa-globe:before { - content: "\f0ac"; -} -.fa-wrench:before { - content: "\f0ad"; -} -.fa-tasks:before { - content: "\f0ae"; -} -.fa-filter:before { - content: "\f0b0"; -} -.fa-briefcase:before { - content: "\f0b1"; -} -.fa-arrows-alt:before { - content: "\f0b2"; -} -.fa-group:before, -.fa-users:before { - content: "\f0c0"; -} -.fa-chain:before, -.fa-link:before { - content: "\f0c1"; -} -.fa-cloud:before { - content: "\f0c2"; -} -.fa-flask:before { - content: "\f0c3"; -} -.fa-cut:before, -.fa-scissors:before { - content: "\f0c4"; -} -.fa-copy:before, -.fa-files-o:before { - content: "\f0c5"; -} -.fa-paperclip:before { - content: "\f0c6"; -} -.fa-save:before, -.fa-floppy-o:before { - content: "\f0c7"; -} -.fa-square:before { - content: "\f0c8"; -} -.fa-navicon:before, -.fa-reorder:before, -.fa-bars:before { - content: "\f0c9"; -} -.fa-list-ul:before { - content: "\f0ca"; -} -.fa-list-ol:before { - content: "\f0cb"; -} -.fa-strikethrough:before { - content: "\f0cc"; -} -.fa-underline:before { - content: "\f0cd"; -} -.fa-table:before { - content: "\f0ce"; -} -.fa-magic:before { - content: "\f0d0"; -} -.fa-truck:before { - content: "\f0d1"; -} -.fa-pinterest:before { - content: "\f0d2"; -} -.fa-pinterest-square:before { - content: "\f0d3"; -} -.fa-google-plus-square:before { - content: "\f0d4"; -} -.fa-google-plus:before { - content: "\f0d5"; -} -.fa-money:before { - content: "\f0d6"; -} -.fa-caret-down:before { - content: "\f0d7"; -} -.fa-caret-up:before { - content: "\f0d8"; -} -.fa-caret-left:before { - content: "\f0d9"; -} -.fa-caret-right:before { - content: "\f0da"; -} -.fa-columns:before { - content: "\f0db"; -} -.fa-unsorted:before, -.fa-sort:before { - content: "\f0dc"; -} -.fa-sort-down:before, -.fa-sort-desc:before { - content: "\f0dd"; -} -.fa-sort-up:before, -.fa-sort-asc:before { - content: "\f0de"; -} -.fa-envelope:before { - content: "\f0e0"; -} -.fa-linkedin:before { - content: "\f0e1"; -} -.fa-rotate-left:before, -.fa-undo:before { - content: "\f0e2"; -} -.fa-legal:before, -.fa-gavel:before { - content: "\f0e3"; -} -.fa-dashboard:before, -.fa-tachometer:before { - content: "\f0e4"; -} -.fa-comment-o:before { - content: "\f0e5"; -} -.fa-comments-o:before { - content: "\f0e6"; -} -.fa-flash:before, -.fa-bolt:before { - content: "\f0e7"; -} -.fa-sitemap:before { - content: "\f0e8"; -} -.fa-umbrella:before { - content: "\f0e9"; -} -.fa-paste:before, -.fa-clipboard:before { - content: "\f0ea"; -} -.fa-lightbulb-o:before { - content: "\f0eb"; -} -.fa-exchange:before { - content: "\f0ec"; -} -.fa-cloud-download:before { - content: "\f0ed"; -} -.fa-cloud-upload:before { - content: "\f0ee"; -} -.fa-user-md:before { - content: "\f0f0"; -} -.fa-stethoscope:before { - content: "\f0f1"; -} -.fa-suitcase:before { - content: "\f0f2"; -} -.fa-bell-o:before { - content: "\f0a2"; -} -.fa-coffee:before { - content: "\f0f4"; -} -.fa-cutlery:before { - content: "\f0f5"; -} -.fa-file-text-o:before { - content: "\f0f6"; -} -.fa-building-o:before { - content: "\f0f7"; -} -.fa-hospital-o:before { - content: "\f0f8"; -} -.fa-ambulance:before { - content: "\f0f9"; -} -.fa-medkit:before { - content: "\f0fa"; -} -.fa-fighter-jet:before { - content: "\f0fb"; -} -.fa-beer:before { - content: "\f0fc"; -} -.fa-h-square:before { - content: "\f0fd"; -} -.fa-plus-square:before { - content: "\f0fe"; -} -.fa-angle-double-left:before { - content: "\f100"; -} -.fa-angle-double-right:before { - content: "\f101"; -} -.fa-angle-double-up:before { - content: "\f102"; -} -.fa-angle-double-down:before { - content: "\f103"; -} -.fa-angle-left:before { - content: "\f104"; -} -.fa-angle-right:before { - content: "\f105"; -} -.fa-angle-up:before { - content: "\f106"; -} -.fa-angle-down:before { - content: "\f107"; -} -.fa-desktop:before { - content: "\f108"; -} -.fa-laptop:before { - content: "\f109"; -} -.fa-tablet:before { - content: "\f10a"; -} -.fa-mobile-phone:before, -.fa-mobile:before { - content: "\f10b"; -} -.fa-circle-o:before { - content: "\f10c"; -} -.fa-quote-left:before { - content: "\f10d"; -} -.fa-quote-right:before { - content: "\f10e"; -} -.fa-spinner:before { - content: "\f110"; -} -.fa-circle:before { - content: "\f111"; -} -.fa-mail-reply:before, -.fa-reply:before { - content: "\f112"; -} -.fa-github-alt:before { - content: "\f113"; -} -.fa-folder-o:before { - content: "\f114"; -} -.fa-folder-open-o:before { - content: "\f115"; -} -.fa-smile-o:before { - content: "\f118"; -} -.fa-frown-o:before { - content: "\f119"; -} -.fa-meh-o:before { - content: "\f11a"; -} -.fa-gamepad:before { - content: "\f11b"; -} -.fa-keyboard-o:before { - content: "\f11c"; -} -.fa-flag-o:before { - content: "\f11d"; -} -.fa-flag-checkered:before { - content: "\f11e"; -} -.fa-terminal:before { - content: "\f120"; -} -.fa-code:before { - content: "\f121"; -} -.fa-mail-reply-all:before, -.fa-reply-all:before { - content: "\f122"; -} -.fa-star-half-empty:before, -.fa-star-half-full:before, -.fa-star-half-o:before { - content: "\f123"; -} -.fa-location-arrow:before { - content: "\f124"; -} -.fa-crop:before { - content: "\f125"; -} -.fa-code-fork:before { - content: "\f126"; -} -.fa-unlink:before, -.fa-chain-broken:before { - content: "\f127"; -} -.fa-question:before { - content: "\f128"; -} -.fa-info:before { - content: "\f129"; -} -.fa-exclamation:before { - content: "\f12a"; -} -.fa-superscript:before { - content: "\f12b"; -} -.fa-subscript:before { - content: "\f12c"; -} -.fa-eraser:before { - content: "\f12d"; -} -.fa-puzzle-piece:before { - content: "\f12e"; -} -.fa-microphone:before { - content: "\f130"; -} -.fa-microphone-slash:before { - content: "\f131"; -} -.fa-shield:before { - content: "\f132"; -} -.fa-calendar-o:before { - content: "\f133"; -} -.fa-fire-extinguisher:before { - content: "\f134"; -} -.fa-rocket:before { - content: "\f135"; -} -.fa-maxcdn:before { - content: "\f136"; -} -.fa-chevron-circle-left:before { - content: "\f137"; -} -.fa-chevron-circle-right:before { - content: "\f138"; -} -.fa-chevron-circle-up:before { - content: "\f139"; -} -.fa-chevron-circle-down:before { - content: "\f13a"; -} -.fa-html5:before { - content: "\f13b"; -} -.fa-css3:before { - content: "\f13c"; -} -.fa-anchor:before { - content: "\f13d"; -} -.fa-unlock-alt:before { - content: "\f13e"; -} -.fa-bullseye:before { - content: "\f140"; -} -.fa-ellipsis-h:before { - content: "\f141"; -} -.fa-ellipsis-v:before { - content: "\f142"; -} -.fa-rss-square:before { - content: "\f143"; -} -.fa-play-circle:before { - content: "\f144"; -} -.fa-ticket:before { - content: "\f145"; -} -.fa-minus-square:before { - content: "\f146"; -} -.fa-minus-square-o:before { - content: "\f147"; -} -.fa-level-up:before { - content: "\f148"; -} -.fa-level-down:before { - content: "\f149"; -} -.fa-check-square:before { - content: "\f14a"; -} -.fa-pencil-square:before { - content: "\f14b"; -} -.fa-external-link-square:before { - content: "\f14c"; -} -.fa-share-square:before { - content: "\f14d"; -} -.fa-compass:before { - content: "\f14e"; -} -.fa-toggle-down:before, -.fa-caret-square-o-down:before { - content: "\f150"; -} -.fa-toggle-up:before, -.fa-caret-square-o-up:before { - content: "\f151"; -} -.fa-toggle-right:before, -.fa-caret-square-o-right:before { - content: "\f152"; -} -.fa-euro:before, -.fa-eur:before { - content: "\f153"; -} -.fa-gbp:before { - content: "\f154"; -} -.fa-dollar:before, -.fa-usd:before { - content: "\f155"; -} -.fa-rupee:before, -.fa-inr:before { - content: "\f156"; -} -.fa-cny:before, -.fa-rmb:before, -.fa-yen:before, -.fa-jpy:before { - content: "\f157"; -} -.fa-ruble:before, -.fa-rouble:before, -.fa-rub:before { - content: "\f158"; -} -.fa-won:before, -.fa-krw:before { - content: "\f159"; -} -.fa-bitcoin:before, -.fa-btc:before { - content: "\f15a"; -} -.fa-file:before { - content: "\f15b"; -} -.fa-file-text:before { - content: "\f15c"; -} -.fa-sort-alpha-asc:before { - content: "\f15d"; -} -.fa-sort-alpha-desc:before { - content: "\f15e"; -} -.fa-sort-amount-asc:before { - content: "\f160"; -} -.fa-sort-amount-desc:before { - content: "\f161"; -} -.fa-sort-numeric-asc:before { - content: "\f162"; -} -.fa-sort-numeric-desc:before { - content: "\f163"; -} -.fa-thumbs-up:before { - content: "\f164"; -} -.fa-thumbs-down:before { - content: "\f165"; -} -.fa-youtube-square:before { - content: "\f166"; -} -.fa-youtube:before { - content: "\f167"; -} -.fa-xing:before { - content: "\f168"; -} -.fa-xing-square:before { - content: "\f169"; -} -.fa-youtube-play:before { - content: "\f16a"; -} -.fa-dropbox:before { - content: "\f16b"; -} -.fa-stack-overflow:before { - content: "\f16c"; -} -.fa-instagram:before { - content: "\f16d"; -} -.fa-flickr:before { - content: "\f16e"; -} -.fa-adn:before { - content: "\f170"; -} -.fa-bitbucket:before { - content: "\f171"; -} -.fa-bitbucket-square:before { - content: "\f172"; -} -.fa-tumblr:before { - content: "\f173"; -} -.fa-tumblr-square:before { - content: "\f174"; -} -.fa-long-arrow-down:before { - content: "\f175"; -} -.fa-long-arrow-up:before { - content: "\f176"; -} -.fa-long-arrow-left:before { - content: "\f177"; -} -.fa-long-arrow-right:before { - content: "\f178"; -} -.fa-apple:before { - content: "\f179"; -} -.fa-windows:before { - content: "\f17a"; -} -.fa-android:before { - content: "\f17b"; -} -.fa-linux:before { - content: "\f17c"; -} -.fa-dribbble:before { - content: "\f17d"; -} -.fa-skype:before { - content: "\f17e"; -} -.fa-foursquare:before { - content: "\f180"; -} -.fa-trello:before { - content: "\f181"; -} -.fa-female:before { - content: "\f182"; -} -.fa-male:before { - content: "\f183"; -} -.fa-gittip:before, -.fa-gratipay:before { - content: "\f184"; -} -.fa-sun-o:before { - content: "\f185"; -} -.fa-moon-o:before { - content: "\f186"; -} -.fa-archive:before { - content: "\f187"; -} -.fa-bug:before { - content: "\f188"; -} -.fa-vk:before { - content: "\f189"; -} -.fa-weibo:before { - content: "\f18a"; -} -.fa-renren:before { - content: "\f18b"; -} -.fa-pagelines:before { - content: "\f18c"; -} -.fa-stack-exchange:before { - content: "\f18d"; -} -.fa-arrow-circle-o-right:before { - content: "\f18e"; -} -.fa-arrow-circle-o-left:before { - content: "\f190"; -} -.fa-toggle-left:before, -.fa-caret-square-o-left:before { - content: "\f191"; -} -.fa-dot-circle-o:before { - content: "\f192"; -} -.fa-wheelchair:before { - content: "\f193"; -} -.fa-vimeo-square:before { - content: "\f194"; -} -.fa-turkish-lira:before, -.fa-try:before { - content: "\f195"; -} -.fa-plus-square-o:before { - content: "\f196"; -} -.fa-space-shuttle:before { - content: "\f197"; -} -.fa-slack:before { - content: "\f198"; -} -.fa-envelope-square:before { - content: "\f199"; -} -.fa-wordpress:before { - content: "\f19a"; -} -.fa-openid:before { - content: "\f19b"; -} -.fa-institution:before, -.fa-bank:before, -.fa-university:before { - content: "\f19c"; -} -.fa-mortar-board:before, -.fa-graduation-cap:before { - content: "\f19d"; -} -.fa-yahoo:before { - content: "\f19e"; -} -.fa-google:before { - content: "\f1a0"; -} -.fa-reddit:before { - content: "\f1a1"; -} -.fa-reddit-square:before { - content: "\f1a2"; -} -.fa-stumbleupon-circle:before { - content: "\f1a3"; -} -.fa-stumbleupon:before { - content: "\f1a4"; -} -.fa-delicious:before { - content: "\f1a5"; -} -.fa-digg:before { - content: "\f1a6"; -} -.fa-pied-piper:before { - content: "\f1a7"; -} -.fa-pied-piper-alt:before { - content: "\f1a8"; -} -.fa-drupal:before { - content: "\f1a9"; -} -.fa-joomla:before { - content: "\f1aa"; -} -.fa-language:before { - content: "\f1ab"; -} -.fa-fax:before { - content: "\f1ac"; -} -.fa-building:before { - content: "\f1ad"; -} -.fa-child:before { - content: "\f1ae"; -} -.fa-paw:before { - content: "\f1b0"; -} -.fa-spoon:before { - content: "\f1b1"; -} -.fa-cube:before { - content: "\f1b2"; -} -.fa-cubes:before { - content: "\f1b3"; -} -.fa-behance:before { - content: "\f1b4"; -} -.fa-behance-square:before { - content: "\f1b5"; -} -.fa-steam:before { - content: "\f1b6"; -} -.fa-steam-square:before { - content: "\f1b7"; -} -.fa-recycle:before { - content: "\f1b8"; -} -.fa-automobile:before, -.fa-car:before { - content: "\f1b9"; -} -.fa-cab:before, -.fa-taxi:before { - content: "\f1ba"; -} -.fa-tree:before { - content: "\f1bb"; -} -.fa-spotify:before { - content: "\f1bc"; -} -.fa-deviantart:before { - content: "\f1bd"; -} -.fa-soundcloud:before { - content: "\f1be"; -} -.fa-database:before { - content: "\f1c0"; -} -.fa-file-pdf-o:before { - content: "\f1c1"; -} -.fa-file-word-o:before { - content: "\f1c2"; -} -.fa-file-excel-o:before { - content: "\f1c3"; -} -.fa-file-powerpoint-o:before { - content: "\f1c4"; -} -.fa-file-photo-o:before, -.fa-file-picture-o:before, -.fa-file-image-o:before { - content: "\f1c5"; -} -.fa-file-zip-o:before, -.fa-file-archive-o:before { - content: "\f1c6"; -} -.fa-file-sound-o:before, -.fa-file-audio-o:before { - content: "\f1c7"; -} -.fa-file-movie-o:before, -.fa-file-video-o:before { - content: "\f1c8"; -} -.fa-file-code-o:before { - content: "\f1c9"; -} -.fa-vine:before { - content: "\f1ca"; -} -.fa-codepen:before { - content: "\f1cb"; -} -.fa-jsfiddle:before { - content: "\f1cc"; -} -.fa-life-bouy:before, -.fa-life-buoy:before, -.fa-life-saver:before, -.fa-support:before, -.fa-life-ring:before { - content: "\f1cd"; -} -.fa-circle-o-notch:before { - content: "\f1ce"; -} -.fa-ra:before, -.fa-rebel:before { - content: "\f1d0"; -} -.fa-ge:before, -.fa-empire:before { - content: "\f1d1"; -} -.fa-git-square:before { - content: "\f1d2"; -} -.fa-git:before { - content: "\f1d3"; -} -.fa-y-combinator-square:before, -.fa-yc-square:before, -.fa-hacker-news:before { - content: "\f1d4"; -} -.fa-tencent-weibo:before { - content: "\f1d5"; -} -.fa-qq:before { - content: "\f1d6"; -} -.fa-wechat:before, -.fa-weixin:before { - content: "\f1d7"; -} -.fa-send:before, -.fa-paper-plane:before { - content: "\f1d8"; -} -.fa-send-o:before, -.fa-paper-plane-o:before { - content: "\f1d9"; -} -.fa-history:before { - content: "\f1da"; -} -.fa-circle-thin:before { - content: "\f1db"; -} -.fa-header:before { - content: "\f1dc"; -} -.fa-paragraph:before { - content: "\f1dd"; -} -.fa-sliders:before { - content: "\f1de"; -} -.fa-share-alt:before { - content: "\f1e0"; -} -.fa-share-alt-square:before { - content: "\f1e1"; -} -.fa-bomb:before { - content: "\f1e2"; -} -.fa-soccer-ball-o:before, -.fa-futbol-o:before { - content: "\f1e3"; -} -.fa-tty:before { - content: "\f1e4"; -} -.fa-binoculars:before { - content: "\f1e5"; -} -.fa-plug:before { - content: "\f1e6"; -} -.fa-slideshare:before { - content: "\f1e7"; -} -.fa-twitch:before { - content: "\f1e8"; -} -.fa-yelp:before { - content: "\f1e9"; -} -.fa-newspaper-o:before { - content: "\f1ea"; -} -.fa-wifi:before { - content: "\f1eb"; -} -.fa-calculator:before { - content: "\f1ec"; -} -.fa-paypal:before { - content: "\f1ed"; -} -.fa-google-wallet:before { - content: "\f1ee"; -} -.fa-cc-visa:before { - content: "\f1f0"; -} -.fa-cc-mastercard:before { - content: "\f1f1"; -} -.fa-cc-discover:before { - content: "\f1f2"; -} -.fa-cc-amex:before { - content: "\f1f3"; -} -.fa-cc-paypal:before { - content: "\f1f4"; -} -.fa-cc-stripe:before { - content: "\f1f5"; -} -.fa-bell-slash:before { - content: "\f1f6"; -} -.fa-bell-slash-o:before { - content: "\f1f7"; -} -.fa-trash:before { - content: "\f1f8"; -} -.fa-copyright:before { - content: "\f1f9"; -} -.fa-at:before { - content: "\f1fa"; -} -.fa-eyedropper:before { - content: "\f1fb"; -} -.fa-paint-brush:before { - content: "\f1fc"; -} -.fa-birthday-cake:before { - content: "\f1fd"; -} -.fa-area-chart:before { - content: "\f1fe"; -} -.fa-pie-chart:before { - content: "\f200"; -} -.fa-line-chart:before { - content: "\f201"; -} -.fa-lastfm:before { - content: "\f202"; -} -.fa-lastfm-square:before { - content: "\f203"; -} -.fa-toggle-off:before { - content: "\f204"; -} -.fa-toggle-on:before { - content: "\f205"; -} -.fa-bicycle:before { - content: "\f206"; -} -.fa-bus:before { - content: "\f207"; -} -.fa-ioxhost:before { - content: "\f208"; -} -.fa-angellist:before { - content: "\f209"; -} -.fa-cc:before { - content: "\f20a"; -} -.fa-shekel:before, -.fa-sheqel:before, -.fa-ils:before { - content: "\f20b"; -} -.fa-meanpath:before { - content: "\f20c"; -} -.fa-buysellads:before { - content: "\f20d"; -} -.fa-connectdevelop:before { - content: "\f20e"; -} -.fa-dashcube:before { - content: "\f210"; -} -.fa-forumbee:before { - content: "\f211"; -} -.fa-leanpub:before { - content: "\f212"; -} -.fa-sellsy:before { - content: "\f213"; -} -.fa-shirtsinbulk:before { - content: "\f214"; -} -.fa-simplybuilt:before { - content: "\f215"; -} -.fa-skyatlas:before { - content: "\f216"; -} -.fa-cart-plus:before { - content: "\f217"; -} -.fa-cart-arrow-down:before { - content: "\f218"; -} -.fa-diamond:before { - content: "\f219"; -} -.fa-ship:before { - content: "\f21a"; -} -.fa-user-secret:before { - content: "\f21b"; -} -.fa-motorcycle:before { - content: "\f21c"; -} -.fa-street-view:before { - content: "\f21d"; -} -.fa-heartbeat:before { - content: "\f21e"; -} -.fa-venus:before { - content: "\f221"; -} -.fa-mars:before { - content: "\f222"; -} -.fa-mercury:before { - content: "\f223"; -} -.fa-intersex:before, -.fa-transgender:before { - content: "\f224"; -} -.fa-transgender-alt:before { - content: "\f225"; -} -.fa-venus-double:before { - content: "\f226"; -} -.fa-mars-double:before { - content: "\f227"; -} -.fa-venus-mars:before { - content: "\f228"; -} -.fa-mars-stroke:before { - content: "\f229"; -} -.fa-mars-stroke-v:before { - content: "\f22a"; -} -.fa-mars-stroke-h:before { - content: "\f22b"; -} -.fa-neuter:before { - content: "\f22c"; -} -.fa-genderless:before { - content: "\f22d"; -} -.fa-facebook-official:before { - content: "\f230"; -} -.fa-pinterest-p:before { - content: "\f231"; -} -.fa-whatsapp:before { - content: "\f232"; -} -.fa-server:before { - content: "\f233"; -} -.fa-user-plus:before { - content: "\f234"; -} -.fa-user-times:before { - content: "\f235"; -} -.fa-hotel:before, -.fa-bed:before { - content: "\f236"; -} -.fa-viacoin:before { - content: "\f237"; -} -.fa-train:before { - content: "\f238"; -} -.fa-subway:before { - content: "\f239"; -} -.fa-medium:before { - content: "\f23a"; -} -.fa-yc:before, -.fa-y-combinator:before { - content: "\f23b"; -} -.fa-optin-monster:before { - content: "\f23c"; -} -.fa-opencart:before { - content: "\f23d"; -} -.fa-expeditedssl:before { - content: "\f23e"; -} -.fa-battery-4:before, -.fa-battery-full:before { - content: "\f240"; -} -.fa-battery-3:before, -.fa-battery-three-quarters:before { - content: "\f241"; -} -.fa-battery-2:before, -.fa-battery-half:before { - content: "\f242"; -} -.fa-battery-1:before, -.fa-battery-quarter:before { - content: "\f243"; -} -.fa-battery-0:before, -.fa-battery-empty:before { - content: "\f244"; -} -.fa-mouse-pointer:before { - content: "\f245"; -} -.fa-i-cursor:before { - content: "\f246"; -} -.fa-object-group:before { - content: "\f247"; -} -.fa-object-ungroup:before { - content: "\f248"; -} -.fa-sticky-note:before { - content: "\f249"; -} -.fa-sticky-note-o:before { - content: "\f24a"; -} -.fa-cc-jcb:before { - content: "\f24b"; -} -.fa-cc-diners-club:before { - content: "\f24c"; -} -.fa-clone:before { - content: "\f24d"; -} -.fa-balance-scale:before { - content: "\f24e"; -} -.fa-hourglass-o:before { - content: "\f250"; -} -.fa-hourglass-1:before, -.fa-hourglass-start:before { - content: "\f251"; -} -.fa-hourglass-2:before, -.fa-hourglass-half:before { - content: "\f252"; -} -.fa-hourglass-3:before, -.fa-hourglass-end:before { - content: "\f253"; -} -.fa-hourglass:before { - content: "\f254"; -} -.fa-hand-grab-o:before, -.fa-hand-rock-o:before { - content: "\f255"; -} -.fa-hand-stop-o:before, -.fa-hand-paper-o:before { - content: "\f256"; -} -.fa-hand-scissors-o:before { - content: "\f257"; -} -.fa-hand-lizard-o:before { - content: "\f258"; -} -.fa-hand-spock-o:before { - content: "\f259"; -} -.fa-hand-pointer-o:before { - content: "\f25a"; -} -.fa-hand-peace-o:before { - content: "\f25b"; -} -.fa-trademark:before { - content: "\f25c"; -} -.fa-registered:before { - content: "\f25d"; -} -.fa-creative-commons:before { - content: "\f25e"; -} -.fa-gg:before { - content: "\f260"; -} -.fa-gg-circle:before { - content: "\f261"; -} -.fa-tripadvisor:before { - content: "\f262"; -} -.fa-odnoklassniki:before { - content: "\f263"; -} -.fa-odnoklassniki-square:before { - content: "\f264"; -} -.fa-get-pocket:before { - content: "\f265"; -} -.fa-wikipedia-w:before { - content: "\f266"; -} -.fa-safari:before { - content: "\f267"; -} -.fa-chrome:before { - content: "\f268"; -} -.fa-firefox:before { - content: "\f269"; -} -.fa-opera:before { - content: "\f26a"; -} -.fa-internet-explorer:before { - content: "\f26b"; -} -.fa-tv:before, -.fa-television:before { - content: "\f26c"; -} -.fa-contao:before { - content: "\f26d"; -} -.fa-500px:before { - content: "\f26e"; -} -.fa-amazon:before { - content: "\f270"; -} -.fa-calendar-plus-o:before { - content: "\f271"; -} -.fa-calendar-minus-o:before { - content: "\f272"; -} -.fa-calendar-times-o:before { - content: "\f273"; -} -.fa-calendar-check-o:before { - content: "\f274"; -} -.fa-industry:before { - content: "\f275"; -} -.fa-map-pin:before { - content: "\f276"; -} -.fa-map-signs:before { - content: "\f277"; -} -.fa-map-o:before { - content: "\f278"; -} -.fa-map:before { - content: "\f279"; -} -.fa-commenting:before { - content: "\f27a"; -} -.fa-commenting-o:before { - content: "\f27b"; -} -.fa-houzz:before { - content: "\f27c"; -} -.fa-vimeo:before { - content: "\f27d"; -} -.fa-black-tie:before { - content: "\f27e"; -} -.fa-fonticons:before { - content: "\f280"; -} -.fa-reddit-alien:before { - content: "\f281"; -} -.fa-edge:before { - content: "\f282"; -} -.fa-credit-card-alt:before { - content: "\f283"; -} -.fa-codiepie:before { - content: "\f284"; -} -.fa-modx:before { - content: "\f285"; -} -.fa-fort-awesome:before { - content: "\f286"; -} -.fa-usb:before { - content: "\f287"; -} -.fa-product-hunt:before { - content: "\f288"; -} -.fa-mixcloud:before { - content: "\f289"; -} -.fa-scribd:before { - content: "\f28a"; -} -.fa-pause-circle:before { - content: "\f28b"; -} -.fa-pause-circle-o:before { - content: "\f28c"; -} -.fa-stop-circle:before { - content: "\f28d"; -} -.fa-stop-circle-o:before { - content: "\f28e"; -} -.fa-shopping-bag:before { - content: "\f290"; -} -.fa-shopping-basket:before { - content: "\f291"; -} -.fa-hashtag:before { - content: "\f292"; -} -.fa-bluetooth:before { - content: "\f293"; -} -.fa-bluetooth-b:before { - content: "\f294"; -} -.fa-percent:before { - content: "\f295"; -} diff --git a/examples/blog/static/fonts/FontAwesome.otf b/examples/blog/static/fonts/FontAwesome.otf deleted file mode 100644 index 3ed7f8b48..000000000 Binary files a/examples/blog/static/fonts/FontAwesome.otf and /dev/null differ diff --git a/examples/blog/static/fonts/fontawesome-webfont.eot b/examples/blog/static/fonts/fontawesome-webfont.eot deleted file mode 100644 index 9b6afaedc..000000000 Binary files a/examples/blog/static/fonts/fontawesome-webfont.eot and /dev/null differ diff --git a/examples/blog/static/fonts/fontawesome-webfont.svg b/examples/blog/static/fonts/fontawesome-webfont.svg deleted file mode 100644 index d05688e9e..000000000 --- a/examples/blog/static/fonts/fontawesome-webfont.svg +++ /dev/null @@ -1,655 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/examples/blog/static/fonts/fontawesome-webfont.ttf b/examples/blog/static/fonts/fontawesome-webfont.ttf deleted file mode 100644 index 26dea7951..000000000 Binary files a/examples/blog/static/fonts/fontawesome-webfont.ttf and /dev/null differ diff --git a/examples/blog/static/fonts/fontawesome-webfont.woff b/examples/blog/static/fonts/fontawesome-webfont.woff deleted file mode 100644 index dc35ce3c2..000000000 Binary files a/examples/blog/static/fonts/fontawesome-webfont.woff and /dev/null differ diff --git a/examples/blog/static/fonts/fontawesome-webfont.woff2 b/examples/blog/static/fonts/fontawesome-webfont.woff2 deleted file mode 100644 index 500e51725..000000000 Binary files a/examples/blog/static/fonts/fontawesome-webfont.woff2 and /dev/null differ diff --git a/examples/blog/static/fonts/glyphicons-halflings-regular.eot b/examples/blog/static/fonts/glyphicons-halflings-regular.eot deleted file mode 100644 index b93a4953f..000000000 Binary files a/examples/blog/static/fonts/glyphicons-halflings-regular.eot and /dev/null differ diff --git a/examples/blog/static/fonts/glyphicons-halflings-regular.svg b/examples/blog/static/fonts/glyphicons-halflings-regular.svg deleted file mode 100644 index 94fb5490a..000000000 --- a/examples/blog/static/fonts/glyphicons-halflings-regular.svg +++ /dev/null @@ -1,288 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/examples/blog/static/fonts/glyphicons-halflings-regular.ttf b/examples/blog/static/fonts/glyphicons-halflings-regular.ttf deleted file mode 100644 index 1413fc609..000000000 Binary files a/examples/blog/static/fonts/glyphicons-halflings-regular.ttf and /dev/null differ diff --git a/examples/blog/static/fonts/glyphicons-halflings-regular.woff b/examples/blog/static/fonts/glyphicons-halflings-regular.woff deleted file mode 100644 index 9e612858f..000000000 Binary files a/examples/blog/static/fonts/glyphicons-halflings-regular.woff and /dev/null differ diff --git a/examples/blog/static/fonts/glyphicons-halflings-regular.woff2 b/examples/blog/static/fonts/glyphicons-halflings-regular.woff2 deleted file mode 100644 index 64539b54c..000000000 Binary files a/examples/blog/static/fonts/glyphicons-halflings-regular.woff2 and /dev/null differ diff --git a/examples/blog/static/js/bootstrap.js b/examples/blog/static/js/bootstrap.js deleted file mode 100644 index 01fbbcbaa..000000000 --- a/examples/blog/static/js/bootstrap.js +++ /dev/null @@ -1,2363 +0,0 @@ -/*! - * Bootstrap v3.3.6 (http://getbootstrap.com) - * Copyright 2011-2015 Twitter, Inc. - * Licensed under the MIT license - */ - -if (typeof jQuery === 'undefined') { - throw new Error('Bootstrap\'s JavaScript requires jQuery') -} - -+function ($) { - 'use strict'; - var version = $.fn.jquery.split(' ')[0].split('.') - if ((version[0] < 2 && version[1] < 9) || (version[0] == 1 && version[1] == 9 && version[2] < 1) || (version[0] > 2)) { - throw new Error('Bootstrap\'s JavaScript requires jQuery version 1.9.1 or higher, but lower than version 3') - } -}(jQuery); - -/* ======================================================================== - * Bootstrap: transition.js v3.3.6 - * http://getbootstrap.com/javascript/#transitions - * ======================================================================== - * Copyright 2011-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - * ======================================================================== */ - - -+function ($) { - 'use strict'; - - // CSS TRANSITION SUPPORT (Shoutout: http://www.modernizr.com/) - // ============================================================ - - function transitionEnd() { - var el = document.createElement('bootstrap') - - var transEndEventNames = { - WebkitTransition : 'webkitTransitionEnd', - MozTransition : 'transitionend', - OTransition : 'oTransitionEnd otransitionend', - transition : 'transitionend' - } - - for (var name in transEndEventNames) { - if (el.style[name] !== undefined) { - return { end: transEndEventNames[name] } - } - } - - return false // explicit for ie8 ( ._.) - } - - // http://blog.alexmaccaw.com/css-transitions - $.fn.emulateTransitionEnd = function (duration) { - var called = false - var $el = this - $(this).one('bsTransitionEnd', function () { called = true }) - var callback = function () { if (!called) $($el).trigger($.support.transition.end) } - setTimeout(callback, duration) - return this - } - - $(function () { - $.support.transition = transitionEnd() - - if (!$.support.transition) return - - $.event.special.bsTransitionEnd = { - bindType: $.support.transition.end, - delegateType: $.support.transition.end, - handle: function (e) { - if ($(e.target).is(this)) return e.handleObj.handler.apply(this, arguments) - } - } - }) - -}(jQuery); - -/* ======================================================================== - * Bootstrap: alert.js v3.3.6 - * http://getbootstrap.com/javascript/#alerts - * ======================================================================== - * Copyright 2011-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - * ======================================================================== */ - - -+function ($) { - 'use strict'; - - // ALERT CLASS DEFINITION - // ====================== - - var dismiss = '[data-dismiss="alert"]' - var Alert = function (el) { - $(el).on('click', dismiss, this.close) - } - - Alert.VERSION = '3.3.6' - - Alert.TRANSITION_DURATION = 150 - - Alert.prototype.close = function (e) { - var $this = $(this) - var selector = $this.attr('data-target') - - if (!selector) { - selector = $this.attr('href') - selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7 - } - - var $parent = $(selector) - - if (e) e.preventDefault() - - if (!$parent.length) { - $parent = $this.closest('.alert') - } - - $parent.trigger(e = $.Event('close.bs.alert')) - - if (e.isDefaultPrevented()) return - - $parent.removeClass('in') - - function removeElement() { - // detach from parent, fire event then clean up data - $parent.detach().trigger('closed.bs.alert').remove() - } - - $.support.transition && $parent.hasClass('fade') ? - $parent - .one('bsTransitionEnd', removeElement) - .emulateTransitionEnd(Alert.TRANSITION_DURATION) : - removeElement() - } - - - // ALERT PLUGIN DEFINITION - // ======================= - - function Plugin(option) { - return this.each(function () { - var $this = $(this) - var data = $this.data('bs.alert') - - if (!data) $this.data('bs.alert', (data = new Alert(this))) - if (typeof option == 'string') data[option].call($this) - }) - } - - var old = $.fn.alert - - $.fn.alert = Plugin - $.fn.alert.Constructor = Alert - - - // ALERT NO CONFLICT - // ================= - - $.fn.alert.noConflict = function () { - $.fn.alert = old - return this - } - - - // ALERT DATA-API - // ============== - - $(document).on('click.bs.alert.data-api', dismiss, Alert.prototype.close) - -}(jQuery); - -/* ======================================================================== - * Bootstrap: button.js v3.3.6 - * http://getbootstrap.com/javascript/#buttons - * ======================================================================== - * Copyright 2011-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - * ======================================================================== */ - - -+function ($) { - 'use strict'; - - // BUTTON PUBLIC CLASS DEFINITION - // ============================== - - var Button = function (element, options) { - this.$element = $(element) - this.options = $.extend({}, Button.DEFAULTS, options) - this.isLoading = false - } - - Button.VERSION = '3.3.6' - - Button.DEFAULTS = { - loadingText: 'loading...' - } - - Button.prototype.setState = function (state) { - var d = 'disabled' - var $el = this.$element - var val = $el.is('input') ? 'val' : 'html' - var data = $el.data() - - state += 'Text' - - if (data.resetText == null) $el.data('resetText', $el[val]()) - - // push to event loop to allow forms to submit - setTimeout($.proxy(function () { - $el[val](data[state] == null ? this.options[state] : data[state]) - - if (state == 'loadingText') { - this.isLoading = true - $el.addClass(d).attr(d, d) - } else if (this.isLoading) { - this.isLoading = false - $el.removeClass(d).removeAttr(d) - } - }, this), 0) - } - - Button.prototype.toggle = function () { - var changed = true - var $parent = this.$element.closest('[data-toggle="buttons"]') - - if ($parent.length) { - var $input = this.$element.find('input') - if ($input.prop('type') == 'radio') { - if ($input.prop('checked')) changed = false - $parent.find('.active').removeClass('active') - this.$element.addClass('active') - } else if ($input.prop('type') == 'checkbox') { - if (($input.prop('checked')) !== this.$element.hasClass('active')) changed = false - this.$element.toggleClass('active') - } - $input.prop('checked', this.$element.hasClass('active')) - if (changed) $input.trigger('change') - } else { - this.$element.attr('aria-pressed', !this.$element.hasClass('active')) - this.$element.toggleClass('active') - } - } - - - // BUTTON PLUGIN DEFINITION - // ======================== - - function Plugin(option) { - return this.each(function () { - var $this = $(this) - var data = $this.data('bs.button') - var options = typeof option == 'object' && option - - if (!data) $this.data('bs.button', (data = new Button(this, options))) - - if (option == 'toggle') data.toggle() - else if (option) data.setState(option) - }) - } - - var old = $.fn.button - - $.fn.button = Plugin - $.fn.button.Constructor = Button - - - // BUTTON NO CONFLICT - // ================== - - $.fn.button.noConflict = function () { - $.fn.button = old - return this - } - - - // BUTTON DATA-API - // =============== - - $(document) - .on('click.bs.button.data-api', '[data-toggle^="button"]', function (e) { - var $btn = $(e.target) - if (!$btn.hasClass('btn')) $btn = $btn.closest('.btn') - Plugin.call($btn, 'toggle') - if (!($(e.target).is('input[type="radio"]') || $(e.target).is('input[type="checkbox"]'))) e.preventDefault() - }) - .on('focus.bs.button.data-api blur.bs.button.data-api', '[data-toggle^="button"]', function (e) { - $(e.target).closest('.btn').toggleClass('focus', /^focus(in)?$/.test(e.type)) - }) - -}(jQuery); - -/* ======================================================================== - * Bootstrap: carousel.js v3.3.6 - * http://getbootstrap.com/javascript/#carousel - * ======================================================================== - * Copyright 2011-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - * ======================================================================== */ - - -+function ($) { - 'use strict'; - - // CAROUSEL CLASS DEFINITION - // ========================= - - var Carousel = function (element, options) { - this.$element = $(element) - this.$indicators = this.$element.find('.carousel-indicators') - this.options = options - this.paused = null - this.sliding = null - this.interval = null - this.$active = null - this.$items = null - - this.options.keyboard && this.$element.on('keydown.bs.carousel', $.proxy(this.keydown, this)) - - this.options.pause == 'hover' && !('ontouchstart' in document.documentElement) && this.$element - .on('mouseenter.bs.carousel', $.proxy(this.pause, this)) - .on('mouseleave.bs.carousel', $.proxy(this.cycle, this)) - } - - Carousel.VERSION = '3.3.6' - - Carousel.TRANSITION_DURATION = 600 - - Carousel.DEFAULTS = { - interval: 5000, - pause: 'hover', - wrap: true, - keyboard: true - } - - Carousel.prototype.keydown = function (e) { - if (/input|textarea/i.test(e.target.tagName)) return - switch (e.which) { - case 37: this.prev(); break - case 39: this.next(); break - default: return - } - - e.preventDefault() - } - - Carousel.prototype.cycle = function (e) { - e || (this.paused = false) - - this.interval && clearInterval(this.interval) - - this.options.interval - && !this.paused - && (this.interval = setInterval($.proxy(this.next, this), this.options.interval)) - - return this - } - - Carousel.prototype.getItemIndex = function (item) { - this.$items = item.parent().children('.item') - return this.$items.index(item || this.$active) - } - - Carousel.prototype.getItemForDirection = function (direction, active) { - var activeIndex = this.getItemIndex(active) - var willWrap = (direction == 'prev' && activeIndex === 0) - || (direction == 'next' && activeIndex == (this.$items.length - 1)) - if (willWrap && !this.options.wrap) return active - var delta = direction == 'prev' ? -1 : 1 - var itemIndex = (activeIndex + delta) % this.$items.length - return this.$items.eq(itemIndex) - } - - Carousel.prototype.to = function (pos) { - var that = this - var activeIndex = this.getItemIndex(this.$active = this.$element.find('.item.active')) - - if (pos > (this.$items.length - 1) || pos < 0) return - - if (this.sliding) return this.$element.one('slid.bs.carousel', function () { that.to(pos) }) // yes, "slid" - if (activeIndex == pos) return this.pause().cycle() - - return this.slide(pos > activeIndex ? 'next' : 'prev', this.$items.eq(pos)) - } - - Carousel.prototype.pause = function (e) { - e || (this.paused = true) - - if (this.$element.find('.next, .prev').length && $.support.transition) { - this.$element.trigger($.support.transition.end) - this.cycle(true) - } - - this.interval = clearInterval(this.interval) - - return this - } - - Carousel.prototype.next = function () { - if (this.sliding) return - return this.slide('next') - } - - Carousel.prototype.prev = function () { - if (this.sliding) return - return this.slide('prev') - } - - Carousel.prototype.slide = function (type, next) { - var $active = this.$element.find('.item.active') - var $next = next || this.getItemForDirection(type, $active) - var isCycling = this.interval - var direction = type == 'next' ? 'left' : 'right' - var that = this - - if ($next.hasClass('active')) return (this.sliding = false) - - var relatedTarget = $next[0] - var slideEvent = $.Event('slide.bs.carousel', { - relatedTarget: relatedTarget, - direction: direction - }) - this.$element.trigger(slideEvent) - if (slideEvent.isDefaultPrevented()) return - - this.sliding = true - - isCycling && this.pause() - - if (this.$indicators.length) { - this.$indicators.find('.active').removeClass('active') - var $nextIndicator = $(this.$indicators.children()[this.getItemIndex($next)]) - $nextIndicator && $nextIndicator.addClass('active') - } - - var slidEvent = $.Event('slid.bs.carousel', { relatedTarget: relatedTarget, direction: direction }) // yes, "slid" - if ($.support.transition && this.$element.hasClass('slide')) { - $next.addClass(type) - $next[0].offsetWidth // force reflow - $active.addClass(direction) - $next.addClass(direction) - $active - .one('bsTransitionEnd', function () { - $next.removeClass([type, direction].join(' ')).addClass('active') - $active.removeClass(['active', direction].join(' ')) - that.sliding = false - setTimeout(function () { - that.$element.trigger(slidEvent) - }, 0) - }) - .emulateTransitionEnd(Carousel.TRANSITION_DURATION) - } else { - $active.removeClass('active') - $next.addClass('active') - this.sliding = false - this.$element.trigger(slidEvent) - } - - isCycling && this.cycle() - - return this - } - - - // CAROUSEL PLUGIN DEFINITION - // ========================== - - function Plugin(option) { - return this.each(function () { - var $this = $(this) - var data = $this.data('bs.carousel') - var options = $.extend({}, Carousel.DEFAULTS, $this.data(), typeof option == 'object' && option) - var action = typeof option == 'string' ? option : options.slide - - if (!data) $this.data('bs.carousel', (data = new Carousel(this, options))) - if (typeof option == 'number') data.to(option) - else if (action) data[action]() - else if (options.interval) data.pause().cycle() - }) - } - - var old = $.fn.carousel - - $.fn.carousel = Plugin - $.fn.carousel.Constructor = Carousel - - - // CAROUSEL NO CONFLICT - // ==================== - - $.fn.carousel.noConflict = function () { - $.fn.carousel = old - return this - } - - - // CAROUSEL DATA-API - // ================= - - var clickHandler = function (e) { - var href - var $this = $(this) - var $target = $($this.attr('data-target') || (href = $this.attr('href')) && href.replace(/.*(?=#[^\s]+$)/, '')) // strip for ie7 - if (!$target.hasClass('carousel')) return - var options = $.extend({}, $target.data(), $this.data()) - var slideIndex = $this.attr('data-slide-to') - if (slideIndex) options.interval = false - - Plugin.call($target, options) - - if (slideIndex) { - $target.data('bs.carousel').to(slideIndex) - } - - e.preventDefault() - } - - $(document) - .on('click.bs.carousel.data-api', '[data-slide]', clickHandler) - .on('click.bs.carousel.data-api', '[data-slide-to]', clickHandler) - - $(window).on('load', function () { - $('[data-ride="carousel"]').each(function () { - var $carousel = $(this) - Plugin.call($carousel, $carousel.data()) - }) - }) - -}(jQuery); - -/* ======================================================================== - * Bootstrap: collapse.js v3.3.6 - * http://getbootstrap.com/javascript/#collapse - * ======================================================================== - * Copyright 2011-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - * ======================================================================== */ - - -+function ($) { - 'use strict'; - - // COLLAPSE PUBLIC CLASS DEFINITION - // ================================ - - var Collapse = function (element, options) { - this.$element = $(element) - this.options = $.extend({}, Collapse.DEFAULTS, options) - this.$trigger = $('[data-toggle="collapse"][href="#' + element.id + '"],' + - '[data-toggle="collapse"][data-target="#' + element.id + '"]') - this.transitioning = null - - if (this.options.parent) { - this.$parent = this.getParent() - } else { - this.addAriaAndCollapsedClass(this.$element, this.$trigger) - } - - if (this.options.toggle) this.toggle() - } - - Collapse.VERSION = '3.3.6' - - Collapse.TRANSITION_DURATION = 350 - - Collapse.DEFAULTS = { - toggle: true - } - - Collapse.prototype.dimension = function () { - var hasWidth = this.$element.hasClass('width') - return hasWidth ? 'width' : 'height' - } - - Collapse.prototype.show = function () { - if (this.transitioning || this.$element.hasClass('in')) return - - var activesData - var actives = this.$parent && this.$parent.children('.panel').children('.in, .collapsing') - - if (actives && actives.length) { - activesData = actives.data('bs.collapse') - if (activesData && activesData.transitioning) return - } - - var startEvent = $.Event('show.bs.collapse') - this.$element.trigger(startEvent) - if (startEvent.isDefaultPrevented()) return - - if (actives && actives.length) { - Plugin.call(actives, 'hide') - activesData || actives.data('bs.collapse', null) - } - - var dimension = this.dimension() - - this.$element - .removeClass('collapse') - .addClass('collapsing')[dimension](0) - .attr('aria-expanded', true) - - this.$trigger - .removeClass('collapsed') - .attr('aria-expanded', true) - - this.transitioning = 1 - - var complete = function () { - this.$element - .removeClass('collapsing') - .addClass('collapse in')[dimension]('') - this.transitioning = 0 - this.$element - .trigger('shown.bs.collapse') - } - - if (!$.support.transition) return complete.call(this) - - var scrollSize = $.camelCase(['scroll', dimension].join('-')) - - this.$element - .one('bsTransitionEnd', $.proxy(complete, this)) - .emulateTransitionEnd(Collapse.TRANSITION_DURATION)[dimension](this.$element[0][scrollSize]) - } - - Collapse.prototype.hide = function () { - if (this.transitioning || !this.$element.hasClass('in')) return - - var startEvent = $.Event('hide.bs.collapse') - this.$element.trigger(startEvent) - if (startEvent.isDefaultPrevented()) return - - var dimension = this.dimension() - - this.$element[dimension](this.$element[dimension]())[0].offsetHeight - - this.$element - .addClass('collapsing') - .removeClass('collapse in') - .attr('aria-expanded', false) - - this.$trigger - .addClass('collapsed') - .attr('aria-expanded', false) - - this.transitioning = 1 - - var complete = function () { - this.transitioning = 0 - this.$element - .removeClass('collapsing') - .addClass('collapse') - .trigger('hidden.bs.collapse') - } - - if (!$.support.transition) return complete.call(this) - - this.$element - [dimension](0) - .one('bsTransitionEnd', $.proxy(complete, this)) - .emulateTransitionEnd(Collapse.TRANSITION_DURATION) - } - - Collapse.prototype.toggle = function () { - this[this.$element.hasClass('in') ? 'hide' : 'show']() - } - - Collapse.prototype.getParent = function () { - return $(this.options.parent) - .find('[data-toggle="collapse"][data-parent="' + this.options.parent + '"]') - .each($.proxy(function (i, element) { - var $element = $(element) - this.addAriaAndCollapsedClass(getTargetFromTrigger($element), $element) - }, this)) - .end() - } - - Collapse.prototype.addAriaAndCollapsedClass = function ($element, $trigger) { - var isOpen = $element.hasClass('in') - - $element.attr('aria-expanded', isOpen) - $trigger - .toggleClass('collapsed', !isOpen) - .attr('aria-expanded', isOpen) - } - - function getTargetFromTrigger($trigger) { - var href - var target = $trigger.attr('data-target') - || (href = $trigger.attr('href')) && href.replace(/.*(?=#[^\s]+$)/, '') // strip for ie7 - - return $(target) - } - - - // COLLAPSE PLUGIN DEFINITION - // ========================== - - function Plugin(option) { - return this.each(function () { - var $this = $(this) - var data = $this.data('bs.collapse') - var options = $.extend({}, Collapse.DEFAULTS, $this.data(), typeof option == 'object' && option) - - if (!data && options.toggle && /show|hide/.test(option)) options.toggle = false - if (!data) $this.data('bs.collapse', (data = new Collapse(this, options))) - if (typeof option == 'string') data[option]() - }) - } - - var old = $.fn.collapse - - $.fn.collapse = Plugin - $.fn.collapse.Constructor = Collapse - - - // COLLAPSE NO CONFLICT - // ==================== - - $.fn.collapse.noConflict = function () { - $.fn.collapse = old - return this - } - - - // COLLAPSE DATA-API - // ================= - - $(document).on('click.bs.collapse.data-api', '[data-toggle="collapse"]', function (e) { - var $this = $(this) - - if (!$this.attr('data-target')) e.preventDefault() - - var $target = getTargetFromTrigger($this) - var data = $target.data('bs.collapse') - var option = data ? 'toggle' : $this.data() - - Plugin.call($target, option) - }) - -}(jQuery); - -/* ======================================================================== - * Bootstrap: dropdown.js v3.3.6 - * http://getbootstrap.com/javascript/#dropdowns - * ======================================================================== - * Copyright 2011-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - * ======================================================================== */ - - -+function ($) { - 'use strict'; - - // DROPDOWN CLASS DEFINITION - // ========================= - - var backdrop = '.dropdown-backdrop' - var toggle = '[data-toggle="dropdown"]' - var Dropdown = function (element) { - $(element).on('click.bs.dropdown', this.toggle) - } - - Dropdown.VERSION = '3.3.6' - - function getParent($this) { - var selector = $this.attr('data-target') - - if (!selector) { - selector = $this.attr('href') - selector = selector && /#[A-Za-z]/.test(selector) && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7 - } - - var $parent = selector && $(selector) - - return $parent && $parent.length ? $parent : $this.parent() - } - - function clearMenus(e) { - if (e && e.which === 3) return - $(backdrop).remove() - $(toggle).each(function () { - var $this = $(this) - var $parent = getParent($this) - var relatedTarget = { relatedTarget: this } - - if (!$parent.hasClass('open')) return - - if (e && e.type == 'click' && /input|textarea/i.test(e.target.tagName) && $.contains($parent[0], e.target)) return - - $parent.trigger(e = $.Event('hide.bs.dropdown', relatedTarget)) - - if (e.isDefaultPrevented()) return - - $this.attr('aria-expanded', 'false') - $parent.removeClass('open').trigger($.Event('hidden.bs.dropdown', relatedTarget)) - }) - } - - Dropdown.prototype.toggle = function (e) { - var $this = $(this) - - if ($this.is('.disabled, :disabled')) return - - var $parent = getParent($this) - var isActive = $parent.hasClass('open') - - clearMenus() - - if (!isActive) { - if ('ontouchstart' in document.documentElement && !$parent.closest('.navbar-nav').length) { - // if mobile we use a backdrop because click events don't delegate - $(document.createElement('div')) - .addClass('dropdown-backdrop') - .insertAfter($(this)) - .on('click', clearMenus) - } - - var relatedTarget = { relatedTarget: this } - $parent.trigger(e = $.Event('show.bs.dropdown', relatedTarget)) - - if (e.isDefaultPrevented()) return - - $this - .trigger('focus') - .attr('aria-expanded', 'true') - - $parent - .toggleClass('open') - .trigger($.Event('shown.bs.dropdown', relatedTarget)) - } - - return false - } - - Dropdown.prototype.keydown = function (e) { - if (!/(38|40|27|32)/.test(e.which) || /input|textarea/i.test(e.target.tagName)) return - - var $this = $(this) - - e.preventDefault() - e.stopPropagation() - - if ($this.is('.disabled, :disabled')) return - - var $parent = getParent($this) - var isActive = $parent.hasClass('open') - - if (!isActive && e.which != 27 || isActive && e.which == 27) { - if (e.which == 27) $parent.find(toggle).trigger('focus') - return $this.trigger('click') - } - - var desc = ' li:not(.disabled):visible a' - var $items = $parent.find('.dropdown-menu' + desc) - - if (!$items.length) return - - var index = $items.index(e.target) - - if (e.which == 38 && index > 0) index-- // up - if (e.which == 40 && index < $items.length - 1) index++ // down - if (!~index) index = 0 - - $items.eq(index).trigger('focus') - } - - - // DROPDOWN PLUGIN DEFINITION - // ========================== - - function Plugin(option) { - return this.each(function () { - var $this = $(this) - var data = $this.data('bs.dropdown') - - if (!data) $this.data('bs.dropdown', (data = new Dropdown(this))) - if (typeof option == 'string') data[option].call($this) - }) - } - - var old = $.fn.dropdown - - $.fn.dropdown = Plugin - $.fn.dropdown.Constructor = Dropdown - - - // DROPDOWN NO CONFLICT - // ==================== - - $.fn.dropdown.noConflict = function () { - $.fn.dropdown = old - return this - } - - - // APPLY TO STANDARD DROPDOWN ELEMENTS - // =================================== - - $(document) - .on('click.bs.dropdown.data-api', clearMenus) - .on('click.bs.dropdown.data-api', '.dropdown form', function (e) { e.stopPropagation() }) - .on('click.bs.dropdown.data-api', toggle, Dropdown.prototype.toggle) - .on('keydown.bs.dropdown.data-api', toggle, Dropdown.prototype.keydown) - .on('keydown.bs.dropdown.data-api', '.dropdown-menu', Dropdown.prototype.keydown) - -}(jQuery); - -/* ======================================================================== - * Bootstrap: modal.js v3.3.6 - * http://getbootstrap.com/javascript/#modals - * ======================================================================== - * Copyright 2011-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - * ======================================================================== */ - - -+function ($) { - 'use strict'; - - // MODAL CLASS DEFINITION - // ====================== - - var Modal = function (element, options) { - this.options = options - this.$body = $(document.body) - this.$element = $(element) - this.$dialog = this.$element.find('.modal-dialog') - this.$backdrop = null - this.isShown = null - this.originalBodyPad = null - this.scrollbarWidth = 0 - this.ignoreBackdropClick = false - - if (this.options.remote) { - this.$element - .find('.modal-content') - .load(this.options.remote, $.proxy(function () { - this.$element.trigger('loaded.bs.modal') - }, this)) - } - } - - Modal.VERSION = '3.3.6' - - Modal.TRANSITION_DURATION = 300 - Modal.BACKDROP_TRANSITION_DURATION = 150 - - Modal.DEFAULTS = { - backdrop: true, - keyboard: true, - show: true - } - - Modal.prototype.toggle = function (_relatedTarget) { - return this.isShown ? this.hide() : this.show(_relatedTarget) - } - - Modal.prototype.show = function (_relatedTarget) { - var that = this - var e = $.Event('show.bs.modal', { relatedTarget: _relatedTarget }) - - this.$element.trigger(e) - - if (this.isShown || e.isDefaultPrevented()) return - - this.isShown = true - - this.checkScrollbar() - this.setScrollbar() - this.$body.addClass('modal-open') - - this.escape() - this.resize() - - this.$element.on('click.dismiss.bs.modal', '[data-dismiss="modal"]', $.proxy(this.hide, this)) - - this.$dialog.on('mousedown.dismiss.bs.modal', function () { - that.$element.one('mouseup.dismiss.bs.modal', function (e) { - if ($(e.target).is(that.$element)) that.ignoreBackdropClick = true - }) - }) - - this.backdrop(function () { - var transition = $.support.transition && that.$element.hasClass('fade') - - if (!that.$element.parent().length) { - that.$element.appendTo(that.$body) // don't move modals dom position - } - - that.$element - .show() - .scrollTop(0) - - that.adjustDialog() - - if (transition) { - that.$element[0].offsetWidth // force reflow - } - - that.$element.addClass('in') - - that.enforceFocus() - - var e = $.Event('shown.bs.modal', { relatedTarget: _relatedTarget }) - - transition ? - that.$dialog // wait for modal to slide in - .one('bsTransitionEnd', function () { - that.$element.trigger('focus').trigger(e) - }) - .emulateTransitionEnd(Modal.TRANSITION_DURATION) : - that.$element.trigger('focus').trigger(e) - }) - } - - Modal.prototype.hide = function (e) { - if (e) e.preventDefault() - - e = $.Event('hide.bs.modal') - - this.$element.trigger(e) - - if (!this.isShown || e.isDefaultPrevented()) return - - this.isShown = false - - this.escape() - this.resize() - - $(document).off('focusin.bs.modal') - - this.$element - .removeClass('in') - .off('click.dismiss.bs.modal') - .off('mouseup.dismiss.bs.modal') - - this.$dialog.off('mousedown.dismiss.bs.modal') - - $.support.transition && this.$element.hasClass('fade') ? - this.$element - .one('bsTransitionEnd', $.proxy(this.hideModal, this)) - .emulateTransitionEnd(Modal.TRANSITION_DURATION) : - this.hideModal() - } - - Modal.prototype.enforceFocus = function () { - $(document) - .off('focusin.bs.modal') // guard against infinite focus loop - .on('focusin.bs.modal', $.proxy(function (e) { - if (this.$element[0] !== e.target && !this.$element.has(e.target).length) { - this.$element.trigger('focus') - } - }, this)) - } - - Modal.prototype.escape = function () { - if (this.isShown && this.options.keyboard) { - this.$element.on('keydown.dismiss.bs.modal', $.proxy(function (e) { - e.which == 27 && this.hide() - }, this)) - } else if (!this.isShown) { - this.$element.off('keydown.dismiss.bs.modal') - } - } - - Modal.prototype.resize = function () { - if (this.isShown) { - $(window).on('resize.bs.modal', $.proxy(this.handleUpdate, this)) - } else { - $(window).off('resize.bs.modal') - } - } - - Modal.prototype.hideModal = function () { - var that = this - this.$element.hide() - this.backdrop(function () { - that.$body.removeClass('modal-open') - that.resetAdjustments() - that.resetScrollbar() - that.$element.trigger('hidden.bs.modal') - }) - } - - Modal.prototype.removeBackdrop = function () { - this.$backdrop && this.$backdrop.remove() - this.$backdrop = null - } - - Modal.prototype.backdrop = function (callback) { - var that = this - var animate = this.$element.hasClass('fade') ? 'fade' : '' - - if (this.isShown && this.options.backdrop) { - var doAnimate = $.support.transition && animate - - this.$backdrop = $(document.createElement('div')) - .addClass('modal-backdrop ' + animate) - .appendTo(this.$body) - - this.$element.on('click.dismiss.bs.modal', $.proxy(function (e) { - if (this.ignoreBackdropClick) { - this.ignoreBackdropClick = false - return - } - if (e.target !== e.currentTarget) return - this.options.backdrop == 'static' - ? this.$element[0].focus() - : this.hide() - }, this)) - - if (doAnimate) this.$backdrop[0].offsetWidth // force reflow - - this.$backdrop.addClass('in') - - if (!callback) return - - doAnimate ? - this.$backdrop - .one('bsTransitionEnd', callback) - .emulateTransitionEnd(Modal.BACKDROP_TRANSITION_DURATION) : - callback() - - } else if (!this.isShown && this.$backdrop) { - this.$backdrop.removeClass('in') - - var callbackRemove = function () { - that.removeBackdrop() - callback && callback() - } - $.support.transition && this.$element.hasClass('fade') ? - this.$backdrop - .one('bsTransitionEnd', callbackRemove) - .emulateTransitionEnd(Modal.BACKDROP_TRANSITION_DURATION) : - callbackRemove() - - } else if (callback) { - callback() - } - } - - // these following methods are used to handle overflowing modals - - Modal.prototype.handleUpdate = function () { - this.adjustDialog() - } - - Modal.prototype.adjustDialog = function () { - var modalIsOverflowing = this.$element[0].scrollHeight > document.documentElement.clientHeight - - this.$element.css({ - paddingLeft: !this.bodyIsOverflowing && modalIsOverflowing ? this.scrollbarWidth : '', - paddingRight: this.bodyIsOverflowing && !modalIsOverflowing ? this.scrollbarWidth : '' - }) - } - - Modal.prototype.resetAdjustments = function () { - this.$element.css({ - paddingLeft: '', - paddingRight: '' - }) - } - - Modal.prototype.checkScrollbar = function () { - var fullWindowWidth = window.innerWidth - if (!fullWindowWidth) { // workaround for missing window.innerWidth in IE8 - var documentElementRect = document.documentElement.getBoundingClientRect() - fullWindowWidth = documentElementRect.right - Math.abs(documentElementRect.left) - } - this.bodyIsOverflowing = document.body.clientWidth < fullWindowWidth - this.scrollbarWidth = this.measureScrollbar() - } - - Modal.prototype.setScrollbar = function () { - var bodyPad = parseInt((this.$body.css('padding-right') || 0), 10) - this.originalBodyPad = document.body.style.paddingRight || '' - if (this.bodyIsOverflowing) this.$body.css('padding-right', bodyPad + this.scrollbarWidth) - } - - Modal.prototype.resetScrollbar = function () { - this.$body.css('padding-right', this.originalBodyPad) - } - - Modal.prototype.measureScrollbar = function () { // thx walsh - var scrollDiv = document.createElement('div') - scrollDiv.className = 'modal-scrollbar-measure' - this.$body.append(scrollDiv) - var scrollbarWidth = scrollDiv.offsetWidth - scrollDiv.clientWidth - this.$body[0].removeChild(scrollDiv) - return scrollbarWidth - } - - - // MODAL PLUGIN DEFINITION - // ======================= - - function Plugin(option, _relatedTarget) { - return this.each(function () { - var $this = $(this) - var data = $this.data('bs.modal') - var options = $.extend({}, Modal.DEFAULTS, $this.data(), typeof option == 'object' && option) - - if (!data) $this.data('bs.modal', (data = new Modal(this, options))) - if (typeof option == 'string') data[option](_relatedTarget) - else if (options.show) data.show(_relatedTarget) - }) - } - - var old = $.fn.modal - - $.fn.modal = Plugin - $.fn.modal.Constructor = Modal - - - // MODAL NO CONFLICT - // ================= - - $.fn.modal.noConflict = function () { - $.fn.modal = old - return this - } - - - // MODAL DATA-API - // ============== - - $(document).on('click.bs.modal.data-api', '[data-toggle="modal"]', function (e) { - var $this = $(this) - var href = $this.attr('href') - var $target = $($this.attr('data-target') || (href && href.replace(/.*(?=#[^\s]+$)/, ''))) // strip for ie7 - var option = $target.data('bs.modal') ? 'toggle' : $.extend({ remote: !/#/.test(href) && href }, $target.data(), $this.data()) - - if ($this.is('a')) e.preventDefault() - - $target.one('show.bs.modal', function (showEvent) { - if (showEvent.isDefaultPrevented()) return // only register focus restorer if modal will actually get shown - $target.one('hidden.bs.modal', function () { - $this.is(':visible') && $this.trigger('focus') - }) - }) - Plugin.call($target, option, this) - }) - -}(jQuery); - -/* ======================================================================== - * Bootstrap: tooltip.js v3.3.6 - * http://getbootstrap.com/javascript/#tooltip - * Inspired by the original jQuery.tipsy by Jason Frame - * ======================================================================== - * Copyright 2011-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - * ======================================================================== */ - - -+function ($) { - 'use strict'; - - // TOOLTIP PUBLIC CLASS DEFINITION - // =============================== - - var Tooltip = function (element, options) { - this.type = null - this.options = null - this.enabled = null - this.timeout = null - this.hoverState = null - this.$element = null - this.inState = null - - this.init('tooltip', element, options) - } - - Tooltip.VERSION = '3.3.6' - - Tooltip.TRANSITION_DURATION = 150 - - Tooltip.DEFAULTS = { - animation: true, - placement: 'top', - selector: false, - template: '', - trigger: 'hover focus', - title: '', - delay: 0, - html: false, - container: false, - viewport: { - selector: 'body', - padding: 0 - } - } - - Tooltip.prototype.init = function (type, element, options) { - this.enabled = true - this.type = type - this.$element = $(element) - this.options = this.getOptions(options) - this.$viewport = this.options.viewport && $($.isFunction(this.options.viewport) ? this.options.viewport.call(this, this.$element) : (this.options.viewport.selector || this.options.viewport)) - this.inState = { click: false, hover: false, focus: false } - - if (this.$element[0] instanceof document.constructor && !this.options.selector) { - throw new Error('`selector` option must be specified when initializing ' + this.type + ' on the window.document object!') - } - - var triggers = this.options.trigger.split(' ') - - for (var i = triggers.length; i--;) { - var trigger = triggers[i] - - if (trigger == 'click') { - this.$element.on('click.' + this.type, this.options.selector, $.proxy(this.toggle, this)) - } else if (trigger != 'manual') { - var eventIn = trigger == 'hover' ? 'mouseenter' : 'focusin' - var eventOut = trigger == 'hover' ? 'mouseleave' : 'focusout' - - this.$element.on(eventIn + '.' + this.type, this.options.selector, $.proxy(this.enter, this)) - this.$element.on(eventOut + '.' + this.type, this.options.selector, $.proxy(this.leave, this)) - } - } - - this.options.selector ? - (this._options = $.extend({}, this.options, { trigger: 'manual', selector: '' })) : - this.fixTitle() - } - - Tooltip.prototype.getDefaults = function () { - return Tooltip.DEFAULTS - } - - Tooltip.prototype.getOptions = function (options) { - options = $.extend({}, this.getDefaults(), this.$element.data(), options) - - if (options.delay && typeof options.delay == 'number') { - options.delay = { - show: options.delay, - hide: options.delay - } - } - - return options - } - - Tooltip.prototype.getDelegateOptions = function () { - var options = {} - var defaults = this.getDefaults() - - this._options && $.each(this._options, function (key, value) { - if (defaults[key] != value) options[key] = value - }) - - return options - } - - Tooltip.prototype.enter = function (obj) { - var self = obj instanceof this.constructor ? - obj : $(obj.currentTarget).data('bs.' + this.type) - - if (!self) { - self = new this.constructor(obj.currentTarget, this.getDelegateOptions()) - $(obj.currentTarget).data('bs.' + this.type, self) - } - - if (obj instanceof $.Event) { - self.inState[obj.type == 'focusin' ? 'focus' : 'hover'] = true - } - - if (self.tip().hasClass('in') || self.hoverState == 'in') { - self.hoverState = 'in' - return - } - - clearTimeout(self.timeout) - - self.hoverState = 'in' - - if (!self.options.delay || !self.options.delay.show) return self.show() - - self.timeout = setTimeout(function () { - if (self.hoverState == 'in') self.show() - }, self.options.delay.show) - } - - Tooltip.prototype.isInStateTrue = function () { - for (var key in this.inState) { - if (this.inState[key]) return true - } - - return false - } - - Tooltip.prototype.leave = function (obj) { - var self = obj instanceof this.constructor ? - obj : $(obj.currentTarget).data('bs.' + this.type) - - if (!self) { - self = new this.constructor(obj.currentTarget, this.getDelegateOptions()) - $(obj.currentTarget).data('bs.' + this.type, self) - } - - if (obj instanceof $.Event) { - self.inState[obj.type == 'focusout' ? 'focus' : 'hover'] = false - } - - if (self.isInStateTrue()) return - - clearTimeout(self.timeout) - - self.hoverState = 'out' - - if (!self.options.delay || !self.options.delay.hide) return self.hide() - - self.timeout = setTimeout(function () { - if (self.hoverState == 'out') self.hide() - }, self.options.delay.hide) - } - - Tooltip.prototype.show = function () { - var e = $.Event('show.bs.' + this.type) - - if (this.hasContent() && this.enabled) { - this.$element.trigger(e) - - var inDom = $.contains(this.$element[0].ownerDocument.documentElement, this.$element[0]) - if (e.isDefaultPrevented() || !inDom) return - var that = this - - var $tip = this.tip() - - var tipId = this.getUID(this.type) - - this.setContent() - $tip.attr('id', tipId) - this.$element.attr('aria-describedby', tipId) - - if (this.options.animation) $tip.addClass('fade') - - var placement = typeof this.options.placement == 'function' ? - this.options.placement.call(this, $tip[0], this.$element[0]) : - this.options.placement - - var autoToken = /\s?auto?\s?/i - var autoPlace = autoToken.test(placement) - if (autoPlace) placement = placement.replace(autoToken, '') || 'top' - - $tip - .detach() - .css({ top: 0, left: 0, display: 'block' }) - .addClass(placement) - .data('bs.' + this.type, this) - - this.options.container ? $tip.appendTo(this.options.container) : $tip.insertAfter(this.$element) - this.$element.trigger('inserted.bs.' + this.type) - - var pos = this.getPosition() - var actualWidth = $tip[0].offsetWidth - var actualHeight = $tip[0].offsetHeight - - if (autoPlace) { - var orgPlacement = placement - var viewportDim = this.getPosition(this.$viewport) - - placement = placement == 'bottom' && pos.bottom + actualHeight > viewportDim.bottom ? 'top' : - placement == 'top' && pos.top - actualHeight < viewportDim.top ? 'bottom' : - placement == 'right' && pos.right + actualWidth > viewportDim.width ? 'left' : - placement == 'left' && pos.left - actualWidth < viewportDim.left ? 'right' : - placement - - $tip - .removeClass(orgPlacement) - .addClass(placement) - } - - var calculatedOffset = this.getCalculatedOffset(placement, pos, actualWidth, actualHeight) - - this.applyPlacement(calculatedOffset, placement) - - var complete = function () { - var prevHoverState = that.hoverState - that.$element.trigger('shown.bs.' + that.type) - that.hoverState = null - - if (prevHoverState == 'out') that.leave(that) - } - - $.support.transition && this.$tip.hasClass('fade') ? - $tip - .one('bsTransitionEnd', complete) - .emulateTransitionEnd(Tooltip.TRANSITION_DURATION) : - complete() - } - } - - Tooltip.prototype.applyPlacement = function (offset, placement) { - var $tip = this.tip() - var width = $tip[0].offsetWidth - var height = $tip[0].offsetHeight - - // manually read margins because getBoundingClientRect includes difference - var marginTop = parseInt($tip.css('margin-top'), 10) - var marginLeft = parseInt($tip.css('margin-left'), 10) - - // we must check for NaN for ie 8/9 - if (isNaN(marginTop)) marginTop = 0 - if (isNaN(marginLeft)) marginLeft = 0 - - offset.top += marginTop - offset.left += marginLeft - - // $.fn.offset doesn't round pixel values - // so we use setOffset directly with our own function B-0 - $.offset.setOffset($tip[0], $.extend({ - using: function (props) { - $tip.css({ - top: Math.round(props.top), - left: Math.round(props.left) - }) - } - }, offset), 0) - - $tip.addClass('in') - - // check to see if placing tip in new offset caused the tip to resize itself - var actualWidth = $tip[0].offsetWidth - var actualHeight = $tip[0].offsetHeight - - if (placement == 'top' && actualHeight != height) { - offset.top = offset.top + height - actualHeight - } - - var delta = this.getViewportAdjustedDelta(placement, offset, actualWidth, actualHeight) - - if (delta.left) offset.left += delta.left - else offset.top += delta.top - - var isVertical = /top|bottom/.test(placement) - var arrowDelta = isVertical ? delta.left * 2 - width + actualWidth : delta.top * 2 - height + actualHeight - var arrowOffsetPosition = isVertical ? 'offsetWidth' : 'offsetHeight' - - $tip.offset(offset) - this.replaceArrow(arrowDelta, $tip[0][arrowOffsetPosition], isVertical) - } - - Tooltip.prototype.replaceArrow = function (delta, dimension, isVertical) { - this.arrow() - .css(isVertical ? 'left' : 'top', 50 * (1 - delta / dimension) + '%') - .css(isVertical ? 'top' : 'left', '') - } - - Tooltip.prototype.setContent = function () { - var $tip = this.tip() - var title = this.getTitle() - - $tip.find('.tooltip-inner')[this.options.html ? 'html' : 'text'](title) - $tip.removeClass('fade in top bottom left right') - } - - Tooltip.prototype.hide = function (callback) { - var that = this - var $tip = $(this.$tip) - var e = $.Event('hide.bs.' + this.type) - - function complete() { - if (that.hoverState != 'in') $tip.detach() - that.$element - .removeAttr('aria-describedby') - .trigger('hidden.bs.' + that.type) - callback && callback() - } - - this.$element.trigger(e) - - if (e.isDefaultPrevented()) return - - $tip.removeClass('in') - - $.support.transition && $tip.hasClass('fade') ? - $tip - .one('bsTransitionEnd', complete) - .emulateTransitionEnd(Tooltip.TRANSITION_DURATION) : - complete() - - this.hoverState = null - - return this - } - - Tooltip.prototype.fixTitle = function () { - var $e = this.$element - if ($e.attr('title') || typeof $e.attr('data-original-title') != 'string') { - $e.attr('data-original-title', $e.attr('title') || '').attr('title', '') - } - } - - Tooltip.prototype.hasContent = function () { - return this.getTitle() - } - - Tooltip.prototype.getPosition = function ($element) { - $element = $element || this.$element - - var el = $element[0] - var isBody = el.tagName == 'BODY' - - var elRect = el.getBoundingClientRect() - if (elRect.width == null) { - // width and height are missing in IE8, so compute them manually; see https://github.com/twbs/bootstrap/issues/14093 - elRect = $.extend({}, elRect, { width: elRect.right - elRect.left, height: elRect.bottom - elRect.top }) - } - var elOffset = isBody ? { top: 0, left: 0 } : $element.offset() - var scroll = { scroll: isBody ? document.documentElement.scrollTop || document.body.scrollTop : $element.scrollTop() } - var outerDims = isBody ? { width: $(window).width(), height: $(window).height() } : null - - return $.extend({}, elRect, scroll, outerDims, elOffset) - } - - Tooltip.prototype.getCalculatedOffset = function (placement, pos, actualWidth, actualHeight) { - return placement == 'bottom' ? { top: pos.top + pos.height, left: pos.left + pos.width / 2 - actualWidth / 2 } : - placement == 'top' ? { top: pos.top - actualHeight, left: pos.left + pos.width / 2 - actualWidth / 2 } : - placement == 'left' ? { top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left - actualWidth } : - /* placement == 'right' */ { top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left + pos.width } - - } - - Tooltip.prototype.getViewportAdjustedDelta = function (placement, pos, actualWidth, actualHeight) { - var delta = { top: 0, left: 0 } - if (!this.$viewport) return delta - - var viewportPadding = this.options.viewport && this.options.viewport.padding || 0 - var viewportDimensions = this.getPosition(this.$viewport) - - if (/right|left/.test(placement)) { - var topEdgeOffset = pos.top - viewportPadding - viewportDimensions.scroll - var bottomEdgeOffset = pos.top + viewportPadding - viewportDimensions.scroll + actualHeight - if (topEdgeOffset < viewportDimensions.top) { // top overflow - delta.top = viewportDimensions.top - topEdgeOffset - } else if (bottomEdgeOffset > viewportDimensions.top + viewportDimensions.height) { // bottom overflow - delta.top = viewportDimensions.top + viewportDimensions.height - bottomEdgeOffset - } - } else { - var leftEdgeOffset = pos.left - viewportPadding - var rightEdgeOffset = pos.left + viewportPadding + actualWidth - if (leftEdgeOffset < viewportDimensions.left) { // left overflow - delta.left = viewportDimensions.left - leftEdgeOffset - } else if (rightEdgeOffset > viewportDimensions.right) { // right overflow - delta.left = viewportDimensions.left + viewportDimensions.width - rightEdgeOffset - } - } - - return delta - } - - Tooltip.prototype.getTitle = function () { - var title - var $e = this.$element - var o = this.options - - title = $e.attr('data-original-title') - || (typeof o.title == 'function' ? o.title.call($e[0]) : o.title) - - return title - } - - Tooltip.prototype.getUID = function (prefix) { - do prefix += ~~(Math.random() * 1000000) - while (document.getElementById(prefix)) - return prefix - } - - Tooltip.prototype.tip = function () { - if (!this.$tip) { - this.$tip = $(this.options.template) - if (this.$tip.length != 1) { - throw new Error(this.type + ' `template` option must consist of exactly 1 top-level element!') - } - } - return this.$tip - } - - Tooltip.prototype.arrow = function () { - return (this.$arrow = this.$arrow || this.tip().find('.tooltip-arrow')) - } - - Tooltip.prototype.enable = function () { - this.enabled = true - } - - Tooltip.prototype.disable = function () { - this.enabled = false - } - - Tooltip.prototype.toggleEnabled = function () { - this.enabled = !this.enabled - } - - Tooltip.prototype.toggle = function (e) { - var self = this - if (e) { - self = $(e.currentTarget).data('bs.' + this.type) - if (!self) { - self = new this.constructor(e.currentTarget, this.getDelegateOptions()) - $(e.currentTarget).data('bs.' + this.type, self) - } - } - - if (e) { - self.inState.click = !self.inState.click - if (self.isInStateTrue()) self.enter(self) - else self.leave(self) - } else { - self.tip().hasClass('in') ? self.leave(self) : self.enter(self) - } - } - - Tooltip.prototype.destroy = function () { - var that = this - clearTimeout(this.timeout) - this.hide(function () { - that.$element.off('.' + that.type).removeData('bs.' + that.type) - if (that.$tip) { - that.$tip.detach() - } - that.$tip = null - that.$arrow = null - that.$viewport = null - }) - } - - - // TOOLTIP PLUGIN DEFINITION - // ========================= - - function Plugin(option) { - return this.each(function () { - var $this = $(this) - var data = $this.data('bs.tooltip') - var options = typeof option == 'object' && option - - if (!data && /destroy|hide/.test(option)) return - if (!data) $this.data('bs.tooltip', (data = new Tooltip(this, options))) - if (typeof option == 'string') data[option]() - }) - } - - var old = $.fn.tooltip - - $.fn.tooltip = Plugin - $.fn.tooltip.Constructor = Tooltip - - - // TOOLTIP NO CONFLICT - // =================== - - $.fn.tooltip.noConflict = function () { - $.fn.tooltip = old - return this - } - -}(jQuery); - -/* ======================================================================== - * Bootstrap: popover.js v3.3.6 - * http://getbootstrap.com/javascript/#popovers - * ======================================================================== - * Copyright 2011-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - * ======================================================================== */ - - -+function ($) { - 'use strict'; - - // POPOVER PUBLIC CLASS DEFINITION - // =============================== - - var Popover = function (element, options) { - this.init('popover', element, options) - } - - if (!$.fn.tooltip) throw new Error('Popover requires tooltip.js') - - Popover.VERSION = '3.3.6' - - Popover.DEFAULTS = $.extend({}, $.fn.tooltip.Constructor.DEFAULTS, { - placement: 'right', - trigger: 'click', - content: '', - template: '' - }) - - - // NOTE: POPOVER EXTENDS tooltip.js - // ================================ - - Popover.prototype = $.extend({}, $.fn.tooltip.Constructor.prototype) - - Popover.prototype.constructor = Popover - - Popover.prototype.getDefaults = function () { - return Popover.DEFAULTS - } - - Popover.prototype.setContent = function () { - var $tip = this.tip() - var title = this.getTitle() - var content = this.getContent() - - $tip.find('.popover-title')[this.options.html ? 'html' : 'text'](title) - $tip.find('.popover-content').children().detach().end()[ // we use append for html objects to maintain js events - this.options.html ? (typeof content == 'string' ? 'html' : 'append') : 'text' - ](content) - - $tip.removeClass('fade top bottom left right in') - - // IE8 doesn't accept hiding via the `:empty` pseudo selector, we have to do - // this manually by checking the contents. - if (!$tip.find('.popover-title').html()) $tip.find('.popover-title').hide() - } - - Popover.prototype.hasContent = function () { - return this.getTitle() || this.getContent() - } - - Popover.prototype.getContent = function () { - var $e = this.$element - var o = this.options - - return $e.attr('data-content') - || (typeof o.content == 'function' ? - o.content.call($e[0]) : - o.content) - } - - Popover.prototype.arrow = function () { - return (this.$arrow = this.$arrow || this.tip().find('.arrow')) - } - - - // POPOVER PLUGIN DEFINITION - // ========================= - - function Plugin(option) { - return this.each(function () { - var $this = $(this) - var data = $this.data('bs.popover') - var options = typeof option == 'object' && option - - if (!data && /destroy|hide/.test(option)) return - if (!data) $this.data('bs.popover', (data = new Popover(this, options))) - if (typeof option == 'string') data[option]() - }) - } - - var old = $.fn.popover - - $.fn.popover = Plugin - $.fn.popover.Constructor = Popover - - - // POPOVER NO CONFLICT - // =================== - - $.fn.popover.noConflict = function () { - $.fn.popover = old - return this - } - -}(jQuery); - -/* ======================================================================== - * Bootstrap: scrollspy.js v3.3.6 - * http://getbootstrap.com/javascript/#scrollspy - * ======================================================================== - * Copyright 2011-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - * ======================================================================== */ - - -+function ($) { - 'use strict'; - - // SCROLLSPY CLASS DEFINITION - // ========================== - - function ScrollSpy(element, options) { - this.$body = $(document.body) - this.$scrollElement = $(element).is(document.body) ? $(window) : $(element) - this.options = $.extend({}, ScrollSpy.DEFAULTS, options) - this.selector = (this.options.target || '') + ' .nav li > a' - this.offsets = [] - this.targets = [] - this.activeTarget = null - this.scrollHeight = 0 - - this.$scrollElement.on('scroll.bs.scrollspy', $.proxy(this.process, this)) - this.refresh() - this.process() - } - - ScrollSpy.VERSION = '3.3.6' - - ScrollSpy.DEFAULTS = { - offset: 10 - } - - ScrollSpy.prototype.getScrollHeight = function () { - return this.$scrollElement[0].scrollHeight || Math.max(this.$body[0].scrollHeight, document.documentElement.scrollHeight) - } - - ScrollSpy.prototype.refresh = function () { - var that = this - var offsetMethod = 'offset' - var offsetBase = 0 - - this.offsets = [] - this.targets = [] - this.scrollHeight = this.getScrollHeight() - - if (!$.isWindow(this.$scrollElement[0])) { - offsetMethod = 'position' - offsetBase = this.$scrollElement.scrollTop() - } - - this.$body - .find(this.selector) - .map(function () { - var $el = $(this) - var href = $el.data('target') || $el.attr('href') - var $href = /^#./.test(href) && $(href) - - return ($href - && $href.length - && $href.is(':visible') - && [[$href[offsetMethod]().top + offsetBase, href]]) || null - }) - .sort(function (a, b) { return a[0] - b[0] }) - .each(function () { - that.offsets.push(this[0]) - that.targets.push(this[1]) - }) - } - - ScrollSpy.prototype.process = function () { - var scrollTop = this.$scrollElement.scrollTop() + this.options.offset - var scrollHeight = this.getScrollHeight() - var maxScroll = this.options.offset + scrollHeight - this.$scrollElement.height() - var offsets = this.offsets - var targets = this.targets - var activeTarget = this.activeTarget - var i - - if (this.scrollHeight != scrollHeight) { - this.refresh() - } - - if (scrollTop >= maxScroll) { - return activeTarget != (i = targets[targets.length - 1]) && this.activate(i) - } - - if (activeTarget && scrollTop < offsets[0]) { - this.activeTarget = null - return this.clear() - } - - for (i = offsets.length; i--;) { - activeTarget != targets[i] - && scrollTop >= offsets[i] - && (offsets[i + 1] === undefined || scrollTop < offsets[i + 1]) - && this.activate(targets[i]) - } - } - - ScrollSpy.prototype.activate = function (target) { - this.activeTarget = target - - this.clear() - - var selector = this.selector + - '[data-target="' + target + '"],' + - this.selector + '[href="' + target + '"]' - - var active = $(selector) - .parents('li') - .addClass('active') - - if (active.parent('.dropdown-menu').length) { - active = active - .closest('li.dropdown') - .addClass('active') - } - - active.trigger('activate.bs.scrollspy') - } - - ScrollSpy.prototype.clear = function () { - $(this.selector) - .parentsUntil(this.options.target, '.active') - .removeClass('active') - } - - - // SCROLLSPY PLUGIN DEFINITION - // =========================== - - function Plugin(option) { - return this.each(function () { - var $this = $(this) - var data = $this.data('bs.scrollspy') - var options = typeof option == 'object' && option - - if (!data) $this.data('bs.scrollspy', (data = new ScrollSpy(this, options))) - if (typeof option == 'string') data[option]() - }) - } - - var old = $.fn.scrollspy - - $.fn.scrollspy = Plugin - $.fn.scrollspy.Constructor = ScrollSpy - - - // SCROLLSPY NO CONFLICT - // ===================== - - $.fn.scrollspy.noConflict = function () { - $.fn.scrollspy = old - return this - } - - - // SCROLLSPY DATA-API - // ================== - - $(window).on('load.bs.scrollspy.data-api', function () { - $('[data-spy="scroll"]').each(function () { - var $spy = $(this) - Plugin.call($spy, $spy.data()) - }) - }) - -}(jQuery); - -/* ======================================================================== - * Bootstrap: tab.js v3.3.6 - * http://getbootstrap.com/javascript/#tabs - * ======================================================================== - * Copyright 2011-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - * ======================================================================== */ - - -+function ($) { - 'use strict'; - - // TAB CLASS DEFINITION - // ==================== - - var Tab = function (element) { - // jscs:disable requireDollarBeforejQueryAssignment - this.element = $(element) - // jscs:enable requireDollarBeforejQueryAssignment - } - - Tab.VERSION = '3.3.6' - - Tab.TRANSITION_DURATION = 150 - - Tab.prototype.show = function () { - var $this = this.element - var $ul = $this.closest('ul:not(.dropdown-menu)') - var selector = $this.data('target') - - if (!selector) { - selector = $this.attr('href') - selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7 - } - - if ($this.parent('li').hasClass('active')) return - - var $previous = $ul.find('.active:last a') - var hideEvent = $.Event('hide.bs.tab', { - relatedTarget: $this[0] - }) - var showEvent = $.Event('show.bs.tab', { - relatedTarget: $previous[0] - }) - - $previous.trigger(hideEvent) - $this.trigger(showEvent) - - if (showEvent.isDefaultPrevented() || hideEvent.isDefaultPrevented()) return - - var $target = $(selector) - - this.activate($this.closest('li'), $ul) - this.activate($target, $target.parent(), function () { - $previous.trigger({ - type: 'hidden.bs.tab', - relatedTarget: $this[0] - }) - $this.trigger({ - type: 'shown.bs.tab', - relatedTarget: $previous[0] - }) - }) - } - - Tab.prototype.activate = function (element, container, callback) { - var $active = container.find('> .active') - var transition = callback - && $.support.transition - && ($active.length && $active.hasClass('fade') || !!container.find('> .fade').length) - - function next() { - $active - .removeClass('active') - .find('> .dropdown-menu > .active') - .removeClass('active') - .end() - .find('[data-toggle="tab"]') - .attr('aria-expanded', false) - - element - .addClass('active') - .find('[data-toggle="tab"]') - .attr('aria-expanded', true) - - if (transition) { - element[0].offsetWidth // reflow for transition - element.addClass('in') - } else { - element.removeClass('fade') - } - - if (element.parent('.dropdown-menu').length) { - element - .closest('li.dropdown') - .addClass('active') - .end() - .find('[data-toggle="tab"]') - .attr('aria-expanded', true) - } - - callback && callback() - } - - $active.length && transition ? - $active - .one('bsTransitionEnd', next) - .emulateTransitionEnd(Tab.TRANSITION_DURATION) : - next() - - $active.removeClass('in') - } - - - // TAB PLUGIN DEFINITION - // ===================== - - function Plugin(option) { - return this.each(function () { - var $this = $(this) - var data = $this.data('bs.tab') - - if (!data) $this.data('bs.tab', (data = new Tab(this))) - if (typeof option == 'string') data[option]() - }) - } - - var old = $.fn.tab - - $.fn.tab = Plugin - $.fn.tab.Constructor = Tab - - - // TAB NO CONFLICT - // =============== - - $.fn.tab.noConflict = function () { - $.fn.tab = old - return this - } - - - // TAB DATA-API - // ============ - - var clickHandler = function (e) { - e.preventDefault() - Plugin.call($(this), 'show') - } - - $(document) - .on('click.bs.tab.data-api', '[data-toggle="tab"]', clickHandler) - .on('click.bs.tab.data-api', '[data-toggle="pill"]', clickHandler) - -}(jQuery); - -/* ======================================================================== - * Bootstrap: affix.js v3.3.6 - * http://getbootstrap.com/javascript/#affix - * ======================================================================== - * Copyright 2011-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - * ======================================================================== */ - - -+function ($) { - 'use strict'; - - // AFFIX CLASS DEFINITION - // ====================== - - var Affix = function (element, options) { - this.options = $.extend({}, Affix.DEFAULTS, options) - - this.$target = $(this.options.target) - .on('scroll.bs.affix.data-api', $.proxy(this.checkPosition, this)) - .on('click.bs.affix.data-api', $.proxy(this.checkPositionWithEventLoop, this)) - - this.$element = $(element) - this.affixed = null - this.unpin = null - this.pinnedOffset = null - - this.checkPosition() - } - - Affix.VERSION = '3.3.6' - - Affix.RESET = 'affix affix-top affix-bottom' - - Affix.DEFAULTS = { - offset: 0, - target: window - } - - Affix.prototype.getState = function (scrollHeight, height, offsetTop, offsetBottom) { - var scrollTop = this.$target.scrollTop() - var position = this.$element.offset() - var targetHeight = this.$target.height() - - if (offsetTop != null && this.affixed == 'top') return scrollTop < offsetTop ? 'top' : false - - if (this.affixed == 'bottom') { - if (offsetTop != null) return (scrollTop + this.unpin <= position.top) ? false : 'bottom' - return (scrollTop + targetHeight <= scrollHeight - offsetBottom) ? false : 'bottom' - } - - var initializing = this.affixed == null - var colliderTop = initializing ? scrollTop : position.top - var colliderHeight = initializing ? targetHeight : height - - if (offsetTop != null && scrollTop <= offsetTop) return 'top' - if (offsetBottom != null && (colliderTop + colliderHeight >= scrollHeight - offsetBottom)) return 'bottom' - - return false - } - - Affix.prototype.getPinnedOffset = function () { - if (this.pinnedOffset) return this.pinnedOffset - this.$element.removeClass(Affix.RESET).addClass('affix') - var scrollTop = this.$target.scrollTop() - var position = this.$element.offset() - return (this.pinnedOffset = position.top - scrollTop) - } - - Affix.prototype.checkPositionWithEventLoop = function () { - setTimeout($.proxy(this.checkPosition, this), 1) - } - - Affix.prototype.checkPosition = function () { - if (!this.$element.is(':visible')) return - - var height = this.$element.height() - var offset = this.options.offset - var offsetTop = offset.top - var offsetBottom = offset.bottom - var scrollHeight = Math.max($(document).height(), $(document.body).height()) - - if (typeof offset != 'object') offsetBottom = offsetTop = offset - if (typeof offsetTop == 'function') offsetTop = offset.top(this.$element) - if (typeof offsetBottom == 'function') offsetBottom = offset.bottom(this.$element) - - var affix = this.getState(scrollHeight, height, offsetTop, offsetBottom) - - if (this.affixed != affix) { - if (this.unpin != null) this.$element.css('top', '') - - var affixType = 'affix' + (affix ? '-' + affix : '') - var e = $.Event(affixType + '.bs.affix') - - this.$element.trigger(e) - - if (e.isDefaultPrevented()) return - - this.affixed = affix - this.unpin = affix == 'bottom' ? this.getPinnedOffset() : null - - this.$element - .removeClass(Affix.RESET) - .addClass(affixType) - .trigger(affixType.replace('affix', 'affixed') + '.bs.affix') - } - - if (affix == 'bottom') { - this.$element.offset({ - top: scrollHeight - height - offsetBottom - }) - } - } - - - // AFFIX PLUGIN DEFINITION - // ======================= - - function Plugin(option) { - return this.each(function () { - var $this = $(this) - var data = $this.data('bs.affix') - var options = typeof option == 'object' && option - - if (!data) $this.data('bs.affix', (data = new Affix(this, options))) - if (typeof option == 'string') data[option]() - }) - } - - var old = $.fn.affix - - $.fn.affix = Plugin - $.fn.affix.Constructor = Affix - - - // AFFIX NO CONFLICT - // ================= - - $.fn.affix.noConflict = function () { - $.fn.affix = old - return this - } - - - // AFFIX DATA-API - // ============== - - $(window).on('load', function () { - $('[data-spy="affix"]').each(function () { - var $spy = $(this) - var data = $spy.data() - - data.offset = data.offset || {} - - if (data.offsetBottom != null) data.offset.bottom = data.offsetBottom - if (data.offsetTop != null) data.offset.top = data.offsetTop - - Plugin.call($spy, data) - }) - }) - -}(jQuery); diff --git a/examples/blog/static/js/jquery-1.11.3.min.js b/examples/blog/static/js/jquery-1.11.3.min.js deleted file mode 100644 index 0f60b7bd0..000000000 --- a/examples/blog/static/js/jquery-1.11.3.min.js +++ /dev/null @@ -1,5 +0,0 @@ -/*! jQuery v1.11.3 | (c) 2005, 2015 jQuery Foundation, Inc. | jquery.org/license */ -!function(a,b){"object"==typeof module&&"object"==typeof module.exports?module.exports=a.document?b(a,!0):function(a){if(!a.document)throw new Error("jQuery requires a window with a document");return b(a)}:b(a)}("undefined"!=typeof window?window:this,function(a,b){var c=[],d=c.slice,e=c.concat,f=c.push,g=c.indexOf,h={},i=h.toString,j=h.hasOwnProperty,k={},l="1.11.3",m=function(a,b){return new m.fn.init(a,b)},n=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,o=/^-ms-/,p=/-([\da-z])/gi,q=function(a,b){return b.toUpperCase()};m.fn=m.prototype={jquery:l,constructor:m,selector:"",length:0,toArray:function(){return d.call(this)},get:function(a){return null!=a?0>a?this[a+this.length]:this[a]:d.call(this)},pushStack:function(a){var b=m.merge(this.constructor(),a);return b.prevObject=this,b.context=this.context,b},each:function(a,b){return m.each(this,a,b)},map:function(a){return this.pushStack(m.map(this,function(b,c){return a.call(b,c,b)}))},slice:function(){return this.pushStack(d.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(a){var b=this.length,c=+a+(0>a?b:0);return this.pushStack(c>=0&&b>c?[this[c]]:[])},end:function(){return this.prevObject||this.constructor(null)},push:f,sort:c.sort,splice:c.splice},m.extend=m.fn.extend=function(){var a,b,c,d,e,f,g=arguments[0]||{},h=1,i=arguments.length,j=!1;for("boolean"==typeof g&&(j=g,g=arguments[h]||{},h++),"object"==typeof g||m.isFunction(g)||(g={}),h===i&&(g=this,h--);i>h;h++)if(null!=(e=arguments[h]))for(d in e)a=g[d],c=e[d],g!==c&&(j&&c&&(m.isPlainObject(c)||(b=m.isArray(c)))?(b?(b=!1,f=a&&m.isArray(a)?a:[]):f=a&&m.isPlainObject(a)?a:{},g[d]=m.extend(j,f,c)):void 0!==c&&(g[d]=c));return g},m.extend({expando:"jQuery"+(l+Math.random()).replace(/\D/g,""),isReady:!0,error:function(a){throw new Error(a)},noop:function(){},isFunction:function(a){return"function"===m.type(a)},isArray:Array.isArray||function(a){return"array"===m.type(a)},isWindow:function(a){return null!=a&&a==a.window},isNumeric:function(a){return!m.isArray(a)&&a-parseFloat(a)+1>=0},isEmptyObject:function(a){var b;for(b in a)return!1;return!0},isPlainObject:function(a){var b;if(!a||"object"!==m.type(a)||a.nodeType||m.isWindow(a))return!1;try{if(a.constructor&&!j.call(a,"constructor")&&!j.call(a.constructor.prototype,"isPrototypeOf"))return!1}catch(c){return!1}if(k.ownLast)for(b in a)return j.call(a,b);for(b in a);return void 0===b||j.call(a,b)},type:function(a){return null==a?a+"":"object"==typeof a||"function"==typeof a?h[i.call(a)]||"object":typeof a},globalEval:function(b){b&&m.trim(b)&&(a.execScript||function(b){a.eval.call(a,b)})(b)},camelCase:function(a){return a.replace(o,"ms-").replace(p,q)},nodeName:function(a,b){return a.nodeName&&a.nodeName.toLowerCase()===b.toLowerCase()},each:function(a,b,c){var d,e=0,f=a.length,g=r(a);if(c){if(g){for(;f>e;e++)if(d=b.apply(a[e],c),d===!1)break}else for(e in a)if(d=b.apply(a[e],c),d===!1)break}else if(g){for(;f>e;e++)if(d=b.call(a[e],e,a[e]),d===!1)break}else for(e in a)if(d=b.call(a[e],e,a[e]),d===!1)break;return a},trim:function(a){return null==a?"":(a+"").replace(n,"")},makeArray:function(a,b){var c=b||[];return null!=a&&(r(Object(a))?m.merge(c,"string"==typeof a?[a]:a):f.call(c,a)),c},inArray:function(a,b,c){var d;if(b){if(g)return g.call(b,a,c);for(d=b.length,c=c?0>c?Math.max(0,d+c):c:0;d>c;c++)if(c in b&&b[c]===a)return c}return-1},merge:function(a,b){var c=+b.length,d=0,e=a.length;while(c>d)a[e++]=b[d++];if(c!==c)while(void 0!==b[d])a[e++]=b[d++];return a.length=e,a},grep:function(a,b,c){for(var d,e=[],f=0,g=a.length,h=!c;g>f;f++)d=!b(a[f],f),d!==h&&e.push(a[f]);return e},map:function(a,b,c){var d,f=0,g=a.length,h=r(a),i=[];if(h)for(;g>f;f++)d=b(a[f],f,c),null!=d&&i.push(d);else for(f in a)d=b(a[f],f,c),null!=d&&i.push(d);return e.apply([],i)},guid:1,proxy:function(a,b){var c,e,f;return"string"==typeof b&&(f=a[b],b=a,a=f),m.isFunction(a)?(c=d.call(arguments,2),e=function(){return a.apply(b||this,c.concat(d.call(arguments)))},e.guid=a.guid=a.guid||m.guid++,e):void 0},now:function(){return+new Date},support:k}),m.each("Boolean Number String Function Array Date RegExp Object Error".split(" "),function(a,b){h["[object "+b+"]"]=b.toLowerCase()});function r(a){var b="length"in a&&a.length,c=m.type(a);return"function"===c||m.isWindow(a)?!1:1===a.nodeType&&b?!0:"array"===c||0===b||"number"==typeof b&&b>0&&b-1 in a}var s=function(a){var b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u="sizzle"+1*new Date,v=a.document,w=0,x=0,y=ha(),z=ha(),A=ha(),B=function(a,b){return a===b&&(l=!0),0},C=1<<31,D={}.hasOwnProperty,E=[],F=E.pop,G=E.push,H=E.push,I=E.slice,J=function(a,b){for(var c=0,d=a.length;d>c;c++)if(a[c]===b)return c;return-1},K="checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",L="[\\x20\\t\\r\\n\\f]",M="(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+",N=M.replace("w","w#"),O="\\["+L+"*("+M+")(?:"+L+"*([*^$|!~]?=)"+L+"*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|("+N+"))|)"+L+"*\\]",P=":("+M+")(?:\\((('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|((?:\\\\.|[^\\\\()[\\]]|"+O+")*)|.*)\\)|)",Q=new RegExp(L+"+","g"),R=new RegExp("^"+L+"+|((?:^|[^\\\\])(?:\\\\.)*)"+L+"+$","g"),S=new RegExp("^"+L+"*,"+L+"*"),T=new RegExp("^"+L+"*([>+~]|"+L+")"+L+"*"),U=new RegExp("="+L+"*([^\\]'\"]*?)"+L+"*\\]","g"),V=new RegExp(P),W=new RegExp("^"+N+"$"),X={ID:new RegExp("^#("+M+")"),CLASS:new RegExp("^\\.("+M+")"),TAG:new RegExp("^("+M.replace("w","w*")+")"),ATTR:new RegExp("^"+O),PSEUDO:new RegExp("^"+P),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+L+"*(even|odd|(([+-]|)(\\d*)n|)"+L+"*(?:([+-]|)"+L+"*(\\d+)|))"+L+"*\\)|)","i"),bool:new RegExp("^(?:"+K+")$","i"),needsContext:new RegExp("^"+L+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+L+"*((?:-\\d)?\\d*)"+L+"*\\)|)(?=[^-]|$)","i")},Y=/^(?:input|select|textarea|button)$/i,Z=/^h\d$/i,$=/^[^{]+\{\s*\[native \w/,_=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,aa=/[+~]/,ba=/'|\\/g,ca=new RegExp("\\\\([\\da-f]{1,6}"+L+"?|("+L+")|.)","ig"),da=function(a,b,c){var d="0x"+b-65536;return d!==d||c?b:0>d?String.fromCharCode(d+65536):String.fromCharCode(d>>10|55296,1023&d|56320)},ea=function(){m()};try{H.apply(E=I.call(v.childNodes),v.childNodes),E[v.childNodes.length].nodeType}catch(fa){H={apply:E.length?function(a,b){G.apply(a,I.call(b))}:function(a,b){var c=a.length,d=0;while(a[c++]=b[d++]);a.length=c-1}}}function ga(a,b,d,e){var f,h,j,k,l,o,r,s,w,x;if((b?b.ownerDocument||b:v)!==n&&m(b),b=b||n,d=d||[],k=b.nodeType,"string"!=typeof a||!a||1!==k&&9!==k&&11!==k)return d;if(!e&&p){if(11!==k&&(f=_.exec(a)))if(j=f[1]){if(9===k){if(h=b.getElementById(j),!h||!h.parentNode)return d;if(h.id===j)return d.push(h),d}else if(b.ownerDocument&&(h=b.ownerDocument.getElementById(j))&&t(b,h)&&h.id===j)return d.push(h),d}else{if(f[2])return H.apply(d,b.getElementsByTagName(a)),d;if((j=f[3])&&c.getElementsByClassName)return H.apply(d,b.getElementsByClassName(j)),d}if(c.qsa&&(!q||!q.test(a))){if(s=r=u,w=b,x=1!==k&&a,1===k&&"object"!==b.nodeName.toLowerCase()){o=g(a),(r=b.getAttribute("id"))?s=r.replace(ba,"\\$&"):b.setAttribute("id",s),s="[id='"+s+"'] ",l=o.length;while(l--)o[l]=s+ra(o[l]);w=aa.test(a)&&pa(b.parentNode)||b,x=o.join(",")}if(x)try{return H.apply(d,w.querySelectorAll(x)),d}catch(y){}finally{r||b.removeAttribute("id")}}}return i(a.replace(R,"$1"),b,d,e)}function ha(){var a=[];function b(c,e){return a.push(c+" ")>d.cacheLength&&delete b[a.shift()],b[c+" "]=e}return b}function ia(a){return a[u]=!0,a}function ja(a){var b=n.createElement("div");try{return!!a(b)}catch(c){return!1}finally{b.parentNode&&b.parentNode.removeChild(b),b=null}}function ka(a,b){var c=a.split("|"),e=a.length;while(e--)d.attrHandle[c[e]]=b}function la(a,b){var c=b&&a,d=c&&1===a.nodeType&&1===b.nodeType&&(~b.sourceIndex||C)-(~a.sourceIndex||C);if(d)return d;if(c)while(c=c.nextSibling)if(c===b)return-1;return a?1:-1}function ma(a){return function(b){var c=b.nodeName.toLowerCase();return"input"===c&&b.type===a}}function na(a){return function(b){var c=b.nodeName.toLowerCase();return("input"===c||"button"===c)&&b.type===a}}function oa(a){return ia(function(b){return b=+b,ia(function(c,d){var e,f=a([],c.length,b),g=f.length;while(g--)c[e=f[g]]&&(c[e]=!(d[e]=c[e]))})})}function pa(a){return a&&"undefined"!=typeof a.getElementsByTagName&&a}c=ga.support={},f=ga.isXML=function(a){var b=a&&(a.ownerDocument||a).documentElement;return b?"HTML"!==b.nodeName:!1},m=ga.setDocument=function(a){var b,e,g=a?a.ownerDocument||a:v;return g!==n&&9===g.nodeType&&g.documentElement?(n=g,o=g.documentElement,e=g.defaultView,e&&e!==e.top&&(e.addEventListener?e.addEventListener("unload",ea,!1):e.attachEvent&&e.attachEvent("onunload",ea)),p=!f(g),c.attributes=ja(function(a){return a.className="i",!a.getAttribute("className")}),c.getElementsByTagName=ja(function(a){return a.appendChild(g.createComment("")),!a.getElementsByTagName("*").length}),c.getElementsByClassName=$.test(g.getElementsByClassName),c.getById=ja(function(a){return o.appendChild(a).id=u,!g.getElementsByName||!g.getElementsByName(u).length}),c.getById?(d.find.ID=function(a,b){if("undefined"!=typeof b.getElementById&&p){var c=b.getElementById(a);return c&&c.parentNode?[c]:[]}},d.filter.ID=function(a){var b=a.replace(ca,da);return function(a){return a.getAttribute("id")===b}}):(delete d.find.ID,d.filter.ID=function(a){var b=a.replace(ca,da);return function(a){var c="undefined"!=typeof a.getAttributeNode&&a.getAttributeNode("id");return c&&c.value===b}}),d.find.TAG=c.getElementsByTagName?function(a,b){return"undefined"!=typeof b.getElementsByTagName?b.getElementsByTagName(a):c.qsa?b.querySelectorAll(a):void 0}:function(a,b){var c,d=[],e=0,f=b.getElementsByTagName(a);if("*"===a){while(c=f[e++])1===c.nodeType&&d.push(c);return d}return f},d.find.CLASS=c.getElementsByClassName&&function(a,b){return p?b.getElementsByClassName(a):void 0},r=[],q=[],(c.qsa=$.test(g.querySelectorAll))&&(ja(function(a){o.appendChild(a).innerHTML="",a.querySelectorAll("[msallowcapture^='']").length&&q.push("[*^$]="+L+"*(?:''|\"\")"),a.querySelectorAll("[selected]").length||q.push("\\["+L+"*(?:value|"+K+")"),a.querySelectorAll("[id~="+u+"-]").length||q.push("~="),a.querySelectorAll(":checked").length||q.push(":checked"),a.querySelectorAll("a#"+u+"+*").length||q.push(".#.+[+~]")}),ja(function(a){var b=g.createElement("input");b.setAttribute("type","hidden"),a.appendChild(b).setAttribute("name","D"),a.querySelectorAll("[name=d]").length&&q.push("name"+L+"*[*^$|!~]?="),a.querySelectorAll(":enabled").length||q.push(":enabled",":disabled"),a.querySelectorAll("*,:x"),q.push(",.*:")})),(c.matchesSelector=$.test(s=o.matches||o.webkitMatchesSelector||o.mozMatchesSelector||o.oMatchesSelector||o.msMatchesSelector))&&ja(function(a){c.disconnectedMatch=s.call(a,"div"),s.call(a,"[s!='']:x"),r.push("!=",P)}),q=q.length&&new RegExp(q.join("|")),r=r.length&&new RegExp(r.join("|")),b=$.test(o.compareDocumentPosition),t=b||$.test(o.contains)?function(a,b){var c=9===a.nodeType?a.documentElement:a,d=b&&b.parentNode;return a===d||!(!d||1!==d.nodeType||!(c.contains?c.contains(d):a.compareDocumentPosition&&16&a.compareDocumentPosition(d)))}:function(a,b){if(b)while(b=b.parentNode)if(b===a)return!0;return!1},B=b?function(a,b){if(a===b)return l=!0,0;var d=!a.compareDocumentPosition-!b.compareDocumentPosition;return d?d:(d=(a.ownerDocument||a)===(b.ownerDocument||b)?a.compareDocumentPosition(b):1,1&d||!c.sortDetached&&b.compareDocumentPosition(a)===d?a===g||a.ownerDocument===v&&t(v,a)?-1:b===g||b.ownerDocument===v&&t(v,b)?1:k?J(k,a)-J(k,b):0:4&d?-1:1)}:function(a,b){if(a===b)return l=!0,0;var c,d=0,e=a.parentNode,f=b.parentNode,h=[a],i=[b];if(!e||!f)return a===g?-1:b===g?1:e?-1:f?1:k?J(k,a)-J(k,b):0;if(e===f)return la(a,b);c=a;while(c=c.parentNode)h.unshift(c);c=b;while(c=c.parentNode)i.unshift(c);while(h[d]===i[d])d++;return d?la(h[d],i[d]):h[d]===v?-1:i[d]===v?1:0},g):n},ga.matches=function(a,b){return ga(a,null,null,b)},ga.matchesSelector=function(a,b){if((a.ownerDocument||a)!==n&&m(a),b=b.replace(U,"='$1']"),!(!c.matchesSelector||!p||r&&r.test(b)||q&&q.test(b)))try{var d=s.call(a,b);if(d||c.disconnectedMatch||a.document&&11!==a.document.nodeType)return d}catch(e){}return ga(b,n,null,[a]).length>0},ga.contains=function(a,b){return(a.ownerDocument||a)!==n&&m(a),t(a,b)},ga.attr=function(a,b){(a.ownerDocument||a)!==n&&m(a);var e=d.attrHandle[b.toLowerCase()],f=e&&D.call(d.attrHandle,b.toLowerCase())?e(a,b,!p):void 0;return void 0!==f?f:c.attributes||!p?a.getAttribute(b):(f=a.getAttributeNode(b))&&f.specified?f.value:null},ga.error=function(a){throw new Error("Syntax error, unrecognized expression: "+a)},ga.uniqueSort=function(a){var b,d=[],e=0,f=0;if(l=!c.detectDuplicates,k=!c.sortStable&&a.slice(0),a.sort(B),l){while(b=a[f++])b===a[f]&&(e=d.push(f));while(e--)a.splice(d[e],1)}return k=null,a},e=ga.getText=function(a){var b,c="",d=0,f=a.nodeType;if(f){if(1===f||9===f||11===f){if("string"==typeof a.textContent)return a.textContent;for(a=a.firstChild;a;a=a.nextSibling)c+=e(a)}else if(3===f||4===f)return a.nodeValue}else while(b=a[d++])c+=e(b);return c},d=ga.selectors={cacheLength:50,createPseudo:ia,match:X,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(a){return a[1]=a[1].replace(ca,da),a[3]=(a[3]||a[4]||a[5]||"").replace(ca,da),"~="===a[2]&&(a[3]=" "+a[3]+" "),a.slice(0,4)},CHILD:function(a){return a[1]=a[1].toLowerCase(),"nth"===a[1].slice(0,3)?(a[3]||ga.error(a[0]),a[4]=+(a[4]?a[5]+(a[6]||1):2*("even"===a[3]||"odd"===a[3])),a[5]=+(a[7]+a[8]||"odd"===a[3])):a[3]&&ga.error(a[0]),a},PSEUDO:function(a){var b,c=!a[6]&&a[2];return X.CHILD.test(a[0])?null:(a[3]?a[2]=a[4]||a[5]||"":c&&V.test(c)&&(b=g(c,!0))&&(b=c.indexOf(")",c.length-b)-c.length)&&(a[0]=a[0].slice(0,b),a[2]=c.slice(0,b)),a.slice(0,3))}},filter:{TAG:function(a){var b=a.replace(ca,da).toLowerCase();return"*"===a?function(){return!0}:function(a){return a.nodeName&&a.nodeName.toLowerCase()===b}},CLASS:function(a){var b=y[a+" "];return b||(b=new RegExp("(^|"+L+")"+a+"("+L+"|$)"))&&y(a,function(a){return b.test("string"==typeof a.className&&a.className||"undefined"!=typeof a.getAttribute&&a.getAttribute("class")||"")})},ATTR:function(a,b,c){return function(d){var e=ga.attr(d,a);return null==e?"!="===b:b?(e+="","="===b?e===c:"!="===b?e!==c:"^="===b?c&&0===e.indexOf(c):"*="===b?c&&e.indexOf(c)>-1:"$="===b?c&&e.slice(-c.length)===c:"~="===b?(" "+e.replace(Q," ")+" ").indexOf(c)>-1:"|="===b?e===c||e.slice(0,c.length+1)===c+"-":!1):!0}},CHILD:function(a,b,c,d,e){var f="nth"!==a.slice(0,3),g="last"!==a.slice(-4),h="of-type"===b;return 1===d&&0===e?function(a){return!!a.parentNode}:function(b,c,i){var j,k,l,m,n,o,p=f!==g?"nextSibling":"previousSibling",q=b.parentNode,r=h&&b.nodeName.toLowerCase(),s=!i&&!h;if(q){if(f){while(p){l=b;while(l=l[p])if(h?l.nodeName.toLowerCase()===r:1===l.nodeType)return!1;o=p="only"===a&&!o&&"nextSibling"}return!0}if(o=[g?q.firstChild:q.lastChild],g&&s){k=q[u]||(q[u]={}),j=k[a]||[],n=j[0]===w&&j[1],m=j[0]===w&&j[2],l=n&&q.childNodes[n];while(l=++n&&l&&l[p]||(m=n=0)||o.pop())if(1===l.nodeType&&++m&&l===b){k[a]=[w,n,m];break}}else if(s&&(j=(b[u]||(b[u]={}))[a])&&j[0]===w)m=j[1];else while(l=++n&&l&&l[p]||(m=n=0)||o.pop())if((h?l.nodeName.toLowerCase()===r:1===l.nodeType)&&++m&&(s&&((l[u]||(l[u]={}))[a]=[w,m]),l===b))break;return m-=e,m===d||m%d===0&&m/d>=0}}},PSEUDO:function(a,b){var c,e=d.pseudos[a]||d.setFilters[a.toLowerCase()]||ga.error("unsupported pseudo: "+a);return e[u]?e(b):e.length>1?(c=[a,a,"",b],d.setFilters.hasOwnProperty(a.toLowerCase())?ia(function(a,c){var d,f=e(a,b),g=f.length;while(g--)d=J(a,f[g]),a[d]=!(c[d]=f[g])}):function(a){return e(a,0,c)}):e}},pseudos:{not:ia(function(a){var b=[],c=[],d=h(a.replace(R,"$1"));return d[u]?ia(function(a,b,c,e){var f,g=d(a,null,e,[]),h=a.length;while(h--)(f=g[h])&&(a[h]=!(b[h]=f))}):function(a,e,f){return b[0]=a,d(b,null,f,c),b[0]=null,!c.pop()}}),has:ia(function(a){return function(b){return ga(a,b).length>0}}),contains:ia(function(a){return a=a.replace(ca,da),function(b){return(b.textContent||b.innerText||e(b)).indexOf(a)>-1}}),lang:ia(function(a){return W.test(a||"")||ga.error("unsupported lang: "+a),a=a.replace(ca,da).toLowerCase(),function(b){var c;do if(c=p?b.lang:b.getAttribute("xml:lang")||b.getAttribute("lang"))return c=c.toLowerCase(),c===a||0===c.indexOf(a+"-");while((b=b.parentNode)&&1===b.nodeType);return!1}}),target:function(b){var c=a.location&&a.location.hash;return c&&c.slice(1)===b.id},root:function(a){return a===o},focus:function(a){return a===n.activeElement&&(!n.hasFocus||n.hasFocus())&&!!(a.type||a.href||~a.tabIndex)},enabled:function(a){return a.disabled===!1},disabled:function(a){return a.disabled===!0},checked:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&!!a.checked||"option"===b&&!!a.selected},selected:function(a){return a.parentNode&&a.parentNode.selectedIndex,a.selected===!0},empty:function(a){for(a=a.firstChild;a;a=a.nextSibling)if(a.nodeType<6)return!1;return!0},parent:function(a){return!d.pseudos.empty(a)},header:function(a){return Z.test(a.nodeName)},input:function(a){return Y.test(a.nodeName)},button:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&"button"===a.type||"button"===b},text:function(a){var b;return"input"===a.nodeName.toLowerCase()&&"text"===a.type&&(null==(b=a.getAttribute("type"))||"text"===b.toLowerCase())},first:oa(function(){return[0]}),last:oa(function(a,b){return[b-1]}),eq:oa(function(a,b,c){return[0>c?c+b:c]}),even:oa(function(a,b){for(var c=0;b>c;c+=2)a.push(c);return a}),odd:oa(function(a,b){for(var c=1;b>c;c+=2)a.push(c);return a}),lt:oa(function(a,b,c){for(var d=0>c?c+b:c;--d>=0;)a.push(d);return a}),gt:oa(function(a,b,c){for(var d=0>c?c+b:c;++db;b++)d+=a[b].value;return d}function sa(a,b,c){var d=b.dir,e=c&&"parentNode"===d,f=x++;return b.first?function(b,c,f){while(b=b[d])if(1===b.nodeType||e)return a(b,c,f)}:function(b,c,g){var h,i,j=[w,f];if(g){while(b=b[d])if((1===b.nodeType||e)&&a(b,c,g))return!0}else while(b=b[d])if(1===b.nodeType||e){if(i=b[u]||(b[u]={}),(h=i[d])&&h[0]===w&&h[1]===f)return j[2]=h[2];if(i[d]=j,j[2]=a(b,c,g))return!0}}}function ta(a){return a.length>1?function(b,c,d){var e=a.length;while(e--)if(!a[e](b,c,d))return!1;return!0}:a[0]}function ua(a,b,c){for(var d=0,e=b.length;e>d;d++)ga(a,b[d],c);return c}function va(a,b,c,d,e){for(var f,g=[],h=0,i=a.length,j=null!=b;i>h;h++)(f=a[h])&&(!c||c(f,d,e))&&(g.push(f),j&&b.push(h));return g}function wa(a,b,c,d,e,f){return d&&!d[u]&&(d=wa(d)),e&&!e[u]&&(e=wa(e,f)),ia(function(f,g,h,i){var j,k,l,m=[],n=[],o=g.length,p=f||ua(b||"*",h.nodeType?[h]:h,[]),q=!a||!f&&b?p:va(p,m,a,h,i),r=c?e||(f?a:o||d)?[]:g:q;if(c&&c(q,r,h,i),d){j=va(r,n),d(j,[],h,i),k=j.length;while(k--)(l=j[k])&&(r[n[k]]=!(q[n[k]]=l))}if(f){if(e||a){if(e){j=[],k=r.length;while(k--)(l=r[k])&&j.push(q[k]=l);e(null,r=[],j,i)}k=r.length;while(k--)(l=r[k])&&(j=e?J(f,l):m[k])>-1&&(f[j]=!(g[j]=l))}}else r=va(r===g?r.splice(o,r.length):r),e?e(null,g,r,i):H.apply(g,r)})}function xa(a){for(var b,c,e,f=a.length,g=d.relative[a[0].type],h=g||d.relative[" "],i=g?1:0,k=sa(function(a){return a===b},h,!0),l=sa(function(a){return J(b,a)>-1},h,!0),m=[function(a,c,d){var e=!g&&(d||c!==j)||((b=c).nodeType?k(a,c,d):l(a,c,d));return b=null,e}];f>i;i++)if(c=d.relative[a[i].type])m=[sa(ta(m),c)];else{if(c=d.filter[a[i].type].apply(null,a[i].matches),c[u]){for(e=++i;f>e;e++)if(d.relative[a[e].type])break;return wa(i>1&&ta(m),i>1&&ra(a.slice(0,i-1).concat({value:" "===a[i-2].type?"*":""})).replace(R,"$1"),c,e>i&&xa(a.slice(i,e)),f>e&&xa(a=a.slice(e)),f>e&&ra(a))}m.push(c)}return ta(m)}function ya(a,b){var c=b.length>0,e=a.length>0,f=function(f,g,h,i,k){var l,m,o,p=0,q="0",r=f&&[],s=[],t=j,u=f||e&&d.find.TAG("*",k),v=w+=null==t?1:Math.random()||.1,x=u.length;for(k&&(j=g!==n&&g);q!==x&&null!=(l=u[q]);q++){if(e&&l){m=0;while(o=a[m++])if(o(l,g,h)){i.push(l);break}k&&(w=v)}c&&((l=!o&&l)&&p--,f&&r.push(l))}if(p+=q,c&&q!==p){m=0;while(o=b[m++])o(r,s,g,h);if(f){if(p>0)while(q--)r[q]||s[q]||(s[q]=F.call(i));s=va(s)}H.apply(i,s),k&&!f&&s.length>0&&p+b.length>1&&ga.uniqueSort(i)}return k&&(w=v,j=t),r};return c?ia(f):f}return h=ga.compile=function(a,b){var c,d=[],e=[],f=A[a+" "];if(!f){b||(b=g(a)),c=b.length;while(c--)f=xa(b[c]),f[u]?d.push(f):e.push(f);f=A(a,ya(e,d)),f.selector=a}return f},i=ga.select=function(a,b,e,f){var i,j,k,l,m,n="function"==typeof a&&a,o=!f&&g(a=n.selector||a);if(e=e||[],1===o.length){if(j=o[0]=o[0].slice(0),j.length>2&&"ID"===(k=j[0]).type&&c.getById&&9===b.nodeType&&p&&d.relative[j[1].type]){if(b=(d.find.ID(k.matches[0].replace(ca,da),b)||[])[0],!b)return e;n&&(b=b.parentNode),a=a.slice(j.shift().value.length)}i=X.needsContext.test(a)?0:j.length;while(i--){if(k=j[i],d.relative[l=k.type])break;if((m=d.find[l])&&(f=m(k.matches[0].replace(ca,da),aa.test(j[0].type)&&pa(b.parentNode)||b))){if(j.splice(i,1),a=f.length&&ra(j),!a)return H.apply(e,f),e;break}}}return(n||h(a,o))(f,b,!p,e,aa.test(a)&&pa(b.parentNode)||b),e},c.sortStable=u.split("").sort(B).join("")===u,c.detectDuplicates=!!l,m(),c.sortDetached=ja(function(a){return 1&a.compareDocumentPosition(n.createElement("div"))}),ja(function(a){return a.innerHTML="","#"===a.firstChild.getAttribute("href")})||ka("type|href|height|width",function(a,b,c){return c?void 0:a.getAttribute(b,"type"===b.toLowerCase()?1:2)}),c.attributes&&ja(function(a){return a.innerHTML="",a.firstChild.setAttribute("value",""),""===a.firstChild.getAttribute("value")})||ka("value",function(a,b,c){return c||"input"!==a.nodeName.toLowerCase()?void 0:a.defaultValue}),ja(function(a){return null==a.getAttribute("disabled")})||ka(K,function(a,b,c){var d;return c?void 0:a[b]===!0?b.toLowerCase():(d=a.getAttributeNode(b))&&d.specified?d.value:null}),ga}(a);m.find=s,m.expr=s.selectors,m.expr[":"]=m.expr.pseudos,m.unique=s.uniqueSort,m.text=s.getText,m.isXMLDoc=s.isXML,m.contains=s.contains;var t=m.expr.match.needsContext,u=/^<(\w+)\s*\/?>(?:<\/\1>|)$/,v=/^.[^:#\[\.,]*$/;function w(a,b,c){if(m.isFunction(b))return m.grep(a,function(a,d){return!!b.call(a,d,a)!==c});if(b.nodeType)return m.grep(a,function(a){return a===b!==c});if("string"==typeof b){if(v.test(b))return m.filter(b,a,c);b=m.filter(b,a)}return m.grep(a,function(a){return m.inArray(a,b)>=0!==c})}m.filter=function(a,b,c){var d=b[0];return c&&(a=":not("+a+")"),1===b.length&&1===d.nodeType?m.find.matchesSelector(d,a)?[d]:[]:m.find.matches(a,m.grep(b,function(a){return 1===a.nodeType}))},m.fn.extend({find:function(a){var b,c=[],d=this,e=d.length;if("string"!=typeof a)return this.pushStack(m(a).filter(function(){for(b=0;e>b;b++)if(m.contains(d[b],this))return!0}));for(b=0;e>b;b++)m.find(a,d[b],c);return c=this.pushStack(e>1?m.unique(c):c),c.selector=this.selector?this.selector+" "+a:a,c},filter:function(a){return this.pushStack(w(this,a||[],!1))},not:function(a){return this.pushStack(w(this,a||[],!0))},is:function(a){return!!w(this,"string"==typeof a&&t.test(a)?m(a):a||[],!1).length}});var x,y=a.document,z=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/,A=m.fn.init=function(a,b){var c,d;if(!a)return this;if("string"==typeof a){if(c="<"===a.charAt(0)&&">"===a.charAt(a.length-1)&&a.length>=3?[null,a,null]:z.exec(a),!c||!c[1]&&b)return!b||b.jquery?(b||x).find(a):this.constructor(b).find(a);if(c[1]){if(b=b instanceof m?b[0]:b,m.merge(this,m.parseHTML(c[1],b&&b.nodeType?b.ownerDocument||b:y,!0)),u.test(c[1])&&m.isPlainObject(b))for(c in b)m.isFunction(this[c])?this[c](b[c]):this.attr(c,b[c]);return this}if(d=y.getElementById(c[2]),d&&d.parentNode){if(d.id!==c[2])return x.find(a);this.length=1,this[0]=d}return this.context=y,this.selector=a,this}return a.nodeType?(this.context=this[0]=a,this.length=1,this):m.isFunction(a)?"undefined"!=typeof x.ready?x.ready(a):a(m):(void 0!==a.selector&&(this.selector=a.selector,this.context=a.context),m.makeArray(a,this))};A.prototype=m.fn,x=m(y);var B=/^(?:parents|prev(?:Until|All))/,C={children:!0,contents:!0,next:!0,prev:!0};m.extend({dir:function(a,b,c){var d=[],e=a[b];while(e&&9!==e.nodeType&&(void 0===c||1!==e.nodeType||!m(e).is(c)))1===e.nodeType&&d.push(e),e=e[b];return d},sibling:function(a,b){for(var c=[];a;a=a.nextSibling)1===a.nodeType&&a!==b&&c.push(a);return c}}),m.fn.extend({has:function(a){var b,c=m(a,this),d=c.length;return this.filter(function(){for(b=0;d>b;b++)if(m.contains(this,c[b]))return!0})},closest:function(a,b){for(var c,d=0,e=this.length,f=[],g=t.test(a)||"string"!=typeof a?m(a,b||this.context):0;e>d;d++)for(c=this[d];c&&c!==b;c=c.parentNode)if(c.nodeType<11&&(g?g.index(c)>-1:1===c.nodeType&&m.find.matchesSelector(c,a))){f.push(c);break}return this.pushStack(f.length>1?m.unique(f):f)},index:function(a){return a?"string"==typeof a?m.inArray(this[0],m(a)):m.inArray(a.jquery?a[0]:a,this):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(a,b){return this.pushStack(m.unique(m.merge(this.get(),m(a,b))))},addBack:function(a){return this.add(null==a?this.prevObject:this.prevObject.filter(a))}});function D(a,b){do a=a[b];while(a&&1!==a.nodeType);return a}m.each({parent:function(a){var b=a.parentNode;return b&&11!==b.nodeType?b:null},parents:function(a){return m.dir(a,"parentNode")},parentsUntil:function(a,b,c){return m.dir(a,"parentNode",c)},next:function(a){return D(a,"nextSibling")},prev:function(a){return D(a,"previousSibling")},nextAll:function(a){return m.dir(a,"nextSibling")},prevAll:function(a){return m.dir(a,"previousSibling")},nextUntil:function(a,b,c){return m.dir(a,"nextSibling",c)},prevUntil:function(a,b,c){return m.dir(a,"previousSibling",c)},siblings:function(a){return m.sibling((a.parentNode||{}).firstChild,a)},children:function(a){return m.sibling(a.firstChild)},contents:function(a){return m.nodeName(a,"iframe")?a.contentDocument||a.contentWindow.document:m.merge([],a.childNodes)}},function(a,b){m.fn[a]=function(c,d){var e=m.map(this,b,c);return"Until"!==a.slice(-5)&&(d=c),d&&"string"==typeof d&&(e=m.filter(d,e)),this.length>1&&(C[a]||(e=m.unique(e)),B.test(a)&&(e=e.reverse())),this.pushStack(e)}});var E=/\S+/g,F={};function G(a){var b=F[a]={};return m.each(a.match(E)||[],function(a,c){b[c]=!0}),b}m.Callbacks=function(a){a="string"==typeof a?F[a]||G(a):m.extend({},a);var b,c,d,e,f,g,h=[],i=!a.once&&[],j=function(l){for(c=a.memory&&l,d=!0,f=g||0,g=0,e=h.length,b=!0;h&&e>f;f++)if(h[f].apply(l[0],l[1])===!1&&a.stopOnFalse){c=!1;break}b=!1,h&&(i?i.length&&j(i.shift()):c?h=[]:k.disable())},k={add:function(){if(h){var d=h.length;!function f(b){m.each(b,function(b,c){var d=m.type(c);"function"===d?a.unique&&k.has(c)||h.push(c):c&&c.length&&"string"!==d&&f(c)})}(arguments),b?e=h.length:c&&(g=d,j(c))}return this},remove:function(){return h&&m.each(arguments,function(a,c){var d;while((d=m.inArray(c,h,d))>-1)h.splice(d,1),b&&(e>=d&&e--,f>=d&&f--)}),this},has:function(a){return a?m.inArray(a,h)>-1:!(!h||!h.length)},empty:function(){return h=[],e=0,this},disable:function(){return h=i=c=void 0,this},disabled:function(){return!h},lock:function(){return i=void 0,c||k.disable(),this},locked:function(){return!i},fireWith:function(a,c){return!h||d&&!i||(c=c||[],c=[a,c.slice?c.slice():c],b?i.push(c):j(c)),this},fire:function(){return k.fireWith(this,arguments),this},fired:function(){return!!d}};return k},m.extend({Deferred:function(a){var b=[["resolve","done",m.Callbacks("once memory"),"resolved"],["reject","fail",m.Callbacks("once memory"),"rejected"],["notify","progress",m.Callbacks("memory")]],c="pending",d={state:function(){return c},always:function(){return e.done(arguments).fail(arguments),this},then:function(){var a=arguments;return m.Deferred(function(c){m.each(b,function(b,f){var g=m.isFunction(a[b])&&a[b];e[f[1]](function(){var a=g&&g.apply(this,arguments);a&&m.isFunction(a.promise)?a.promise().done(c.resolve).fail(c.reject).progress(c.notify):c[f[0]+"With"](this===d?c.promise():this,g?[a]:arguments)})}),a=null}).promise()},promise:function(a){return null!=a?m.extend(a,d):d}},e={};return d.pipe=d.then,m.each(b,function(a,f){var g=f[2],h=f[3];d[f[1]]=g.add,h&&g.add(function(){c=h},b[1^a][2].disable,b[2][2].lock),e[f[0]]=function(){return e[f[0]+"With"](this===e?d:this,arguments),this},e[f[0]+"With"]=g.fireWith}),d.promise(e),a&&a.call(e,e),e},when:function(a){var b=0,c=d.call(arguments),e=c.length,f=1!==e||a&&m.isFunction(a.promise)?e:0,g=1===f?a:m.Deferred(),h=function(a,b,c){return function(e){b[a]=this,c[a]=arguments.length>1?d.call(arguments):e,c===i?g.notifyWith(b,c):--f||g.resolveWith(b,c)}},i,j,k;if(e>1)for(i=new Array(e),j=new Array(e),k=new Array(e);e>b;b++)c[b]&&m.isFunction(c[b].promise)?c[b].promise().done(h(b,k,c)).fail(g.reject).progress(h(b,j,i)):--f;return f||g.resolveWith(k,c),g.promise()}});var H;m.fn.ready=function(a){return m.ready.promise().done(a),this},m.extend({isReady:!1,readyWait:1,holdReady:function(a){a?m.readyWait++:m.ready(!0)},ready:function(a){if(a===!0?!--m.readyWait:!m.isReady){if(!y.body)return setTimeout(m.ready);m.isReady=!0,a!==!0&&--m.readyWait>0||(H.resolveWith(y,[m]),m.fn.triggerHandler&&(m(y).triggerHandler("ready"),m(y).off("ready")))}}});function I(){y.addEventListener?(y.removeEventListener("DOMContentLoaded",J,!1),a.removeEventListener("load",J,!1)):(y.detachEvent("onreadystatechange",J),a.detachEvent("onload",J))}function J(){(y.addEventListener||"load"===event.type||"complete"===y.readyState)&&(I(),m.ready())}m.ready.promise=function(b){if(!H)if(H=m.Deferred(),"complete"===y.readyState)setTimeout(m.ready);else if(y.addEventListener)y.addEventListener("DOMContentLoaded",J,!1),a.addEventListener("load",J,!1);else{y.attachEvent("onreadystatechange",J),a.attachEvent("onload",J);var c=!1;try{c=null==a.frameElement&&y.documentElement}catch(d){}c&&c.doScroll&&!function e(){if(!m.isReady){try{c.doScroll("left")}catch(a){return setTimeout(e,50)}I(),m.ready()}}()}return H.promise(b)};var K="undefined",L;for(L in m(k))break;k.ownLast="0"!==L,k.inlineBlockNeedsLayout=!1,m(function(){var a,b,c,d;c=y.getElementsByTagName("body")[0],c&&c.style&&(b=y.createElement("div"),d=y.createElement("div"),d.style.cssText="position:absolute;border:0;width:0;height:0;top:0;left:-9999px",c.appendChild(d).appendChild(b),typeof b.style.zoom!==K&&(b.style.cssText="display:inline;margin:0;border:0;padding:1px;width:1px;zoom:1",k.inlineBlockNeedsLayout=a=3===b.offsetWidth,a&&(c.style.zoom=1)),c.removeChild(d))}),function(){var a=y.createElement("div");if(null==k.deleteExpando){k.deleteExpando=!0;try{delete a.test}catch(b){k.deleteExpando=!1}}a=null}(),m.acceptData=function(a){var b=m.noData[(a.nodeName+" ").toLowerCase()],c=+a.nodeType||1;return 1!==c&&9!==c?!1:!b||b!==!0&&a.getAttribute("classid")===b};var M=/^(?:\{[\w\W]*\}|\[[\w\W]*\])$/,N=/([A-Z])/g;function O(a,b,c){if(void 0===c&&1===a.nodeType){var d="data-"+b.replace(N,"-$1").toLowerCase();if(c=a.getAttribute(d),"string"==typeof c){try{c="true"===c?!0:"false"===c?!1:"null"===c?null:+c+""===c?+c:M.test(c)?m.parseJSON(c):c}catch(e){}m.data(a,b,c)}else c=void 0}return c}function P(a){var b;for(b in a)if(("data"!==b||!m.isEmptyObject(a[b]))&&"toJSON"!==b)return!1; - -return!0}function Q(a,b,d,e){if(m.acceptData(a)){var f,g,h=m.expando,i=a.nodeType,j=i?m.cache:a,k=i?a[h]:a[h]&&h;if(k&&j[k]&&(e||j[k].data)||void 0!==d||"string"!=typeof b)return k||(k=i?a[h]=c.pop()||m.guid++:h),j[k]||(j[k]=i?{}:{toJSON:m.noop}),("object"==typeof b||"function"==typeof b)&&(e?j[k]=m.extend(j[k],b):j[k].data=m.extend(j[k].data,b)),g=j[k],e||(g.data||(g.data={}),g=g.data),void 0!==d&&(g[m.camelCase(b)]=d),"string"==typeof b?(f=g[b],null==f&&(f=g[m.camelCase(b)])):f=g,f}}function R(a,b,c){if(m.acceptData(a)){var d,e,f=a.nodeType,g=f?m.cache:a,h=f?a[m.expando]:m.expando;if(g[h]){if(b&&(d=c?g[h]:g[h].data)){m.isArray(b)?b=b.concat(m.map(b,m.camelCase)):b in d?b=[b]:(b=m.camelCase(b),b=b in d?[b]:b.split(" ")),e=b.length;while(e--)delete d[b[e]];if(c?!P(d):!m.isEmptyObject(d))return}(c||(delete g[h].data,P(g[h])))&&(f?m.cleanData([a],!0):k.deleteExpando||g!=g.window?delete g[h]:g[h]=null)}}}m.extend({cache:{},noData:{"applet ":!0,"embed ":!0,"object ":"clsid:D27CDB6E-AE6D-11cf-96B8-444553540000"},hasData:function(a){return a=a.nodeType?m.cache[a[m.expando]]:a[m.expando],!!a&&!P(a)},data:function(a,b,c){return Q(a,b,c)},removeData:function(a,b){return R(a,b)},_data:function(a,b,c){return Q(a,b,c,!0)},_removeData:function(a,b){return R(a,b,!0)}}),m.fn.extend({data:function(a,b){var c,d,e,f=this[0],g=f&&f.attributes;if(void 0===a){if(this.length&&(e=m.data(f),1===f.nodeType&&!m._data(f,"parsedAttrs"))){c=g.length;while(c--)g[c]&&(d=g[c].name,0===d.indexOf("data-")&&(d=m.camelCase(d.slice(5)),O(f,d,e[d])));m._data(f,"parsedAttrs",!0)}return e}return"object"==typeof a?this.each(function(){m.data(this,a)}):arguments.length>1?this.each(function(){m.data(this,a,b)}):f?O(f,a,m.data(f,a)):void 0},removeData:function(a){return this.each(function(){m.removeData(this,a)})}}),m.extend({queue:function(a,b,c){var d;return a?(b=(b||"fx")+"queue",d=m._data(a,b),c&&(!d||m.isArray(c)?d=m._data(a,b,m.makeArray(c)):d.push(c)),d||[]):void 0},dequeue:function(a,b){b=b||"fx";var c=m.queue(a,b),d=c.length,e=c.shift(),f=m._queueHooks(a,b),g=function(){m.dequeue(a,b)};"inprogress"===e&&(e=c.shift(),d--),e&&("fx"===b&&c.unshift("inprogress"),delete f.stop,e.call(a,g,f)),!d&&f&&f.empty.fire()},_queueHooks:function(a,b){var c=b+"queueHooks";return m._data(a,c)||m._data(a,c,{empty:m.Callbacks("once memory").add(function(){m._removeData(a,b+"queue"),m._removeData(a,c)})})}}),m.fn.extend({queue:function(a,b){var c=2;return"string"!=typeof a&&(b=a,a="fx",c--),arguments.lengthh;h++)b(a[h],c,g?d:d.call(a[h],h,b(a[h],c)));return e?a:j?b.call(a):i?b(a[0],c):f},W=/^(?:checkbox|radio)$/i;!function(){var a=y.createElement("input"),b=y.createElement("div"),c=y.createDocumentFragment();if(b.innerHTML="
    a",k.leadingWhitespace=3===b.firstChild.nodeType,k.tbody=!b.getElementsByTagName("tbody").length,k.htmlSerialize=!!b.getElementsByTagName("link").length,k.html5Clone="<:nav>"!==y.createElement("nav").cloneNode(!0).outerHTML,a.type="checkbox",a.checked=!0,c.appendChild(a),k.appendChecked=a.checked,b.innerHTML="",k.noCloneChecked=!!b.cloneNode(!0).lastChild.defaultValue,c.appendChild(b),b.innerHTML="",k.checkClone=b.cloneNode(!0).cloneNode(!0).lastChild.checked,k.noCloneEvent=!0,b.attachEvent&&(b.attachEvent("onclick",function(){k.noCloneEvent=!1}),b.cloneNode(!0).click()),null==k.deleteExpando){k.deleteExpando=!0;try{delete b.test}catch(d){k.deleteExpando=!1}}}(),function(){var b,c,d=y.createElement("div");for(b in{submit:!0,change:!0,focusin:!0})c="on"+b,(k[b+"Bubbles"]=c in a)||(d.setAttribute(c,"t"),k[b+"Bubbles"]=d.attributes[c].expando===!1);d=null}();var X=/^(?:input|select|textarea)$/i,Y=/^key/,Z=/^(?:mouse|pointer|contextmenu)|click/,$=/^(?:focusinfocus|focusoutblur)$/,_=/^([^.]*)(?:\.(.+)|)$/;function aa(){return!0}function ba(){return!1}function ca(){try{return y.activeElement}catch(a){}}m.event={global:{},add:function(a,b,c,d,e){var f,g,h,i,j,k,l,n,o,p,q,r=m._data(a);if(r){c.handler&&(i=c,c=i.handler,e=i.selector),c.guid||(c.guid=m.guid++),(g=r.events)||(g=r.events={}),(k=r.handle)||(k=r.handle=function(a){return typeof m===K||a&&m.event.triggered===a.type?void 0:m.event.dispatch.apply(k.elem,arguments)},k.elem=a),b=(b||"").match(E)||[""],h=b.length;while(h--)f=_.exec(b[h])||[],o=q=f[1],p=(f[2]||"").split(".").sort(),o&&(j=m.event.special[o]||{},o=(e?j.delegateType:j.bindType)||o,j=m.event.special[o]||{},l=m.extend({type:o,origType:q,data:d,handler:c,guid:c.guid,selector:e,needsContext:e&&m.expr.match.needsContext.test(e),namespace:p.join(".")},i),(n=g[o])||(n=g[o]=[],n.delegateCount=0,j.setup&&j.setup.call(a,d,p,k)!==!1||(a.addEventListener?a.addEventListener(o,k,!1):a.attachEvent&&a.attachEvent("on"+o,k))),j.add&&(j.add.call(a,l),l.handler.guid||(l.handler.guid=c.guid)),e?n.splice(n.delegateCount++,0,l):n.push(l),m.event.global[o]=!0);a=null}},remove:function(a,b,c,d,e){var f,g,h,i,j,k,l,n,o,p,q,r=m.hasData(a)&&m._data(a);if(r&&(k=r.events)){b=(b||"").match(E)||[""],j=b.length;while(j--)if(h=_.exec(b[j])||[],o=q=h[1],p=(h[2]||"").split(".").sort(),o){l=m.event.special[o]||{},o=(d?l.delegateType:l.bindType)||o,n=k[o]||[],h=h[2]&&new RegExp("(^|\\.)"+p.join("\\.(?:.*\\.|)")+"(\\.|$)"),i=f=n.length;while(f--)g=n[f],!e&&q!==g.origType||c&&c.guid!==g.guid||h&&!h.test(g.namespace)||d&&d!==g.selector&&("**"!==d||!g.selector)||(n.splice(f,1),g.selector&&n.delegateCount--,l.remove&&l.remove.call(a,g));i&&!n.length&&(l.teardown&&l.teardown.call(a,p,r.handle)!==!1||m.removeEvent(a,o,r.handle),delete k[o])}else for(o in k)m.event.remove(a,o+b[j],c,d,!0);m.isEmptyObject(k)&&(delete r.handle,m._removeData(a,"events"))}},trigger:function(b,c,d,e){var f,g,h,i,k,l,n,o=[d||y],p=j.call(b,"type")?b.type:b,q=j.call(b,"namespace")?b.namespace.split("."):[];if(h=l=d=d||y,3!==d.nodeType&&8!==d.nodeType&&!$.test(p+m.event.triggered)&&(p.indexOf(".")>=0&&(q=p.split("."),p=q.shift(),q.sort()),g=p.indexOf(":")<0&&"on"+p,b=b[m.expando]?b:new m.Event(p,"object"==typeof b&&b),b.isTrigger=e?2:3,b.namespace=q.join("."),b.namespace_re=b.namespace?new RegExp("(^|\\.)"+q.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,b.result=void 0,b.target||(b.target=d),c=null==c?[b]:m.makeArray(c,[b]),k=m.event.special[p]||{},e||!k.trigger||k.trigger.apply(d,c)!==!1)){if(!e&&!k.noBubble&&!m.isWindow(d)){for(i=k.delegateType||p,$.test(i+p)||(h=h.parentNode);h;h=h.parentNode)o.push(h),l=h;l===(d.ownerDocument||y)&&o.push(l.defaultView||l.parentWindow||a)}n=0;while((h=o[n++])&&!b.isPropagationStopped())b.type=n>1?i:k.bindType||p,f=(m._data(h,"events")||{})[b.type]&&m._data(h,"handle"),f&&f.apply(h,c),f=g&&h[g],f&&f.apply&&m.acceptData(h)&&(b.result=f.apply(h,c),b.result===!1&&b.preventDefault());if(b.type=p,!e&&!b.isDefaultPrevented()&&(!k._default||k._default.apply(o.pop(),c)===!1)&&m.acceptData(d)&&g&&d[p]&&!m.isWindow(d)){l=d[g],l&&(d[g]=null),m.event.triggered=p;try{d[p]()}catch(r){}m.event.triggered=void 0,l&&(d[g]=l)}return b.result}},dispatch:function(a){a=m.event.fix(a);var b,c,e,f,g,h=[],i=d.call(arguments),j=(m._data(this,"events")||{})[a.type]||[],k=m.event.special[a.type]||{};if(i[0]=a,a.delegateTarget=this,!k.preDispatch||k.preDispatch.call(this,a)!==!1){h=m.event.handlers.call(this,a,j),b=0;while((f=h[b++])&&!a.isPropagationStopped()){a.currentTarget=f.elem,g=0;while((e=f.handlers[g++])&&!a.isImmediatePropagationStopped())(!a.namespace_re||a.namespace_re.test(e.namespace))&&(a.handleObj=e,a.data=e.data,c=((m.event.special[e.origType]||{}).handle||e.handler).apply(f.elem,i),void 0!==c&&(a.result=c)===!1&&(a.preventDefault(),a.stopPropagation()))}return k.postDispatch&&k.postDispatch.call(this,a),a.result}},handlers:function(a,b){var c,d,e,f,g=[],h=b.delegateCount,i=a.target;if(h&&i.nodeType&&(!a.button||"click"!==a.type))for(;i!=this;i=i.parentNode||this)if(1===i.nodeType&&(i.disabled!==!0||"click"!==a.type)){for(e=[],f=0;h>f;f++)d=b[f],c=d.selector+" ",void 0===e[c]&&(e[c]=d.needsContext?m(c,this).index(i)>=0:m.find(c,this,null,[i]).length),e[c]&&e.push(d);e.length&&g.push({elem:i,handlers:e})}return h]","i"),ha=/^\s+/,ia=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi,ja=/<([\w:]+)/,ka=/\s*$/g,ra={option:[1,""],legend:[1,"
    ","
    "],area:[1,"",""],param:[1,"",""],thead:[1,"","
    "],tr:[2,"","
    "],col:[2,"","
    "],td:[3,"","
    "],_default:k.htmlSerialize?[0,"",""]:[1,"X
    ","
    "]},sa=da(y),ta=sa.appendChild(y.createElement("div"));ra.optgroup=ra.option,ra.tbody=ra.tfoot=ra.colgroup=ra.caption=ra.thead,ra.th=ra.td;function ua(a,b){var c,d,e=0,f=typeof a.getElementsByTagName!==K?a.getElementsByTagName(b||"*"):typeof a.querySelectorAll!==K?a.querySelectorAll(b||"*"):void 0;if(!f)for(f=[],c=a.childNodes||a;null!=(d=c[e]);e++)!b||m.nodeName(d,b)?f.push(d):m.merge(f,ua(d,b));return void 0===b||b&&m.nodeName(a,b)?m.merge([a],f):f}function va(a){W.test(a.type)&&(a.defaultChecked=a.checked)}function wa(a,b){return m.nodeName(a,"table")&&m.nodeName(11!==b.nodeType?b:b.firstChild,"tr")?a.getElementsByTagName("tbody")[0]||a.appendChild(a.ownerDocument.createElement("tbody")):a}function xa(a){return a.type=(null!==m.find.attr(a,"type"))+"/"+a.type,a}function ya(a){var b=pa.exec(a.type);return b?a.type=b[1]:a.removeAttribute("type"),a}function za(a,b){for(var c,d=0;null!=(c=a[d]);d++)m._data(c,"globalEval",!b||m._data(b[d],"globalEval"))}function Aa(a,b){if(1===b.nodeType&&m.hasData(a)){var c,d,e,f=m._data(a),g=m._data(b,f),h=f.events;if(h){delete g.handle,g.events={};for(c in h)for(d=0,e=h[c].length;e>d;d++)m.event.add(b,c,h[c][d])}g.data&&(g.data=m.extend({},g.data))}}function Ba(a,b){var c,d,e;if(1===b.nodeType){if(c=b.nodeName.toLowerCase(),!k.noCloneEvent&&b[m.expando]){e=m._data(b);for(d in e.events)m.removeEvent(b,d,e.handle);b.removeAttribute(m.expando)}"script"===c&&b.text!==a.text?(xa(b).text=a.text,ya(b)):"object"===c?(b.parentNode&&(b.outerHTML=a.outerHTML),k.html5Clone&&a.innerHTML&&!m.trim(b.innerHTML)&&(b.innerHTML=a.innerHTML)):"input"===c&&W.test(a.type)?(b.defaultChecked=b.checked=a.checked,b.value!==a.value&&(b.value=a.value)):"option"===c?b.defaultSelected=b.selected=a.defaultSelected:("input"===c||"textarea"===c)&&(b.defaultValue=a.defaultValue)}}m.extend({clone:function(a,b,c){var d,e,f,g,h,i=m.contains(a.ownerDocument,a);if(k.html5Clone||m.isXMLDoc(a)||!ga.test("<"+a.nodeName+">")?f=a.cloneNode(!0):(ta.innerHTML=a.outerHTML,ta.removeChild(f=ta.firstChild)),!(k.noCloneEvent&&k.noCloneChecked||1!==a.nodeType&&11!==a.nodeType||m.isXMLDoc(a)))for(d=ua(f),h=ua(a),g=0;null!=(e=h[g]);++g)d[g]&&Ba(e,d[g]);if(b)if(c)for(h=h||ua(a),d=d||ua(f),g=0;null!=(e=h[g]);g++)Aa(e,d[g]);else Aa(a,f);return d=ua(f,"script"),d.length>0&&za(d,!i&&ua(a,"script")),d=h=e=null,f},buildFragment:function(a,b,c,d){for(var e,f,g,h,i,j,l,n=a.length,o=da(b),p=[],q=0;n>q;q++)if(f=a[q],f||0===f)if("object"===m.type(f))m.merge(p,f.nodeType?[f]:f);else if(la.test(f)){h=h||o.appendChild(b.createElement("div")),i=(ja.exec(f)||["",""])[1].toLowerCase(),l=ra[i]||ra._default,h.innerHTML=l[1]+f.replace(ia,"<$1>")+l[2],e=l[0];while(e--)h=h.lastChild;if(!k.leadingWhitespace&&ha.test(f)&&p.push(b.createTextNode(ha.exec(f)[0])),!k.tbody){f="table"!==i||ka.test(f)?""!==l[1]||ka.test(f)?0:h:h.firstChild,e=f&&f.childNodes.length;while(e--)m.nodeName(j=f.childNodes[e],"tbody")&&!j.childNodes.length&&f.removeChild(j)}m.merge(p,h.childNodes),h.textContent="";while(h.firstChild)h.removeChild(h.firstChild);h=o.lastChild}else p.push(b.createTextNode(f));h&&o.removeChild(h),k.appendChecked||m.grep(ua(p,"input"),va),q=0;while(f=p[q++])if((!d||-1===m.inArray(f,d))&&(g=m.contains(f.ownerDocument,f),h=ua(o.appendChild(f),"script"),g&&za(h),c)){e=0;while(f=h[e++])oa.test(f.type||"")&&c.push(f)}return h=null,o},cleanData:function(a,b){for(var d,e,f,g,h=0,i=m.expando,j=m.cache,l=k.deleteExpando,n=m.event.special;null!=(d=a[h]);h++)if((b||m.acceptData(d))&&(f=d[i],g=f&&j[f])){if(g.events)for(e in g.events)n[e]?m.event.remove(d,e):m.removeEvent(d,e,g.handle);j[f]&&(delete j[f],l?delete d[i]:typeof d.removeAttribute!==K?d.removeAttribute(i):d[i]=null,c.push(f))}}}),m.fn.extend({text:function(a){return V(this,function(a){return void 0===a?m.text(this):this.empty().append((this[0]&&this[0].ownerDocument||y).createTextNode(a))},null,a,arguments.length)},append:function(){return this.domManip(arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=wa(this,a);b.appendChild(a)}})},prepend:function(){return this.domManip(arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=wa(this,a);b.insertBefore(a,b.firstChild)}})},before:function(){return this.domManip(arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this)})},after:function(){return this.domManip(arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this.nextSibling)})},remove:function(a,b){for(var c,d=a?m.filter(a,this):this,e=0;null!=(c=d[e]);e++)b||1!==c.nodeType||m.cleanData(ua(c)),c.parentNode&&(b&&m.contains(c.ownerDocument,c)&&za(ua(c,"script")),c.parentNode.removeChild(c));return this},empty:function(){for(var a,b=0;null!=(a=this[b]);b++){1===a.nodeType&&m.cleanData(ua(a,!1));while(a.firstChild)a.removeChild(a.firstChild);a.options&&m.nodeName(a,"select")&&(a.options.length=0)}return this},clone:function(a,b){return a=null==a?!1:a,b=null==b?a:b,this.map(function(){return m.clone(this,a,b)})},html:function(a){return V(this,function(a){var b=this[0]||{},c=0,d=this.length;if(void 0===a)return 1===b.nodeType?b.innerHTML.replace(fa,""):void 0;if(!("string"!=typeof a||ma.test(a)||!k.htmlSerialize&&ga.test(a)||!k.leadingWhitespace&&ha.test(a)||ra[(ja.exec(a)||["",""])[1].toLowerCase()])){a=a.replace(ia,"<$1>");try{for(;d>c;c++)b=this[c]||{},1===b.nodeType&&(m.cleanData(ua(b,!1)),b.innerHTML=a);b=0}catch(e){}}b&&this.empty().append(a)},null,a,arguments.length)},replaceWith:function(){var a=arguments[0];return this.domManip(arguments,function(b){a=this.parentNode,m.cleanData(ua(this)),a&&a.replaceChild(b,this)}),a&&(a.length||a.nodeType)?this:this.remove()},detach:function(a){return this.remove(a,!0)},domManip:function(a,b){a=e.apply([],a);var c,d,f,g,h,i,j=0,l=this.length,n=this,o=l-1,p=a[0],q=m.isFunction(p);if(q||l>1&&"string"==typeof p&&!k.checkClone&&na.test(p))return this.each(function(c){var d=n.eq(c);q&&(a[0]=p.call(this,c,d.html())),d.domManip(a,b)});if(l&&(i=m.buildFragment(a,this[0].ownerDocument,!1,this),c=i.firstChild,1===i.childNodes.length&&(i=c),c)){for(g=m.map(ua(i,"script"),xa),f=g.length;l>j;j++)d=i,j!==o&&(d=m.clone(d,!0,!0),f&&m.merge(g,ua(d,"script"))),b.call(this[j],d,j);if(f)for(h=g[g.length-1].ownerDocument,m.map(g,ya),j=0;f>j;j++)d=g[j],oa.test(d.type||"")&&!m._data(d,"globalEval")&&m.contains(h,d)&&(d.src?m._evalUrl&&m._evalUrl(d.src):m.globalEval((d.text||d.textContent||d.innerHTML||"").replace(qa,"")));i=c=null}return this}}),m.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(a,b){m.fn[a]=function(a){for(var c,d=0,e=[],g=m(a),h=g.length-1;h>=d;d++)c=d===h?this:this.clone(!0),m(g[d])[b](c),f.apply(e,c.get());return this.pushStack(e)}});var Ca,Da={};function Ea(b,c){var d,e=m(c.createElement(b)).appendTo(c.body),f=a.getDefaultComputedStyle&&(d=a.getDefaultComputedStyle(e[0]))?d.display:m.css(e[0],"display");return e.detach(),f}function Fa(a){var b=y,c=Da[a];return c||(c=Ea(a,b),"none"!==c&&c||(Ca=(Ca||m(".*?\n", - }, - // set class - { - `{{< youtube w7Ft2ymGmfc video>}}`, - "(?s)\n
    .*?.*?
    \n", - }, - // set class and autoplay (using named params) - { - `{{< youtube id="w7Ft2ymGmfc" class="video" autoplay="true" >}}`, - "(?s)\n
    .*?.*?
    ", - }, - } { - var ( - cfg, fs = newTestCfg() - th = newTestHelper(cfg, fs, t) - ) - - writeSource(t, fs, filepath.Join("content", "simple.md"), fmt.Sprintf(`--- -title: Shorty ---- -%s`, this.in)) - writeSource(t, fs, filepath.Join("layouts", "_default", "single.html"), `{{ .Content }}`) - - buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{}) - - th.assertFileContentRegexp(filepath.Join("public", "simple", "index.html"), this.expected) - } - -} - -func TestShortcodeVimeo(t *testing.T) { - t.Parallel() - - for _, this := range []struct { - in, expected string - }{ - { - `{{< vimeo 146022717 >}}`, - "(?s)\n
    .*?.*?
    \n", - }, - // set class - { - `{{< vimeo 146022717 video >}}`, - "(?s)\n
    .*?.*?
    \n", - }, - // set vimeo title - { - `{{< vimeo 146022717 video my-title >}}`, - "(?s)\n
    .*?.*?
    \n", - }, - // set class (using named params) - { - `{{< vimeo id="146022717" class="video" >}}`, - "(?s)^
    .*?.*?
    ", - }, - // set vimeo title (using named params) - { - `{{< vimeo id="146022717" class="video" title="my vimeo video" >}}`, - "(?s)^
    .*?.*?
    ", - }, - } { - var ( - cfg, fs = newTestCfg() - th = newTestHelper(cfg, fs, t) - ) - - writeSource(t, fs, filepath.Join("content", "simple.md"), fmt.Sprintf(`--- -title: Shorty ---- -%s`, this.in)) - writeSource(t, fs, filepath.Join("layouts", "_default", "single.html"), `{{ .Content }}`) - - buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{}) - - th.assertFileContentRegexp(filepath.Join("public", "simple", "index.html"), this.expected) - - } -} - -func TestShortcodeGist(t *testing.T) { - t.Parallel() - - for _, this := range []struct { - in, expected string - }{ - { - `{{< gist spf13 7896402 >}}`, - "(?s)^", - }, - { - `{{< gist spf13 7896402 "img.html" >}}`, - "(?s)^", - }, - } { - var ( - cfg, fs = newTestCfg() - th = newTestHelper(cfg, fs, t) - ) - - writeSource(t, fs, filepath.Join("content", "simple.md"), fmt.Sprintf(`--- -title: Shorty ---- -%s`, this.in)) - writeSource(t, fs, filepath.Join("layouts", "_default", "single.html"), `{{ .Content }}`) - - buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{}) - - th.assertFileContentRegexp(filepath.Join("public", "simple", "index.html"), this.expected) - - } -} - -func TestShortcodeTweet(t *testing.T) { - t.Parallel() - - for i, this := range []struct { - privacy map[string]interface{} - in, resp, expected string - }{ - { - map[string]interface{}{ - "twitter": map[string]interface{}{ - "simple": true, - }, - }, - `{{< tweet 666616452582129664 >}}`, - `{"url":"https:\/\/twitter.com\/spf13\/status\/666616452582129664","author_name":"Steve Francia","author_url":"https:\/\/twitter.com\/spf13","html":"\u003Cblockquote class=\"twitter-tweet\"\u003E\u003Cp lang=\"en\" dir=\"ltr\"\u003EHugo 0.15 will have 30%+ faster render times thanks to this commit \u003Ca href=\"https:\/\/t.co\/FfzhM8bNhT\"\u003Ehttps:\/\/t.co\/FfzhM8bNhT\u003C\/a\u003E \u003Ca href=\"https:\/\/twitter.com\/hashtag\/gohugo?src=hash\"\u003E#gohugo\u003C\/a\u003E \u003Ca href=\"https:\/\/twitter.com\/hashtag\/golang?src=hash\"\u003E#golang\u003C\/a\u003E \u003Ca href=\"https:\/\/t.co\/ITbMNU2BUf\"\u003Ehttps:\/\/t.co\/ITbMNU2BUf\u003C\/a\u003E\u003C\/p\u003E— Steve Francia (@spf13) \u003Ca href=\"https:\/\/twitter.com\/spf13\/status\/666616452582129664\"\u003ENovember 17, 2015\u003C\/a\u003E\u003C\/blockquote\u003E\n\u003Cscript async src=\"\/\/platform.twitter.com\/widgets.js\" charset=\"utf-8\"\u003E\u003C\/script\u003E","width":550,"height":null,"type":"rich","cache_age":"3153600000","provider_name":"Twitter","provider_url":"https:\/\/twitter.com","version":"1.0"}`, - `.twitter-tweet a`, - }, - { - map[string]interface{}{ - "twitter": map[string]interface{}{ - "simple": false, - }, - }, - `{{< tweet 666616452582129664 >}}`, - `{"url":"https:\/\/twitter.com\/spf13\/status\/666616452582129664","author_name":"Steve Francia","author_url":"https:\/\/twitter.com\/spf13","html":"\u003Cblockquote class=\"twitter-tweet\"\u003E\u003Cp lang=\"en\" dir=\"ltr\"\u003EHugo 0.15 will have 30%+ faster render times thanks to this commit \u003Ca href=\"https:\/\/t.co\/FfzhM8bNhT\"\u003Ehttps:\/\/t.co\/FfzhM8bNhT\u003C\/a\u003E \u003Ca href=\"https:\/\/twitter.com\/hashtag\/gohugo?src=hash\"\u003E#gohugo\u003C\/a\u003E \u003Ca href=\"https:\/\/twitter.com\/hashtag\/golang?src=hash\"\u003E#golang\u003C\/a\u003E \u003Ca href=\"https:\/\/t.co\/ITbMNU2BUf\"\u003Ehttps:\/\/t.co\/ITbMNU2BUf\u003C\/a\u003E\u003C\/p\u003E— Steve Francia (@spf13) \u003Ca href=\"https:\/\/twitter.com\/spf13\/status\/666616452582129664\"\u003ENovember 17, 2015\u003C\/a\u003E\u003C\/blockquote\u003E\n\u003Cscript async src=\"\/\/platform.twitter.com\/widgets.js\" charset=\"utf-8\"\u003E\u003C\/script\u003E","width":550,"height":null,"type":"rich","cache_age":"3153600000","provider_name":"Twitter","provider_url":"https:\/\/twitter.com","version":"1.0"}`, - `(?s)^.*?`, - }, - } { - // overload getJSON to return mock API response from Twitter - tweetFuncMap := template.FuncMap{ - "getJSON": func(urlParts ...interface{}) interface{} { - var v interface{} - err := json.Unmarshal([]byte(this.resp), &v) - if err != nil { - t.Fatalf("[%d] unexpected error in json.Unmarshal: %s", i, err) - return err - } - return v - }, - } - - var ( - cfg, fs = newTestCfg() - th = newTestHelper(cfg, fs, t) - ) - - cfg.Set("privacy", this.privacy) - - writeSource(t, fs, filepath.Join("content", "simple.md"), fmt.Sprintf(`--- -title: Shorty ---- -%s`, this.in)) - writeSource(t, fs, filepath.Join("layouts", "_default", "single.html"), `{{ .Content }}`) - - buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg, OverloadedTemplateFuncs: tweetFuncMap}, BuildCfg{}) - - th.assertFileContentRegexp(filepath.Join("public", "simple", "index.html"), this.expected) - - } -} - -func TestShortcodeInstagram(t *testing.T) { - t.Parallel() - - for i, this := range []struct { - in, hidecaption, resp, expected string - }{ - { - `{{< instagram BMokmydjG-M >}}`, - `0`, - `{"provider_url": "https://www.instagram.com", "media_id": "1380514280986406796_25025320", "author_name": "instagram", "height": null, "thumbnail_url": "https://scontent-amt2-1.cdninstagram.com/t51.2885-15/s640x640/sh0.08/e35/15048135_1880160212214218_7827880881132929024_n.jpg?ig_cache_key=MTM4MDUxNDI4MDk4NjQwNjc5Ng%3D%3D.2", "thumbnail_width": 640, "thumbnail_height": 640, "provider_name": "Instagram", "title": "Today, we\u2019re introducing a few new tools to help you make your story even more fun: Boomerang and mentions. We\u2019re also starting to test links inside some stories.\nBoomerang lets you turn everyday moments into something fun and unexpected. Now you can easily take a Boomerang right inside Instagram. Swipe right from your feed to open the stories camera. A new format picker under the record button lets you select \u201cBoomerang\u201d mode.\nYou can also now share who you\u2019re with or who you\u2019re thinking of by mentioning them in your story. When you add text to your story, type \u201c@\u201d followed by a username and select the person you\u2019d like to mention. Their username will appear underlined in your story. And when someone taps the mention, they'll see a pop-up that takes them to that profile.\nYou may begin to spot \u201cSee More\u201d links at the bottom of some stories. This is a test that lets verified accounts add links so it\u2019s easy to learn more. From your favorite chefs\u2019 recipes to articles from top journalists or concert dates from the musicians you love, tap \u201cSee More\u201d or swipe up to view the link right inside the app.\nTo learn more about today\u2019s updates, check out help.instagram.com.\nThese updates for Instagram Stories are available as part of Instagram version 9.7 available for iOS in the Apple App Store, for Android in Google Play and for Windows 10 in the Windows Store.", "html": "\u003cblockquote class=\"instagram-media\" data-instgrm-captioned data-instgrm-version=\"7\" style=\" background:#FFF; border:0; border-radius:3px; box-shadow:0 0 1px 0 rgba(0,0,0,0.5),0 1px 10px 0 rgba(0,0,0,0.15); margin: 1px; max-width:658px; padding:0; width:99.375%; width:-webkit-calc(100% - 2px); width:calc(100% - 2px);\"\u003e\u003cdiv style=\"padding:8px;\"\u003e \u003cdiv style=\" background:#F8F8F8; line-height:0; margin-top:40px; padding:50.0% 0; text-align:center; width:100%;\"\u003e \u003cdiv style=\" background:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAsCAMAAAApWqozAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAMUExURczMzPf399fX1+bm5mzY9AMAAADiSURBVDjLvZXbEsMgCES5/P8/t9FuRVCRmU73JWlzosgSIIZURCjo/ad+EQJJB4Hv8BFt+IDpQoCx1wjOSBFhh2XssxEIYn3ulI/6MNReE07UIWJEv8UEOWDS88LY97kqyTliJKKtuYBbruAyVh5wOHiXmpi5we58Ek028czwyuQdLKPG1Bkb4NnM+VeAnfHqn1k4+GPT6uGQcvu2h2OVuIf/gWUFyy8OWEpdyZSa3aVCqpVoVvzZZ2VTnn2wU8qzVjDDetO90GSy9mVLqtgYSy231MxrY6I2gGqjrTY0L8fxCxfCBbhWrsYYAAAAAElFTkSuQmCC); display:block; height:44px; margin:0 auto -44px; position:relative; top:-22px; width:44px;\"\u003e\u003c/div\u003e\u003c/div\u003e \u003cp style=\" margin:8px 0 0 0; padding:0 4px;\"\u003e \u003ca href=\"https://www.instagram.com/p/BMokmydjG-M/\" style=\" color:#000; font-family:Arial,sans-serif; font-size:14px; font-style:normal; font-weight:normal; line-height:17px; text-decoration:none; word-wrap:break-word;\" target=\"_blank\"\u003eToday, we\u2019re introducing a few new tools to help you make your story even more fun: Boomerang and mentions. We\u2019re also starting to test links inside some stories. Boomerang lets you turn everyday moments into something fun and unexpected. Now you can easily take a Boomerang right inside Instagram. Swipe right from your feed to open the stories camera. A new format picker under the record button lets you select \u201cBoomerang\u201d mode. You can also now share who you\u2019re with or who you\u2019re thinking of by mentioning them in your story. When you add text to your story, type \u201c@\u201d followed by a username and select the person you\u2019d like to mention. Their username will appear underlined in your story. And when someone taps the mention, they\u0026#39;ll see a pop-up that takes them to that profile. You may begin to spot \u201cSee More\u201d links at the bottom of some stories. This is a test that lets verified accounts add links so it\u2019s easy to learn more. From your favorite chefs\u2019 recipes to articles from top journalists or concert dates from the musicians you love, tap \u201cSee More\u201d or swipe up to view the link right inside the app. To learn more about today\u2019s updates, check out help.instagram.com. These updates for Instagram Stories are available as part of Instagram version 9.7 available for iOS in the Apple App Store, for Android in Google Play and for Windows 10 in the Windows Store.\u003c/a\u003e\u003c/p\u003e \u003cp style=\" color:#c9c8cd; font-family:Arial,sans-serif; font-size:14px; line-height:17px; margin-bottom:0; margin-top:8px; overflow:hidden; padding:8px 0 7px; text-align:center; text-overflow:ellipsis; white-space:nowrap;\"\u003eA photo posted by Instagram (@instagram) on \u003ctime style=\" font-family:Arial,sans-serif; font-size:14px; line-height:17px;\" datetime=\"2016-11-10T15:02:28+00:00\"\u003eNov 10, 2016 at 7:02am PST\u003c/time\u003e\u003c/p\u003e\u003c/div\u003e\u003c/blockquote\u003e\n\u003cscript async defer src=\"//platform.instagram.com/en_US/embeds.js\"\u003e\u003c/script\u003e", "width": 658, "version": "1.0", "author_url": "https://www.instagram.com/instagram", "author_id": 25025320, "type": "rich"}`, - `(?s)
    `, - }, - { - `{{< instagram BMokmydjG-M hidecaption >}}`, - `1`, - `{"provider_url": "https://www.instagram.com", "media_id": "1380514280986406796_25025320", "author_name": "instagram", "height": null, "thumbnail_url": "https://scontent-amt2-1.cdninstagram.com/t51.2885-15/s640x640/sh0.08/e35/15048135_1880160212214218_7827880881132929024_n.jpg?ig_cache_key=MTM4MDUxNDI4MDk4NjQwNjc5Ng%3D%3D.2", "thumbnail_width": 640, "thumbnail_height": 640, "provider_name": "Instagram", "title": "Today, we\u2019re introducing a few new tools to help you make your story even more fun: Boomerang and mentions. We\u2019re also starting to test links inside some stories.\nBoomerang lets you turn everyday moments into something fun and unexpected. Now you can easily take a Boomerang right inside Instagram. Swipe right from your feed to open the stories camera. A new format picker under the record button lets you select \u201cBoomerang\u201d mode.\nYou can also now share who you\u2019re with or who you\u2019re thinking of by mentioning them in your story. When you add text to your story, type \u201c@\u201d followed by a username and select the person you\u2019d like to mention. Their username will appear underlined in your story. And when someone taps the mention, they'll see a pop-up that takes them to that profile.\nYou may begin to spot \u201cSee More\u201d links at the bottom of some stories. This is a test that lets verified accounts add links so it\u2019s easy to learn more. From your favorite chefs\u2019 recipes to articles from top journalists or concert dates from the musicians you love, tap \u201cSee More\u201d or swipe up to view the link right inside the app.\nTo learn more about today\u2019s updates, check out help.instagram.com.\nThese updates for Instagram Stories are available as part of Instagram version 9.7 available for iOS in the Apple App Store, for Android in Google Play and for Windows 10 in the Windows Store.", "html": "\u003cblockquote class=\"instagram-media\" data-instgrm-version=\"7\" style=\" background:#FFF; border:0; border-radius:3px; box-shadow:0 0 1px 0 rgba(0,0,0,0.5),0 1px 10px 0 rgba(0,0,0,0.15); margin: 1px; max-width:658px; padding:0; width:99.375%; width:-webkit-calc(100% - 2px); width:calc(100% - 2px);\"\u003e\u003cdiv style=\"padding:8px;\"\u003e \u003cdiv style=\" background:#F8F8F8; line-height:0; margin-top:40px; padding:50.0% 0; text-align:center; width:100%;\"\u003e \u003cdiv style=\" background:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAsCAMAAAApWqozAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAMUExURczMzPf399fX1+bm5mzY9AMAAADiSURBVDjLvZXbEsMgCES5/P8/t9FuRVCRmU73JWlzosgSIIZURCjo/ad+EQJJB4Hv8BFt+IDpQoCx1wjOSBFhh2XssxEIYn3ulI/6MNReE07UIWJEv8UEOWDS88LY97kqyTliJKKtuYBbruAyVh5wOHiXmpi5we58Ek028czwyuQdLKPG1Bkb4NnM+VeAnfHqn1k4+GPT6uGQcvu2h2OVuIf/gWUFyy8OWEpdyZSa3aVCqpVoVvzZZ2VTnn2wU8qzVjDDetO90GSy9mVLqtgYSy231MxrY6I2gGqjrTY0L8fxCxfCBbhWrsYYAAAAAElFTkSuQmCC); display:block; height:44px; margin:0 auto -44px; position:relative; top:-22px; width:44px;\"\u003e\u003c/div\u003e\u003c/div\u003e\u003cp style=\" color:#c9c8cd; font-family:Arial,sans-serif; font-size:14px; line-height:17px; margin-bottom:0; margin-top:8px; overflow:hidden; padding:8px 0 7px; text-align:center; text-overflow:ellipsis; white-space:nowrap;\"\u003e\u003ca href=\"https://www.instagram.com/p/BMokmydjG-M/\" style=\" color:#c9c8cd; font-family:Arial,sans-serif; font-size:14px; font-style:normal; font-weight:normal; line-height:17px; text-decoration:none;\" target=\"_blank\"\u003eA photo posted by Instagram (@instagram)\u003c/a\u003e on \u003ctime style=\" font-family:Arial,sans-serif; font-size:14px; line-height:17px;\" datetime=\"2016-11-10T15:02:28+00:00\"\u003eNov 10, 2016 at 7:02am PST\u003c/time\u003e\u003c/p\u003e\u003c/div\u003e\u003c/blockquote\u003e\n\u003cscript async defer src=\"//platform.instagram.com/en_US/embeds.js\"\u003e\u003c/script\u003e", "width": 658, "version": "1.0", "author_url": "https://www.instagram.com/instagram", "author_id": 25025320, "type": "rich"}`, - `(?s)
    `, - }, - } { - // overload getJSON to return mock API response from Instagram - instagramFuncMap := template.FuncMap{ - "getJSON": func(urlParts ...string) interface{} { - var v interface{} - err := json.Unmarshal([]byte(this.resp), &v) - if err != nil { - t.Fatalf("[%d] unexpected error in json.Unmarshal: %s", i, err) - return err - } - return v - }, - } - - var ( - cfg, fs = newTestCfg() - th = newTestHelper(cfg, fs, t) - ) - - writeSource(t, fs, filepath.Join("content", "simple.md"), fmt.Sprintf(`--- -title: Shorty ---- -%s`, this.in)) - writeSource(t, fs, filepath.Join("layouts", "_default", "single.html"), `{{ .Content | safeHTML }}`) - - buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg, OverloadedTemplateFuncs: instagramFuncMap}, BuildCfg{}) - - th.assertFileContentRegexp(filepath.Join("public", "simple", "index.html"), this.expected) - - } -} diff --git a/hugolib/embedded_templates_test.go b/hugolib/embedded_templates_test.go index c6f2ab661..ec59751f3 100644 --- a/hugolib/embedded_templates_test.go +++ b/hugolib/embedded_templates_test.go @@ -15,48 +15,8 @@ package hugolib import ( "testing" - - qt "github.com/frankban/quicktest" ) -// Just some simple test of the embedded templates to avoid -// https://github.com/gohugoio/hugo/issues/4757 and similar. -// TODO(bep) fix me https://github.com/gohugoio/hugo/issues/5926 -func _TestEmbeddedTemplates(t *testing.T) { - t.Parallel() - - c := qt.New(t) - c.Assert(true, qt.Equals, true) - - home := []string{"index.html", ` -GA: -{{ template "_internal/google_analytics.html" . }} - -GA async: - -{{ template "_internal/google_analytics_async.html" . }} - -Disqus: - -{{ template "_internal/disqus.html" . }} - -`} - - b := newTestSitesBuilder(t) - b.WithSimpleConfigFile().WithTemplatesAdded(home...) - - b.Build(BuildCfg{}) - - // Gheck GA regular and async - b.AssertFileContent("public/index.html", - "'anonymizeIp', true", - "'script','https://www.google-analytics.com/analytics.js','ga');\n\tga('create', 'ga_id', 'auto')", - " + + +` + + b, err := NewIntegrationTestBuilder( + IntegrationTestConfig{ + T: t, + TxtarString: files, + }, + ).BuildE() + + b.Assert(err, qt.IsNotNil) + fe := herrors.UnwrapFileError(err) + b.Assert(fe, qt.IsNotNil) + b.Assert(fe.Position().LineNumber, qt.Equals, 2) + b.Assert(fe.Position().ColumnNumber, qt.Equals, 9) + b.Assert(fe.Error(), qt.Contains, "unexpected = in expression on line 2 and column 9") + b.Assert(filepath.ToSlash(fe.Position().Filename), qt.Contains, "hugo-transform-error") + b.Assert(os.Remove(fe.Position().Filename), qt.IsNil) +} + +func TestErrorNestedRender(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +-- content/_index.md -- +--- +title: "Home" +--- +-- layouts/index.html -- +line 1 +line 2 +1{{ .Render "myview" }} +-- layouts/_default/myview.html -- +line 1 +12{{ partial "foo.html" . }} +line 4 +line 5 +-- layouts/partials/foo.html -- +line 1 +line 2 +123{{ .ThisDoesNotExist }} +line 4 +` + + b, err := NewIntegrationTestBuilder( + IntegrationTestConfig{ + T: t, + TxtarString: files, + }, + ).BuildE() + + b.Assert(err, qt.IsNotNil) + errors := herrors.UnwrapFileErrorsWithErrorContext(err) + b.Assert(errors, qt.HasLen, 4) + b.Assert(errors[0].Position().LineNumber, qt.Equals, 3) + b.Assert(errors[0].Position().ColumnNumber, qt.Equals, 4) + b.Assert(errors[0].Error(), qt.Contains, filepath.FromSlash(`"/layouts/index.html:3:4": execute of template failed`)) + b.Assert(errors[0].ErrorContext().Lines, qt.DeepEquals, []string{"line 1", "line 2", "1{{ .Render \"myview\" }}"}) + b.Assert(errors[2].Position().LineNumber, qt.Equals, 2) + b.Assert(errors[2].Position().ColumnNumber, qt.Equals, 5) + b.Assert(errors[2].ErrorContext().Lines, qt.DeepEquals, []string{"line 1", "12{{ partial \"foo.html\" . }}", "line 4", "line 5"}) + + b.Assert(errors[3].Position().LineNumber, qt.Equals, 3) + b.Assert(errors[3].Position().ColumnNumber, qt.Equals, 6) + b.Assert(errors[3].ErrorContext().Lines, qt.DeepEquals, []string{"line 1", "line 2", "123{{ .ThisDoesNotExist }}", "line 4"}) +} + +func TestErrorNestedShortcode(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +-- content/_index.md -- +--- +title: "Home" +--- + +## Hello +{{< hello >}} + +-- layouts/index.html -- +line 1 +line 2 +{{ .Content }} +line 5 +-- layouts/shortcodes/hello.html -- +line 1 +12{{ partial "foo.html" . }} +line 4 +line 5 +-- layouts/partials/foo.html -- +line 1 +line 2 +123{{ .ThisDoesNotExist }} +line 4 +` + + b, err := NewIntegrationTestBuilder( + IntegrationTestConfig{ + T: t, + TxtarString: files, + }, + ).BuildE() + + b.Assert(err, qt.IsNotNil) + errors := herrors.UnwrapFileErrorsWithErrorContext(err) + + b.Assert(errors, qt.HasLen, 4) + + b.Assert(errors[1].Position().LineNumber, qt.Equals, 6) + b.Assert(errors[1].Position().ColumnNumber, qt.Equals, 1) + b.Assert(errors[1].ErrorContext().ChromaLexer, qt.Equals, "md") + b.Assert(errors[1].Error(), qt.Contains, filepath.FromSlash(`"/content/_index.md:6:1": failed to render shortcode "hello": failed to process shortcode: "/layouts/shortcodes/hello.html:2:5":`)) + b.Assert(errors[1].ErrorContext().Lines, qt.DeepEquals, []string{"", "## Hello", "{{< hello >}}", ""}) + b.Assert(errors[2].ErrorContext().Lines, qt.DeepEquals, []string{"line 1", "12{{ partial \"foo.html\" . }}", "line 4", "line 5"}) + b.Assert(errors[3].ErrorContext().Lines, qt.DeepEquals, []string{"line 1", "line 2", "123{{ .ThisDoesNotExist }}", "line 4"}) +} + +func TestErrorRenderHookHeading(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +-- content/_index.md -- +--- +title: "Home" +--- + +## Hello + +-- layouts/index.html -- +line 1 +line 2 +{{ .Content }} +line 5 +-- layouts/_default/_markup/render-heading.html -- +line 1 +12{{ .Levels }} +line 4 +line 5 +` + + b, err := NewIntegrationTestBuilder( + IntegrationTestConfig{ + T: t, + TxtarString: files, + }, + ).BuildE() + + b.Assert(err, qt.IsNotNil) + errors := herrors.UnwrapFileErrorsWithErrorContext(err) + + b.Assert(errors, qt.HasLen, 3) + b.Assert(errors[0].Error(), qt.Contains, filepath.FromSlash(`"/content/_index.md:1:1": "/layouts/_default/_markup/render-heading.html:2:5": execute of template failed`)) +} + +func TestErrorRenderHookCodeblock(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +-- content/_index.md -- +--- +title: "Home" +--- + +## Hello + +§§§ foo +bar +§§§ + + +-- layouts/index.html -- +line 1 +line 2 +{{ .Content }} +line 5 +-- layouts/_default/_markup/render-codeblock-foo.html -- +line 1 +12{{ .Foo }} +line 4 +line 5 +` + + b, err := NewIntegrationTestBuilder( + IntegrationTestConfig{ + T: t, + TxtarString: files, + }, + ).BuildE() + + b.Assert(err, qt.IsNotNil) + errors := herrors.UnwrapFileErrorsWithErrorContext(err) + + b.Assert(errors, qt.HasLen, 3) + first := errors[0] + b.Assert(first.Error(), qt.Contains, filepath.FromSlash(`"/content/_index.md:7:1": "/layouts/_default/_markup/render-codeblock-foo.html:2:5": execute of template failed`)) +} + +func TestErrorInBaseTemplate(t *testing.T) { + t.Parallel() + + filesTemplate := ` +-- config.toml -- +-- content/_index.md -- +--- +title: "Home" +--- +-- layouts/baseof.html -- +line 1 base +line 2 base +{{ block "main" . }}empty{{ end }} +line 4 base +{{ block "toc" . }}empty{{ end }} +-- layouts/index.html -- +{{ define "main" }} +line 2 index +line 3 index +line 4 index +{{ end }} +{{ define "toc" }} +TOC: {{ partial "toc.html" . }} +{{ end }} +-- layouts/partials/toc.html -- +toc line 1 +toc line 2 +toc line 3 +toc line 4 + + +` + + t.Run("base template", func(t *testing.T) { + files := strings.Replace(filesTemplate, "line 4 base", "123{{ .ThisDoesNotExist \"abc\" }}", 1) + + b, err := NewIntegrationTestBuilder( + IntegrationTestConfig{ + T: t, + TxtarString: files, + }, + ).BuildE() + + b.Assert(err, qt.IsNotNil) + b.Assert(err.Error(), qt.Contains, `baseof.html:4:6`) + }) + + t.Run("index template", func(t *testing.T) { + files := strings.Replace(filesTemplate, "line 3 index", "1234{{ .ThisDoesNotExist \"abc\" }}", 1) + + b, err := NewIntegrationTestBuilder( + IntegrationTestConfig{ + T: t, + TxtarString: files, + }, + ).BuildE() + + b.Assert(err, qt.IsNotNil) + b.Assert(err.Error(), qt.Contains, `index.html:3:7"`) + }) + + t.Run("partial from define", func(t *testing.T) { + files := strings.Replace(filesTemplate, "toc line 2", "12345{{ .ThisDoesNotExist \"abc\" }}", 1) + + b, err := NewIntegrationTestBuilder( + IntegrationTestConfig{ + T: t, + TxtarString: files, + }, + ).BuildE() + + b.Assert(err, qt.IsNotNil) + b.Assert(err.Error(), qt.Contains, `toc.html:2:8"`) + }) +} + // https://github.com/gohugoio/hugo/issues/5375 func TestSiteBuildTimeout(t *testing.T) { - if !isCI() { - defer leaktest.CheckTimeout(t, 10*time.Second)() - } - b := newTestSitesBuilder(t) b.WithConfigFile("toml", ` timeout = 5 @@ -348,9 +621,24 @@ title: "A page" --- {{< c >}}`) - } b.CreateSites().BuildFail(BuildCfg{}) - +} + +func TestErrorTemplateRuntime(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +-- layouts/index.html -- +Home. +{{ .ThisDoesNotExist }} + ` + + b, err := TestE(t, files) + + b.Assert(err, qt.Not(qt.IsNil)) + b.Assert(err.Error(), qt.Contains, filepath.FromSlash(`/layouts/index.html:2:3`)) + b.Assert(err.Error(), qt.Contains, `can't evaluate field ThisDoesNotExist`) } diff --git a/hugolib/hugo_sites_build_test.go b/hugolib/hugo_sites_build_test.go index d62d6d519..4c2bf452c 100644 --- a/hugolib/hugo_sites_build_test.go +++ b/hugolib/hugo_sites_build_test.go @@ -2,152 +2,47 @@ package hugolib import ( "fmt" + "path/filepath" "strings" "testing" - "path/filepath" - "time" - qt "github.com/frankban/quicktest" - "github.com/gohugoio/hugo/resources/page" + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/htesting" + "github.com/gohugoio/hugo/resources/kinds" - "github.com/fortytw2/leaktest" - "github.com/fsnotify/fsnotify" "github.com/gohugoio/hugo/helpers" "github.com/gohugoio/hugo/hugofs" "github.com/spf13/afero" ) func TestMultiSitesMainLangInRoot(t *testing.T) { - t.Parallel() - for _, b := range []bool{false} { - doTestMultiSitesMainLangInRoot(t, b) - } -} + files := ` +-- hugo.toml -- +defaultContentLanguage = "fr" +defaultContentLanguageInSubdir = false +disableKinds = ["taxonomy", "term"] +[languages] +[languages.en] +weight = 1 +[languages.fr] +weight = 2 +-- content/sect/doc1.en.md -- +--- +title: doc1 en +--- +-- content/sect/doc1.fr.md -- +--- +title: doc1 fr +slug: doc1-fr +--- +-- layouts/_default/single.html -- +Single: {{ .Title }}|{{ .Lang }}|{{ .RelPermalink }}| -func doTestMultiSitesMainLangInRoot(t *testing.T, defaultInSubDir bool) { - c := qt.New(t) - - siteConfig := map[string]interface{}{ - "DefaultContentLanguage": "fr", - "DefaultContentLanguageInSubdir": defaultInSubDir, - } - - b := newMultiSiteTestBuilder(t, "toml", multiSiteTOMLConfigTemplate, siteConfig) - - pathMod := func(s string) string { - return s - } - - if !defaultInSubDir { - pathMod = func(s string) string { - return strings.Replace(s, "/fr/", "/", -1) - } - } - - b.CreateSites() - b.Build(BuildCfg{}) - - sites := b.H.Sites - c.Assert(len(sites), qt.Equals, 4) - - enSite := sites[0] - frSite := sites[1] - - c.Assert(enSite.Info.LanguagePrefix, qt.Equals, "/en") - - if defaultInSubDir { - c.Assert(frSite.Info.LanguagePrefix, qt.Equals, "/fr") - } else { - c.Assert(frSite.Info.LanguagePrefix, qt.Equals, "") - } - - c.Assert(enSite.PathSpec.RelURL("foo", true), qt.Equals, "/blog/en/foo") - - doc1en := enSite.RegularPages()[0] - doc1fr := frSite.RegularPages()[0] - - enPerm := doc1en.Permalink() - enRelPerm := doc1en.RelPermalink() - c.Assert(enPerm, qt.Equals, "http://example.com/blog/en/sect/doc1-slug/") - c.Assert(enRelPerm, qt.Equals, "/blog/en/sect/doc1-slug/") - - frPerm := doc1fr.Permalink() - frRelPerm := doc1fr.RelPermalink() - - b.AssertFileContent(pathMod("public/fr/sect/doc1/index.html"), "Single", "Bonjour") - b.AssertFileContent("public/en/sect/doc1-slug/index.html", "Single", "Hello") - - if defaultInSubDir { - c.Assert(frPerm, qt.Equals, "http://example.com/blog/fr/sect/doc1/") - c.Assert(frRelPerm, qt.Equals, "/blog/fr/sect/doc1/") - - // should have a redirect on top level. - b.AssertFileContent("public/index.html", ``) - } else { - // Main language in root - c.Assert(frPerm, qt.Equals, "http://example.com/blog/sect/doc1/") - c.Assert(frRelPerm, qt.Equals, "/blog/sect/doc1/") - - // should have redirect back to root - b.AssertFileContent("public/fr/index.html", ``) - } - b.AssertFileContent(pathMod("public/fr/index.html"), "Home", "Bonjour") - b.AssertFileContent("public/en/index.html", "Home", "Hello") - - // Check list pages - b.AssertFileContent(pathMod("public/fr/sect/index.html"), "List", "Bonjour") - b.AssertFileContent("public/en/sect/index.html", "List", "Hello") - b.AssertFileContent(pathMod("public/fr/plaques/FRtag1/index.html"), "Taxonomy List", "Bonjour") - b.AssertFileContent("public/en/tags/tag1/index.html", "Taxonomy List", "Hello") - - // Check sitemaps - // Sitemaps behaves different: In a multilanguage setup there will always be a index file and - // one sitemap in each lang folder. - b.AssertFileContent("public/sitemap.xml", - "http://example.com/blog/en/sitemap.xml", - "http://example.com/blog/fr/sitemap.xml") - - if defaultInSubDir { - b.AssertFileContent("public/fr/sitemap.xml", "http://example.com/blog/fr/") - } else { - b.AssertFileContent("public/fr/sitemap.xml", "http://example.com/blog/") - } - b.AssertFileContent("public/en/sitemap.xml", "http://example.com/blog/en/") - - // Check rss - b.AssertFileContent(pathMod("public/fr/index.xml"), pathMod(`http://example.com/blog/en/sitemap.xml", - "http://example.com/blog/fr/sitemap.xml") - b.AssertFileContent("public/en/sitemap.xml", "http://example.com/blog/en/sect/doc2/") - b.AssertFileContent("public/fr/sitemap.xml", "http://example.com/blog/fr/sect/doc1/") - - // Check taxonomies - enTags := enSite.Taxonomies["tags"] - frTags := frSite.Taxonomies["plaques"] - c.Assert(len(enTags), qt.Equals, 2, qt.Commentf("Tags in en: %v", enTags)) - c.Assert(len(frTags), qt.Equals, 2, qt.Commentf("Tags in fr: %v", frTags)) - c.Assert(enTags["tag1"], qt.Not(qt.IsNil)) - c.Assert(frTags["FRtag1"], qt.Not(qt.IsNil)) - b.AssertFileContent("public/fr/plaques/FRtag1/index.html", "FRtag1|Bonjour|http://example.com/blog/fr/plaques/FRtag1/") - - // Check Blackfriday config - c.Assert(strings.Contains(content(doc1fr), "«"), qt.Equals, true) - c.Assert(strings.Contains(content(doc1en), "«"), qt.Equals, false) - c.Assert(strings.Contains(content(doc1en), "“"), qt.Equals, true) - - // en and nn have custom site menus - c.Assert(len(frSite.Menus()), qt.Equals, 0) - c.Assert(len(enSite.Menus()), qt.Equals, 1) - c.Assert(len(nnSite.Menus()), qt.Equals, 1) - - c.Assert(enSite.Menus()["main"].ByName()[0].Name, qt.Equals, "Home") - c.Assert(nnSite.Menus()["main"].ByName()[0].Name, qt.Equals, "Heim") - - // Issue #3108 - prevPage := enSite.RegularPages()[0].Prev() - c.Assert(prevPage, qt.Not(qt.IsNil)) - c.Assert(prevPage.Kind(), qt.Equals, page.KindPage) - - for { - if prevPage == nil { - break - } - c.Assert(prevPage.Kind(), qt.Equals, page.KindPage) - prevPage = prevPage.Prev() - } - - // Check bundles - b.AssertFileContent("public/fr/bundles/b1/index.html", "RelPermalink: /blog/fr/bundles/b1/|") - bundleFr := frSite.getPage(page.KindPage, "bundles/b1/index.md") - c.Assert(bundleFr, qt.Not(qt.IsNil)) - c.Assert(len(bundleFr.Resources()), qt.Equals, 1) - logoFr := bundleFr.Resources().GetMatch("logo*") - c.Assert(logoFr, qt.Not(qt.IsNil)) - b.AssertFileContent("public/fr/bundles/b1/index.html", "Resources: image/png: /blog/fr/bundles/b1/logo.png") - b.AssertFileContent("public/fr/bundles/b1/logo.png", "PNG Data") - - bundleEn := enSite.getPage(page.KindPage, "bundles/b1/index.en.md") - c.Assert(bundleEn, qt.Not(qt.IsNil)) - b.AssertFileContent("public/en/bundles/b1/index.html", "RelPermalink: /blog/en/bundles/b1/|") - c.Assert(len(bundleEn.Resources()), qt.Equals, 1) - logoEn := bundleEn.Resources().GetMatch("logo*") - c.Assert(logoEn, qt.Not(qt.IsNil)) - b.AssertFileContent("public/en/bundles/b1/index.html", "Resources: image/png: /blog/en/bundles/b1/logo.png") - b.AssertFileContent("public/en/bundles/b1/logo.png", "PNG Data") - -} - -func TestMultiSitesRebuild(t *testing.T) { - // t.Parallel() not supported, see https://github.com/fortytw2/leaktest/issues/4 - // This leaktest seems to be a little bit shaky on Travis. - if !isCI() { - defer leaktest.CheckTimeout(t, 10*time.Second)() - } - - c := qt.New(t) - - b := newMultiSiteTestDefaultBuilder(t).Running().CreateSites().Build(BuildCfg{}) - - sites := b.H.Sites - fs := b.Fs - - b.AssertFileContent("public/en/sect/doc2/index.html", "Single: doc2|Hello|en|", "\n\n

    doc2

    \n\n

    some content") - - enSite := sites[0] - frSite := sites[1] - - c.Assert(len(enSite.RegularPages()), qt.Equals, 5) - c.Assert(len(frSite.RegularPages()), qt.Equals, 4) - - // Verify translations - b.AssertFileContent("public/en/sect/doc1-slug/index.html", "Hello") - b.AssertFileContent("public/fr/sect/doc1/index.html", "Bonjour") - - // check single page content - b.AssertFileContent("public/fr/sect/doc1/index.html", "Single", "Shortcode: Bonjour") - b.AssertFileContent("public/en/sect/doc1-slug/index.html", "Single", "Shortcode: Hello") - - homeEn := enSite.getPage(page.KindHome) - c.Assert(homeEn, qt.Not(qt.IsNil)) - c.Assert(len(homeEn.Translations()), qt.Equals, 3) - - contentFs := b.H.Fs.Source - - for i, this := range []struct { - preFunc func(t *testing.T) - events []fsnotify.Event - assertFunc func(t *testing.T) - }{ - // * Remove doc - // * Add docs existing languages - // (Add doc new language: TODO(bep) we should load config.toml as part of these so we can add languages). - // * Rename file - // * Change doc - // * Change a template - // * Change language file - { - func(t *testing.T) { - fs.Source.Remove("content/sect/doc2.en.md") - }, - []fsnotify.Event{{Name: filepath.FromSlash("content/sect/doc2.en.md"), Op: fsnotify.Remove}}, - func(t *testing.T) { - c.Assert(len(enSite.RegularPages()), qt.Equals, 4, qt.Commentf("1 en removed")) - - }, - }, - { - func(t *testing.T) { - writeNewContentFile(t, contentFs, "new_en_1", "2016-07-31", "content/new1.en.md", -5) - writeNewContentFile(t, contentFs, "new_en_2", "1989-07-30", "content/new2.en.md", -10) - writeNewContentFile(t, contentFs, "new_fr_1", "2016-07-30", "content/new1.fr.md", 10) - }, - []fsnotify.Event{ - {Name: filepath.FromSlash("content/new1.en.md"), Op: fsnotify.Create}, - {Name: filepath.FromSlash("content/new2.en.md"), Op: fsnotify.Create}, - {Name: filepath.FromSlash("content/new1.fr.md"), Op: fsnotify.Create}, - }, - func(t *testing.T) { - c.Assert(len(enSite.RegularPages()), qt.Equals, 6) - c.Assert(len(enSite.AllPages()), qt.Equals, 34) - c.Assert(len(frSite.RegularPages()), qt.Equals, 5) - c.Assert(frSite.RegularPages()[3].Title(), qt.Equals, "new_fr_1") - c.Assert(enSite.RegularPages()[0].Title(), qt.Equals, "new_en_2") - c.Assert(enSite.RegularPages()[1].Title(), qt.Equals, "new_en_1") - - rendered := readDestination(t, fs, "public/en/new1/index.html") - c.Assert(strings.Contains(rendered, "new_en_1"), qt.Equals, true) - }, - }, - { - func(t *testing.T) { - p := "content/sect/doc1.en.md" - doc1 := readFileFromFs(t, contentFs, p) - doc1 += "CHANGED" - writeToFs(t, contentFs, p, doc1) - }, - []fsnotify.Event{{Name: filepath.FromSlash("content/sect/doc1.en.md"), Op: fsnotify.Write}}, - func(t *testing.T) { - c.Assert(len(enSite.RegularPages()), qt.Equals, 6) - doc1 := readDestination(t, fs, "public/en/sect/doc1-slug/index.html") - c.Assert(strings.Contains(doc1, "CHANGED"), qt.Equals, true) - - }, - }, - // Rename a file - { - func(t *testing.T) { - if err := contentFs.Rename("content/new1.en.md", "content/new1renamed.en.md"); err != nil { - t.Fatalf("Rename failed: %s", err) - } - }, - []fsnotify.Event{ - {Name: filepath.FromSlash("content/new1renamed.en.md"), Op: fsnotify.Rename}, - {Name: filepath.FromSlash("content/new1.en.md"), Op: fsnotify.Rename}, - }, - func(t *testing.T) { - c.Assert(len(enSite.RegularPages()), qt.Equals, 6, qt.Commentf("Rename")) - c.Assert(enSite.RegularPages()[1].Title(), qt.Equals, "new_en_1") - rendered := readDestination(t, fs, "public/en/new1renamed/index.html") - c.Assert(rendered, qt.Contains, "new_en_1") - }}, - { - // Change a template - func(t *testing.T) { - template := "layouts/_default/single.html" - templateContent := readSource(t, fs, template) - templateContent += "{{ print \"Template Changed\"}}" - writeSource(t, fs, template, templateContent) - }, - []fsnotify.Event{{Name: filepath.FromSlash("layouts/_default/single.html"), Op: fsnotify.Write}}, - func(t *testing.T) { - c.Assert(len(enSite.RegularPages()), qt.Equals, 6) - c.Assert(len(enSite.AllPages()), qt.Equals, 34) - c.Assert(len(frSite.RegularPages()), qt.Equals, 5) - doc1 := readDestination(t, fs, "public/en/sect/doc1-slug/index.html") - c.Assert(strings.Contains(doc1, "Template Changed"), qt.Equals, true) - }, - }, - { - // Change a language file - func(t *testing.T) { - languageFile := "i18n/fr.yaml" - langContent := readSource(t, fs, languageFile) - langContent = strings.Replace(langContent, "Bonjour", "Salut", 1) - writeSource(t, fs, languageFile, langContent) - }, - []fsnotify.Event{{Name: filepath.FromSlash("i18n/fr.yaml"), Op: fsnotify.Write}}, - func(t *testing.T) { - c.Assert(len(enSite.RegularPages()), qt.Equals, 6) - c.Assert(len(enSite.AllPages()), qt.Equals, 34) - c.Assert(len(frSite.RegularPages()), qt.Equals, 5) - docEn := readDestination(t, fs, "public/en/sect/doc1-slug/index.html") - c.Assert(strings.Contains(docEn, "Hello"), qt.Equals, true) - docFr := readDestination(t, fs, "public/fr/sect/doc1/index.html") - c.Assert(strings.Contains(docFr, "Salut"), qt.Equals, true) - - homeEn := enSite.getPage(page.KindHome) - c.Assert(homeEn, qt.Not(qt.IsNil)) - c.Assert(len(homeEn.Translations()), qt.Equals, 3) - c.Assert(homeEn.Translations()[0].Language().Lang, qt.Equals, "fr") - - }, - }, - // Change a shortcode - { - func(t *testing.T) { - writeSource(t, fs, "layouts/shortcodes/shortcode.html", "Modified Shortcode: {{ i18n \"hello\" }}") - }, - []fsnotify.Event{ - {Name: filepath.FromSlash("layouts/shortcodes/shortcode.html"), Op: fsnotify.Write}, - }, - func(t *testing.T) { - c.Assert(len(enSite.RegularPages()), qt.Equals, 6) - c.Assert(len(enSite.AllPages()), qt.Equals, 34) - c.Assert(len(frSite.RegularPages()), qt.Equals, 5) - b.AssertFileContent("public/fr/sect/doc1/index.html", "Single", "Modified Shortcode: Salut") - b.AssertFileContent("public/en/sect/doc1-slug/index.html", "Single", "Modified Shortcode: Hello") - }, - }, - } { - - if this.preFunc != nil { - this.preFunc(t) - } - - err := b.H.Build(BuildCfg{}, this.events...) - - if err != nil { - t.Fatalf("[%d] Failed to rebuild sites: %s", i, err) - } - - this.assertFunc(t) - } - -} - // https://github.com/gohugoio/hugo/issues/4706 func TestContentStressTest(t *testing.T) { b := newTestSitesBuilder(t) @@ -703,10 +195,10 @@ END func checkContent(s *sitesBuilder, filename string, matches ...string) { s.T.Helper() - content := readDestination(s.T, s.Fs, filename) + content := readWorkingDir(s.T, s.Fs, filename) for _, match := range matches { if !strings.Contains(content, match) { - s.Fatalf("No match for %q in content for %s\n%q", match, filename, content) + s.Fatalf("No match for\n%q\nin content for %s\n%q\nDiff:\n%s", match, filename, content, htesting.DiffStrings(content, match)) } } } @@ -759,7 +251,6 @@ Title: My categories `) for _, lang := range []string{"en", "nn"} { - b.WithContent(lang+"/mysection/page.md", ` --- Title: My Page @@ -767,7 +258,6 @@ categories: ["mycat"] --- `) - } b.Build(BuildCfg{}) @@ -778,12 +268,11 @@ categories: ["mycat"] "/categories", "/categories/mycat", } { - t.Run(path, func(t *testing.T) { c := qt.New(t) - s1, _ := b.H.Sites[0].getPageNew(nil, path) - s2, _ := b.H.Sites[1].getPageNew(nil, path) + s1, _ := b.H.Sites[0].getPage(nil, path) + s2, _ := b.H.Sites[1].getPage(nil, path) c.Assert(s1, qt.Not(qt.IsNil)) c.Assert(s2, qt.Not(qt.IsNil)) @@ -799,372 +288,9 @@ categories: ["mycat"] c.Assert(len(m1), qt.Equals, 1) c.Assert(len(m2), qt.Equals, 1) }) - } } -// https://github.com/gohugoio/hugo/issues/5777 -func TestTableOfContentsInShortcodes(t *testing.T) { - t.Parallel() - - b := newMultiSiteTestDefaultBuilder(t) - - b.WithTemplatesAdded("layouts/shortcodes/toc.html", tocShortcode) - b.WithTemplatesAdded("layouts/shortcodes/wrapper.html", "{{ .Inner }}") - b.WithContent("post/simple.en.md", tocPageSimple) - b.WithContent("post/variants1.en.md", tocPageVariants1) - b.WithContent("post/variants2.en.md", tocPageVariants2) - - b.WithContent("post/withSCInHeading.en.md", tocPageWithShortcodesInHeadings) - - b.CreateSites().Build(BuildCfg{}) - - b.AssertFileContent("public/en/post/simple/index.html", - tocPageSimpleExpected, - // Make sure it is inserted twice - `TOC1:

    `) + b.AssertFileContent("public/p1/index.html", `
    `) +} + +// Issue 12811. +func TestTableDefaultRSSAndHTML(t *testing.T) { + t.Parallel() + files := ` +-- hugo.toml -- +[outputFormats] + [outputFormats.rss] + weight = 30 + [outputFormats.html] + weight = 20 +-- content/_index.md -- +--- +title: "Home" +output: ["rss", "html"] +--- + +| Item | In Stock | Price | +| :---------------- | :------: | ----: | +| Python Hat | True | 23.99 | +| SQL Hat | True | 23.99 | +| Codecademy Tee | False | 19.99 | +| Codecademy Hoodie | False | 42.99 | + +{{< foo >}} + +-- layouts/index.html -- +Content: {{ .Content }} +-- layouts/index.xml -- +Content: {{ .Content }} +-- layouts/shortcodes/foo.xml -- +foo xml +-- layouts/shortcodes/foo.html -- +foo html + +` + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.xml", "
    ") + b.AssertFileContent("public/index.html", "
    ") +} + +func TestTableDefaultRSSOnly(t *testing.T) { + t.Parallel() + files := ` +-- hugo.toml -- +[outputs] + home = ['rss'] + section = ['rss'] + taxonomy = ['rss'] + term = ['rss'] + page = ['rss'] +disableKinds = ["taxonomy", "term", "page", "section"] +-- content/_index.md -- +--- +title: "Home" +--- + +## Table 1 + +| Item | In Stock | Price | +| :---------------- | :------: | ----: | +| Python Hat | True | 23.99 | +| SQL Hat | True | 23.99 | +| Codecademy Tee | False | 19.99 | +| Codecademy Hoodie | False | 42.99 | + +-- layouts/index.xml -- +Content: {{ .Content }} +` + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.xml", "
    ") +} diff --git a/markup/goldmark/toc.go b/markup/goldmark/toc.go index 1753ede1b..538f65df4 100644 --- a/markup/goldmark/toc.go +++ b/markup/goldmark/toc.go @@ -16,11 +16,16 @@ package goldmark import ( "bytes" + strikethroughAst "github.com/yuin/goldmark/extension/ast" + + emojiAst "github.com/yuin/goldmark-emoji/ast" + "github.com/gohugoio/hugo/markup/tableofcontents" "github.com/yuin/goldmark" "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" "github.com/yuin/goldmark/text" "github.com/yuin/goldmark/util" ) @@ -31,6 +36,7 @@ var ( ) type tocTransformer struct { + r renderer.Renderer } func (t *tocTransformer) Transform(n *ast.Document, reader text.Reader, pc parser.Context) { @@ -39,22 +45,26 @@ func (t *tocTransformer) Transform(n *ast.Document, reader text.Reader, pc parse } var ( - toc tableofcontents.Root - header tableofcontents.Header + toc tableofcontents.Builder + tocHeading = &tableofcontents.Heading{} level int row = -1 inHeading bool headingText bytes.Buffer ) + if ids := pc.IDs().(stringValuesProvider).StringValues(); len(ids) > 0 { + toc.SetIdentifiers(ids) + } + ast.Walk(n, func(n ast.Node, entering bool) (ast.WalkStatus, error) { s := ast.WalkStatus(ast.WalkContinue) if n.Kind() == ast.KindHeading { if inHeading && !entering { - header.Text = headingText.String() + tocHeading.Title = headingText.String() headingText.Reset() - toc.AddAt(header, row, level-1) - header = tableofcontents.Header{} + toc.AddAt(tocHeading, row, level-1) + tocHeading = &tableofcontents.Heading{} inHeading = false return s, nil } @@ -77,25 +87,55 @@ func (t *tocTransformer) Transform(n *ast.Document, reader text.Reader, pc parse id, found := heading.AttributeString("id") if found { - header.ID = string(id.([]byte)) + tocHeading.ID = string(id.([]byte)) + tocHeading.Level = level + } + case + ast.KindCodeSpan, + ast.KindLink, + ast.KindImage, + ast.KindEmphasis, + strikethroughAst.KindStrikethrough, + emojiAst.KindEmoji: + err := t.r.Render(&headingText, reader.Source(), n) + if err != nil { + return s, err + } + + return ast.WalkSkipChildren, nil + case + ast.KindAutoLink, + ast.KindRawHTML, + ast.KindText, + ast.KindString: + err := t.r.Render(&headingText, reader.Source(), n) + if err != nil { + return s, err } - case ast.KindText, ast.KindString: - headingText.Write(n.Text(reader.Source())) } return s, nil }) - pc.Set(tocResultKey, toc) + pc.Set(tocResultKey, toc.Build()) } type tocExtension struct { + options []renderer.Option } -func newTocExtension() goldmark.Extender { - return &tocExtension{} +func newTocExtension(options []renderer.Option) goldmark.Extender { + return &tocExtension{ + options: options, + } } func (e *tocExtension) Extend(m goldmark.Markdown) { - m.Parser().AddOptions(parser.WithASTTransformers(util.Prioritized(&tocTransformer{}, 10))) + r := goldmark.DefaultRenderer() + r.AddOptions(e.options...) + m.Parser().AddOptions(parser.WithASTTransformers(util.Prioritized(&tocTransformer{ + r: r, + }, + // This must run after the ID generation (priority 100). + 110))) } diff --git a/markup/goldmark/toc_integration_test.go b/markup/goldmark/toc_integration_test.go new file mode 100644 index 000000000..814ae199b --- /dev/null +++ b/markup/goldmark/toc_integration_test.go @@ -0,0 +1,286 @@ +// Copyright 2024 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 goldmark_test + +import ( + "testing" + + "github.com/gohugoio/hugo/hugolib" +) + +func TestTableOfContents(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['home','rss','section','sitemap','taxonomy','term'] +enableEmoji = false + +[markup.tableOfContents] +startLevel = 2 +endLevel = 4 +ordered = false + +[markup.goldmark.extensions] +strikethrough = false + +[markup.goldmark.extensions.typographer] +disable = true + +[markup.goldmark.parser] +autoHeadingID = false +autoHeadingIDType = 'github' + +[markup.goldmark.renderer] +unsafe = false +xhtml = false +-- layouts/_default/single.html -- +{{ .TableOfContents }} +-- content/p1.md -- +--- +title: p1 (basic) +--- +# Title +## Section 1 +### Section 1.1 +### Section 1.2 +#### Section 1.2.1 +##### Section 1.2.1.1 +-- content/p2.md -- +--- +title: p2 (markdown) +--- +## Some *emphasized* text +## Some ` + "`" + `inline` + "`" + ` code +## Something to escape A < B && C > B +--- +-- content/p3.md -- +--- +title: p3 (image) +--- +## An image ![kitten](a.jpg) +-- content/p4.md -- +--- +title: p4 (raw html) +--- +## Some raw HTML +-- content/p5.md -- +--- +title: p5 (typographer) +--- +## Some "typographer" markup +-- content/p6.md -- +--- +title: p6 (strikethrough) +--- +## Some ~~deleted~~ text +-- content/p7.md -- +--- +title: p7 (emoji) +--- +## A :snake: emoji +` + + b := hugolib.Test(t, files) + + // basic + b.AssertFileContentExact("public/p1/index.html", ``) + + // markdown + b.AssertFileContent("public/p2/index.html", `
    \n
    \n
    \n
    4")
     
     		result, _ = h.Highlight(lines, "bash", "linenos=inline,hl_lines=2")
    -		c.Assert(result, qt.Contains, "2LINE2\n")
    +		c.Assert(result, qt.Contains, "2LINE2\n")
     		c.Assert(result, qt.Not(qt.Contains), "2\n")
    +		result, _ = h.Highlight(lines, "bash", "anchorlinenos=false,hl_lines=2")
    +		c.Assert(result, qt.Not(qt.Contains), "id=\"2\"")
    +	})
    +
     	c.Run("Highlight lines, linenumbers default on, linenumbers in table default off", func(c *qt.C) {
     		cfg := DefaultConfig
     		cfg.NoClasses = false
    @@ -80,7 +99,7 @@ LINE5
     		h := New(cfg)
     
     		result, _ := h.Highlight(lines, "bash", "")
    -		c.Assert(result, qt.Contains, "2LINE2\n<")
    +		c.Assert(result, qt.Contains, "LINE2\n")
     		result, _ = h.Highlight(lines, "bash", "linenos=table")
     		c.Assert(result, qt.Contains, "1\n")
     	})
    @@ -92,7 +111,7 @@ LINE5
     		h := New(cfg)
     
     		result, _ := h.Highlight(lines, "", "")
    -		c.Assert(result, qt.Equals, "
    LINE1\nLINE2\nLINE3\nLINE4\nLINE5\n
    ") + c.Assert(result, qt.Equals, "
    LINE1\nLINE2\nLINE3\nLINE4\nLINE5\n
    ") }) c.Run("No language, guess syntax", func(c *qt.C) { @@ -104,6 +123,25 @@ LINE5 h := New(cfg) result, _ := h.Highlight(lines, "", "") - c.Assert(result, qt.Contains, "2LINE2\n<") + c.Assert(result, qt.Contains, "LINE2\n") + }) + + c.Run("No language, Escape HTML string", func(c *qt.C) { + cfg := DefaultConfig + cfg.NoClasses = false + h := New(cfg) + + result, _ := h.Highlight("Escaping less-than in code block? ", "", "") + c.Assert(result, qt.Contains, "<fail>") + }) + + c.Run("Highlight lines, default config", func(c *qt.C) { + cfg := DefaultConfig + cfg.NoClasses = false + h := New(cfg) + + result, _ := h.Highlight(coalesceNeeded, "http", "linenos=true,hl_lines=2") + c.Assert(result, qt.Contains, "hello") + c.Assert(result, qt.Contains, "}") }) } diff --git a/markup/internal/attributes/attributes.go b/markup/internal/attributes/attributes.go new file mode 100644 index 000000000..b2d4a5ed6 --- /dev/null +++ b/markup/internal/attributes/attributes.go @@ -0,0 +1,223 @@ +// Copyright 2024 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 attributes + +import ( + "fmt" + "strconv" + "strings" + "sync" + + "github.com/gohugoio/hugo/common/hugio" + "github.com/spf13/cast" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/util" +) + +// Markdown attributes used as options by the Chroma highlighter. +var chromaHighlightProcessingAttributes = map[string]bool{ + "anchorLineNos": true, + "guessSyntax": true, + "hl_Lines": true, + "hl_inline": true, + "lineAnchors": true, + "lineNos": true, + "lineNoStart": true, + "lineNumbersInTable": true, + "noClasses": true, + "style": true, + "tabWidth": true, +} + +func init() { + for k, v := range chromaHighlightProcessingAttributes { + chromaHighlightProcessingAttributes[strings.ToLower(k)] = v + } +} + +type AttributesOwnerType int + +const ( + AttributesOwnerGeneral AttributesOwnerType = iota + AttributesOwnerCodeBlockChroma + AttributesOwnerCodeBlockCustom +) + +func New(astAttributes []ast.Attribute, ownerType AttributesOwnerType) *AttributesHolder { + var ( + attrs []Attribute + opts []Attribute + ) + for _, v := range astAttributes { + nameLower := strings.ToLower(string(v.Name)) + if strings.HasPrefix(string(nameLower), "on") { + continue + } + var vv any + switch vvv := v.Value.(type) { + case bool, float64: + vv = vvv + case []any: + // Highlight line number hlRanges. + var hlRanges [][2]int + for _, l := range vvv { + if ln, ok := l.(float64); ok { + hlRanges = append(hlRanges, [2]int{int(ln) - 1, int(ln) - 1}) + } else if rng, ok := l.([]uint8); ok { + slices := strings.Split(string([]byte(rng)), "-") + lhs, err := strconv.Atoi(slices[0]) + if err != nil { + continue + } + rhs := lhs + if len(slices) > 1 { + rhs, err = strconv.Atoi(slices[1]) + if err != nil { + continue + } + } + hlRanges = append(hlRanges, [2]int{lhs - 1, rhs - 1}) + } + } + vv = hlRanges + case []byte: + // Note that we don't do any HTML escaping here. + // We used to do that, but that changed in #9558. + // Now it's up to the templates to decide. + vv = string(vvv) + default: + panic(fmt.Sprintf("not implemented: %T", vvv)) + } + + if ownerType == AttributesOwnerCodeBlockChroma && chromaHighlightProcessingAttributes[nameLower] { + attr := Attribute{Name: string(v.Name), Value: vv} + opts = append(opts, attr) + } else { + attr := Attribute{Name: nameLower, Value: vv} + attrs = append(attrs, attr) + } + + } + + return &AttributesHolder{ + attributes: attrs, + options: opts, + } +} + +type Attribute struct { + Name string + Value any +} + +func (a Attribute) ValueString() string { + return cast.ToString(a.Value) +} + +// Empty holds no attributes. +var Empty = &AttributesHolder{} + +type AttributesHolder struct { + // What we get from Goldmark. + attributes []Attribute + + // Attributes considered to be an option (code blocks) + options []Attribute + + // What we send to the render hooks. + attributesMapInit sync.Once + attributesMap map[string]any + optionsMapInit sync.Once + optionsMap map[string]any +} + +type Attributes map[string]any + +func (a *AttributesHolder) Attributes() map[string]any { + a.attributesMapInit.Do(func() { + a.attributesMap = make(map[string]any) + for _, v := range a.attributes { + a.attributesMap[v.Name] = v.Value + } + }) + return a.attributesMap +} + +func (a *AttributesHolder) Options() map[string]any { + a.optionsMapInit.Do(func() { + a.optionsMap = make(map[string]any) + for _, v := range a.options { + a.optionsMap[v.Name] = v.Value + } + }) + return a.optionsMap +} + +func (a *AttributesHolder) AttributesSlice() []Attribute { + return a.attributes +} + +func (a *AttributesHolder) OptionsSlice() []Attribute { + return a.options +} + +// RenderASTAttributes writes the AST attributes to the given as attributes to an HTML element. +// This is used by the default HTML renderers, e.g. for headings etc. where no hook template could be found. +// This performs HTML escaping of string attributes. +func RenderASTAttributes(w hugio.FlexiWriter, attributes ...ast.Attribute) { + for _, attr := range attributes { + + a := strings.ToLower(string(attr.Name)) + if strings.HasPrefix(a, "on") { + continue + } + + _, _ = w.WriteString(" ") + _, _ = w.Write(attr.Name) + _, _ = w.WriteString(`="`) + + switch v := attr.Value.(type) { + case []byte: + _, _ = w.Write(util.EscapeHTML(v)) + default: + w.WriteString(cast.ToString(v)) + } + + _ = w.WriteByte('"') + } +} + +// RenderAttributes Render writes the attributes to the given as attributes to an HTML element. +// This is used for the default codeblock rendering. +// This performs HTML escaping of string attributes. +func RenderAttributes(w hugio.FlexiWriter, skipClass bool, attributes ...Attribute) { + for _, attr := range attributes { + a := strings.ToLower(string(attr.Name)) + if skipClass && a == "class" { + continue + } + _, _ = w.WriteString(" ") + _, _ = w.WriteString(attr.Name) + _, _ = w.WriteString(`="`) + + switch v := attr.Value.(type) { + case []byte: + _, _ = w.Write(util.EscapeHTML(v)) + default: + w.WriteString(cast.ToString(v)) + } + + _ = w.WriteByte('"') + } +} diff --git a/markup/internal/external.go b/markup/internal/external.go index 2105e7cff..dc8cc61c2 100644 --- a/markup/internal/external.go +++ b/markup/internal/external.go @@ -2,37 +2,57 @@ package internal import ( "bytes" - "os/exec" + "fmt" "strings" + "github.com/gohugoio/hugo/common/collections" + "github.com/gohugoio/hugo/common/hexec" "github.com/gohugoio/hugo/markup/converter" ) func ExternallyRenderContent( cfg converter.ProviderConfig, ctx converter.DocumentContext, - content []byte, path string, args []string) []byte { - + content []byte, binaryName string, args []string, +) ([]byte, error) { logger := cfg.Logger - cmd := exec.Command(path, args...) - cmd.Stdin = bytes.NewReader(content) + + if strings.Contains(binaryName, "/") { + panic(fmt.Sprintf("should be no slash in %q", binaryName)) + } + + argsv := collections.StringSliceToInterfaceSlice(args) + var out, cmderr bytes.Buffer - cmd.Stdout = &out - cmd.Stderr = &cmderr - err := cmd.Run() + argsv = append(argsv, hexec.WithStdout(&out)) + argsv = append(argsv, hexec.WithStderr(&cmderr)) + argsv = append(argsv, hexec.WithStdin(bytes.NewReader(content))) + + cmd, err := cfg.Exec.New(binaryName, argsv...) + if err != nil { + return nil, err + } + + err = cmd.Run() + // Most external helpers exit w/ non-zero exit code only if severe, i.e. // halting errors occurred. -> log stderr output regardless of state of err for _, item := range strings.Split(cmderr.String(), "\n") { item := strings.TrimSpace(item) if item != "" { - logger.ERROR.Printf("%s: %s", ctx.DocumentName, item) + if err == nil { + logger.Warnf("%s: %s", ctx.DocumentName, item) + } else { + logger.Errorf("%s: %s", ctx.DocumentName, item) + } } } + if err != nil { - logger.ERROR.Printf("%s rendering %s: %v", path, ctx.DocumentName, err) + logger.Errorf("%s rendering %s: %v", binaryName, ctx.DocumentName, err) } - return normalizeExternalHelperLineFeeds(out.Bytes()) + return normalizeExternalHelperLineFeeds(out.Bytes()), nil } // Strips carriage returns from third-party / external processes (useful for Windows) @@ -40,13 +60,13 @@ func normalizeExternalHelperLineFeeds(content []byte) []byte { return bytes.Replace(content, []byte("\r"), []byte(""), -1) } -func GetPythonExecPath() string { - path, err := exec.LookPath("python") - if err != nil { - path, err = exec.LookPath("python.exe") - if err != nil { - return "" +var pythonBinaryCandidates = []string{"python", "python.exe"} + +func GetPythonBinaryAndExecPath() (string, string) { + for _, p := range pythonBinaryCandidates { + if pth := hexec.LookPath(p); pth != "" { + return p, pth } } - return path + return "", "" } diff --git a/markup/markup.go b/markup/markup.go index 8bcfa4c61..cd1c1f823 100644 --- a/markup/markup.go +++ b/markup/markup.go @@ -11,12 +11,15 @@ // See the License for the specific language governing permissions and // limitations under the License. +// Package markup contains the markup handling (e.g. Markdown). package markup import ( + "fmt" "strings" "github.com/gohugoio/hugo/markup/highlight" + "github.com/gohugoio/hugo/media" "github.com/gohugoio/hugo/markup/markup_config" @@ -24,10 +27,8 @@ import ( "github.com/gohugoio/hugo/markup/org" - "github.com/gohugoio/hugo/markup/asciidoc" - "github.com/gohugoio/hugo/markup/blackfriday" + "github.com/gohugoio/hugo/markup/asciidocext" "github.com/gohugoio/hugo/markup/converter" - "github.com/gohugoio/hugo/markup/mmark" "github.com/gohugoio/hugo/markup/pandoc" "github.com/gohugoio/hugo/markup/rst" ) @@ -35,21 +36,16 @@ import ( func NewConverterProvider(cfg converter.ProviderConfig) (ConverterProvider, error) { converters := make(map[string]converter.Provider) - markupConfig, err := markup_config.Decode(cfg.Cfg) - if err != nil { - return nil, err + mcfg := cfg.MarkupConfig() + + if cfg.Highlighter == nil { + cfg.Highlighter = highlight.New(mcfg.Highlight) } - if cfg.Highlight == nil { - h := highlight.New(markupConfig.Highlight) - cfg.Highlight = func(code, lang, optsStr string) (string, error) { - return h.Highlight(code, lang, optsStr) - } - } + defaultHandler := mcfg.DefaultMarkdownHandler + var defaultFound bool - cfg.MarkupConfig = markupConfig - - add := func(p converter.ProviderProvider, aliases ...string) error { + add := func(p converter.ProviderProvider, subType string, aliases ...string) error { c, err := p.New(cfg) if err != nil { return err @@ -58,35 +54,41 @@ func NewConverterProvider(cfg converter.ProviderConfig) (ConverterProvider, erro name := c.Name() aliases = append(aliases, name) + aliases = append(aliases, subType) - if strings.EqualFold(name, cfg.MarkupConfig.DefaultMarkdownHandler) { + if strings.EqualFold(name, defaultHandler) { aliases = append(aliases, "markdown") + defaultFound = true } addConverter(converters, c, aliases...) return nil } - if err := add(goldmark.Provider); err != nil { + contentTypes := cfg.Conf.ContentTypes().(media.ContentTypes) + + if err := add(goldmark.Provider, contentTypes.Markdown.SubType, contentTypes.Markdown.Suffixes()...); err != nil { return nil, err } - if err := add(blackfriday.Provider); err != nil { + if err := add(asciidocext.Provider, contentTypes.AsciiDoc.SubType, contentTypes.AsciiDoc.Suffixes()...); err != nil { return nil, err } - if err := add(mmark.Provider); err != nil { + if err := add(rst.Provider, contentTypes.ReStructuredText.SubType, contentTypes.ReStructuredText.Suffixes()...); err != nil { return nil, err } - if err := add(asciidoc.Provider, "ad", "adoc"); err != nil { + if err := add(pandoc.Provider, contentTypes.Pandoc.SubType, contentTypes.Pandoc.Suffixes()...); err != nil { return nil, err } - if err := add(rst.Provider); err != nil { + if err := add(org.Provider, contentTypes.EmacsOrgMode.SubType, contentTypes.EmacsOrgMode.Suffixes()...); err != nil { return nil, err } - if err := add(pandoc.Provider, "pdc"); err != nil { - return nil, err - } - if err := add(org.Provider); err != nil { - return nil, err + + if !defaultFound { + msg := "markup: Configured defaultMarkdownHandler %q not found." + if defaultHandler == "blackfriday" { + msg += " Did you mean to use goldmark? Blackfriday was removed in Hugo v0.100.0." + } + return nil, fmt.Errorf(msg, defaultHandler) } return &converterRegistry{ @@ -97,13 +99,14 @@ func NewConverterProvider(cfg converter.ProviderConfig) (ConverterProvider, erro type ConverterProvider interface { Get(name string) converter.Provider - //Default() converter.Provider + IsGoldmark(name string) bool + // Default() converter.Provider GetMarkupConfig() markup_config.Config - Highlight(code, lang, optsStr string) (string, error) + GetHighlighter() highlight.Highlighter } type converterRegistry struct { - // Maps name (md, markdown, blackfriday etc.) to a converter provider. + // Maps name (md, markdown, goldmark etc.) to a converter provider. // Note that this is also used for aliasing, so the same converter // may be registered multiple times. // All names are lower case. @@ -112,16 +115,21 @@ type converterRegistry struct { config converter.ProviderConfig } +func (r *converterRegistry) IsGoldmark(name string) bool { + cp := r.Get(name) + return cp != nil && cp.Name() == "goldmark" +} + func (r *converterRegistry) Get(name string) converter.Provider { return r.converters[strings.ToLower(name)] } -func (r *converterRegistry) Highlight(code, lang, optsStr string) (string, error) { - return r.config.Highlight(code, lang, optsStr) +func (r *converterRegistry) GetHighlighter() highlight.Highlighter { + return r.config.Highlighter } func (r *converterRegistry) GetMarkupConfig() markup_config.Config { - return r.config.MarkupConfig + return r.config.MarkupConfig() } func addConverter(m map[string]converter.Provider, c converter.Provider, aliases ...string) { @@ -129,3 +137,16 @@ func addConverter(m map[string]converter.Provider, c converter.Provider, aliases m[alias] = c } } + +// ResolveMarkup returns the markup type. +func ResolveMarkup(s string) string { + s = strings.ToLower(s) + switch s { + case "goldmark": + return media.DefaultContentTypes.Markdown.SubType + case "asciidocext": + return media.DefaultContentTypes.AsciiDoc.SubType + default: + return s + } +} diff --git a/markup/markup_config/config.go b/markup/markup_config/config.go index 529553cb5..e944caae6 100644 --- a/markup/markup_config/config.go +++ b/markup/markup_config/config.go @@ -14,28 +14,35 @@ package markup_config import ( + "github.com/gohugoio/hugo/common/maps" "github.com/gohugoio/hugo/config" - "github.com/gohugoio/hugo/docshelper" - "github.com/gohugoio/hugo/markup/blackfriday/blackfriday_config" + "github.com/gohugoio/hugo/markup/asciidocext/asciidocext_config" "github.com/gohugoio/hugo/markup/goldmark/goldmark_config" "github.com/gohugoio/hugo/markup/highlight" "github.com/gohugoio/hugo/markup/tableofcontents" - "github.com/gohugoio/hugo/parser" "github.com/mitchellh/mapstructure" ) type Config struct { // Default markdown handler for md/markdown extensions. // Default is "goldmark". - // Before Hugo 0.60 this was "blackfriday". DefaultMarkdownHandler string - Highlight highlight.Config + // The configuration used by code highlighters. + Highlight highlight.Config + + // Table of contents configuration TableOfContents tableofcontents.Config - // Content renderers - Goldmark goldmark_config.Config - BlackFriday blackfriday_config.Config + // Configuration for the Goldmark markdown engine. + Goldmark goldmark_config.Config + + // Configuration for the Asciidoc external markdown engine. + AsciidocExt asciidocext_config.Config +} + +func (c *Config) Init() error { + return c.Goldmark.Init() } func Decode(cfg config.Provider) (conf Config, err error) { @@ -45,13 +52,16 @@ func Decode(cfg config.Provider) (conf Config, err error) { if m == nil { return } + m = maps.CleanConfigStringMap(m) + + normalizeConfig(m) err = mapstructure.WeakDecode(m, &conf) if err != nil { return } - if err = applyLegacyConfig(cfg, &conf); err != nil { + if err = conf.Init(); err != nil { return } @@ -62,25 +72,37 @@ func Decode(cfg config.Provider) (conf Config, err error) { return } -func applyLegacyConfig(cfg config.Provider, conf *Config) error { - if bm := cfg.GetStringMap("blackfriday"); bm != nil { - // Legacy top level blackfriday config. - err := mapstructure.WeakDecode(bm, &conf.BlackFriday) - if err != nil { - return err +func normalizeConfig(m map[string]any) { + v, err := maps.GetNestedParam("goldmark.parser", ".", m) + if err == nil { + vm := maps.ToStringMap(v) + // Changed from a bool in 0.81.0 + if vv, found := vm["attribute"]; found { + if vvb, ok := vv.(bool); ok { + vm["attribute"] = goldmark_config.ParserAttribute{ + Title: vvb, + } + } } } - if conf.BlackFriday.FootnoteAnchorPrefix == "" { - conf.BlackFriday.FootnoteAnchorPrefix = cfg.GetString("footnoteAnchorPrefix") + // Changed from a bool in 0.112.0. + v, err = maps.GetNestedParam("goldmark.extensions", ".", m) + if err == nil { + vm := maps.ToStringMap(v) + const typographerKey = "typographer" + if vv, found := vm[typographerKey]; found { + if vvb, ok := vv.(bool); ok { + if !vvb { + vm[typographerKey] = goldmark_config.Typographer{ + Disable: true, + } + } else { + delete(vm, typographerKey) + } + } + } } - - if conf.BlackFriday.FootnoteReturnLinkContents == "" { - conf.BlackFriday.FootnoteReturnLinkContents = cfg.GetString("footnoteReturnLinkContents") - } - - return nil - } var Default = Config{ @@ -90,16 +112,5 @@ var Default = Config{ Highlight: highlight.DefaultConfig, Goldmark: goldmark_config.Default, - BlackFriday: blackfriday_config.Default, -} - -func init() { - docsProvider := func() map[string]interface{} { - docs := make(map[string]interface{}) - docs["markup"] = parser.LowerCaseCamelJSONMarshaller{Value: Default} - return docs - - } - // TODO(bep) merge maps - docshelper.AddDocProvider("config", docsProvider) + AsciidocExt: asciidocext_config.Default, } diff --git a/markup/markup_config/config_test.go b/markup/markup_config/config_test.go index 22f0ab1d4..5169bd79f 100644 --- a/markup/markup_config/config_test.go +++ b/markup/markup_config/config_test.go @@ -16,7 +16,7 @@ package markup_config import ( "testing" - "github.com/spf13/viper" + "github.com/gohugoio/hugo/config" qt "github.com/frankban/quicktest" ) @@ -26,45 +26,62 @@ func TestConfig(t *testing.T) { c.Run("Decode", func(c *qt.C) { c.Parallel() - v := viper.New() + v := config.New() - v.Set("markup", map[string]interface{}{ - "goldmark": map[string]interface{}{ - "renderer": map[string]interface{}{ + v.Set("markup", map[string]any{ + "goldmark": map[string]any{ + "renderer": map[string]any{ "unsafe": true, }, }, + "asciidocext": map[string]any{ + "workingFolderCurrent": true, + "safeMode": "save", + "extensions": []string{"asciidoctor-html5s"}, + }, }) conf, err := Decode(v) c.Assert(err, qt.IsNil) c.Assert(conf.Goldmark.Renderer.Unsafe, qt.Equals, true) - c.Assert(conf.BlackFriday.Fractions, qt.Equals, true) + c.Assert(conf.Goldmark.Parser.Attribute.Title, qt.Equals, true) + c.Assert(conf.Goldmark.Parser.Attribute.Block, qt.Equals, false) + c.Assert(conf.AsciidocExt.WorkingFolderCurrent, qt.Equals, true) + c.Assert(conf.AsciidocExt.Extensions[0], qt.Equals, "asciidoctor-html5s") }) - c.Run("legacy", func(c *qt.C) { + c.Run("Decode legacy typographer", func(c *qt.C) { c.Parallel() - v := viper.New() + v := config.New() - v.Set("blackfriday", map[string]interface{}{ - "angledQuotes": true, + // typographer was changed from a bool to a struct in 0.112.0. + v.Set("markup", map[string]any{ + "goldmark": map[string]any{ + "extensions": map[string]any{ + "typographer": false, + }, + }, }) - v.Set("footnoteAnchorPrefix", "myprefix") - v.Set("footnoteReturnLinkContents", "myreturn") - v.Set("pygmentsStyle", "hugo") - v.Set("pygmentsCodefencesGuessSyntax", true) conf, err := Decode(v) c.Assert(err, qt.IsNil) - c.Assert(conf.BlackFriday.AngledQuotes, qt.Equals, true) - c.Assert(conf.BlackFriday.FootnoteAnchorPrefix, qt.Equals, "myprefix") - c.Assert(conf.BlackFriday.FootnoteReturnLinkContents, qt.Equals, "myreturn") - c.Assert(conf.Highlight.Style, qt.Equals, "hugo") - c.Assert(conf.Highlight.CodeFences, qt.Equals, true) - c.Assert(conf.Highlight.GuessSyntax, qt.Equals, true) - }) + c.Assert(conf.Goldmark.Extensions.Typographer.Disable, qt.Equals, true) + v.Set("markup", map[string]any{ + "goldmark": map[string]any{ + "extensions": map[string]any{ + "typographer": true, + }, + }, + }) + + conf, err = Decode(v) + + c.Assert(err, qt.IsNil) + c.Assert(conf.Goldmark.Extensions.Typographer.Disable, qt.Equals, false) + c.Assert(conf.Goldmark.Extensions.Typographer.Ellipsis, qt.Equals, "…") + }) } diff --git a/markup/markup_test.go b/markup/markup_test.go index 669c0a446..172099d5c 100644 --- a/markup/markup_test.go +++ b/markup/markup_test.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Hugo Authors. All rights reserved. +// Copyright 2024 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. @@ -11,22 +11,21 @@ // See the License for the specific language governing permissions and // limitations under the License. -package markup +package markup_test import ( "testing" - "github.com/spf13/viper" - - "github.com/gohugoio/hugo/markup/converter" - qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/config/testconfig" + "github.com/gohugoio/hugo/markup" + "github.com/gohugoio/hugo/markup/converter" ) func TestConverterRegistry(t *testing.T) { c := qt.New(t) - - r, err := NewConverterProvider(converter.ProviderConfig{Cfg: viper.New()}) + conf := testconfig.GetTestConfig(nil, nil) + r, err := markup.NewConverterProvider(converter.ProviderConfig{Conf: conf}) c.Assert(err, qt.IsNil) c.Assert("goldmark", qt.Equals, r.GetMarkupConfig().DefaultMarkdownHandler) @@ -41,11 +40,8 @@ func TestConverterRegistry(t *testing.T) { c.Assert(r.Get("markdown").Name(), qt.Equals, "goldmark") checkName("goldmark") - checkName("mmark") - checkName("asciidoc") + checkName("asciidocext") checkName("rst") checkName("pandoc") checkName("org") - checkName("blackfriday") - } diff --git a/markup/mmark/convert.go b/markup/mmark/convert.go deleted file mode 100644 index 0682ad276..000000000 --- a/markup/mmark/convert.go +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright 2019 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 mmark converts Markdown to HTML using MMark v1. -package mmark - -import ( - "github.com/gohugoio/hugo/identity" - "github.com/gohugoio/hugo/markup/blackfriday/blackfriday_config" - "github.com/gohugoio/hugo/markup/converter" - "github.com/miekg/mmark" -) - -// Provider is the package entry point. -var Provider converter.ProviderProvider = provider{} - -type provider struct { -} - -func (p provider) New(cfg converter.ProviderConfig) (converter.Provider, error) { - defaultBlackFriday := cfg.MarkupConfig.BlackFriday - defaultExtensions := getMmarkExtensions(defaultBlackFriday) - - return converter.NewProvider("mmark", func(ctx converter.DocumentContext) (converter.Converter, error) { - b := defaultBlackFriday - extensions := defaultExtensions - - if ctx.ConfigOverrides != nil { - var err error - b, err = blackfriday_config.UpdateConfig(b, ctx.ConfigOverrides) - if err != nil { - return nil, err - } - extensions = getMmarkExtensions(b) - } - - return &mmarkConverter{ - ctx: ctx, - b: b, - extensions: extensions, - cfg: cfg, - }, nil - }), nil - -} - -type mmarkConverter struct { - ctx converter.DocumentContext - extensions int - b blackfriday_config.Config - cfg converter.ProviderConfig -} - -func (c *mmarkConverter) Convert(ctx converter.RenderContext) (converter.Result, error) { - r := getHTMLRenderer(c.ctx, c.b, c.cfg) - return mmark.Parse(ctx.Src, r, c.extensions), nil -} - -func (c *mmarkConverter) Supports(feature identity.Identity) bool { - return false -} - -func getHTMLRenderer( - ctx converter.DocumentContext, - cfg blackfriday_config.Config, - pcfg converter.ProviderConfig) mmark.Renderer { - - var ( - flags int - documentID string - ) - - documentID = ctx.DocumentID - - renderParameters := mmark.HtmlRendererParameters{ - FootnoteAnchorPrefix: cfg.FootnoteAnchorPrefix, - FootnoteReturnLinkContents: cfg.FootnoteReturnLinkContents, - } - - if documentID != "" && !cfg.PlainIDAnchors { - renderParameters.FootnoteAnchorPrefix = documentID + ":" + renderParameters.FootnoteAnchorPrefix - } - - htmlFlags := flags - htmlFlags |= mmark.HTML_FOOTNOTE_RETURN_LINKS - - return &mmarkRenderer{ - BlackfridayConfig: cfg, - Config: pcfg, - Renderer: mmark.HtmlRendererWithParameters(htmlFlags, "", "", renderParameters), - } - -} - -func getMmarkExtensions(cfg blackfriday_config.Config) int { - flags := 0 - flags |= mmark.EXTENSION_TABLES - flags |= mmark.EXTENSION_FENCED_CODE - flags |= mmark.EXTENSION_AUTOLINK - flags |= mmark.EXTENSION_SPACE_HEADERS - flags |= mmark.EXTENSION_CITATION - flags |= mmark.EXTENSION_TITLEBLOCK_TOML - flags |= mmark.EXTENSION_HEADER_IDS - flags |= mmark.EXTENSION_AUTO_HEADER_IDS - flags |= mmark.EXTENSION_UNIQUE_HEADER_IDS - flags |= mmark.EXTENSION_FOOTNOTES - flags |= mmark.EXTENSION_SHORT_REF - flags |= mmark.EXTENSION_NO_EMPTY_LINE_BEFORE_BLOCK - flags |= mmark.EXTENSION_INCLUDE - - for _, extension := range cfg.Extensions { - if flag, ok := mmarkExtensionMap[extension]; ok { - flags |= flag - } - } - return flags -} - -var mmarkExtensionMap = map[string]int{ - "tables": mmark.EXTENSION_TABLES, - "fencedCode": mmark.EXTENSION_FENCED_CODE, - "autolink": mmark.EXTENSION_AUTOLINK, - "laxHtmlBlocks": mmark.EXTENSION_LAX_HTML_BLOCKS, - "spaceHeaders": mmark.EXTENSION_SPACE_HEADERS, - "hardLineBreak": mmark.EXTENSION_HARD_LINE_BREAK, - "footnotes": mmark.EXTENSION_FOOTNOTES, - "noEmptyLineBeforeBlock": mmark.EXTENSION_NO_EMPTY_LINE_BEFORE_BLOCK, - "headerIds": mmark.EXTENSION_HEADER_IDS, - "autoHeaderIds": mmark.EXTENSION_AUTO_HEADER_IDS, -} diff --git a/markup/mmark/convert_test.go b/markup/mmark/convert_test.go deleted file mode 100644 index 3945f80da..000000000 --- a/markup/mmark/convert_test.go +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright 2019 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 mmark - -import ( - "testing" - - "github.com/spf13/viper" - - "github.com/gohugoio/hugo/common/loggers" - - qt "github.com/frankban/quicktest" - "github.com/gohugoio/hugo/markup/blackfriday/blackfriday_config" - "github.com/gohugoio/hugo/markup/converter" - "github.com/miekg/mmark" -) - -func TestGetMmarkExtensions(t *testing.T) { - b := blackfriday_config.Default - - //TODO: This is doing the same just with different marks... - type data struct { - testFlag int - } - - b.Extensions = []string{"tables"} - b.ExtensionsMask = []string{""} - allExtensions := []data{ - {mmark.EXTENSION_TABLES}, - {mmark.EXTENSION_FENCED_CODE}, - {mmark.EXTENSION_AUTOLINK}, - {mmark.EXTENSION_SPACE_HEADERS}, - {mmark.EXTENSION_CITATION}, - {mmark.EXTENSION_TITLEBLOCK_TOML}, - {mmark.EXTENSION_HEADER_IDS}, - {mmark.EXTENSION_AUTO_HEADER_IDS}, - {mmark.EXTENSION_UNIQUE_HEADER_IDS}, - {mmark.EXTENSION_FOOTNOTES}, - {mmark.EXTENSION_SHORT_REF}, - {mmark.EXTENSION_NO_EMPTY_LINE_BEFORE_BLOCK}, - {mmark.EXTENSION_INCLUDE}, - } - - actualFlags := getMmarkExtensions(b) - for _, e := range allExtensions { - if actualFlags&e.testFlag != e.testFlag { - t.Errorf("Flag %v was not found in the list of extensions.", e) - } - } -} - -func TestConvert(t *testing.T) { - c := qt.New(t) - p, err := Provider.New(converter.ProviderConfig{Cfg: viper.New(), Logger: loggers.NewErrorLogger()}) - c.Assert(err, qt.IsNil) - conv, err := p.New(converter.DocumentContext{}) - c.Assert(err, qt.IsNil) - b, err := conv.Convert(converter.RenderContext{Src: []byte("testContent")}) - c.Assert(err, qt.IsNil) - c.Assert(string(b.Bytes()), qt.Equals, "

    testContent

    \n") -} diff --git a/markup/mmark/renderer.go b/markup/mmark/renderer.go deleted file mode 100644 index 6cb7f105e..000000000 --- a/markup/mmark/renderer.go +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright 2019 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 mmark - -import ( - "bytes" - "strings" - - "github.com/gohugoio/hugo/markup/blackfriday/blackfriday_config" - "github.com/gohugoio/hugo/markup/converter" - "github.com/miekg/mmark" -) - -// hugoHTMLRenderer wraps a blackfriday.Renderer, typically a blackfriday.Html -// adding some custom behaviour. -type mmarkRenderer struct { - Config converter.ProviderConfig - BlackfridayConfig blackfriday_config.Config - mmark.Renderer -} - -// BlockCode renders a given text as a block of code. -func (r *mmarkRenderer) BlockCode(out *bytes.Buffer, text []byte, lang string, caption []byte, subfigure bool, callouts bool) { - if r.Config.MarkupConfig.Highlight.CodeFences { - str := strings.Trim(string(text), "\n\r") - highlighted, _ := r.Config.Highlight(str, lang, "") - out.WriteString(highlighted) - } else { - r.Renderer.BlockCode(out, text, lang, caption, subfigure, callouts) - } -} diff --git a/markup/org/convert.go b/markup/org/convert.go index 2b1fbb73c..141269f1d 100644 --- a/markup/org/convert.go +++ b/markup/org/convert.go @@ -16,6 +16,7 @@ package org import ( "bytes" + "log" "github.com/gohugoio/hugo/identity" @@ -27,8 +28,7 @@ import ( // Provider is the package entry point. var Provider converter.ProviderProvider = provide{} -type provide struct { -} +type provide struct{} func (p provide) New(cfg converter.ProviderConfig) (converter.Provider, error) { return converter.NewProvider("org", func(ctx converter.DocumentContext) (converter.Converter, error) { @@ -44,18 +44,18 @@ type orgConverter struct { cfg converter.ProviderConfig } -func (c *orgConverter) Convert(ctx converter.RenderContext) (converter.Result, error) { +func (c *orgConverter) Convert(ctx converter.RenderContext) (converter.ResultRender, error) { logger := c.cfg.Logger config := org.New() - config.Log = logger.WARN + config.Log = log.Default() // TODO(bep) config.ReadFile = func(filename string) ([]byte, error) { return afero.ReadFile(c.cfg.ContentFs, filename) } writer := org.NewHTMLWriter() - writer.HighlightCodeBlock = func(source, lang string) string { + writer.HighlightCodeBlock = func(source, lang string, inline bool, params map[string]string) string { highlightedSource, err := c.cfg.Highlight(source, lang, "") if err != nil { - logger.ERROR.Printf("Could not highlight source as lang %s. Using raw source.", lang) + logger.Errorf("Could not highlight source as lang %s. Using raw source.", lang) return source } return highlightedSource @@ -63,7 +63,7 @@ func (c *orgConverter) Convert(ctx converter.RenderContext) (converter.Result, e html, err := config.Parse(bytes.NewReader(ctx.Src), c.ctx.DocumentName).Write(writer) if err != nil { - logger.ERROR.Printf("Could not render org: %s. Using unrendered content.", err) + logger.Errorf("Could not render org: %s. Using unrendered content.", err) return converter.Bytes(ctx.Src), nil } return converter.Bytes([]byte(html)), nil diff --git a/markup/org/convert_test.go b/markup/org/convert_test.go index 94fcdf836..16c4306ff 100644 --- a/markup/org/convert_test.go +++ b/markup/org/convert_test.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Hugo Authors. All rights reserved. +// Copyright 2024 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. @@ -11,25 +11,31 @@ // See the License for the specific language governing permissions and // limitations under the License. -package org +package org_test import ( "testing" "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/config/testconfig" + "github.com/spf13/afero" "github.com/gohugoio/hugo/markup/converter" + "github.com/gohugoio/hugo/markup/org" qt "github.com/frankban/quicktest" ) func TestConvert(t *testing.T) { c := qt.New(t) - p, err := Provider.New(converter.ProviderConfig{Logger: loggers.NewErrorLogger()}) + p, err := org.Provider.New(converter.ProviderConfig{ + Logger: loggers.NewDefault(), + Conf: testconfig.GetTestConfig(afero.NewMemMapFs(), nil), + }) c.Assert(err, qt.IsNil) conv, err := p.New(converter.DocumentContext{}) c.Assert(err, qt.IsNil) b, err := conv.Convert(converter.RenderContext{Src: []byte("testContent")}) c.Assert(err, qt.IsNil) - c.Assert(string(b.Bytes()), qt.Equals, "

    \ntestContent\n

    \n") + c.Assert(string(b.Bytes()), qt.Equals, "

    testContent

    \n") } diff --git a/markup/pandoc/convert.go b/markup/pandoc/convert.go index d6d5ab18c..8f2d99c9a 100644 --- a/markup/pandoc/convert.go +++ b/markup/pandoc/convert.go @@ -15,19 +15,18 @@ package pandoc import ( - "os/exec" - + "github.com/gohugoio/hugo/common/hexec" + "github.com/gohugoio/hugo/htesting" "github.com/gohugoio/hugo/identity" - "github.com/gohugoio/hugo/markup/internal" "github.com/gohugoio/hugo/markup/converter" + "github.com/gohugoio/hugo/markup/internal" ) // Provider is the package entry point. var Provider converter.ProviderProvider = provider{} -type provider struct { -} +type provider struct{} func (p provider) New(cfg converter.ProviderConfig) (converter.Provider, error) { return converter.NewProvider("pandoc", func(ctx converter.DocumentContext) (converter.Converter, error) { @@ -36,7 +35,6 @@ func (p provider) New(cfg converter.ProviderConfig) (converter.Provider, error) cfg: cfg, }, nil }), nil - } type pandocConverter struct { @@ -44,8 +42,12 @@ type pandocConverter struct { cfg converter.ProviderConfig } -func (c *pandocConverter) Convert(ctx converter.RenderContext) (converter.Result, error) { - return converter.Bytes(c.getPandocContent(ctx.Src, c.ctx)), nil +func (c *pandocConverter) Convert(ctx converter.RenderContext) (converter.ResultRender, error) { + b, err := c.getPandocContent(ctx.Src, c.ctx) + if err != nil { + return nil, err + } + return converter.Bytes(b), nil } func (c *pandocConverter) Supports(feature identity.Identity) bool { @@ -53,28 +55,35 @@ func (c *pandocConverter) Supports(feature identity.Identity) bool { } // getPandocContent calls pandoc as an external helper to convert pandoc markdown to HTML. -func (c *pandocConverter) getPandocContent(src []byte, ctx converter.DocumentContext) []byte { +func (c *pandocConverter) getPandocContent(src []byte, ctx converter.DocumentContext) ([]byte, error) { logger := c.cfg.Logger - path := getPandocExecPath() - if path == "" { - logger.ERROR.Println("pandoc not found in $PATH: Please install.\n", + binaryName := getPandocBinaryName() + if binaryName == "" { + logger.Println("pandoc not found in $PATH: Please install.\n", " Leaving pandoc content unrendered.") - return src + return src, nil } args := []string{"--mathjax"} - return internal.ExternallyRenderContent(c.cfg, ctx, src, path, args) + return internal.ExternallyRenderContent(c.cfg, ctx, src, binaryName, args) } -func getPandocExecPath() string { - path, err := exec.LookPath("pandoc") - if err != nil { - return "" - } +const pandocBinary = "pandoc" - return path +func getPandocBinaryName() string { + if hexec.InPath(pandocBinary) { + return pandocBinary + } + return "" } // Supports returns whether Pandoc is installed on this computer. func Supports() bool { - return getPandocExecPath() != "" + hasBin := getPandocBinaryName() != "" + if htesting.SupportsAll() { + if !hasBin { + panic("pandoc not installed") + } + return true + } + return hasBin } diff --git a/markup/pandoc/convert_test.go b/markup/pandoc/convert_test.go index bd6ca19e6..dff6b1ed3 100644 --- a/markup/pandoc/convert_test.go +++ b/markup/pandoc/convert_test.go @@ -16,7 +16,9 @@ package pandoc import ( "testing" + "github.com/gohugoio/hugo/common/hexec" "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/config/security" "github.com/gohugoio/hugo/markup/converter" @@ -28,7 +30,11 @@ func TestConvert(t *testing.T) { t.Skip("pandoc not installed") } c := qt.New(t) - p, err := Provider.New(converter.ProviderConfig{Logger: loggers.NewErrorLogger()}) + sc := security.DefaultConfig + var err error + sc.Exec.Allow, err = security.NewWhitelist("pandoc") + c.Assert(err, qt.IsNil) + p, err := Provider.New(converter.ProviderConfig{Exec: hexec.New(sc, "", loggers.NewDefault()), Logger: loggers.NewDefault()}) c.Assert(err, qt.IsNil) conv, err := p.New(converter.DocumentContext{}) c.Assert(err, qt.IsNil) diff --git a/markup/rst/convert.go b/markup/rst/convert.go index 64cc8b511..5bb0adb15 100644 --- a/markup/rst/convert.go +++ b/markup/rst/convert.go @@ -16,20 +16,21 @@ package rst import ( "bytes" - "os/exec" "runtime" + "github.com/gohugoio/hugo/common/hexec" + "github.com/gohugoio/hugo/htesting" + "github.com/gohugoio/hugo/identity" - "github.com/gohugoio/hugo/markup/internal" "github.com/gohugoio/hugo/markup/converter" + "github.com/gohugoio/hugo/markup/internal" ) // Provider is the package entry point. var Provider converter.ProviderProvider = provider{} -type provider struct { -} +type provider struct{} func (p provider) New(cfg converter.ProviderConfig) (converter.Provider, error) { return converter.NewProvider("rst", func(ctx converter.DocumentContext) (converter.Converter, error) { @@ -45,8 +46,12 @@ type rstConverter struct { cfg converter.ProviderConfig } -func (c *rstConverter) Convert(ctx converter.RenderContext) (converter.Result, error) { - return converter.Bytes(c.getRstContent(ctx.Src, c.ctx)), nil +func (c *rstConverter) Convert(ctx converter.RenderContext) (converter.ResultRender, error) { + b, err := c.getRstContent(ctx.Src, c.ctx) + if err != nil { + return nil, err + } + return converter.Bytes(b), nil } func (c *rstConverter) Supports(feature identity.Identity) bool { @@ -55,58 +60,72 @@ func (c *rstConverter) Supports(feature identity.Identity) bool { // getRstContent calls the Python script rst2html as an external helper // to convert reStructuredText content to HTML. -func (c *rstConverter) getRstContent(src []byte, ctx converter.DocumentContext) []byte { +func (c *rstConverter) getRstContent(src []byte, ctx converter.DocumentContext) ([]byte, error) { logger := c.cfg.Logger - path := getRstExecPath() + binaryName, binaryPath := getRstBinaryNameAndPath() - if path == "" { - logger.ERROR.Println("rst2html / rst2html.py not found in $PATH: Please install.\n", + if binaryName == "" { + logger.Println("rst2html / rst2html.py not found in $PATH: Please install.\n", " Leaving reStructuredText content unrendered.") - return src + return src, nil } - logger.INFO.Println("Rendering", ctx.DocumentName, "with", path, "...") + + logger.Infoln("Rendering", ctx.DocumentName, "with", binaryName, "...") + var result []byte + var err error + // certain *nix based OSs wrap executables in scripted launchers // invoking binaries on these OSs via python interpreter causes SyntaxError // invoke directly so that shebangs work as expected // handle Windows manually because it doesn't do shebangs if runtime.GOOS == "windows" { - python := internal.GetPythonExecPath() - args := []string{path, "--leave-comments", "--initial-header-level=2"} - result = internal.ExternallyRenderContent(c.cfg, ctx, src, python, args) + pythonBinary, _ := internal.GetPythonBinaryAndExecPath() + args := []string{binaryPath, "--leave-comments", "--initial-header-level=2"} + result, err = internal.ExternallyRenderContent(c.cfg, ctx, src, pythonBinary, args) } else { args := []string{"--leave-comments", "--initial-header-level=2"} - result = internal.ExternallyRenderContent(c.cfg, ctx, src, path, args) + result, err = internal.ExternallyRenderContent(c.cfg, ctx, src, binaryName, args) } + + if err != nil { + return nil, err + } + // TODO(bep) check if rst2html has a body only option. bodyStart := bytes.Index(result, []byte("\n")) if bodyStart < 0 { - bodyStart = -7 //compensate for length + bodyStart = -7 // compensate for length } bodyEnd := bytes.Index(result, []byte("\n")) if bodyEnd < 0 || bodyEnd >= len(result) { - bodyEnd = len(result) - 1 - if bodyEnd < 0 { - bodyEnd = 0 - } + bodyEnd = max(len(result)-1, 0) } - return result[bodyStart+7 : bodyEnd] + return result[bodyStart+7 : bodyEnd], err } -func getRstExecPath() string { - path, err := exec.LookPath("rst2html") - if err != nil { - path, err = exec.LookPath("rst2html.py") - if err != nil { - return "" +var rst2Binaries = []string{"rst2html", "rst2html.py"} + +func getRstBinaryNameAndPath() (string, string) { + for _, candidate := range rst2Binaries { + if pth := hexec.LookPath(candidate); pth != "" { + return candidate, pth } } - return path + return "", "" } -// Supports returns whether rst is installed on this computer. +// Supports returns whether rst is (or should be) installed on this computer. func Supports() bool { - return getRstExecPath() != "" + name, _ := getRstBinaryNameAndPath() + hasBin := name != "" + if htesting.SupportsAll() { + if !hasBin { + panic("rst not installed") + } + return true + } + return hasBin } diff --git a/markup/rst/convert_test.go b/markup/rst/convert_test.go index 269d92caa..730e00acf 100644 --- a/markup/rst/convert_test.go +++ b/markup/rst/convert_test.go @@ -16,7 +16,9 @@ package rst import ( "testing" + "github.com/gohugoio/hugo/common/hexec" "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/config/security" "github.com/gohugoio/hugo/markup/converter" @@ -28,7 +30,14 @@ func TestConvert(t *testing.T) { t.Skip("rst not installed") } c := qt.New(t) - p, err := Provider.New(converter.ProviderConfig{Logger: loggers.NewErrorLogger()}) + sc := security.DefaultConfig + sc.Exec.Allow = security.MustNewWhitelist("rst", "python") + + p, err := Provider.New( + converter.ProviderConfig{ + Logger: loggers.NewDefault(), + Exec: hexec.New(sc, "", loggers.NewDefault()), + }) c.Assert(err, qt.IsNil) conv, err := p.New(converter.DocumentContext{}) c.Assert(err, qt.IsNil) diff --git a/markup/tableofcontents/tableofcontents.go b/markup/tableofcontents/tableofcontents.go index 780310083..6c40c9a59 100644 --- a/markup/tableofcontents/tableofcontents.go +++ b/markup/tableofcontents/tableofcontents.go @@ -14,69 +14,175 @@ package tableofcontents import ( + "fmt" + "html/template" + "sort" "strings" + + "github.com/gohugoio/hugo/common/collections" + "github.com/spf13/cast" ) -// Headers holds the top level (h1) headers. -type Headers []Header +// Empty is an empty ToC. +var Empty = &Fragments{ + Headings: Headings{}, + HeadingsMap: map[string]*Heading{}, +} -// Header holds the data about a header and its children. -type Header struct { - ID string - Text string +// Builder is used to build the ToC data structure. +type Builder struct { + identifiersSet bool + toc *Fragments +} - Headers Headers +// AddAt adds the heading to the ToC. +func (b *Builder) AddAt(h *Heading, row, level int) { + if b.toc == nil { + b.toc = &Fragments{} + } + b.toc.addAt(h, row, level) +} + +// SetIdentifiers sets the identifiers in the ToC. +func (b *Builder) SetIdentifiers(ids []string) { + if b.toc == nil { + b.toc = &Fragments{} + } + b.identifiersSet = true + sort.Strings(ids) + b.toc.Identifiers = ids +} + +// Build returns the ToC. +func (b Builder) Build() *Fragments { + if b.toc == nil { + return Empty + } + b.toc.HeadingsMap = make(map[string]*Heading) + b.toc.walk(func(h *Heading) { + if h.ID != "" { + b.toc.HeadingsMap[h.ID] = h + if !b.identifiersSet { + b.toc.Identifiers = append(b.toc.Identifiers, h.ID) + } + } + }) + sort.Strings(b.toc.Identifiers) + return b.toc +} + +// Headings holds the top level headings. +type Headings []*Heading + +// FilterBy returns a new Headings slice with all headings that matches the given predicate. +// For internal use only. +func (h Headings) FilterBy(fn func(*Heading) bool) Headings { + var out Headings + + for _, h := range h { + h.walk(func(h *Heading) { + if fn(h) { + out = append(out, h) + } + }) + } + return out +} + +// Heading holds the data about a heading and its children. +type Heading struct { + ID string + Level int + Title string + + Headings Headings } // IsZero is true when no ID or Text is set. -func (h Header) IsZero() bool { - return h.ID == "" && h.Text == "" +func (h Heading) IsZero() bool { + return h.ID == "" && h.Title == "" } -// Root implements AddAt, which can be used to build the -// data structure for the ToC. -type Root struct { - Headers Headers +func (h *Heading) walk(fn func(*Heading)) { + fn(h) + for _, h := range h.Headings { + h.walk(fn) + } } -// AddAt adds the header into the given location. -func (toc *Root) AddAt(h Header, y, x int) { - for i := len(toc.Headers); i <= y; i++ { - toc.Headers = append(toc.Headers, Header{}) +// Fragments holds the table of contents for a page. +type Fragments struct { + // Headings holds the top level headings. + Headings Headings + + // Identifiers holds all the identifiers in the ToC as a sorted slice. + // Note that collections.SortedStringSlice has both a Contains and Count method + // that can be used to identify missing and duplicate IDs. + Identifiers collections.SortedStringSlice + + // HeadingsMap holds all the headings in the ToC as a map. + // Note that with duplicate IDs, the last one will win. + HeadingsMap map[string]*Heading +} + +// addAt adds the heading into the given location. +func (toc *Fragments) addAt(h *Heading, row, level int) { + for i := len(toc.Headings); i <= row; i++ { + toc.Headings = append(toc.Headings, &Heading{}) } - if x == 0 { - toc.Headers[y] = h + if level == 0 { + toc.Headings[row] = h return } - header := &toc.Headers[y] + heading := toc.Headings[row] - for i := 1; i < x; i++ { - if len(header.Headers) == 0 { - header.Headers = append(header.Headers, Header{}) + for i := 1; i < level; i++ { + if len(heading.Headings) == 0 { + heading.Headings = append(heading.Headings, &Heading{}) } - header = &header.Headers[len(header.Headers)-1] + heading = heading.Headings[len(heading.Headings)-1] } - header.Headers = append(header.Headers, h) + heading.Headings = append(heading.Headings, h) } // ToHTML renders the ToC as HTML. -func (toc Root) ToHTML(startLevel, stopLevel int, ordered bool) string { +func (toc *Fragments) ToHTML(startLevel, stopLevel any, ordered bool) (template.HTML, error) { + if toc == nil { + return "", nil + } + + iStartLevel, err := cast.ToIntE(startLevel) + if err != nil { + return "", fmt.Errorf("startLevel: %w", err) + } + + iStopLevel, err := cast.ToIntE(stopLevel) + if err != nil { + return "", fmt.Errorf("stopLevel: %w", err) + } + b := &tocBuilder{ s: strings.Builder{}, - h: toc.Headers, - startLevel: startLevel, - stopLevel: stopLevel, + h: toc.Headings, + startLevel: iStartLevel, + stopLevel: iStopLevel, ordered: ordered, } b.Build() - return b.s.String() + return template.HTML(b.s.String()), nil +} + +func (toc Fragments) walk(fn func(*Heading)) { + for _, h := range toc.Headings { + h.walk(fn) + } } type tocBuilder struct { s strings.Builder - h Headers + h Headings startLevel int stopLevel int @@ -87,16 +193,16 @@ func (b *tocBuilder) Build() { b.writeNav(b.h) } -func (b *tocBuilder) writeNav(h Headers) { +func (b *tocBuilder) writeNav(h Headings) { b.s.WriteString("") } -func (b *tocBuilder) writeHeaders(level, indent int, h Headers) { +func (b *tocBuilder) writeHeadings(level, indent int, h Headings) { if level < b.startLevel { for _, h := range h { - b.writeHeaders(level+1, indent, h.Headers) + b.writeHeadings(level+1, indent, h.Headings) } return } @@ -118,7 +224,7 @@ func (b *tocBuilder) writeHeaders(level, indent int, h Headers) { } for _, h := range h { - b.writeHeader(level+1, indent+2, h) + b.writeHeading(level+1, indent+2, h) } if hasChildren { @@ -131,20 +237,20 @@ func (b *tocBuilder) writeHeaders(level, indent int, h Headers) { b.s.WriteString("\n") b.indent(indent) } - } -func (b *tocBuilder) writeHeader(level, indent int, h Header) { + +func (b *tocBuilder) writeHeading(level, indent int, h *Heading) { b.indent(indent) b.s.WriteString("
  • ") if !h.IsZero() { - b.s.WriteString("" + h.Text + "") + b.s.WriteString("" + h.Title + "") } - b.writeHeaders(level, indent, h.Headers) + b.writeHeadings(level, indent, h.Headings) b.s.WriteString("
  • \n") } func (b *tocBuilder) indent(n int) { - for i := 0; i < n; i++ { + for range n { b.s.WriteString(" ") } } @@ -159,6 +265,7 @@ var DefaultConfig = Config{ type Config struct { // Heading start level to include in the table of contents, starting // at h1 (inclusive). + // { "identifiers": ["h1"] } StartLevel int // Heading end level, inclusive, to include in the table of contents. diff --git a/markup/tableofcontents/tableofcontents_integration_test.go b/markup/tableofcontents/tableofcontents_integration_test.go new file mode 100644 index 000000000..e6ae03ce2 --- /dev/null +++ b/markup/tableofcontents/tableofcontents_integration_test.go @@ -0,0 +1,123 @@ +// Copyright 2024 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 tableofcontents_test + +import ( + "strings" + "testing" + + "github.com/gohugoio/hugo/hugolib" +) + +// Issue #10776 +func TestHeadingsLevel(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['page','rss','section','sitemap','taxonomy','term'] +-- layouts/index.html -- +{{ range .Fragments.HeadingsMap }} + {{ printf "%s|%d|%s" .ID .Level .Title }} +{{ end }} +-- content/_index.md -- +## Heading L2 +### Heading L3 +##### Heading L5 +` + + b := hugolib.Test(t, files) + b.AssertFileContent("public/index.html", + "heading-l2|2|Heading L2", + "heading-l3|3|Heading L3", + "heading-l5|5|Heading L5", + ) +} + +// Issue #13107 +func TestToHTMLArgTypes(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['home','section','rss','sitemap','taxonomy','term'] +-- layouts/_default/single.html -- +{{ .Fragments.ToHTML .Params.toc.startLevel .Params.toc.endLevel false }} +-- content/json.md -- +{ + "title": "json", + "params": { + "toc": { + "startLevel": 2, + "endLevel": 4 + } + } +} +CONTENT +-- content/toml.md -- ++++ +title = 'toml' +[params.toc] +startLevel = 2 +endLevel = 4 ++++ +CONTENT +-- content/yaml.md -- +--- +title: yaml +params: + toc: + startLevel: 2 + endLevel: 4 +--- +CONTENT +` + + content := ` +# Level One +## Level Two +### Level Three +#### Level Four +##### Level Five +###### Level Six + ` + + want := ` + +` + + files = strings.ReplaceAll(files, "CONTENT", content) + + b := hugolib.Test(t, files) + b.AssertFileContentEquals("public/json/index.html", strings.TrimSpace(want)) + b.AssertFileContentEquals("public/toml/index.html", strings.TrimSpace(want)) + b.AssertFileContentEquals("public/yaml/index.html", strings.TrimSpace(want)) + + files = strings.ReplaceAll(files, `2`, `"x"`) + + b, _ = hugolib.TestE(t, files) + b.AssertLogMatches(`error calling ToHTML: startLevel: unable to cast "x" of type string`) +} diff --git a/markup/tableofcontents/tableofcontents_test.go b/markup/tableofcontents/tableofcontents_test.go index 8e5a47c52..b07d9e3ad 100644 --- a/markup/tableofcontents/tableofcontents_test.go +++ b/markup/tableofcontents/tableofcontents_test.go @@ -17,23 +17,39 @@ import ( "testing" qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/common/collections" ) +var newTestTocBuilder = func() Builder { + var b Builder + b.AddAt(&Heading{Title: "Heading 1", ID: "h1-1"}, 0, 0) + b.AddAt(&Heading{Title: "1-H2-1", ID: "1-h2-1"}, 0, 1) + b.AddAt(&Heading{Title: "1-H2-2", ID: "1-h2-2"}, 0, 1) + b.AddAt(&Heading{Title: "1-H3-1", ID: "1-h2-2"}, 0, 2) + b.AddAt(&Heading{Title: "Heading 2", ID: "h1-2"}, 1, 0) + return b +} + +var newTestToc = func() *Fragments { + return newTestTocBuilder().Build() +} + func TestToc(t *testing.T) { c := qt.New(t) - toc := &Root{} + toc := &Fragments{} - toc.AddAt(Header{Text: "Header 1", ID: "h1-1"}, 0, 0) - toc.AddAt(Header{Text: "1-H2-1", ID: "1-h2-1"}, 0, 1) - toc.AddAt(Header{Text: "1-H2-2", ID: "1-h2-2"}, 0, 1) - toc.AddAt(Header{Text: "1-H3-1", ID: "1-h2-2"}, 0, 2) - toc.AddAt(Header{Text: "Header 2", ID: "h1-2"}, 1, 0) + toc.addAt(&Heading{Title: "Heading 1", ID: "h1-1"}, 0, 0) + toc.addAt(&Heading{Title: "1-H2-1", ID: "1-h2-1"}, 0, 1) + toc.addAt(&Heading{Title: "1-H2-2", ID: "1-h2-2"}, 0, 1) + toc.addAt(&Heading{Title: "1-H3-1", ID: "1-h2-2"}, 0, 2) + toc.addAt(&Heading{Title: "Heading 2", ID: "h1-2"}, 1, 0) - got := toc.ToHTML(1, -1, false) + tocHTML, _ := toc.ToHTML(1, -1, false) + got := string(tocHTML) c.Assert(got, qt.Equals, ``, qt.Commentf(got)) - got = toc.ToHTML(1, 1, false) + tocHTML, _ = toc.ToHTML(1, 1, false) + got = string(tocHTML) c.Assert(got, qt.Equals, ``, qt.Commentf(got)) - got = toc.ToHTML(1, 2, false) + tocHTML, _ = toc.ToHTML(1, 2, false) + got = string(tocHTML) c.Assert(got, qt.Equals, ``, qt.Commentf(got)) - got = toc.ToHTML(2, 2, false) + tocHTML, _ = toc.ToHTML(2, 2, false) + got = string(tocHTML) c.Assert(got, qt.Equals, ``, qt.Commentf(got)) - got = toc.ToHTML(1, -1, true) + tocHTML, _ = toc.ToHTML(1, -1, true) + got = string(tocHTML) c.Assert(got, qt.Equals, ``, qt.Commentf(got)) } @@ -97,13 +117,14 @@ func TestToc(t *testing.T) { func TestTocMissingParent(t *testing.T) { c := qt.New(t) - toc := &Root{} + toc := &Fragments{} - toc.AddAt(Header{Text: "H2", ID: "h2"}, 0, 1) - toc.AddAt(Header{Text: "H3", ID: "h3"}, 1, 2) - toc.AddAt(Header{Text: "H3", ID: "h3"}, 1, 2) + toc.addAt(&Heading{Title: "H2", ID: "h2"}, 0, 1) + toc.addAt(&Heading{Title: "H3", ID: "h3"}, 1, 2) + toc.addAt(&Heading{Title: "H3", ID: "h3"}, 1, 2) - got := toc.ToHTML(1, -1, false) + tocHTML, _ := toc.ToHTML(1, -1, false) + got := string(tocHTML) c.Assert(got, qt.Equals, ``, qt.Commentf(got)) - got = toc.ToHTML(3, 3, false) + tocHTML, _ = toc.ToHTML(3, 3, false) + got = string(tocHTML) c.Assert(got, qt.Equals, ``, qt.Commentf(got)) - got = toc.ToHTML(1, -1, true) + tocHTML, _ = toc.ToHTML(1, -1, true) + got = string(tocHTML) c.Assert(got, qt.Equals, ``, qt.Commentf(got)) - +} + +func TestTocMisc(t *testing.T) { + c := qt.New(t) + + c.Run("Identifiers", func(c *qt.C) { + toc := newTestToc() + c.Assert(toc.Identifiers, qt.DeepEquals, collections.SortedStringSlice{"1-h2-1", "1-h2-2", "1-h2-2", "h1-1", "h1-2"}) + }) + + c.Run("HeadingsMap", func(c *qt.C) { + toc := newTestToc() + m := toc.HeadingsMap + c.Assert(m["h1-1"].Title, qt.Equals, "Heading 1") + c.Assert(m["doesnot exist"], qt.IsNil) + }) +} + +func BenchmarkToc(b *testing.B) { + newTocs := func(n int) []*Fragments { + var tocs []*Fragments + for range n { + tocs = append(tocs, newTestToc()) + } + return tocs + } + + b.Run("Build", func(b *testing.B) { + var builders []Builder + for i := 0; i < b.N; i++ { + builders = append(builders, newTestTocBuilder()) + } + b.ResetTimer() + + for i := 0; i < b.N; i++ { + b := builders[i] + b.Build() + } + }) + + b.Run("ToHTML", func(b *testing.B) { + tocs := newTocs(b.N) + b.ResetTimer() + for i := 0; i < b.N; i++ { + toc := tocs[i] + toc.ToHTML(1, -1, false) + } + }) } diff --git a/media/builtin.go b/media/builtin.go new file mode 100644 index 000000000..41d1ed655 --- /dev/null +++ b/media/builtin.go @@ -0,0 +1,169 @@ +package media + +type BuiltinTypes struct { + CalendarType Type + CSSType Type + SCSSType Type + SASSType Type + GotmplType Type + CSVType Type + HTMLType Type + JavascriptType Type + TypeScriptType Type + TSXType Type + JSXType Type + + JSONType Type + WebAppManifestType Type + RSSType Type + XMLType Type + SVGType Type + TextType Type + TOMLType Type + YAMLType Type + + // Common image types + PNGType Type + JPEGType Type + GIFType Type + TIFFType Type + BMPType Type + WEBPType Type + + // Common font types + TrueTypeFontType Type + OpenTypeFontType Type + + // Common document types + PDFType Type + MarkdownType Type + EmacsOrgModeType Type + AsciiDocType Type + PandocType Type + ReStructuredTextType Type + + // Common video types + AVIType Type + MPEGType Type + MP4Type Type + OGGType Type + WEBMType Type + GPPType Type + + // wasm + WasmType Type + + OctetType Type +} + +var Builtin = BuiltinTypes{ + CalendarType: Type{Type: "text/calendar"}, + CSSType: Type{Type: "text/css"}, + SCSSType: Type{Type: "text/x-scss"}, + SASSType: Type{Type: "text/x-sass"}, + GotmplType: Type{Type: "text/x-gotmpl"}, + CSVType: Type{Type: "text/csv"}, + HTMLType: Type{Type: "text/html"}, + JavascriptType: Type{Type: "text/javascript"}, + TypeScriptType: Type{Type: "text/typescript"}, + TSXType: Type{Type: "text/tsx"}, + JSXType: Type{Type: "text/jsx"}, + + JSONType: Type{Type: "application/json"}, + WebAppManifestType: Type{Type: "application/manifest+json"}, + RSSType: Type{Type: "application/rss+xml"}, + XMLType: Type{Type: "application/xml"}, + SVGType: Type{Type: "image/svg+xml"}, + TextType: Type{Type: "text/plain"}, + TOMLType: Type{Type: "application/toml"}, + YAMLType: Type{Type: "application/yaml"}, + + // Common image types + PNGType: Type{Type: "image/png"}, + JPEGType: Type{Type: "image/jpeg"}, + GIFType: Type{Type: "image/gif"}, + TIFFType: Type{Type: "image/tiff"}, + BMPType: Type{Type: "image/bmp"}, + WEBPType: Type{Type: "image/webp"}, + + // Common font types + TrueTypeFontType: Type{Type: "font/ttf"}, + OpenTypeFontType: Type{Type: "font/otf"}, + + // Common document types + PDFType: Type{Type: "application/pdf"}, + MarkdownType: Type{Type: "text/markdown"}, + AsciiDocType: Type{Type: "text/asciidoc"}, // https://github.com/asciidoctor/asciidoctor/issues/2502 + PandocType: Type{Type: "text/pandoc"}, + ReStructuredTextType: Type{Type: "text/rst"}, // https://docutils.sourceforge.io/FAQ.html#what-s-the-official-mime-type-for-restructuredtext-data + EmacsOrgModeType: Type{Type: "text/org"}, + + // Common video types + AVIType: Type{Type: "video/x-msvideo"}, + MPEGType: Type{Type: "video/mpeg"}, + MP4Type: Type{Type: "video/mp4"}, + OGGType: Type{Type: "video/ogg"}, + WEBMType: Type{Type: "video/webm"}, + GPPType: Type{Type: "video/3gpp"}, + + // Web assembly. + WasmType: Type{Type: "application/wasm"}, + + OctetType: Type{Type: "application/octet-stream"}, +} + +var defaultMediaTypesConfig = map[string]any{ + "text/calendar": map[string]any{"suffixes": []string{"ics"}}, + "text/css": map[string]any{"suffixes": []string{"css"}}, + "text/x-scss": map[string]any{"suffixes": []string{"scss"}}, + "text/x-sass": map[string]any{"suffixes": []string{"sass"}}, + "text/csv": map[string]any{"suffixes": []string{"csv"}}, + "text/html": map[string]any{"suffixes": []string{"html", "htm"}}, + "text/javascript": map[string]any{"suffixes": []string{"js", "jsm", "mjs"}}, + "text/typescript": map[string]any{"suffixes": []string{"ts"}}, + "text/tsx": map[string]any{"suffixes": []string{"tsx"}}, + "text/jsx": map[string]any{"suffixes": []string{"jsx"}}, + "text/x-gotmpl": map[string]any{"suffixes": []string{"gotmpl"}}, + + "application/json": map[string]any{"suffixes": []string{"json"}}, + "application/manifest+json": map[string]any{"suffixes": []string{"webmanifest"}}, + "application/rss+xml": map[string]any{"suffixes": []string{"xml", "rss"}}, + "application/xml": map[string]any{"suffixes": []string{"xml"}}, + "image/svg+xml": map[string]any{"suffixes": []string{"svg"}}, + "text/plain": map[string]any{"suffixes": []string{"txt"}}, + "application/toml": map[string]any{"suffixes": []string{"toml"}}, + "application/yaml": map[string]any{"suffixes": []string{"yaml", "yml"}}, + + // Common image types + "image/png": map[string]any{"suffixes": []string{"png"}}, + "image/jpeg": map[string]any{"suffixes": []string{"jpg", "jpeg", "jpe", "jif", "jfif"}}, + "image/gif": map[string]any{"suffixes": []string{"gif"}}, + "image/tiff": map[string]any{"suffixes": []string{"tif", "tiff"}}, + "image/bmp": map[string]any{"suffixes": []string{"bmp"}}, + "image/webp": map[string]any{"suffixes": []string{"webp"}}, + + // Common font types + "font/ttf": map[string]any{"suffixes": []string{"ttf"}}, + "font/otf": map[string]any{"suffixes": []string{"otf"}}, + + // Common document types + "application/pdf": map[string]any{"suffixes": []string{"pdf"}}, + "text/markdown": map[string]any{"suffixes": []string{"md", "mdown", "markdown"}}, + "text/asciidoc": map[string]any{"suffixes": []string{"adoc", "asciidoc", "ad"}}, + "text/pandoc": map[string]any{"suffixes": []string{"pandoc", "pdc"}}, + "text/rst": map[string]any{"suffixes": []string{"rst"}}, + "text/org": map[string]any{"suffixes": []string{"org"}}, + + // Common video types + "video/x-msvideo": map[string]any{"suffixes": []string{"avi"}}, + "video/mpeg": map[string]any{"suffixes": []string{"mpg", "mpeg"}}, + "video/mp4": map[string]any{"suffixes": []string{"mp4"}}, + "video/ogg": map[string]any{"suffixes": []string{"ogv"}}, + "video/webm": map[string]any{"suffixes": []string{"webm"}}, + "video/3gpp": map[string]any{"suffixes": []string{"3gpp", "3gp"}}, + + // wasm + "application/wasm": map[string]any{"suffixes": []string{"wasm"}}, + + "application/octet-stream": map[string]any{}, +} diff --git a/media/config.go b/media/config.go new file mode 100644 index 000000000..6d3687a4f --- /dev/null +++ b/media/config.go @@ -0,0 +1,277 @@ +// Copyright 2024 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 media + +import ( + "fmt" + "path/filepath" + "reflect" + "slices" + "sort" + "strings" + + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/common/paths" + "github.com/gohugoio/hugo/config" + + "github.com/mitchellh/mapstructure" + "github.com/spf13/cast" +) + +// DefaultTypes is the default media types supported by Hugo. +var DefaultTypes Types + +func init() { + // Apply delimiter to all. + for _, m := range defaultMediaTypesConfig { + m.(map[string]any)["delimiter"] = "." + } + + ns, err := DecodeTypes(nil) + if err != nil { + panic(err) + } + DefaultTypes = ns.Config + + // Initialize the Builtin types with values from DefaultTypes. + v := reflect.ValueOf(&Builtin).Elem() + + for i := range v.NumField() { + f := v.Field(i) + fieldName := v.Type().Field(i).Name + builtinType := f.Interface().(Type) + if builtinType.Type == "" { + panic(fmt.Errorf("builtin type %q is empty", fieldName)) + } + defaultType, found := DefaultTypes.GetByType(builtinType.Type) + if !found { + panic(fmt.Errorf("missing default type for field builtin type: %q", fieldName)) + } + f.Set(reflect.ValueOf(defaultType)) + } +} + +func init() { + DefaultContentTypes = ContentTypes{ + HTML: Builtin.HTMLType, + Markdown: Builtin.MarkdownType, + AsciiDoc: Builtin.AsciiDocType, + Pandoc: Builtin.PandocType, + ReStructuredText: Builtin.ReStructuredTextType, + EmacsOrgMode: Builtin.EmacsOrgModeType, + } + + DefaultContentTypes.init(nil) +} + +var DefaultContentTypes ContentTypes + +type ContentTypeConfig struct { + // Empty for now. +} + +// ContentTypes holds the media types that are considered content in Hugo. +type ContentTypes struct { + HTML Type + Markdown Type + AsciiDoc Type + Pandoc Type + ReStructuredText Type + EmacsOrgMode Type + + types Types + + // Created in init(). + extensionSet map[string]bool +} + +func (t *ContentTypes) init(types Types) { + sort.Slice(t.types, func(i, j int) bool { + return t.types[i].Type < t.types[j].Type + }) + + if tt, ok := types.GetByType(t.HTML.Type); ok { + t.HTML = tt + } + if tt, ok := types.GetByType(t.Markdown.Type); ok { + t.Markdown = tt + } + if tt, ok := types.GetByType(t.AsciiDoc.Type); ok { + t.AsciiDoc = tt + } + if tt, ok := types.GetByType(t.Pandoc.Type); ok { + t.Pandoc = tt + } + if tt, ok := types.GetByType(t.ReStructuredText.Type); ok { + t.ReStructuredText = tt + } + if tt, ok := types.GetByType(t.EmacsOrgMode.Type); ok { + t.EmacsOrgMode = tt + } + + t.extensionSet = make(map[string]bool) + for _, mt := range t.types { + for _, suffix := range mt.Suffixes() { + t.extensionSet[suffix] = true + } + } +} + +func (t ContentTypes) IsContentSuffix(suffix string) bool { + return t.extensionSet[suffix] +} + +// IsContentFile returns whether the given filename is a content file. +func (t ContentTypes) IsContentFile(filename string) bool { + return t.IsContentSuffix(strings.TrimPrefix(filepath.Ext(filename), ".")) +} + +// IsIndexContentFile returns whether the given filename is an index content file. +func (t ContentTypes) IsIndexContentFile(filename string) bool { + if !t.IsContentFile(filename) { + return false + } + + base := filepath.Base(filename) + + return strings.HasPrefix(base, "index.") || strings.HasPrefix(base, "_index.") +} + +// IsHTMLSuffix returns whether the given suffix is a HTML media type. +func (t ContentTypes) IsHTMLSuffix(suffix string) bool { + return slices.Contains(t.HTML.Suffixes(), suffix) +} + +// Types is a slice of media types. +func (t ContentTypes) Types() Types { + return t.types +} + +// Hold the configuration for a given media type. +type MediaTypeConfig struct { + // The file suffixes used for this media type. + Suffixes []string + // Delimiter used before suffix. + Delimiter string +} + +var defaultContentTypesConfig = map[string]ContentTypeConfig{ + Builtin.HTMLType.Type: {}, + Builtin.MarkdownType.Type: {}, + Builtin.AsciiDocType.Type: {}, + Builtin.PandocType.Type: {}, + Builtin.ReStructuredTextType.Type: {}, + Builtin.EmacsOrgModeType.Type: {}, +} + +// DecodeContentTypes decodes the given map of content types. +func DecodeContentTypes(in map[string]any, types Types) (*config.ConfigNamespace[map[string]ContentTypeConfig, ContentTypes], error) { + buildConfig := func(v any) (ContentTypes, any, error) { + var s map[string]ContentTypeConfig + c := DefaultContentTypes + m, err := maps.ToStringMapE(v) + if err != nil { + return c, nil, err + } + if len(m) == 0 { + s = defaultContentTypesConfig + } else { + s = make(map[string]ContentTypeConfig) + m = maps.CleanConfigStringMap(m) + for k, v := range m { + var ctc ContentTypeConfig + if err := mapstructure.WeakDecode(v, &ctc); err != nil { + return c, nil, err + } + s[k] = ctc + } + } + + for k := range s { + mediaType, found := types.GetByType(k) + if !found { + return c, nil, fmt.Errorf("unknown media type %q", k) + } + c.types = append(c.types, mediaType) + } + + c.init(types) + + return c, s, nil + } + + ns, err := config.DecodeNamespace[map[string]ContentTypeConfig](in, buildConfig) + if err != nil { + return nil, fmt.Errorf("failed to decode media types: %w", err) + } + return ns, nil +} + +// DecodeTypes decodes the given map of media types. +func DecodeTypes(in map[string]any) (*config.ConfigNamespace[map[string]MediaTypeConfig, Types], error) { + buildConfig := func(v any) (Types, any, error) { + m, err := maps.ToStringMapE(v) + if err != nil { + return nil, nil, err + } + if m == nil { + m = map[string]any{} + } + m = maps.CleanConfigStringMap(m) + // Merge with defaults. + maps.MergeShallow(m, defaultMediaTypesConfig) + + var types Types + + for k, v := range m { + mediaType, err := FromString(k) + if err != nil { + return nil, nil, err + } + if err := mapstructure.WeakDecode(v, &mediaType); err != nil { + return nil, nil, err + } + mm := maps.ToStringMap(v) + suffixes, _, found := maps.LookupEqualFold(mm, "suffixes") + if found { + mediaType.SuffixesCSV = strings.TrimSpace(strings.ToLower(strings.Join(cast.ToStringSlice(suffixes), ","))) + } + if mediaType.SuffixesCSV != "" && mediaType.Delimiter == "" { + mediaType.Delimiter = DefaultDelimiter + } + InitMediaType(&mediaType) + types = append(types, mediaType) + } + + sort.Sort(types) + + return types, m, nil + } + + ns, err := config.DecodeNamespace[map[string]MediaTypeConfig](in, buildConfig) + if err != nil { + return nil, fmt.Errorf("failed to decode media types: %w", err) + } + return ns, nil +} + +// TODO(bep) get rid of this. +var DefaultPathParser = &paths.PathParser{ + IsContentExt: func(ext string) bool { + panic("not supported") + }, + IsOutputFormat: func(name, ext string) bool { + panic("DefaultPathParser: not supported") + }, +} diff --git a/media/config_test.go b/media/config_test.go new file mode 100644 index 000000000..5abbcac2f --- /dev/null +++ b/media/config_test.go @@ -0,0 +1,155 @@ +// Copyright 2024 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 media + +import ( + "fmt" + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestDecodeTypes(t *testing.T) { + c := qt.New(t) + + tests := []struct { + name string + m map[string]any + shouldError bool + assert func(t *testing.T, name string, tt Types) + }{ + { + "Redefine JSON", + map[string]any{ + "application/json": map[string]any{ + "suffixes": []string{"jasn"}, + }, + }, + + false, + func(t *testing.T, name string, tt Types) { + for _, ttt := range tt { + if _, ok := DefaultTypes.GetByType(ttt.Type); !ok { + fmt.Println(ttt.Type, "not found in default types") + } + } + + c.Assert(len(tt), qt.Equals, len(DefaultTypes)) + json, si, found := tt.GetBySuffix("jasn") + c.Assert(found, qt.Equals, true) + c.Assert(json.String(), qt.Equals, "application/json") + c.Assert(si.FullSuffix, qt.Equals, ".jasn") + }, + }, + { + "MIME suffix in key, multiple file suffixes, custom delimiter", + map[string]any{ + "application/hugo+hg": map[string]any{ + "suffixes": []string{"hg1", "hG2"}, + "Delimiter": "_", + }, + }, + false, + func(t *testing.T, name string, tt Types) { + c.Assert(len(tt), qt.Equals, len(DefaultTypes)+1) + hg, si, found := tt.GetBySuffix("hg2") + c.Assert(found, qt.Equals, true) + c.Assert(hg.FirstSuffix.Suffix, qt.Equals, "hg1") + c.Assert(hg.FirstSuffix.FullSuffix, qt.Equals, "_hg1") + c.Assert(si.Suffix, qt.Equals, "hg2") + c.Assert(si.FullSuffix, qt.Equals, "_hg2") + c.Assert(hg.String(), qt.Equals, "application/hugo+hg") + + _, found = tt.GetByType("application/hugo+hg") + c.Assert(found, qt.Equals, true) + }, + }, + { + "Add custom media type", + map[string]any{ + "text/hugo+hgo": map[string]any{ + "Suffixes": []string{"hgo2"}, + }, + }, + false, + func(t *testing.T, name string, tp Types) { + c.Assert(len(tp), qt.Equals, len(DefaultTypes)+1) + // Make sure we have not broken the default config. + + _, _, found := tp.GetBySuffix("json") + c.Assert(found, qt.Equals, true) + + hugo, _, found := tp.GetBySuffix("hgo2") + c.Assert(found, qt.Equals, true) + c.Assert(hugo.String(), qt.Equals, "text/hugo+hgo") + }, + }, + } + + for _, test := range tests { + result, err := DecodeTypes(test.m) + if test.shouldError { + c.Assert(err, qt.Not(qt.IsNil)) + } else { + c.Assert(err, qt.IsNil) + test.assert(t, test.name, result.Config) + } + } +} + +func TestDefaultTypes(t *testing.T) { + c := qt.New(t) + for _, test := range []struct { + tp Type + expectedMainType string + expectedSubType string + expectedSuffixes string + expectedType string + expectedString string + }{ + {Builtin.CalendarType, "text", "calendar", "ics", "text/calendar", "text/calendar"}, + {Builtin.CSSType, "text", "css", "css", "text/css", "text/css"}, + {Builtin.SCSSType, "text", "x-scss", "scss", "text/x-scss", "text/x-scss"}, + {Builtin.CSVType, "text", "csv", "csv", "text/csv", "text/csv"}, + {Builtin.HTMLType, "text", "html", "html,htm", "text/html", "text/html"}, + {Builtin.MarkdownType, "text", "markdown", "md,mdown,markdown", "text/markdown", "text/markdown"}, + {Builtin.EmacsOrgModeType, "text", "org", "org", "text/org", "text/org"}, + {Builtin.PandocType, "text", "pandoc", "pandoc,pdc", "text/pandoc", "text/pandoc"}, + {Builtin.ReStructuredTextType, "text", "rst", "rst", "text/rst", "text/rst"}, + {Builtin.AsciiDocType, "text", "asciidoc", "adoc,asciidoc,ad", "text/asciidoc", "text/asciidoc"}, + {Builtin.JavascriptType, "text", "javascript", "js,jsm,mjs", "text/javascript", "text/javascript"}, + {Builtin.TypeScriptType, "text", "typescript", "ts", "text/typescript", "text/typescript"}, + {Builtin.TSXType, "text", "tsx", "tsx", "text/tsx", "text/tsx"}, + {Builtin.JSXType, "text", "jsx", "jsx", "text/jsx", "text/jsx"}, + {Builtin.JSONType, "application", "json", "json", "application/json", "application/json"}, + {Builtin.RSSType, "application", "rss", "xml,rss", "application/rss+xml", "application/rss+xml"}, + {Builtin.SVGType, "image", "svg", "svg", "image/svg+xml", "image/svg+xml"}, + {Builtin.TextType, "text", "plain", "txt", "text/plain", "text/plain"}, + {Builtin.XMLType, "application", "xml", "xml", "application/xml", "application/xml"}, + {Builtin.TOMLType, "application", "toml", "toml", "application/toml", "application/toml"}, + {Builtin.YAMLType, "application", "yaml", "yaml,yml", "application/yaml", "application/yaml"}, + {Builtin.PDFType, "application", "pdf", "pdf", "application/pdf", "application/pdf"}, + {Builtin.TrueTypeFontType, "font", "ttf", "ttf", "font/ttf", "font/ttf"}, + {Builtin.OpenTypeFontType, "font", "otf", "otf", "font/otf", "font/otf"}, + } { + c.Assert(test.tp.MainType, qt.Equals, test.expectedMainType) + c.Assert(test.tp.SubType, qt.Equals, test.expectedSubType) + c.Assert(test.tp.SuffixesCSV, qt.Equals, test.expectedSuffixes) + c.Assert(test.tp.Type, qt.Equals, test.expectedType) + c.Assert(test.tp.String(), qt.Equals, test.expectedString) + + } + + c.Assert(len(DefaultTypes), qt.Equals, 41) +} diff --git a/media/docshelper.go b/media/docshelper.go deleted file mode 100644 index f5afb52f0..000000000 --- a/media/docshelper.go +++ /dev/null @@ -1,17 +0,0 @@ -package media - -import ( - "github.com/gohugoio/hugo/docshelper" -) - -// This is is just some helpers used to create some JSON used in the Hugo docs. -func init() { - docsProvider := func() map[string]interface{} { - docs := make(map[string]interface{}) - - docs["types"] = DefaultTypes - return docs - } - - docshelper.AddDocProvider("media", docsProvider) -} diff --git a/media/mediaType.go b/media/mediaType.go index e33583a0e..b3b615444 100644 --- a/media/mediaType.go +++ b/media/mediaType.go @@ -11,60 +11,130 @@ // See the License for the specific language governing permissions and // limitations under the License. +// Package media contains Media Type (MIME type) related types and functions. package media import ( "encoding/json" - "errors" "fmt" - "sort" + "net/http" "strings" - - "github.com/gohugoio/hugo/common/maps" - - "github.com/mitchellh/mapstructure" ) +var zero Type + const ( - defaultDelimiter = "." + DefaultDelimiter = "." ) -// Type (also known as MIME type and content type) is a two-part identifier for +// MediaType (also known as MIME type and content type) is a two-part identifier for // file formats and format contents transmitted on the Internet. // For Hugo's use case, we use the top-level type name / subtype name + suffix. // One example would be application/svg+xml // If suffix is not provided, the sub type will be used. -// See // https://en.wikipedia.org/wiki/Media_type +// { "name": "MediaType" } type Type struct { - MainType string `json:"mainType"` // i.e. text - SubType string `json:"subType"` // i.e. html + // The full MIME type string, e.g. "application/rss+xml". + Type string `json:"-"` + + // The top-level type name, e.g. "application". + MainType string `json:"mainType"` + // The subtype name, e.g. "rss". + SubType string `json:"subType"` + // The delimiter before the suffix, e.g. ".". + Delimiter string `json:"delimiter"` + + // FirstSuffix holds the first suffix defined for this MediaType. + FirstSuffix SuffixInfo `json:"-"` // This is the optional suffix after the "+" in the MIME type, - // e.g. "xml" in "applicatiion/rss+xml". + // e.g. "xml" in "application/rss+xml". mimeSuffix string - Delimiter string `json:"delimiter"` // e.g. "." - - // TODO(bep) make this a string to make it hashable + method - Suffixes []string `json:"suffixes"` - - // Set when doing lookup by suffix. - fileSuffix string + // E.g. "jpg,jpeg" + // Stored as a string to make Type comparable. + // For internal use only. + SuffixesCSV string `json:"-"` } -// FromStringAndExt is same as FromString, but adds the file extension to the type. -func FromStringAndExt(t, ext string) (Type, error) { - tp, err := fromString(t) +// SuffixInfo holds information about a Media Type's suffix. +type SuffixInfo struct { + // Suffix is the suffix without the delimiter, e.g. "xml". + Suffix string `json:"suffix"` + + // FullSuffix is the suffix with the delimiter, e.g. ".xml". + FullSuffix string `json:"fullSuffix"` +} + +// FromContent resolve the Type primarily using http.DetectContentType. +// If http.DetectContentType resolves to application/octet-stream, a zero Type is returned. +// If http.DetectContentType resolves to text/plain or application/xml, we try to get more specific using types and ext. +func FromContent(types Types, extensionHints []string, content []byte) Type { + t := strings.Split(http.DetectContentType(content), ";")[0] + if t == "application/octet-stream" { + return zero + } + + var found bool + m, found := types.GetByType(t) + if !found { + if t == "text/xml" { + // This is how it's configured in Hugo by default. + m, found = types.GetByType("application/xml") + } + } + + if !found { + return zero + } + + var mm Type + + for _, extension := range extensionHints { + extension = strings.TrimPrefix(extension, ".") + mm, _, found = types.GetFirstBySuffix(extension) + if found { + break + } + } + + if found { + if m == mm { + return m + } + + if m.IsText() && mm.IsText() { + // http.DetectContentType isn't brilliant when it comes to common text formats, so we need to do better. + // For now we say that if it's detected to be a text format and the extension/content type in header reports + // it to be a text format, then we use that. + return mm + } + + // E.g. an image with a *.js extension. + return zero + } + + return m +} + +// FromStringAndExt creates a Type from a MIME string and a given extensions +func FromStringAndExt(t string, ext ...string) (Type, error) { + tp, err := FromString(t) if err != nil { return tp, err } - tp.Suffixes = []string{strings.TrimPrefix(ext, ".")} + for i, e := range ext { + ext[i] = strings.TrimPrefix(e, ".") + } + tp.SuffixesCSV = strings.Join(ext, ",") + tp.Delimiter = DefaultDelimiter + tp.init() return tp, nil } // FromString creates a new Type given a type string on the form MainType/SubType and // an optional suffix, e.g. "text/html" or "text/html+html". -func fromString(t string) (Type, error) { +func FromString(t string) (Type, error) { t = strings.ToLower(t) parts := strings.Split(t, "/") if len(parts) != 2 { @@ -81,123 +151,113 @@ func fromString(t string) (Type, error) { suffix = subParts[1] } - return Type{MainType: mainType, SubType: subType, mimeSuffix: suffix}, nil -} - -// Type returns a string representing the main- and sub-type of a media type, e.g. "text/css". -// A suffix identifier will be appended after a "+" if set, e.g. "image/svg+xml". -// Hugo will register a set of default media types. -// These can be overridden by the user in the configuration, -// by defining a media type with the same Type. -func (m Type) Type() string { - // Examples are - // image/svg+xml - // text/css - if m.mimeSuffix != "" { - return m.MainType + "/" + m.SubType + "+" + m.mimeSuffix + var typ string + if suffix != "" { + typ = mainType + "/" + subType + "+" + suffix + } else { + typ = mainType + "/" + subType } - return m.MainType + "/" + m.SubType + + return Type{Type: typ, MainType: mainType, SubType: subType, mimeSuffix: suffix}, nil } +// For internal use. func (m Type) String() string { - return m.Type() + return m.Type } -// FullSuffix returns the file suffix with any delimiter prepended. -func (m Type) FullSuffix() string { - return m.Delimiter + m.Suffix() -} - -// Suffix returns the file suffix without any delmiter prepended. -func (m Type) Suffix() string { - if m.fileSuffix != "" { - return m.fileSuffix +// Suffixes returns all valid file suffixes for this type. +func (m Type) Suffixes() []string { + if m.SuffixesCSV == "" { + return nil } - if len(m.Suffixes) > 0 { - return m.Suffixes[0] + + return strings.Split(m.SuffixesCSV, ",") +} + +// IsText returns whether this Type is a text format. +// Note that this may currently return false negatives. +// TODO(bep) improve +// For internal use. +func (m Type) IsText() bool { + if m.MainType == "text" { + return true } - // There are MIME types without file suffixes. - return "" + switch m.SubType { + case "javascript", "json", "rss", "xml", "svg", "toml", "yml", "yaml": + return true + } + return false } -// Definitions from https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types etc. -// Note that from Hugo 0.44 we only set Suffix if it is part of the MIME type. -var ( - CalendarType = Type{MainType: "text", SubType: "calendar", Suffixes: []string{"ics"}, Delimiter: defaultDelimiter} - CSSType = Type{MainType: "text", SubType: "css", Suffixes: []string{"css"}, Delimiter: defaultDelimiter} - SCSSType = Type{MainType: "text", SubType: "x-scss", Suffixes: []string{"scss"}, Delimiter: defaultDelimiter} - SASSType = Type{MainType: "text", SubType: "x-sass", Suffixes: []string{"sass"}, Delimiter: defaultDelimiter} - CSVType = Type{MainType: "text", SubType: "csv", Suffixes: []string{"csv"}, Delimiter: defaultDelimiter} - HTMLType = Type{MainType: "text", SubType: "html", Suffixes: []string{"html"}, Delimiter: defaultDelimiter} - JavascriptType = Type{MainType: "application", SubType: "javascript", Suffixes: []string{"js"}, Delimiter: defaultDelimiter} - JSONType = Type{MainType: "application", SubType: "json", Suffixes: []string{"json"}, Delimiter: defaultDelimiter} - RSSType = Type{MainType: "application", SubType: "rss", mimeSuffix: "xml", Suffixes: []string{"xml"}, Delimiter: defaultDelimiter} - XMLType = Type{MainType: "application", SubType: "xml", Suffixes: []string{"xml"}, Delimiter: defaultDelimiter} - SVGType = Type{MainType: "image", SubType: "svg", mimeSuffix: "xml", Suffixes: []string{"svg"}, Delimiter: defaultDelimiter} - TextType = Type{MainType: "text", SubType: "plain", Suffixes: []string{"txt"}, Delimiter: defaultDelimiter} - TOMLType = Type{MainType: "application", SubType: "toml", Suffixes: []string{"toml"}, Delimiter: defaultDelimiter} - YAMLType = Type{MainType: "application", SubType: "yaml", Suffixes: []string{"yaml", "yml"}, Delimiter: defaultDelimiter} - - // Common image types - PNGType = Type{MainType: "image", SubType: "png", Suffixes: []string{"png"}, Delimiter: defaultDelimiter} - JPEGType = Type{MainType: "image", SubType: "jpeg", Suffixes: []string{"jpg", "jpeg"}, Delimiter: defaultDelimiter} - GIFType = Type{MainType: "image", SubType: "gif", Suffixes: []string{"gif"}, Delimiter: defaultDelimiter} - TIFFType = Type{MainType: "image", SubType: "tiff", Suffixes: []string{"tif", "tiff"}, Delimiter: defaultDelimiter} - BMPType = Type{MainType: "image", SubType: "bmp", Suffixes: []string{"bmp"}, Delimiter: defaultDelimiter} - - // Common video types - AVIType = Type{MainType: "video", SubType: "x-msvideo", Suffixes: []string{"avi"}, Delimiter: defaultDelimiter} - MPEGType = Type{MainType: "video", SubType: "mpeg", Suffixes: []string{"mpg", "mpeg"}, Delimiter: defaultDelimiter} - MP4Type = Type{MainType: "video", SubType: "mp4", Suffixes: []string{"mp4"}, Delimiter: defaultDelimiter} - OGGType = Type{MainType: "video", SubType: "ogg", Suffixes: []string{"ogv"}, Delimiter: defaultDelimiter} - WEBMType = Type{MainType: "video", SubType: "webm", Suffixes: []string{"webm"}, Delimiter: defaultDelimiter} - GPPType = Type{MainType: "video", SubType: "3gpp", Suffixes: []string{"3gpp", "3gp"}, Delimiter: defaultDelimiter} - - OctetType = Type{MainType: "application", SubType: "octet-stream"} -) - -// DefaultTypes is the default media types supported by Hugo. -var DefaultTypes = Types{ - CalendarType, - CSSType, - CSVType, - SCSSType, - SASSType, - HTMLType, - JavascriptType, - JSONType, - RSSType, - XMLType, - SVGType, - TextType, - OctetType, - YAMLType, - TOMLType, - PNGType, - JPEGType, - AVIType, - MPEGType, - MP4Type, - OGGType, - WEBMType, - GPPType, +// For internal use. +func (m Type) IsHTML() bool { + return m.SubType == Builtin.HTMLType.SubType } -func init() { - sort.Sort(DefaultTypes) +// For internal use. +func (m Type) IsMarkdown() bool { + return m.SubType == Builtin.MarkdownType.SubType +} + +func InitMediaType(m *Type) { + m.init() +} + +func (m *Type) init() { + m.FirstSuffix.FullSuffix = "" + m.FirstSuffix.Suffix = "" + if suffixes := m.Suffixes(); suffixes != nil { + m.FirstSuffix.Suffix = suffixes[0] + m.FirstSuffix.FullSuffix = m.Delimiter + m.FirstSuffix.Suffix + } +} + +func newMediaType(main, sub string, suffixes []string) Type { + t := Type{MainType: main, SubType: sub, SuffixesCSV: strings.Join(suffixes, ","), Delimiter: DefaultDelimiter} + t.init() + return t +} + +func newMediaTypeWithMimeSuffix(main, sub, mimeSuffix string, suffixes []string) Type { + mt := newMediaType(main, sub, suffixes) + mt.mimeSuffix = mimeSuffix + mt.init() + return mt } // Types is a slice of media types. +// { "name": "MediaTypes" } type Types []Type func (t Types) Len() int { return len(t) } func (t Types) Swap(i, j int) { t[i], t[j] = t[j], t[i] } -func (t Types) Less(i, j int) bool { return t[i].Type() < t[j].Type() } +func (t Types) Less(i, j int) bool { return t[i].Type < t[j].Type } + +// GetBestMatch returns the best match for the given media type string. +func (t Types) GetBestMatch(s string) (Type, bool) { + // First try an exact match. + if mt, found := t.GetByType(s); found { + return mt, true + } + + // Try main type. + if mt, found := t.GetBySubType(s); found { + return mt, true + } + + // Try extension. + if mt, _, found := t.GetFirstBySuffix(s); found { + return mt, true + } + + return Type{}, false +} // GetByType returns a media type for tp. func (t Types) GetByType(tp string) (Type, bool) { for _, tt := range t { - if strings.EqualFold(tt.Type(), tp) { + if strings.EqualFold(tt.Type, tp) { return tt, true } } @@ -213,56 +273,72 @@ func (t Types) GetByType(tp string) (Type, bool) { return Type{}, false } +func (t Types) normalizeSuffix(s string) string { + return strings.ToLower(strings.TrimPrefix(s, ".")) +} + // BySuffix will return all media types matching a suffix. func (t Types) BySuffix(suffix string) []Type { + suffix = t.normalizeSuffix(suffix) var types []Type for _, tt := range t { - if match := tt.matchSuffix(suffix); match != "" { + if tt.HasSuffix(suffix) { types = append(types, tt) } } return types } -// GetFirstBySuffix will return the first media type matching the given suffix. -func (t Types) GetFirstBySuffix(suffix string) (Type, bool) { +// GetFirstBySuffix will return the first type matching the given suffix. +func (t Types) GetFirstBySuffix(suffix string) (Type, SuffixInfo, bool) { + suffix = t.normalizeSuffix(suffix) for _, tt := range t { - if match := tt.matchSuffix(suffix); match != "" { - tt.fileSuffix = match - return tt, true + if tt.HasSuffix(suffix) { + return tt, SuffixInfo{ + FullSuffix: tt.Delimiter + suffix, + Suffix: suffix, + }, true } } - return Type{}, false + return Type{}, SuffixInfo{}, false } // GetBySuffix gets a media type given as suffix, e.g. "html". // It will return false if no format could be found, or if the suffix given // is ambiguous. // The lookup is case insensitive. -func (t Types) GetBySuffix(suffix string) (tp Type, found bool) { +func (t Types) GetBySuffix(suffix string) (tp Type, si SuffixInfo, found bool) { + suffix = t.normalizeSuffix(suffix) for _, tt := range t { - if match := tt.matchSuffix(suffix); match != "" { + if tt.HasSuffix(suffix) { if found { // ambiguous found = false return } tp = tt - tp.fileSuffix = match + si = SuffixInfo{ + FullSuffix: tt.Delimiter + suffix, + Suffix: suffix, + } found = true } } return } -func (m Type) matchSuffix(suffix string) string { - for _, s := range m.Suffixes { - if strings.EqualFold(suffix, s) { - return s +func (t Types) IsTextSuffix(suffix string) bool { + suffix = t.normalizeSuffix(suffix) + for _, tt := range t { + if tt.HasSuffix(suffix) { + return tt.IsText() } } + return false +} - return "" +func (m Type) HasSuffix(suffix string) bool { + return strings.Contains(","+m.SuffixesCSV+",", ","+suffix+",") } // GetByMainSubType gets a media type given a main and a sub type e.g. "text" and "plain". @@ -285,103 +361,41 @@ func (t Types) GetByMainSubType(mainType, subType string) (tp Type, found bool) return } -func suffixIsRemoved() error { - return errors.New(`MediaType.Suffix is removed. Before Hugo 0.44 this was used both to set a custom file suffix and as way -to augment the mediatype definition (what you see after the "+", e.g. "image/svg+xml"). - -This had its limitations. For one, it was only possible with one file extension per MIME type. - -Now you can specify multiple file suffixes using "suffixes", but you need to specify the full MIME type -identifier: - -[mediaTypes] -[mediaTypes."image/svg+xml"] -suffixes = ["svg", "abc" ] - -In most cases, it will be enough to just change: - -[mediaTypes] -[mediaTypes."my/custom-mediatype"] -suffix = "txt" - -To: - -[mediaTypes] -[mediaTypes."my/custom-mediatype"] -suffixes = ["txt"] - -Note that you can still get the Media Type's suffix from a template: {{ $mediaType.Suffix }}. But this will now map to the MIME type filename. -`) -} - -// DecodeTypes takes a list of media type configurations and merges those, -// in the order given, with the Hugo defaults as the last resort. -func DecodeTypes(mms ...map[string]interface{}) (Types, error) { - var m Types - - // Maps type string to Type. Type string is the full application/svg+xml. - mmm := make(map[string]Type) - for _, dt := range DefaultTypes { - suffixes := make([]string, len(dt.Suffixes)) - copy(suffixes, dt.Suffixes) - dt.Suffixes = suffixes - mmm[dt.Type()] = dt - } - - for _, mm := range mms { - for k, v := range mm { - var mediaType Type - - mediaType, found := mmm[k] - if !found { - var err error - mediaType, err = fromString(k) - if err != nil { - return m, err - } +// GetBySubType gets a media type given a sub type e.g. "plain". +func (t Types) GetBySubType(subType string) (tp Type, found bool) { + for _, tt := range t { + if strings.EqualFold(subType, tt.SubType) { + if found { + // ambiguous + found = false + return } - - if err := mapstructure.WeakDecode(v, &mediaType); err != nil { - return m, err - } - - vm := v.(map[string]interface{}) - maps.ToLower(vm) - _, delimiterSet := vm["delimiter"] - _, suffixSet := vm["suffix"] - - if suffixSet { - return Types{}, suffixIsRemoved() - } - - // The user may set the delimiter as an empty string. - if !delimiterSet && len(mediaType.Suffixes) != 0 { - mediaType.Delimiter = defaultDelimiter - } - - mmm[k] = mediaType - + tp = tt + found = true } } + return +} - for _, v := range mmm { - m = append(m, v) - } - sort.Sort(m) - - return m, nil +// IsZero reports whether this Type represents a zero value. +// For internal use. +func (m Type) IsZero() bool { + return m.SubType == "" } // MarshalJSON returns the JSON encoding of m. +// For internal use. func (m Type) MarshalJSON() ([]byte, error) { type Alias Type return json.Marshal(&struct { - Type string `json:"type"` - String string `json:"string"` Alias + Type string `json:"type"` + String string `json:"string"` + Suffixes []string `json:"suffixes"` }{ - Type: m.Type(), - String: m.String(), - Alias: (Alias)(m), + Alias: (Alias)(m), + Type: m.Type, + String: m.String(), + Suffixes: strings.Split(m.SuffixesCSV, ","), }) } diff --git a/media/mediaType_test.go b/media/mediaType_test.go index f18fd90bb..3b8e099b8 100644 --- a/media/mediaType_test.go +++ b/media/mediaType_test.go @@ -14,80 +14,43 @@ package media import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" "testing" qt "github.com/frankban/quicktest" - "github.com/google/go-cmp/cmp" + "github.com/gohugoio/hugo/common/paths" ) -var eq = qt.CmpEquals(cmp.Comparer(func(m1, m2 Type) bool { - return m1.Type() == m2.Type() -})) - -func TestDefaultTypes(t *testing.T) { - c := qt.New(t) - for _, test := range []struct { - tp Type - expectedMainType string - expectedSubType string - expectedSuffix string - expectedType string - expectedString string - }{ - {CalendarType, "text", "calendar", "ics", "text/calendar", "text/calendar"}, - {CSSType, "text", "css", "css", "text/css", "text/css"}, - {SCSSType, "text", "x-scss", "scss", "text/x-scss", "text/x-scss"}, - {CSVType, "text", "csv", "csv", "text/csv", "text/csv"}, - {HTMLType, "text", "html", "html", "text/html", "text/html"}, - {JavascriptType, "application", "javascript", "js", "application/javascript", "application/javascript"}, - {JSONType, "application", "json", "json", "application/json", "application/json"}, - {RSSType, "application", "rss", "xml", "application/rss+xml", "application/rss+xml"}, - {SVGType, "image", "svg", "svg", "image/svg+xml", "image/svg+xml"}, - {TextType, "text", "plain", "txt", "text/plain", "text/plain"}, - {XMLType, "application", "xml", "xml", "application/xml", "application/xml"}, - {TOMLType, "application", "toml", "toml", "application/toml", "application/toml"}, - {YAMLType, "application", "yaml", "yaml", "application/yaml", "application/yaml"}, - } { - c.Assert(test.tp.MainType, qt.Equals, test.expectedMainType) - c.Assert(test.tp.SubType, qt.Equals, test.expectedSubType) - c.Assert(test.tp.Suffix(), qt.Equals, test.expectedSuffix) - c.Assert(test.tp.Delimiter, qt.Equals, defaultDelimiter) - - c.Assert(test.tp.Type(), qt.Equals, test.expectedType) - c.Assert(test.tp.String(), qt.Equals, test.expectedString) - - } - - c.Assert(len(DefaultTypes), qt.Equals, 23) - -} - func TestGetByType(t *testing.T) { c := qt.New(t) - types := Types{HTMLType, RSSType} + types := DefaultTypes mt, found := types.GetByType("text/HTML") c.Assert(found, qt.Equals, true) - c.Assert(HTMLType, eq, mt) + c.Assert(mt.SubType, qt.Equals, "html") _, found = types.GetByType("text/nono") c.Assert(found, qt.Equals, false) mt, found = types.GetByType("application/rss+xml") c.Assert(found, qt.Equals, true) - c.Assert(RSSType, eq, mt) + c.Assert(mt.SubType, qt.Equals, "rss") mt, found = types.GetByType("application/rss") c.Assert(found, qt.Equals, true) - c.Assert(RSSType, eq, mt) + c.Assert(mt.SubType, qt.Equals, "rss") } func TestGetByMainSubType(t *testing.T) { c := qt.New(t) f, found := DefaultTypes.GetByMainSubType("text", "plain") c.Assert(found, qt.Equals, true) - c.Assert(TextType, eq, f) + c.Assert(f.SubType, qt.Equals, "plain") _, found = DefaultTypes.GetByMainSubType("foo", "plain") c.Assert(found, qt.Equals, false) } @@ -102,123 +65,152 @@ func TestBySuffix(t *testing.T) { func TestGetFirstBySuffix(t *testing.T) { c := qt.New(t) - f, found := DefaultTypes.GetFirstBySuffix("xml") - c.Assert(found, qt.Equals, true) - c.Assert(f, eq, Type{MainType: "application", SubType: "rss", mimeSuffix: "xml", Delimiter: ".", Suffixes: []string{"xml"}, fileSuffix: "xml"}) + + types := make(Types, len(DefaultTypes)) + copy(types, DefaultTypes) + + // Issue #8406 + geoJSON := newMediaTypeWithMimeSuffix("application", "geo", "json", []string{"geojson", "gjson"}) + types = append(types, geoJSON) + sort.Sort(types) + + check := func(suffix string, expectedType Type) { + t, f, found := types.GetFirstBySuffix(suffix) + c.Assert(found, qt.Equals, true) + c.Assert(f, qt.Equals, SuffixInfo{ + Suffix: suffix, + FullSuffix: "." + suffix, + }) + c.Assert(t, qt.Equals, expectedType) + } + + check("js", Builtin.JavascriptType) + check("json", Builtin.JSONType) + check("geojson", geoJSON) + check("gjson", geoJSON) } func TestFromTypeString(t *testing.T) { c := qt.New(t) - f, err := fromString("text/html") + f, err := FromString("text/html") c.Assert(err, qt.IsNil) - c.Assert(f.Type(), eq, HTMLType.Type()) + c.Assert(f.Type, qt.Equals, Builtin.HTMLType.Type) - f, err = fromString("application/custom") + f, err = FromString("application/custom") c.Assert(err, qt.IsNil) - c.Assert(f, eq, Type{MainType: "application", SubType: "custom", mimeSuffix: "", fileSuffix: ""}) + c.Assert(f, qt.Equals, Type{Type: "application/custom", MainType: "application", SubType: "custom", mimeSuffix: ""}) - f, err = fromString("application/custom+sfx") + f, err = FromString("application/custom+sfx") c.Assert(err, qt.IsNil) - c.Assert(f, eq, Type{MainType: "application", SubType: "custom", mimeSuffix: "sfx"}) + c.Assert(f, qt.Equals, Type{Type: "application/custom+sfx", MainType: "application", SubType: "custom", mimeSuffix: "sfx"}) - _, err = fromString("noslash") + _, err = FromString("noslash") c.Assert(err, qt.Not(qt.IsNil)) - f, err = fromString("text/xml; charset=utf-8") + f, err = FromString("text/xml; charset=utf-8") c.Assert(err, qt.IsNil) - c.Assert(f, eq, Type{MainType: "text", SubType: "xml", mimeSuffix: ""}) - c.Assert(f.Suffix(), qt.Equals, "") + + c.Assert(f, qt.Equals, Type{Type: "text/xml", MainType: "text", SubType: "xml", mimeSuffix: ""}) +} + +func TestFromStringAndExt(t *testing.T) { + c := qt.New(t) + f, err := FromStringAndExt("text/html", "html", "htm") + c.Assert(err, qt.IsNil) + c.Assert(f, qt.Equals, Builtin.HTMLType) + f, err = FromStringAndExt("text/html", ".html", ".htm") + c.Assert(err, qt.IsNil) + c.Assert(f, qt.Equals, Builtin.HTMLType) } // Add a test for the SVG case // https://github.com/gohugoio/hugo/issues/4920 func TestFromExtensionMultipleSuffixes(t *testing.T) { c := qt.New(t) - tp, found := DefaultTypes.GetBySuffix("svg") + tp, si, found := DefaultTypes.GetBySuffix("svg") c.Assert(found, qt.Equals, true) c.Assert(tp.String(), qt.Equals, "image/svg+xml") - c.Assert(tp.fileSuffix, qt.Equals, "svg") - c.Assert(tp.FullSuffix(), qt.Equals, ".svg") - tp, found = DefaultTypes.GetByType("image/svg+xml") + c.Assert(si.Suffix, qt.Equals, "svg") + c.Assert(si.FullSuffix, qt.Equals, ".svg") + c.Assert(tp.FirstSuffix.Suffix, qt.Equals, si.Suffix) + c.Assert(tp.FirstSuffix.FullSuffix, qt.Equals, si.FullSuffix) + ftp, found := DefaultTypes.GetByType("image/svg+xml") c.Assert(found, qt.Equals, true) - c.Assert(tp.String(), qt.Equals, "image/svg+xml") + c.Assert(ftp.String(), qt.Equals, "image/svg+xml") c.Assert(found, qt.Equals, true) - c.Assert(tp.FullSuffix(), qt.Equals, ".svg") - } -func TestDecodeTypes(t *testing.T) { +func TestFromContent(t *testing.T) { c := qt.New(t) - var tests = []struct { - name string - maps []map[string]interface{} - shouldError bool - assert func(t *testing.T, name string, tt Types) - }{ - { - "Redefine JSON", - []map[string]interface{}{ - { - "application/json": map[string]interface{}{ - "suffixes": []string{"jasn"}}}}, - false, - func(t *testing.T, name string, tt Types) { - c.Assert(len(tt), qt.Equals, len(DefaultTypes)) - json, found := tt.GetBySuffix("jasn") - c.Assert(found, qt.Equals, true) - c.Assert(json.String(), qt.Equals, "application/json") - c.Assert(json.FullSuffix(), qt.Equals, ".jasn") - }}, - { - "MIME suffix in key, multiple file suffixes, custom delimiter", - []map[string]interface{}{ - { - "application/hugo+hg": map[string]interface{}{ - "suffixes": []string{"hg1", "hg2"}, - "Delimiter": "_", - }}}, - false, - func(t *testing.T, name string, tt Types) { - c.Assert(len(tt), qt.Equals, len(DefaultTypes)+1) - hg, found := tt.GetBySuffix("hg2") - c.Assert(found, qt.Equals, true) - c.Assert(hg.mimeSuffix, qt.Equals, "hg") - c.Assert(hg.Suffix(), qt.Equals, "hg2") - c.Assert(hg.FullSuffix(), qt.Equals, "_hg2") - c.Assert(hg.String(), qt.Equals, "application/hugo+hg") + files, err := filepath.Glob("./testdata/resource.*") + c.Assert(err, qt.IsNil) - hg, found = tt.GetByType("application/hugo+hg") - c.Assert(found, qt.Equals, true) - - }}, - { - "Add custom media type", - []map[string]interface{}{ - { - "text/hugo+hgo": map[string]interface{}{ - "Suffixes": []string{"hgo2"}}}}, - false, - func(t *testing.T, name string, tt Types) { - c.Assert(len(tt), qt.Equals, len(DefaultTypes)+1) - // Make sure we have not broken the default config. - - _, found := tt.GetBySuffix("json") - c.Assert(found, qt.Equals, true) - - hugo, found := tt.GetBySuffix("hgo2") - c.Assert(found, qt.Equals, true) - c.Assert(hugo.String(), qt.Equals, "text/hugo+hgo") - }}, - } - - for _, test := range tests { - result, err := DecodeTypes(test.maps...) - if test.shouldError { - c.Assert(err, qt.Not(qt.IsNil)) - } else { + for _, filename := range files { + name := filepath.Base(filename) + c.Run(name, func(c *qt.C) { + content, err := os.ReadFile(filename) c.Assert(err, qt.IsNil) - test.assert(t, test.name, result) - } + ext := strings.TrimPrefix(paths.Ext(filename), ".") + var exts []string + if ext == "jpg" { + exts = append(exts, "foo", "bar", "jpg") + } else { + exts = []string{ext} + } + expected, _, found := DefaultTypes.GetFirstBySuffix(ext) + c.Assert(found, qt.IsTrue) + got := FromContent(DefaultTypes, exts, content) + c.Assert(got, qt.Equals, expected) + }) + } +} + +func TestFromContentFakes(t *testing.T) { + c := qt.New(t) + + files, err := filepath.Glob("./testdata/fake.*") + c.Assert(err, qt.IsNil) + + for _, filename := range files { + name := filepath.Base(filename) + c.Run(name, func(c *qt.C) { + content, err := os.ReadFile(filename) + c.Assert(err, qt.IsNil) + ext := strings.TrimPrefix(paths.Ext(filename), ".") + got := FromContent(DefaultTypes, []string{ext}, content) + c.Assert(got, qt.Equals, zero) + }) + } +} + +func TestToJSON(t *testing.T) { + c := qt.New(t) + b, err := json.Marshal(Builtin.MPEGType) + c.Assert(err, qt.IsNil) + c.Assert(string(b), qt.Equals, `{"mainType":"video","subType":"mpeg","delimiter":".","type":"video/mpeg","string":"video/mpeg","suffixes":["mpg","mpeg"]}`) +} + +func BenchmarkTypeOps(b *testing.B) { + mt := Builtin.MPEGType + mts := DefaultTypes + for i := 0; i < b.N; i++ { + ff := mt.FirstSuffix + _ = ff.FullSuffix + _ = mt.IsZero() + c, err := mt.MarshalJSON() + if c == nil || err != nil { + b.Fatal("failed") + } + _ = mt.String() + _ = ff.Suffix + _ = mt.Suffixes + _ = mt.Type + _ = mts.BySuffix("xml") + _, _ = mts.GetByMainSubType("application", "xml") + _, _, _ = mts.GetBySuffix("xml") + _, _ = mts.GetByType("application") + _, _, _ = mts.GetFirstBySuffix("xml") + } } diff --git a/media/testdata/fake.js b/media/testdata/fake.js new file mode 100644 index 000000000..08ae570d2 Binary files /dev/null and b/media/testdata/fake.js differ diff --git a/media/testdata/fake.png b/media/testdata/fake.png new file mode 100644 index 000000000..75ba3b7fe --- /dev/null +++ b/media/testdata/fake.png @@ -0,0 +1,3 @@ +function foo() { + return "foo"; +} \ No newline at end of file diff --git a/media/testdata/reosurce.otf b/media/testdata/reosurce.otf new file mode 100644 index 000000000..99034a2de Binary files /dev/null and b/media/testdata/reosurce.otf differ diff --git a/media/testdata/resource.bmp b/media/testdata/resource.bmp new file mode 100644 index 000000000..19759b33d Binary files /dev/null and b/media/testdata/resource.bmp differ diff --git a/media/testdata/resource.css b/media/testdata/resource.css new file mode 100644 index 000000000..a267873b5 --- /dev/null +++ b/media/testdata/resource.css @@ -0,0 +1,8 @@ +body { + background-color: lightblue; + } + + h1 { + color: navy; + margin-left: 20px; + } \ No newline at end of file diff --git a/media/testdata/resource.csv b/media/testdata/resource.csv new file mode 100644 index 000000000..ee6b058b6 --- /dev/null +++ b/media/testdata/resource.csv @@ -0,0 +1,130 @@ +"LatD", "LatM", "LatS", "NS", "LonD", "LonM", "LonS", "EW", "City", "State" + 41, 5, 59, "N", 80, 39, 0, "W", "Youngstown", OH + 42, 52, 48, "N", 97, 23, 23, "W", "Yankton", SD + 46, 35, 59, "N", 120, 30, 36, "W", "Yakima", WA + 42, 16, 12, "N", 71, 48, 0, "W", "Worcester", MA + 43, 37, 48, "N", 89, 46, 11, "W", "Wisconsin Dells", WI + 36, 5, 59, "N", 80, 15, 0, "W", "Winston-Salem", NC + 49, 52, 48, "N", 97, 9, 0, "W", "Winnipeg", MB + 39, 11, 23, "N", 78, 9, 36, "W", "Winchester", VA + 34, 14, 24, "N", 77, 55, 11, "W", "Wilmington", NC + 39, 45, 0, "N", 75, 33, 0, "W", "Wilmington", DE + 48, 9, 0, "N", 103, 37, 12, "W", "Williston", ND + 41, 15, 0, "N", 77, 0, 0, "W", "Williamsport", PA + 37, 40, 48, "N", 82, 16, 47, "W", "Williamson", WV + 33, 54, 0, "N", 98, 29, 23, "W", "Wichita Falls", TX + 37, 41, 23, "N", 97, 20, 23, "W", "Wichita", KS + 40, 4, 11, "N", 80, 43, 12, "W", "Wheeling", WV + 26, 43, 11, "N", 80, 3, 0, "W", "West Palm Beach", FL + 47, 25, 11, "N", 120, 19, 11, "W", "Wenatchee", WA + 41, 25, 11, "N", 122, 23, 23, "W", "Weed", CA + 31, 13, 11, "N", 82, 20, 59, "W", "Waycross", GA + 44, 57, 35, "N", 89, 38, 23, "W", "Wausau", WI + 42, 21, 36, "N", 87, 49, 48, "W", "Waukegan", IL + 44, 54, 0, "N", 97, 6, 36, "W", "Watertown", SD + 43, 58, 47, "N", 75, 55, 11, "W", "Watertown", NY + 42, 30, 0, "N", 92, 20, 23, "W", "Waterloo", IA + 41, 32, 59, "N", 73, 3, 0, "W", "Waterbury", CT + 38, 53, 23, "N", 77, 1, 47, "W", "Washington", DC + 41, 50, 59, "N", 79, 8, 23, "W", "Warren", PA + 46, 4, 11, "N", 118, 19, 48, "W", "Walla Walla", WA + 31, 32, 59, "N", 97, 8, 23, "W", "Waco", TX + 38, 40, 48, "N", 87, 31, 47, "W", "Vincennes", IN + 28, 48, 35, "N", 97, 0, 36, "W", "Victoria", TX + 32, 20, 59, "N", 90, 52, 47, "W", "Vicksburg", MS + 49, 16, 12, "N", 123, 7, 12, "W", "Vancouver", BC + 46, 55, 11, "N", 98, 0, 36, "W", "Valley City", ND + 30, 49, 47, "N", 83, 16, 47, "W", "Valdosta", GA + 43, 6, 36, "N", 75, 13, 48, "W", "Utica", NY + 39, 54, 0, "N", 79, 43, 48, "W", "Uniontown", PA + 32, 20, 59, "N", 95, 18, 0, "W", "Tyler", TX + 42, 33, 36, "N", 114, 28, 12, "W", "Twin Falls", ID + 33, 12, 35, "N", 87, 34, 11, "W", "Tuscaloosa", AL + 34, 15, 35, "N", 88, 42, 35, "W", "Tupelo", MS + 36, 9, 35, "N", 95, 54, 36, "W", "Tulsa", OK + 32, 13, 12, "N", 110, 58, 12, "W", "Tucson", AZ + 37, 10, 11, "N", 104, 30, 36, "W", "Trinidad", CO + 40, 13, 47, "N", 74, 46, 11, "W", "Trenton", NJ + 44, 45, 35, "N", 85, 37, 47, "W", "Traverse City", MI + 43, 39, 0, "N", 79, 22, 47, "W", "Toronto", ON + 39, 2, 59, "N", 95, 40, 11, "W", "Topeka", KS + 41, 39, 0, "N", 83, 32, 24, "W", "Toledo", OH + 33, 25, 48, "N", 94, 3, 0, "W", "Texarkana", TX + 39, 28, 12, "N", 87, 24, 36, "W", "Terre Haute", IN + 27, 57, 0, "N", 82, 26, 59, "W", "Tampa", FL + 30, 27, 0, "N", 84, 16, 47, "W", "Tallahassee", FL + 47, 14, 24, "N", 122, 25, 48, "W", "Tacoma", WA + 43, 2, 59, "N", 76, 9, 0, "W", "Syracuse", NY + 32, 35, 59, "N", 82, 20, 23, "W", "Swainsboro", GA + 33, 55, 11, "N", 80, 20, 59, "W", "Sumter", SC + 40, 59, 24, "N", 75, 11, 24, "W", "Stroudsburg", PA + 37, 57, 35, "N", 121, 17, 24, "W", "Stockton", CA + 44, 31, 12, "N", 89, 34, 11, "W", "Stevens Point", WI + 40, 21, 36, "N", 80, 37, 12, "W", "Steubenville", OH + 40, 37, 11, "N", 103, 13, 12, "W", "Sterling", CO + 38, 9, 0, "N", 79, 4, 11, "W", "Staunton", VA + 39, 55, 11, "N", 83, 48, 35, "W", "Springfield", OH + 37, 13, 12, "N", 93, 17, 24, "W", "Springfield", MO + 42, 5, 59, "N", 72, 35, 23, "W", "Springfield", MA + 39, 47, 59, "N", 89, 39, 0, "W", "Springfield", IL + 47, 40, 11, "N", 117, 24, 36, "W", "Spokane", WA + 41, 40, 48, "N", 86, 15, 0, "W", "South Bend", IN + 43, 32, 24, "N", 96, 43, 48, "W", "Sioux Falls", SD + 42, 29, 24, "N", 96, 23, 23, "W", "Sioux City", IA + 32, 30, 35, "N", 93, 45, 0, "W", "Shreveport", LA + 33, 38, 23, "N", 96, 36, 36, "W", "Sherman", TX + 44, 47, 59, "N", 106, 57, 35, "W", "Sheridan", WY + 35, 13, 47, "N", 96, 40, 48, "W", "Seminole", OK + 32, 25, 11, "N", 87, 1, 11, "W", "Selma", AL + 38, 42, 35, "N", 93, 13, 48, "W", "Sedalia", MO + 47, 35, 59, "N", 122, 19, 48, "W", "Seattle", WA + 41, 24, 35, "N", 75, 40, 11, "W", "Scranton", PA + 41, 52, 11, "N", 103, 39, 36, "W", "Scottsbluff", NB + 42, 49, 11, "N", 73, 56, 59, "W", "Schenectady", NY + 32, 4, 48, "N", 81, 5, 23, "W", "Savannah", GA + 46, 29, 24, "N", 84, 20, 59, "W", "Sault Sainte Marie", MI + 27, 20, 24, "N", 82, 31, 47, "W", "Sarasota", FL + 38, 26, 23, "N", 122, 43, 12, "W", "Santa Rosa", CA + 35, 40, 48, "N", 105, 56, 59, "W", "Santa Fe", NM + 34, 25, 11, "N", 119, 41, 59, "W", "Santa Barbara", CA + 33, 45, 35, "N", 117, 52, 12, "W", "Santa Ana", CA + 37, 20, 24, "N", 121, 52, 47, "W", "San Jose", CA + 37, 46, 47, "N", 122, 25, 11, "W", "San Francisco", CA + 41, 27, 0, "N", 82, 42, 35, "W", "Sandusky", OH + 32, 42, 35, "N", 117, 9, 0, "W", "San Diego", CA + 34, 6, 36, "N", 117, 18, 35, "W", "San Bernardino", CA + 29, 25, 12, "N", 98, 30, 0, "W", "San Antonio", TX + 31, 27, 35, "N", 100, 26, 24, "W", "San Angelo", TX + 40, 45, 35, "N", 111, 52, 47, "W", "Salt Lake City", UT + 38, 22, 11, "N", 75, 35, 59, "W", "Salisbury", MD + 36, 40, 11, "N", 121, 39, 0, "W", "Salinas", CA + 38, 50, 24, "N", 97, 36, 36, "W", "Salina", KS + 38, 31, 47, "N", 106, 0, 0, "W", "Salida", CO + 44, 56, 23, "N", 123, 1, 47, "W", "Salem", OR + 44, 57, 0, "N", 93, 5, 59, "W", "Saint Paul", MN + 38, 37, 11, "N", 90, 11, 24, "W", "Saint Louis", MO + 39, 46, 12, "N", 94, 50, 23, "W", "Saint Joseph", MO + 42, 5, 59, "N", 86, 28, 48, "W", "Saint Joseph", MI + 44, 25, 11, "N", 72, 1, 11, "W", "Saint Johnsbury", VT + 45, 34, 11, "N", 94, 10, 11, "W", "Saint Cloud", MN + 29, 53, 23, "N", 81, 19, 11, "W", "Saint Augustine", FL + 43, 25, 48, "N", 83, 56, 24, "W", "Saginaw", MI + 38, 35, 24, "N", 121, 29, 23, "W", "Sacramento", CA + 43, 36, 36, "N", 72, 58, 12, "W", "Rutland", VT + 33, 24, 0, "N", 104, 31, 47, "W", "Roswell", NM + 35, 56, 23, "N", 77, 48, 0, "W", "Rocky Mount", NC + 41, 35, 24, "N", 109, 13, 48, "W", "Rock Springs", WY + 42, 16, 12, "N", 89, 5, 59, "W", "Rockford", IL + 43, 9, 35, "N", 77, 36, 36, "W", "Rochester", NY + 44, 1, 12, "N", 92, 27, 35, "W", "Rochester", MN + 37, 16, 12, "N", 79, 56, 24, "W", "Roanoke", VA + 37, 32, 24, "N", 77, 26, 59, "W", "Richmond", VA + 39, 49, 48, "N", 84, 53, 23, "W", "Richmond", IN + 38, 46, 12, "N", 112, 5, 23, "W", "Richfield", UT + 45, 38, 23, "N", 89, 25, 11, "W", "Rhinelander", WI + 39, 31, 12, "N", 119, 48, 35, "W", "Reno", NV + 50, 25, 11, "N", 104, 39, 0, "W", "Regina", SA + 40, 10, 48, "N", 122, 14, 23, "W", "Red Bluff", CA + 40, 19, 48, "N", 75, 55, 48, "W", "Reading", PA + 41, 9, 35, "N", 81, 14, 23, "W", "Ravenna", OH + diff --git a/media/testdata/resource.gif b/media/testdata/resource.gif new file mode 100644 index 000000000..9549c0b9d Binary files /dev/null and b/media/testdata/resource.gif differ diff --git a/media/testdata/resource.ics b/media/testdata/resource.ics new file mode 100644 index 000000000..b9a263e93 --- /dev/null +++ b/media/testdata/resource.ics @@ -0,0 +1,24 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//ZContent.net//Zap Calendar 1.0//EN +CALSCALE:GREGORIAN +METHOD:PUBLISH +BEGIN:VEVENT +SUMMARY:Abraham Lincoln +UID:c7614cff-3549-4a00-9152-d25cc1fe077d +SEQUENCE:0 +STATUS:CONFIRMED +TRANSP:TRANSPARENT +RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=2;BYMONTHDAY=12 +DTSTART:20080212 +DTEND:20080213 +DTSTAMP:20150421T141403 +CATEGORIES:U.S. Presidents,Civil War People +LOCATION:Hodgenville\, Kentucky +GEO:37.5739497;-85.7399606 +DESCRIPTION:Born February 12\, 1809\nSixteenth President (1861-1865)\n\n\n + \nhttp://AmericanHistoryCalendar.com +URL:http://americanhistorycalendar.com/peoplecalendar/1,328-abraham-lincol + n +END:VEVENT +END:VCALENDAR \ No newline at end of file diff --git a/media/testdata/resource.jpe b/media/testdata/resource.jpe new file mode 100644 index 000000000..a9049e81b Binary files /dev/null and b/media/testdata/resource.jpe differ diff --git a/media/testdata/resource.jpg b/media/testdata/resource.jpg new file mode 100644 index 000000000..a9049e81b Binary files /dev/null and b/media/testdata/resource.jpg differ diff --git a/media/testdata/resource.js b/media/testdata/resource.js new file mode 100644 index 000000000..75ba3b7fe --- /dev/null +++ b/media/testdata/resource.js @@ -0,0 +1,3 @@ +function foo() { + return "foo"; +} \ No newline at end of file diff --git a/media/testdata/resource.json b/media/testdata/resource.json new file mode 100644 index 000000000..446899897 --- /dev/null +++ b/media/testdata/resource.json @@ -0,0 +1,14 @@ +{ + "firstName": "Joe", + "lastName": "Jackson", + "gender": "male", + "age": 28, + "address": { + "streetAddress": "101", + "city": "San Diego", + "state": "CA" + }, + "phoneNumbers": [ + { "type": "home", "number": "7349282382" } + ] +} \ No newline at end of file diff --git a/media/testdata/resource.pdf b/media/testdata/resource.pdf new file mode 100644 index 000000000..c0e31a076 --- /dev/null +++ b/media/testdata/resource.pdf @@ -0,0 +1,198 @@ +%PDF-1.3 +% + +1 0 obj +<< +/Type /Catalog +/Outlines 2 0 R +/Pages 3 0 R +>> +endobj + +2 0 obj +<< +/Type /Outlines +/Count 0 +>> +endobj + +3 0 obj +<< +/Type /Pages +/Count 2 +/Kids [ 4 0 R 6 0 R ] +>> +endobj + +4 0 obj +<< +/Type /Page +/Parent 3 0 R +/Resources << +/Font << +/F1 9 0 R +>> +/ProcSet 8 0 R +>> +/MediaBox [0 0 612.0000 792.0000] +/Contents 5 0 R +>> +endobj + +5 0 obj +<< /Length 1074 >> +stream +2 J +BT +0 0 0 rg +/F1 0027 Tf +57.3750 722.2800 Td +( A Simple PDF File ) Tj +ET +BT +/F1 0010 Tf +69.2500 688.6080 Td +( This is a small demonstration .pdf file - ) Tj +ET +BT +/F1 0010 Tf +69.2500 664.7040 Td +( just for use in the Virtual Mechanics tutorials. More text. And more ) Tj +ET +BT +/F1 0010 Tf +69.2500 652.7520 Td +( text. And more text. And more text. And more text. ) Tj +ET +BT +/F1 0010 Tf +69.2500 628.8480 Td +( And more text. And more text. And more text. And more text. And more ) Tj +ET +BT +/F1 0010 Tf +69.2500 616.8960 Td +( text. And more text. Boring, zzzzz. And more text. And more text. And ) Tj +ET +BT +/F1 0010 Tf +69.2500 604.9440 Td +( more text. And more text. And more text. And more text. And more text. ) Tj +ET +BT +/F1 0010 Tf +69.2500 592.9920 Td +( And more text. And more text. ) Tj +ET +BT +/F1 0010 Tf +69.2500 569.0880 Td +( And more text. And more text. And more text. And more text. And more ) Tj +ET +BT +/F1 0010 Tf +69.2500 557.1360 Td +( text. And more text. And more text. Even more. Continued on page 2 ...) Tj +ET +endstream +endobj + +6 0 obj +<< +/Type /Page +/Parent 3 0 R +/Resources << +/Font << +/F1 9 0 R +>> +/ProcSet 8 0 R +>> +/MediaBox [0 0 612.0000 792.0000] +/Contents 7 0 R +>> +endobj + +7 0 obj +<< /Length 676 >> +stream +2 J +BT +0 0 0 rg +/F1 0027 Tf +57.3750 722.2800 Td +( Simple PDF File 2 ) Tj +ET +BT +/F1 0010 Tf +69.2500 688.6080 Td +( ...continued from page 1. Yet more text. And more text. And more text. ) Tj +ET +BT +/F1 0010 Tf +69.2500 676.6560 Td +( And more text. And more text. And more text. And more text. And more ) Tj +ET +BT +/F1 0010 Tf +69.2500 664.7040 Td +( text. Oh, how boring typing this stuff. But not as boring as watching ) Tj +ET +BT +/F1 0010 Tf +69.2500 652.7520 Td +( paint dry. And more text. And more text. And more text. And more text. ) Tj +ET +BT +/F1 0010 Tf +69.2500 640.8000 Td +( Boring. More, a little more text. The end, and just as well. ) Tj +ET +endstream +endobj + +8 0 obj +[/PDF /Text] +endobj + +9 0 obj +<< +/Type /Font +/Subtype /Type1 +/Name /F1 +/BaseFont /Helvetica +/Encoding /WinAnsiEncoding +>> +endobj + +10 0 obj +<< +/Creator (Rave \(http://www.nevrona.com/rave\)) +/Producer (Nevrona Designs) +/CreationDate (D:20060301072826) +>> +endobj + +xref +0 11 +0000000000 65535 f +0000000019 00000 n +0000000093 00000 n +0000000147 00000 n +0000000222 00000 n +0000000390 00000 n +0000001522 00000 n +0000001690 00000 n +0000002423 00000 n +0000002456 00000 n +0000002574 00000 n + +trailer +<< +/Size 11 +/Root 1 0 R +/Info 10 0 R +>> + +startxref +2714 +%%EOF diff --git a/media/testdata/resource.png b/media/testdata/resource.png new file mode 100644 index 000000000..08ae570d2 Binary files /dev/null and b/media/testdata/resource.png differ diff --git a/media/testdata/resource.rss b/media/testdata/resource.rss new file mode 100644 index 000000000..b20b0fcca --- /dev/null +++ b/media/testdata/resource.rss @@ -0,0 +1,20 @@ + + + + + W3Schools Home Page + https://www.w3schools.com + Free web building tutorials + + RSS Tutorial + https://www.w3schools.com/xml/xml_rss.asp + New RSS tutorial on W3Schools + + + XML Tutorial + https://www.w3schools.com/xml + New XML tutorial on W3Schools + + + + \ No newline at end of file diff --git a/media/testdata/resource.sass b/media/testdata/resource.sass new file mode 100644 index 000000000..ad857fac7 --- /dev/null +++ b/media/testdata/resource.sass @@ -0,0 +1,6 @@ +$font-stack: Helvetica, sans-serif +$primary-color: #333 + +body + font: 100% $font-stack + color: $primary-color \ No newline at end of file diff --git a/media/testdata/resource.scss b/media/testdata/resource.scss new file mode 100644 index 000000000..d63e420f6 --- /dev/null +++ b/media/testdata/resource.scss @@ -0,0 +1,7 @@ +$font-stack: Helvetica, sans-serif; +$primary-color: #333; + +body { + font: 100% $font-stack; + color: $primary-color; +} \ No newline at end of file diff --git a/media/testdata/resource.svg b/media/testdata/resource.svg new file mode 100644 index 000000000..2759ae703 --- /dev/null +++ b/media/testdata/resource.svg @@ -0,0 +1,5 @@ + + + Sorry, your browser does not support inline SVG. + + \ No newline at end of file diff --git a/media/testdata/resource.ttf b/media/testdata/resource.ttf new file mode 100644 index 000000000..8bc614d06 Binary files /dev/null and b/media/testdata/resource.ttf differ diff --git a/media/testdata/resource.webp b/media/testdata/resource.webp new file mode 100644 index 000000000..4365e7b9f Binary files /dev/null and b/media/testdata/resource.webp differ diff --git a/media/testdata/resource.xml b/media/testdata/resource.xml new file mode 100644 index 000000000..fa0c0a5b6 --- /dev/null +++ b/media/testdata/resource.xml @@ -0,0 +1,7 @@ + + + Tove + Jani + Reminder + Don't forget me this weekend! + \ No newline at end of file diff --git a/merge-release.sh b/merge-release.sh new file mode 100755 index 000000000..a87f9f4a1 --- /dev/null +++ b/merge-release.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +if (( $# < 1 )); + then + echo "USAGE: ./merge-release.sh 0.76.0" + exit 1 +fi + +die() { echo "$*" 1>&2 ; exit 1; } + +v=$1 +git merge "release-${v}" || die; +git push || die; + +git checkout stable || die; +git reset --hard "v${v}" || die; +git push -f || die; + +git checkout master || die; + + git subtree push --prefix=docs/ docs-local "tempv${v}"; + + diff --git a/metrics/metrics.go b/metrics/metrics.go index 329981202..08d9322ab 100644 --- a/metrics/metrics.go +++ b/metrics/metrics.go @@ -18,15 +18,16 @@ import ( "fmt" "io" "math" + "reflect" "sort" "strconv" "strings" "sync" "time" + "github.com/gohugoio/hugo/common/hashing" + "github.com/gohugoio/hugo/common/types" "github.com/gohugoio/hugo/compare" - - "github.com/gohugoio/hugo/common/hreflect" ) // The Provider interface defines an interface for measuring metrics. @@ -39,27 +40,27 @@ type Provider interface { WriteMetrics(w io.Writer) // TrackValue tracks the value for diff calculations etc. - TrackValue(key string, value interface{}) + TrackValue(key string, value any, cached bool) // Reset clears the metric store. Reset() } type diff struct { - baseline interface{} + baseline any count int simSum int } -func (d *diff) add(v interface{}) *diff { - if !hreflect.IsTruthful(v) { +func (d *diff) add(v any) *diff { + if types.IsNil(d.baseline) { d.baseline = v d.count = 1 d.simSum = 100 // If we get only one it is very cache friendly. return d } - - d.simSum += howSimilar(v, d.baseline) + adder := howSimilar(v, d.baseline) + d.simSum += adder d.count++ return d @@ -72,6 +73,8 @@ type Store struct { mu sync.Mutex diffs map[string]*diff diffmu sync.Mutex + cached map[string]int + cachedmu sync.Mutex } // NewProvider returns a new instance of a metric store. @@ -80,6 +83,7 @@ func NewProvider(calculateHints bool) Provider { calculateHints: calculateHints, metrics: make(map[string][]time.Duration), diffs: make(map[string]*diff), + cached: make(map[string]int), } } @@ -88,24 +92,24 @@ func (s *Store) Reset() { s.mu.Lock() s.metrics = make(map[string][]time.Duration) s.mu.Unlock() + s.diffmu.Lock() s.diffs = make(map[string]*diff) s.diffmu.Unlock() + + s.cachedmu.Lock() + s.cached = make(map[string]int) + s.cachedmu.Unlock() } // TrackValue tracks the value for diff calculations etc. -func (s *Store) TrackValue(key string, value interface{}) { +func (s *Store) TrackValue(key string, value any, cached bool) { if !s.calculateHints { return } s.diffmu.Lock() - var ( - d *diff - found bool - ) - - d, found = s.diffs[key] + d, found := s.diffs[key] if !found { d = &diff{} @@ -114,6 +118,12 @@ func (s *Store) TrackValue(key string, value interface{}) { d.add(value) s.diffmu.Unlock() + + if cached { + s.cachedmu.Lock() + s.cached[key] = s.cached[key] + 1 + s.cachedmu.Unlock() + } } // MeasureSince adds a measurement for key to the metric store. @@ -135,6 +145,7 @@ func (s *Store) WriteMetrics(w io.Writer) { var max time.Duration diff, found := s.diffs[k] + cacheFactor := 0 if found { cacheFactor = int(math.Floor(float64(diff.simSum) / float64(diff.count))) @@ -148,39 +159,39 @@ func (s *Store) WriteMetrics(w io.Writer) { } avg := time.Duration(int(sum) / len(v)) + cacheCount := s.cached[k] - results[i] = result{key: k, count: len(v), max: max, sum: sum, avg: avg, cacheFactor: cacheFactor} + results[i] = result{key: k, count: len(v), max: max, sum: sum, avg: avg, cacheCount: cacheCount, cacheFactor: cacheFactor} i++ } s.mu.Unlock() if s.calculateHints { - fmt.Fprintf(w, " %9s %13s %12s %12s %5s %s\n", "cache", "cumulative", "average", "maximum", "", "") - fmt.Fprintf(w, " %9s %13s %12s %12s %5s %s\n", "potential", "duration", "duration", "duration", "count", "template") - fmt.Fprintf(w, " %9s %13s %12s %12s %5s %s\n", "-----", "----------", "--------", "--------", "-----", "--------") + fmt.Fprintf(w, " %15s %12s %12s %9s %7s %6s %5s %s\n", "cumulative", "average", "maximum", "cache", "percent", "cached", "total", "") + fmt.Fprintf(w, " %15s %12s %12s %9s %7s %6s %5s %s\n", "duration", "duration", "duration", "potential", "cached", "count", "count", "template") + fmt.Fprintf(w, " %15s %12s %12s %9s %7s %6s %5s %s\n", "----------", "--------", "--------", "---------", "-------", "------", "-----", "--------") } else { - fmt.Fprintf(w, " %13s %12s %12s %5s %s\n", "cumulative", "average", "maximum", "", "") - fmt.Fprintf(w, " %13s %12s %12s %5s %s\n", "duration", "duration", "duration", "count", "template") - fmt.Fprintf(w, " %13s %12s %12s %5s %s\n", "----------", "--------", "--------", "-----", "--------") - + fmt.Fprintf(w, " %15s %12s %12s %5s %s\n", "cumulative", "average", "maximum", "", "") + fmt.Fprintf(w, " %15s %12s %12s %5s %s\n", "duration", "duration", "duration", "count", "template") + fmt.Fprintf(w, " %15s %12s %12s %5s %s\n", "----------", "--------", "--------", "-----", "--------") } sort.Sort(bySum(results)) for _, v := range results { if s.calculateHints { - fmt.Fprintf(w, " %9d %13s %12s %12s %5d %s\n", v.cacheFactor, v.sum, v.avg, v.max, v.count, v.key) + fmt.Fprintf(w, " %15s %12s %12s %9d %7.f %6d %5d %s\n", v.sum, v.avg, v.max, v.cacheFactor, float64(v.cacheCount)/float64(v.count)*100, v.cacheCount, v.count, v.key) } else { - fmt.Fprintf(w, " %13s %12s %12s %5d %s\n", v.sum, v.avg, v.max, v.count, v.key) + fmt.Fprintf(w, " %15s %12s %12s %5d %s\n", v.sum, v.avg, v.max, v.count, v.key) } } - } // A result represents the calculated results for a given metric. type result struct { key string count int + cacheCount int cacheFactor int sum time.Duration max time.Duration @@ -195,12 +206,20 @@ func (b bySum) Less(i, j int) bool { return b[i].sum > b[j].sum } // howSimilar is a naive diff implementation that returns // a number between 0-100 indicating how similar a and b are. -func howSimilar(a, b interface{}) int { - // TODO(bep) object equality fast path, but remember that - // we can get anytning in here. +func howSimilar(a, b any) int { + t1, t2 := reflect.TypeOf(a), reflect.TypeOf(b) + if t1 != t2 { + return 0 + } - as, ok1 := a.(string) - bs, ok2 := b.(string) + if t1.Comparable() && t2.Comparable() { + if a == b { + return 100 + } + } + + as, ok1 := types.TypeToString(a) + bs, ok2 := types.TypeToString(b) if ok1 && ok2 { return howSimilarStrings(as, bs) @@ -222,6 +241,10 @@ func howSimilar(a, b interface{}) int { return 90 } + h1, h2 := hashing.HashString(a), hashing.HashString(b) + if h1 == h2 { + return 100 + } return 0 } @@ -229,6 +252,9 @@ func howSimilar(a, b interface{}) int { // a number between 0-100 indicating how similar a and b are. // 100 is when all words in a also exists in b. func howSimilarStrings(a, b string) int { + if a == b { + return 100 + } // Give some weight to the word positions. const partitionSize = 4 @@ -254,6 +280,10 @@ func howSimilarStrings(a, b string) int { } } + if common == 0 && common == len(af) { + return 100 + } + return int(math.Floor((float64(common) / float64(len(af)) * 100))) } diff --git a/metrics/metrics_test.go b/metrics/metrics_test.go index d4c362b7b..6e799a393 100644 --- a/metrics/metrics_test.go +++ b/metrics/metrics_test.go @@ -14,6 +14,7 @@ package metrics import ( + "html/template" "strings" "testing" @@ -39,13 +40,22 @@ func TestSimilarPercentage(t *testing.T) { c.Assert(howSimilar("The Hugo", "The Hugo Rules"), qt.Equals, 66) c.Assert(howSimilar("Totally different", "Not Same"), qt.Equals, 0) c.Assert(howSimilar(sentence, sentenceReversed), qt.Equals, 14) + c.Assert(howSimilar(template.HTML("Hugo Rules"), template.HTML("Hugo Rules")), qt.Equals, 100) + c.Assert(howSimilar(map[string]any{"a": 32, "b": 33}, map[string]any{"a": 32, "b": 33}), qt.Equals, 100) + c.Assert(howSimilar(map[string]any{"a": 32, "b": 33}, map[string]any{"a": 32, "b": 34}), qt.Equals, 0) + c.Assert(howSimilar("\n", ""), qt.Equals, 100) +} +type testStruct struct { + Name string } func TestSimilarPercentageNonString(t *testing.T) { c := qt.New(t) c.Assert(howSimilar(page.NopPage, page.NopPage), qt.Equals, 100) c.Assert(howSimilar(page.Pages{}, page.Pages{}), qt.Equals, 90) + c.Assert(howSimilar(testStruct{Name: "A"}, testStruct{Name: "B"}), qt.Equals, 0) + c.Assert(howSimilar(testStruct{Name: "A"}, testStruct{Name: "A"}), qt.Equals, 100) } func BenchmarkHowSimilar(b *testing.B) { diff --git a/minifiers/config.go b/minifiers/config.go new file mode 100644 index 000000000..1ebbd1e05 --- /dev/null +++ b/minifiers/config.go @@ -0,0 +1,135 @@ +// Copyright 2019 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 minifiers + +import ( + "github.com/gohugoio/hugo/common/maps" + "github.com/spf13/cast" + + "github.com/mitchellh/mapstructure" + "github.com/tdewolff/minify/v2/css" + "github.com/tdewolff/minify/v2/html" + "github.com/tdewolff/minify/v2/js" + "github.com/tdewolff/minify/v2/json" + "github.com/tdewolff/minify/v2/svg" + "github.com/tdewolff/minify/v2/xml" +) + +var defaultTdewolffConfig = TdewolffConfig{ + HTML: html.Minifier{ + KeepDocumentTags: true, + KeepSpecialComments: true, + KeepEndTags: true, + KeepDefaultAttrVals: true, + KeepWhitespace: false, + }, + CSS: css.Minifier{ + Precision: 0, + KeepCSS2: true, + }, + JS: js.Minifier{ + Version: 2022, + }, + JSON: json.Minifier{}, + SVG: svg.Minifier{ + KeepComments: false, + Precision: 0, + }, + XML: xml.Minifier{ + KeepWhitespace: false, + }, +} + +type TdewolffConfig struct { + HTML html.Minifier + CSS css.Minifier + JS js.Minifier + JSON json.Minifier + SVG svg.Minifier + XML xml.Minifier +} + +type MinifyConfig struct { + // Whether to minify the published output (the HTML written to /public). + MinifyOutput bool + + DisableHTML bool + DisableCSS bool + DisableJS bool + DisableJSON bool + DisableSVG bool + DisableXML bool + + Tdewolff TdewolffConfig +} + +var defaultConfig = MinifyConfig{ + Tdewolff: defaultTdewolffConfig, +} + +func DecodeConfig(v any) (conf MinifyConfig, err error) { + conf = defaultConfig + + if v == nil { + return + } + + m := maps.ToStringMap(v) + + // Handle upstream renames. + if td, found := m["tdewolff"]; found { + tdm := maps.ToStringMap(td) + + for _, key := range []string{"css", "svg"} { + if v, found := tdm[key]; found { + vm := maps.ToStringMap(v) + ko := "decimal" + kn := "precision" + if vv, found := vm[ko]; found { + if _, found = vm[kn]; !found { + vvi := cast.ToInt(vv) + if vvi > 0 { + vm[kn] = vvi + } + } + delete(vm, ko) + } + } + } + + // keepConditionalComments was renamed to keepSpecialComments + if v, found := tdm["html"]; found { + vm := maps.ToStringMap(v) + ko := "keepconditionalcomments" + kn := "keepspecialcomments" + if vv, found := vm[ko]; found { + // Set keepspecialcomments, if not already set + if _, found := vm[kn]; !found { + vm[kn] = cast.ToBool(vv) + } + // Remove the old key to prevent deprecation warnings + delete(vm, ko) + } + } + + } + + err = mapstructure.WeakDecode(m, &conf) + + if err != nil { + return + } + + return +} diff --git a/minifiers/config_test.go b/minifiers/config_test.go new file mode 100644 index 000000000..7edd8734e --- /dev/null +++ b/minifiers/config_test.go @@ -0,0 +1,103 @@ +// Copyright 2024 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 minifiers_test + +import ( + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/config/testconfig" +) + +func TestConfig(t *testing.T) { + c := qt.New(t) + v := config.New() + + v.Set("minify", map[string]any{ + "disablexml": true, + "tdewolff": map[string]any{ + "html": map[string]any{ + "keepwhitespace": false, + }, + }, + }) + + conf := testconfig.GetTestConfigs(nil, v).Base.Minify + + c.Assert(conf.MinifyOutput, qt.Equals, false) + + // explicitly set value + c.Assert(conf.Tdewolff.HTML.KeepWhitespace, qt.Equals, false) + // default value + c.Assert(conf.Tdewolff.HTML.KeepEndTags, qt.Equals, true) + c.Assert(conf.Tdewolff.CSS.KeepCSS2, qt.Equals, true) + + // `enable` flags + c.Assert(conf.DisableHTML, qt.Equals, false) + c.Assert(conf.DisableXML, qt.Equals, true) +} + +func TestConfigLegacy(t *testing.T) { + c := qt.New(t) + v := config.New() + + // This was a bool < Hugo v0.58. + v.Set("minify", true) + + conf := testconfig.GetTestConfigs(nil, v).Base.Minify + c.Assert(conf.MinifyOutput, qt.Equals, true) +} + +func TestConfigNewCommentOptions(t *testing.T) { + c := qt.New(t) + v := config.New() + + // setting the old options should automatically set the new options + v.Set("minify", map[string]any{ + "tdewolff": map[string]any{ + "html": map[string]any{ + "keepConditionalComments": false, + }, + "svg": map[string]any{ + "decimal": "5", + }, + }, + }) + + conf := testconfig.GetTestConfigs(nil, v).Base.Minify + + c.Assert(conf.Tdewolff.HTML.KeepSpecialComments, qt.Equals, false) + c.Assert(conf.Tdewolff.SVG.Precision, qt.Equals, 5) + + // the new values should win, regardless of the contents of the old values + v = config.New() + v.Set("minify", map[string]any{ + "tdewolff": map[string]any{ + "html": map[string]any{ + "keepConditionalComments": false, + "keepSpecialComments": true, + }, + "svg": map[string]any{ + "decimal": "5", + "precision": "10", + }, + }, + }) + + conf = testconfig.GetTestConfigs(nil, v).Base.Minify + + c.Assert(conf.Tdewolff.HTML.KeepSpecialComments, qt.Equals, true) + c.Assert(conf.Tdewolff.SVG.Precision, qt.Equals, 10) +} diff --git a/minifiers/minifiers.go b/minifiers/minifiers.go index 9533ebb69..2696e1c52 100644 --- a/minifiers/minifiers.go +++ b/minifiers/minifiers.go @@ -20,28 +20,26 @@ import ( "io" "regexp" + "github.com/gohugoio/hugo/config" "github.com/gohugoio/hugo/output" "github.com/gohugoio/hugo/transform" "github.com/gohugoio/hugo/media" "github.com/tdewolff/minify/v2" - "github.com/tdewolff/minify/v2/css" - "github.com/tdewolff/minify/v2/html" - "github.com/tdewolff/minify/v2/js" - "github.com/tdewolff/minify/v2/json" - "github.com/tdewolff/minify/v2/svg" - "github.com/tdewolff/minify/v2/xml" ) // Client wraps a minifier. type Client struct { + // Whether output minification is enabled (HTML in /public) + MinifyOutput bool + m *minify.M } // Transformer returns a func that can be used in the transformer publishing chain. // TODO(bep) minify config etc func (m Client) Transformer(mediatype media.Type) transform.Transformer { - _, params, min := m.m.Match(mediatype.Type()) + _, params, min := m.m.Match(mediatype.Type) if min == nil { // No minifier for this MIME type return nil @@ -56,57 +54,78 @@ func (m Client) Transformer(mediatype media.Type) transform.Transformer { // Minify tries to minify the src into dst given a MIME type. func (m Client) Minify(mediatype media.Type, dst io.Writer, src io.Reader) error { - return m.m.Minify(mediatype.Type(), dst, src) + return m.m.Minify(mediatype.Type, dst, src) +} + +// noopMinifier implements minify.Minifier [1], but doesn't minify content. This means +// that we can avoid missing minifiers for any MIME types in our minify.M, which +// causes minify to return errors, while still allowing minification to be +// disabled for specific types. +// +// [1]: https://pkg.go.dev/github.com/tdewolff/minify#Minifier +type noopMinifier struct{} + +// Minify copies r into w without transformation. +func (m noopMinifier) Minify(_ *minify.M, w io.Writer, r io.Reader, _ map[string]string) error { + _, err := io.Copy(w, r) + return err } // New creates a new Client with the provided MIME types as the mapping foundation. // The HTML minifier is also registered for additional HTML types (AMP etc.) in the // provided list of output formats. -func New(mediaTypes media.Types, outputFormats output.Formats) Client { +func New(mediaTypes media.Types, outputFormats output.Formats, cfg config.AllProvider) (Client, error) { + conf := cfg.GetConfigSection("minify").(MinifyConfig) m := minify.New() - htmlMin := &html.Minifier{ - KeepDocumentTags: true, - KeepConditionalComments: true, - KeepEndTags: true, - KeepDefaultAttrVals: true, - } - - cssMin := &css.Minifier{ - Decimals: -1, - KeepCSS2: true, - } // We use the Type definition of the media types defined in the site if found. - addMinifier(m, mediaTypes, "css", cssMin) - addMinifierFunc(m, mediaTypes, "js", js.Minify) - m.AddFuncRegexp(regexp.MustCompile("^(application|text)/(x-)?(java|ecma)script$"), js.Minify) - m.AddFuncRegexp(regexp.MustCompile(`^(application|text)/(x-|ld\+)?json$`), json.Minify) - addMinifierFunc(m, mediaTypes, "json", json.Minify) - addMinifierFunc(m, mediaTypes, "svg", svg.Minify) - addMinifierFunc(m, mediaTypes, "xml", xml.Minify) + addMinifier(m, mediaTypes, "css", getMinifier(conf, "css")) + + addMinifier(m, mediaTypes, "js", getMinifier(conf, "js")) + m.AddRegexp(regexp.MustCompile("^(application|text)/(x-)?(java|ecma)script$"), getMinifier(conf, "js")) + + addMinifier(m, mediaTypes, "json", getMinifier(conf, "json")) + m.AddRegexp(regexp.MustCompile(`^(application|text)/(x-|(ld|manifest)\+)?json$`), getMinifier(conf, "json")) + + addMinifier(m, mediaTypes, "svg", getMinifier(conf, "svg")) + + addMinifier(m, mediaTypes, "xml", getMinifier(conf, "xml")) // HTML - addMinifier(m, mediaTypes, "html", htmlMin) + addMinifier(m, mediaTypes, "html", getMinifier(conf, "html")) for _, of := range outputFormats { if of.IsHTML { - m.Add(of.MediaType.Type(), htmlMin) + m.Add(of.MediaType.Type, getMinifier(conf, "html")) } } - return Client{m: m} + return Client{m: m, MinifyOutput: conf.MinifyOutput}, nil +} +// getMinifier returns the appropriate minify.MinifierFunc for the MIME +// type suffix s, given the config c. +func getMinifier(c MinifyConfig, s string) minify.Minifier { + switch { + case s == "css" && !c.DisableCSS: + return &c.Tdewolff.CSS + case s == "js" && !c.DisableJS: + return &c.Tdewolff.JS + case s == "json" && !c.DisableJSON: + return &c.Tdewolff.JSON + case s == "svg" && !c.DisableSVG: + return &c.Tdewolff.SVG + case s == "xml" && !c.DisableXML: + return &c.Tdewolff.XML + case s == "html" && !c.DisableHTML: + return &c.Tdewolff.HTML + default: + return noopMinifier{} + } } func addMinifier(m *minify.M, mt media.Types, suffix string, min minify.Minifier) { types := mt.BySuffix(suffix) for _, t := range types { - m.Add(t.Type(), min) - } -} - -func addMinifierFunc(m *minify.M, mt media.Types, suffix string, min minify.MinifierFunc) { - types := mt.BySuffix(suffix) - for _, t := range types { - m.AddFunc(t.Type(), min) + m.Add(t.Type, min) } } diff --git a/minifiers/minifiers_test.go b/minifiers/minifiers_test.go index 87e706320..b81c632fe 100644 --- a/minifiers/minifiers_test.go +++ b/minifiers/minifiers_test.go @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package minifiers +package minifiers_test import ( "bytes" @@ -19,20 +19,24 @@ import ( "strings" "testing" - "github.com/gohugoio/hugo/media" - qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/config/testconfig" + "github.com/gohugoio/hugo/media" + "github.com/gohugoio/hugo/minifiers" "github.com/gohugoio/hugo/output" + "github.com/spf13/afero" + "github.com/tdewolff/minify/v2/html" ) func TestNew(t *testing.T) { c := qt.New(t) - m := New(media.DefaultTypes, output.DefaultFormats) + m, _ := minifiers.New(media.DefaultTypes, output.DefaultFormats, testconfig.GetTestConfig(afero.NewMemMapFs(), nil)) var rawJS string var minJS string rawJS = " var foo =1 ; foo ++ ; " - minJS = "var foo=1;foo++;" + minJS = "var foo=1;foo++" var rawJSON string var minJSON string @@ -44,38 +48,67 @@ func TestNew(t *testing.T) { rawString string expectedMinString string }{ - {media.CSSType, " body { color: blue; } ", "body{color:blue}"}, - {media.RSSType, " Hugo! ", "Hugo!"}, // RSS should be handled as XML - {media.JSONType, rawJSON, minJSON}, - {media.JavascriptType, rawJS, minJS}, + {media.Builtin.CSSType, " body { color: blue; } ", "body{color:blue}"}, + {media.Builtin.RSSType, " Hugo! ", "Hugo!"}, // RSS should be handled as XML + {media.Builtin.JSONType, rawJSON, minJSON}, + {media.Builtin.JavascriptType, rawJS, minJS}, // JS Regex minifiers - {media.Type{MainType: "application", SubType: "ecmascript"}, rawJS, minJS}, - {media.Type{MainType: "application", SubType: "javascript"}, rawJS, minJS}, - {media.Type{MainType: "application", SubType: "x-javascript"}, rawJS, minJS}, - {media.Type{MainType: "application", SubType: "x-ecmascript"}, rawJS, minJS}, - {media.Type{MainType: "text", SubType: "ecmascript"}, rawJS, minJS}, - {media.Type{MainType: "text", SubType: "javascript"}, rawJS, minJS}, - {media.Type{MainType: "text", SubType: "x-javascript"}, rawJS, minJS}, - {media.Type{MainType: "text", SubType: "x-ecmascript"}, rawJS, minJS}, + {media.Type{Type: "application/ecmascript", MainType: "application", SubType: "ecmascript"}, rawJS, minJS}, + {media.Type{Type: "application/javascript", MainType: "application", SubType: "javascript"}, rawJS, minJS}, + {media.Type{Type: "application/x-javascript", MainType: "application", SubType: "x-javascript"}, rawJS, minJS}, + {media.Type{Type: "application/x-ecmascript", MainType: "application", SubType: "x-ecmascript"}, rawJS, minJS}, + {media.Type{Type: "text/ecmascript", MainType: "text", SubType: "ecmascript"}, rawJS, minJS}, + {media.Type{Type: "application/javascript", MainType: "text", SubType: "javascript"}, rawJS, minJS}, // JSON Regex minifiers - {media.Type{MainType: "application", SubType: "json"}, rawJSON, minJSON}, - {media.Type{MainType: "application", SubType: "x-json"}, rawJSON, minJSON}, - {media.Type{MainType: "application", SubType: "ld+json"}, rawJSON, minJSON}, - {media.Type{MainType: "text", SubType: "json"}, rawJSON, minJSON}, - {media.Type{MainType: "text", SubType: "x-json"}, rawJSON, minJSON}, - {media.Type{MainType: "text", SubType: "ld+json"}, rawJSON, minJSON}, + {media.Type{Type: "application/json", MainType: "application", SubType: "json"}, rawJSON, minJSON}, + {media.Type{Type: "application/x-json", MainType: "application", SubType: "x-json"}, rawJSON, minJSON}, + {media.Type{Type: "application/ld+json", MainType: "application", SubType: "ld+json"}, rawJSON, minJSON}, + {media.Type{Type: "application/json", MainType: "text", SubType: "json"}, rawJSON, minJSON}, } { var b bytes.Buffer c.Assert(m.Minify(test.tp, &b, strings.NewReader(test.rawString)), qt.IsNil) c.Assert(b.String(), qt.Equals, test.expectedMinString) } +} +func TestConfigureMinify(t *testing.T) { + c := qt.New(t) + v := config.New() + v.Set("minify", map[string]any{ + "disablexml": true, + "tdewolff": map[string]any{ + "html": map[string]any{ + "keepwhitespace": true, + }, + }, + }) + m, _ := minifiers.New(media.DefaultTypes, output.DefaultFormats, testconfig.GetTestConfig(afero.NewMemMapFs(), v)) + + for _, test := range []struct { + tp media.Type + rawString string + expectedMinString string + errorExpected bool + }{ + {media.Builtin.HTMLType, " Hugo! ", " Hugo! ", false}, // configured minifier + {media.Builtin.CSSType, " body { color: blue; } ", "body{color:blue}", false}, // default minifier + {media.Builtin.XMLType, " Hugo! ", " Hugo! ", false}, // disable Xml minification + } { + var b bytes.Buffer + if !test.errorExpected { + c.Assert(m.Minify(test.tp, &b, strings.NewReader(test.rawString)), qt.IsNil) + c.Assert(b.String(), qt.Equals, test.expectedMinString) + } else { + err := m.Minify(test.tp, &b, strings.NewReader(test.rawString)) + c.Assert(err, qt.ErrorMatches, "minifier does not exist for mimetype") + } + } } func TestJSONRoundTrip(t *testing.T) { c := qt.New(t) - m := New(media.DefaultTypes, output.DefaultFormats) + m, _ := minifiers.New(media.DefaultTypes, output.DefaultFormats, testconfig.GetTestConfig(nil, nil)) for _, test := range []string{`{ "glossary": { @@ -101,32 +134,83 @@ func TestJSONRoundTrip(t *testing.T) { }`} { var b bytes.Buffer - m1 := make(map[string]interface{}) - m2 := make(map[string]interface{}) + m1 := make(map[string]any) + m2 := make(map[string]any) c.Assert(json.Unmarshal([]byte(test), &m1), qt.IsNil) - c.Assert(m.Minify(media.JSONType, &b, strings.NewReader(test)), qt.IsNil) + c.Assert(m.Minify(media.Builtin.JSONType, &b, strings.NewReader(test)), qt.IsNil) c.Assert(json.Unmarshal(b.Bytes(), &m2), qt.IsNil) c.Assert(m1, qt.DeepEquals, m2) } - } func TestBugs(t *testing.T) { c := qt.New(t) - m := New(media.DefaultTypes, output.DefaultFormats) + v := config.New() + m, _ := minifiers.New(media.DefaultTypes, output.DefaultFormats, testconfig.GetTestConfig(nil, v)) for _, test := range []struct { tp media.Type rawString string expectedMinString string }{ - // https://github.com/gohugoio/hugo/issues/5506 - {media.CSSType, " body { color: rgba(000, 000, 000, 0.7); }", "body{color:rgba(0,0,0,.7)}"}, + // Issue 5506 + {media.Builtin.CSSType, " body { color: rgba(000, 000, 000, 0.7); }", "body{color:rgba(0,0,0,.7)}"}, + // Issue 8332 + {media.Builtin.HTMLType, " Tags", ` Tags`}, + // Issue #13082 + {media.Builtin.HTMLType, "", ``}, } { var b bytes.Buffer c.Assert(m.Minify(test.tp, &b, strings.NewReader(test.rawString)), qt.IsNil) c.Assert(b.String(), qt.Equals, test.expectedMinString) } - +} + +// Renamed to Precision in v2.7.0. Check that we support both. +func TestDecodeConfigDecimalIsNowPrecision(t *testing.T) { + c := qt.New(t) + v := config.New() + v.Set("minify", map[string]any{ + "disablexml": true, + "tdewolff": map[string]any{ + "css": map[string]any{ + "decimal": 3, + }, + "svg": map[string]any{ + "decimal": 3, + }, + }, + }) + + conf := testconfig.GetTestConfigs(nil, v).Base.Minify + + c.Assert(conf.Tdewolff.CSS.Precision, qt.Equals, 3) +} + +// Issue 9456 +func TestDecodeConfigKeepWhitespace(t *testing.T) { + c := qt.New(t) + v := config.New() + v.Set("minify", map[string]any{ + "tdewolff": map[string]any{ + "html": map[string]any{ + "keepEndTags": false, + }, + }, + }) + + conf := testconfig.GetTestConfigs(nil, v).Base.Minify + + c.Assert(conf.Tdewolff.HTML, qt.DeepEquals, + html.Minifier{ + KeepComments: false, + KeepSpecialComments: true, + KeepDefaultAttrVals: true, + KeepDocumentTags: true, + KeepEndTags: false, + KeepQuotes: false, + KeepWhitespace: false, + }, + ) } diff --git a/modules/client.go b/modules/client.go index 1aacc5aa7..a8998bb8d 100644 --- a/modules/client.go +++ b/modules/client.go @@ -18,33 +18,38 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" - "io/ioutil" "os" "os/exec" "path/filepath" - - "github.com/gohugoio/hugo/hugofs/files" - - "github.com/gohugoio/hugo/common/loggers" - + "regexp" "strings" "time" + "github.com/gohugoio/hugo/common/collections" + "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/common/hexec" + "github.com/gohugoio/hugo/common/loggers" "github.com/gohugoio/hugo/config" - "github.com/rogpeppe/go-internal/module" + hglob "github.com/gohugoio/hugo/hugofs/glob" + + "github.com/gobwas/glob" + + "github.com/gohugoio/hugo/hugofs" + + "github.com/gohugoio/hugo/hugofs/files" + + "golang.org/x/mod/module" "github.com/gohugoio/hugo/common/hugio" - "github.com/pkg/errors" "github.com/spf13/afero" ) -var ( - fileSeparator = string(os.PathSeparator) -) +var fileSeparator = string(os.PathSeparator) const ( goBinaryStatusOK goBinaryStatus = iota @@ -73,40 +78,33 @@ func NewClient(cfg ClientConfig) *Client { goModFilename = n } - env := os.Environ() - mcfg := cfg.ModuleConfig - - config.SetEnvVars(&env, - "PWD", cfg.WorkingDir, - "GO111MODULE", "on", - "GOPROXY", mcfg.Proxy, - "GOPRIVATE", mcfg.Private, - "GONOPROXY", mcfg.NoProxy) - - if cfg.CacheDir != "" { - // Module cache stored below $GOPATH/pkg - config.SetEnvVars(&env, "GOPATH", cfg.CacheDir) - - } - logger := cfg.Logger if logger == nil { - logger = loggers.NewWarningLogger() + logger = loggers.NewDefault() + } + + var noVendor glob.Glob + if cfg.ModuleConfig.NoVendor != "" { + noVendor, _ = hglob.GetGlob(hglob.NormalizePath(cfg.ModuleConfig.NoVendor)) } return &Client{ fs: fs, ccfg: cfg, logger: logger, - moduleConfig: mcfg, - environ: env, - GoModulesFilename: goModFilename} + noVendor: noVendor, + moduleConfig: cfg.ModuleConfig, + environ: cfg.toEnv(), + GoModulesFilename: goModFilename, + } } // Client contains most of the API provided by this package. type Client struct { fs afero.Fs - logger *loggers.Logger + logger loggers.Logger + + noVendor glob.Glob ccfg ClientConfig @@ -127,7 +125,7 @@ type Client struct { goBinaryStatus goBinaryStatus } -// Graph writes a module dependenchy graph to the given writer. +// Graph writes a module dependency graph to the given writer. func (c *Client) Graph(w io.Writer) error { mc, coll := c.collect(true) if coll.err != nil { @@ -138,10 +136,6 @@ func (c *Client) Graph(w io.Writer) error { continue } - prefix := "" - if module.Disabled() { - prefix = "DISABLED " - } dep := pathVersion(module.Owner()) + " " + pathVersion(module) if replace := module.Replace(); replace != nil { if replace.Version() != "" { @@ -150,9 +144,8 @@ func (c *Client) Graph(w io.Writer) error { // Local dir. dep += " => " + replace.Dir() } - } - fmt.Fprintln(w, prefix+dep) + fmt.Fprintln(w, dep) } return nil @@ -178,7 +171,8 @@ func (c *Client) Tidy() error { // // We, by default, use the /_vendor folder first, if found. To disable, // run with -// hugo --ignoreVendor +// +// hugo --ignoreVendorPaths=".*" // // Given a module tree, Hugo will pick the first module for a given path, // meaning that if the top-level module is vendored, that will be the full @@ -188,6 +182,9 @@ func (c *Client) Vendor() error { if err := c.rmVendorDir(vendorDir); err != nil { return err } + if err := c.fs.MkdirAll(vendorDir, 0o755); err != nil { + return err + } // Write the modules list to modules.txt. // @@ -210,20 +207,49 @@ func (c *Client) Vendor() error { // This is the project. continue } - // We respect the --ignoreVendor flag even for the vendor command. + + if !c.shouldVendor(t.Path()) { + continue + } + if !t.IsGoMod() && !t.Vendor() { // We currently do not vendor components living in the // theme directory, see https://github.com/gohugoio/hugo/issues/5993 continue } + // See https://github.com/gohugoio/hugo/issues/8239 + // This is an error situation. We need something to vendor. + if t.Mounts() == nil { + return fmt.Errorf("cannot vendor module %q, need at least one mount", t.Path()) + } + fmt.Fprintln(&modulesContent, "# "+t.Path()+" "+t.Version()) dir := t.Dir() for _, mount := range t.Mounts() { - if err := hugio.CopyDir(c.fs, filepath.Join(dir, mount.Source), filepath.Join(vendorDir, t.Path(), mount.Source), nil); err != nil { - return errors.Wrap(err, "failed to copy module to vendor dir") + sourceFilename := filepath.Join(dir, mount.Source) + targetFilename := filepath.Join(vendorDir, t.Path(), mount.Source) + fi, err := c.fs.Stat(sourceFilename) + if err != nil { + return fmt.Errorf("failed to vendor module: %w", err) + } + + if fi.IsDir() { + if err := hugio.CopyDir(c.fs, sourceFilename, targetFilename, nil); err != nil { + return fmt.Errorf("failed to copy module to vendor dir: %w", err) + } + } else { + targetDir := filepath.Dir(targetFilename) + + if err := c.fs.MkdirAll(targetDir, 0o755); err != nil { + return fmt.Errorf("failed to make target dir: %w", err) + } + + if err := hugio.CopyFile(c.fs, sourceFilename, targetFilename); err != nil { + return fmt.Errorf("failed to copy module file to vendor: %w", err) + } } } @@ -232,16 +258,27 @@ func (c *Client) Vendor() error { _, err := c.fs.Stat(resourcesDir) if err == nil { if err := hugio.CopyDir(c.fs, resourcesDir, filepath.Join(vendorDir, t.Path(), files.FolderResources), nil); err != nil { - return errors.Wrap(err, "failed to copy resources to vendor dir") + return fmt.Errorf("failed to copy resources to vendor dir: %w", err) } } - // Also include any theme.toml or config.* files in the root. + // Include the config directory if present. + configDir := filepath.Join(dir, "config") + _, err = c.fs.Stat(configDir) + if err == nil { + if err := hugio.CopyDir(c.fs, configDir, filepath.Join(vendorDir, t.Path(), "config"), nil); err != nil { + return fmt.Errorf("failed to copy config dir to vendor dir: %w", err) + } + } + + // Also include any theme.toml or config.* or hugo.* files in the root. configFiles, _ := afero.Glob(c.fs, filepath.Join(dir, "config.*")) + configFiles2, _ := afero.Glob(c.fs, filepath.Join(dir, "hugo.*")) + configFiles = append(configFiles, configFiles2...) configFiles = append(configFiles, filepath.Join(dir, "theme.toml")) for _, configFile := range configFiles { if err := hugio.CopyFile(c.fs, configFile, filepath.Join(vendorDir, t.Path(), filepath.Base(configFile))); err != nil { - if !os.IsNotExist(err) { + if !herrors.IsNotExist(err) { return err } } @@ -249,7 +286,7 @@ func (c *Client) Vendor() error { } if modulesContent.Len() > 0 { - if err := afero.WriteFile(c.fs, filepath.Join(vendorDir, vendorModulesFilename), modulesContent.Bytes(), 0666); err != nil { + if err := afero.WriteFile(c.fs, filepath.Join(vendorDir, vendorModulesFilename), modulesContent.Bytes(), 0o666); err != nil { return err } } @@ -259,16 +296,47 @@ func (c *Client) Vendor() error { // Get runs "go get" with the supplied arguments. func (c *Client) Get(args ...string) error { - if len(args) == 0 || (len(args) == 1 && args[0] == "-u") { + if len(args) == 0 || (len(args) == 1 && strings.Contains(args[0], "-u")) { update := len(args) != 0 + patch := update && (args[0] == "-u=patch") // // We need to be explicit about the modules to get. - for _, m := range c.moduleConfig.Imports { - var args []string - if update { - args = []string{"-u"} + var modules []string + // Update all active modules if the -u flag presents. + if update { + mc, coll := c.collect(true) + if coll.err != nil { + return coll.err } - args = append(args, m.Path) + for _, m := range mc.AllModules { + if m.Owner() == nil || !isProbablyModule(m.Path()) { + continue + } + modules = append(modules, m.Path()) + } + } else { + for _, m := range c.moduleConfig.Imports { + if !isProbablyModule(m.Path) { + // Skip themes/components stored below /themes etc. + // There may be false positives in the above, but those + // should be rare, and they will fail below with an + // "cannot find module providing ..." message. + continue + } + modules = append(modules, m.Path) + } + } + + for _, m := range modules { + var args []string + + if update && !patch { + args = append(args, "-u") + } else if update && patch { + args = append(args, "-u=patch") + } + args = append(args, m) + if err := c.get(args...); err != nil { return err } @@ -281,8 +349,8 @@ func (c *Client) Get(args ...string) error { } func (c *Client) get(args ...string) error { - if err := c.runGo(context.Background(), c.logger.Out, append([]string{"get"}, args...)...); err != nil { - errors.Wrapf(err, "failed to get %q", args) + if err := c.runGo(context.Background(), c.logger.StdOut(), append([]string{"get"}, args...)...); err != nil { + return fmt.Errorf("failed to get %q: %w", args, err) } return nil } @@ -291,9 +359,9 @@ func (c *Client) get(args ...string) error { // If path is empty, Go will try to guess. // If this succeeds, this project will be marked as Go Module. func (c *Client) Init(path string) error { - err := c.runGo(context.Background(), c.logger.Out, "mod", "init", path) + err := c.runGo(context.Background(), c.logger.StdOut(), "mod", "init", path) if err != nil { - return errors.Wrap(err, "failed to init modules") + return fmt.Errorf("failed to init modules: %w", err) } c.GoModulesFilename = filepath.Join(c.ccfg.WorkingDir, goModFilename) @@ -301,6 +369,67 @@ func (c *Client) Init(path string) error { return nil } +var verifyErrorDirRe = regexp.MustCompile(`dir has been modified \((.*?)\)`) + +// Verify checks that the dependencies of the current module, +// which are stored in a local downloaded source cache, have not been +// modified since being downloaded. +func (c *Client) Verify(clean bool) error { + // TODO(bep) add path to mod clean + err := c.runVerify() + if err != nil { + if clean { + m := verifyErrorDirRe.FindAllStringSubmatch(err.Error(), -1) + for i := range m { + c, err := hugofs.MakeReadableAndRemoveAllModulePkgDir(c.fs, m[i][1]) + if err != nil { + return err + } + fmt.Println("Cleaned", c) + } + // Try to verify it again. + err = c.runVerify() + } + } + return err +} + +func (c *Client) Clean(pattern string) error { + mods, err := c.listGoMods() + if err != nil { + return err + } + + var g glob.Glob + + if pattern != "" { + var err error + g, err = hglob.GetGlob(pattern) + if err != nil { + return err + } + } + + for _, m := range mods { + if m.Replace != nil || m.Main { + continue + } + + if g != nil && !g.Match(m.Path) { + continue + } + dirCount, err := hugofs.MakeReadableAndRemoveAllModulePkgDir(c.fs, m.Dir) + if err == nil { + c.logger.Printf("hugo: removed %d dirs in module cache for %q", dirCount, m.Path) + } + } + return err +} + +func (c *Client) runVerify() error { + return c.runGo(context.Background(), io.Discard, "mod", "verify") +} + func isProbablyModule(path string) bool { return module.CheckPath(path) == nil } @@ -310,35 +439,86 @@ func (c *Client) listGoMods() (goModules, error) { return nil, nil } - out := ioutil.Discard - err := c.runGo(context.Background(), out, "mod", "download") - if err != nil { - return nil, errors.Wrap(err, "failed to download modules") + downloadModules := func(modules ...string) error { + args := []string{"mod", "download", "-modcacherw"} + args = append(args, modules...) + out := io.Discard + err := c.runGo(context.Background(), out, args...) + if err != nil { + return fmt.Errorf("failed to download modules: %w", err) + } + return nil } - b := &bytes.Buffer{} - err = c.runGo(context.Background(), b, "list", "-m", "-json", "all") - if err != nil { - return nil, errors.Wrap(err, "failed to list modules") + if err := downloadModules(); err != nil { + return nil, err + } + + listAndDecodeModules := func(handle func(m *goModule) error, modules ...string) error { + b := &bytes.Buffer{} + args := []string{"list", "-m", "-json"} + if len(modules) > 0 { + args = append(args, modules...) + } else { + args = append(args, "all") + } + err := c.runGo(context.Background(), b, args...) + if err != nil { + return fmt.Errorf("failed to list modules: %w", err) + } + + dec := json.NewDecoder(b) + for { + m := &goModule{} + if err := dec.Decode(m); err != nil { + if err == io.EOF { + break + } + return fmt.Errorf("failed to decode modules list: %w", err) + } + + if err := handle(m); err != nil { + return err + } + } + return nil } var modules goModules - - dec := json.NewDecoder(b) - for { - m := &goModule{} - if err := dec.Decode(m); err != nil { - if err == io.EOF { - break - } - return nil, errors.Wrap(err, "failed to decode modules list") - } - + err := listAndDecodeModules(func(m *goModule) error { modules = append(modules, m) + return nil + }) + if err != nil { + return nil, err + } + + // From Go 1.17, go lazy loads transitive dependencies. + // That does not work for us. + // So, download these modules and update the Dir in the modules list. + var modulesToDownload []string + for _, m := range modules { + if m.Dir == "" { + modulesToDownload = append(modulesToDownload, fmt.Sprintf("%s@%s", m.Path, m.Version)) + } + } + + if len(modulesToDownload) > 0 { + if err := downloadModules(modulesToDownload...); err != nil { + return nil, err + } + err := listAndDecodeModules(func(m *goModule) error { + if mm := modules.GetByPath(m.Path); mm != nil { + mm.Dir = m.Dir + } + return nil + }, modulesToDownload...) + if err != nil { + return nil, err + } } return modules, err - } func (c *Client) rewriteGoMod(name string, isGoMod map[string]bool) error { @@ -347,7 +527,7 @@ func (c *Client) rewriteGoMod(name string, isGoMod map[string]bool) error { return err } if data != nil { - if err := afero.WriteFile(c.fs, filepath.Join(c.ccfg.WorkingDir, name), data, 0666); err != nil { + if err := afero.WriteFile(c.fs, filepath.Join(c.ccfg.WorkingDir, name), data, 0o666); err != nil { return err } } @@ -366,7 +546,7 @@ func (c *Client) rewriteGoModRewrite(name string, isGoMod map[string]bool) ([]by b := &bytes.Buffer{} f, err := c.fs.Open(filepath.Join(c.ccfg.WorkingDir, name)) if err != nil { - if os.IsNotExist(err) { + if herrors.IsNotExist(err) { // It's been deleted. return nil, nil } @@ -403,7 +583,6 @@ func (c *Client) rewriteGoModRewrite(name string, isGoMod map[string]bool) ([]by } return b.Bytes(), nil - } func (c *Client) rmVendorDir(vendorDir string) error { @@ -426,21 +605,25 @@ func (c *Client) rmVendorDir(vendorDir string) error { func (c *Client) runGo( ctx context.Context, stdout io.Writer, - args ...string) error { - + args ...string, +) error { if c.goBinaryStatus != 0 { return nil } - //defer c.logger.PrintTimer(time.Now(), fmt.Sprint(args)) - stderr := new(bytes.Buffer) - cmd := exec.CommandContext(ctx, "go", args...) - cmd.Env = c.environ - cmd.Dir = c.ccfg.WorkingDir - cmd.Stdout = stdout - cmd.Stderr = io.MultiWriter(stderr, os.Stderr) + argsv := collections.StringSliceToInterfaceSlice(args) + argsv = append(argsv, hexec.WithEnviron(c.environ)) + argsv = append(argsv, hexec.WithStderr(goOutputReplacerWriter{w: io.MultiWriter(stderr, os.Stderr)})) + argsv = append(argsv, hexec.WithStdout(stdout)) + argsv = append(argsv, hexec.WithDir(c.ccfg.WorkingDir)) + argsv = append(argsv, hexec.WithContext(ctx)) + + cmd, err := c.ccfg.Exec.New("go", argsv...) + if err != nil { + return err + } if err := cmd.Run(); err != nil { if ee, ok := err.(*exec.Error); ok && ee.Err == exec.ErrNotFound { @@ -450,12 +633,19 @@ func (c *Client) runGo( if strings.Contains(stderr.String(), "invalid version: unknown revision") { // See https://github.com/gohugoio/hugo/issues/6825 - c.logger.FEEDBACK.Println(`hugo: you need to manually edit go.mod to resolve the unknown revision.`) + c.logger.Println(`An unknown revision most likely means that someone has deleted the remote ref (e.g. with a force push to GitHub). +To resolve this, you need to manually edit your go.mod file and replace the version for the module in question with a valid ref. + +The easiest is to just enter a valid branch name there, e.g. master, which would be what you put in place of 'v0.5.1' in the example below. + +require github.com/gohugoio/hugo-mod-jslibs/instantpage v0.5.1 + +If you then run 'hugo mod graph' it should resolve itself to the most recent version (or commit if no semver versions are available).`) } _, ok := err.(*exec.ExitError) if !ok { - return errors.Errorf("failed to execute 'go %v': %s %T", args, err, err) + return fmt.Errorf("failed to execute 'go %v': %s %T", args, err, err) } // Too old Go version @@ -464,13 +654,31 @@ func (c *Client) runGo( return nil } - return errors.Errorf("go command failed: %s", stderr) + return fmt.Errorf("go command failed: %s", stderr) } return nil } +var goOutputReplacer = strings.NewReplacer( + "go: to add module requirements and sums:", "hugo: to add module requirements and sums:", + "go mod tidy", "hugo mod tidy", +) + +type goOutputReplacerWriter struct { + w io.Writer +} + +func (w goOutputReplacerWriter) Write(p []byte) (n int, err error) { + s := goOutputReplacer.Replace(string(p)) + _, err = w.w.Write([]byte(s)) + if err != nil { + return 0, err + } + return len(p), nil +} + func (c *Client) tidy(mods Modules, goModOnly bool) error { isGoMod := make(map[string]bool) for _, m := range mods { @@ -499,17 +707,43 @@ func (c *Client) tidy(mods Modules, goModOnly bool) error { return nil } +func (c *Client) shouldVendor(path string) bool { + return c.noVendor == nil || !c.noVendor.Match(path) +} + +func (c *Client) createThemeDirname(modulePath string, isProjectMod bool) (string, error) { + invalid := fmt.Errorf("invalid module path %q; must be relative to themesDir when defined outside of the project", modulePath) + + modulePath = filepath.Clean(modulePath) + if filepath.IsAbs(modulePath) { + if isProjectMod { + return modulePath, nil + } + return "", invalid + } + + moduleDir := filepath.Join(c.ccfg.ThemesDir, modulePath) + if !isProjectMod && !strings.HasPrefix(moduleDir, c.ccfg.ThemesDir) { + return "", invalid + } + return moduleDir, nil +} + // ClientConfig configures the module Client. type ClientConfig struct { Fs afero.Fs - Logger *loggers.Logger + Logger loggers.Logger // If set, it will be run before we do any duplicate checks for modules // etc. HookBeforeFinalize func(m *ModulesConfig) error - // Ignore any _vendor directory. - IgnoreVendor bool + // Ignore any _vendor directory for module paths matching the given pattern. + // This can be nil. + IgnoreVendor glob.Glob + + // Ignore any module not found errors. + IgnoreModuleDoesNotExist bool // Absolute path to the project dir. WorkingDir string @@ -517,10 +751,53 @@ type ClientConfig struct { // Absolute path to the project's themes dir. ThemesDir string + // The publish dir. + PublishDir string + + // Eg. "production" + Environment string + + Exec *hexec.Exec + CacheDir string // Module cache ModuleConfig Config } +func (c ClientConfig) shouldIgnoreVendor(path string) bool { + return c.IgnoreVendor != nil && c.IgnoreVendor.Match(path) +} + +func (cfg ClientConfig) toEnv() []string { + mcfg := cfg.ModuleConfig + var env []string + keyVals := []string{ + "PWD", cfg.WorkingDir, + "GO111MODULE", "on", + "GOPATH", cfg.CacheDir, + "GOWORK", mcfg.Workspace, // Requires Go 1.18, see https://tip.golang.org/doc/go1.18 + // GOCACHE was introduced in Go 1.15. This matches the location derived from GOPATH above. + "GOCACHE", filepath.Join(cfg.CacheDir, "pkg", "mod"), + } + + if mcfg.Proxy != "" { + keyVals = append(keyVals, "GOPROXY", mcfg.Proxy) + } + if mcfg.Private != "" { + keyVals = append(keyVals, "GOPRIVATE", mcfg.Private) + } + if mcfg.NoProxy != "" { + keyVals = append(keyVals, "GONOPROXY", mcfg.NoProxy) + } + if mcfg.Auth != "" { + // GOAUTH was introduced in Go 1.24, see https://tip.golang.org/doc/go1.24. + keyVals = append(keyVals, "GOAUTH", mcfg.Auth) + } + + config.SetEnvVars(&env, keyVals...) + + return env +} + type goBinaryStatus int type goModule struct { diff --git a/modules/client_test.go b/modules/client_test.go index 07b71c409..1b4b1161a 100644 --- a/modules/client_test.go +++ b/modules/client_test.go @@ -15,9 +15,16 @@ package modules import ( "bytes" + "fmt" + "os" + "path/filepath" + "sync/atomic" "testing" - "github.com/gohugoio/hugo/common/hugo" + "github.com/gohugoio/hugo/common/hexec" + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/config/security" + "github.com/gohugoio/hugo/hugofs/glob" "github.com/gohugoio/hugo/htesting" @@ -27,82 +34,177 @@ import ( ) func TestClient(t *testing.T) { - if hugo.GoMinorVersion() < 12 { - // https://github.com/golang/go/issues/26794 - // There were some concurrent issues with Go modules in < Go 12. - t.Skip("skip this for Go <= 1.11 due to a bug in Go's stdlib") - } - - t.Parallel() - modName := "hugo-modules-basic-test" modPath := "github.com/gohugoio/tests/" + modName - modConfig := DefaultModuleConfig - modConfig.Imports = []Import{Import{Path: "github.com/gohugoio/hugoTestModules1_darwin/modh2_2"}} - - c := qt.New(t) - - workingDir, clean, err := htesting.CreateTempDir(hugofs.Os, modName) - c.Assert(err, qt.IsNil) - defer clean() - - client := NewClient(ClientConfig{ - Fs: hugofs.Os, - WorkingDir: workingDir, - ModuleConfig: modConfig, - }) - - // Test Init - c.Assert(client.Init(modPath), qt.IsNil) - - // Test Collect - mc, err := client.Collect() - c.Assert(err, qt.IsNil) - c.Assert(len(mc.AllModules), qt.Equals, 4) - for _, m := range mc.AllModules { - c.Assert(m, qt.Not(qt.IsNil)) - } - - // Test Graph - var graphb bytes.Buffer - c.Assert(client.Graph(&graphb), qt.IsNil) - + defaultImport := "modh2_2" expect := `github.com/gohugoio/tests/hugo-modules-basic-test github.com/gohugoio/hugoTestModules1_darwin/modh2_2@v1.4.0 github.com/gohugoio/hugoTestModules1_darwin/modh2_2@v1.4.0 github.com/gohugoio/hugoTestModules1_darwin/modh2_2_1v@v1.3.0 github.com/gohugoio/hugoTestModules1_darwin/modh2_2@v1.4.0 github.com/gohugoio/hugoTestModules1_darwin/modh2_2_2@v1.3.0 ` - c.Assert(graphb.String(), qt.Equals, expect) + c := qt.New(t) + var clientID uint64 // we increment this to get each test in its own directory. - // Test Vendor - c.Assert(client.Vendor(), qt.IsNil) - graphb.Reset() - c.Assert(client.Graph(&graphb), qt.IsNil) - expectVendored := `project github.com/gohugoio/hugoTestModules1_darwin/modh2_2@v1.4.0+vendor + newClient := func(c *qt.C, withConfig func(cfg *ClientConfig), imp string) (*Client, func()) { + atomic.AddUint64(&clientID, uint64(1)) + workingDir, clean, err := htesting.CreateTempDir(hugofs.Os, fmt.Sprintf("%s-%d", modName, clientID)) + c.Assert(err, qt.IsNil) + themesDir := filepath.Join(workingDir, "themes") + err = os.Mkdir(themesDir, 0o777) + c.Assert(err, qt.IsNil) + publishDir := filepath.Join(workingDir, "public") + err = os.Mkdir(publishDir, 0o777) + c.Assert(err, qt.IsNil) + + ccfg := ClientConfig{ + Fs: hugofs.Os, + CacheDir: filepath.Join(workingDir, "modcache"), + WorkingDir: workingDir, + ThemesDir: themesDir, + PublishDir: publishDir, + Exec: hexec.New(security.DefaultConfig, "", loggers.NewDefault()), + } + + withConfig(&ccfg) + ccfg.ModuleConfig.Imports = []Import{{Path: "github.com/gohugoio/hugoTestModules1_darwin/" + imp}} + client := NewClient(ccfg) + + return client, clean + } + + c.Run("All", func(c *qt.C) { + client, clean := newClient(c, func(cfg *ClientConfig) { + cfg.ModuleConfig = DefaultModuleConfig + }, defaultImport) + defer clean() + + // Test Init + c.Assert(client.Init(modPath), qt.IsNil) + + // Test Collect + mc, err := client.Collect() + c.Assert(err, qt.IsNil) + c.Assert(len(mc.AllModules), qt.Equals, 4) + for _, m := range mc.AllModules { + c.Assert(m, qt.Not(qt.IsNil)) + } + + // Test Graph + var graphb bytes.Buffer + c.Assert(client.Graph(&graphb), qt.IsNil) + + c.Assert(graphb.String(), qt.Equals, expect) + + // Test Vendor + c.Assert(client.Vendor(), qt.IsNil) + graphb.Reset() + c.Assert(client.Graph(&graphb), qt.IsNil) + + expectVendored := `project github.com/gohugoio/hugoTestModules1_darwin/modh2_2@v1.4.0+vendor project github.com/gohugoio/hugoTestModules1_darwin/modh2_2_1v@v1.3.0+vendor project github.com/gohugoio/hugoTestModules1_darwin/modh2_2_2@v1.3.0+vendor ` - c.Assert(graphb.String(), qt.Equals, expectVendored) - // Test the ignoreVendor setting - clientIgnoreVendor := NewClient(ClientConfig{ - Fs: hugofs.Os, - WorkingDir: workingDir, - ModuleConfig: modConfig, - IgnoreVendor: true, + c.Assert(graphb.String(), qt.Equals, expectVendored) + + // Test Tidy + c.Assert(client.Tidy(), qt.IsNil) }) - graphb.Reset() - c.Assert(clientIgnoreVendor.Graph(&graphb), qt.IsNil) - c.Assert(graphb.String(), qt.Equals, expect) + c.Run("IgnoreVendor", func(c *qt.C) { + client, clean := newClient( + c, func(cfg *ClientConfig) { + cfg.ModuleConfig = DefaultModuleConfig + cfg.IgnoreVendor = globAll + }, defaultImport) + defer clean() - // Test Tidy - c.Assert(client.Tidy(), qt.IsNil) + c.Assert(client.Init(modPath), qt.IsNil) + _, err := client.Collect() + c.Assert(err, qt.IsNil) + c.Assert(client.Vendor(), qt.IsNil) + var graphb bytes.Buffer + c.Assert(client.Graph(&graphb), qt.IsNil) + c.Assert(graphb.String(), qt.Equals, expect) + }) + + c.Run("NoVendor", func(c *qt.C) { + mcfg := DefaultModuleConfig + mcfg.NoVendor = "**" + client, clean := newClient( + c, func(cfg *ClientConfig) { + cfg.ModuleConfig = mcfg + }, defaultImport) + defer clean() + + c.Assert(client.Init(modPath), qt.IsNil) + _, err := client.Collect() + c.Assert(err, qt.IsNil) + c.Assert(client.Vendor(), qt.IsNil) + + var graphb bytes.Buffer + c.Assert(client.Graph(&graphb), qt.IsNil) + c.Assert(graphb.String(), qt.Equals, expect) + }) + + c.Run("VendorClosest", func(c *qt.C) { + mcfg := DefaultModuleConfig + mcfg.VendorClosest = true + + client, clean := newClient( + c, func(cfg *ClientConfig) { + cfg.ModuleConfig = mcfg + s := "github.com/gohugoio/hugoTestModules1_darwin/modh1_1v" + g, _ := glob.GetGlob(s) + cfg.IgnoreVendor = g + }, "modh1v") + defer clean() + + c.Assert(client.Init(modPath), qt.IsNil) + _, err := client.Collect() + c.Assert(err, qt.IsNil) + c.Assert(client.Vendor(), qt.IsNil) + + var graphb bytes.Buffer + c.Assert(client.Graph(&graphb), qt.IsNil) + + c.Assert(graphb.String(), qt.Contains, "github.com/gohugoio/hugoTestModules1_darwin/modh1_1v@v1.3.0 github.com/gohugoio/hugoTestModules1_darwin/modh1_1_1v@v1.1.0+vendor") + }) + + // https://github.com/gohugoio/hugo/issues/7908 + c.Run("createThemeDirname", func(c *qt.C) { + mcfg := DefaultModuleConfig + client, clean := newClient( + c, func(cfg *ClientConfig) { + cfg.ModuleConfig = mcfg + }, defaultImport) + defer clean() + + dirname, err := client.createThemeDirname("foo", false) + c.Assert(err, qt.IsNil) + c.Assert(dirname, qt.Equals, filepath.Join(client.ccfg.ThemesDir, "foo")) + + dirname, err = client.createThemeDirname("../../foo", true) + c.Assert(err, qt.IsNil) + c.Assert(dirname, qt.Equals, filepath.Join(client.ccfg.ThemesDir, "../../foo")) + + _, err = client.createThemeDirname("../../foo", false) + c.Assert(err, qt.Not(qt.IsNil)) + + absDir := filepath.Join(client.ccfg.WorkingDir, "..", "..") + dirname, err = client.createThemeDirname(absDir, true) + c.Assert(err, qt.IsNil) + c.Assert(dirname, qt.Equals, absDir) + dirname, err = client.createThemeDirname(absDir, false) + fmt.Println(dirname) + c.Assert(err, qt.Not(qt.IsNil)) + }) } -func TestGetModlineSplitter(t *testing.T) { +var globAll, _ = glob.GetGlob("**") +func TestGetModlineSplitter(t *testing.T) { c := qt.New(t) gomodSplitter := getModlineSplitter(true) @@ -113,5 +215,43 @@ func TestGetModlineSplitter(t *testing.T) { gosumSplitter := getModlineSplitter(false) c.Assert(gosumSplitter("github.com/BurntSushi/toml v0.3.1"), qt.DeepEquals, []string{"github.com/BurntSushi/toml", "v0.3.1"}) - +} + +func TestClientConfigToEnv(t *testing.T) { + c := qt.New(t) + + ccfg := ClientConfig{ + WorkingDir: "/mywork", + CacheDir: "/mycache", + } + + env := ccfg.toEnv() + + c.Assert(env, qt.DeepEquals, []string{"PWD=/mywork", "GO111MODULE=on", "GOPATH=/mycache", "GOWORK=", filepath.FromSlash("GOCACHE=/mycache/pkg/mod")}) + + ccfg = ClientConfig{ + WorkingDir: "/mywork", + CacheDir: "/mycache", + ModuleConfig: Config{ + Proxy: "https://proxy.example.org", + Private: "myprivate", + NoProxy: "mynoproxy", + Workspace: "myworkspace", + Auth: "myauth", + }, + } + + env = ccfg.toEnv() + + c.Assert(env, qt.DeepEquals, []string{ + "PWD=/mywork", + "GO111MODULE=on", + "GOPATH=/mycache", + "GOWORK=myworkspace", + filepath.FromSlash("GOCACHE=/mycache/pkg/mod"), + "GOPROXY=https://proxy.example.org", + "GOPRIVATE=myprivate", + "GONOPROXY=mynoproxy", + "GOAUTH=myauth", + }) } diff --git a/modules/collect.go b/modules/collect.go index 0ac766fb9..7034a6b16 100644 --- a/modules/collect.go +++ b/modules/collect.go @@ -15,14 +15,19 @@ package modules import ( "bufio" + "errors" "fmt" + "io/fs" "os" "path/filepath" + "regexp" "strings" "time" "github.com/bep/debounce" + "github.com/gohugoio/hugo/common/herrors" "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/common/paths" "github.com/spf13/cast" @@ -33,9 +38,7 @@ import ( "github.com/gohugoio/hugo/hugofs/files" - "github.com/rogpeppe/go-internal/module" - - "github.com/pkg/errors" + "golang.org/x/mod/module" "github.com/gohugoio/hugo/config" "github.com/spf13/afero" @@ -45,25 +48,6 @@ var ErrNotExist = errors.New("module does not exist") const vendorModulesFilename = "modules.txt" -// IsNotExist returns whether an error means that a module could not be found. -func IsNotExist(err error) bool { - return errors.Cause(err) == ErrNotExist -} - -// CreateProjectModule creates modules from the given config. -// This is used in tests only. -func CreateProjectModule(cfg config.Provider) (Module, error) { - workingDir := cfg.GetString("workingDir") - var modConfig Config - - mod := createProjectModule(nil, workingDir, modConfig) - if err := ApplyProjectConfigDefaults(cfg, mod); err != nil { - return nil, err - } - - return mod, nil -} - func (h *Client) Collect() (ModulesConfig, error) { mc, coll := h.collect(true) if coll.err != nil { @@ -88,6 +72,9 @@ func (h *Client) Collect() (ModulesConfig, error) { } func (h *Client) collect(tidy bool) (ModulesConfig, *collector) { + if h == nil { + panic("nil client") + } c := &collector{ Client: h, } @@ -105,41 +92,49 @@ func (h *Client) collect(tidy bool) (ModulesConfig, *collector) { } }*/ - return ModulesConfig{ - AllModules: c.modules, - GoModulesFilename: c.GoModulesFilename, - }, c + var workspaceFilename string + if h.ccfg.ModuleConfig.Workspace != WorkspaceDisabled { + workspaceFilename = h.ccfg.ModuleConfig.Workspace + } + return ModulesConfig{ + AllModules: c.modules, + GoModulesFilename: c.GoModulesFilename, + GoWorkspaceFilename: workspaceFilename, + }, c } type ModulesConfig struct { - // All modules, including any disabled. - AllModules Modules - // All active modules. - ActiveModules Modules + AllModules Modules // Set if this is a Go modules enabled project. GoModulesFilename string + + // Set if a Go workspace file is configured. + GoWorkspaceFilename string } -func (m *ModulesConfig) setActiveMods(logger *loggers.Logger) error { - var activeMods Modules +func (m ModulesConfig) HasConfigFile() bool { for _, mod := range m.AllModules { - if !mod.Config().HugoVersion.IsValid() { - logger.WARN.Printf(`Module %q is not compatible with this Hugo version; run "hugo mod graph" for more information.`, mod.Path()) - } - if !mod.Disabled() { - activeMods = append(activeMods, mod) + if len(mod.ConfigFilenames()) > 0 { + return true } } + return false +} - m.ActiveModules = activeMods +func (m *ModulesConfig) setActiveMods(logger loggers.Logger) error { + for _, mod := range m.AllModules { + if !mod.Config().HugoVersion.IsValid() { + logger.Warnf(`Module %q is not compatible with this Hugo version: %s; run "hugo mod graph" for more information.`, mod.Path(), mod.Config().HugoVersion) + } + } return nil } -func (m *ModulesConfig) finalize(logger *loggers.Logger) error { +func (m *ModulesConfig) finalize(logger loggers.Logger) error { for _, mod := range m.AllModules { m := mod.(*moduleAdapter) m.mounts = filterUnwantedMounts(m.mounts) @@ -149,13 +144,13 @@ func (m *ModulesConfig) finalize(logger *loggers.Logger) error { func filterUnwantedMounts(mounts []Mount) []Mount { // Remove duplicates - seen := make(map[Mount]bool) + seen := make(map[string]bool) tmp := mounts[:0] for _, m := range mounts { - if !seen[m] { + if !seen[m.key()] { tmp = append(tmp, m) } - seen[m] = true + seen[m.key()] = true } return tmp } @@ -196,7 +191,8 @@ func (c *collector) initModules() error { gomods: goModules{}, } - if !c.ccfg.IgnoreVendor && c.isVendored(c.ccfg.WorkingDir) { + // If both these are true, we don't even need Go installed to build. + if c.ccfg.IgnoreVendor == nil && c.isVendored(c.ccfg.WorkingDir) { return nil } @@ -218,7 +214,7 @@ func (c *collector) getVendoredDir(path string) (vendoredModule, bool) { return v, found } -func (c *collector) add(owner *moduleAdapter, moduleImport Import, disabled bool) (*moduleAdapter, error) { +func (c *collector) add(owner *moduleAdapter, moduleImport Import) (*moduleAdapter, error) { var ( mod *goModule moduleDir string @@ -229,7 +225,7 @@ func (c *collector) add(owner *moduleAdapter, moduleImport Import, disabled bool modulePath := moduleImport.Path var realOwner Module = owner - if !c.ccfg.IgnoreVendor { + if !c.ccfg.shouldIgnoreVendor(modulePath) { if err := c.collectModulesTXT(owner); err != nil { return nil, err } @@ -251,15 +247,25 @@ func (c *collector) add(owner *moduleAdapter, moduleImport Import, disabled bool } if moduleDir == "" { + var versionQuery string mod = c.gomods.GetByPath(modulePath) if mod != nil { moduleDir = mod.Dir + versionQuery = mod.Version } if moduleDir == "" { if c.GoModulesFilename != "" && isProbablyModule(modulePath) { // Try to "go get" it and reload the module configuration. - if err := c.Get(modulePath); err != nil { + if versionQuery == "" { + // See https://golang.org/ref/mod#version-queries + // This will select the latest release-version (not beta etc.). + versionQuery = "upgrade" + } + + // Note that we cannot use c.Get for this, as that may + // trigger a new module collection and potentially create a infinite loop. + if err := c.get(fmt.Sprintf("%s@%s", modulePath, versionQuery)); err != nil { return nil, err } if err := c.loadModules(); err != nil { @@ -272,12 +278,17 @@ func (c *collector) add(owner *moduleAdapter, moduleImport Import, disabled bool } } - // Fall back to /themes/ + // Fall back to project/themes/ if moduleDir == "" { - moduleDir = filepath.Join(c.ccfg.ThemesDir, modulePath) - + var err error + moduleDir, err = c.createThemeDirname(modulePath, owner.projectMod || moduleImport.pathProjectReplaced) + if err != nil { + c.err = err + return nil, nil + } if found, _ := afero.Exists(c.fs, moduleDir); !found { - c.err = c.wrapModuleNotFound(errors.Errorf(`module %q not found; either add it as a Hugo Module or store it in %q.`, modulePath, c.ccfg.ThemesDir)) + //lint:ignore ST1005 end user message. + c.err = c.wrapModuleNotFound(fmt.Errorf(`module %q not found in %q; either add it as a Hugo Module or store it in %q.`, modulePath, moduleDir, c.ccfg.ThemesDir)) return nil, nil } } @@ -285,7 +296,7 @@ func (c *collector) add(owner *moduleAdapter, moduleImport Import, disabled bool } if found, _ := afero.Exists(c.fs, moduleDir); !found { - c.err = c.wrapModuleNotFound(errors.Errorf("%q not found", moduleDir)) + c.err = c.wrapModuleNotFound(fmt.Errorf("%q not found", moduleDir)) return nil, nil } @@ -294,11 +305,10 @@ func (c *collector) add(owner *moduleAdapter, moduleImport Import, disabled bool } ma := &moduleAdapter{ - dir: moduleDir, - vendor: vendored, - disabled: disabled, - gomod: mod, - version: version, + dir: moduleDir, + vendor: vendored, + gomod: mod, + version: version, // This may be the owner of the _vendor dir owner: realOwner, } @@ -319,29 +329,29 @@ func (c *collector) add(owner *moduleAdapter, moduleImport Import, disabled bool c.modules = append(c.modules, ma) return ma, nil - } -func (c *collector) addAndRecurse(owner *moduleAdapter, disabled bool) error { +func (c *collector) addAndRecurse(owner *moduleAdapter) error { moduleConfig := owner.Config() if owner.projectMod { if err := c.applyMounts(Import{}, owner); err != nil { - return err + return fmt.Errorf("failed to apply mounts for project: %w", err) } } for _, moduleImport := range moduleConfig.Imports { - disabled := disabled || moduleImport.Disable - + if moduleImport.Disable { + continue + } if !c.isSeen(moduleImport.Path) { - tc, err := c.add(owner, moduleImport, disabled) + tc, err := c.add(owner, moduleImport) if err != nil { return err } - if tc == nil { + if tc == nil || moduleImport.IgnoreImports { continue } - if err := c.addAndRecurse(tc, disabled); err != nil { + if err := c.addAndRecurse(tc); err != nil { return err } } @@ -350,6 +360,11 @@ func (c *collector) addAndRecurse(owner *moduleAdapter, disabled bool) error { } func (c *collector) applyMounts(moduleImport Import, mod *moduleAdapter) error { + if moduleImport.NoMounts { + mod.mounts = nil + return nil + } + mounts := moduleImport.Mounts modConfig := mod.Config() @@ -357,7 +372,6 @@ func (c *collector) applyMounts(moduleImport Import, mod *moduleAdapter) error { if len(mounts) == 0 { // Mounts not defined by the import. mounts = modConfig.Mounts - } if !mod.projectMod && len(mounts) == 0 { @@ -381,26 +395,31 @@ func (c *collector) applyMounts(moduleImport Import, mod *moduleAdapter) error { return err } + mounts, err = c.mountCommonJSConfig(mod, mounts) + if err != nil { + return err + } + mod.mounts = mounts return nil } func (c *collector) applyThemeConfig(tc *moduleAdapter) error { - var ( configFilename string - cfg config.Provider - themeCfg map[string]interface{} - hasConfig bool + themeCfg map[string]any + hasConfigFile bool err error ) - // Viper supports more, but this is the sub-set supported by Hugo. - for _, configFormats := range config.ValidConfigFileExtensions { - configFilename = filepath.Join(tc.Dir(), "config."+configFormats) - hasConfig, _ = afero.Exists(c.fs, configFilename) - if hasConfig { - break +LOOP: + for _, configBaseName := range config.DefaultConfigNames { + for _, configFormats := range config.ValidConfigFileExtensions { + configFilename = filepath.Join(tc.Dir(), configBaseName+"."+configFormats) + hasConfigFile, _ = afero.Exists(c.fs, configFilename) + if hasConfigFile { + break LOOP + } } } @@ -415,26 +434,44 @@ func (c *collector) applyThemeConfig(tc *moduleAdapter) error { } themeCfg, err = metadecoders.Default.UnmarshalToMap(data, metadecoders.TOML) if err != nil { - c.logger.WARN.Printf("Failed to read module config for %q in %q: %s", tc.Path(), themeTOML, err) + c.logger.Warnf("Failed to read module config for %q in %q: %s", tc.Path(), themeTOML, err) } else { - maps.ToLower(themeCfg) + maps.PrepareParams(themeCfg) } } - if hasConfig { + if hasConfigFile { if configFilename != "" { var err error - cfg, err = config.FromFile(c.fs, configFilename) + tc.cfg, err = config.FromFile(c.fs, configFilename) if err != nil { - return errors.Wrapf(err, "failed to read module config for %q in %q", tc.Path(), configFilename) + return err } } - tc.configFilename = configFilename - tc.cfg = cfg + tc.configFilenames = append(tc.configFilenames, configFilename) + } - config, err := DecodeConfig(cfg) + // Also check for a config dir, which we overlay on top of the file configuration. + configDir := filepath.Join(tc.Dir(), "config") + dcfg, dirnames, err := config.LoadConfigFromDir(c.fs, configDir, c.ccfg.Environment) + if err != nil { + return err + } + + if len(dirnames) > 0 { + tc.configFilenames = append(tc.configFilenames, dirnames...) + + if hasConfigFile { + // Set will overwrite existing keys. + tc.cfg.Set("", dcfg.Get("")) + } else { + tc.cfg = dcfg + } + } + + config, err := decodeConfig(tc.cfg, c.moduleConfig.replacementsMap) if err != nil { return err } @@ -451,7 +488,7 @@ func (c *collector) applyThemeConfig(tc *moduleAdapter) error { } if config.Params == nil { - config.Params = make(map[string]interface{}) + config.Params = make(map[string]any) } for k, v := range themeCfg { @@ -466,14 +503,13 @@ func (c *collector) applyThemeConfig(tc *moduleAdapter) error { tc.config = config return nil - } func (c *collector) collect() { defer c.logger.PrintTimerIfDelayed(time.Now(), "hugo: collected modules") d := debounce.New(2 * time.Second) d(func() { - c.logger.FEEDBACK.Println("hugo: downloading modules …") + c.logger.Println("hugo: downloading modules …") }) defer d(func() {}) @@ -484,14 +520,13 @@ func (c *collector) collect() { projectMod := createProjectModule(c.gomods.GetMain(), c.ccfg.WorkingDir, c.moduleConfig) - if err := c.addAndRecurse(projectMod, false); err != nil { + if err := c.addAndRecurse(projectMod); err != nil { c.err = err return } // Add the project mod on top. c.modules = append(Modules{projectMod}, c.modules...) - } func (c *collector) isVendored(dir string) bool { @@ -504,9 +539,8 @@ func (c *collector) collectModulesTXT(owner Module) error { filename := filepath.Join(vendorDir, vendorModulesFilename) f, err := c.fs.Open(filename) - if err != nil { - if os.IsNotExist(err) { + if herrors.IsNotExist(err) { return nil } @@ -522,12 +556,24 @@ func (c *collector) collectModulesTXT(owner Module) error { line := scanner.Text() line = strings.Trim(line, "# ") line = strings.TrimSpace(line) + if line == "" { + continue + } parts := strings.Fields(line) if len(parts) != 2 { - return errors.Errorf("invalid modules list: %q", filename) + return fmt.Errorf("invalid modules list: %q", filename) } path := parts[0] - if _, found := c.vendored[path]; !found { + + shouldAdd := c.Client.moduleConfig.VendorClosest + + if !shouldAdd { + if _, found := c.vendored[path]; !found { + shouldAdd = true + } + } + + if shouldAdd { c.vendored[path] = vendoredModule{ Owner: owner, Dir: filepath.Join(vendorDir, path), @@ -548,6 +594,48 @@ func (c *collector) loadModules() error { return nil } +// Matches postcss.config.js etc. +var commonJSConfigs = regexp.MustCompile(`(babel|postcss|tailwind)\.config\.js`) + +func (c *collector) mountCommonJSConfig(owner *moduleAdapter, mounts []Mount) ([]Mount, error) { + for _, m := range mounts { + if strings.HasPrefix(m.Target, files.JsConfigFolderMountPrefix) { + // This follows the convention of the other component types (assets, content, etc.), + // if one or more is specified by the user, we skip the defaults. + // These mounts were added to Hugo in 0.75. + return mounts, nil + } + } + + // Mount the common JS config files. + d, err := c.fs.Open(owner.Dir()) + if err != nil { + return mounts, fmt.Errorf("failed to open dir %q: %q", owner.Dir(), err) + } + defer d.Close() + fis, err := d.(fs.ReadDirFile).ReadDir(-1) + if err != nil { + return mounts, fmt.Errorf("failed to read dir %q: %q", owner.Dir(), err) + } + + for _, fi := range fis { + n := fi.Name() + + should := n == files.FilenamePackageHugoJSON || n == files.FilenamePackageJSON + should = should || commonJSConfigs.MatchString(n) + + if should { + mounts = append(mounts, Mount{ + Source: n, + Target: filepath.Join(files.ComponentFolderAssets, files.FolderJSConfig, n), + }) + } + + } + + return mounts, nil +} + func (c *collector) normalizeMounts(owner *moduleAdapter, mounts []Mount) ([]Mount, error) { var out []Mount dir := owner.Dir() @@ -561,7 +649,6 @@ func (c *collector) normalizeMounts(owner *moduleAdapter, mounts []Mount) ([]Mou mnt.Source = filepath.Clean(mnt.Source) mnt.Target = filepath.Clean(mnt.Target) - var sourceDir string if owner.projectMod && filepath.IsAbs(mnt.Source) { @@ -574,7 +661,28 @@ func (c *collector) normalizeMounts(owner *moduleAdapter, mounts []Mount) ([]Mou // Verify that Source exists _, err := c.fs.Stat(sourceDir) if err != nil { - continue + if paths.IsSameFilePath(sourceDir, c.ccfg.PublishDir) { + // This is a little exotic, but there are use cases for mounting the public folder. + // This will typically also be in .gitingore, so create it. + if err := c.fs.MkdirAll(sourceDir, 0o755); err != nil { + return nil, fmt.Errorf("%s: %q", errMsg, err) + } + } else if strings.HasSuffix(sourceDir, files.FilenameHugoStatsJSON) { + // A common pattern for Tailwind 3 is to mount that file to get it on the server watch list. + + // A common pattern is also to add hugo_stats.json to .gitignore. + + // Create an empty file. + f, err := c.fs.Create(sourceDir) + if err != nil { + return nil, fmt.Errorf("%s: %q", errMsg, err) + } + f.Close() + } else { + // TODO(bep) commenting out for now, as this will create to much noise. + // c.logger.Warnf("module %q: mount source %q does not exist", owner.Path(), sourceDir) + continue + } } // Verify that target points to one of the predefined component dirs @@ -584,7 +692,7 @@ func (c *collector) normalizeMounts(owner *moduleAdapter, mounts []Mount) ([]Mou targetBase = mnt.Target[0:idxPathSep] } if !files.IsComponentFolder(targetBase) { - return nil, errors.Errorf("%s: mount target must be one of: %v", errMsg, files.ComponentFolders) + return nil, fmt.Errorf("%s: mount target must be one of: %v", errMsg, files.ComponentFolders) } out = append(out, mnt) @@ -594,7 +702,10 @@ func (c *collector) normalizeMounts(owner *moduleAdapter, mounts []Mount) ([]Mou } func (c *collector) wrapModuleNotFound(err error) error { - err = errors.Wrap(ErrNotExist, err.Error()) + if c.Client.ccfg.IgnoreModuleDoesNotExist { + return nil + } + err = fmt.Errorf(err.Error()+": %w", ErrNotExist) if c.GoModulesFilename == "" { return err } @@ -603,13 +714,12 @@ func (c *collector) wrapModuleNotFound(err error) error { switch c.goBinaryStatus { case goBinaryStatusNotFound: - return errors.Wrap(err, baseMsg+" you need to install Go to use it. See https://golang.org/dl/.") + return fmt.Errorf(baseMsg+" you need to install Go to use it. See https://golang.org/dl/ : %q", err) case goBinaryStatusTooOld: - return errors.Wrap(err, baseMsg+" you need to a newer version of Go to use it. See https://golang.org/dl/.") + return fmt.Errorf(baseMsg+" you need to a newer version of Go to use it. See https://golang.org/dl/ : %w", err) } return err - } type vendoredModule struct { @@ -632,7 +742,6 @@ func createProjectModule(gomod *goModule, workingDir string, conf Config) *modul projectMod: true, config: conf, } - } // In the first iteration of Hugo Modules, we do not support multiple diff --git a/modules/collect_test.go b/modules/collect_test.go index 7f320f40a..9487c0a0e 100644 --- a/modules/collect_test.go +++ b/modules/collect_test.go @@ -34,21 +34,18 @@ func TestPathKey(t *testing.T) { } { c.Assert(pathKey(test.in), qt.Equals, test.expect) } - } func TestFilterUnwantedMounts(t *testing.T) { - mounts := []Mount{ - Mount{Source: "a", Target: "b", Lang: "en"}, - Mount{Source: "a", Target: "b", Lang: "en"}, - Mount{Source: "b", Target: "c", Lang: "en"}, + {Source: "a", Target: "b", Lang: "en"}, + {Source: "a", Target: "b", Lang: "en"}, + {Source: "b", Target: "c", Lang: "en"}, } filtered := filterUnwantedMounts(mounts) c := qt.New(t) c.Assert(len(filtered), qt.Equals, 2) - c.Assert(filtered, qt.DeepEquals, []Mount{Mount{Source: "a", Target: "b", Lang: "en"}, Mount{Source: "b", Target: "c", Lang: "en"}}) - + c.Assert(filtered, qt.DeepEquals, []Mount{{Source: "a", Target: "b", Lang: "en"}, {Source: "b", Target: "c", Lang: "en"}}) } diff --git a/modules/config.go b/modules/config.go index a50845df3..1a833b301 100644 --- a/modules/config.go +++ b/modules/config.go @@ -15,19 +15,20 @@ package modules import ( "fmt" + "os" "path/filepath" "strings" "github.com/gohugoio/hugo/common/hugo" + "github.com/gohugoio/hugo/hugofs/files" "github.com/gohugoio/hugo/config" - "github.com/gohugoio/hugo/hugofs/files" - "github.com/gohugoio/hugo/langs" "github.com/mitchellh/mapstructure" ) -var DefaultModuleConfig = Config{ +const WorkspaceDisabled = "off" +var DefaultModuleConfig = Config{ // Default to direct, which means "git clone" and similar. We // will investigate proxy settings in more depth later. // See https://github.com/golang/go/issues/26334 @@ -40,147 +41,137 @@ var DefaultModuleConfig = Config{ // Comma separated glob list matching paths that should be // treated as private. Private: "*.*", + + // Default is no workspace resolution. + Workspace: WorkspaceDisabled, + + // A list of replacement directives mapping a module path to a directory + // or a theme component in the themes folder. + // Note that this will turn the component into a traditional theme component + // that does not partake in vendoring etc. + // The syntax is the similar to the replacement directives used in go.mod, e.g: + // github.com/mod1 -> ../mod1,github.com/mod2 -> ../mod2 + Replacements: nil, } // ApplyProjectConfigDefaults applies default/missing module configuration for // the main project. -func ApplyProjectConfigDefaults(cfg config.Provider, mod Module) error { +func ApplyProjectConfigDefaults(mod Module, cfgs ...config.AllProvider) error { moda := mod.(*moduleAdapter) - // Map legacy directory config into the new module. - languages := cfg.Get("languagesSortedDefaultFirst").(langs.Languages) - isMultiHost := languages.IsMultihost() - // To bridge between old and new configuration format we need // a way to make sure all of the core components are configured on // the basic level. componentsConfigured := make(map[string]bool) for _, mnt := range moda.mounts { - componentsConfigured[mnt.Component()] = true - } - - type dirKeyComponent struct { - key string - component string - multilingual bool - } - - dirKeys := []dirKeyComponent{ - {"contentDir", files.ComponentFolderContent, true}, - {"dataDir", files.ComponentFolderData, false}, - {"layoutDir", files.ComponentFolderLayouts, false}, - {"i18nDir", files.ComponentFolderI18n, false}, - {"archetypeDir", files.ComponentFolderArchetypes, false}, - {"assetDir", files.ComponentFolderAssets, false}, - {"", files.ComponentFolderStatic, isMultiHost}, - } - - createMountsFor := func(d dirKeyComponent, cfg config.Provider) []Mount { - var lang string - if language, ok := cfg.(*langs.Language); ok { - lang = language.Lang + if !strings.HasPrefix(mnt.Target, files.JsConfigFolderMountPrefix) { + componentsConfigured[mnt.Component()] = true } - - // Static mounts are a little special. - if d.component == files.ComponentFolderStatic { - var mounts []Mount - staticDirs := getStaticDirs(cfg) - if len(staticDirs) > 0 { - componentsConfigured[d.component] = true - } - - for _, dir := range staticDirs { - mounts = append(mounts, Mount{Lang: lang, Source: dir, Target: d.component}) - } - - return mounts - - } - - if cfg.IsSet(d.key) { - source := cfg.GetString(d.key) - componentsConfigured[d.component] = true - - return []Mount{Mount{ - // No lang set for layouts etc. - Source: source, - Target: d.component}} - } - - return nil - } - - createMounts := func(d dirKeyComponent) []Mount { - var mounts []Mount - if d.multilingual { - if d.component == files.ComponentFolderContent { - seen := make(map[string]bool) - hasContentDir := false - for _, language := range languages { - if language.ContentDir != "" { - hasContentDir = true - break - } - } - - if hasContentDir { - for _, language := range languages { - contentDir := language.ContentDir - if contentDir == "" { - contentDir = files.ComponentFolderContent - } - if contentDir == "" || seen[contentDir] { - continue - } - seen[contentDir] = true - mounts = append(mounts, Mount{Lang: language.Lang, Source: contentDir, Target: d.component}) - } - } - - componentsConfigured[d.component] = len(seen) > 0 - - } else { - for _, language := range languages { - mounts = append(mounts, createMountsFor(d, language)...) - } - } - } else { - mounts = append(mounts, createMountsFor(d, cfg)...) - } - - return mounts } var mounts []Mount - for _, dirKey := range dirKeys { - if componentsConfigured[dirKey.component] { + for _, component := range []string{ + files.ComponentFolderContent, + files.ComponentFolderData, + files.ComponentFolderLayouts, + files.ComponentFolderI18n, + files.ComponentFolderArchetypes, + files.ComponentFolderAssets, + files.ComponentFolderStatic, + } { + if componentsConfigured[component] { continue } - mounts = append(mounts, createMounts(dirKey)...) + first := cfgs[0] + dirsBase := first.DirsBase() + isMultihost := first.IsMultihost() + for i, cfg := range cfgs { + dirs := cfg.Dirs() + var dir string + var dropLang bool + switch component { + case files.ComponentFolderContent: + dir = dirs.ContentDir + dropLang = dir == dirsBase.ContentDir + case files.ComponentFolderData: + //lint:ignore SA1019 Keep as adapter for now. + dir = dirs.DataDir + case files.ComponentFolderLayouts: + //lint:ignore SA1019 Keep as adapter for now. + dir = dirs.LayoutDir + case files.ComponentFolderI18n: + //lint:ignore SA1019 Keep as adapter for now. + dir = dirs.I18nDir + case files.ComponentFolderArchetypes: + //lint:ignore SA1019 Keep as adapter for now. + dir = dirs.ArcheTypeDir + case files.ComponentFolderAssets: + //lint:ignore SA1019 Keep as adapter for now. + dir = dirs.AssetDir + case files.ComponentFolderStatic: + // For static dirs, we only care about the language in multihost setups. + dropLang = !isMultihost + } + + var perLang bool + switch component { + case files.ComponentFolderContent, files.ComponentFolderStatic: + perLang = true + default: + } + if i > 0 && !perLang { + continue + } + + var lang string + if perLang && !dropLang { + lang = cfg.Language().Lang + } + + // Static mounts are a little special. + if component == files.ComponentFolderStatic { + staticDirs := cfg.StaticDirs() + for _, dir := range staticDirs { + mounts = append(mounts, Mount{Lang: lang, Source: dir, Target: component}) + } + continue + } + + if dir != "" { + mounts = append(mounts, Mount{Lang: lang, Source: dir, Target: component}) + } + } } - // Add default configuration - for _, dirKey := range dirKeys { - if componentsConfigured[dirKey.component] { + moda.mounts = append(moda.mounts, mounts...) + + // Temporary: Remove duplicates. + seen := make(map[string]bool) + var newMounts []Mount + for _, m := range moda.mounts { + key := m.Source + m.Target + m.Lang + if seen[key] { continue } - mounts = append(mounts, Mount{Source: dirKey.component, Target: dirKey.component}) + seen[key] = true + newMounts = append(newMounts, m) } - - // Prepend the mounts from configuration. - mounts = append(moda.mounts, mounts...) - - moda.mounts = mounts + moda.mounts = newMounts return nil } // DecodeConfig creates a modules Config from a given Hugo configuration. func DecodeConfig(cfg config.Provider) (Config, error) { + return decodeConfig(cfg, nil) +} + +func decodeConfig(cfg config.Provider, pathReplacements map[string]string) (Config, error) { c := DefaultModuleConfig + c.replacementsMap = pathReplacements if cfg == nil { return c, nil @@ -195,12 +186,57 @@ func DecodeConfig(cfg config.Provider) (Config, error) { return c, err } + if c.replacementsMap == nil { + + if len(c.Replacements) == 1 { + c.Replacements = strings.Split(c.Replacements[0], ",") + } + + for i, repl := range c.Replacements { + c.Replacements[i] = strings.TrimSpace(repl) + } + + c.replacementsMap = make(map[string]string) + for _, repl := range c.Replacements { + parts := strings.Split(repl, "->") + if len(parts) != 2 { + return c, fmt.Errorf(`invalid module.replacements: %q; configure replacement pairs on the form "oldpath->newpath" `, repl) + } + + c.replacementsMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + } + + if c.replacementsMap != nil && c.Imports != nil { + for i, imp := range c.Imports { + if newImp, found := c.replacementsMap[imp.Path]; found { + imp.Path = newImp + imp.pathProjectReplaced = true + c.Imports[i] = imp + } + } + } + for i, mnt := range c.Mounts { mnt.Source = filepath.Clean(mnt.Source) mnt.Target = filepath.Clean(mnt.Target) c.Mounts[i] = mnt } + if c.Workspace == "" { + c.Workspace = WorkspaceDisabled + } + if c.Workspace != WorkspaceDisabled { + c.Workspace = filepath.Clean(c.Workspace) + if !filepath.IsAbs(c.Workspace) { + workingDir := cfg.GetString("workingDir") + c.Workspace = filepath.Join(workingDir, c.Workspace) + } + if _, err := os.Stat(c.Workspace); err != nil { + //lint:ignore ST1005 end user message. + return c, fmt.Errorf("module workspace %q does not exist. Check your module.workspace setting (or HUGO_MODULE_WORKSPACE env var).", c.Workspace) + } + } } if themeSet { @@ -210,7 +246,6 @@ func DecodeConfig(cfg config.Provider) (Config, error) { Path: imp, }) } - } return c, nil @@ -218,21 +253,60 @@ func DecodeConfig(cfg config.Provider) (Config, error) { // Config holds a module config. type Config struct { - Mounts []Mount + // File system mounts. + Mounts []Mount + + // Module imports. Imports []Import // Meta info about this module (license information etc.). - Params map[string]interface{} + Params map[string]any // Will be validated against the running Hugo version. HugoVersion HugoVersion - // Configures GOPROXY. + // Optional Glob pattern matching module paths to skip when vendoring, e.g. “github.com/**” + NoVendor string + + // When enabled, we will pick the vendored module closest to the module + // using it. + // The default behavior is to pick the first. + // Note that there can still be only one dependency of a given module path, + // so once it is in use it cannot be redefined. + VendorClosest bool + + // A comma separated (or a slice) list of module path to directory replacement mapping, + // e.g. github.com/bep/my-theme -> ../..,github.com/bep/shortcodes -> /some/path. + // This is mostly useful for temporary locally development of a module, and then it makes sense to set it as an + // OS environment variable, e.g: env HUGO_MODULE_REPLACEMENTS="github.com/bep/my-theme -> ../..". + // Any relative path is relate to themesDir, and absolute paths are allowed. + Replacements []string + replacementsMap map[string]string + + // Defines the proxy server to use to download remote modules. Default is direct, which means “git clone” and similar. + // Configures GOPROXY when running the Go command for module operations. Proxy string - // Configures GONOPROXY. + + // Comma separated glob list matching paths that should not use the proxy configured above. + // Configures GONOPROXY when running the Go command for module operations. NoProxy string - // Configures GOPRIVATE. + + // Comma separated glob list matching paths that should be treated as private. + // Configures GOPRIVATE when running the Go command for module operations. Private string + + // Configures GOAUTH when running the Go command for module operations. + // This is a semicolon-separated list of authentication commands for go-import and HTTPS module mirror interactions. + // This is useful for private repositories. + // See `go help goauth` for more information. + Auth string + + // Defaults to "off". + // Set to a work file, e.g. hugo.work, to enable Go "Workspace" mode. + // Can be relative to the working directory or absolute. + // Requires Go 1.18+. + // Note that this can also be set via OS env, e.g. export HUGO_MODULE_WORKSPACE=/my/hugo.work. + Workspace string } // hasModuleImport reports whether the project config have one or more @@ -251,7 +325,7 @@ type HugoVersion struct { // The minimum Hugo version that this module works with. Min hugo.VersionString - // The maxium Hugo version that this module works with. + // The maximum Hugo version that this module works with. Max hugo.VersionString // Set if the extended version is needed. @@ -301,37 +375,54 @@ func (v HugoVersion) IsValid() bool { } type Import struct { - Path string // Module path - IgnoreConfig bool // Ignore any config.toml found. - Disable bool // Turn off this module. - Mounts []Mount + // Module path + Path string + // Set when Path is replaced in project config. + pathProjectReplaced bool + // Ignore any config in config.toml (will still follow imports). + IgnoreConfig bool + // Do not follow any configured imports. + IgnoreImports bool + // Do not mount any folder in this import. + NoMounts bool + // Never vendor this import (only allowed in main project). + NoVendor bool + // Turn off this module. + Disable bool + // File mounts. + Mounts []Mount } type Mount struct { - Source string // relative path in source repo, e.g. "scss" - Target string // relative target path, e.g. "assets/bootstrap/scss" + // Relative path in source repo, e.g. "scss". + Source string - Lang string // any language code associated with this mount. + // Relative target path, e.g. "assets/bootstrap/scss". + Target string + + // Any file in this mount will be associated with this language. + Lang string + + // Include only files matching the given Glob patterns (string or slice). + IncludeFiles any + + // Exclude all files matching the given Glob patterns (string or slice). + ExcludeFiles any + + // Disable watching in watch mode for this mount. + DisableWatch bool +} + +// Used as key to remove duplicates. +func (m Mount) key() string { + return strings.Join([]string{m.Lang, m.Source, m.Target}, "/") } func (m Mount) Component() string { return strings.Split(m.Target, fileSeparator)[0] } -func getStaticDirs(cfg config.Provider) []string { - var staticDirs []string - for i := -1; i <= 10; i++ { - staticDirs = append(staticDirs, getStringOrStringSlice(cfg, "staticDir", i)...) - } - return staticDirs -} - -func getStringOrStringSlice(cfg config.Provider, key string, id int) []string { - - if id >= 0 { - key = fmt.Sprintf("%s%d", key, id) - } - - return config.GetStringSlicePreserveString(cfg, key) - +func (m Mount) ComponentAndName() (string, string) { + c, n, _ := strings.Cut(m.Target, fileSeparator) + return c, n } diff --git a/modules/config_test.go b/modules/config_test.go index 60fa9586e..6dac14e9a 100644 --- a/modules/config_test.go +++ b/modules/config_test.go @@ -14,6 +14,9 @@ package modules import ( + "fmt" + "os" + "path/filepath" "testing" "github.com/gohugoio/hugo/common/hugo" @@ -33,22 +36,25 @@ func TestConfigHugoVersionIsValid(t *testing.T) { {HugoVersion{Min: "0.33.0"}, true}, {HugoVersion{Min: "0.56.0-DEV"}, true}, {HugoVersion{Min: "0.33.0", Max: "0.55.0"}, false}, - {HugoVersion{Min: "0.33.0", Max: "0.99.0"}, true}, + {HugoVersion{Min: "0.33.0", Max: "0.199.0"}, true}, } { - c.Assert(test.in.IsValid(), qt.Equals, test.expect) + c.Assert(test.in.IsValid(), qt.Equals, test.expect, qt.Commentf("%#v", test.in)) } } func TestDecodeConfig(t *testing.T) { c := qt.New(t) - tomlConfig := ` -[module] + c.Run("Basic", func(c *qt.C) { + tempDir := c.TempDir() + tomlConfig := ` +workingDir = %q +[module] +workspace = "hugo.work" [module.hugoVersion] min = "0.54.2" -max = "0.99.0" +max = "0.199.0" extended = true - [[module.mounts]] source="src/project/blog" target="content/blog" @@ -63,32 +69,67 @@ source="src/markdown/blog" target="content/blog" lang="en" ` - cfg, err := config.FromConfigString(tomlConfig, "toml") - c.Assert(err, qt.IsNil) - mcfg, err := DecodeConfig(cfg) - c.Assert(err, qt.IsNil) + hugoWorkFilename := filepath.Join(tempDir, "hugo.work") + f, _ := os.Create(hugoWorkFilename) + f.Close() + cfg, err := config.FromConfigString(fmt.Sprintf(tomlConfig, tempDir), "toml") + c.Assert(err, qt.IsNil) - v056 := hugo.VersionString("0.56.0") + mcfg, err := DecodeConfig(cfg) + c.Assert(err, qt.IsNil) - hv := mcfg.HugoVersion + v056 := hugo.VersionString("0.56.0") - c.Assert(v056.Compare(hv.Min), qt.Equals, -1) - c.Assert(v056.Compare(hv.Max), qt.Equals, 1) - c.Assert(hv.Extended, qt.Equals, true) + hv := mcfg.HugoVersion - if hugo.IsExtended { - c.Assert(hv.IsValid(), qt.Equals, true) - } + c.Assert(v056.Compare(hv.Min), qt.Equals, -1) + c.Assert(v056.Compare(hv.Max), qt.Equals, 1) + c.Assert(hv.Extended, qt.Equals, true) - c.Assert(len(mcfg.Mounts), qt.Equals, 1) - c.Assert(len(mcfg.Imports), qt.Equals, 1) - imp := mcfg.Imports[0] - imp.Path = "github.com/bep/mycomponent" - c.Assert(imp.Mounts[1].Source, qt.Equals, "src/markdown/blog") - c.Assert(imp.Mounts[1].Target, qt.Equals, "content/blog") - c.Assert(imp.Mounts[1].Lang, qt.Equals, "en") + if hugo.IsExtended { + c.Assert(hv.IsValid(), qt.Equals, true) + } + c.Assert(mcfg.Workspace, qt.Equals, hugoWorkFilename) + + c.Assert(len(mcfg.Mounts), qt.Equals, 1) + c.Assert(len(mcfg.Imports), qt.Equals, 1) + imp := mcfg.Imports[0] + imp.Path = "github.com/bep/mycomponent" + c.Assert(imp.Mounts[1].Source, qt.Equals, "src/markdown/blog") + c.Assert(imp.Mounts[1].Target, qt.Equals, "content/blog") + c.Assert(imp.Mounts[1].Lang, qt.Equals, "en") + }) + + c.Run("Replacements", func(c *qt.C) { + for _, tomlConfig := range []string{` +[module] +replacements="a->b,github.com/bep/mycomponent->c" +[[module.imports]] +path="github.com/bep/mycomponent" +`, ` +[module] +replacements=["a->b","github.com/bep/mycomponent->c"] +[[module.imports]] +path="github.com/bep/mycomponent" +`} { + + cfg, err := config.FromConfigString(tomlConfig, "toml") + c.Assert(err, qt.IsNil) + + mcfg, err := DecodeConfig(cfg) + c.Assert(err, qt.IsNil) + c.Assert(mcfg.Replacements, qt.DeepEquals, []string{"a->b", "github.com/bep/mycomponent->c"}) + c.Assert(mcfg.replacementsMap, qt.DeepEquals, map[string]string{ + "a": "b", + "github.com/bep/mycomponent": "c", + }) + + c.Assert(mcfg.Imports[0].Path, qt.Equals, "c") + + } + }) } func TestDecodeConfigBothOldAndNewProvided(t *testing.T) { @@ -109,7 +150,6 @@ path="a" c.Assert(err, qt.IsNil) c.Assert(len(modCfg.Imports), qt.Equals, 3) c.Assert(modCfg.Imports[0].Path, qt.Equals, "a") - } // Test old style theme import. diff --git a/modules/module.go b/modules/module.go index a5f707635..8c7316eea 100644 --- a/modules/module.go +++ b/modules/module.go @@ -17,30 +17,28 @@ package modules import ( + "time" + "github.com/gohugoio/hugo/config" ) var _ Module = (*moduleAdapter)(nil) type Module interface { - // Optional config read from the configFilename above. Cfg() config.Provider // The decoded module config and mounts. Config() Config - // Optional configuration filename (e.g. "/themes/mytheme/config.json"). + // Optional configuration filenames (e.g. "/themes/mytheme/config.json"). // This will be added to the special configuration watch list when in // server mode. - ConfigFilename() string + ConfigFilenames() []string // Directory holding files for this module. Dir() string - // This module is disabled. - Disabled() bool - // Returns whether this is a Go Module. IsGoMod() bool @@ -65,6 +63,9 @@ type Module interface { // The module version. Version() string + // Time version was created. + Time() time.Time + // Whether this module's dir is a watch candidate. Watch() bool } @@ -76,15 +77,14 @@ type moduleAdapter struct { dir string version string vendor bool - disabled bool projectMod bool owner Module mounts []Mount - configFilename string - cfg config.Provider - config Config + configFilenames []string + cfg config.Provider + config Config // Set if a Go module. gomod *goModule @@ -98,8 +98,8 @@ func (m *moduleAdapter) Config() Config { return m.config } -func (m *moduleAdapter) ConfigFilename() string { - return m.configFilename +func (m *moduleAdapter) ConfigFilenames() []string { + return m.configFilenames } func (m *moduleAdapter) Dir() string { @@ -110,10 +110,6 @@ func (m *moduleAdapter) Dir() string { return m.gomod.Dir } -func (m *moduleAdapter) Disabled() bool { - return m.disabled -} - func (m *moduleAdapter) IsGoMod() bool { return m.gomod != nil } @@ -154,6 +150,14 @@ func (m *moduleAdapter) Version() string { return m.gomod.Version } +func (m *moduleAdapter) Time() time.Time { + if !m.IsGoMod() || m.gomod.Time == nil { + return time.Time{} + } + + return *m.gomod.Time +} + func (m *moduleAdapter) Watch() bool { if m.Owner() == nil { // Main project @@ -170,5 +174,7 @@ func (m *moduleAdapter) Watch() bool { return m.Replace().Version() == "" } - return false + // Any module set up in a workspace file will have Indirect set to false. + // That leaves modules inside the read-only module cache. + return !m.gomod.Indirect } diff --git a/modules/npm/package_builder.go b/modules/npm/package_builder.go new file mode 100644 index 000000000..0deed2f42 --- /dev/null +++ b/modules/npm/package_builder.go @@ -0,0 +1,247 @@ +// Copyright 2020 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 npm + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "io/fs" + "strings" + + "github.com/gohugoio/hugo/common/hugio" + + "github.com/gohugoio/hugo/hugofs/files" + + "github.com/gohugoio/hugo/hugofs" + "github.com/spf13/afero" + + "github.com/gohugoio/hugo/common/maps" + + "github.com/gohugoio/hugo/helpers" +) + +const ( + dependenciesKey = "dependencies" + devDependenciesKey = "devDependencies" + + packageJSONName = "package.json" + + packageJSONTemplate = `{ + "name": "%s", + "version": "%s" +}` +) + +func Pack(sourceFs, assetsWithDuplicatesPreservedFs afero.Fs) error { + var b *packageBuilder + + // Have a package.hugo.json? + fi, err := sourceFs.Stat(files.FilenamePackageHugoJSON) + if err != nil { + // Have a package.json? + fi, err = sourceFs.Stat(packageJSONName) + if err == nil { + // Preserve the original in package.hugo.json. + if err = hugio.CopyFile(sourceFs, packageJSONName, files.FilenamePackageHugoJSON); err != nil { + return fmt.Errorf("npm pack: failed to copy package file: %w", err) + } + } else { + // Create one. + name := "project" + // Use the Hugo site's folder name as the default name. + // The owner can change it later. + rfi, err := sourceFs.Stat("") + if err == nil { + name = rfi.Name() + } + packageJSONContent := fmt.Sprintf(packageJSONTemplate, name, "0.1.0") + if err = afero.WriteFile(sourceFs, files.FilenamePackageHugoJSON, []byte(packageJSONContent), 0o666); err != nil { + return err + } + fi, err = sourceFs.Stat(files.FilenamePackageHugoJSON) + if err != nil { + return err + } + } + } + + meta := fi.(hugofs.FileMetaInfo).Meta() + masterFilename := meta.Filename + f, err := meta.Open() + if err != nil { + return fmt.Errorf("npm pack: failed to open package file: %w", err) + } + b = newPackageBuilder(meta.Module, f) + f.Close() + + d, err := assetsWithDuplicatesPreservedFs.Open(files.FolderJSConfig) + if err != nil { + return nil + } + + fis, err := d.(fs.ReadDirFile).ReadDir(-1) + if err != nil { + return fmt.Errorf("npm pack: failed to read assets: %w", err) + } + + for _, fi := range fis { + if fi.IsDir() { + continue + } + + if fi.Name() != files.FilenamePackageHugoJSON { + continue + } + + meta := fi.(hugofs.FileMetaInfo).Meta() + + if meta.Filename == masterFilename { + continue + } + + f, err := meta.Open() + if err != nil { + return fmt.Errorf("npm pack: failed to open package file: %w", err) + } + b.Add(meta.Module, f) + f.Close() + } + + if b.Err() != nil { + return fmt.Errorf("npm pack: failed to build: %w", b.Err()) + } + + // Replace the dependencies in the original template with the merged set. + b.originalPackageJSON[dependenciesKey] = b.dependencies + b.originalPackageJSON[devDependenciesKey] = b.devDependencies + var commentsm map[string]any + comments, found := b.originalPackageJSON["comments"] + if found { + commentsm = maps.ToStringMap(comments) + } else { + commentsm = make(map[string]any) + } + commentsm[dependenciesKey] = b.dependenciesComments + commentsm[devDependenciesKey] = b.devDependenciesComments + b.originalPackageJSON["comments"] = commentsm + + // Write it out to the project package.json + packageJSONData := new(bytes.Buffer) + encoder := json.NewEncoder(packageJSONData) + encoder.SetEscapeHTML(false) + encoder.SetIndent("", strings.Repeat(" ", 2)) + if err := encoder.Encode(b.originalPackageJSON); err != nil { + return fmt.Errorf("npm pack: failed to marshal JSON: %w", err) + } + + if err := afero.WriteFile(sourceFs, packageJSONName, packageJSONData.Bytes(), 0o666); err != nil { + return fmt.Errorf("npm pack: failed to write package.json: %w", err) + } + + return nil +} + +func newPackageBuilder(source string, first io.Reader) *packageBuilder { + b := &packageBuilder{ + devDependencies: make(map[string]any), + devDependenciesComments: make(map[string]any), + dependencies: make(map[string]any), + dependenciesComments: make(map[string]any), + } + + m := b.unmarshal(first) + if b.err != nil { + return b + } + + b.addm(source, m) + b.originalPackageJSON = m + + return b +} + +type packageBuilder struct { + err error + + // The original package.hugo.json. + originalPackageJSON map[string]any + + devDependencies map[string]any + devDependenciesComments map[string]any + dependencies map[string]any + dependenciesComments map[string]any +} + +func (b *packageBuilder) Add(source string, r io.Reader) *packageBuilder { + if b.err != nil { + return b + } + + m := b.unmarshal(r) + if b.err != nil { + return b + } + + b.addm(source, m) + + return b +} + +func (b *packageBuilder) addm(source string, m map[string]any) { + if source == "" { + source = "project" + } + + // The version selection is currently very simple. + // We may consider minimal version selection or something + // after testing this out. + // + // But for now, the first version string for a given dependency wins. + // These packages will be added by order of import (project, module1, module2...), + // so that should at least give the project control over the situation. + if devDeps, found := m[devDependenciesKey]; found { + mm := maps.ToStringMapString(devDeps) + for k, v := range mm { + if _, added := b.devDependencies[k]; !added { + b.devDependencies[k] = v + b.devDependenciesComments[k] = source + } + } + } + + if deps, found := m[dependenciesKey]; found { + mm := maps.ToStringMapString(deps) + for k, v := range mm { + if _, added := b.dependencies[k]; !added { + b.dependencies[k] = v + b.dependenciesComments[k] = source + } + } + } +} + +func (b *packageBuilder) unmarshal(r io.Reader) map[string]any { + m := make(map[string]any) + err := json.Unmarshal(helpers.ReaderToBytes(r), &m) + if err != nil { + b.err = err + } + return m +} + +func (b *packageBuilder) Err() error { + return b.err +} diff --git a/modules/npm/package_builder_test.go b/modules/npm/package_builder_test.go new file mode 100644 index 000000000..2523292ee --- /dev/null +++ b/modules/npm/package_builder_test.go @@ -0,0 +1,95 @@ +// Copyright 2020 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 npm + +import ( + "strings" + "testing" + + qt "github.com/frankban/quicktest" +) + +const templ = `{ + "name": "foo", + "version": "0.1.1", + "scripts": {}, + "dependencies": { + "react-dom": "1.1.1", + "tailwindcss": "1.2.0", + "@babel/cli": "7.8.4", + "@babel/core": "7.9.0", + "@babel/preset-env": "7.9.5" + }, + "devDependencies": { + "postcss-cli": "7.1.0", + "tailwindcss": "1.2.0", + "@babel/cli": "7.8.4", + "@babel/core": "7.9.0", + "@babel/preset-env": "7.9.5" + } +}` + +func TestPackageBuilder(t *testing.T) { + c := qt.New(t) + + b := newPackageBuilder("", strings.NewReader(templ)) + c.Assert(b.Err(), qt.IsNil) + + b.Add("mymod", strings.NewReader(`{ +"dependencies": { + "react-dom": "9.1.1", + "add1": "1.1.1" +}, +"devDependencies": { + "tailwindcss": "error", + "add2": "2.1.1" +} +}`)) + + b.Add("mymod", strings.NewReader(`{ +"dependencies": { + "react-dom": "error", + "add1": "error", + "add3": "3.1.1" +}, +"devDependencies": { + "tailwindcss": "error", + "add2": "error", + "add4": "4.1.1" + +} +}`)) + + c.Assert(b.Err(), qt.IsNil) + + c.Assert(b.dependencies, qt.DeepEquals, map[string]any{ + "@babel/cli": "7.8.4", + "add1": "1.1.1", + "add3": "3.1.1", + "@babel/core": "7.9.0", + "@babel/preset-env": "7.9.5", + "react-dom": "1.1.1", + "tailwindcss": "1.2.0", + }) + + c.Assert(b.devDependencies, qt.DeepEquals, map[string]any{ + "tailwindcss": "1.2.0", + "@babel/cli": "7.8.4", + "@babel/core": "7.9.0", + "add2": "2.1.1", + "add4": "4.1.1", + "@babel/preset-env": "7.9.5", + "postcss-cli": "7.1.0", + }) +} diff --git a/navigation/menu.go b/navigation/menu.go index ae2e0e4ff..a971f2e74 100644 --- a/navigation/menu.go +++ b/navigation/menu.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Hugo Authors. All rights reserved. +// Copyright 2024 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. @@ -11,55 +11,84 @@ // See the License for the specific language governing permissions and // limitations under the License. +// Package navigation provides the menu functionality. package navigation import ( + "html/template" + "sort" + "github.com/gohugoio/hugo/common/maps" "github.com/gohugoio/hugo/common/types" "github.com/gohugoio/hugo/compare" - - "html/template" - "sort" - "strings" + "github.com/gohugoio/hugo/config" + "github.com/mitchellh/mapstructure" "github.com/spf13/cast" + "slices" ) +var smc = newMenuCache() + // MenuEntry represents a menu item defined in either Page front matter // or in the site config. type MenuEntry struct { - ConfiguredURL string // The URL value from front matter / config. - Page Page - Name string - Menu string - Identifier string - title string - Pre template.HTML - Post template.HTML - Weight int - Parent string - Children Menu + // The menu entry configuration. + MenuConfig + + // The menu containing this menu entry. + Menu string + + // The URL value from front matter / config. + ConfiguredURL string + + // The Page connected to this menu entry. + Page Page + + // Child entries. + Children Menu } func (m *MenuEntry) URL() string { - if m.ConfiguredURL != "" { - return m.ConfiguredURL - } - + // Check page first. + // In Hugo 0.86.0 we added `pageRef`, + // a way to connect menu items in site config to pages. + // This means that you now can have both a Page + // and a configured URL. + // Having the configured URL as a fallback if the Page isn't found + // is obviously more useful, especially in multilingual sites. if !types.IsNil(m.Page) { return m.Page.RelPermalink() } - return "" + return m.ConfiguredURL +} + +// SetPageValues sets the Page and URL values for this menu entry. +func SetPageValues(m *MenuEntry, p Page) { + m.Page = p + if m.MenuConfig.Name == "" { + m.MenuConfig.Name = p.LinkTitle() + } + if m.MenuConfig.Title == "" { + m.MenuConfig.Title = p.Title() + } + if m.MenuConfig.Weight == 0 { + m.MenuConfig.Weight = p.Weight() + } } // A narrow version of page.Page. type Page interface { LinkTitle() string + Title() string RelPermalink() string + Path() string Section() string Weight() int IsPage() bool + IsSection() bool + IsAncestor(other any) bool Params() maps.Params } @@ -95,42 +124,46 @@ func (m *MenuEntry) hopefullyUniqueID() string { } } -// IsEqual returns whether the two menu entries represents the same menu entry. -func (m *MenuEntry) IsEqual(inme *MenuEntry) bool { +// isEqual returns whether the two menu entries represents the same menu entry. +func (m *MenuEntry) isEqual(inme *MenuEntry) bool { return m.hopefullyUniqueID() == inme.hopefullyUniqueID() && m.Parent == inme.Parent } -// IsSameResource returns whether the two menu entries points to the same +// isSameResource returns whether the two menu entries points to the same // resource (URL). -func (m *MenuEntry) IsSameResource(inme *MenuEntry) bool { +func (m *MenuEntry) isSameResource(inme *MenuEntry) bool { + if m.isSamePage(inme.Page) { + return m.Page == inme.Page + } murl, inmeurl := m.URL(), inme.URL() return murl != "" && inmeurl != "" && murl == inmeurl } -func (m *MenuEntry) MarshallMap(ime map[string]interface{}) { - for k, v := range ime { - loki := strings.ToLower(k) - switch loki { - case "url": - m.ConfiguredURL = cast.ToString(v) - case "weight": - m.Weight = cast.ToInt(v) - case "name": - m.Name = cast.ToString(v) - case "title": - m.title = cast.ToString(v) - case "pre": - m.Pre = template.HTML(cast.ToString(v)) - case "post": - m.Post = template.HTML(cast.ToString(v)) - case "identifier": - m.Identifier = cast.ToString(v) - case "parent": - m.Parent = cast.ToString(v) - } +func (m *MenuEntry) isSamePage(p Page) bool { + if !types.IsNil(m.Page) && !types.IsNil(p) { + return m.Page == p } + return false } +// MenuConfig holds the configuration for a menu. +type MenuConfig struct { + Identifier string + Parent string + Name string + Pre template.HTML + Post template.HTML + URL string + PageRef string + Weight int + Title string + // User defined params. + Params maps.Params +} + +// For internal use. + +// This is for internal use only. func (m Menu) Add(me *MenuEntry) Menu { m = append(m, me) // TODO(bep) @@ -201,37 +234,84 @@ func (m Menu) Limit(n int) Menu { // ByWeight sorts the menu by the weight defined in the menu configuration. func (m Menu) ByWeight() Menu { - menuEntryBy(defaultMenuEntrySort).Sort(m) - return m + const key = "menuSort.ByWeight" + menus, _ := smc.get(key, menuEntryBy(defaultMenuEntrySort).Sort, m) + + return menus } // ByName sorts the menu by the name defined in the menu configuration. func (m Menu) ByName() Menu { + const key = "menuSort.ByName" title := func(m1, m2 *MenuEntry) bool { return compare.LessStrings(m1.Name, m2.Name) } - menuEntryBy(title).Sort(m) - return m + menus, _ := smc.get(key, menuEntryBy(title).Sort, m) + + return menus } // Reverse reverses the order of the menu entries. func (m Menu) Reverse() Menu { - for i, j := 0, len(m)-1; i < j; i, j = i+1, j-1 { - m[i], m[j] = m[j], m[i] + const key = "menuSort.Reverse" + reverseFunc := func(menu Menu) { + for i, j := 0, len(menu)-1; i < j; i, j = i+1, j-1 { + menu[i], menu[j] = menu[j], menu[i] + } } + menus, _ := smc.get(key, reverseFunc, m) - return m + return menus } -func (m *MenuEntry) Title() string { - if m.title != "" { - return m.title - } - - if m.Page != nil { - return m.Page.LinkTitle() - } - - return "" +// Clone clones the menu entries. +// This is for internal use only. +func (m Menu) Clone() Menu { + return slices.Clone(m) +} + +func DecodeConfig(in any) (*config.ConfigNamespace[map[string]MenuConfig, Menus], error) { + buildConfig := func(in any) (Menus, any, error) { + ret := Menus{} + + if in == nil { + return ret, map[string]any{}, nil + } + + menus, err := maps.ToStringMapE(in) + if err != nil { + return ret, nil, err + } + menus = maps.CleanConfigStringMap(menus) + + for name, menu := range menus { + m, err := cast.ToSliceE(menu) + if err != nil { + return ret, nil, err + } else { + for _, entry := range m { + var menuConfig MenuConfig + if err := mapstructure.WeakDecode(entry, &menuConfig); err != nil { + return ret, nil, err + } + maps.PrepareParams(menuConfig.Params) + menuEntry := MenuEntry{ + Menu: name, + MenuConfig: menuConfig, + } + menuEntry.ConfiguredURL = menuEntry.MenuConfig.URL + + if ret[name] == nil { + ret[name] = Menu{} + } + ret[name] = ret[name].Add(&menuEntry) + } + } + } + + return ret, menus, nil + } + + return config.DecodeNamespace[map[string]MenuConfig](in, buildConfig) } diff --git a/navigation/menu_cache.go b/navigation/menu_cache.go new file mode 100644 index 000000000..065781780 --- /dev/null +++ b/navigation/menu_cache.go @@ -0,0 +1,102 @@ +// Copyright 2021 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 navigation + +import ( + "slices" + "sync" +) + +type menuCacheEntry struct { + in []Menu + out Menu +} + +func (entry menuCacheEntry) matches(menuList []Menu) bool { + if len(entry.in) != len(menuList) { + return false + } + for i, m := range menuList { + if !menuEqual(m, entry.in[i]) { + return false + } + } + + return true +} + +// newMenuCache creates a new menuCache instance. +func newMenuCache() *menuCache { + return &menuCache{m: make(map[string][]menuCacheEntry)} +} + +type menuCache struct { + sync.RWMutex + m map[string][]menuCacheEntry +} + +// menuEqual checks if two menus are equal. +func menuEqual(m1, m2 Menu) bool { + if len(m1) != len(m2) { + return false + } + + for i := range m1 { + if m1[i] != m2[i] { + return false + } + } + + return true +} + +// get retrieves a menu from the cache based on the provided key and menuLists. +// If the menu is not found, it applies the provided function and caches the result. +func (c *menuCache) get(key string, apply func(m Menu), menuLists ...Menu) (Menu, bool) { + return c.getP(key, func(m *Menu) { + if apply != nil { + apply(*m) + } + }, menuLists...) +} + +// getP is similar to get but also returns a boolean indicating whether the menu was found in the cache. +func (c *menuCache) getP(key string, apply func(m *Menu), menuLists ...Menu) (Menu, bool) { + c.Lock() + defer c.Unlock() + + if cached, ok := c.m[key]; ok { + for _, entry := range cached { + if entry.matches(menuLists) { + return entry.out, true + } + } + } + + m := menuLists[0] + menuCopy := slices.Clone(m) + + if apply != nil { + apply(&menuCopy) + } + + entry := menuCacheEntry{in: menuLists, out: menuCopy} + if v, ok := c.m[key]; ok { + c.m[key] = append(v, entry) + } else { + c.m[key] = []menuCacheEntry{entry} + } + + return menuCopy, false +} diff --git a/navigation/menu_cache_test.go b/navigation/menu_cache_test.go new file mode 100644 index 000000000..8fa17ffc3 --- /dev/null +++ b/navigation/menu_cache_test.go @@ -0,0 +1,81 @@ +// Copyright 2021 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 navigation + +import ( + "sync" + "sync/atomic" + "testing" + + qt "github.com/frankban/quicktest" +) + +func createSortTestMenu(num int) Menu { + menu := make(Menu, num) + for i := range num { + m := &MenuEntry{} + menu[i] = m + } + return menu +} + +func TestMenuCache(t *testing.T) { + t.Parallel() + c := qt.New(t) + c1 := newMenuCache() + + changeFirst := func(m Menu) { + m[0].MenuConfig.Title = "changed" + } + + var o1 uint64 + var o2 uint64 + + var wg sync.WaitGroup + + var l1 sync.Mutex + var l2 sync.Mutex + + var testMenuSets []Menu + + for i := range 50 { + testMenuSets = append(testMenuSets, createSortTestMenu(i+1)) + } + + for range 100 { + wg.Add(1) + go func() { + defer wg.Done() + for k, menu := range testMenuSets { + l1.Lock() + m, ca := c1.get("k1", nil, menu) + c.Assert(ca, qt.Equals, !atomic.CompareAndSwapUint64(&o1, uint64(k), uint64(k+1))) + l1.Unlock() + m2, c2 := c1.get("k1", nil, m) + c.Assert(c2, qt.Equals, true) + c.Assert(menuEqual(m, m2), qt.Equals, true) + c.Assert(menuEqual(m, menu), qt.Equals, true) + c.Assert(m, qt.Not(qt.IsNil)) + + l2.Lock() + m3, c3 := c1.get("k2", changeFirst, menu) + c.Assert(c3, qt.Equals, !atomic.CompareAndSwapUint64(&o2, uint64(k), uint64(k+1))) + l2.Unlock() + c.Assert(m3, qt.Not(qt.IsNil)) + c.Assert("changed", qt.Equals, m3[0].Title) + } + }() + } + wg.Wait() +} diff --git a/navigation/pagemenus.go b/navigation/pagemenus.go index 352a91557..0919d93bb 100644 --- a/navigation/pagemenus.go +++ b/navigation/pagemenus.go @@ -14,9 +14,12 @@ package navigation import ( - "github.com/gohugoio/hugo/common/maps" + "fmt" + + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/common/types" + "github.com/mitchellh/mapstructure" - "github.com/pkg/errors" "github.com/spf13/cast" ) @@ -38,21 +41,13 @@ type MenuQueryProvider interface { IsMenuCurrent(menuID string, inme *MenuEntry) bool } -func PageMenusFromPage(p Page) (PageMenus, error) { - params := p.Params() - - ms, ok := params["menus"] - if !ok { - ms, ok = params["menu"] - } - - pm := PageMenus{} - - if !ok { +func PageMenusFromPage(ms any, p Page) (PageMenus, error) { + if ms == nil { return nil, nil } - - me := MenuEntry{Page: p, Name: p.LinkTitle(), Weight: p.Weight()} + pm := PageMenus{} + me := MenuEntry{} + SetPageValues(&me, p) // Could be the name of the menu to attach it to mname, err := cast.ToStringE(ms) @@ -74,57 +69,55 @@ func PageMenusFromPage(p Page) (PageMenus, error) { return pm, nil } + wrapErr := func(err error) error { + return fmt.Errorf("unable to process menus for page %q: %w", p.Path(), err) + } + // Could be a structured menu entry menus, err := maps.ToStringMapE(ms) if err != nil { - return pm, errors.Wrapf(err, "unable to process menus for %q", p.LinkTitle()) + return pm, wrapErr(err) } for name, menu := range menus { - menuEntry := MenuEntry{Page: p, Name: p.LinkTitle(), Weight: p.Weight(), Menu: name} + menuEntry := MenuEntry{Menu: name} if menu != nil { ime, err := maps.ToStringMapE(menu) if err != nil { - return pm, errors.Wrapf(err, "unable to process menus for %q", p.LinkTitle()) + return pm, wrapErr(err) + } + if err := mapstructure.WeakDecode(ime, &menuEntry.MenuConfig); err != nil { + return pm, err } - - menuEntry.MarshallMap(ime) } + SetPageValues(&menuEntry, p) pm[name] = &menuEntry } return pm, nil - } func NewMenuQueryProvider( - setionPagesMenu string, pagem PageMenusGetter, sitem MenusGetter, - p Page) MenuQueryProvider { - + p Page, +) MenuQueryProvider { return &pageMenus{ - p: p, - pagem: pagem, - sitem: sitem, - setionPagesMenu: setionPagesMenu, + p: p, + pagem: pagem, + sitem: sitem, } } type pageMenus struct { - pagem PageMenusGetter - sitem MenusGetter - setionPagesMenu string - p Page + pagem PageMenusGetter + sitem MenusGetter + p Page } func (pm *pageMenus) HasMenuCurrent(menuID string, me *MenuEntry) bool { - - // page is labeled as "shadow-member" of the menu with the same identifier as the section - if pm.setionPagesMenu != "" { - section := pm.p.Section() - - if section != "" && pm.setionPagesMenu == menuID && section == me.Identifier { + if !types.IsNil(me.Page) && me.Page.IsSection() { + if ok := me.Page.IsAncestor(pm.p); ok { return true } } @@ -136,9 +129,8 @@ func (pm *pageMenus) HasMenuCurrent(menuID string, me *MenuEntry) bool { menus := pm.pagem.Menus() if m, ok := menus[menuID]; ok { - for _, child := range me.Children { - if child.IsEqual(m) { + if child.isEqual(m) { return true } if pm.HasMenuCurrent(menuID, child) { @@ -147,53 +139,45 @@ func (pm *pageMenus) HasMenuCurrent(menuID string, me *MenuEntry) bool { } } - if pm.p == nil || pm.p.IsPage() { + if pm.p == nil { return false } - // The following logic is kept from back when Hugo had both Page and Node types. - // TODO(bep) consolidate / clean - nme := MenuEntry{Page: pm.p, Name: pm.p.LinkTitle()} - for _, child := range me.Children { - if nme.IsSameResource(child) { + if child.isSamePage(pm.p) { return true } + if pm.HasMenuCurrent(menuID, child) { return true } } return false - } func (pm *pageMenus) IsMenuCurrent(menuID string, inme *MenuEntry) bool { menus := pm.pagem.Menus() if me, ok := menus[menuID]; ok { - if me.IsEqual(inme) { + if me.isEqual(inme) { return true } } - if pm.p == nil || pm.p.IsPage() { + if pm.p == nil { return false } - // The following logic is kept from back when Hugo had both Page and Node types. - // TODO(bep) consolidate / clean - me := MenuEntry{Page: pm.p, Name: pm.p.LinkTitle()} - - if !me.IsSameResource(inme) { + if !inme.isSamePage(pm.p) { return false } - // this resource may be included in several menus - // search for it to make sure that it is in the menu with the given menuId + // This resource may be included in several menus. + // Search for it to make sure that it is in the menu with the given menuId. if menu, ok := pm.sitem.Menus()[menuID]; ok { for _, menuEntry := range menu { - if menuEntry.IsSameResource(inme) { + if menuEntry.isSameResource(inme) { return true } @@ -211,7 +195,7 @@ func (pm *pageMenus) IsMenuCurrent(menuID string, inme *MenuEntry) bool { func (pm *pageMenus) isSameAsDescendantMenu(inme *MenuEntry, parent *MenuEntry) bool { if parent.HasChildren() { for _, child := range parent.Children { - if child.IsSameResource(inme) { + if child.isSameResource(inme) { return true } descendantFound := pm.isSameAsDescendantMenu(inme, child) diff --git a/output/config.go b/output/config.go new file mode 100644 index 000000000..09d29f184 --- /dev/null +++ b/output/config.go @@ -0,0 +1,143 @@ +// Copyright 2024 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 output + +import ( + "fmt" + "reflect" + "sort" + "strings" + + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/media" + "github.com/mitchellh/mapstructure" +) + +// OutputFormatConfig configures a single output format. +type OutputFormatConfig struct { + // The MediaType string. This must be a configured media type. + MediaType string + Format +} + +var defaultOutputFormat = Format{ + BaseName: "index", + Rel: "alternate", +} + +func DecodeConfig(mediaTypes media.Types, in any) (*config.ConfigNamespace[map[string]OutputFormatConfig, Formats], error) { + buildConfig := func(in any) (Formats, any, error) { + f := make(Formats, len(DefaultFormats)) + copy(f, DefaultFormats) + if in != nil { + m, err := maps.ToStringMapE(in) + if err != nil { + return nil, nil, fmt.Errorf("failed convert config to map: %s", err) + } + m = maps.CleanConfigStringMap(m) + + for k, v := range m { + found := false + for i, vv := range f { + // Both are lower case. + if k == vv.Name { + // Merge it with the existing + if err := decode(mediaTypes, v, &f[i]); err != nil { + return f, nil, err + } + found = true + } + } + if found { + continue + } + + newOutFormat := defaultOutputFormat + if err := decode(mediaTypes, v, &newOutFormat); err != nil { + return f, nil, err + } + newOutFormat.Name = k + + f = append(f, newOutFormat) + + } + } + + // Also format is a map for documentation purposes. + docm := make(map[string]OutputFormatConfig, len(f)) + for _, ff := range f { + docm[ff.Name] = OutputFormatConfig{ + MediaType: ff.MediaType.Type, + Format: ff, + } + } + + sort.Sort(f) + return f, docm, nil + } + + return config.DecodeNamespace[map[string]OutputFormatConfig](in, buildConfig) +} + +func decode(mediaTypes media.Types, input any, output *Format) error { + config := &mapstructure.DecoderConfig{ + Metadata: nil, + Result: output, + WeaklyTypedInput: true, + DecodeHook: func(a reflect.Type, b reflect.Type, c any) (any, error) { + if a.Kind() == reflect.Map { + dataVal := reflect.Indirect(reflect.ValueOf(c)) + for _, key := range dataVal.MapKeys() { + keyStr, ok := key.Interface().(string) + if !ok { + // Not a string key + continue + } + if strings.EqualFold(keyStr, "mediaType") { + // If mediaType is a string, look it up and replace it + // in the map. + vv := dataVal.MapIndex(key) + vvi := vv.Interface() + + switch vviv := vvi.(type) { + case media.Type: + // OK + case string: + mediaType, found := mediaTypes.GetByType(vviv) + if !found { + return c, fmt.Errorf("media type %q not found", vviv) + } + dataVal.SetMapIndex(key, reflect.ValueOf(mediaType)) + default: + return nil, fmt.Errorf("invalid output format configuration; wrong type for media type, expected string (e.g. text/html), got %T", vvi) + } + } + } + } + return c, nil + }, + } + + decoder, err := mapstructure.NewDecoder(config) + if err != nil { + return err + } + + if err = decoder.Decode(input); err != nil { + return fmt.Errorf("failed to decode output format configuration: %w", err) + } + + return nil +} diff --git a/output/config_test.go b/output/config_test.go new file mode 100644 index 000000000..c2f0af980 --- /dev/null +++ b/output/config_test.go @@ -0,0 +1,98 @@ +// Copyright 2024 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 output + +import ( + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/media" +) + +func TestDecodeConfig(t *testing.T) { + c := qt.New(t) + + mediaTypes := media.Types{media.Builtin.JSONType, media.Builtin.XMLType} + + tests := []struct { + name string + m map[string]any + shouldError bool + assert func(t *testing.T, name string, f Formats) + }{ + { + "Redefine JSON", + map[string]any{ + "json": map[string]any{ + "baseName": "myindex", + "isPlainText": "false", + }, + }, + false, + func(t *testing.T, name string, f Formats) { + msg := qt.Commentf(name) + c.Assert(len(f), qt.Equals, len(DefaultFormats), msg) + json, _ := f.GetByName("JSON") + c.Assert(json.BaseName, qt.Equals, "myindex") + c.Assert(json.MediaType, qt.Equals, media.Builtin.JSONType) + c.Assert(json.IsPlainText, qt.Equals, false) + }, + }, + { + "Add XML format with string as mediatype", + map[string]any{ + "MYXMLFORMAT": map[string]any{ + "baseName": "myxml", + "mediaType": "application/xml", + }, + }, + false, + func(t *testing.T, name string, f Formats) { + c.Assert(len(f), qt.Equals, len(DefaultFormats)+1) + xml, found := f.GetByName("MYXMLFORMAT") + c.Assert(found, qt.Equals, true) + c.Assert(xml.BaseName, qt.Equals, "myxml") + c.Assert(xml.MediaType, qt.Equals, media.Builtin.XMLType) + + // Verify that we haven't changed the DefaultFormats slice. + json, _ := f.GetByName("JSON") + c.Assert(json.BaseName, qt.Equals, "index") + }, + }, + { + "Add format unknown mediatype", + map[string]any{ + "MYINVALID": map[string]any{ + "baseName": "mymy", + "mediaType": "application/hugo", + }, + }, + true, + func(t *testing.T, name string, f Formats) { + }, + }, + } + + for _, test := range tests { + result, err := DecodeConfig(mediaTypes, test.m) + msg := qt.Commentf(test.name) + + if test.shouldError { + c.Assert(err, qt.Not(qt.IsNil), msg) + } else { + c.Assert(err, qt.IsNil, msg) + test.assert(t, test.name, result.Config) + } + } +} diff --git a/output/docshelper.go b/output/docshelper.go index f08b20b01..387c011ad 100644 --- a/output/docshelper.go +++ b/output/docshelper.go @@ -1,7 +1,6 @@ package output import ( - "strings" // "fmt" @@ -10,93 +9,15 @@ import ( // This is is just some helpers used to create some JSON used in the Hugo docs. func init() { - docsProvider := func() map[string]interface{} { - docs := make(map[string]interface{}) - - docs["formats"] = DefaultFormats - docs["layouts"] = createLayoutExamples() - return docs - } - - docshelper.AddDocProvider("output", docsProvider) -} - -func createLayoutExamples() interface{} { - - type Example struct { - Example string - Kind string - OutputFormat string - Suffix string - Layouts []string `json:"Template Lookup Order"` - } - - var ( - basicExamples []Example - demoLayout = "demolayout" - demoType = "demotype" - ) - - for _, example := range []struct { - name string - d LayoutDescriptor - f Format - }{ - // Taxonomy output.LayoutDescriptor={categories category taxonomy en false Type Section - {"Single page in \"posts\" section", LayoutDescriptor{Kind: "page", Type: "posts"}, HTMLFormat}, - {"Base template for single page in \"posts\" section", LayoutDescriptor{Baseof: true, Kind: "page", Type: "posts"}, HTMLFormat}, - {"Single page in \"posts\" section with layout set", LayoutDescriptor{Kind: "page", Type: "posts", Layout: demoLayout}, HTMLFormat}, - {"Base template for single page in \"posts\" section with layout set", LayoutDescriptor{Baseof: true, Kind: "page", Type: "posts", Layout: demoLayout}, HTMLFormat}, - {"AMP single page", LayoutDescriptor{Kind: "page", Type: "posts"}, AMPFormat}, - {"AMP single page, French language", LayoutDescriptor{Kind: "page", Type: "posts", Lang: "fr"}, AMPFormat}, - // All section or typeless pages gets "page" as type - {"Home page", LayoutDescriptor{Kind: "home", Type: "page"}, HTMLFormat}, - {"Base template for home page", LayoutDescriptor{Baseof: true, Kind: "home", Type: "page"}, HTMLFormat}, - {"Home page with type set", LayoutDescriptor{Kind: "home", Type: demoType}, HTMLFormat}, - {"Base template for home page with type set", LayoutDescriptor{Baseof: true, Kind: "home", Type: demoType}, HTMLFormat}, - {"Home page with layout set", LayoutDescriptor{Kind: "home", Type: "page", Layout: demoLayout}, HTMLFormat}, - {`AMP home, French language"`, LayoutDescriptor{Kind: "home", Type: "page", Lang: "fr"}, AMPFormat}, - {"JSON home", LayoutDescriptor{Kind: "home", Type: "page"}, JSONFormat}, - {"RSS home", LayoutDescriptor{Kind: "home", Type: "page"}, RSSFormat}, - {"RSS section posts", LayoutDescriptor{Kind: "section", Type: "posts"}, RSSFormat}, - {"Taxonomy list in categories", LayoutDescriptor{Kind: "taxonomy", Type: "categories", Section: "category"}, RSSFormat}, - {"Taxonomy terms in categories", LayoutDescriptor{Kind: "taxonomyTerm", Type: "categories", Section: "category"}, RSSFormat}, - {"Section list for \"posts\" section", LayoutDescriptor{Kind: "section", Type: "posts", Section: "posts"}, HTMLFormat}, - {"Section list for \"posts\" section with type set to \"blog\"", LayoutDescriptor{Kind: "section", Type: "blog", Section: "posts"}, HTMLFormat}, - {"Section list for \"posts\" section with layout set to \"demoLayout\"", LayoutDescriptor{Kind: "section", Layout: demoLayout, Section: "posts"}, HTMLFormat}, - - {"Taxonomy list in categories", LayoutDescriptor{Kind: "taxonomy", Type: "categories", Section: "category"}, HTMLFormat}, - {"Taxonomy term in categories", LayoutDescriptor{Kind: "taxonomyTerm", Type: "categories", Section: "category"}, HTMLFormat}, - } { - - l := NewLayoutHandler() - layouts, _ := l.For(example.d, example.f) - - basicExamples = append(basicExamples, Example{ - Example: example.name, - Kind: example.d.Kind, - OutputFormat: example.f.Name, - Suffix: example.f.MediaType.Suffix(), - Layouts: makeLayoutsPresentable(layouts)}) - } - - return basicExamples - -} - -func makeLayoutsPresentable(l []string) []string { - var filtered []string - for _, ll := range l { - if strings.Contains(ll, "page/") { - // This is a valid lookup, but it's more confusing than useful. - continue - } - ll = "layouts/" + strings.TrimPrefix(ll, "_text/") - - if !strings.Contains(ll, "indexes") { - filtered = append(filtered, ll) + docsProvider := func() docshelper.DocProvider { + return docshelper.DocProvider{ + "output": map[string]any{ + // TODO(bep), maybe revisit this later, but I hope this isn't needed. + // "layouts": createLayoutExamples(), + "layouts": map[string]any{}, + }, } } - return filtered + docshelper.AddDocProviderFunc(docsProvider) } diff --git a/output/layout.go b/output/layout.go deleted file mode 100644 index e59404684..000000000 --- a/output/layout.go +++ /dev/null @@ -1,284 +0,0 @@ -// Copyright 2017-present 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 output - -import ( - "fmt" - "strings" - "sync" - - "github.com/gohugoio/hugo/helpers" -) - -// These may be used as content sections with potential conflicts. Avoid that. -var reservedSections = map[string]bool{ - "shortcodes": true, - "partials": true, -} - -// LayoutDescriptor describes how a layout should be chosen. This is -// typically built from a Page. -type LayoutDescriptor struct { - Type string - Section string - Kind string - Lang string - Layout string - // LayoutOverride indicates what we should only look for the above layout. - LayoutOverride bool - - RenderingHook bool - Baseof bool -} - -func (d LayoutDescriptor) isList() bool { - return !d.RenderingHook && d.Kind != "page" && d.Kind != "404" -} - -// LayoutHandler calculates the layout template to use to render a given output type. -type LayoutHandler struct { - mu sync.RWMutex - cache map[layoutCacheKey][]string -} - -type layoutCacheKey struct { - d LayoutDescriptor - f string -} - -// NewLayoutHandler creates a new LayoutHandler. -func NewLayoutHandler() *LayoutHandler { - return &LayoutHandler{cache: make(map[layoutCacheKey][]string)} -} - -// For returns a layout for the given LayoutDescriptor and options. -// Layouts are rendered and cached internally. -func (l *LayoutHandler) For(d LayoutDescriptor, f Format) ([]string, error) { - - // We will get lots of requests for the same layouts, so avoid recalculations. - key := layoutCacheKey{d, f.Name} - l.mu.RLock() - if cacheVal, found := l.cache[key]; found { - l.mu.RUnlock() - return cacheVal, nil - } - l.mu.RUnlock() - - layouts := resolvePageTemplate(d, f) - - layouts = helpers.UniqueStringsReuse(layouts) - - l.mu.Lock() - l.cache[key] = layouts - l.mu.Unlock() - - return layouts, nil -} - -type layoutBuilder struct { - layoutVariations []string - typeVariations []string - d LayoutDescriptor - f Format -} - -func (l *layoutBuilder) addLayoutVariations(vars ...string) { - for _, layoutVar := range vars { - if l.d.Baseof && layoutVar != "baseof" { - l.layoutVariations = append(l.layoutVariations, layoutVar+"-baseof") - continue - } - if !l.d.RenderingHook && !l.d.Baseof && l.d.LayoutOverride && layoutVar != l.d.Layout { - continue - } - l.layoutVariations = append(l.layoutVariations, layoutVar) - } -} - -func (l *layoutBuilder) addTypeVariations(vars ...string) { - for _, typeVar := range vars { - if !reservedSections[typeVar] { - if l.d.RenderingHook { - typeVar = typeVar + renderingHookRoot - } - l.typeVariations = append(l.typeVariations, typeVar) - } - } -} - -func (l *layoutBuilder) addSectionType() { - if l.d.Section != "" { - l.addTypeVariations(l.d.Section) - } -} - -func (l *layoutBuilder) addKind() { - l.addLayoutVariations(l.d.Kind) - l.addTypeVariations(l.d.Kind) -} - -const renderingHookRoot = "/_markup" - -func resolvePageTemplate(d LayoutDescriptor, f Format) []string { - - b := &layoutBuilder{d: d, f: f} - - if d.RenderingHook { - b.addLayoutVariations(d.Kind) - } else { - if d.Layout != "" { - b.addLayoutVariations(d.Layout) - } - if d.Type != "" { - b.addTypeVariations(d.Type) - } - } - - switch d.Kind { - case "page": - b.addLayoutVariations("single") - b.addSectionType() - case "home": - b.addLayoutVariations("index", "home") - // Also look in the root - b.addTypeVariations("") - case "section": - if d.Section != "" { - b.addLayoutVariations(d.Section) - } - b.addSectionType() - b.addKind() - case "taxonomy": - if d.Section != "" { - b.addLayoutVariations(d.Section) - } - b.addKind() - b.addSectionType() - - case "taxonomyTerm": - if d.Section != "" { - b.addLayoutVariations(d.Section + ".terms") - } - b.addTypeVariations("taxonomy") - b.addSectionType() - b.addLayoutVariations("terms") - case "404": - b.addLayoutVariations("404") - b.addTypeVariations("") - } - - isRSS := f.Name == RSSFormat.Name - if !d.RenderingHook && !d.Baseof && isRSS { - // The historic and common rss.xml case - b.addLayoutVariations("") - } - - if d.Baseof || d.Kind != "404" { - // Most have _default in their lookup path - b.addTypeVariations("_default") - } - - if d.isList() { - // Add the common list type - b.addLayoutVariations("list") - } - - if d.Baseof { - b.addLayoutVariations("baseof") - } - - layouts := b.resolveVariations() - - if !d.RenderingHook && !d.Baseof && isRSS { - layouts = append(layouts, "_internal/_default/rss.xml") - } - - return layouts - -} - -func (l *layoutBuilder) resolveVariations() []string { - - var layouts []string - - var variations []string - name := strings.ToLower(l.f.Name) - - if l.d.Lang != "" { - // We prefer the most specific type before language. - variations = append(variations, []string{fmt.Sprintf("%s.%s", l.d.Lang, name), name, l.d.Lang}...) - } else { - variations = append(variations, name) - } - - variations = append(variations, "") - - for _, typeVar := range l.typeVariations { - for _, variation := range variations { - for _, layoutVar := range l.layoutVariations { - if variation == "" && layoutVar == "" { - continue - } - template := layoutTemplate(typeVar, layoutVar) - layouts = append(layouts, replaceKeyValues(template, - "TYPE", typeVar, - "LAYOUT", layoutVar, - "VARIATIONS", variation, - "EXTENSION", l.f.MediaType.Suffix(), - )) - } - } - - } - - return filterDotLess(layouts) -} - -func layoutTemplate(typeVar, layoutVar string) string { - - var l string - - if typeVar != "" { - l = "TYPE/" - } - - if layoutVar != "" { - l += "LAYOUT.VARIATIONS.EXTENSION" - } else { - l += "VARIATIONS.EXTENSION" - } - - return l -} - -func filterDotLess(layouts []string) []string { - var filteredLayouts []string - - for _, l := range layouts { - l = strings.Replace(l, "..", ".", -1) - l = strings.Trim(l, ".") - // If media type has no suffix, we have "index" type of layouts in this list, which - // doesn't make much sense. - if strings.Contains(l, ".") { - filteredLayouts = append(filteredLayouts, l) - } - } - - return filteredLayouts -} - -func replaceKeyValues(s string, oldNew ...string) string { - replacer := strings.NewReplacer(oldNew...) - return replacer.Replace(s) -} diff --git a/output/layout_test.go b/output/layout_test.go deleted file mode 100644 index 8f26bb6c4..000000000 --- a/output/layout_test.go +++ /dev/null @@ -1,169 +0,0 @@ -// Copyright 2017-present 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 output - -import ( - "fmt" - "reflect" - "strings" - "testing" - - "github.com/gohugoio/hugo/media" - - qt "github.com/frankban/quicktest" -) - -func TestLayout(t *testing.T) { - c := qt.New(t) - - noExtNoDelimMediaType := media.TextType - noExtNoDelimMediaType.Suffixes = nil - noExtNoDelimMediaType.Delimiter = "" - - noExtMediaType := media.TextType - noExtMediaType.Suffixes = nil - - var ( - ampType = Format{ - Name: "AMP", - MediaType: media.HTMLType, - BaseName: "index", - } - - htmlFormat = HTMLFormat - - noExtDelimFormat = Format{ - Name: "NEM", - MediaType: noExtNoDelimMediaType, - BaseName: "_redirects", - } - - noExt = Format{ - Name: "NEX", - MediaType: noExtMediaType, - BaseName: "next", - } - ) - - for _, this := range []struct { - name string - d LayoutDescriptor - layoutOverride string - tp Format - expect []string - expectCount int - }{ - {"Home", LayoutDescriptor{Kind: "home"}, "", ampType, - []string{"index.amp.html", "home.amp.html", "list.amp.html", "index.html", "home.html", "list.html", "_default/index.amp.html"}, 12}, - {"Home baseof", LayoutDescriptor{Kind: "home", Baseof: true}, "", ampType, - []string{"index-baseof.amp.html", "home-baseof.amp.html", "list-baseof.amp.html", "baseof.amp.html", "index-baseof.html"}, 16}, - {"Home, HTML", LayoutDescriptor{Kind: "home"}, "", htmlFormat, - // We will eventually get to index.html. This looks stuttery, but makes the lookup logic easy to understand. - []string{"index.html.html", "home.html.html"}, 12}, - {"Home, HTML, baseof", LayoutDescriptor{Kind: "home", Baseof: true}, "", htmlFormat, - []string{"index-baseof.html.html", "home-baseof.html.html", "list-baseof.html.html", "baseof.html.html"}, 16}, - {"Home, french language", LayoutDescriptor{Kind: "home", Lang: "fr"}, "", ampType, - []string{"index.fr.amp.html"}, - 24}, - {"Home, no ext or delim", LayoutDescriptor{Kind: "home"}, "", noExtDelimFormat, - []string{"index.nem", "home.nem", "list.nem"}, 6}, - {"Home, no ext", LayoutDescriptor{Kind: "home"}, "", noExt, - []string{"index.nex", "home.nex", "list.nex"}, 6}, - {"Page, no ext or delim", LayoutDescriptor{Kind: "page"}, "", noExtDelimFormat, - []string{"_default/single.nem"}, 1}, - {"Section", LayoutDescriptor{Kind: "section", Section: "sect1"}, "", ampType, - []string{"sect1/sect1.amp.html", "sect1/section.amp.html", "sect1/list.amp.html", "sect1/sect1.html", "sect1/section.html", "sect1/list.html", "section/sect1.amp.html", "section/section.amp.html"}, 18}, - {"Section, baseof", LayoutDescriptor{Kind: "section", Section: "sect1", Baseof: true}, "", ampType, - []string{"sect1/sect1-baseof.amp.html", "sect1/section-baseof.amp.html", "sect1/list-baseof.amp.html", "sect1/baseof.amp.html", "sect1/sect1-baseof.html", "sect1/section-baseof.html", "sect1/list-baseof.html", "sect1/baseof.html"}, 24}, - {"Section with layout", LayoutDescriptor{Kind: "section", Section: "sect1", Layout: "mylayout"}, "", ampType, - []string{"sect1/mylayout.amp.html", "sect1/sect1.amp.html", "sect1/section.amp.html", "sect1/list.amp.html", "sect1/mylayout.html", "sect1/sect1.html"}, 24}, - {"Taxonomy", LayoutDescriptor{Kind: "taxonomy", Section: "tag"}, "", ampType, - []string{"taxonomy/tag.amp.html", "taxonomy/taxonomy.amp.html", "taxonomy/list.amp.html", "taxonomy/tag.html", "taxonomy/taxonomy.html"}, 18}, - {"Taxonomy term", LayoutDescriptor{Kind: "taxonomyTerm", Section: "categories"}, "", ampType, - []string{"taxonomy/categories.terms.amp.html", "taxonomy/terms.amp.html", "taxonomy/list.amp.html", "taxonomy/categories.terms.html", "taxonomy/terms.html"}, 18}, - {"Page", LayoutDescriptor{Kind: "page"}, "", ampType, - []string{"_default/single.amp.html", "_default/single.html"}, 2}, - {"Page, baseof", LayoutDescriptor{Kind: "page", Baseof: true}, "", ampType, - []string{"_default/single-baseof.amp.html", "_default/baseof.amp.html", "_default/single-baseof.html", "_default/baseof.html"}, 4}, - {"Page with layout", LayoutDescriptor{Kind: "page", Layout: "mylayout"}, "", ampType, - []string{"_default/mylayout.amp.html", "_default/single.amp.html", "_default/mylayout.html", "_default/single.html"}, 4}, - {"Page with layout, baseof", LayoutDescriptor{Kind: "page", Layout: "mylayout", Baseof: true}, "", ampType, - []string{"_default/mylayout-baseof.amp.html", "_default/single-baseof.amp.html", "_default/baseof.amp.html", "_default/mylayout-baseof.html", "_default/single-baseof.html", "_default/baseof.html"}, 6}, - {"Page with layout and type", LayoutDescriptor{Kind: "page", Layout: "mylayout", Type: "myttype"}, "", ampType, - []string{"myttype/mylayout.amp.html", "myttype/single.amp.html", "myttype/mylayout.html"}, 8}, - {"Page with layout and type with subtype", LayoutDescriptor{Kind: "page", Layout: "mylayout", Type: "myttype/mysubtype"}, "", ampType, - []string{"myttype/mysubtype/mylayout.amp.html", "myttype/mysubtype/single.amp.html", "myttype/mysubtype/mylayout.html"}, 8}, - // RSS - {"RSS Home", LayoutDescriptor{Kind: "home"}, "", RSSFormat, - []string{"index.rss.xml", "home.rss.xml", "rss.xml"}, 15}, - {"RSS Home, baseof", LayoutDescriptor{Kind: "home", Baseof: true}, "", RSSFormat, - []string{"index-baseof.rss.xml", "home-baseof.rss.xml", "list-baseof.rss.xml", "baseof.rss.xml"}, 16}, - {"RSS Section", LayoutDescriptor{Kind: "section", Section: "sect1"}, "", RSSFormat, - []string{"sect1/sect1.rss.xml", "sect1/section.rss.xml", "sect1/rss.xml", "sect1/list.rss.xml", "sect1/sect1.xml", "sect1/section.xml"}, 22}, - {"RSS Taxonomy", LayoutDescriptor{Kind: "taxonomy", Section: "tag"}, "", RSSFormat, - []string{"taxonomy/tag.rss.xml", "taxonomy/taxonomy.rss.xml", "taxonomy/rss.xml", "taxonomy/list.rss.xml", "taxonomy/tag.xml", "taxonomy/taxonomy.xml"}, 22}, - {"RSS Taxonomy term", LayoutDescriptor{Kind: "taxonomyTerm", Section: "tag"}, "", RSSFormat, - []string{"taxonomy/tag.terms.rss.xml", "taxonomy/terms.rss.xml", "taxonomy/rss.xml", "taxonomy/list.rss.xml", "taxonomy/tag.terms.xml"}, 22}, - {"Home plain text", LayoutDescriptor{Kind: "home"}, "", JSONFormat, - []string{"index.json.json", "home.json.json"}, 12}, - {"Page plain text", LayoutDescriptor{Kind: "page"}, "", JSONFormat, - []string{"_default/single.json.json", "_default/single.json"}, 2}, - {"Reserved section, shortcodes", LayoutDescriptor{Kind: "section", Section: "shortcodes", Type: "shortcodes"}, "", ampType, - []string{"section/shortcodes.amp.html"}, 12}, - {"Reserved section, partials", LayoutDescriptor{Kind: "section", Section: "partials", Type: "partials"}, "", ampType, - []string{"section/partials.amp.html"}, 12}, - // This is currently always HTML only - {"404, HTML", LayoutDescriptor{Kind: "404"}, "", htmlFormat, - []string{"404.html.html", "404.html"}, 2}, - {"404, HTML baseof", LayoutDescriptor{Kind: "404", Baseof: true}, "", htmlFormat, - []string{"404-baseof.html.html", "baseof.html.html", "404-baseof.html", "baseof.html", "_default/404-baseof.html.html", "_default/baseof.html.html", "_default/404-baseof.html", "_default/baseof.html"}, 8}, - // We may add type support ... later. - {"Content hook", LayoutDescriptor{Kind: "render-link", RenderingHook: true, Layout: "mylayout", Section: "blog"}, "", ampType, - []string{"_default/_markup/render-link.amp.html", "_default/_markup/render-link.html"}, 2}, - } { - c.Run(this.name, func(c *qt.C) { - l := NewLayoutHandler() - - layouts, err := l.For(this.d, this.tp) - - c.Assert(err, qt.IsNil) - c.Assert(layouts, qt.Not(qt.IsNil), qt.Commentf(this.d.Kind)) - c.Assert(len(layouts) >= len(this.expect), qt.Equals, true, qt.Commentf("%d vs %d", len(layouts), len(this.expect))) - // Not checking the complete list for now ... - got := layouts[:len(this.expect)] - if len(layouts) != this.expectCount || !reflect.DeepEqual(got, this.expect) { - formatted := strings.Replace(fmt.Sprintf("%v", layouts), "[", "\"", 1) - formatted = strings.Replace(formatted, "]", "\"", 1) - formatted = strings.Replace(formatted, " ", "\", \"", -1) - - c.Fatalf("Got %d/%d:\n%v\nExpected:\n%v\nAll:\n%v\nFormatted:\n%s", len(layouts), this.expectCount, got, this.expect, layouts, formatted) - - } - - }) - } - -} - -func BenchmarkLayout(b *testing.B) { - c := qt.New(b) - descriptor := LayoutDescriptor{Kind: "taxonomyTerm", Section: "categories"} - l := NewLayoutHandler() - - for i := 0; i < b.N; i++ { - layouts, err := l.For(descriptor, HTMLFormat) - c.Assert(err, qt.IsNil) - c.Assert(layouts, qt.Not(qt.HasLen), 0) - } -} diff --git a/output/outputFormat.go b/output/outputFormat.go index c9c108ac5..8f3716e3e 100644 --- a/output/outputFormat.go +++ b/output/outputFormat.go @@ -11,6 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +// Package output contains Output Format types and functions. package output import ( @@ -19,20 +20,18 @@ import ( "sort" "strings" - "reflect" - - "github.com/mitchellh/mapstructure" - "github.com/gohugoio/hugo/media" ) // Format represents an output representation, usually to a file on disk. +// { "name": "OutputFormat" } type Format struct { - // The Name is used as an identifier. Internal output formats (i.e. HTML and RSS) + // The Name is used as an identifier. Internal output formats (i.e. html and rss) // can be overridden by providing a new definition for those types. - Name string `json:"name"` + // { "identifiers": ["html", "rss"] } + Name string `json:"-"` - MediaType media.Type `json:"mediaType"` + MediaType media.Type `json:"-"` // Must be set to a value when there are two or more conflicting mediatype for the same resource. Path string `json:"path"` @@ -40,14 +39,7 @@ type Format struct { // The base output file name used when not using "ugly URLs", defaults to "index". BaseName string `json:"baseName"` - // The value to use for rel links - // - // See https://www.w3schools.com/tags/att_link_rel.asp - // - // AMP has a special requirement in this department, see: - // https://www.ampproject.org/docs/guides/deploy/discovery - // I.e.: - // + // The value to use for rel links. Rel string `json:"rel"` // The protocol to use, i.e. "webcal://". Defaults to the protocol of the baseURL. @@ -64,30 +56,37 @@ type Format struct { // Enable to ignore the global uglyURLs setting. NoUgly bool `json:"noUgly"` + // Enable to override the global uglyURLs setting. + Ugly bool `json:"ugly"` + // Enable if it doesn't make sense to include this format in an alternative // format listing, CSS being one good example. // Note that we use the term "alternative" and not "alternate" here, as it // does not necessarily replace the other format, it is an alternative representation. NotAlternative bool `json:"notAlternative"` + // Eneable if this is a resource which path always starts at the root, + // e.g. /robots.txt. + Root bool `json:"root"` + // Setting this will make this output format control the value of // .Permalink and .RelPermalink for a rendered Page. // If not set, these values will point to the main (first) output format - // configured. That is probably the behaviour you want in most situations, + // configured. That is probably the behavior you want in most situations, // as you probably don't want to link back to the RSS version of a page, as an // example. AMP would, however, be a good example of an output format where this - // behaviour is wanted. + // behavior is wanted. Permalinkable bool `json:"permalinkable"` // Setting this to a non-zero value will be used as the first sort criteria. Weight int `json:"weight"` } -// An ordered list of built-in output formats. +// Built-in output formats. var ( AMPFormat = Format{ - Name: "AMP", - MediaType: media.HTMLType, + Name: "amp", + MediaType: media.Builtin.HTMLType, BaseName: "index", Path: "amp", Rel: "amphtml", @@ -97,8 +96,8 @@ var ( } CalendarFormat = Format{ - Name: "Calendar", - MediaType: media.CalendarType, + Name: "calendar", + MediaType: media.Builtin.CalendarType, IsPlainText: true, Protocol: "webcal://", BaseName: "index", @@ -106,24 +105,24 @@ var ( } CSSFormat = Format{ - Name: "CSS", - MediaType: media.CSSType, + Name: "css", + MediaType: media.Builtin.CSSType, BaseName: "styles", IsPlainText: true, Rel: "stylesheet", NotAlternative: true, } CSVFormat = Format{ - Name: "CSV", - MediaType: media.CSVType, + Name: "csv", + MediaType: media.Builtin.CSVType, BaseName: "index", IsPlainText: true, Rel: "alternate", } HTMLFormat = Format{ - Name: "HTML", - MediaType: media.HTMLType, + Name: "html", + MediaType: media.Builtin.HTMLType, BaseName: "index", Rel: "canonical", IsHTML: true, @@ -134,37 +133,91 @@ var ( Weight: 10, } + // Alias is the output format used for alias redirects. + AliasHTMLFormat = Format{ + Name: "alias", + MediaType: media.Builtin.HTMLType, + IsHTML: true, + Ugly: true, + Permalinkable: false, + } + + MarkdownFormat = Format{ + Name: "markdown", + MediaType: media.Builtin.MarkdownType, + BaseName: "index", + Rel: "alternate", + IsPlainText: true, + } + JSONFormat = Format{ - Name: "JSON", - MediaType: media.JSONType, + Name: "json", + MediaType: media.Builtin.JSONType, BaseName: "index", IsPlainText: true, Rel: "alternate", } + WebAppManifestFormat = Format{ + Name: "webappmanifest", + MediaType: media.Builtin.WebAppManifestType, + BaseName: "manifest", + IsPlainText: true, + NotAlternative: true, + Rel: "manifest", + } + RobotsTxtFormat = Format{ - Name: "ROBOTS", - MediaType: media.TextType, + Name: "robots", + MediaType: media.Builtin.TextType, BaseName: "robots", IsPlainText: true, + Root: true, Rel: "alternate", } RSSFormat = Format{ - Name: "RSS", - MediaType: media.RSSType, + Name: "rss", + MediaType: media.Builtin.RSSType, BaseName: "index", NoUgly: true, Rel: "alternate", } SitemapFormat = Format{ - Name: "Sitemap", - MediaType: media.XMLType, + Name: "sitemap", + MediaType: media.Builtin.XMLType, BaseName: "sitemap", - NoUgly: true, + Ugly: true, Rel: "sitemap", } + + SitemapIndexFormat = Format{ + Name: "sitemapindex", + MediaType: media.Builtin.XMLType, + BaseName: "sitemap", + Ugly: true, + Root: true, + Rel: "sitemap", + } + + GotmplFormat = Format{ + Name: "gotmpl", + MediaType: media.Builtin.GotmplType, + IsPlainText: true, + NotAlternative: true, + } + + // I'm not sure having a 404 format is a good idea, + // for one, we would want to have multiple formats for this. + HTTPStatus404HTMLFormat = Format{ + Name: "404", + MediaType: media.Builtin.HTMLType, + NotAlternative: true, + Ugly: true, + IsHTML: true, + Permalinkable: true, + } ) // DefaultFormats contains the default output formats supported by Hugo. @@ -174,10 +227,16 @@ var DefaultFormats = Formats{ CSSFormat, CSVFormat, HTMLFormat, + GotmplFormat, + HTTPStatus404HTMLFormat, + AliasHTMLFormat, JSONFormat, + MarkdownFormat, + WebAppManifestFormat, RobotsTxtFormat, RSSFormat, SitemapFormat, + SitemapIndexFormat, } func init() { @@ -185,6 +244,7 @@ func init() { } // Formats is a slice of Format. +// { "name": "OutputFormats" } type Formats []Format func (formats Formats) Len() int { return len(formats) } @@ -200,7 +260,6 @@ func (formats Formats) Less(i, j int) bool { } return fi.Weight > 0 && fi.Weight < fj.Weight - } // GetBySuffix gets a output format given as suffix, e.g. "html". @@ -209,14 +268,16 @@ func (formats Formats) Less(i, j int) bool { // The lookup is case insensitive. func (formats Formats) GetBySuffix(suffix string) (f Format, found bool) { for _, ff := range formats { - if strings.EqualFold(suffix, ff.MediaType.Suffix()) { - if found { - // ambiguous - found = false - return + for _, suffix2 := range ff.MediaType.Suffixes() { + if strings.EqualFold(suffix, suffix2) { + if found { + // ambiguous + found = false + return + } + f = ff + found = true } - f = ff - found = true } } return @@ -278,100 +339,23 @@ func (formats Formats) FromFilename(filename string) (f Format, found bool) { return } -// DecodeFormats takes a list of output format configurations and merges those, -// in the order given, with the Hugo defaults as the last resort. -func DecodeFormats(mediaTypes media.Types, maps ...map[string]interface{}) (Formats, error) { - f := make(Formats, len(DefaultFormats)) - copy(f, DefaultFormats) - - for _, m := range maps { - for k, v := range m { - found := false - for i, vv := range f { - if strings.EqualFold(k, vv.Name) { - // Merge it with the existing - if err := decode(mediaTypes, v, &f[i]); err != nil { - return f, err - } - found = true - } - } - if !found { - var newOutFormat Format - newOutFormat.Name = k - if err := decode(mediaTypes, v, &newOutFormat); err != nil { - return f, err - } - - // We need values for these - if newOutFormat.BaseName == "" { - newOutFormat.BaseName = "index" - } - if newOutFormat.Rel == "" { - newOutFormat.Rel = "alternate" - } - - f = append(f, newOutFormat) - } - } - } - - sort.Sort(f) - - return f, nil -} - -func decode(mediaTypes media.Types, input, output interface{}) error { - config := &mapstructure.DecoderConfig{ - Metadata: nil, - Result: output, - WeaklyTypedInput: true, - DecodeHook: func(a reflect.Type, b reflect.Type, c interface{}) (interface{}, error) { - if a.Kind() == reflect.Map { - dataVal := reflect.Indirect(reflect.ValueOf(c)) - for _, key := range dataVal.MapKeys() { - keyStr, ok := key.Interface().(string) - if !ok { - // Not a string key - continue - } - if strings.EqualFold(keyStr, "mediaType") { - // If mediaType is a string, look it up and replace it - // in the map. - vv := dataVal.MapIndex(key) - if mediaTypeStr, ok := vv.Interface().(string); ok { - mediaType, found := mediaTypes.GetByType(mediaTypeStr) - if !found { - return c, fmt.Errorf("media type %q not found", mediaTypeStr) - } - dataVal.SetMapIndex(key, reflect.ValueOf(mediaType)) - } - } - } - } - return c, nil - }, - } - - decoder, err := mapstructure.NewDecoder(config) - if err != nil { - return err - } - - return decoder.Decode(input) -} - // BaseFilename returns the base filename of f including an extension (ie. // "index.xml"). func (f Format) BaseFilename() string { - return f.BaseName + f.MediaType.FullSuffix() + return f.BaseName + f.MediaType.FirstSuffix.FullSuffix +} + +// IsZero returns true if f represents a zero value. +func (f Format) IsZero() bool { + return f.Name == "" } // MarshalJSON returns the JSON encoding of f. +// For internal use only. func (f Format) MarshalJSON() ([]byte, error) { type Alias Format return json.Marshal(&struct { - MediaType string + MediaType string `json:"mediaType"` Alias }{ MediaType: f.MediaType.String(), diff --git a/output/outputFormat_test.go b/output/outputFormat_test.go index 2b10c5a9e..5950c590c 100644 --- a/output/outputFormat_test.go +++ b/output/outputFormat_test.go @@ -19,72 +19,63 @@ import ( qt "github.com/frankban/quicktest" "github.com/gohugoio/hugo/media" - "github.com/google/go-cmp/cmp" -) - -var eq = qt.CmpEquals( - cmp.Comparer(func(m1, m2 media.Type) bool { - return m1.Type() == m2.Type() - }), - cmp.Comparer(func(o1, o2 Format) bool { - return o1.Name == o2.Name - }), ) func TestDefaultTypes(t *testing.T) { c := qt.New(t) - c.Assert(CalendarFormat.Name, qt.Equals, "Calendar") - c.Assert(CalendarFormat.MediaType, eq, media.CalendarType) + c.Assert(CalendarFormat.Name, qt.Equals, "calendar") + c.Assert(CalendarFormat.MediaType, qt.Equals, media.Builtin.CalendarType) c.Assert(CalendarFormat.Protocol, qt.Equals, "webcal://") c.Assert(CalendarFormat.Path, qt.HasLen, 0) c.Assert(CalendarFormat.IsPlainText, qt.Equals, true) c.Assert(CalendarFormat.IsHTML, qt.Equals, false) - c.Assert(CSSFormat.Name, qt.Equals, "CSS") - c.Assert(CSSFormat.MediaType, eq, media.CSSType) + c.Assert(CSSFormat.Name, qt.Equals, "css") + c.Assert(CSSFormat.MediaType, qt.Equals, media.Builtin.CSSType) c.Assert(CSSFormat.Path, qt.HasLen, 0) c.Assert(CSSFormat.Protocol, qt.HasLen, 0) // Will inherit the BaseURL protocol. c.Assert(CSSFormat.IsPlainText, qt.Equals, true) c.Assert(CSSFormat.IsHTML, qt.Equals, false) - c.Assert(CSVFormat.Name, qt.Equals, "CSV") - c.Assert(CSVFormat.MediaType, eq, media.CSVType) + c.Assert(CSVFormat.Name, qt.Equals, "csv") + c.Assert(CSVFormat.MediaType, qt.Equals, media.Builtin.CSVType) c.Assert(CSVFormat.Path, qt.HasLen, 0) c.Assert(CSVFormat.Protocol, qt.HasLen, 0) c.Assert(CSVFormat.IsPlainText, qt.Equals, true) c.Assert(CSVFormat.IsHTML, qt.Equals, false) c.Assert(CSVFormat.Permalinkable, qt.Equals, false) - c.Assert(HTMLFormat.Name, qt.Equals, "HTML") - c.Assert(HTMLFormat.MediaType, eq, media.HTMLType) + c.Assert(HTMLFormat.Name, qt.Equals, "html") + c.Assert(HTMLFormat.MediaType, qt.Equals, media.Builtin.HTMLType) c.Assert(HTMLFormat.Path, qt.HasLen, 0) c.Assert(HTMLFormat.Protocol, qt.HasLen, 0) c.Assert(HTMLFormat.IsPlainText, qt.Equals, false) c.Assert(HTMLFormat.IsHTML, qt.Equals, true) c.Assert(AMPFormat.Permalinkable, qt.Equals, true) - c.Assert(AMPFormat.Name, qt.Equals, "AMP") - c.Assert(AMPFormat.MediaType, eq, media.HTMLType) + c.Assert(AMPFormat.Name, qt.Equals, "amp") + c.Assert(AMPFormat.MediaType, qt.Equals, media.Builtin.HTMLType) c.Assert(AMPFormat.Path, qt.Equals, "amp") c.Assert(AMPFormat.Protocol, qt.HasLen, 0) c.Assert(AMPFormat.IsPlainText, qt.Equals, false) c.Assert(AMPFormat.IsHTML, qt.Equals, true) c.Assert(AMPFormat.Permalinkable, qt.Equals, true) - c.Assert(RSSFormat.Name, qt.Equals, "RSS") - c.Assert(RSSFormat.MediaType, eq, media.RSSType) + c.Assert(RSSFormat.Name, qt.Equals, "rss") + c.Assert(RSSFormat.MediaType, qt.Equals, media.Builtin.RSSType) c.Assert(RSSFormat.Path, qt.HasLen, 0) c.Assert(RSSFormat.IsPlainText, qt.Equals, false) c.Assert(RSSFormat.NoUgly, qt.Equals, true) c.Assert(CalendarFormat.IsHTML, qt.Equals, false) + c.Assert(len(DefaultFormats), qt.Equals, 15) } func TestGetFormatByName(t *testing.T) { c := qt.New(t) formats := Formats{AMPFormat, CalendarFormat} tp, _ := formats.GetByName("AMp") - c.Assert(tp, eq, AMPFormat) + c.Assert(tp, qt.Equals, AMPFormat) _, found := formats.GetByName("HTML") c.Assert(found, qt.Equals, false) _, found = formats.GetByName("FOO") @@ -96,9 +87,9 @@ func TestGetFormatByExt(t *testing.T) { formats1 := Formats{AMPFormat, CalendarFormat} formats2 := Formats{AMPFormat, HTMLFormat, CalendarFormat} tp, _ := formats1.GetBySuffix("html") - c.Assert(tp, eq, AMPFormat) + c.Assert(tp, qt.Equals, AMPFormat) tp, _ = formats1.GetBySuffix("ics") - c.Assert(tp, eq, CalendarFormat) + c.Assert(tp, qt.Equals, CalendarFormat) _, found := formats1.GetBySuffix("not") c.Assert(found, qt.Equals, false) @@ -109,10 +100,10 @@ func TestGetFormatByExt(t *testing.T) { func TestGetFormatByFilename(t *testing.T) { c := qt.New(t) - noExtNoDelimMediaType := media.TextType + noExtNoDelimMediaType := media.Builtin.TextType noExtNoDelimMediaType.Delimiter = "" - noExtMediaType := media.TextType + noExtMediaType := media.Builtin.TextType var ( noExtDelimFormat = Format{ @@ -130,124 +121,26 @@ func TestGetFormatByFilename(t *testing.T) { formats := Formats{AMPFormat, HTMLFormat, noExtDelimFormat, noExt, CalendarFormat} f, found := formats.FromFilename("my.amp.html") c.Assert(found, qt.Equals, true) - c.Assert(f, eq, AMPFormat) + c.Assert(f, qt.Equals, AMPFormat) _, found = formats.FromFilename("my.ics") c.Assert(found, qt.Equals, true) f, found = formats.FromFilename("my.html") c.Assert(found, qt.Equals, true) - c.Assert(f, eq, HTMLFormat) + c.Assert(f, qt.Equals, HTMLFormat) f, found = formats.FromFilename("my.nem") c.Assert(found, qt.Equals, true) - c.Assert(f, eq, noExtDelimFormat) + c.Assert(f, qt.Equals, noExtDelimFormat) f, found = formats.FromFilename("my.nex") c.Assert(found, qt.Equals, true) - c.Assert(f, eq, noExt) + c.Assert(f, qt.Equals, noExt) _, found = formats.FromFilename("my.css") c.Assert(found, qt.Equals, false) - -} - -func TestDecodeFormats(t *testing.T) { - c := qt.New(t) - - mediaTypes := media.Types{media.JSONType, media.XMLType} - - var tests = []struct { - name string - maps []map[string]interface{} - shouldError bool - assert func(t *testing.T, name string, f Formats) - }{ - { - "Redefine JSON", - []map[string]interface{}{ - { - "JsON": map[string]interface{}{ - "baseName": "myindex", - "isPlainText": "false"}}}, - false, - func(t *testing.T, name string, f Formats) { - msg := qt.Commentf(name) - c.Assert(len(f), qt.Equals, len(DefaultFormats), msg) - json, _ := f.GetByName("JSON") - c.Assert(json.BaseName, qt.Equals, "myindex") - c.Assert(json.MediaType, eq, media.JSONType) - c.Assert(json.IsPlainText, qt.Equals, false) - - }}, - { - "Add XML format with string as mediatype", - []map[string]interface{}{ - { - "MYXMLFORMAT": map[string]interface{}{ - "baseName": "myxml", - "mediaType": "application/xml", - }}}, - false, - func(t *testing.T, name string, f Formats) { - c.Assert(len(f), qt.Equals, len(DefaultFormats)+1) - xml, found := f.GetByName("MYXMLFORMAT") - c.Assert(found, qt.Equals, true) - c.Assert(xml.BaseName, qt.Equals, "myxml") - c.Assert(xml.MediaType, eq, media.XMLType) - - // Verify that we haven't changed the DefaultFormats slice. - json, _ := f.GetByName("JSON") - c.Assert(json.BaseName, qt.Equals, "index") - - }}, - { - "Add format unknown mediatype", - []map[string]interface{}{ - { - "MYINVALID": map[string]interface{}{ - "baseName": "mymy", - "mediaType": "application/hugo", - }}}, - true, - func(t *testing.T, name string, f Formats) { - - }}, - { - "Add and redefine XML format", - []map[string]interface{}{ - { - "MYOTHERXMLFORMAT": map[string]interface{}{ - "baseName": "myotherxml", - "mediaType": media.XMLType, - }}, - { - "MYOTHERXMLFORMAT": map[string]interface{}{ - "baseName": "myredefined", - }}, - }, - false, - func(t *testing.T, name string, f Formats) { - c.Assert(len(f), qt.Equals, len(DefaultFormats)+1) - xml, found := f.GetByName("MYOTHERXMLFORMAT") - c.Assert(found, qt.Equals, true) - c.Assert(xml.BaseName, qt.Equals, "myredefined") - c.Assert(xml.MediaType, eq, media.XMLType) - }}, - } - - for _, test := range tests { - result, err := DecodeFormats(mediaTypes, test.maps...) - msg := qt.Commentf(test.name) - - if test.shouldError { - c.Assert(err, qt.Not(qt.IsNil), msg) - } else { - c.Assert(err, qt.IsNil, msg) - test.assert(t, test.name, result) - } - } } func TestSort(t *testing.T) { c := qt.New(t) - c.Assert(DefaultFormats[0].Name, qt.Equals, "HTML") - c.Assert(DefaultFormats[1].Name, qt.Equals, "AMP") + c.Assert(DefaultFormats[0].Name, qt.Equals, "html") + c.Assert(DefaultFormats[1].Name, qt.Equals, "404") json := JSONFormat json.Weight = 1 @@ -260,8 +153,7 @@ func TestSort(t *testing.T) { sort.Sort(formats) - c.Assert(formats[0].Name, qt.Equals, "JSON") - c.Assert(formats[1].Name, qt.Equals, "HTML") - c.Assert(formats[2].Name, qt.Equals, "AMP") - + c.Assert(formats[0].Name, qt.Equals, "json") + c.Assert(formats[1].Name, qt.Equals, "html") + c.Assert(formats[2].Name, qt.Equals, "amp") } diff --git a/parser/frontmatter.go b/parser/frontmatter.go index 4965d3fe8..18e55f9ad 100644 --- a/parser/frontmatter.go +++ b/parser/frontmatter.go @@ -20,9 +20,11 @@ import ( "github.com/gohugoio/hugo/parser/metadecoders" - "github.com/BurntSushi/toml" + toml "github.com/pelletier/go-toml/v2" yaml "gopkg.in/yaml.v2" + + xml "github.com/clbanning/mxj/v2" ) const ( @@ -30,7 +32,7 @@ const ( tomlDelimLf = "+++\n" ) -func InterfaceToConfig(in interface{}, format metadecoders.Format, w io.Writer) error { +func InterfaceToConfig(in any, format metadecoders.Format, w io.Writer) error { if in == nil { return errors.New("input was nil") } @@ -46,7 +48,9 @@ func InterfaceToConfig(in interface{}, format metadecoders.Format, w io.Writer) return err case metadecoders.TOML: - return toml.NewEncoder(w).Encode(in) + enc := toml.NewEncoder(w) + enc.SetIndentTables(true) + return enc.Encode(in) case metadecoders.JSON: b, err := json.MarshalIndent(in, "", " ") if err != nil { @@ -60,13 +64,20 @@ func InterfaceToConfig(in interface{}, format metadecoders.Format, w io.Writer) _, err = w.Write([]byte{'\n'}) return err + case metadecoders.XML: + b, err := xml.AnyXmlIndent(in, "", "\t", "root") + if err != nil { + return err + } + _, err = w.Write(b) + return err default: return errors.New("unsupported Format provided") } } -func InterfaceToFrontMatter(in interface{}, format metadecoders.Format, w io.Writer) error { +func InterfaceToFrontMatter(in any, format metadecoders.Format, w io.Writer) error { if in == nil { return errors.New("input was nil") } @@ -93,12 +104,11 @@ func InterfaceToFrontMatter(in interface{}, format metadecoders.Format, w io.Wri } err = InterfaceToConfig(in, format, w) - if err != nil { return err } - _, err = w.Write([]byte("\n" + tomlDelimLf)) + _, err = w.Write([]byte(tomlDelimLf)) return err default: diff --git a/parser/frontmatter_test.go b/parser/frontmatter_test.go index 9d9b7c3b8..7b3ffc100 100644 --- a/parser/frontmatter_test.go +++ b/parser/frontmatter_test.go @@ -23,33 +23,33 @@ import ( func TestInterfaceToConfig(t *testing.T) { cases := []struct { - input interface{} + input any format metadecoders.Format want []byte isErr bool }{ // TOML - {map[string]interface{}{}, metadecoders.TOML, nil, false}, + {map[string]any{}, metadecoders.TOML, nil, false}, { - map[string]interface{}{"title": "test 1"}, + map[string]any{"title": "test' 1"}, metadecoders.TOML, - []byte("title = \"test 1\"\n"), + []byte("title = \"test' 1\"\n"), false, }, // YAML - {map[string]interface{}{}, metadecoders.YAML, []byte("{}\n"), false}, + {map[string]any{}, metadecoders.YAML, []byte("{}\n"), false}, { - map[string]interface{}{"title": "test 1"}, + map[string]any{"title": "test 1"}, metadecoders.YAML, []byte("title: test 1\n"), false, }, // JSON - {map[string]interface{}{}, metadecoders.JSON, []byte("{}\n"), false}, + {map[string]any{}, metadecoders.JSON, []byte("{}\n"), false}, { - map[string]interface{}{"title": "test 1"}, + map[string]any{"title": "test 1"}, metadecoders.JSON, []byte("{\n \"title\": \"test 1\"\n}\n"), false, @@ -57,7 +57,7 @@ func TestInterfaceToConfig(t *testing.T) { // Errors {nil, metadecoders.TOML, nil, true}, - {map[string]interface{}{}, "foo", nil, true}, + {map[string]any{}, "foo", nil, true}, } for i, c := range cases { diff --git a/parser/lowercase_camel_json.go b/parser/lowercase_camel_json.go index e7aeb2abf..9d89ff020 100644 --- a/parser/lowercase_camel_json.go +++ b/parser/lowercase_camel_json.go @@ -14,19 +14,42 @@ package parser import ( + "bytes" "encoding/json" "regexp" "unicode" "unicode/utf8" + + "github.com/gohugoio/hugo/common/hreflect" ) // Regexp definitions -var keyMatchRegex = regexp.MustCompile(`\"(\w+)\":`) -var wordBarrierRegex = regexp.MustCompile(`(\w)([A-Z])`) +var ( + keyMatchRegex = regexp.MustCompile(`\"(\w+)\":`) + nullEnableBoolRegex = regexp.MustCompile(`\"(enable\w+)\":null`) +) + +type NullBoolJSONMarshaller struct { + Wrapped json.Marshaler +} + +func (c NullBoolJSONMarshaller) MarshalJSON() ([]byte, error) { + b, err := c.Wrapped.MarshalJSON() + if err != nil { + return nil, err + } + return nullEnableBoolRegex.ReplaceAll(b, []byte(`"$1": false`)), nil +} // Code adapted from https://gist.github.com/piersy/b9934790a8892db1a603820c0c23e4a7 type LowerCaseCamelJSONMarshaller struct { - Value interface{} + Value any +} + +var preserveUpperCaseKeyRe = regexp.MustCompile(`^"HTTP`) + +func preserveUpperCaseKey(match []byte) bool { + return preserveUpperCaseKeyRe.Match(match) } func (c LowerCaseCamelJSONMarshaller) MarshalJSON() ([]byte, error) { @@ -35,9 +58,14 @@ func (c LowerCaseCamelJSONMarshaller) MarshalJSON() ([]byte, error) { converted := keyMatchRegex.ReplaceAllFunc( marshalled, func(match []byte) []byte { + // Attributes on the form XML, JSON etc. + if bytes.Equal(match, bytes.ToUpper(match)) { + return bytes.ToLower(match) + } + // Empty keys are valid JSON, only lowercase if we do not have an // empty key. - if len(match) > 2 { + if len(match) > 2 && !preserveUpperCaseKey(match) { // Decode first rune after the double quotes r, width := utf8.DecodeRune(match[1:]) r = unicode.ToLower(r) @@ -49,3 +77,56 @@ func (c LowerCaseCamelJSONMarshaller) MarshalJSON() ([]byte, error) { return converted, err } + +type ReplacingJSONMarshaller struct { + Value any + + KeysToLower bool + OmitEmpty bool +} + +func (c ReplacingJSONMarshaller) MarshalJSON() ([]byte, error) { + converted, err := json.Marshal(c.Value) + + if c.KeysToLower { + converted = keyMatchRegex.ReplaceAllFunc( + converted, + func(match []byte) []byte { + return bytes.ToLower(match) + }, + ) + } + + if c.OmitEmpty { + // It's tricky to do this with a regexp, so convert it to a map, remove zero values and convert back. + var m map[string]any + err = json.Unmarshal(converted, &m) + if err != nil { + return nil, err + } + var removeZeroVAlues func(m map[string]any) + removeZeroVAlues = func(m map[string]any) { + for k, v := range m { + if !hreflect.IsMap(v) && !hreflect.IsTruthful(v) { + delete(m, k) + } else { + switch vv := v.(type) { + case map[string]any: + removeZeroVAlues(vv) + case []any: + for _, vvv := range vv { + if m, ok := vvv.(map[string]any); ok { + removeZeroVAlues(m) + } + } + } + } + } + } + removeZeroVAlues(m) + converted, err = json.Marshal(m) + + } + + return converted, err +} diff --git a/parser/lowercase_camel_json_test.go b/parser/lowercase_camel_json_test.go new file mode 100644 index 000000000..ffbc80295 --- /dev/null +++ b/parser/lowercase_camel_json_test.go @@ -0,0 +1,33 @@ +package parser + +import ( + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestReplacingJSONMarshaller(t *testing.T) { + c := qt.New(t) + + m := map[string]any{ + "foo": "bar", + "baz": 42, + "zeroInt1": 0, + "zeroInt2": 0, + "zeroFloat": 0.0, + "zeroString": "", + "zeroBool": false, + "nil": nil, + } + + marshaller := ReplacingJSONMarshaller{ + Value: m, + KeysToLower: true, + OmitEmpty: true, + } + + b, err := marshaller.MarshalJSON() + c.Assert(err, qt.IsNil) + + c.Assert(string(b), qt.Equals, `{"baz":42,"foo":"bar"}`) +} diff --git a/parser/metadecoders/decoder.go b/parser/metadecoders/decoder.go index f90dc5703..419fbf4d2 100644 --- a/parser/metadecoders/decoder.go +++ b/parser/metadecoders/decoder.go @@ -18,27 +18,40 @@ import ( "encoding/csv" "encoding/json" "fmt" + "log" + "regexp" + "strconv" "strings" "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/common/maps" "github.com/niklasfasching/go-org/org" - "github.com/BurntSushi/toml" - "github.com/pkg/errors" + xml "github.com/clbanning/mxj/v2" + toml "github.com/pelletier/go-toml/v2" "github.com/spf13/afero" "github.com/spf13/cast" - jww "github.com/spf13/jwalterweatherman" yaml "gopkg.in/yaml.v2" ) // Decoder provides some configuration options for the decoders. type Decoder struct { - // Delimiter is the field delimiter used in the CSV decoder. It defaults to ','. + // Delimiter is the field delimiter. Used in the CSV decoder. Default is + // ','. Delimiter rune - // Comment, if not 0, is the comment character ued in the CSV decoder. Lines beginning with the - // Comment character without preceding whitespace are ignored. + // Comment, if not 0, is the comment character. Lines beginning with the + // Comment character without preceding whitespace are ignored. Used in the + // CSV decoder. Comment rune + + // If true, a quote may appear in an unquoted field and a non-doubled quote + // may appear in a quoted field. Used in the CSV decoder. Default is false. + LazyQuotes bool + + // The target data type, either slice or map. Used in the CSV decoder. + // Default is slice. + TargetType string } // OptionsKey is used in cache keys. @@ -46,33 +59,36 @@ func (d Decoder) OptionsKey() string { var sb strings.Builder sb.WriteRune(d.Delimiter) sb.WriteRune(d.Comment) + sb.WriteString(strconv.FormatBool(d.LazyQuotes)) + sb.WriteString(d.TargetType) return sb.String() } // Default is a Decoder in its default configuration. var Default = Decoder{ - Delimiter: ',', + Delimiter: ',', + TargetType: "slice", } // UnmarshalToMap will unmarshall data in format f into a new map. This is // what's needed for Hugo's front matter decoding. -func (d Decoder) UnmarshalToMap(data []byte, f Format) (map[string]interface{}, error) { - m := make(map[string]interface{}) +func (d Decoder) UnmarshalToMap(data []byte, f Format) (map[string]any, error) { + m := make(map[string]any) if data == nil { return m, nil } - err := d.unmarshal(data, f, &m) + err := d.UnmarshalTo(data, f, &m) return m, err } // UnmarshalFileToMap is the same as UnmarshalToMap, but reads the data from // the given filename. -func (d Decoder) UnmarshalFileToMap(fs afero.Fs, filename string) (map[string]interface{}, error) { +func (d Decoder) UnmarshalFileToMap(fs afero.Fs, filename string) (map[string]any, error) { format := FormatFromString(filename) if format == "" { - return nil, errors.Errorf("%q is not a valid configuration format", filename) + return nil, fmt.Errorf("%q is not a valid configuration format", filename) } data, err := afero.ReadFile(fs, filename) @@ -83,16 +99,16 @@ func (d Decoder) UnmarshalFileToMap(fs afero.Fs, filename string) (map[string]in } // UnmarshalStringTo tries to unmarshal data to a new instance of type typ. -func (d Decoder) UnmarshalStringTo(data string, typ interface{}) (interface{}, error) { +func (d Decoder) UnmarshalStringTo(data string, typ any) (any, error) { data = strings.TrimSpace(data) // We only check for the possible types in YAML, JSON and TOML. switch typ.(type) { case string: return data, nil - case map[string]interface{}: + case map[string]any, maps.Params: format := d.FormatFromContentString(data) return d.UnmarshalToMap([]byte(data), format) - case []interface{}: + case []any: // A standalone slice. Let YAML handle it. return d.Unmarshal([]byte(data), YAML) case bool: @@ -104,31 +120,36 @@ func (d Decoder) UnmarshalStringTo(data string, typ interface{}) (interface{}, e case float64: return cast.ToFloat64E(data) default: - return nil, errors.Errorf("unmarshal: %T not supportedd", typ) + return nil, fmt.Errorf("unmarshal: %T not supported", typ) } } // Unmarshal will unmarshall data in format f into an interface{}. // This is what's needed for Hugo's /data handling. -func (d Decoder) Unmarshal(data []byte, f Format) (interface{}, error) { - if data == nil { +func (d Decoder) Unmarshal(data []byte, f Format) (any, error) { + if len(data) == 0 { switch f { case CSV: - return make([][]string, 0), nil + switch d.TargetType { + case "map": + return make(map[string]any), nil + case "slice": + return make([][]string, 0), nil + default: + return nil, fmt.Errorf("invalid targetType: expected either slice or map, received %s", d.TargetType) + } default: - return make(map[string]interface{}), nil + return make(map[string]any), nil } - } - var v interface{} - err := d.unmarshal(data, f, &v) + var v any + err := d.UnmarshalTo(data, f, &v) return v, err } -// unmarshal unmarshals data in format f into v. -func (d Decoder) unmarshal(data []byte, f Format, v interface{}) error { - +// UnmarshalTo unmarshals data in format f into v. +func (d Decoder) UnmarshalTo(data []byte, f Format, v any) error { var err error switch f { @@ -136,103 +157,174 @@ func (d Decoder) unmarshal(data []byte, f Format, v interface{}) error { err = d.unmarshalORG(data, v) case JSON: err = json.Unmarshal(data, v) + case XML: + var xmlRoot xml.Map + xmlRoot, err = xml.NewMapXml(data) + + var xmlValue map[string]any + if err == nil { + xmlRootName, err := xmlRoot.Root() + if err != nil { + return toFileError(f, data, fmt.Errorf("failed to unmarshal XML: %w", err)) + } + + // Get the root value and verify it's a map + rootValue := xmlRoot[xmlRootName] + if rootValue == nil { + return toFileError(f, data, fmt.Errorf("XML root element '%s' has no value", xmlRootName)) + } + + // Type check before conversion + mapValue, ok := rootValue.(map[string]any) + if !ok { + return toFileError(f, data, fmt.Errorf("XML root element '%s' must be a map/object, got %T", xmlRootName, rootValue)) + } + xmlValue = mapValue + } + + switch v := v.(type) { + case *map[string]any: + *v = xmlValue + case *any: + *v = xmlValue + } case TOML: err = toml.Unmarshal(data, v) case YAML: err = yaml.Unmarshal(data, v) if err != nil { - return toFileError(f, errors.Wrap(err, "failed to unmarshal YAML")) + return toFileError(f, data, fmt.Errorf("failed to unmarshal YAML: %w", err)) } // To support boolean keys, the YAML package unmarshals maps to // map[interface{}]interface{}. Here we recurse through the result // and change all maps to map[string]interface{} like we would've // gotten from `json`. - var ptr interface{} - switch v.(type) { - case *map[string]interface{}: - ptr = *v.(*map[string]interface{}) - case *interface{}: - ptr = *v.(*interface{}) + var ptr any + switch vv := v.(type) { + case *map[string]any: + ptr = *vv + case *any: + ptr = *vv default: - return errors.Errorf("unknown type %T in YAML unmarshal", v) + // Not a map. } - if mm, changed := stringifyMapKeys(ptr); changed { - switch v.(type) { - case *map[string]interface{}: - *v.(*map[string]interface{}) = mm.(map[string]interface{}) - case *interface{}: - *v.(*interface{}) = mm + if ptr != nil { + if mm, changed := stringifyMapKeys(ptr); changed { + switch vv := v.(type) { + case *map[string]any: + *vv = mm.(map[string]any) + case *any: + *vv = mm + } } } case CSV: return d.unmarshalCSV(data, v) default: - return errors.Errorf("unmarshal of format %q is not supported", f) + return fmt.Errorf("unmarshal of format %q is not supported", f) } if err == nil { return nil } - return toFileError(f, errors.Wrap(err, "unmarshal failed")) - + return toFileError(f, data, fmt.Errorf("unmarshal failed: %w", err)) } -func (d Decoder) unmarshalCSV(data []byte, v interface{}) error { +func (d Decoder) unmarshalCSV(data []byte, v any) error { r := csv.NewReader(bytes.NewReader(data)) r.Comma = d.Delimiter r.Comment = d.Comment + r.LazyQuotes = d.LazyQuotes records, err := r.ReadAll() if err != nil { return err } - switch v.(type) { - case *interface{}: - *v.(*interface{}) = records - default: - return errors.Errorf("CSV cannot be unmarshaled into %T", v) + switch vv := v.(type) { + case *any: + switch d.TargetType { + case "map": + if len(records) < 2 { + return fmt.Errorf("cannot unmarshal CSV into %T: expected at least a header row and one data row", v) + } + seen := make(map[string]bool, len(records[0])) + for _, fieldName := range records[0] { + if seen[fieldName] { + return fmt.Errorf("cannot unmarshal CSV into %T: header row contains duplicate field names", v) + } + seen[fieldName] = true + } + + sm := make([]map[string]string, len(records)-1) + for i, record := range records[1:] { + m := make(map[string]string, len(records[0])) + for j, col := range record { + m[records[0][j]] = col + } + sm[i] = m + } + *vv = sm + case "slice": + *vv = records + default: + return fmt.Errorf("cannot unmarshal CSV into %T: invalid targetType: expected either slice or map, received %s", v, d.TargetType) + } + default: + return fmt.Errorf("cannot unmarshal CSV into %T", v) } return nil - } -func (d Decoder) unmarshalORG(data []byte, v interface{}) error { +func parseORGDate(s string) string { + r := regexp.MustCompile(`[<\[](\d{4}-\d{2}-\d{2}) .*[>\]]`) + if m := r.FindStringSubmatch(s); m != nil { + return m[1] + } + return s +} + +func (d Decoder) unmarshalORG(data []byte, v any) error { config := org.New() - config.Log = jww.WARN + config.Log = log.Default() // TODO(bep) document := config.Parse(bytes.NewReader(data), "") if document.Error != nil { return document.Error } - frontMatter := make(map[string]interface{}, len(document.BufferSettings)) + frontMatter := make(map[string]any, len(document.BufferSettings)) for k, v := range document.BufferSettings { k = strings.ToLower(k) if strings.HasSuffix(k, "[]") { frontMatter[k[:len(k)-2]] = strings.Fields(v) - } else if k == "tags" || k == "categories" || k == "aliases" { - jww.WARN.Printf("Please use '#+%s[]:' notation, automatic conversion is deprecated.", k) - frontMatter[k] = strings.Fields(v) + } else if strings.Contains(v, "\n") { + frontMatter[k] = strings.Split(v, "\n") + } else if k == "filetags" { + trimmed := strings.TrimPrefix(v, ":") + trimmed = strings.TrimSuffix(trimmed, ":") + frontMatter[k] = strings.Split(trimmed, ":") + } else if k == "date" || k == "lastmod" || k == "publishdate" || k == "expirydate" { + frontMatter[k] = parseORGDate(v) } else { frontMatter[k] = v } } - switch v.(type) { - case *map[string]interface{}: - *v.(*map[string]interface{}) = frontMatter - default: - *v.(*interface{}) = frontMatter + switch vv := v.(type) { + case *map[string]any: + *vv = frontMatter + case *any: + *vv = frontMatter } return nil } -func toFileError(f Format, err error) error { - return herrors.ToFileError(string(f), err) +func toFileError(f Format, data []byte, err error) error { + return herrors.NewFileErrorFromName(err, fmt.Sprintf("_stream.%s", f)).UpdateContent(bytes.NewReader(data), nil) } // stringifyMapKeys recurses into in and changes all instances of @@ -241,23 +333,22 @@ func toFileError(f Format, err error) error { // described here: https://github.com/go-yaml/yaml/issues/139 // // Inspired by https://github.com/stripe/stripe-mock, MIT licensed -func stringifyMapKeys(in interface{}) (interface{}, bool) { - +func stringifyMapKeys(in any) (any, bool) { switch in := in.(type) { - case []interface{}: + case []any: for i, v := range in { if vv, replaced := stringifyMapKeys(v); replaced { in[i] = vv } } - case map[string]interface{}: + case map[string]any: for k, v := range in { if vv, changed := stringifyMapKeys(v); changed { in[k] = vv } } - case map[interface{}]interface{}: - res := make(map[string]interface{}) + case map[any]any: + res := make(map[string]any) var ( ok bool err error diff --git a/parser/metadecoders/decoder_test.go b/parser/metadecoders/decoder_test.go index 3cb2e6365..d78293402 100644 --- a/parser/metadecoders/decoder_test.go +++ b/parser/metadecoders/decoder_test.go @@ -20,28 +20,86 @@ import ( qt "github.com/frankban/quicktest" ) +func TestUnmarshalXML(t *testing.T) { + c := qt.New(t) + + xmlDoc := ` + + + Example feed + https://example.com/ + Example feed + Hugo -- gohugo.io + en-us + Example + Fri, 08 Jan 2021 14:44:10 +0000 + + + Example title + https://example.com/2021/11/30/example-title/ + Tue, 30 Nov 2021 15:00:00 +0000 + https://example.com/2021/11/30/example-title/ + Example description + + + ` + + expect := map[string]any{ + "-atom": "http://www.w3.org/2005/Atom", "-version": "2.0", + "channel": map[string]any{ + "copyright": "Example", + "description": "Example feed", + "generator": "Hugo -- gohugo.io", + "item": map[string]any{ + "description": "Example description", + "guid": "https://example.com/2021/11/30/example-title/", + "link": "https://example.com/2021/11/30/example-title/", + "pubDate": "Tue, 30 Nov 2021 15:00:00 +0000", + "title": "Example title", + }, + "language": "en-us", + "lastBuildDate": "Fri, 08 Jan 2021 14:44:10 +0000", + "link": []any{"https://example.com/", map[string]any{ + "-href": "https://example.com/feed.xml", + "-rel": "self", + "-type": "application/rss+xml", + }}, + "title": "Example feed", + }, + } + + d := Default + + m, err := d.Unmarshal([]byte(xmlDoc), XML) + c.Assert(err, qt.IsNil) + c.Assert(m, qt.DeepEquals, expect) +} + func TestUnmarshalToMap(t *testing.T) { c := qt.New(t) - expect := map[string]interface{}{"a": "b"} + expect := map[string]any{"a": "b"} d := Default for i, test := range []struct { data string format Format - expect interface{} + expect any }{ {`a = "b"`, TOML, expect}, {`a: "b"`, YAML, expect}, // Make sure we get all string keys, even for YAML - {"a: Easy!\nb:\n c: 2\n d: [3, 4]", YAML, map[string]interface{}{"a": "Easy!", "b": map[string]interface{}{"c": 2, "d": []interface{}{3, 4}}}}, - {"a:\n true: 1\n false: 2", YAML, map[string]interface{}{"a": map[string]interface{}{"true": 1, "false": 2}}}, + {"a: Easy!\nb:\n c: 2\n d: [3, 4]", YAML, map[string]any{"a": "Easy!", "b": map[string]any{"c": 2, "d": []any{3, 4}}}}, + {"a:\n true: 1\n false: 2", YAML, map[string]any{"a": map[string]any{"true": 1, "false": 2}}}, {`{ "a": "b" }`, JSON, expect}, + {`b`, XML, expect}, {`#+a: b`, ORG, expect}, // errors {`a = b`, TOML, false}, {`a,b,c`, CSV, false}, // Use Unmarshal for CSV + {`just a string`, XML, false}, } { msg := qt.Commentf("%d: %s", i, test.format) m, err := d.UnmarshalToMap([]byte(test.data), test.format) @@ -57,27 +115,37 @@ func TestUnmarshalToMap(t *testing.T) { func TestUnmarshalToInterface(t *testing.T) { c := qt.New(t) - expect := map[string]interface{}{"a": "b"} + expect := map[string]any{"a": "b"} d := Default for i, test := range []struct { - data string + data []byte format Format - expect interface{} + expect any }{ - {`[ "Brecker", "Blake", "Redman" ]`, JSON, []interface{}{"Brecker", "Blake", "Redman"}}, - {`{ "a": "b" }`, JSON, expect}, - {`#+a: b`, ORG, expect}, - {`a = "b"`, TOML, expect}, - {`a: "b"`, YAML, expect}, - {`a,b,c`, CSV, [][]string{{"a", "b", "c"}}}, - {"a: Easy!\nb:\n c: 2\n d: [3, 4]", YAML, map[string]interface{}{"a": "Easy!", "b": map[string]interface{}{"c": 2, "d": []interface{}{3, 4}}}}, + {[]byte(`[ "Brecker", "Blake", "Redman" ]`), JSON, []any{"Brecker", "Blake", "Redman"}}, + {[]byte(`{ "a": "b" }`), JSON, expect}, + {[]byte(``), JSON, map[string]any{}}, + {[]byte(nil), JSON, map[string]any{}}, + {[]byte(`#+a: b`), ORG, expect}, + {[]byte("#+a: foo bar\n#+a: baz"), ORG, map[string]any{"a": []string{string("foo bar"), string("baz")}}}, + {[]byte(`#+DATE: <2020-06-26 Fri>`), ORG, map[string]any{"date": "2020-06-26"}}, + {[]byte(`#+LASTMOD: <2020-06-26 Fri>`), ORG, map[string]any{"lastmod": "2020-06-26"}}, + {[]byte(`#+FILETAGS: :work:`), ORG, map[string]any{"filetags": []string{"work"}}}, + {[]byte(`#+FILETAGS: :work:fun:`), ORG, map[string]any{"filetags": []string{"work", "fun"}}}, + {[]byte(`#+PUBLISHDATE: <2020-06-26 Fri>`), ORG, map[string]any{"publishdate": "2020-06-26"}}, + {[]byte(`#+EXPIRYDATE: <2020-06-26 Fri>`), ORG, map[string]any{"expirydate": "2020-06-26"}}, + {[]byte(`a = "b"`), TOML, expect}, + {[]byte(`a: "b"`), YAML, expect}, + {[]byte(`b`), XML, expect}, + {[]byte(`a,b,c`), CSV, [][]string{{"a", "b", "c"}}}, + {[]byte("a: Easy!\nb:\n c: 2\n d: [3, 4]"), YAML, map[string]any{"a": "Easy!", "b": map[string]any{"c": 2, "d": []any{3, 4}}}}, // errors - {`a = "`, TOML, false}, + {[]byte(`a = "`), TOML, false}, } { msg := qt.Commentf("%d: %s", i, test.format) - m, err := d.Unmarshal([]byte(test.data), test.format) + m, err := d.Unmarshal(test.data, test.format) if b, ok := test.expect.(bool); ok && !b { c.Assert(err, qt.Not(qt.IsNil), msg) } else { @@ -86,7 +154,6 @@ func TestUnmarshalToInterface(t *testing.T) { } } - } func TestUnmarshalStringTo(t *testing.T) { @@ -94,20 +161,20 @@ func TestUnmarshalStringTo(t *testing.T) { d := Default - expectMap := map[string]interface{}{"a": "b"} + expectMap := map[string]any{"a": "b"} for i, test := range []struct { data string - to interface{} - expect interface{} + to any + expect any }{ {"a string", "string", "a string"}, - {`{ "a": "b" }`, make(map[string]interface{}), expectMap}, + {`{ "a": "b" }`, make(map[string]any), expectMap}, {"32", int64(1234), int64(32)}, {"32", int(1234), int(32)}, {"3.14159", float64(1), float64(3.14159)}, - {"[3,7,9]", []interface{}{}, []interface{}{3, 7, 9}}, - {"[3.1,7.2,9.3]", []interface{}{}, []interface{}{3.1, 7.2, 9.3}}, + {"[3,7,9]", []any{}, []any{3, 7, 9}}, + {"[3.1,7.2,9.3]", []any{}, []any{3.1, 7.2, 9.3}}, } { msg := qt.Commentf("%d: %T", i, test.to) m, err := d.UnmarshalStringTo(test.data, test.to) @@ -123,43 +190,43 @@ func TestUnmarshalStringTo(t *testing.T) { func TestStringifyYAMLMapKeys(t *testing.T) { cases := []struct { - input interface{} - want interface{} + input any + want any replaced bool }{ { - map[interface{}]interface{}{"a": 1, "b": 2}, - map[string]interface{}{"a": 1, "b": 2}, + map[any]any{"a": 1, "b": 2}, + map[string]any{"a": 1, "b": 2}, true, }, { - map[interface{}]interface{}{"a": []interface{}{1, map[interface{}]interface{}{"b": 2}}}, - map[string]interface{}{"a": []interface{}{1, map[string]interface{}{"b": 2}}}, + map[any]any{"a": []any{1, map[any]any{"b": 2}}}, + map[string]any{"a": []any{1, map[string]any{"b": 2}}}, true, }, { - map[interface{}]interface{}{true: 1, "b": false}, - map[string]interface{}{"true": 1, "b": false}, + map[any]any{true: 1, "b": false}, + map[string]any{"true": 1, "b": false}, true, }, { - map[interface{}]interface{}{1: "a", 2: "b"}, - map[string]interface{}{"1": "a", "2": "b"}, + map[any]any{1: "a", 2: "b"}, + map[string]any{"1": "a", "2": "b"}, true, }, { - map[interface{}]interface{}{"a": map[interface{}]interface{}{"b": 1}}, - map[string]interface{}{"a": map[string]interface{}{"b": 1}}, + map[any]any{"a": map[any]any{"b": 1}}, + map[string]any{"a": map[string]any{"b": 1}}, true, }, { - map[string]interface{}{"a": map[string]interface{}{"b": 1}}, - map[string]interface{}{"a": map[string]interface{}{"b": 1}}, + map[string]any{"a": map[string]any{"b": 1}}, + map[string]any{"a": map[string]any{"b": 1}}, false, }, { - []interface{}{map[interface{}]interface{}{1: "a", 2: "b"}}, - []interface{}{map[string]interface{}{"1": "a", "2": "b"}}, + []any{map[any]any{1: "a", 2: "b"}}, + []any{map[string]any{"1": "a", "2": "b"}}, false, }, } @@ -180,18 +247,18 @@ func TestStringifyYAMLMapKeys(t *testing.T) { } func BenchmarkStringifyMapKeysStringsOnlyInterfaceMaps(b *testing.B) { - maps := make([]map[interface{}]interface{}, b.N) + maps := make([]map[any]any, b.N) for i := 0; i < b.N; i++ { - maps[i] = map[interface{}]interface{}{ - "a": map[interface{}]interface{}{ + maps[i] = map[any]any{ + "a": map[any]any{ "b": 32, "c": 43, - "d": map[interface{}]interface{}{ + "d": map[any]any{ "b": 32, "c": 43, }, }, - "b": []interface{}{"a", "b"}, + "b": []any{"a", "b"}, "c": "d", } } @@ -202,16 +269,16 @@ func BenchmarkStringifyMapKeysStringsOnlyInterfaceMaps(b *testing.B) { } func BenchmarkStringifyMapKeysStringsOnlyStringMaps(b *testing.B) { - m := map[string]interface{}{ - "a": map[string]interface{}{ + m := map[string]any{ + "a": map[string]any{ "b": 32, "c": 43, - "d": map[string]interface{}{ + "d": map[string]any{ "b": 32, "c": 43, }, }, - "b": []interface{}{"a", "b"}, + "b": []any{"a", "b"}, "c": "d", } @@ -222,18 +289,18 @@ func BenchmarkStringifyMapKeysStringsOnlyStringMaps(b *testing.B) { } func BenchmarkStringifyMapKeysIntegers(b *testing.B) { - maps := make([]map[interface{}]interface{}, b.N) + maps := make([]map[any]any, b.N) for i := 0; i < b.N; i++ { - maps[i] = map[interface{}]interface{}{ - 1: map[interface{}]interface{}{ + maps[i] = map[any]any{ + 1: map[any]any{ 4: 32, 5: 43, - 6: map[interface{}]interface{}{ + 6: map[any]any{ 7: 32, 8: 43, }, }, - 2: []interface{}{"a", "b"}, + 2: []any{"a", "b"}, 3: "d", } } @@ -242,3 +309,26 @@ func BenchmarkStringifyMapKeysIntegers(b *testing.B) { stringifyMapKeys(maps[i]) } } + +func BenchmarkDecodeYAMLToMap(b *testing.B) { + d := Default + + data := []byte(` +a: + v1: 32 + v2: 43 + v3: "foo" +b: + - a + - b +c: "d" + +`) + + for i := 0; i < b.N; i++ { + _, err := d.UnmarshalToMap(data, YAML) + if err != nil { + b.Fatal(err) + } + } +} diff --git a/parser/metadecoders/format.go b/parser/metadecoders/format.go index 4f81528c3..2e7e6964c 100644 --- a/parser/metadecoders/format.go +++ b/parser/metadecoders/format.go @@ -16,24 +16,31 @@ package metadecoders import ( "path/filepath" "strings" - - "github.com/gohugoio/hugo/media" - - "github.com/gohugoio/hugo/parser/pageparser" ) type Format string const ( - // These are the supported metdata formats in Hugo. Most of these are also + // These are the supported metadata formats in Hugo. Most of these are also // supported as /data formats. ORG Format = "org" JSON Format = "json" TOML Format = "toml" YAML Format = "yaml" CSV Format = "csv" + XML Format = "xml" ) +// FormatFromStrings returns the first non-empty Format from the given strings. +func FormatFromStrings(ss ...string) Format { + for _, s := range ss { + if f := FormatFromString(s); f != "" { + return f + } + } + return "" +} + // FormatFromString turns formatStr, typically a file extension without any ".", // into a Format. It returns an empty string for unknown formats. func FormatFromString(formatStr string) Format { @@ -41,7 +48,6 @@ func FormatFromString(formatStr string) Format { if strings.Contains(formatStr, ".") { // Assume a filename formatStr = strings.TrimPrefix(filepath.Ext(formatStr), ".") - } switch formatStr { case "yaml", "yml": @@ -54,61 +60,39 @@ func FormatFromString(formatStr string) Format { return ORG case "csv": return CSV - } - - return "" - -} - -// FormatFromMediaType gets the Format given a MIME type, empty string -// if unknown. -func FormatFromMediaType(m media.Type) Format { - for _, suffix := range m.Suffixes { - if f := FormatFromString(suffix); f != "" { - return f - } + case "xml": + return XML } return "" } -// FormatFromFrontMatterType will return empty if not supported. -func FormatFromFrontMatterType(typ pageparser.ItemType) Format { - switch typ { - case pageparser.TypeFrontMatterJSON: - return JSON - case pageparser.TypeFrontMatterORG: - return ORG - case pageparser.TypeFrontMatterTOML: - return TOML - case pageparser.TypeFrontMatterYAML: - return YAML - default: - return "" - } -} - -// FormatFromContentString tries to detect the format (JSON, YAML or TOML) +// FormatFromContentString tries to detect the format (JSON, YAML, TOML or XML) // in the given string. // It return an empty string if no format could be detected. func (d Decoder) FormatFromContentString(data string) Format { csvIdx := strings.IndexRune(data, d.Delimiter) jsonIdx := strings.Index(data, "{") yamlIdx := strings.Index(data, ":") + xmlIdx := strings.Index(data, "<") tomlIdx := strings.Index(data, "=") - if isLowerIndexThan(csvIdx, jsonIdx, yamlIdx, tomlIdx) { + if isLowerIndexThan(csvIdx, jsonIdx, yamlIdx, xmlIdx, tomlIdx) { return CSV } - if isLowerIndexThan(jsonIdx, yamlIdx, tomlIdx) { + if isLowerIndexThan(jsonIdx, yamlIdx, xmlIdx, tomlIdx) { return JSON } - if isLowerIndexThan(yamlIdx, tomlIdx) { + if isLowerIndexThan(yamlIdx, xmlIdx, tomlIdx) { return YAML } + if isLowerIndexThan(xmlIdx, tomlIdx) { + return XML + } + if tomlIdx != -1 { return TOML } diff --git a/parser/metadecoders/format_test.go b/parser/metadecoders/format_test.go index 74d105010..c70db3fb3 100644 --- a/parser/metadecoders/format_test.go +++ b/parser/metadecoders/format_test.go @@ -16,10 +16,6 @@ package metadecoders import ( "testing" - "github.com/gohugoio/hugo/media" - - "github.com/gohugoio/hugo/parser/pageparser" - qt "github.com/frankban/quicktest" ) @@ -32,6 +28,7 @@ func TestFormatFromString(t *testing.T) { {"json", JSON}, {"yaml", YAML}, {"yml", YAML}, + {"xml", XML}, {"toml", TOML}, {"config.toml", TOML}, {"tOMl", TOML}, @@ -42,44 +39,13 @@ func TestFormatFromString(t *testing.T) { } } -func TestFormatFromMediaType(t *testing.T) { - c := qt.New(t) - for _, test := range []struct { - m media.Type - expect Format - }{ - {media.JSONType, JSON}, - {media.YAMLType, YAML}, - {media.TOMLType, TOML}, - {media.CalendarType, ""}, - } { - c.Assert(FormatFromMediaType(test.m), qt.Equals, test.expect) - } -} - -func TestFormatFromFrontMatterType(t *testing.T) { - c := qt.New(t) - for _, test := range []struct { - typ pageparser.ItemType - expect Format - }{ - {pageparser.TypeFrontMatterJSON, JSON}, - {pageparser.TypeFrontMatterTOML, TOML}, - {pageparser.TypeFrontMatterYAML, YAML}, - {pageparser.TypeFrontMatterORG, ORG}, - {pageparser.TypeIgnore, ""}, - } { - c.Assert(FormatFromFrontMatterType(test.typ), qt.Equals, test.expect) - } -} - func TestFormatFromContentString(t *testing.T) { t.Parallel() c := qt.New(t) for i, test := range []struct { data string - expect interface{} + expect any }{ {`foo = "bar"`, TOML}, {` foo = "bar"`, TOML}, @@ -88,6 +54,7 @@ func TestFormatFromContentString(t *testing.T) { {`foo:"bar"`, YAML}, {`{ "foo": "bar"`, JSON}, {`a,b,c"`, CSV}, + {`bar"`, XML}, {`asdfasdf`, Format("")}, {``, Format("")}, } { diff --git a/parser/pageparser/doc.go b/parser/pageparser/doc.go new file mode 100644 index 000000000..96dfb7ce2 --- /dev/null +++ b/parser/pageparser/doc.go @@ -0,0 +1,18 @@ +// Copyright 2018 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 pageparser provides a parser for Hugo content files (Markdown, HTML etc.) in Hugo. +// This implementation is highly inspired by the great talk given by Rob Pike called "Lexical Scanning in Go" +// It's on YouTube, Google it!. +// See slides here: https://go.dev/talks/2011/lex.slide#1 +package pageparser diff --git a/parser/pageparser/item.go b/parser/pageparser/item.go index 48003ee86..7d63be0ad 100644 --- a/parser/pageparser/item.go +++ b/parser/pageparser/item.go @@ -18,23 +18,63 @@ import ( "fmt" "regexp" "strconv" + + "github.com/yuin/goldmark/util" ) +type lowHigh struct { + Low int + High int +} + type Item struct { - Type ItemType - Pos int - Val []byte + Type ItemType + Err error + + // The common case is a single segment. + low int + high int + + // This is the uncommon case. + segments []lowHigh + + // Used for validation. + firstByte byte + isString bool } type Items []Item -func (i Item) ValStr() string { - return string(i.Val) +func (i Item) Pos() int { + if len(i.segments) > 0 { + return i.segments[0].Low + } + return i.low } -func (i Item) ValTyped() interface{} { - str := i.ValStr() +func (i Item) Val(source []byte) []byte { + if len(i.segments) == 0 { + return source[i.low:i.high] + } + + if len(i.segments) == 1 { + return source[i.segments[0].Low:i.segments[0].High] + } + + var b bytes.Buffer + for _, s := range i.segments { + b.Write(source[s.Low:s.High]) + } + return b.Bytes() +} + +func (i Item) ValStr(source []byte) string { + return string(i.Val(source)) +} + +func (i Item) ValTyped(source []byte) any { + str := i.ValStr(source) if i.isString { // A quoted value that is a string even if it looks like a number etc. return str @@ -64,11 +104,15 @@ func (i Item) ValTyped() interface{} { } func (i Item) IsText() bool { - return i.Type == tText + return i.Type == tText || i.IsIndentation() } -func (i Item) IsNonWhitespace() bool { - return len(bytes.TrimSpace(i.Val)) > 0 +func (i Item) IsIndentation() bool { + return i.Type == tIndentation +} + +func (i Item) IsNonWhitespace(source []byte) bool { + return len(bytes.TrimSpace(i.Val(source))) > 0 } func (i Item) IsShortcodeName() bool { @@ -108,7 +152,7 @@ func (i Item) IsFrontMatter() bool { } func (i Item) IsDone() bool { - return i.Type == tError || i.Type == tEOF + return i.IsError() || i.IsEOF() } func (i Item) IsEOF() bool { @@ -119,18 +163,22 @@ func (i Item) IsError() bool { return i.Type == tError } -func (i Item) String() string { +func (i Item) ToString(source []byte) string { + val := i.Val(source) switch { - case i.Type == tEOF: + case i.IsEOF(): return "EOF" - case i.Type == tError: - return string(i.Val) + case i.IsError(): + return string(val) + case i.IsIndentation(): + return fmt.Sprintf("%s:[%s]", i.Type, util.VisualizeSpaces(val)) case i.Type > tKeywordMarker: - return fmt.Sprintf("<%s>", i.Val) - case len(i.Val) > 50: - return fmt.Sprintf("%v:%.20q...", i.Type, i.Val) + return fmt.Sprintf("<%s>", val) + case len(val) > 50: + return fmt.Sprintf("%v:%.20q...", i.Type, val) + default: + return fmt.Sprintf("%v:[%s]", i.Type, val) } - return fmt.Sprintf("%v:[%s]", i.Type, i.Val) } type ItemType int @@ -140,13 +188,11 @@ const ( tEOF // page items - TypeHTMLStart // document starting with < as first non-whitespace TypeLeadSummaryDivider // , # more TypeFrontMatterYAML TypeFrontMatterTOML TypeFrontMatterJSON TypeFrontMatterORG - TypeEmoji TypeIgnore // // The BOM Unicode byte order marker and possibly others // shortcode items @@ -160,6 +206,8 @@ const ( tScParam tScParamVal + tIndentation + tText // plain text // preserved for later - keywords come after this @@ -167,7 +215,7 @@ const ( ) var ( - boolRe = regexp.MustCompile(`^(true$)|(false$)`) + boolRe = regexp.MustCompile(`^(true|false)$`) intRe = regexp.MustCompile(`^[-+]?\d+$`) floatRe = regexp.MustCompile(`^[-+]?\d*\.\d+$`) ) diff --git a/parser/pageparser/item_test.go b/parser/pageparser/item_test.go index a30860f17..10dbfe895 100644 --- a/parser/pageparser/item_test.go +++ b/parser/pageparser/item_test.go @@ -22,14 +22,242 @@ import ( func TestItemValTyped(t *testing.T) { c := qt.New(t) - c.Assert(Item{Val: []byte("3.14")}.ValTyped(), qt.Equals, float64(3.14)) - c.Assert(Item{Val: []byte(".14")}.ValTyped(), qt.Equals, float64(.14)) - c.Assert(Item{Val: []byte("314")}.ValTyped(), qt.Equals, 314) - c.Assert(Item{Val: []byte("314x")}.ValTyped(), qt.Equals, "314x") - c.Assert(Item{Val: []byte("314 ")}.ValTyped(), qt.Equals, "314 ") - c.Assert(Item{Val: []byte("314"), isString: true}.ValTyped(), qt.Equals, "314") - c.Assert(Item{Val: []byte("true")}.ValTyped(), qt.Equals, true) - c.Assert(Item{Val: []byte("false")}.ValTyped(), qt.Equals, false) - c.Assert(Item{Val: []byte("trues")}.ValTyped(), qt.Equals, "trues") - + source := []byte("3.14") + c.Assert(Item{low: 0, high: len(source)}.ValTyped(source), qt.Equals, float64(3.14)) + source = []byte(".14") + c.Assert(Item{low: 0, high: len(source)}.ValTyped(source), qt.Equals, float64(0.14)) + source = []byte("314") + c.Assert(Item{low: 0, high: len(source)}.ValTyped(source), qt.Equals, 314) + source = []byte("314") + c.Assert(Item{low: 0, high: len(source), isString: true}.ValTyped(source), qt.Equals, "314") + source = []byte("314x") + c.Assert(Item{low: 0, high: len(source)}.ValTyped(source), qt.Equals, "314x") + source = []byte("314 ") + c.Assert(Item{low: 0, high: len(source)}.ValTyped(source), qt.Equals, "314 ") + source = []byte("true") + c.Assert(Item{low: 0, high: len(source)}.ValTyped(source), qt.Equals, true) + source = []byte("false") + c.Assert(Item{low: 0, high: len(source)}.ValTyped(source), qt.Equals, false) + source = []byte("falsex") + c.Assert(Item{low: 0, high: len(source)}.ValTyped(source), qt.Equals, "falsex") + source = []byte("xfalse") + c.Assert(Item{low: 0, high: len(source)}.ValTyped(source), qt.Equals, "xfalse") + source = []byte("truex") + c.Assert(Item{low: 0, high: len(source)}.ValTyped(source), qt.Equals, "truex") + source = []byte("xtrue") + c.Assert(Item{low: 0, high: len(source)}.ValTyped(source), qt.Equals, "xtrue") +} + +func TestItemBoolMethods(t *testing.T) { + c := qt.New(t) + + source := []byte(" shortcode ") + tests := []struct { + name string + item Item + source []byte + want bool + call func(Item, []byte) bool + }{ + { + name: "IsText true", + item: Item{Type: tText}, + call: func(i Item, _ []byte) bool { return i.IsText() }, + want: true, + }, + { + name: "IsIndentation false", + item: Item{Type: tText}, + call: func(i Item, _ []byte) bool { return i.IsIndentation() }, + want: false, + }, + { + name: "IsShortcodeName", + item: Item{Type: tScName}, + call: func(i Item, _ []byte) bool { return i.IsShortcodeName() }, + want: true, + }, + { + name: "IsNonWhitespace true", + item: Item{ + Type: tText, + low: 2, + high: 11, + }, + source: source, + call: func(i Item, src []byte) bool { return i.IsNonWhitespace(src) }, + want: true, + }, + { + name: "IsShortcodeParam false", + item: Item{Type: tScParamVal}, + call: func(i Item, _ []byte) bool { return i.IsShortcodeParam() }, + want: false, + }, + { + name: "IsInlineShortcodeName", + item: Item{Type: tScNameInline}, + call: func(i Item, _ []byte) bool { return i.IsInlineShortcodeName() }, + want: true, + }, + { + name: "IsLeftShortcodeDelim tLeftDelimScWithMarkup", + item: Item{Type: tLeftDelimScWithMarkup}, + call: func(i Item, _ []byte) bool { return i.IsLeftShortcodeDelim() }, + want: true, + }, + { + name: "IsLeftShortcodeDelim tLeftDelimScNoMarkup", + item: Item{Type: tLeftDelimScNoMarkup}, + call: func(i Item, _ []byte) bool { return i.IsLeftShortcodeDelim() }, + want: true, + }, + { + name: "IsRightShortcodeDelim tRightDelimScWithMarkup", + item: Item{Type: tRightDelimScWithMarkup}, + call: func(i Item, _ []byte) bool { return i.IsRightShortcodeDelim() }, + want: true, + }, + { + name: "IsRightShortcodeDelim tRightDelimScNoMarkup", + item: Item{Type: tRightDelimScNoMarkup}, + call: func(i Item, _ []byte) bool { return i.IsRightShortcodeDelim() }, + want: true, + }, + { + name: "IsShortcodeClose", + item: Item{Type: tScClose}, + call: func(i Item, _ []byte) bool { return i.IsShortcodeClose() }, + want: true, + }, + { + name: "IsShortcodeParamVal", + item: Item{Type: tScParamVal}, + call: func(i Item, _ []byte) bool { return i.IsShortcodeParamVal() }, + want: true, + }, + { + name: "IsShortcodeMarkupDelimiter tLeftDelimScWithMarkup", + item: Item{Type: tLeftDelimScWithMarkup}, + call: func(i Item, _ []byte) bool { return i.IsShortcodeMarkupDelimiter() }, + want: true, + }, + { + name: "IsShortcodeMarkupDelimiter tRightDelimScWithMarkup", + item: Item{Type: tRightDelimScWithMarkup}, + call: func(i Item, _ []byte) bool { return i.IsShortcodeMarkupDelimiter() }, + want: true, + }, + { + name: "IsFrontMatter TypeFrontMatterYAML", + item: Item{Type: TypeFrontMatterYAML}, + call: func(i Item, _ []byte) bool { return i.IsFrontMatter() }, + want: true, + }, + { + name: "IsFrontMatter TypeFrontMatterTOML", + item: Item{Type: TypeFrontMatterTOML}, + call: func(i Item, _ []byte) bool { return i.IsFrontMatter() }, + want: true, + }, + { + name: "IsFrontMatter TypeFrontMatterJSON", + item: Item{Type: TypeFrontMatterJSON}, + call: func(i Item, _ []byte) bool { return i.IsFrontMatter() }, + want: true, + }, + { + name: "IsFrontMatter TypeFrontMatterORG", + item: Item{Type: TypeFrontMatterORG}, + call: func(i Item, _ []byte) bool { return i.IsFrontMatter() }, + want: true, + }, + { + name: "IsDone tError", + item: Item{Type: tError}, + call: func(i Item, _ []byte) bool { return i.IsDone() }, + want: true, + }, + { + name: "IsDone tEOF", + item: Item{Type: tEOF}, + call: func(i Item, _ []byte) bool { return i.IsDone() }, + want: true, + }, + { + name: "IsEOF", + item: Item{Type: tEOF}, + call: func(i Item, _ []byte) bool { return i.IsEOF() }, + want: true, + }, + { + name: "IsError", + item: Item{Type: tError}, + call: func(i Item, _ []byte) bool { return i.IsError() }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.call(tt.item, tt.source) + c.Assert(got, qt.Equals, tt.want) + }) + } +} + +func TestItem_ToString(t *testing.T) { + c := qt.New(t) + + source := []byte("src") + long := make([]byte, 100) + for i := range long { + long[i] = byte(i) + } + + tests := []struct { + name string + item Item + source []byte + want string + call func(Item, []byte) string + }{ + { + name: "EOF", + item: Item{Type: tEOF}, + call: func(i Item, _ []byte) string { return i.ToString(source) }, + want: "EOF", + }, + { + name: "Error", + item: Item{Type: tError}, + call: func(i Item, _ []byte) string { return i.ToString(source) }, + want: "", + }, + { + name: "Indentation", + item: Item{Type: tIndentation}, + call: func(i Item, _ []byte) string { return i.ToString(source) }, + want: "tIndentation:[]", + }, + { + name: "Long", + item: Item{Type: tKeywordMarker + 1, low: 0, high: 100}, + call: func(i Item, _ []byte) string { return i.ToString(long) }, + want: "<" + string(long) + ">", + }, + { + name: "Empty", + item: Item{Type: tKeywordMarker + 1}, + call: func(i Item, _ []byte) string { return i.ToString([]byte("")) }, + want: "<>", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.call(tt.item, tt.source) + c.Assert(got, qt.Equals, tt.want) + }) + } } diff --git a/parser/pageparser/itemtype_string.go b/parser/pageparser/itemtype_string.go index 632afaecc..92a87b612 100644 --- a/parser/pageparser/itemtype_string.go +++ b/parser/pageparser/itemtype_string.go @@ -4,9 +4,35 @@ package pageparser import "strconv" -const _ItemType_name = "tErrortEOFTypeHTMLStartTypeLeadSummaryDividerTypeFrontMatterYAMLTypeFrontMatterTOMLTypeFrontMatterJSONTypeFrontMatterORGTypeEmojiTypeIgnoretLeftDelimScNoMarkuptRightDelimScNoMarkuptLeftDelimScWithMarkuptRightDelimScWithMarkuptScClosetScNametScNameInlinetScParamtScParamValtTexttKeywordMarker" +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[tError-0] + _ = x[tEOF-1] + _ = x[TypeLeadSummaryDivider-2] + _ = x[TypeFrontMatterYAML-3] + _ = x[TypeFrontMatterTOML-4] + _ = x[TypeFrontMatterJSON-5] + _ = x[TypeFrontMatterORG-6] + _ = x[TypeIgnore-7] + _ = x[tLeftDelimScNoMarkup-8] + _ = x[tRightDelimScNoMarkup-9] + _ = x[tLeftDelimScWithMarkup-10] + _ = x[tRightDelimScWithMarkup-11] + _ = x[tScClose-12] + _ = x[tScName-13] + _ = x[tScNameInline-14] + _ = x[tScParam-15] + _ = x[tScParamVal-16] + _ = x[tIndentation-17] + _ = x[tText-18] + _ = x[tKeywordMarker-19] +} -var _ItemType_index = [...]uint16{0, 6, 10, 23, 45, 64, 83, 102, 120, 129, 139, 159, 180, 202, 225, 233, 240, 253, 261, 272, 277, 291} +const _ItemType_name = "tErrortEOFTypeLeadSummaryDividerTypeFrontMatterYAMLTypeFrontMatterTOMLTypeFrontMatterJSONTypeFrontMatterORGTypeIgnoretLeftDelimScNoMarkuptRightDelimScNoMarkuptLeftDelimScWithMarkuptRightDelimScWithMarkuptScClosetScNametScNameInlinetScParamtScParamValtIndentationtTexttKeywordMarker" + +var _ItemType_index = [...]uint16{0, 6, 10, 32, 51, 70, 89, 107, 117, 137, 158, 180, 203, 211, 218, 231, 239, 250, 262, 267, 281} func (i ItemType) String() string { if i < 0 || i >= ItemType(len(_ItemType_index)-1) { diff --git a/parser/pageparser/pagelexer.go b/parser/pageparser/pagelexer.go index f994286d9..a5f64b037 100644 --- a/parser/pageparser/pagelexer.go +++ b/parser/pageparser/pagelexer.go @@ -11,10 +11,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package pageparser provides a parser for Hugo content files (Markdown, HTML etc.) in Hugo. -// This implementation is highly inspired by the great talk given by Rob Pike called "Lexical Scanning in Go" -// It's on YouTube, Google it!. -// See slides here: http://cuddle.googlecode.com/hg/talk/lex.html package pageparser import ( @@ -47,36 +43,38 @@ type pageLexer struct { summaryDivider []byte // Set when we have parsed any summary divider summaryDividerChecked bool - // Whether we're in a HTML comment. - isInHTMLComment bool lexerShortcodeState // items delivered to client items Items + + // error delivered to the client + err error } // Implement the Result interface func (l *pageLexer) Iterator() *Iterator { - return l.newIterator() + return NewIterator(l.items) } func (l *pageLexer) Input() []byte { return l.input - } type Config struct { - EnableEmoji bool + NoFrontMatter bool + NoSummaryDivider bool } // note: the input position here is normally 0 (start), but // can be set if position of first shortcode is known func newPageLexer(input []byte, stateStart stateFunc, cfg Config) *pageLexer { lexer := &pageLexer{ - input: input, - stateStart: stateStart, - cfg: cfg, + input: input, + stateStart: stateStart, + summaryDivider: summaryDivider, + cfg: cfg, lexerShortcodeState: lexerShortcodeState{ currLeftDelimItem: tLeftDelimScNoMarkup, currRightDelimItem: tRightDelimScNoMarkup, @@ -90,10 +88,6 @@ func newPageLexer(input []byte, stateStart stateFunc, cfg Config) *pageLexer { return lexer } -func (l *pageLexer) newIterator() *Iterator { - return &Iterator{l: l, lastPos: -1} -} - // main loop func (l *pageLexer) run() *pageLexer { for l.state = l.stateStart; l.state != nil; { @@ -110,10 +104,6 @@ var ( delimTOML = []byte("+++") delimYAML = []byte("---") delimOrg = []byte("#+") - htmlCommentStart = []byte("") - - emojiDelim = byte(':') ) func (l *pageLexer) next() rune { @@ -125,6 +115,7 @@ func (l *pageLexer) next() rune { runeValue, runeWidth := utf8.DecodeRune(l.input[l.pos:]) l.width = runeWidth l.pos += l.width + return runeValue } @@ -140,15 +131,47 @@ func (l *pageLexer) backup() { l.pos -= l.width } +func (l *pageLexer) append(item Item) { + if item.Pos() < len(l.input) { + item.firstByte = l.input[item.Pos()] + } + l.items = append(l.items, item) +} + // sends an item back to the client. func (l *pageLexer) emit(t ItemType) { - l.items = append(l.items, Item{t, l.start, l.input[l.start:l.pos], false}) - l.start = l.pos + defer func() { + l.start = l.pos + }() + + if t == tText { + // Identify any trailing whitespace/intendation. + // We currently only care about the last one. + for i := l.pos - 1; i >= l.start; i-- { + b := l.input[i] + if b != ' ' && b != '\t' && b != '\r' && b != '\n' { + break + } + if i == l.start && b != '\n' { + l.append(Item{Type: tIndentation, low: l.start, high: l.pos}) + return + } else if b == '\n' && i < l.pos-1 { + l.append(Item{Type: t, low: l.start, high: i + 1}) + l.append(Item{Type: tIndentation, low: i + 1, high: l.pos}) + return + } else if b == '\n' && i == l.pos-1 { + break + } + + } + } + + l.append(Item{Type: t, low: l.start, high: l.pos}) } // sends a string item back to the client. func (l *pageLexer) emitString(t ItemType) { - l.items = append(l.items, Item{t, l.start, l.input[l.start:l.pos], true}) + l.append(Item{Type: t, low: l.start, high: l.pos, isString: true}) l.start = l.pos } @@ -158,13 +181,36 @@ func (l *pageLexer) isEOF() bool { // special case, do not send '\\' back to client func (l *pageLexer) ignoreEscapesAndEmit(t ItemType, isString bool) { - val := bytes.Map(func(r rune) rune { + i := l.start + k := i + + var segments []lowHigh + + for i < l.pos { + r, w := utf8.DecodeRune(l.input[i:l.pos]) if r == '\\' { - return -1 + if i > k { + segments = append(segments, lowHigh{k, i}) + } + // See issue #10236. + // We don't send the backslash back to the client, + // which makes the end parsing simpler. + // This means that we cannot render the AST back to be + // exactly the same as the input, + // but that was also the situation before we introduced the issue in #10236. + k = i + w } - return r - }, l.input[l.start:l.pos]) - l.items = append(l.items, Item{t, l.start, val, isString}) + i += w + } + + if k < l.pos { + segments = append(segments, lowHigh{k, l.pos}) + } + + if len(segments) > 0 { + l.append(Item{Type: t, segments: segments}) + } + l.start = l.pos } @@ -181,8 +227,8 @@ func (l *pageLexer) ignore() { var lf = []byte("\n") // nil terminates the parser -func (l *pageLexer) errorf(format string, args ...interface{}) stateFunc { - l.items = append(l.items, Item{tError, l.start, []byte(fmt.Sprintf(format, args...)), true}) +func (l *pageLexer) errorf(format string, args ...any) stateFunc { + l.append(Item{Type: tError, Err: fmt.Errorf(format, args...), low: l.start, high: l.pos}) return nil } @@ -198,15 +244,6 @@ func (l *pageLexer) consumeCRLF() bool { return consumed } -func (l *pageLexer) consumeToNextLine() { - for { - r := l.next() - if r == eof || isEndOfLine(r) { - return - } - } -} - func (l *pageLexer) consumeToSpace() { for { r := l.next() @@ -227,34 +264,6 @@ func (l *pageLexer) consumeSpace() { } } -// lex a string starting at ":" -func lexEmoji(l *pageLexer) stateFunc { - pos := l.pos + 1 - valid := false - - for i := pos; i < len(l.input); i++ { - if i > pos && l.input[i] == emojiDelim { - pos = i + 1 - valid = true - break - } - r, _ := utf8.DecodeRune(l.input[i:]) - if !(isAlphaNumericOrHyphen(r) || r == '+') { - break - } - } - - if valid { - l.pos = pos - l.emit(TypeEmoji) - } else { - l.pos++ - l.emit(tText) - } - - return lexMainSection -} - type sectionHandlers struct { l *pageLexer @@ -290,6 +299,7 @@ func (s *sectionHandlers) skip() int { } func createSectionHandlers(l *pageLexer) *sectionHandlers { + handlers := make([]*sectionHandler, 0, 2) shortCodeHandler := §ionHandler{ l: l, @@ -326,45 +336,34 @@ func createSectionHandlers(l *pageLexer) *sectionHandlers { }, } - summaryDividerHandler := §ionHandler{ - l: l, - skipFunc: func(l *pageLexer) int { - if l.summaryDividerChecked || l.summaryDivider == nil { - return -1 + handlers = append(handlers, shortCodeHandler) - } - return l.index(l.summaryDivider) - }, - lexFunc: func(origin stateFunc, l *pageLexer) (stateFunc, bool) { - if !l.hasPrefix(l.summaryDivider) { - return origin, false - } - - l.summaryDividerChecked = true - l.pos += len(l.summaryDivider) - // This makes it a little easier to reason about later. - l.consumeSpace() - l.emit(TypeLeadSummaryDivider) - - return origin, true - - }, - } - - handlers := []*sectionHandler{shortCodeHandler, summaryDividerHandler} - - if l.cfg.EnableEmoji { - emojiHandler := §ionHandler{ + if !l.cfg.NoSummaryDivider { + summaryDividerHandler := §ionHandler{ l: l, skipFunc: func(l *pageLexer) int { - return l.indexByte(emojiDelim) + if l.summaryDividerChecked { + return -1 + } + return l.index(l.summaryDivider) }, lexFunc: func(origin stateFunc, l *pageLexer) (stateFunc, bool) { - return lexEmoji, true + if !l.hasPrefix(l.summaryDivider) { + return origin, false + } + + l.summaryDividerChecked = true + l.pos += len(l.summaryDivider) + // This makes it a little easier to reason about later. + l.consumeSpace() + l.emit(TypeLeadSummaryDivider) + + return origin, true }, } - handlers = append(handlers, emojiHandler) + handlers = append(handlers, summaryDividerHandler) + } return §ionHandlers{ @@ -429,15 +428,10 @@ func (s *sectionHandler) skip() int { } func lexMainSection(l *pageLexer) stateFunc { - if l.isEOF() { return lexDone } - if l.isInHTMLComment { - return lexEndFromtMatterHTMLComment - } - // Fast forward as far as possible. skip := l.sectionHandlers.skip() @@ -455,11 +449,9 @@ func lexMainSection(l *pageLexer) stateFunc { l.pos = len(l.input) return lexDone - } func lexDone(l *pageLexer) stateFunc { - // Done! if l.pos > l.start { l.emit(tText) @@ -468,6 +460,7 @@ func lexDone(l *pageLexer) stateFunc { return nil } +//lint:ignore U1000 useful for debugging func (l *pageLexer) printCurrentInput() { fmt.Printf("input[%d:]: %q", l.pos, string(l.input[l.pos:])) } @@ -478,10 +471,6 @@ func (l *pageLexer) index(sep []byte) int { return bytes.Index(l.input[l.pos:], sep) } -func (l *pageLexer) indexByte(sep byte) int { - return bytes.IndexByte(l.input[l.pos:], sep) -} - func (l *pageLexer) hasPrefix(prefix []byte) bool { return bytes.HasPrefix(l.input[l.pos:], prefix) } diff --git a/parser/pageparser/pagelexer_intro.go b/parser/pageparser/pagelexer_intro.go index 56dd4224d..a68a9e03a 100644 --- a/parser/pageparser/pagelexer_intro.go +++ b/parser/pageparser/pagelexer_intro.go @@ -11,15 +11,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package pageparser provides a parser for Hugo content files (Markdown, HTML etc.) in Hugo. -// This implementation is highly inspired by the great talk given by Rob Pike called "Lexical Scanning in Go" -// It's on YouTube, Google it!. -// See slides here: http://cuddle.googlecode.com/hg/talk/lex.html package pageparser func lexIntroSection(l *pageLexer) stateFunc { - l.summaryDivider = summaryDivider - LOOP: for { r := l.next() @@ -39,26 +33,6 @@ LOOP: case r == byteOrderMark: l.emit(TypeIgnore) case !isSpace(r) && !isEndOfLine(r): - if r == '<' { - l.backup() - if l.hasPrefix(htmlCommentStart) { - // This may be commented out front mattter, which should - // still be read. - l.consumeToNextLine() - l.isInHTMLComment = true - l.emit(TypeIgnore) - continue LOOP - } else { - if l.pos > l.start { - l.emit(tText) - } - l.next() - // This is the start of a plain HTML document with no - // front matter. I still can contain shortcodes, so we - // have to keep looking. - l.emit(TypeHTMLStart) - } - } break LOOP } } @@ -67,19 +41,6 @@ LOOP: return lexMainSection } -func lexEndFromtMatterHTMLComment(l *pageLexer) stateFunc { - l.isInHTMLComment = false - right := l.index(htmlCommentEnd) - if right == -1 { - return l.errorf("starting HTML comment with no end") - } - l.pos += right + len(htmlCommentEnd) - l.emit(TypeIgnore) - - // Now move on to the shortcodes. - return lexMainSection -} - func lexFrontMatterJSON(l *pageLexer) stateFunc { // Include the left delimiter l.backup() @@ -130,14 +91,14 @@ func lexFrontMatterOrgMode(l *pageLexer) stateFunc { #+DESCRIPTION: Just another golang parser for org content! */ - l.summaryDivider = summaryDividerOrg - l.backup() if !l.hasPrefix(delimOrg) { return lexMainSection } + l.summaryDivider = summaryDividerOrg + // Read lines until we no longer see a #+ prefix LOOP: for { @@ -158,13 +119,11 @@ LOOP: l.emit(TypeFrontMatterORG) return lexMainSection - } // Handle YAML or TOML front matter. func (l *pageLexer) lexFrontMatterSection(tp ItemType, delimr rune, name string, delim []byte) stateFunc { - - for i := 0; i < 2; i++ { + for range 2 { if r := l.next(); r != delimr { return l.errorf("invalid %s delimiter", name) } diff --git a/parser/pageparser/pagelexer_intro_test.go b/parser/pageparser/pagelexer_intro_test.go new file mode 100644 index 000000000..c074e6f50 --- /dev/null +++ b/parser/pageparser/pagelexer_intro_test.go @@ -0,0 +1,46 @@ +// Copyright 2024 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 pageparser + +import ( + "testing" + + qt "github.com/frankban/quicktest" +) + +func Test_lexIntroSection(t *testing.T) { + t.Parallel() + c := qt.New(t) + for i, tt := range []struct { + input string + expectItemType ItemType + expectSummaryDivider []byte + }{ + {"{\"title\": \"JSON\"}\n", TypeFrontMatterJSON, summaryDivider}, + {"#+TITLE: ORG\n", TypeFrontMatterORG, summaryDividerOrg}, + {"+++\ntitle = \"TOML\"\n+++\n", TypeFrontMatterTOML, summaryDivider}, + {"---\ntitle: YAML\n---\n", TypeFrontMatterYAML, summaryDivider}, + // Issue 13152 + {"# ATX Header Level 1\n", tText, summaryDivider}, + } { + errMsg := qt.Commentf("[%d] %v", i, tt.input) + + l := newPageLexer([]byte(tt.input), lexIntroSection, Config{}) + l.run() + + c.Assert(l.items[0].Type, qt.Equals, tt.expectItemType, errMsg) + c.Assert(l.summaryDivider, qt.DeepEquals, tt.expectSummaryDivider, errMsg) + + } +} diff --git a/parser/pageparser/pagelexer_shortcode.go b/parser/pageparser/pagelexer_shortcode.go index 61ba43f2c..535d8192c 100644 --- a/parser/pageparser/pagelexer_shortcode.go +++ b/parser/pageparser/pagelexer_shortcode.go @@ -11,10 +11,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package pageparser provides a parser for Hugo content files (Markdown, HTML etc.) in Hugo. -// This implementation is highly inspired by the great talk given by Rob Pike called "Lexical Scanning in Go" -// It's on YouTube, Google it!. -// See slides here: http://cuddle.googlecode.com/hg/talk/lex.html package pageparser type lexerShortcodeState struct { @@ -26,7 +22,6 @@ type lexerShortcodeState struct { elementStepNum int // step number in element paramElements int // number of elements (name + value = 2) found first openShortcodes map[string]bool // set of shortcodes in open state - } // Shortcode syntax @@ -88,7 +83,6 @@ func lexShortcodeRightDelim(l *pageLexer) stateFunc { // 5. `param` // 6. param=`123` func lexShortcodeParam(l *pageLexer, escapedQuoteStart bool) stateFunc { - first := true nextEq := false @@ -142,7 +136,6 @@ func lexShortcodeParam(l *pageLexer, escapedQuoteStart bool) stateFunc { l.emit(tScParam) return lexInsideShortcode - } func lexShortcodeParamVal(l *pageLexer) stateFunc { @@ -191,7 +184,7 @@ Loop: l.backup() break Loop } else if openQuoteFound { - // the coming quoute is inside + // the coming quote is inside escapedInnerQuoteFound = true escapedQuoteState = 1 } @@ -329,6 +322,7 @@ func lexInsideShortcode(l *pageLexer) stateFunc { } l.closingState++ l.isInline = false + l.elementStepNum = 0 l.emit(tScClose) case r == '\\': l.ignore() @@ -360,7 +354,6 @@ func (l *pageLexer) currentLeftShortcodeDelim() []byte { return leftDelimScWithMarkup } return leftDelimScNoMarkup - } func (l *pageLexer) currentRightShortcodeDelim() []byte { diff --git a/parser/pageparser/pagelexer_test.go b/parser/pageparser/pagelexer_test.go index 3bc3bf6ad..00669c27b 100644 --- a/parser/pageparser/pagelexer_test.go +++ b/parser/pageparser/pagelexer_test.go @@ -25,5 +25,4 @@ func TestMinIndex(t *testing.T) { c.Assert(minIndex(4, 0, -2, 2, 5), qt.Equals, 0) c.Assert(minIndex(), qt.Equals, -1) c.Assert(minIndex(-2, -3), qt.Equals, -1) - } diff --git a/parser/pageparser/pageparser.go b/parser/pageparser/pageparser.go index acdb09587..5c6f4b2ff 100644 --- a/parser/pageparser/pageparser.go +++ b/parser/pageparser/pageparser.go @@ -11,18 +11,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package pageparser provides a parser for Hugo content files (Markdown, HTML etc.) in Hugo. -// This implementation is highly inspired by the great talk given by Rob Pike called "Lexical Scanning in Go" -// It's on YouTube, Google it!. -// See slides here: http://cuddle.googlecode.com/hg/talk/lex.html package pageparser import ( "bytes" + "errors" + "fmt" "io" - "io/ioutil" + "regexp" + "strings" - "github.com/pkg/errors" + "github.com/gohugoio/hugo/parser/metadecoders" ) // Result holds the parse result. @@ -35,12 +34,76 @@ type Result interface { var _ Result = (*pageLexer)(nil) -// Parse parses the page in the given reader according to the given Config. -// TODO(bep) now that we have improved the "lazy order" init, it *may* be -// some potential saving in doing a buffered approach where the first pass does -// the frontmatter only. -func Parse(r io.Reader, cfg Config) (Result, error) { - return parseSection(r, cfg, lexIntroSection) +// ParseBytes parses the page in b according to the given Config. +func ParseBytes(b []byte, cfg Config) (Items, error) { + startLexer := lexIntroSection + if cfg.NoFrontMatter { + startLexer = lexMainSection + } + l, err := parseBytes(b, cfg, startLexer) + if err != nil { + return nil, err + } + return l.items, l.err +} + +type ContentFrontMatter struct { + Content []byte + FrontMatter map[string]any + FrontMatterFormat metadecoders.Format +} + +// ParseFrontMatterAndContent is a convenience method to extract front matter +// and content from a content page. +func ParseFrontMatterAndContent(r io.Reader) (ContentFrontMatter, error) { + var cf ContentFrontMatter + + input, err := io.ReadAll(r) + if err != nil { + return cf, fmt.Errorf("failed to read page content: %w", err) + } + + psr, err := ParseBytes(input, Config{}) + if err != nil { + return cf, err + } + + var frontMatterSource []byte + + iter := NewIterator(psr) + + walkFn := func(item Item) bool { + if frontMatterSource != nil { + // The rest is content. + cf.Content = input[item.low:] + // Done + return false + } else if item.IsFrontMatter() { + cf.FrontMatterFormat = FormatFromFrontMatterType(item.Type) + frontMatterSource = item.Val(input) + } + return true + } + + iter.PeekWalk(walkFn) + + cf.FrontMatter, err = metadecoders.Default.UnmarshalToMap(frontMatterSource, cf.FrontMatterFormat) + return cf, err +} + +func FormatFromFrontMatterType(typ ItemType) metadecoders.Format { + switch typ { + case TypeFrontMatterJSON: + return metadecoders.JSON + case TypeFrontMatterORG: + return metadecoders.ORG + case TypeFrontMatterTOML: + return metadecoders.TOML + case TypeFrontMatterYAML: + return metadecoders.YAML + default: + return "" + } } // ParseMain parses starting with the main section. Used in tests. @@ -49,23 +112,28 @@ func ParseMain(r io.Reader, cfg Config) (Result, error) { } func parseSection(r io.Reader, cfg Config, start stateFunc) (Result, error) { - b, err := ioutil.ReadAll(r) + b, err := io.ReadAll(r) if err != nil { - return nil, errors.Wrap(err, "failed to read page content") + return nil, fmt.Errorf("failed to read page content: %w", err) } return parseBytes(b, cfg, start) } -func parseBytes(b []byte, cfg Config, start stateFunc) (Result, error) { +func parseBytes(b []byte, cfg Config, start stateFunc) (*pageLexer, error) { lexer := newPageLexer(b, start, cfg) lexer.run() return lexer, nil } +// NewIterator creates a new Iterator. +func NewIterator(items Items) *Iterator { + return &Iterator{items: items, lastPos: -1} +} + // An Iterator has methods to iterate a parsed page with support going back // if needed. type Iterator struct { - l *pageLexer + items Items lastPos int // position of the last item returned by nextItem } @@ -75,19 +143,14 @@ func (t *Iterator) Next() Item { return t.Current() } -// Input returns the input source. -func (t *Iterator) Input() []byte { - return t.l.Input() -} - -var errIndexOutOfBounds = Item{tError, 0, []byte("no more tokens"), true} +var errIndexOutOfBounds = Item{Type: tError, Err: errors.New("no more tokens")} // Current will repeatably return the current item. func (t *Iterator) Current() Item { - if t.lastPos >= len(t.l.items) { + if t.lastPos >= len(t.items) { return errIndexOutOfBounds } - return t.l.items[t.lastPos] + return t.items[t.lastPos] } // backs up one token. @@ -98,6 +161,11 @@ func (t *Iterator) Backup() { t.lastPos-- } +// Pos returns the current position in the input. +func (t *Iterator) Pos() int { + return t.lastPos +} + // check for non-error and non-EOF types coming next func (t *Iterator) IsValueNext() bool { i := t.Peek() @@ -107,24 +175,24 @@ func (t *Iterator) IsValueNext() bool { // look at, but do not consume, the next item // repeated, sequential calls will return the same item func (t *Iterator) Peek() Item { - return t.l.items[t.lastPos+1] + return t.items[t.lastPos+1] } // PeekWalk will feed the next items in the iterator to walkFn // until it returns false. func (t *Iterator) PeekWalk(walkFn func(item Item) bool) { - for i := t.lastPos + 1; i < len(t.l.items); i++ { - item := t.l.items[i] + for i := t.lastPos + 1; i < len(t.items); i++ { + item := t.items[i] if !walkFn(item) { break } } } -// Consume is a convencience method to consume the next n tokens, +// Consume is a convenience method to consume the next n tokens, // but back off Errors and EOF. func (t *Iterator) Consume(cnt int) { - for i := 0; i < cnt; i++ { + for range cnt { token := t.Next() if token.Type == tError || token.Type == tEOF { t.Backup() @@ -134,6 +202,60 @@ func (t *Iterator) Consume(cnt int) { } // LineNumber returns the current line number. Used for logging. -func (t *Iterator) LineNumber() int { - return bytes.Count(t.l.input[:t.Current().Pos], lf) + 1 +func (t *Iterator) LineNumber(source []byte) int { + return bytes.Count(source[:t.Current().low], lf) + 1 +} + +// IsProbablySourceOfItems returns true if the given source looks like original +// source of the items. +// There may be some false positives, but that is highly unlikely and good enough +// for the planned purpose. +// It will also return false if the last item is not EOF (error situations) and +// true if both source and items are empty. +func IsProbablySourceOfItems(source []byte, items Items) bool { + if len(source) == 0 && len(items) == 0 { + return false + } + if len(items) == 0 { + return false + } + + last := items[len(items)-1] + if last.Type != tEOF { + return false + } + + if last.Pos() != len(source) { + return false + } + + for _, item := range items { + if item.Type == tError { + return false + } + if item.Type == tEOF { + return true + } + + if item.Pos() >= len(source) { + return false + } + + if item.firstByte != source[item.Pos()] { + return false + } + } + + return true +} + +var hasShortcodeRe = regexp.MustCompile(`{{[%,<][^\/]`) + +// HasShortcode returns true if the given string contains a shortcode. +func HasShortcode(s string) bool { + // Fast path for the common case. + if !strings.Contains(s, "{{") { + return false + } + return hasShortcodeRe.MatchString(s) } diff --git a/parser/pageparser/pageparser_intro_test.go b/parser/pageparser/pageparser_intro_test.go index 0f20ae5a1..12f4fc61c 100644 --- a/parser/pageparser/pageparser_intro_test.go +++ b/parser/pageparser/pageparser_intro_test.go @@ -15,19 +15,26 @@ package pageparser import ( "fmt" - "reflect" "strings" "testing" + + qt "github.com/frankban/quicktest" ) type lexerTest struct { name string input string - items []Item + items []typeText + err error } -func nti(tp ItemType, val string) Item { - return Item{tp, 0, []byte(val), false} +type typeText struct { + typ ItemType + text string +} + +func nti(tp ItemType, val string) typeText { + return typeText{typ: tp, text: val} } var ( @@ -38,7 +45,6 @@ var ( tstFrontMatterJSON = nti(TypeFrontMatterJSON, tstJSON+"\r\n") tstSomeText = nti(tText, "\nSome text.\n") tstSummaryDivider = nti(TypeLeadSummaryDivider, "\n") - tstHtmlStart = nti(TypeHTMLStart, "<") tstNewline = nti(tText, "\n") tstORG = ` @@ -53,48 +59,84 @@ var crLfReplacer = strings.NewReplacer("\r", "#", "\n", "$") // TODO(bep) a way to toggle ORG mode vs the rest. var frontMatterTests = []lexerTest{ - {"empty", "", []Item{tstEOF}}, - {"Byte order mark", "\ufeff\nSome text.\n", []Item{nti(TypeIgnore, "\ufeff"), tstSomeText, tstEOF}}, - {"HTML Document", ` `, []Item{nti(tText, " "), tstHtmlStart, nti(tText, "html> "), tstEOF}}, - {"HTML Document with shortcode", `{{< sc1 >}}`, []Item{tstHtmlStart, nti(tText, "html>"), tstLeftNoMD, tstSC1, tstRightNoMD, nti(tText, ""), tstEOF}}, - {"No front matter", "\nSome text.\n", []Item{tstSomeText, tstEOF}}, - {"YAML front matter", "---\nfoo: \"bar\"\n---\n\nSome text.\n", []Item{tstFrontMatterYAML, tstSomeText, tstEOF}}, - {"YAML empty front matter", "---\n---\n\nSome text.\n", []Item{nti(TypeFrontMatterYAML, ""), tstSomeText, tstEOF}}, - {"YAML commented out front matter", "\nSome text.\n", []Item{nti(TypeIgnore, ""), tstSomeText, tstEOF}}, - {"YAML commented out front matter, no end", "\nSome text.\n", []Item{tstFrontMatterTOML, tstSomeText, tstSummaryDivider, nti(tText, "Some text.\n"), tstEOF}}, - {"Summary divider same line", "+++\nfoo = \"bar\"\n+++\n\nSome text.Some text.\n", []Item{tstFrontMatterTOML, nti(tText, "\nSome text."), nti(TypeLeadSummaryDivider, ""), nti(tText, "Some text.\n"), tstEOF}}, + {"YAML front matter CRLF", "---\r\nfoo: \"bar\"\r\n---\n\nSome text.\n", []typeText{tstFrontMatterYAMLCRLF, tstSomeText, tstEOF}, nil}, + {"TOML front matter", "+++\nfoo = \"bar\"\n+++\n\nSome text.\n", []typeText{tstFrontMatterTOML, tstSomeText, tstEOF}, nil}, + {"JSON front matter", tstJSON + "\r\n\nSome text.\n", []typeText{tstFrontMatterJSON, tstSomeText, tstEOF}, nil}, + {"ORG front matter", tstORG + "\nSome text.\n", []typeText{tstFrontMatterORG, tstSomeText, tstEOF}, nil}, + {"Summary divider ORG", tstORG + "\nSome text.\n# more\nSome text.\n", []typeText{tstFrontMatterORG, tstSomeText, nti(TypeLeadSummaryDivider, "# more\n"), nti(tText, "Some text.\n"), tstEOF}, nil}, + {"Summary divider", "+++\nfoo = \"bar\"\n+++\n\nSome text.\n\nSome text.\n", []typeText{tstFrontMatterTOML, tstSomeText, tstSummaryDivider, nti(tText, "Some text.\n"), tstEOF}, nil}, + {"Summary divider same line", "+++\nfoo = \"bar\"\n+++\n\nSome text.Some text.\n", []typeText{tstFrontMatterTOML, nti(tText, "\nSome text."), nti(TypeLeadSummaryDivider, ""), nti(tText, "Some text.\n"), tstEOF}, nil}, // https://github.com/gohugoio/hugo/issues/5402 - {"Summary and shortcode, no space", "+++\nfoo = \"bar\"\n+++\n\nSome text.\n{{< sc1 >}}\nSome text.\n", []Item{tstFrontMatterTOML, tstSomeText, nti(TypeLeadSummaryDivider, ""), tstLeftNoMD, tstSC1, tstRightNoMD, tstSomeText, tstEOF}}, + {"Summary and shortcode, no space", "+++\nfoo = \"bar\"\n+++\n\nSome text.\n{{< sc1 >}}\nSome text.\n", []typeText{tstFrontMatterTOML, tstSomeText, nti(TypeLeadSummaryDivider, ""), tstLeftNoMD, tstSC1, tstRightNoMD, tstSomeText, tstEOF}, nil}, // https://github.com/gohugoio/hugo/issues/5464 - {"Summary and shortcode only", "+++\nfoo = \"bar\"\n+++\n{{< sc1 >}}\n\n{{< sc2 >}}", []Item{tstFrontMatterTOML, tstLeftNoMD, tstSC1, tstRightNoMD, tstNewline, tstSummaryDivider, tstLeftNoMD, tstSC2, tstRightNoMD, tstEOF}}, + {"Summary and shortcode only", "+++\nfoo = \"bar\"\n+++\n{{< sc1 >}}\n\n{{< sc2 >}}", []typeText{tstFrontMatterTOML, tstLeftNoMD, tstSC1, tstRightNoMD, tstNewline, tstSummaryDivider, tstLeftNoMD, tstSC2, tstRightNoMD, tstEOF}, nil}, } func TestFrontMatter(t *testing.T) { t.Parallel() + c := qt.New(t) for i, test := range frontMatterTests { - items := collect([]byte(test.input), false, lexIntroSection) - if !equal(items, test.items) { - got := crLfReplacer.Replace(fmt.Sprint(items)) - expected := crLfReplacer.Replace(fmt.Sprint(test.items)) - t.Errorf("[%d] %s: got\n\t%v\nexpected\n\t%v", i, test.name, got, expected) + items, err := collect([]byte(test.input), false, lexIntroSection) + if err != nil { + c.Assert(err, qt.Equals, test.err) + continue + } else { + c.Assert(test.err, qt.IsNil) + } + if !equal(test.input, items, test.items) { + got := itemsToString(items, []byte(test.input)) + expected := testItemsToString(test.items) + c.Assert(got, qt.Equals, expected, qt.Commentf("Test %d: %s", i, test.name)) } } } -func collectWithConfig(input []byte, skipFrontMatter bool, stateStart stateFunc, cfg Config) (items []Item) { +func itemsToString(items []Item, source []byte) string { + var sb strings.Builder + for i, item := range items { + var s string + if item.Err != nil { + s = item.Err.Error() + } else { + s = string(item.Val(source)) + } + sb.WriteString(fmt.Sprintf("%s: %s\n", item.Type, s)) + + if i < len(items)-1 { + sb.WriteString("\n") + } + } + return crLfReplacer.Replace(sb.String()) +} + +func testItemsToString(items []typeText) string { + var sb strings.Builder + for i, item := range items { + sb.WriteString(fmt.Sprintf("%s: %s\n", item.typ, item.text)) + + if i < len(items)-1 { + sb.WriteString("\n") + } + } + return crLfReplacer.Replace(sb.String()) +} + +func collectWithConfig(input []byte, skipFrontMatter bool, stateStart stateFunc, cfg Config) (items []Item, err error) { l := newPageLexer(input, stateStart, cfg) l.run() - t := l.newIterator() + iter := NewIterator(l.items) for { - item := t.Next() + if l.err != nil { + return nil, l.err + } + item := iter.Next() items = append(items, item) if item.Type == tEOF || item.Type == tError { break @@ -103,26 +145,40 @@ func collectWithConfig(input []byte, skipFrontMatter bool, stateStart stateFunc, return } -func collect(input []byte, skipFrontMatter bool, stateStart stateFunc) (items []Item) { +func collect(input []byte, skipFrontMatter bool, stateStart stateFunc) (items []Item, err error) { var cfg Config return collectWithConfig(input, skipFrontMatter, stateStart, cfg) +} +func collectStringMain(input string) ([]Item, error) { + return collect([]byte(input), true, lexMainSection) } // no positional checking, for now ... -func equal(i1, i2 []Item) bool { - if len(i1) != len(i2) { +func equal(source string, got []Item, expect []typeText) bool { + if len(got) != len(expect) { return false } - for k := range i1 { - if i1[k].Type != i2[k].Type { + sourceb := []byte(source) + for k := range got { + g := got[k] + e := expect[k] + if g.Type != e.typ { return false } - if !reflect.DeepEqual(i1[k].Val, i2[k].Val) { + var s string + if g.Err != nil { + s = g.Err.Error() + } else { + s = string(g.Val(sourceb)) + } + + if s != e.text { return false } + } return true } diff --git a/parser/pageparser/pageparser_main_test.go b/parser/pageparser/pageparser_main_test.go deleted file mode 100644 index 008c88c51..000000000 --- a/parser/pageparser/pageparser_main_test.go +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2018 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 pageparser - -import ( - "fmt" - "testing" -) - -func TestMain(t *testing.T) { - t.Parallel() - - var mainTests = []lexerTest{ - {"emoji #1", "Some text with :emoji:", []Item{nti(tText, "Some text with "), nti(TypeEmoji, ":emoji:"), tstEOF}}, - {"emoji #2", "Some text with :emoji: and some text.", []Item{nti(tText, "Some text with "), nti(TypeEmoji, ":emoji:"), nti(tText, " and some text."), tstEOF}}, - {"looks like an emoji #1", "Some text and then :emoji", []Item{nti(tText, "Some text and then "), nti(tText, ":"), nti(tText, "emoji"), tstEOF}}, - {"looks like an emoji #2", "Some text and then ::", []Item{nti(tText, "Some text and then "), nti(tText, ":"), nti(tText, ":"), tstEOF}}, - {"looks like an emoji #3", ":Some :text", []Item{nti(tText, ":"), nti(tText, "Some "), nti(tText, ":"), nti(tText, "text"), tstEOF}}, - } - - for i, test := range mainTests { - items := collectWithConfig([]byte(test.input), false, lexMainSection, Config{EnableEmoji: true}) - if !equal(items, test.items) { - got := crLfReplacer.Replace(fmt.Sprint(items)) - expected := crLfReplacer.Replace(fmt.Sprint(test.items)) - t.Errorf("[%d] %s: got\n\t%v\nexpected\n\t%v", i, test.name, got, expected) - } - } -} diff --git a/parser/pageparser/pageparser_shortcode_test.go b/parser/pageparser/pageparser_shortcode_test.go index b8bf5f727..29626b6ad 100644 --- a/parser/pageparser/pageparser_shortcode_test.go +++ b/parser/pageparser/pageparser_shortcode_test.go @@ -15,45 +15,48 @@ package pageparser import ( "testing" + + qt "github.com/frankban/quicktest" ) var ( - tstEOF = nti(tEOF, "") - tstLeftNoMD = nti(tLeftDelimScNoMarkup, "{{<") - tstRightNoMD = nti(tRightDelimScNoMarkup, ">}}") - tstLeftMD = nti(tLeftDelimScWithMarkup, "{{%") - tstRightMD = nti(tRightDelimScWithMarkup, "%}}") - tstSCClose = nti(tScClose, "/") - tstSC1 = nti(tScName, "sc1") - tstSC1Inline = nti(tScNameInline, "sc1.inline") - tstSC2Inline = nti(tScNameInline, "sc2.inline") - tstSC2 = nti(tScName, "sc2") - tstSC3 = nti(tScName, "sc3") - tstSCSlash = nti(tScName, "sc/sub") - tstParam1 = nti(tScParam, "param1") - tstParam2 = nti(tScParam, "param2") - tstParamBoolTrue = nti(tScParam, "true") - tstParamBoolFalse = nti(tScParam, "false") - tstParamInt = nti(tScParam, "32") - tstParamFloat = nti(tScParam, "3.14") - tstVal = nti(tScParamVal, "Hello World") - tstText = nti(tText, "Hello World") + tstEOF = nti(tEOF, "") + tstLeftNoMD = nti(tLeftDelimScNoMarkup, "{{<") + tstRightNoMD = nti(tRightDelimScNoMarkup, ">}}") + tstLeftMD = nti(tLeftDelimScWithMarkup, "{{%") + tstRightMD = nti(tRightDelimScWithMarkup, "%}}") + tstSCClose = nti(tScClose, "/") + tstSC1 = nti(tScName, "sc1") + tstSC1Inline = nti(tScNameInline, "sc1.inline") + tstSC2Inline = nti(tScNameInline, "sc2.inline") + tstSC2 = nti(tScName, "sc2") + tstSC3 = nti(tScName, "sc3") + tstSCSlash = nti(tScName, "sc/sub") + tstParam1 = nti(tScParam, "param1") + tstParam2 = nti(tScParam, "param2") + tstVal = nti(tScParamVal, "Hello World") + tstText = nti(tText, "Hello World") ) var shortCodeLexerTests = []lexerTest{ - {"empty", "", []Item{tstEOF}}, - {"spaces", " \t\n", []Item{nti(tText, " \t\n"), tstEOF}}, - {"text", `to be or not`, []Item{nti(tText, "to be or not"), tstEOF}}, - {"no markup", `{{< sc1 >}}`, []Item{tstLeftNoMD, tstSC1, tstRightNoMD, tstEOF}}, - {"with EOL", "{{< sc1 \n >}}", []Item{tstLeftNoMD, tstSC1, tstRightNoMD, tstEOF}}, + {"empty", "", []typeText{tstEOF}, nil}, + {"spaces", " \t\n", []typeText{nti(tText, " \t\n"), tstEOF}, nil}, + {"text", `to be or not`, []typeText{nti(tText, "to be or not"), tstEOF}, nil}, + {"no markup", `{{< sc1 >}}`, []typeText{tstLeftNoMD, tstSC1, tstRightNoMD, tstEOF}, nil}, + {"with EOL", "{{< sc1 \n >}}", []typeText{tstLeftNoMD, tstSC1, tstRightNoMD, tstEOF}, nil}, - {"forward slash inside name", `{{< sc/sub >}}`, []Item{tstLeftNoMD, tstSCSlash, tstRightNoMD, tstEOF}}, + {"forward slash inside name", `{{< sc/sub >}}`, []typeText{tstLeftNoMD, tstSCSlash, tstRightNoMD, tstEOF}, nil}, - {"simple with markup", `{{% sc1 %}}`, []Item{tstLeftMD, tstSC1, tstRightMD, tstEOF}}, - {"with spaces", `{{< sc1 >}}`, []Item{tstLeftNoMD, tstSC1, tstRightNoMD, tstEOF}}, - {"mismatched rightDelim", `{{< sc1 %}}`, []Item{tstLeftNoMD, tstSC1, - nti(tError, "unrecognized character in shortcode action: U+0025 '%'. Note: Parameters with non-alphanumeric args must be quoted")}}, - {"inner, markup", `{{% sc1 %}} inner {{% /sc1 %}}`, []Item{ + {"simple with markup", `{{% sc1 %}}`, []typeText{tstLeftMD, tstSC1, tstRightMD, tstEOF}, nil}, + {"with spaces", `{{< sc1 >}}`, []typeText{tstLeftNoMD, tstSC1, tstRightNoMD, tstEOF}, nil}, + {"indented on new line", "Hello\n {{% sc1 %}}", []typeText{nti(tText, "Hello\n"), nti(tIndentation, " "), tstLeftMD, tstSC1, tstRightMD, tstEOF}, nil}, + {"indented on new line tab", "Hello\n\t{{% sc1 %}}", []typeText{nti(tText, "Hello\n"), nti(tIndentation, "\t"), tstLeftMD, tstSC1, tstRightMD, tstEOF}, nil}, + {"indented on first line", " {{% sc1 %}}", []typeText{nti(tIndentation, " "), tstLeftMD, tstSC1, tstRightMD, tstEOF}, nil}, + {"mismatched rightDelim", `{{< sc1 %}}`, []typeText{ + tstLeftNoMD, tstSC1, + nti(tError, "unrecognized character in shortcode action: U+0025 '%'. Note: Parameters with non-alphanumeric args must be quoted"), + }, nil}, + {"inner, markup", `{{% sc1 %}} inner {{% /sc1 %}}`, []typeText{ tstLeftMD, tstSC1, tstRightMD, @@ -63,60 +66,83 @@ var shortCodeLexerTests = []lexerTest{ tstSC1, tstRightMD, tstEOF, - }}, - {"close, but no open", `{{< /sc1 >}}`, []Item{ - tstLeftNoMD, nti(tError, "got closing shortcode, but none is open")}}, - {"close wrong", `{{< sc1 >}}{{< /another >}}`, []Item{ + }, nil}, + {"close, but no open", `{{< /sc1 >}}`, []typeText{ + tstLeftNoMD, nti(tError, "got closing shortcode, but none is open"), + }, nil}, + {"close wrong", `{{< sc1 >}}{{< /another >}}`, []typeText{ tstLeftNoMD, tstSC1, tstRightNoMD, tstLeftNoMD, tstSCClose, - nti(tError, "closing tag for shortcode 'another' does not match start tag")}}, - {"close, but no open, more", `{{< sc1 >}}{{< /sc1 >}}{{< /another >}}`, []Item{ + nti(tError, "closing tag for shortcode 'another' does not match start tag"), + }, nil}, + {"close, but no open, more", `{{< sc1 >}}{{< /sc1 >}}{{< /another >}}`, []typeText{ tstLeftNoMD, tstSC1, tstRightNoMD, tstLeftNoMD, tstSCClose, tstSC1, tstRightNoMD, tstLeftNoMD, tstSCClose, - nti(tError, "closing tag for shortcode 'another' does not match start tag")}}, - {"close with extra keyword", `{{< sc1 >}}{{< /sc1 keyword>}}`, []Item{ + nti(tError, "closing tag for shortcode 'another' does not match start tag"), + }, nil}, + {"close with extra keyword", `{{< sc1 >}}{{< /sc1 keyword>}}`, []typeText{ tstLeftNoMD, tstSC1, tstRightNoMD, tstLeftNoMD, tstSCClose, tstSC1, - nti(tError, "unclosed shortcode")}}, - {"float param, positional", `{{< sc1 3.14 >}}`, []Item{ - tstLeftNoMD, tstSC1, nti(tScParam, "3.14"), tstRightNoMD, tstEOF}}, - {"float param, named", `{{< sc1 param1=3.14 >}}`, []Item{ - tstLeftNoMD, tstSC1, tstParam1, nti(tScParamVal, "3.14"), tstRightNoMD, tstEOF}}, - {"named param, raw string", `{{< sc1 param1=` + "`" + "Hello World" + "`" + " >}}", []Item{ - tstLeftNoMD, tstSC1, tstParam1, nti(tScParamVal, "Hello World"), tstRightNoMD, tstEOF}}, - {"float param, named, space before", `{{< sc1 param1= 3.14 >}}`, []Item{ - tstLeftNoMD, tstSC1, tstParam1, nti(tScParamVal, "3.14"), tstRightNoMD, tstEOF}}, - {"Youtube id", `{{< sc1 -ziL-Q_456igdO-4 >}}`, []Item{ - tstLeftNoMD, tstSC1, nti(tScParam, "-ziL-Q_456igdO-4"), tstRightNoMD, tstEOF}}, - {"non-alphanumerics param quoted", `{{< sc1 "-ziL-.%QigdO-4" >}}`, []Item{ - tstLeftNoMD, tstSC1, nti(tScParam, "-ziL-.%QigdO-4"), tstRightNoMD, tstEOF}}, - {"raw string", `{{< sc1` + "`" + "Hello World" + "`" + ` >}}`, []Item{ - tstLeftNoMD, tstSC1, nti(tScParam, "Hello World"), tstRightNoMD, tstEOF}}, + nti(tError, "unclosed shortcode"), + }, nil}, + {"float param, positional", `{{< sc1 3.14 >}}`, []typeText{ + tstLeftNoMD, tstSC1, nti(tScParam, "3.14"), tstRightNoMD, tstEOF, + }, nil}, + {"float param, named", `{{< sc1 param1=3.14 >}}`, []typeText{ + tstLeftNoMD, tstSC1, tstParam1, nti(tScParamVal, "3.14"), tstRightNoMD, tstEOF, + }, nil}, + {"named param, raw string", `{{< sc1 param1=` + "`" + "Hello World" + "`" + " >}}", []typeText{ + tstLeftNoMD, tstSC1, tstParam1, nti(tScParamVal, "Hello World"), tstRightNoMD, tstEOF, + }, nil}, + {"float param, named, space before", `{{< sc1 param1= 3.14 >}}`, []typeText{ + tstLeftNoMD, tstSC1, tstParam1, nti(tScParamVal, "3.14"), tstRightNoMD, tstEOF, + }, nil}, + {"Youtube id", `{{< sc1 -ziL-Q_456igdO-4 >}}`, []typeText{ + tstLeftNoMD, tstSC1, nti(tScParam, "-ziL-Q_456igdO-4"), tstRightNoMD, tstEOF, + }, nil}, + {"non-alphanumerics param quoted", `{{< sc1 "-ziL-.%QigdO-4" >}}`, []typeText{ + tstLeftNoMD, tstSC1, nti(tScParam, "-ziL-.%QigdO-4"), tstRightNoMD, tstEOF, + }, nil}, + {"raw string", `{{< sc1` + "`" + "Hello World" + "`" + ` >}}`, []typeText{ + tstLeftNoMD, tstSC1, nti(tScParam, "Hello World"), tstRightNoMD, tstEOF, + }, nil}, {"raw string with newline", `{{< sc1` + "`" + `Hello - World` + "`" + ` >}}`, []Item{ + World` + "`" + ` >}}`, []typeText{ tstLeftNoMD, tstSC1, nti(tScParam, `Hello - World`), tstRightNoMD, tstEOF}}, - {"raw string with escape character", `{{< sc1` + "`" + `Hello \b World` + "`" + ` >}}`, []Item{ - tstLeftNoMD, tstSC1, nti(tScParam, `Hello \b World`), tstRightNoMD, tstEOF}}, - {"two params", `{{< sc1 param1 param2 >}}`, []Item{ - tstLeftNoMD, tstSC1, tstParam1, tstParam2, tstRightNoMD, tstEOF}}, + World`), tstRightNoMD, tstEOF, + }, nil}, + {"raw string with escape character", `{{< sc1` + "`" + `Hello \b World` + "`" + ` >}}`, []typeText{ + tstLeftNoMD, tstSC1, nti(tScParam, `Hello \b World`), tstRightNoMD, tstEOF, + }, nil}, + {"two params", `{{< sc1 param1 param2 >}}`, []typeText{ + tstLeftNoMD, tstSC1, tstParam1, tstParam2, tstRightNoMD, tstEOF, + }, nil}, // issue #934 - {"self-closing", `{{< sc1 />}}`, []Item{ - tstLeftNoMD, tstSC1, tstSCClose, tstRightNoMD, tstEOF}}, + {"self-closing", `{{< sc1 />}}`, []typeText{ + tstLeftNoMD, tstSC1, tstSCClose, tstRightNoMD, tstEOF, + }, nil}, // Issue 2498 - {"multiple self-closing", `{{< sc1 />}}{{< sc1 />}}`, []Item{ + {"multiple self-closing", `{{< sc1 />}}{{< sc1 />}}`, []typeText{ tstLeftNoMD, tstSC1, tstSCClose, tstRightNoMD, - tstLeftNoMD, tstSC1, tstSCClose, tstRightNoMD, tstEOF}}, - {"self-closing with param", `{{< sc1 param1 />}}`, []Item{ - tstLeftNoMD, tstSC1, tstParam1, tstSCClose, tstRightNoMD, tstEOF}}, - {"multiple self-closing with param", `{{< sc1 param1 />}}{{< sc1 param1 />}}`, []Item{ + tstLeftNoMD, tstSC1, tstSCClose, tstRightNoMD, tstEOF, + }, nil}, + {"self-closing with param", `{{< sc1 param1 />}}`, []typeText{ + tstLeftNoMD, tstSC1, tstParam1, tstSCClose, tstRightNoMD, tstEOF, + }, nil}, + {"self-closing with extra keyword", `{{< sc1 / keyword>}}`, []typeText{ + tstLeftNoMD, tstSC1, tstSCClose, nti(tError, "closing tag for shortcode 'keyword' does not match start tag"), + }, nil}, + {"multiple self-closing with param", `{{< sc1 param1 />}}{{< sc1 param1 />}}`, []typeText{ tstLeftNoMD, tstSC1, tstParam1, tstSCClose, tstRightNoMD, - tstLeftNoMD, tstSC1, tstParam1, tstSCClose, tstRightNoMD, tstEOF}}, - {"multiple different self-closing with param", `{{< sc1 param1 />}}{{< sc2 param1 />}}`, []Item{ + tstLeftNoMD, tstSC1, tstParam1, tstSCClose, tstRightNoMD, tstEOF, + }, nil}, + {"multiple different self-closing with param", `{{< sc1 param1 />}}{{< sc2 param1 />}}`, []typeText{ tstLeftNoMD, tstSC1, tstParam1, tstSCClose, tstRightNoMD, - tstLeftNoMD, tstSC2, tstParam1, tstSCClose, tstRightNoMD, tstEOF}}, - {"nested simple", `{{< sc1 >}}{{< sc2 >}}{{< /sc1 >}}`, []Item{ + tstLeftNoMD, tstSC2, tstParam1, tstSCClose, tstRightNoMD, tstEOF, + }, nil}, + {"nested simple", `{{< sc1 >}}{{< sc2 >}}{{< /sc1 >}}`, []typeText{ tstLeftNoMD, tstSC1, tstRightNoMD, tstLeftNoMD, tstSC2, tstRightNoMD, - tstLeftNoMD, tstSCClose, tstSC1, tstRightNoMD, tstEOF}}, - {"nested complex", `{{< sc1 >}}ab{{% sc2 param1 %}}cd{{< sc3 >}}ef{{< /sc3 >}}gh{{% /sc2 %}}ij{{< /sc1 >}}kl`, []Item{ + tstLeftNoMD, tstSCClose, tstSC1, tstRightNoMD, tstEOF, + }, nil}, + {"nested complex", `{{< sc1 >}}ab{{% sc2 param1 %}}cd{{< sc3 >}}ef{{< /sc3 >}}gh{{% /sc2 %}}ij{{< /sc1 >}}kl`, []typeText{ tstLeftNoMD, tstSC1, tstRightNoMD, nti(tText, "ab"), tstLeftMD, tstSC2, tstParam1, tstRightMD, @@ -129,79 +155,114 @@ var shortCodeLexerTests = []lexerTest{ nti(tText, "ij"), tstLeftNoMD, tstSCClose, tstSC1, tstRightNoMD, nti(tText, "kl"), tstEOF, - }}, + }, nil}, - {"two quoted params", `{{< sc1 "param nr. 1" "param nr. 2" >}}`, []Item{ - tstLeftNoMD, tstSC1, nti(tScParam, "param nr. 1"), nti(tScParam, "param nr. 2"), tstRightNoMD, tstEOF}}, - {"two named params", `{{< sc1 param1="Hello World" param2="p2Val">}}`, []Item{ - tstLeftNoMD, tstSC1, tstParam1, tstVal, tstParam2, nti(tScParamVal, "p2Val"), tstRightNoMD, tstEOF}}, - {"escaped quotes", `{{< sc1 param1=\"Hello World\" >}}`, []Item{ - tstLeftNoMD, tstSC1, tstParam1, tstVal, tstRightNoMD, tstEOF}}, - {"escaped quotes, positional param", `{{< sc1 \"param1\" >}}`, []Item{ - tstLeftNoMD, tstSC1, tstParam1, tstRightNoMD, tstEOF}}, - {"escaped quotes inside escaped quotes", `{{< sc1 param1=\"Hello \"escaped\" World\" >}}`, []Item{ + {"two quoted params", `{{< sc1 "param nr. 1" "param nr. 2" >}}`, []typeText{ + tstLeftNoMD, tstSC1, nti(tScParam, "param nr. 1"), nti(tScParam, "param nr. 2"), tstRightNoMD, tstEOF, + }, nil}, + {"two named params", `{{< sc1 param1="Hello World" param2="p2Val">}}`, []typeText{ + tstLeftNoMD, tstSC1, tstParam1, tstVal, tstParam2, nti(tScParamVal, "p2Val"), tstRightNoMD, tstEOF, + }, nil}, + {"escaped quotes", `{{< sc1 param1=\"Hello World\" >}}`, []typeText{ + tstLeftNoMD, tstSC1, tstParam1, tstVal, tstRightNoMD, tstEOF, + }, nil}, + {"escaped quotes, positional param", `{{< sc1 \"param1\" >}}`, []typeText{ + tstLeftNoMD, tstSC1, tstParam1, tstRightNoMD, tstEOF, + }, nil}, + {"escaped quotes inside escaped quotes", `{{< sc1 param1=\"Hello \"escaped\" World\" >}}`, []typeText{ tstLeftNoMD, tstSC1, tstParam1, - nti(tScParamVal, `Hello `), nti(tError, `got positional parameter 'escaped'. Cannot mix named and positional parameters`)}}, - {"escaped quotes inside nonescaped quotes", - `{{< sc1 param1="Hello \"escaped\" World" >}}`, []Item{ - tstLeftNoMD, tstSC1, tstParam1, nti(tScParamVal, `Hello "escaped" World`), tstRightNoMD, tstEOF}}, - {"escaped quotes inside nonescaped quotes in positional param", - `{{< sc1 "Hello \"escaped\" World" >}}`, []Item{ - tstLeftNoMD, tstSC1, nti(tScParam, `Hello "escaped" World`), tstRightNoMD, tstEOF}}, - {"escaped raw string, named param", `{{< sc1 param1=` + `\` + "`" + "Hello World" + `\` + "`" + ` >}}`, []Item{ - tstLeftNoMD, tstSC1, tstParam1, nti(tError, "unrecognized escape character")}}, - {"escaped raw string, positional param", `{{< sc1 param1 ` + `\` + "`" + "Hello World" + `\` + "`" + ` >}}`, []Item{ - tstLeftNoMD, tstSC1, tstParam1, nti(tError, "unrecognized escape character")}}, - {"two raw string params", `{{< sc1` + "`" + "Hello World" + "`" + "`" + "Second Param" + "`" + ` >}}`, []Item{ - tstLeftNoMD, tstSC1, nti(tScParam, "Hello World"), nti(tScParam, "Second Param"), tstRightNoMD, tstEOF}}, - {"unterminated quote", `{{< sc1 param2="Hello World>}}`, []Item{ - tstLeftNoMD, tstSC1, tstParam2, nti(tError, "unterminated quoted string in shortcode parameter-argument: 'Hello World>}}'")}}, - {"unterminated raw string", `{{< sc1` + "`" + "Hello World" + ` >}}`, []Item{ - tstLeftNoMD, tstSC1, nti(tError, "unterminated raw string in shortcode parameter-argument: 'Hello World >}}'")}}, - {"unterminated raw string in second argument", `{{< sc1` + "`" + "Hello World" + "`" + "`" + "Second Param" + ` >}}`, []Item{ - tstLeftNoMD, tstSC1, nti(tScParam, "Hello World"), nti(tError, "unterminated raw string in shortcode parameter-argument: 'Second Param >}}'")}}, - {"one named param, one not", `{{< sc1 param1="Hello World" p2 >}}`, []Item{ + nti(tScParamVal, `Hello `), nti(tError, `got positional parameter 'escaped'. Cannot mix named and positional parameters`), + }, nil}, + { + "escaped quotes inside nonescaped quotes", + `{{< sc1 param1="Hello \"escaped\" World" >}}`, + []typeText{ + tstLeftNoMD, tstSC1, tstParam1, nti(tScParamVal, `Hello "escaped" World`), tstRightNoMD, tstEOF, + }, + nil, + }, + { + "escaped quotes inside nonescaped quotes in positional param", + `{{< sc1 "Hello \"escaped\" World" >}}`, + []typeText{ + tstLeftNoMD, tstSC1, nti(tScParam, `Hello "escaped" World`), tstRightNoMD, tstEOF, + }, + nil, + }, + {"escaped raw string, named param", `{{< sc1 param1=` + `\` + "`" + "Hello World" + `\` + "`" + ` >}}`, []typeText{ + tstLeftNoMD, tstSC1, tstParam1, nti(tError, "unrecognized escape character"), + }, nil}, + {"escaped raw string, positional param", `{{< sc1 param1 ` + `\` + "`" + "Hello World" + `\` + "`" + ` >}}`, []typeText{ + tstLeftNoMD, tstSC1, tstParam1, nti(tError, "unrecognized escape character"), + }, nil}, + {"two raw string params", `{{< sc1` + "`" + "Hello World" + "`" + "`" + "Second Param" + "`" + ` >}}`, []typeText{ + tstLeftNoMD, tstSC1, nti(tScParam, "Hello World"), nti(tScParam, "Second Param"), tstRightNoMD, tstEOF, + }, nil}, + {"unterminated quote", `{{< sc1 param2="Hello World>}}`, []typeText{ + tstLeftNoMD, tstSC1, tstParam2, nti(tError, "unterminated quoted string in shortcode parameter-argument: 'Hello World>}}'"), + }, nil}, + {"unterminated raw string", `{{< sc1` + "`" + "Hello World" + ` >}}`, []typeText{ + tstLeftNoMD, tstSC1, nti(tError, "unterminated raw string in shortcode parameter-argument: 'Hello World >}}'"), + }, nil}, + {"unterminated raw string in second argument", `{{< sc1` + "`" + "Hello World" + "`" + "`" + "Second Param" + ` >}}`, []typeText{ + tstLeftNoMD, tstSC1, nti(tScParam, "Hello World"), nti(tError, "unterminated raw string in shortcode parameter-argument: 'Second Param >}}'"), + }, nil}, + {"one named param, one not", `{{< sc1 param1="Hello World" p2 >}}`, []typeText{ tstLeftNoMD, tstSC1, tstParam1, tstVal, - nti(tError, "got positional parameter 'p2'. Cannot mix named and positional parameters")}}, - {"one named param, one quoted positional param, both raw strings", `{{< sc1 param1=` + "`" + "Hello World" + "`" + "`" + "Second Param" + "`" + ` >}}`, []Item{ + nti(tError, "got positional parameter 'p2'. Cannot mix named and positional parameters"), + }, nil}, + {"one named param, one quoted positional param, both raw strings", `{{< sc1 param1=` + "`" + "Hello World" + "`" + "`" + "Second Param" + "`" + ` >}}`, []typeText{ tstLeftNoMD, tstSC1, tstParam1, tstVal, - nti(tError, "got quoted positional parameter. Cannot mix named and positional parameters")}}, - {"one named param, one quoted positional param", `{{< sc1 param1="Hello World" "And Universe" >}}`, []Item{ + nti(tError, "got quoted positional parameter. Cannot mix named and positional parameters"), + }, nil}, + {"one named param, one quoted positional param", `{{< sc1 param1="Hello World" "And Universe" >}}`, []typeText{ tstLeftNoMD, tstSC1, tstParam1, tstVal, - nti(tError, "got quoted positional parameter. Cannot mix named and positional parameters")}}, - {"one quoted positional param, one named param", `{{< sc1 "param1" param2="And Universe" >}}`, []Item{ + nti(tError, "got quoted positional parameter. Cannot mix named and positional parameters"), + }, nil}, + {"one quoted positional param, one named param", `{{< sc1 "param1" param2="And Universe" >}}`, []typeText{ tstLeftNoMD, tstSC1, tstParam1, - nti(tError, "got named parameter 'param2'. Cannot mix named and positional parameters")}}, - {"ono positional param, one not", `{{< sc1 param1 param2="Hello World">}}`, []Item{ + nti(tError, "got named parameter 'param2'. Cannot mix named and positional parameters"), + }, nil}, + {"ono positional param, one not", `{{< sc1 param1 param2="Hello World">}}`, []typeText{ tstLeftNoMD, tstSC1, tstParam1, - nti(tError, "got named parameter 'param2'. Cannot mix named and positional parameters")}}, - {"commented out", `{{}}`, []Item{ - nti(tText, "{{<"), nti(tText, " sc1 "), nti(tText, ">}}"), tstEOF}}, - {"commented out, with asterisk inside", `{{}}`, []Item{ - nti(tText, "{{<"), nti(tText, " sc1 \"**/*.pdf\" "), nti(tText, ">}}"), tstEOF}}, - {"commented out, missing close", `{{}}`, []Item{ - nti(tError, "comment must be closed")}}, - {"commented out, misplaced close", `{{}}*/`, []Item{ - nti(tError, "comment must be closed")}}, + nti(tError, "got named parameter 'param2'. Cannot mix named and positional parameters"), + }, nil}, + {"commented out", `{{}}`, []typeText{ + nti(tText, "{{<"), nti(tText, " sc1 "), nti(tText, ">}}"), tstEOF, + }, nil}, + {"commented out, with asterisk inside", `{{}}`, []typeText{ + nti(tText, "{{<"), nti(tText, " sc1 \"**/*.pdf\" "), nti(tText, ">}}"), tstEOF, + }, nil}, + {"commented out, missing close", `{{}}`, []typeText{ + nti(tError, "comment must be closed"), + }, nil}, + {"commented out, misplaced close", `{{}}*/`, []typeText{ + nti(tError, "comment must be closed"), + }, nil}, // Inline shortcodes - {"basic inline", `{{< sc1.inline >}}Hello World{{< /sc1.inline >}}`, []Item{tstLeftNoMD, tstSC1Inline, tstRightNoMD, tstText, tstLeftNoMD, tstSCClose, tstSC1Inline, tstRightNoMD, tstEOF}}, - {"basic inline with space", `{{< sc1.inline >}}Hello World{{< / sc1.inline >}}`, []Item{tstLeftNoMD, tstSC1Inline, tstRightNoMD, tstText, tstLeftNoMD, tstSCClose, tstSC1Inline, tstRightNoMD, tstEOF}}, - {"inline self closing", `{{< sc1.inline >}}Hello World{{< /sc1.inline >}}Hello World{{< sc1.inline />}}`, []Item{tstLeftNoMD, tstSC1Inline, tstRightNoMD, tstText, tstLeftNoMD, tstSCClose, tstSC1Inline, tstRightNoMD, tstText, tstLeftNoMD, tstSC1Inline, tstSCClose, tstRightNoMD, tstEOF}}, - {"inline self closing, then a new inline", `{{< sc1.inline >}}Hello World{{< /sc1.inline >}}Hello World{{< sc1.inline />}}{{< sc2.inline >}}Hello World{{< /sc2.inline >}}`, []Item{ + {"basic inline", `{{< sc1.inline >}}Hello World{{< /sc1.inline >}}`, []typeText{tstLeftNoMD, tstSC1Inline, tstRightNoMD, tstText, tstLeftNoMD, tstSCClose, tstSC1Inline, tstRightNoMD, tstEOF}, nil}, + {"basic inline with space", `{{< sc1.inline >}}Hello World{{< / sc1.inline >}}`, []typeText{tstLeftNoMD, tstSC1Inline, tstRightNoMD, tstText, tstLeftNoMD, tstSCClose, tstSC1Inline, tstRightNoMD, tstEOF}, nil}, + {"inline self closing", `{{< sc1.inline >}}Hello World{{< /sc1.inline >}}Hello World{{< sc1.inline />}}`, []typeText{tstLeftNoMD, tstSC1Inline, tstRightNoMD, tstText, tstLeftNoMD, tstSCClose, tstSC1Inline, tstRightNoMD, tstText, tstLeftNoMD, tstSC1Inline, tstSCClose, tstRightNoMD, tstEOF}, nil}, + {"inline self closing, then a new inline", `{{< sc1.inline >}}Hello World{{< /sc1.inline >}}Hello World{{< sc1.inline />}}{{< sc2.inline >}}Hello World{{< /sc2.inline >}}`, []typeText{ tstLeftNoMD, tstSC1Inline, tstRightNoMD, tstText, tstLeftNoMD, tstSCClose, tstSC1Inline, tstRightNoMD, tstText, tstLeftNoMD, tstSC1Inline, tstSCClose, tstRightNoMD, - tstLeftNoMD, tstSC2Inline, tstRightNoMD, tstText, tstLeftNoMD, tstSCClose, tstSC2Inline, tstRightNoMD, tstEOF}}, - {"inline with template syntax", `{{< sc1.inline >}}{{ .Get 0 }}{{ .Get 1 }}{{< /sc1.inline >}}`, []Item{tstLeftNoMD, tstSC1Inline, tstRightNoMD, nti(tText, "{{ .Get 0 }}"), nti(tText, "{{ .Get 1 }}"), tstLeftNoMD, tstSCClose, tstSC1Inline, tstRightNoMD, tstEOF}}, - {"inline with nested shortcode (not supported)", `{{< sc1.inline >}}Hello World{{< sc1 >}}{{< /sc1.inline >}}`, []Item{tstLeftNoMD, tstSC1Inline, tstRightNoMD, tstText, nti(tError, "inline shortcodes do not support nesting")}}, - {"inline case mismatch", `{{< sc1.Inline >}}Hello World{{< /sc1.Inline >}}`, []Item{tstLeftNoMD, nti(tError, "period in shortcode name only allowed for inline identifiers")}}, + tstLeftNoMD, tstSC2Inline, tstRightNoMD, tstText, tstLeftNoMD, tstSCClose, tstSC2Inline, tstRightNoMD, tstEOF, + }, nil}, + {"inline with template syntax", `{{< sc1.inline >}}{{ .Get 0 }}{{ .Get 1 }}{{< /sc1.inline >}}`, []typeText{tstLeftNoMD, tstSC1Inline, tstRightNoMD, nti(tText, "{{ .Get 0 }}"), nti(tText, "{{ .Get 1 }}"), tstLeftNoMD, tstSCClose, tstSC1Inline, tstRightNoMD, tstEOF}, nil}, + {"inline with nested shortcode (not supported)", `{{< sc1.inline >}}Hello World{{< sc1 >}}{{< /sc1.inline >}}`, []typeText{tstLeftNoMD, tstSC1Inline, tstRightNoMD, tstText, nti(tError, "inline shortcodes do not support nesting")}, nil}, + {"inline case mismatch", `{{< sc1.Inline >}}Hello World{{< /sc1.Inline >}}`, []typeText{tstLeftNoMD, nti(tError, "period in shortcode name only allowed for inline identifiers")}, nil}, } func TestShortcodeLexer(t *testing.T) { t.Parallel() + c := qt.New(t) for i, test := range shortCodeLexerTests { t.Run(test.name, func(t *testing.T) { - items := collect([]byte(test.input), true, lexMainSection) - if !equal(items, test.items) { - t.Errorf("[%d] %s: got\n\t%v\nexpected\n\t%v", i, test.name, items, test.items) + items, err := collect([]byte(test.input), true, lexMainSection) + c.Assert(err, qt.IsNil) + if !equal(test.input, items, test.items) { + got := itemsToString(items, []byte(test.input)) + expected := testItemsToString(test.items) + c.Assert(got, qt.Equals, expected, qt.Commentf("Test %d: %s", i, test.name)) } }) } @@ -216,8 +277,9 @@ func BenchmarkShortcodeLexer(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { for _, input := range testInputs { - items := collectWithConfig(input, true, lexMainSection, cfg) - if len(items) == 0 { + _, err := collectWithConfig(input, true, lexMainSection, cfg) + if err != nil { + b.Fatal(err) } } diff --git a/parser/pageparser/pageparser_test.go b/parser/pageparser/pageparser_test.go index f54376c33..c6bedbd6f 100644 --- a/parser/pageparser/pageparser_test.go +++ b/parser/pageparser/pageparser_test.go @@ -14,13 +14,17 @@ package pageparser import ( + "bytes" "strings" "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/parser/metadecoders" ) func BenchmarkParse(b *testing.B) { start := ` - + --- title: "Front Matters" @@ -34,7 +38,7 @@ This is some summary. This is some summary. This is some summary. This is some s ` input := []byte(start + strings.Repeat(strings.Repeat("this is text", 30)+"{{< myshortcode >}}This is some inner content.{{< /myshortcode >}}", 10)) - cfg := Config{EnableEmoji: false} + cfg := Config{} b.ResetTimer() for i := 0; i < b.N; i++ { @@ -44,28 +48,67 @@ This is some summary. This is some summary. This is some summary. This is some s } } -func BenchmarkParseWithEmoji(b *testing.B) { - start := ` - - ---- -title: "Front Matters" -description: "It really does" ---- - -This is some summary. This is some summary. This is some summary. This is some summary. - - - - -` - input := []byte(start + strings.Repeat("this is not emoji: ", 50) + strings.Repeat("some text ", 70) + strings.Repeat("this is not: ", 50) + strings.Repeat("but this is a :smile: ", 3) + strings.Repeat("some text ", 70)) - cfg := Config{EnableEmoji: true} - - b.ResetTimer() - for i := 0; i < b.N; i++ { - if _, err := parseBytes(input, cfg, lexIntroSection); err != nil { - b.Fatal(err) - } +func TestFormatFromFrontMatterType(t *testing.T) { + c := qt.New(t) + for _, test := range []struct { + typ ItemType + expect metadecoders.Format + }{ + {TypeFrontMatterJSON, metadecoders.JSON}, + {TypeFrontMatterTOML, metadecoders.TOML}, + {TypeFrontMatterYAML, metadecoders.YAML}, + {TypeFrontMatterORG, metadecoders.ORG}, + {TypeIgnore, ""}, + } { + c.Assert(FormatFromFrontMatterType(test.typ), qt.Equals, test.expect) } } + +func TestIsProbablyItemsSource(t *testing.T) { + c := qt.New(t) + + input := ` {{< foo >}} ` + items, err := collectStringMain(input) + c.Assert(err, qt.IsNil) + + c.Assert(IsProbablySourceOfItems([]byte(input), items), qt.IsTrue) + c.Assert(IsProbablySourceOfItems(bytes.Repeat([]byte(" "), len(input)), items), qt.IsFalse) + c.Assert(IsProbablySourceOfItems([]byte(`{{< foo >}} `), items), qt.IsFalse) + c.Assert(IsProbablySourceOfItems([]byte(``), items), qt.IsFalse) +} + +func TestHasShortcode(t *testing.T) { + c := qt.New(t) + + c.Assert(HasShortcode("{{< foo >}}"), qt.IsTrue) + c.Assert(HasShortcode("aSDasd SDasd aSD\n\nasdfadf{{% foo %}}\nasdf"), qt.IsTrue) + c.Assert(HasShortcode("{{}}"), qt.IsFalse) + c.Assert(HasShortcode("{{%/* foo */%}}"), qt.IsFalse) +} + +func BenchmarkHasShortcode(b *testing.B) { + withShortcode := strings.Repeat("this is text", 30) + "{{< myshortcode >}}This is some inner content.{{< /myshortcode >}}" + strings.Repeat("this is text", 30) + withoutShortcode := strings.Repeat("this is text", 30) + "This is some inner content." + strings.Repeat("this is text", 30) + b.Run("Match", func(b *testing.B) { + for i := 0; i < b.N; i++ { + HasShortcode(withShortcode) + } + }) + + b.Run("NoMatch", func(b *testing.B) { + for i := 0; i < b.N; i++ { + HasShortcode(withoutShortcode) + } + }) +} + +func TestSummaryDividerStartingFromMain(t *testing.T) { + c := qt.New(t) + + input := `aaa bbb` + items, err := collectStringMain(input) + c.Assert(err, qt.IsNil) + + c.Assert(items, qt.HasLen, 4) + c.Assert(items[1].Type, qt.Equals, TypeLeadSummaryDivider) +} diff --git a/publisher/htmlElementsCollector.go b/publisher/htmlElementsCollector.go new file mode 100644 index 000000000..11a640550 --- /dev/null +++ b/publisher/htmlElementsCollector.go @@ -0,0 +1,555 @@ +// Copyright 2020 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 publisher + +import ( + "bytes" + "regexp" + "sort" + "strings" + "sync" + "unicode" + "unicode/utf8" + + "golang.org/x/net/html" + + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/helpers" +) + +const eof = -1 + +var ( + htmlJsonFixer = strings.NewReplacer(", ", "\n") + jsonAttrRe = regexp.MustCompile(`'?(.*?)'?:\s.*`) + classAttrRe = regexp.MustCompile(`(?i)^class$|transition`) + + skipInnerElementRe = regexp.MustCompile(`(?i)^(pre|textarea|script|style)`) + skipAllElementRe = regexp.MustCompile(`(?i)^!DOCTYPE`) + + exceptionList = map[string]bool{ + "thead": true, + "tbody": true, + "tfoot": true, + "td": true, + "tr": true, + } +) + +func newHTMLElementsCollector(conf config.BuildStats) *htmlElementsCollector { + return &htmlElementsCollector{ + conf: conf, + elementSet: make(map[string]bool), + } +} + +func newHTMLElementsCollectorWriter(collector *htmlElementsCollector) *htmlElementsCollectorWriter { + w := &htmlElementsCollectorWriter{ + collector: collector, + state: htmlLexStart, + } + + w.defaultLexElementInside = w.lexElementInside(htmlLexStart) + + return w +} + +// HTMLElements holds lists of tags and attribute values for classes and id. +type HTMLElements struct { + Tags []string `json:"tags"` + Classes []string `json:"classes"` + IDs []string `json:"ids"` +} + +func (h *HTMLElements) Merge(other HTMLElements) { + h.Tags = append(h.Tags, other.Tags...) + h.Classes = append(h.Classes, other.Classes...) + h.IDs = append(h.IDs, other.IDs...) + + h.Tags = helpers.UniqueStringsReuse(h.Tags) + h.Classes = helpers.UniqueStringsReuse(h.Classes) + h.IDs = helpers.UniqueStringsReuse(h.IDs) +} + +func (h *HTMLElements) Sort() { + sort.Strings(h.Tags) + sort.Strings(h.Classes) + sort.Strings(h.IDs) +} + +type htmlElement struct { + Tag string + Classes []string + IDs []string +} + +type htmlElementsCollector struct { + conf config.BuildStats + + // Contains the raw HTML string. We will get the same element + // several times, and want to avoid costly reparsing when this + // is used for aggregated data only. + elementSet map[string]bool + + elements []htmlElement + + mu sync.RWMutex +} + +func (c *htmlElementsCollector) getHTMLElements() HTMLElements { + var ( + classes []string + ids []string + tags []string + ) + + for _, el := range c.elements { + classes = append(classes, el.Classes...) + ids = append(ids, el.IDs...) + if !c.conf.DisableTags { + tags = append(tags, el.Tag) + } + } + + classes = helpers.UniqueStringsSorted(classes) + ids = helpers.UniqueStringsSorted(ids) + tags = helpers.UniqueStringsSorted(tags) + + els := HTMLElements{ + Classes: classes, + IDs: ids, + Tags: tags, + } + + return els +} + +type htmlElementsCollectorWriter struct { + collector *htmlElementsCollector + + r rune // Current rune + width int // The width in bytes of r + input []byte // The current slice written to Write + pos int // The current position in input + + err error + + inQuote rune + + buff bytes.Buffer + + // Current state + state htmlCollectorStateFunc + + // Precompiled state funcs + defaultLexElementInside htmlCollectorStateFunc +} + +// Write collects HTML elements from p, which must contain complete runes. +func (w *htmlElementsCollectorWriter) Write(p []byte) (int, error) { + if p == nil { + return 0, nil + } + + w.input = p + + for { + w.r = w.next() + if w.r == eof || w.r == utf8.RuneError { + break + } + w.state = w.state(w) + } + + w.pos = 0 + w.input = nil + + return len(p), nil +} + +func (l *htmlElementsCollectorWriter) backup() { + l.pos -= l.width + l.r, _ = utf8.DecodeRune(l.input[l.pos:]) +} + +func (w *htmlElementsCollectorWriter) consumeBuffUntil(condition func() bool, resolve htmlCollectorStateFunc) htmlCollectorStateFunc { + var s htmlCollectorStateFunc + s = func(*htmlElementsCollectorWriter) htmlCollectorStateFunc { + w.buff.WriteRune(w.r) + if condition() { + w.buff.Reset() + return resolve + } + return s + } + return s +} + +func (w *htmlElementsCollectorWriter) consumeRuneUntil(condition func(r rune) bool, resolve htmlCollectorStateFunc) htmlCollectorStateFunc { + var s htmlCollectorStateFunc + s = func(*htmlElementsCollectorWriter) htmlCollectorStateFunc { + if condition(w.r) { + return resolve + } + return s + } + return s +} + +// Starts with e.g. "' { + + // Work with the bytes slice as long as it's practical, + // to save memory allocations. + b := w.buff.Bytes() + + defer func() { + w.buff.Reset() + }() + + // First check if we have processed this element before. + w.collector.mu.RLock() + + seen := w.collector.elementSet[string(b)] + w.collector.mu.RUnlock() + if seen { + return resolve + } + + s := w.buff.String() + + if s == "" { + return resolve + } + + // Parse each collected element. + el, err := w.parseHTMLElement(s) + if err != nil { + w.err = err + return resolve + } + + // Write this tag to the element set. + w.collector.mu.Lock() + w.collector.elementSet[s] = true + w.collector.elements = append(w.collector.elements, el) + w.collector.mu.Unlock() + + return resolve + + } + + return s + } + + return s +} + +func (l *htmlElementsCollectorWriter) next() rune { + if l.pos >= len(l.input) { + l.width = 0 + return eof + } + + runeValue, runeWidth := utf8.DecodeRune(l.input[l.pos:]) + + l.width = runeWidth + l.pos += l.width + return runeValue +} + +// returns the next state in HTML element scanner. +type htmlCollectorStateFunc func(*htmlElementsCollectorWriter) htmlCollectorStateFunc + +// At "<", buffer empty. +// Potentially starting a HTML element. +func htmlLexElementStart(w *htmlElementsCollectorWriter) htmlCollectorStateFunc { + if w.r == '>' || unicode.IsSpace(w.r) { + if w.buff.Len() < 2 || bytes.HasPrefix(w.buff.Bytes(), []byte("' { + return false + } + return isClosedByTag(w.buff.Bytes(), tagNameCopy) + }, + htmlLexStart, + )) + case skipAllElementRe.Match(tagName): + // E.g. "' + }, htmlLexStart) + default: + w.backup() + return w.defaultLexElementInside + } + } + + w.buff.WriteRune(w.r) + + // If it's a comment, skip to its end. + if w.r == '-' && bytes.Equal(w.buff.Bytes(), []byte("")) { + // Done, start looking for HTML elements again. + return htmlLexStart + } + + return htmlLexToEndOfComment +} + +func (w *htmlElementsCollectorWriter) parseHTMLElement(elStr string) (el htmlElement, err error) { + conf := w.collector.conf + + tagName := parseStartTag(elStr) + + el.Tag = strings.ToLower(tagName) + tagNameToParse := el.Tag + + // The net/html parser does not handle single table elements as input, e.g. tbody. + // We only care about the element/class/ids, so just store away the original tag name + // and pretend it's a
    . + if exceptionList[el.Tag] { + elStr = strings.Replace(elStr, tagName, "div", 1) + tagNameToParse = "div" + } + + n, err := html.Parse(strings.NewReader(elStr)) + if err != nil { + return + } + + var walk func(*html.Node) + walk = func(n *html.Node) { + if n.Type == html.ElementNode && n.Data == tagNameToParse { + for _, a := range n.Attr { + switch { + case strings.EqualFold(a.Key, "id"): + // There should be only one, but one never knows... + if !conf.DisableIDs { + el.IDs = append(el.IDs, a.Val) + } + default: + if conf.DisableClasses { + continue + } + + if classAttrRe.MatchString(a.Key) { + el.Classes = append(el.Classes, strings.Fields(a.Val)...) + } else { + key := strings.ToLower(a.Key) + val := strings.TrimSpace(a.Val) + + if strings.Contains(key, ":class") { + if strings.HasPrefix(val, "{") { + // This looks like a Vue or AlpineJS class binding. + val = htmlJsonFixer.Replace(strings.Trim(val, "{}")) + lines := strings.Split(val, "\n") + for i, l := range lines { + lines[i] = strings.TrimSpace(l) + } + val = strings.Join(lines, "\n") + + val = jsonAttrRe.ReplaceAllString(val, "$1") + + el.Classes = append(el.Classes, strings.Fields(val)...) + } + // Also add single quoted strings. + // This may introduce some false positives, but it covers some missing cases in the above. + // E.g. AlpinesJS' :class="isTrue 'class1' : 'class2'" + el.Classes = append(el.Classes, extractSingleQuotedStrings(val)...) + } + } + } + } + } + + for c := n.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + + walk(n) + + return +} + +// Variants of s +// +// +//
    +func parseStartTag(s string) string { + spaceIndex := strings.IndexFunc(s, func(r rune) bool { + return unicode.IsSpace(r) + }) + + if spaceIndex == -1 { + s = s[1 : len(s)-1] + } else { + s = s[1:spaceIndex] + } + + if s[len(s)-1] == '/' { + // Self closing. + s = s[:len(s)-1] + } + + return s +} + +// isClosedByTag reports whether b ends with a closing tag for tagName. +func isClosedByTag(b, tagName []byte) bool { + if len(b) == 0 { + return false + } + + if b[len(b)-1] != '>' { + return false + } + + var ( + lo int + hi int + + state int + inWord bool + ) + +LOOP: + for i := len(b) - 2; i >= 0; i-- { + switch { + case b[i] == '<': + if state != 1 { + return false + } + state = 2 + break LOOP + case b[i] == '/': + if state != 0 { + return false + } + state++ + if inWord { + lo = i + 1 + inWord = false + } + case isSpace(b[i]): + if inWord { + lo = i + 1 + inWord = false + } + default: + if !inWord { + hi = i + 1 + inWord = true + } + } + } + + if state != 2 || lo >= hi { + return false + } + + return bytes.EqualFold(tagName, b[lo:hi]) +} + +func isSpace(b byte) bool { + return b == ' ' || b == '\t' || b == '\n' +} + +func extractSingleQuotedStrings(s string) []string { + var ( + inQuote bool + lo int + hi int + ) + + var words []string + + for i, r := range s { + switch { + case r == '\'': + if !inQuote { + inQuote = true + lo = i + 1 + } else { + inQuote = false + hi = i + words = append(words, strings.Fields(s[lo:hi])...) + } + } + } + + return words +} diff --git a/publisher/htmlElementsCollector_test.go b/publisher/htmlElementsCollector_test.go new file mode 100644 index 000000000..36447258e --- /dev/null +++ b/publisher/htmlElementsCollector_test.go @@ -0,0 +1,278 @@ +// Copyright 2020 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 publisher + +import ( + "bytes" + "fmt" + "io" + "math/rand" + "strings" + "testing" + "time" + + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/config/testconfig" + "github.com/gohugoio/hugo/media" + "github.com/gohugoio/hugo/minifiers" + "github.com/gohugoio/hugo/output" + + qt "github.com/frankban/quicktest" +) + +func TestClassCollector(t *testing.T) { + c := qt.New((t)) + rnd := rand.New(rand.NewSource(time.Now().Unix())) + + f := func(tags, classes, ids string) HTMLElements { + var tagss, classess, idss []string + if tags != "" { + tagss = strings.Split(tags, " ") + } + if classes != "" { + classess = strings.Split(classes, " ") + } + if ids != "" { + idss = strings.Split(ids, " ") + } + return HTMLElements{ + Tags: tagss, + Classes: classess, + IDs: idss, + } + } + + skipMinifyTest := map[string]bool{ + "Script tags content should be skipped": true, // https://github.com/tdewolff/minify/issues/396 + } + + for _, test := range []struct { + name string + html string + expect HTMLElements + }{ + {"basic", ``, f("body", "a b", "")}, + {"duplicates", `
    x'`, f("div", "a b", "")}, + {"single quote", ``, f("body", "a b", "")}, + {"no quote", ``, f("body", "b", "myelement")}, + {"short", ``, f("i", "", "")}, + {"invalid", `< body class="b a">
    `, f("div", "", "")}, + // https://github.com/gohugoio/hugo/issues/7318 + {"thead", ` + + +
    `, f("table tbody td thead tr", "cl1 cl2 cl3 cl4 cl5 cl6 cl7", "")}, + {"thead uppercase", ` + + +
    `, f("table tbody td thead tr", "CL1 CL2 CL3 CL4 CL5 CL6 CL7", "")}, + // https://github.com/gohugoio/hugo/issues/7161 + {"minified a href", ``, f("a", "a b", "")}, + {"AlpineJS bind 1", ` +
    +
    +`, f("body div", "class1 class2 class3", "")}, + { + "AlpineJS bind 2", `
    FOO
    `, + f("div", "bg-black bg-gray-300 inline-block mb-2 mr-1 px-2 py-2 rounded", ""), + }, + {"AlpineJS bind 3", `
    `, f("div", "text-gray-800 text-white", "")}, + {"AlpineJS bind 4", `
    `, f("div", "text-gray-800 text-white", "")}, + {"AlpineJS bind 5", ``, f("a", "block capitalize cursor-pointer no-underline pl-2 pl-3 pr-3 text-a text-b text-gray-600 w-36", "")}, + {"AlpineJS bind 6", ``, f("button", "bg-white border-gray-500 border-t-2 border-transparent hover:bg-gray-100 pt", "")}, + {"AlpineJS bind 7", ``, f("button", "bg-white border-gray-500 border-t-2 border-transparent hover:bg-gray-100 pt", "")}, + {"AlpineJS transition 1", `
    `, f("div", "mobile:-translate-x-8 opacity-0 sm:-translate-y-8 transform", "")}, + {"Vue bind", `
    `, f("div", "active", "")}, + // Issue #7746 + {"Apostrophe inside attribute value", `my text
    `, f("a div", "missingclass", "")}, + // Issue #7567 + {"Script tags content should be skipped", `
    `, f("div script", "foo", "")}, + {"Style tags content should be skipped", `
    `, f("div style", "foo", "")}, + {"Pre tags content should be skipped", `
    foobar
    `, f("div pre", "foo preclass", "")}, + {"Textarea tags content should be skipped", `
    `, f("div textarea", "foo textareaclass", "")}, + {"DOCTYPE should beskipped", ``, f("", "", "")}, + {"Comments should be skipped", ``, f("", "", "")}, + {"Comments with elements before and after", `
    `, f("div span", "", "")}, + {"Self closing tag", `

    `, f("div hr", "", "")}, + // svg with self closing style tag. + {"SVG with self closing style tag", ``, f("g path style svg", "foo", "")}, + // Issue #8530 + {"Comment with single quote", ``, f("i", "foo", "")}, + {"Uppercase tags", `
    `, f("div", "", "")}, + {"Predefined tags with distinct casing", `
    `, f("div script", "", "")}, + // Issue #8417 + {"Tabs inline", `
    d
    `, f("div hr", "bar foo", "a")}, + {"Tabs on multiple rows", `
    +
    d
    `, f("div form", "foo", "a b")}, + {"Big input, multibyte runes", strings.Repeat(`神真美好 `, rnd.Intn(500)+1) + "
    " + strings.Repeat(`神真美好 `, rnd.Intn(100)+1) + " 神真美好", f("div span", "foo", "神真美好")}, + } { + for _, variant := range []struct { + minify bool + }{ + {minify: false}, + {minify: true}, + } { + + name := fmt.Sprintf("%s--minify-%t", test.name, variant.minify) + + c.Run(name, func(c *qt.C) { + w := newHTMLElementsCollectorWriter(newHTMLElementsCollector( + config.BuildStats{Enable: true}, + )) + if variant.minify { + if skipMinifyTest[test.name] { + c.Skip("skip minify test") + } + m, _ := minifiers.New(media.DefaultTypes, output.DefaultFormats, testconfig.GetTestConfig(nil, nil)) + m.Minify(media.Builtin.HTMLType, w, strings.NewReader(test.html)) + + } else { + var buff bytes.Buffer + buff.WriteString(test.html) + io.Copy(w, &buff) + } + got := w.collector.getHTMLElements() + c.Assert(got, qt.DeepEquals, test.expect) + }) + } + } +} + +func TestEndsWithTag(t *testing.T) { + c := qt.New((t)) + + for _, test := range []struct { + name string + s string + tagName string + expect bool + }{ + {"empty", "", "div", false}, + {"no match", "foo", "div", false}, + {"no close", "foo
    ", "div", false}, + {"no close 2", "foo/div>", "div", false}, + {"no close 2", "foo//div>", "div", false}, + {"no tag", "foo", "div", false}, + {"match", "foo
    ", "div", true}, + {"match space", "foo< / div>", "div", true}, + {"match space 2", "foo< / div \n>", "div", true}, + {"match case", "foo
    ", "div", true}, + {"self closing", ``, "div", false}, + } { + c.Run(test.name, func(c *qt.C) { + got := isClosedByTag([]byte(test.s), []byte(test.tagName)) + c.Assert(got, qt.Equals, test.expect) + }) + } +} + +func BenchmarkElementsCollectorWriter(b *testing.B) { + const benchHTML = ` + + + +title + + + + +
    + + +
    + + + +

    To force
    line breaks
    in a text,
    use the br
    element.

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    MonthSavings
    January$100
    February$200
    $300
    + + +` + for i := 0; i < b.N; i++ { + w := newHTMLElementsCollectorWriter(newHTMLElementsCollector( + config.BuildStats{Enable: true}, + )) + fmt.Fprint(w, benchHTML) + + } +} + +func BenchmarkElementsCollectorWriterPre(b *testing.B) { + const benchHTML = ` +
    +foobar
    +
    +foo
    +bar
    +baz
    +qux
    +quux
    +quuz
    +corge
    +
    +
    + +` + w := newHTMLElementsCollectorWriter(newHTMLElementsCollector( + config.BuildStats{Enable: true}, + )) + for i := 0; i < b.N; i++ { + fmt.Fprint(w, benchHTML) + } +} diff --git a/publisher/publisher.go b/publisher/publisher.go index 119be356b..bbe65ff8a 100644 --- a/publisher/publisher.go +++ b/publisher/publisher.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Hugo Authors. All rights reserved. +// Copyright 2020 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. @@ -15,9 +15,13 @@ package publisher import ( "errors" + "fmt" "io" + "net/url" "sync/atomic" + "github.com/gohugoio/hugo/resources" + "github.com/gohugoio/hugo/media" "github.com/gohugoio/hugo/minifiers" @@ -49,8 +53,8 @@ type Descriptor struct { StatCounter *uint64 // Configuration that trigger pre-processing. - // LiveReload script will be injected if this is > 0 - LiveReloadPort int + // LiveReload script will be injected if this is != nil + LiveReloadBaseURL *url.URL // Enable to inject the Hugo generated tag in the header. Is currently only // injected on the home page for HTML type of output formats. @@ -67,19 +71,22 @@ type Descriptor struct { // DestinationPublisher is the default and currently only publisher in Hugo. This // publisher prepares and publishes an item to the defined destination, e.g. /public. type DestinationPublisher struct { - fs afero.Fs - minify bool - min minifiers.Client + fs afero.Fs + min minifiers.Client + htmlElementsCollector *htmlElementsCollector } // NewDestinationPublisher creates a new DestinationPublisher. -func NewDestinationPublisher(fs afero.Fs, outputFormats output.Formats, mediaTypes media.Types, minify bool) DestinationPublisher { - pub := DestinationPublisher{fs: fs} - if minify { - pub.min = minifiers.New(mediaTypes, outputFormats) - pub.minify = true +func NewDestinationPublisher(rs *resources.Spec, outputFormats output.Formats, mediaTypes media.Types) (pub DestinationPublisher, err error) { + fs := rs.BaseFs.PublishFs + cfg := rs.Cfg + var classCollector *htmlElementsCollector + if rs.BuildConfig().BuildStats.Enabled() { + classCollector = newHTMLElementsCollector(rs.BuildConfig().BuildStats) } - return pub + pub = DestinationPublisher{fs: fs, htmlElementsCollector: classCollector} + pub.min, err = minifiers.New(mediaTypes, outputFormats, cfg) + return } // Publish applies any relevant transformations and writes the file @@ -98,7 +105,7 @@ func (p DestinationPublisher) Publish(d Descriptor) error { defer bp.PutBuffer(b) if err := transformers.Apply(b, d.Src); err != nil { - return err + return fmt.Errorf("failed to process %q: %w", d.TargetPath, err) } // This is now what we write to disk. @@ -111,16 +118,38 @@ func (p DestinationPublisher) Publish(d Descriptor) error { } defer f.Close() - _, err = io.Copy(f, src) + var w io.Writer = f + + if p.htmlElementsCollector != nil && d.OutputFormat.IsHTML { + w = io.MultiWriter(w, newHTMLElementsCollectorWriter(p.htmlElementsCollector)) + } + + _, err = io.Copy(w, src) if err == nil && d.StatCounter != nil { atomic.AddUint64(d.StatCounter, uint64(1)) } + return err } +func (p DestinationPublisher) PublishStats() PublishStats { + if p.htmlElementsCollector == nil { + return PublishStats{} + } + + return PublishStats{ + HTMLElements: p.htmlElementsCollector.getHTMLElements(), + } +} + +type PublishStats struct { + HTMLElements HTMLElements `json:"htmlElements"` +} + // Publisher publishes a result file. type Publisher interface { Publish(d Descriptor) error + PublishStats() PublishStats } // XML transformer := transform.New(urlreplacers.NewAbsURLInXMLTransformer(path)) @@ -139,8 +168,8 @@ func (p DestinationPublisher) createTransformerChain(f Descriptor) transform.Cha } if isHTML { - if f.LiveReloadPort > 0 { - transformers = append(transformers, livereloadinject.New(f.LiveReloadPort)) + if f.LiveReloadBaseURL != nil { + transformers = append(transformers, livereloadinject.New(f.LiveReloadBaseURL)) } // This is only injected on the home page. @@ -150,7 +179,7 @@ func (p DestinationPublisher) createTransformerChain(f Descriptor) transform.Cha } - if p.minify { + if p.min.MinifyOutput { minifyTransformer := p.min.Transformer(f.OutputFormat.MediaType) if minifyTransformer != nil { transformers = append(transformers, minifyTransformer) @@ -158,5 +187,4 @@ func (p DestinationPublisher) createTransformerChain(f Descriptor) transform.Cha } return transformers - } diff --git a/publisher/publisher_test.go b/publisher/publisher_test.go deleted file mode 100644 index 200accc8b..000000000 --- a/publisher/publisher_test.go +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright 2018 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 publisher diff --git a/related/inverted_index.go b/related/inverted_index.go index fda6b9222..b8f1ad3e2 100644 --- a/related/inverted_index.go +++ b/related/inverted_index.go @@ -15,17 +15,37 @@ package related import ( + "context" "errors" "fmt" "math" "sort" "strings" + "sync" "time" + xmaps "golang.org/x/exp/maps" + + "github.com/gohugoio/hugo/common/collections" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/compare" + "github.com/gohugoio/hugo/markup/tableofcontents" + "github.com/spf13/cast" + "github.com/gohugoio/hugo/common/types" "github.com/mitchellh/mapstructure" ) +const ( + TypeBasic = "basic" + TypeFragments = "fragments" +) + +var validTypes = map[string]bool{ + TypeBasic: true, + TypeFragments: true, +} + var ( _ Keyword = (*StringKeyword)(nil) zeroDate = time.Time{} @@ -33,32 +53,15 @@ var ( // DefaultConfig is the default related config. DefaultConfig = Config{ Threshold: 80, - Indices: IndexConfigs{ - IndexConfig{Name: "keywords", Weight: 100}, - IndexConfig{Name: "date", Weight: 10}, + Indices: IndicesConfig{ + IndexConfig{Name: "keywords", Weight: 100, Type: TypeBasic}, + IndexConfig{Name: "date", Weight: 10, Type: TypeBasic}, }, } ) -/* -Config is the top level configuration element used to configure how to retrieve -related content in Hugo. - -An example site config.toml: - - [related] - threshold = 1 - [[related.indices]] - name = "keywords" - weight = 200 - [[related.indices]] - name = "tags" - weight = 100 - [[related.indices]] - name = "date" - weight = 1 - pattern = "2006" -*/ +// Config is the top level configuration element used to configure how to retrieve +// related content in Hugo. type Config struct { // Only include matches >= threshold, a normalized rank between 0 and 100. Threshold int @@ -70,7 +73,7 @@ type Config struct { // May get better results, but at a slight performance cost. ToLower bool - Indices IndexConfigs + Indices IndicesConfig } // Add adds a given index. @@ -81,14 +84,30 @@ func (c *Config) Add(index IndexConfig) { c.Indices = append(c.Indices, index) } -// IndexConfigs holds a set of index configurations. -type IndexConfigs []IndexConfig +func (c *Config) HasType(s string) bool { + for _, i := range c.Indices { + if i.Type == s { + return true + } + } + return false +} + +// IndicesConfig holds a set of index configurations. +type IndicesConfig []IndexConfig // IndexConfig configures an index. type IndexConfig struct { // The index name. This directly maps to a field or Param name. Name string + // The index type. + Type string + + // Enable to apply a type specific filter to the results. + // This is currently only used for the "fragments" type. + ApplyFilter bool + // Contextual pattern used to convert the Param value into a string. // Currently only used for dates. Can be used to, say, bump posts in the same // time frame when searching for related documents. @@ -99,6 +118,11 @@ type IndexConfig struct { // This field's weight when doing multi-index searches. Higher is "better". Weight int + // A percentage (0-100) used to remove common keywords from the index. + // As an example, setting this to 50 will remove all keywords that are + // used in more than 50% of the documents in the index. + CardinalityThreshold int + // Will lower case all string values in and queries tothis index. // May get better accurate results, but at a slight performance cost. ToLower bool @@ -117,14 +141,27 @@ type Document interface { Name() string } +// FragmentProvider is an optional interface that can be implemented by a Document. +type FragmentProvider interface { + Fragments(context.Context) *tableofcontents.Fragments + + // For internal use. + ApplyFilterToHeadings(context.Context, func(*tableofcontents.Heading) bool) Document +} + // InvertedIndex holds an inverted index, also sometimes named posting list, which // lists, for every possible search term, the documents that contain that term. type InvertedIndex struct { cfg Config index map[string]map[Keyword][]Document + // Counts the number of documents added to each index. + indexDocCount map[string]int minWeight int maxWeight int + + // No modifications after this is set. + finalized bool } func (idx *InvertedIndex) getIndexCfg(name string) (IndexConfig, bool) { @@ -140,7 +177,7 @@ func (idx *InvertedIndex) getIndexCfg(name string) (IndexConfig, bool) { // NewInvertedIndex creates a new InvertedIndex. // Documents to index must be added in Add. func NewInvertedIndex(cfg Config) *InvertedIndex { - idx := &InvertedIndex{index: make(map[string]map[Keyword][]Document), cfg: cfg} + idx := &InvertedIndex{index: make(map[string]map[Keyword][]Document), indexDocCount: make(map[string]int), cfg: cfg} for _, conf := range cfg.Indices { idx.index[conf.Name] = make(map[Keyword][]Document) if conf.Weight < idx.minWeight { @@ -157,7 +194,10 @@ func NewInvertedIndex(cfg Config) *InvertedIndex { // Add documents to the inverted index. // The value must support == and !=. -func (idx *InvertedIndex) Add(docs ...Document) error { +func (idx *InvertedIndex) Add(ctx context.Context, docs ...Document) error { + if idx.finalized { + panic("index is finalized") + } var err error for _, config := range idx.cfg.Indices { if config.Weight == 0 { @@ -167,6 +207,7 @@ func (idx *InvertedIndex) Add(docs ...Document) error { setm := idx.index[config.Name] for _, doc := range docs { + var added bool var words []Keyword words, err = doc.RelatedKeywords(config) if err != nil { @@ -174,13 +215,56 @@ func (idx *InvertedIndex) Add(docs ...Document) error { } for _, keyword := range words { + added = true setm[keyword] = append(setm[keyword], doc) } + + if config.Type == TypeFragments { + if fp, ok := doc.(FragmentProvider); ok { + for _, fragment := range fp.Fragments(ctx).Identifiers { + added = true + setm[FragmentKeyword(fragment)] = append(setm[FragmentKeyword(fragment)], doc) + } + } + } + + if added { + idx.indexDocCount[config.Name]++ + } } } return err +} +func (idx *InvertedIndex) Finalize(ctx context.Context) error { + if idx.finalized { + return nil + } + + for _, config := range idx.cfg.Indices { + if config.CardinalityThreshold == 0 { + continue + } + setm := idx.index[config.Name] + if idx.indexDocCount[config.Name] == 0 { + continue + } + + // Remove high cardinality terms. + numDocs := idx.indexDocCount[config.Name] + for k, v := range setm { + percentageWithKeyword := int(math.Ceil(float64(len(v)) / float64(numDocs) * 100)) + if percentageWithKeyword > config.CardinalityThreshold { + delete(setm, k) + } + } + + } + + idx.finalized = true + + return nil } // queryElement holds the index name and keywords that can be used to compose a @@ -207,8 +291,22 @@ func (r *rank) addWeight(w int) { r.Matches++ } -func newRank(doc Document, weight int) *rank { - return &rank{Doc: doc, Weight: weight, Matches: 1} +var rankPool = sync.Pool{ + New: func() any { + return &rank{} + }, +} + +func getRank(doc Document, weight int) *rank { + r := rankPool.Get().(*rank) + r.Doc = doc + r.Weight = weight + r.Matches = 1 + return r +} + +func putRank(r *rank) { + rankPool.Put(r) } func (r ranks) Len() int { return len(r) } @@ -223,22 +321,40 @@ func (r ranks) Less(i, j int) bool { return r[i].Weight > r[j].Weight } -// SearchDoc finds the documents matching any of the keywords in the given indices -// against the given document. +// SearchOpts holds the options for a related search. +type SearchOpts struct { + // The Document to search for related content for. + Document Document + + // The keywords to search for. + NamedSlices []types.KeyValues + + // The indices to search in. + Indices []string + + // Fragments holds a a list of special keywords that is used + // for indices configured as type "fragments". + // This will match the fragment identifiers of the documents. + Fragments []string +} + +// Search finds the documents matching any of the keywords in the given indices +// against query options in opts. // The resulting document set will be sorted according to number of matches // and the index weights, and any matches with a rank below the configured // threshold (normalize to 0..100) will be removed. // If an index name is provided, only that index will be queried. -func (idx *InvertedIndex) SearchDoc(doc Document, indices ...string) ([]Document, error) { - var q []queryElement +func (idx *InvertedIndex) Search(ctx context.Context, opts SearchOpts) ([]Document, error) { + var ( + queryElements []queryElement + configs IndicesConfig + ) - var configs IndexConfigs - - if len(indices) == 0 { + if len(opts.Indices) == 0 { configs = idx.cfg.Indices } else { - configs = make(IndexConfigs, len(indices)) - for i, indexName := range indices { + configs = make(IndicesConfig, len(opts.Indices)) + for i, indexName := range opts.Indices { cfg, found := idx.getIndexCfg(indexName) if !found { return nil, fmt.Errorf("index %q not found", indexName) @@ -248,37 +364,81 @@ func (idx *InvertedIndex) SearchDoc(doc Document, indices ...string) ([]Document } for _, cfg := range configs { - keywords, err := doc.RelatedKeywords(cfg) - if err != nil { - return nil, err + var keywords []Keyword + if opts.Document != nil { + k, err := opts.Document.RelatedKeywords(cfg) + if err != nil { + return nil, err + } + keywords = append(keywords, k...) + } + if cfg.Type == TypeFragments { + for _, fragment := range opts.Fragments { + keywords = append(keywords, FragmentKeyword(fragment)) + } + if opts.Document != nil { + if fp, ok := opts.Document.(FragmentProvider); ok { + for _, fragment := range fp.Fragments(ctx).Identifiers { + keywords = append(keywords, FragmentKeyword(fragment)) + } + } + } + + } + queryElements = append(queryElements, newQueryElement(cfg.Name, keywords...)) + } + for _, slice := range opts.NamedSlices { + var keywords []Keyword + key := slice.KeyString() + if key == "" { + return nil, fmt.Errorf("index %q not valid", slice.Key) + } + conf, found := idx.getIndexCfg(key) + if !found { + return nil, fmt.Errorf("index %q not found", key) } - q = append(q, newQueryElement(cfg.Name, keywords...)) - + for _, val := range slice.Values { + k, err := conf.ToKeywords(val) + if err != nil { + return nil, err + } + keywords = append(keywords, k...) + } + queryElements = append(queryElements, newQueryElement(conf.Name, keywords...)) } - return idx.searchDate(doc.PublishDate(), q...) + if opts.Document != nil { + return idx.searchDate(ctx, opts.Document, opts.Document.PublishDate(), queryElements...) + } + return idx.search(ctx, queryElements...) +} + +func (cfg IndexConfig) stringToKeyword(s string) Keyword { + if cfg.ToLower { + s = strings.ToLower(s) + } + if cfg.Type == TypeFragments { + return FragmentKeyword(s) + } + return StringKeyword(s) } // ToKeywords returns a Keyword slice of the given input. -func (cfg IndexConfig) ToKeywords(v interface{}) ([]Keyword, error) { - var ( - keywords []Keyword - toLower = cfg.ToLower - ) +func (cfg IndexConfig) ToKeywords(v any) ([]Keyword, error) { + var keywords []Keyword + switch vv := v.(type) { case string: - if toLower { - vv = strings.ToLower(vv) - } - keywords = append(keywords, StringKeyword(vv)) + keywords = append(keywords, cfg.stringToKeyword(vv)) case []string: - if toLower { - for i := 0; i < len(vv); i++ { - vv[i] = strings.ToLower(vv[i]) - } + vvv := make([]Keyword, len(vv)) + for i := range vvv { + vvv[i] = cfg.stringToKeyword(vv[i]) } - keywords = append(keywords, StringsToKeywords(vv...)...) + keywords = append(keywords, vvv...) + case []any: + return cfg.ToKeywords(cast.ToStringSlice(vv)) case time.Time: layout := "2006" if cfg.Pattern != "" { @@ -294,46 +454,20 @@ func (cfg IndexConfig) ToKeywords(v interface{}) ([]Keyword, error) { return keywords, nil } -// SearchKeyValues finds the documents matching any of the keywords in the given indices. -// The resulting document set will be sorted according to number of matches -// and the index weights, and any matches with a rank below the configured -// threshold (normalize to 0..100) will be removed. -func (idx *InvertedIndex) SearchKeyValues(args ...types.KeyValues) ([]Document, error) { - q := make([]queryElement, len(args)) - - for i, arg := range args { - var keywords []Keyword - key := arg.KeyString() - if key == "" { - return nil, fmt.Errorf("index %q not valid", arg.Key) - } - conf, found := idx.getIndexCfg(key) - if !found { - return nil, fmt.Errorf("index %q not found", key) - } - - for _, val := range arg.Values { - k, err := conf.ToKeywords(val) - if err != nil { - return nil, err - } - keywords = append(keywords, k...) - } - - q[i] = newQueryElement(conf.Name, keywords...) - - } - - return idx.search(q...) +func (idx *InvertedIndex) search(ctx context.Context, query ...queryElement) ([]Document, error) { + return idx.searchDate(ctx, nil, zeroDate, query...) } -func (idx *InvertedIndex) search(query ...queryElement) ([]Document, error) { - return idx.searchDate(zeroDate, query...) -} - -func (idx *InvertedIndex) searchDate(upperDate time.Time, query ...queryElement) ([]Document, error) { +func (idx *InvertedIndex) searchDate(ctx context.Context, self Document, upperDate time.Time, query ...queryElement) ([]Document, error) { matchm := make(map[Document]*rank, 200) + defer func() { + for _, r := range matchm { + putRank(r) + } + }() + applyDateFilter := !idx.cfg.IncludeNewer && !upperDate.IsZero() + var fragmentsFilter collections.SortedStringSlice for _, el := range query { setm, found := idx.index[el.Index] @@ -349,15 +483,27 @@ func (idx *InvertedIndex) searchDate(upperDate time.Time, query ...queryElement) for _, kw := range el.Keywords { if docs, found := setm[kw]; found { for _, doc := range docs { + if compare.Eq(doc, self) { + continue + } + if applyDateFilter { // Exclude newer than the limit given if doc.PublishDate().After(upperDate) { continue } } + + if config.Type == TypeFragments && config.ApplyFilter { + if fkw, ok := kw.(FragmentKeyword); ok { + fragmentsFilter = append(fragmentsFilter, string(fkw)) + } + } + r, found := matchm[doc] if !found { - matchm[doc] = newRank(doc, config.Weight) + r = getRank(doc, config.Weight) + matchm[doc] = r } else { r.addWeight(config.Weight) } @@ -383,11 +529,20 @@ func (idx *InvertedIndex) searchDate(upperDate time.Time, query ...queryElement) } sort.Stable(matches) + sort.Strings(fragmentsFilter) result := make([]Document, len(matches)) for i, m := range matches { result[i] = m.Doc + + if len(fragmentsFilter) > 0 { + if dp, ok := result[i].(FragmentProvider); ok { + result[i] = dp.ApplyFilterToHeadings(ctx, func(h *tableofcontents.Heading) bool { + return fragmentsFilter.Contains(h.ID) + }) + } + } } return result, nil @@ -402,16 +557,11 @@ func norm(num, min, max int) int { } // DecodeConfig decodes a slice of map into Config. -func DecodeConfig(in interface{}) (Config, error) { - if in == nil { +func DecodeConfig(m maps.Params) (Config, error) { + if m == nil { return Config{}, errors.New("no related config provided") } - m, ok := in.(map[string]interface{}) - if !ok { - return Config{}, fmt.Errorf("expected map[string]interface {} got %T", in) - } - if len(m) == 0 { return Config{}, errors.New("empty related config provided") } @@ -431,6 +581,21 @@ func DecodeConfig(in interface{}) (Config, error) { c.Indices[i].ToLower = true } } + for i := range c.Indices { + // Lower case name. + c.Indices[i].Name = strings.ToLower(c.Indices[i].Name) + + icfg := c.Indices[i] + if icfg.Type == "" { + c.Indices[i].Type = TypeBasic + } + if !validTypes[c.Indices[i].Type] { + return c, fmt.Errorf("invalid index type %q. Must be one of %v", c.Indices[i].Type, xmaps.Keys(validTypes)) + } + if icfg.CardinalityThreshold < 0 || icfg.CardinalityThreshold > 100 { + return Config{}, errors.New("cardinalityThreshold threshold must be between 0 and 100") + } + } return c, nil } @@ -442,17 +607,24 @@ func (s StringKeyword) String() string { return string(s) } +// FragmentKeyword represents a document fragment. +type FragmentKeyword string + +func (f FragmentKeyword) String() string { + return string(f) +} + // Keyword is the interface a keyword in the search index must implement. type Keyword interface { String() string } // StringsToKeywords converts the given slice of strings to a slice of Keyword. -func StringsToKeywords(s ...string) []Keyword { +func (cfg IndexConfig) StringsToKeywords(s ...string) []Keyword { kw := make([]Keyword, len(s)) - for i := 0; i < len(s); i++ { - kw[i] = StringKeyword(s[i]) + for i := range s { + kw[i] = cfg.stringToKeyword(s[i]) } return kw diff --git a/related/inverted_index_test.go b/related/inverted_index_test.go index 57e722364..d57237e11 100644 --- a/related/inverted_index_test.go +++ b/related/inverted_index_test.go @@ -14,12 +14,14 @@ package related import ( + "context" "fmt" "math/rand" "testing" "time" qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/config" ) type testDoc struct { @@ -63,7 +65,7 @@ func (d *testDoc) addKeywords(name string, keywords ...string) *testDoc { for k, v := range keywordm { keywords := make([]Keyword, len(v)) - for i := 0; i < len(v); i++ { + for i := range v { keywords[i] = StringKeyword(v[i]) } d.keywords[k] = keywords @@ -85,19 +87,52 @@ func (d *testDoc) PublishDate() time.Time { return d.date } -func TestSearch(t *testing.T) { - +func TestCardinalityThreshold(t *testing.T) { + c := qt.New(t) config := Config{ Threshold: 90, IncludeNewer: false, - Indices: IndexConfigs{ + Indices: IndicesConfig{ + IndexConfig{Name: "tags", Weight: 50, CardinalityThreshold: 79}, + IndexConfig{Name: "keywords", Weight: 65, CardinalityThreshold: 90}, + }, + } + + idx := NewInvertedIndex(config) + hasKeyword := func(index, keyword string) bool { + _, found := idx.index[index][StringKeyword(keyword)] + return found + } + + docs := []Document{ + newTestDoc("tags", "a", "b", "c", "d"), + newTestDoc("tags", "b", "d", "g"), + newTestDoc("tags", "b", "d", "g"), + newTestDoc("tags", "b", "h").addKeywords("keywords", "a"), + newTestDoc("tags", "g", "h").addKeywords("keywords", "a", "b", "z"), + } + + idx.Add(context.Background(), docs...) + c.Assert(idx.Finalize(context.Background()), qt.IsNil) + // Only tags=b should be removed. + c.Assert(hasKeyword("tags", "a"), qt.Equals, true) + c.Assert(hasKeyword("tags", "b"), qt.Equals, false) + c.Assert(hasKeyword("tags", "d"), qt.Equals, true) + c.Assert(hasKeyword("keywords", "b"), qt.Equals, true) +} + +func TestSearch(t *testing.T) { + config := Config{ + Threshold: 90, + IncludeNewer: false, + Indices: IndicesConfig{ IndexConfig{Name: "tags", Weight: 50}, IndexConfig{Name: "keywords", Weight: 65}, }, } idx := NewInvertedIndex(config) - //idx.debug = true + // idx.debug = true docs := []Document{ newTestDoc("tags", "a", "b", "c", "d"), @@ -106,7 +141,7 @@ func TestSearch(t *testing.T) { newTestDoc("tags", "g", "h").addKeywords("keywords", "a", "b"), } - idx.Add(docs...) + idx.Add(context.Background(), docs...) t.Run("count", func(t *testing.T) { c := qt.New(t) @@ -119,12 +154,12 @@ func TestSearch(t *testing.T) { set2, found := idx.index["keywords"] c.Assert(found, qt.Equals, true) c.Assert(len(set2), qt.Equals, 2) - }) t.Run("search-tags", func(t *testing.T) { c := qt.New(t) - m, err := idx.search(newQueryElement("tags", StringsToKeywords("a", "b", "d", "z")...)) + var cfg IndexConfig + m, err := idx.search(context.Background(), newQueryElement("tags", cfg.StringsToKeywords("a", "b", "d", "z")...)) c.Assert(err, qt.IsNil) c.Assert(len(m), qt.Equals, 2) c.Assert(m[0], qt.Equals, docs[0]) @@ -133,9 +168,10 @@ func TestSearch(t *testing.T) { t.Run("search-tags-and-keywords", func(t *testing.T) { c := qt.New(t) - m, err := idx.search( - newQueryElement("tags", StringsToKeywords("a", "b", "z")...), - newQueryElement("keywords", StringsToKeywords("a", "b")...)) + var cfg IndexConfig + m, err := idx.search(context.Background(), + newQueryElement("tags", cfg.StringsToKeywords("a", "b", "z")...), + newQueryElement("keywords", cfg.StringsToKeywords("a", "b")...)) c.Assert(err, qt.IsNil) c.Assert(len(m), qt.Equals, 3) c.Assert(m[0], qt.Equals, docs[3]) @@ -146,7 +182,7 @@ func TestSearch(t *testing.T) { t.Run("searchdoc-all", func(t *testing.T) { c := qt.New(t) doc := newTestDoc("tags", "a").addKeywords("keywords", "a") - m, err := idx.SearchDoc(doc) + m, err := idx.Search(context.Background(), SearchOpts{Document: doc}) c.Assert(err, qt.IsNil) c.Assert(len(m), qt.Equals, 2) c.Assert(m[0], qt.Equals, docs[3]) @@ -156,7 +192,7 @@ func TestSearch(t *testing.T) { t.Run("searchdoc-tags", func(t *testing.T) { c := qt.New(t) doc := newTestDoc("tags", "a", "b", "d", "z").addKeywords("keywords", "a", "b") - m, err := idx.SearchDoc(doc, "tags") + m, err := idx.Search(context.Background(), SearchOpts{Document: doc, Indices: []string{"tags"}}) c.Assert(err, qt.IsNil) c.Assert(len(m), qt.Equals, 2) c.Assert(m[0], qt.Equals, docs[0]) @@ -168,9 +204,9 @@ func TestSearch(t *testing.T) { doc := newTestDoc("tags", "a", "b", "d", "z").addKeywords("keywords", "a", "b") // This will get a date newer than the others. newDoc := newTestDoc("keywords", "a", "b") - idx.Add(newDoc) + idx.Add(context.Background(), newDoc) - m, err := idx.SearchDoc(doc, "keywords") + m, err := idx.Search(context.Background(), SearchOpts{Document: doc, Indices: []string{"keywords"}}) c.Assert(err, qt.IsNil) c.Assert(len(m), qt.Equals, 2) c.Assert(m[0], qt.Equals, docs[3]) @@ -185,32 +221,101 @@ func TestSearch(t *testing.T) { doc := newTestDocWithDate("keywords", date, "a", "b") doc.name = "thedoc" - for i := 0; i < 10; i++ { + for i := range 10 { docc := *doc docc.name = fmt.Sprintf("doc%d", i) - idx.Add(&docc) + idx.Add(context.Background(), &docc) } - m, err := idx.SearchDoc(doc, "keywords") + m, err := idx.Search(context.Background(), SearchOpts{Document: doc, Indices: []string{"keywords"}}) c.Assert(err, qt.IsNil) c.Assert(len(m), qt.Equals, 10) - for i := 0; i < 10; i++ { + for i := range 10 { c.Assert(m[i].Name(), qt.Equals, fmt.Sprintf("doc%d", i)) } }) +} +func TestToKeywordsToLower(t *testing.T) { + c := qt.New(t) + slice := []string{"A", "B", "C"} + config := IndexConfig{ToLower: true} + keywords, err := config.ToKeywords(slice) + c.Assert(err, qt.IsNil) + c.Assert(slice, qt.DeepEquals, []string{"A", "B", "C"}) + c.Assert(keywords, qt.DeepEquals, []Keyword{ + StringKeyword("a"), + StringKeyword("b"), + StringKeyword("c"), + }) +} + +func TestDecodeConfig(t *testing.T) { + c := qt.New(t) + + configToml := ` +[related] + includeNewer = true + threshold = 32 + toLower = false + [[related.indices]] + applyFilter = false + cardinalityThreshold = 0 + name = 'KeyworDs' + pattern = '' + toLower = false + type = 'basic' + weight = 100 + [[related.indices]] + applyFilter = true + cardinalityThreshold = 32 + name = 'date' + pattern = '' + toLower = false + type = 'basic' + weight = 10 + [[related.indices]] + applyFilter = false + cardinalityThreshold = 0 + name = 'tags' + pattern = '' + toLower = false + type = 'fragments' + weight = 80 +` + + m, err := config.FromConfigString(configToml, "toml") + c.Assert(err, qt.IsNil) + conf, err := DecodeConfig(m.GetParams("related")) + + c.Assert(err, qt.IsNil) + c.Assert(conf.IncludeNewer, qt.IsTrue) + first := conf.Indices[0] + c.Assert(first.Name, qt.Equals, "keywords") +} + +func TestToKeywordsAnySlice(t *testing.T) { + c := qt.New(t) + var config IndexConfig + slice := []any{"A", 32, "C"} + keywords, err := config.ToKeywords(slice) + c.Assert(err, qt.IsNil) + c.Assert(keywords, qt.DeepEquals, []Keyword{ + StringKeyword("A"), + StringKeyword("32"), + StringKeyword("C"), + }) } func BenchmarkRelatedNewIndex(b *testing.B) { - pages := make([]*testDoc, 100) numkeywords := 30 allKeywords := make([]string, numkeywords) - for i := 0; i < numkeywords; i++ { + for i := range numkeywords { allKeywords[i] = fmt.Sprintf("keyword%d", i+1) } - for i := 0; i < len(pages); i++ { + for i := range pages { start := rand.Intn(len(allKeywords)) end := start + 3 if end >= len(allKeywords) { @@ -232,7 +337,7 @@ func BenchmarkRelatedNewIndex(b *testing.B) { cfg := Config{ Threshold: 50, - Indices: IndexConfigs{ + Indices: IndicesConfig{ IndexConfig{Name: "tags", Weight: 100}, IndexConfig{Name: "keywords", Weight: 200}, }, @@ -242,7 +347,7 @@ func BenchmarkRelatedNewIndex(b *testing.B) { for i := 0; i < b.N; i++ { idx := NewInvertedIndex(cfg) for _, doc := range pages { - idx.Add(doc) + idx.Add(context.Background(), doc) } } }) @@ -251,30 +356,29 @@ func BenchmarkRelatedNewIndex(b *testing.B) { for i := 0; i < b.N; i++ { idx := NewInvertedIndex(cfg) docs := make([]Document, len(pages)) - for i := 0; i < len(pages); i++ { + for i := range pages { docs[i] = pages[i] } - idx.Add(docs...) + idx.Add(context.Background(), docs...) } }) - } func BenchmarkRelatedMatchesIn(b *testing.B) { - - q1 := newQueryElement("tags", StringsToKeywords("keyword2", "keyword5", "keyword32", "asdf")...) - q2 := newQueryElement("keywords", StringsToKeywords("keyword3", "keyword4")...) + var icfg IndexConfig + q1 := newQueryElement("tags", icfg.StringsToKeywords("keyword2", "keyword5", "keyword32", "asdf")...) + q2 := newQueryElement("keywords", icfg.StringsToKeywords("keyword3", "keyword4")...) docs := make([]*testDoc, 1000) numkeywords := 20 allKeywords := make([]string, numkeywords) - for i := 0; i < numkeywords; i++ { + for i := range numkeywords { allKeywords[i] = fmt.Sprintf("keyword%d", i+1) } cfg := Config{ Threshold: 20, - Indices: IndexConfigs{ + Indices: IndicesConfig{ IndexConfig{Name: "tags", Weight: 100}, IndexConfig{Name: "keywords", Weight: 200}, }, @@ -282,7 +386,7 @@ func BenchmarkRelatedMatchesIn(b *testing.B) { idx := NewInvertedIndex(cfg) - for i := 0; i < len(docs); i++ { + for i := range docs { start := rand.Intn(len(allKeywords)) end := start + 3 if end >= len(allKeywords) { @@ -294,15 +398,16 @@ func BenchmarkRelatedMatchesIn(b *testing.B) { index = "keywords" } - idx.Add(newTestDoc(index, allKeywords[start:end]...)) + idx.Add(context.Background(), newTestDoc(index, allKeywords[start:end]...)) } b.ResetTimer() + ctx := context.Background() for i := 0; i < b.N; i++ { if i%10 == 0 { - idx.search(q2) + idx.search(ctx, q2) } else { - idx.search(q1) + idx.search(ctx, q1) } } } diff --git a/related/related_integration_test.go b/related/related_integration_test.go new file mode 100644 index 000000000..6d3c6d6de --- /dev/null +++ b/related/related_integration_test.go @@ -0,0 +1,189 @@ +// Copyright 2024 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 related_test + +import ( + "fmt" + "math/rand" + "testing" + + "github.com/gohugoio/hugo/hugolib" +) + +func TestRelatedFragments(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "http://example.com/" +disableKinds = ["taxonomy", "term", "RSS", "sitemap", "robotsTXT"] +[related] + includeNewer = false + threshold = 80 + toLower = false +[[related.indices]] + name = 'pagerefs' + type = 'fragments' + applyFilter = true + weight = 90 +[[related.indices]] + name = 'keywords' + weight = 80 +-- content/p1.md -- +--- +title: p1 +pagerefs: ['ref1'] +--- +{{< see-also >}} + +## P1 title + +-- content/p2.md -- +--- +title: p2 +--- + +## P2 title 1 + +## P2 title 2 + +## First title {#ref1} +{{< see-also "ref1" >}} +-- content/p3.md -- +--- +title: p3 +keywords: ['foo'] +--- + +## P3 title 1 + +## P3 title 2 + +## Common p3, p4, p5 +-- content/p4.md -- +--- +title: p4 +--- + +## Common p3, p4, p5 + +## P4 title 1 + +-- content/p5.md -- +--- +title: p5 +keywords: ['foo'] +--- + +## P5 title 1 + +## Common p3, p4, p5 + +-- layouts/shortcodes/see-also.html -- +{{ $p1 := site.GetPage "p1" }} +{{ $p2 := site.GetPage "p2" }} +{{ $p3 := site.GetPage "p3" }} +P1 Fragments: {{ $p1.Fragments.Identifiers }} +P2 Fragments: {{ $p2.Fragments.Identifiers }} +Contains ref1: {{ $p2.Fragments.Identifiers.Contains "ref1" }} +Count ref1: {{ $p2.Fragments.Identifiers.Count "ref1" }} +{{ $opts := dict "document" .Page "fragments" $.Params }} +{{ $related1 := site.RegularPages.Related $opts }} +{{ $related2 := site.RegularPages.Related $p3 }} +Len Related 1: {{ len $related1 }} +Len Related 2: {{ len $related2 }} +Related 1: {{ template "list-related" $related1 }} +Related 2: {{ template "list-related" $related2 }} + +{{ define "list-related" }}{{ range $i, $e := . }} {{ $i }}: {{ .Title }}: {{ with .HeadingsFiltered}}{{ range $i, $e := .}}h{{ $i }}: {{ .Title }}|{{ .ID }}|{{ end }}{{ end }}::END{{ end }}{{ end }} + +-- layouts/_default/single.html -- +Content: {{ .Content }} + + +` + + b := hugolib.Test(t, files) + + expect := ` +P1 Fragments: [p1-title] +P2 Fragments: [p2-title-1 p2-title-2 ref1] +Len Related 1: 1 +Related 2: 2 +` + + for _, p := range []string{"p1", "p2"} { + b.AssertFileContent("public/"+p+"/index.html", expect) + } + + b.AssertFileContent("public/p1/index.html", + "Related 1: 0: p2: h0: First title|ref1|::END", + "Related 2: 0: p5: h0: Common p3, p4, p5|common-p3-p4-p5|::END 1: p4: h0: Common p3, p4, p5|common-p3-p4-p5|::END", + ) +} + +func BenchmarkRelatedSite(b *testing.B) { + files := ` +-- config.toml -- +baseURL = "http://example.com/" +disableKinds = ["taxonomy", "term", "RSS", "sitemap", "robotsTXT"] +[related] + includeNewer = false + threshold = 80 + toLower = false +[[related.indices]] + name = 'keywords' + weight = 70 +[[related.indices]] + name = 'pagerefs' + type = 'fragments' + weight = 30 +-- layouts/_default/single.html -- +Len related: {{ site.RegularPages.Related . | len }} +` + + createContent := func(n int) string { + base := `--- +title: "Page %d" +keywords: ['k%d'] +--- +` + + for range 32 { + base += fmt.Sprintf("\n## Title %d", rand.Intn(100)) + } + + return fmt.Sprintf(base, n, rand.Intn(32)) + } + + for i := 1; i < 100; i++ { + files += fmt.Sprintf("\n-- content/posts/p%d.md --\n"+createContent(i+1), i+1) + } + + cfg := hugolib.IntegrationTestConfig{ + T: b, + TxtarString: files, + } + builders := make([]*hugolib.IntegrationTestBuilder, b.N) + + for i := range builders { + builders[i] = hugolib.NewIntegrationTestBuilder(cfg) + } + + b.ResetTimer() + + for i := 0; i < b.N; i++ { + builders[i].Build() + } +} diff --git a/releaser/git.go b/releaser/git.go deleted file mode 100644 index 69ba6268b..000000000 --- a/releaser/git.go +++ /dev/null @@ -1,311 +0,0 @@ -// Copyright 2017-present 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 releaser - -import ( - "fmt" - "os/exec" - "regexp" - "sort" - "strconv" - "strings" -) - -var issueRe = regexp.MustCompile(`(?i)[Updates?|Closes?|Fix.*|See] #(\d+)`) - -const ( - notesChanges = "notesChanges" - templateChanges = "templateChanges" - coreChanges = "coreChanges" - outChanges = "outChanges" - otherChanges = "otherChanges" -) - -type changeLog struct { - Version string - Enhancements map[string]gitInfos - Fixes map[string]gitInfos - Notes gitInfos - All gitInfos - Docs gitInfos - - // Overall stats - Repo *gitHubRepo - ContributorCount int - ThemeCount int -} - -func newChangeLog(infos, docInfos gitInfos) *changeLog { - return &changeLog{ - Enhancements: make(map[string]gitInfos), - Fixes: make(map[string]gitInfos), - All: infos, - Docs: docInfos, - } -} - -func (l *changeLog) addGitInfo(isFix bool, info gitInfo, category string) { - var ( - infos gitInfos - found bool - segment map[string]gitInfos - ) - - if category == notesChanges { - l.Notes = append(l.Notes, info) - return - } else if isFix { - segment = l.Fixes - } else { - segment = l.Enhancements - } - - infos, found = segment[category] - if !found { - infos = gitInfos{} - } - - infos = append(infos, info) - segment[category] = infos -} - -func gitInfosToChangeLog(infos, docInfos gitInfos) *changeLog { - log := newChangeLog(infos, docInfos) - for _, info := range infos { - los := strings.ToLower(info.Subject) - isFix := strings.Contains(los, "fix") - var category = otherChanges - - // TODO(bep) improve - if regexp.MustCompile("(?i)deprecate").MatchString(los) { - category = notesChanges - } else if regexp.MustCompile("(?i)tpl|tplimpl:|layout").MatchString(los) { - category = templateChanges - } else if regexp.MustCompile("(?i)hugolib:").MatchString(los) { - category = coreChanges - } else if regexp.MustCompile("(?i)out(put)?:|media:|Output|Media").MatchString(los) { - category = outChanges - } - - // Trim package prefix. - colonIdx := strings.Index(info.Subject, ":") - if colonIdx != -1 && colonIdx < (len(info.Subject)/2) { - info.Subject = info.Subject[colonIdx+1:] - } - - info.Subject = strings.TrimSpace(info.Subject) - - log.addGitInfo(isFix, info, category) - } - - return log -} - -type gitInfo struct { - Hash string - Author string - Subject string - Body string - - GitHubCommit *gitHubCommit -} - -func (g gitInfo) Issues() []int { - return extractIssues(g.Body) -} - -func (g gitInfo) AuthorID() string { - if g.GitHubCommit != nil { - return g.GitHubCommit.Author.Login - } - return g.Author -} - -func extractIssues(body string) []int { - var i []int - m := issueRe.FindAllStringSubmatch(body, -1) - for _, mm := range m { - issueID, err := strconv.Atoi(mm[1]) - if err != nil { - continue - } - i = append(i, issueID) - } - return i -} - -type gitInfos []gitInfo - -func git(args ...string) (string, error) { - cmd := exec.Command("git", args...) - out, err := cmd.CombinedOutput() - if err != nil { - return "", fmt.Errorf("git failed: %q: %q (%q)", err, out, args) - } - return string(out), nil -} - -func getGitInfos(tag, repo, repoPath string, remote bool) (gitInfos, error) { - return getGitInfosBefore("HEAD", tag, repo, repoPath, remote) -} - -type countribCount struct { - Author string - GitHubAuthor gitHubAuthor - Count int -} - -func (c countribCount) AuthorLink() string { - if c.GitHubAuthor.HTMLURL != "" { - return fmt.Sprintf("[@%s](%s)", c.GitHubAuthor.Login, c.GitHubAuthor.HTMLURL) - } - - if !strings.Contains(c.Author, "@") { - return c.Author - } - - return c.Author[:strings.Index(c.Author, "@")] - -} - -type contribCounts []countribCount - -func (c contribCounts) Less(i, j int) bool { return c[i].Count > c[j].Count } -func (c contribCounts) Len() int { return len(c) } -func (c contribCounts) Swap(i, j int) { c[i], c[j] = c[j], c[i] } - -func (g gitInfos) ContribCountPerAuthor() contribCounts { - var c contribCounts - - counters := make(map[string]countribCount) - - for _, gi := range g { - authorID := gi.AuthorID() - if count, ok := counters[authorID]; ok { - count.Count = count.Count + 1 - counters[authorID] = count - } else { - var ghA gitHubAuthor - if gi.GitHubCommit != nil { - ghA = gi.GitHubCommit.Author - } - authorCount := countribCount{Count: 1, Author: gi.Author, GitHubAuthor: ghA} - counters[authorID] = authorCount - } - } - - for _, v := range counters { - c = append(c, v) - } - - sort.Sort(c) - return c -} - -func getGitInfosBefore(ref, tag, repo, repoPath string, remote bool) (gitInfos, error) { - client := newGitHubAPI(repo) - var g gitInfos - - log, err := gitLogBefore(ref, tag, repoPath) - if err != nil { - return g, err - } - - log = strings.Trim(log, "\n\x1e'") - entries := strings.Split(log, "\x1e") - - for _, entry := range entries { - items := strings.Split(entry, "\x1f") - gi := gitInfo{} - - if len(items) > 0 { - gi.Hash = items[0] - } - if len(items) > 1 { - gi.Author = items[1] - } - if len(items) > 2 { - gi.Subject = items[2] - } - if len(items) > 3 { - gi.Body = items[3] - } - - if remote && gi.Hash != "" { - gc, err := client.fetchCommit(gi.Hash) - if err == nil { - gi.GitHubCommit = &gc - } - } - g = append(g, gi) - } - - return g, nil -} - -// Ignore autogenerated commits etc. in change log. This is a regexp. -const ignoredCommits = "releaser?:|snapcraft:|Merge commit|Squashed|Revert" - -func gitLogBefore(ref, tag, repoPath string) (string, error) { - var prevTag string - var err error - if tag != "" { - prevTag = tag - } else { - prevTag, err = gitVersionTagBefore(ref) - if err != nil { - return "", err - } - } - - defaultArgs := []string{"log", "-E", fmt.Sprintf("--grep=%s", ignoredCommits), "--invert-grep", "--pretty=format:%x1e%h%x1f%aE%x1f%s%x1f%b", "--abbrev-commit", prevTag + ".." + ref} - - var args []string - - if repoPath != "" { - args = append([]string{"-C", repoPath}, defaultArgs...) - } else { - args = defaultArgs - } - - log, err := git(args...) - if err != nil { - return ",", err - } - - return log, err -} - -func gitVersionTagBefore(ref string) (string, error) { - return gitShort("describe", "--tags", "--abbrev=0", "--always", "--match", "v[0-9]*", ref+"^") -} - -func gitShort(args ...string) (output string, err error) { - output, err = git(args...) - return strings.Replace(strings.Split(output, "\n")[0], "'", "", -1), err -} - -func tagExists(tag string) (bool, error) { - out, err := git("tag", "-l", tag) - - if err != nil { - return false, err - } - - if strings.Contains(out, tag) { - return true, nil - } - - return false, nil -} diff --git a/releaser/git_test.go b/releaser/git_test.go deleted file mode 100644 index 1c5f78886..000000000 --- a/releaser/git_test.go +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright 2017-present 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 releaser - -import ( - "testing" - - qt "github.com/frankban/quicktest" -) - -func TestGitInfos(t *testing.T) { - c := qt.New(t) - skipIfCI(t) - infos, err := getGitInfos("v0.20", "hugo", "", false) - - c.Assert(err, qt.IsNil) - c.Assert(len(infos) > 0, qt.Equals, true) -} - -func TestIssuesRe(t *testing.T) { - c := qt.New(t) - - body := ` -This is a commit message. - -Updates #123 -Fix #345 -closes #543 -See #456 - ` - - issues := extractIssues(body) - - c.Assert(len(issues), qt.Equals, 4) - c.Assert(issues[0], qt.Equals, 123) - c.Assert(issues[2], qt.Equals, 543) - -} - -func TestGitVersionTagBefore(t *testing.T) { - skipIfCI(t) - c := qt.New(t) - v1, err := gitVersionTagBefore("v0.18") - c.Assert(err, qt.IsNil) - c.Assert(v1, qt.Equals, "v0.17") -} - -func TestTagExists(t *testing.T) { - skipIfCI(t) - c := qt.New(t) - b1, err := tagExists("v0.18") - c.Assert(err, qt.IsNil) - c.Assert(b1, qt.Equals, true) - - b2, err := tagExists("adfagdsfg") - c.Assert(err, qt.IsNil) - c.Assert(b2, qt.Equals, false) - -} - -func skipIfCI(t *testing.T) { - if isCI() { - // Travis has an ancient git with no --invert-grep: https://github.com/travis-ci/travis-ci/issues/6328 - // Also Travis clones very shallowly, making some of the tests above shaky. - t.Skip("Skip git test on Linux to make Travis happy.") - } -} diff --git a/releaser/github.go b/releaser/github.go deleted file mode 100644 index ba019ccad..000000000 --- a/releaser/github.go +++ /dev/null @@ -1,144 +0,0 @@ -package releaser - -import ( - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "os" - "strings" -) - -var ( - gitHubCommitsAPI = "https://api.github.com/repos/gohugoio/REPO/commits/%s" - gitHubRepoAPI = "https://api.github.com/repos/gohugoio/REPO" - gitHubContributorsAPI = "https://api.github.com/repos/gohugoio/REPO/contributors" -) - -type gitHubAPI struct { - commitsAPITemplate string - repoAPI string - contributorsAPITemplate string -} - -func newGitHubAPI(repo string) *gitHubAPI { - return &gitHubAPI{ - commitsAPITemplate: strings.Replace(gitHubCommitsAPI, "REPO", repo, -1), - repoAPI: strings.Replace(gitHubRepoAPI, "REPO", repo, -1), - contributorsAPITemplate: strings.Replace(gitHubContributorsAPI, "REPO", repo, -1), - } -} - -type gitHubCommit struct { - Author gitHubAuthor `json:"author"` - HTMLURL string `json:"html_url"` -} - -type gitHubAuthor struct { - ID int `json:"id"` - Login string `json:"login"` - HTMLURL string `json:"html_url"` - AvatarURL string `json:"avatar_url"` -} - -type gitHubRepo struct { - ID int `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - HTMLURL string `json:"html_url"` - Stars int `json:"stargazers_count"` - Contributors []gitHubContributor -} - -type gitHubContributor struct { - ID int `json:"id"` - Login string `json:"login"` - HTMLURL string `json:"html_url"` - Contributions int `json:"contributions"` -} - -func (g *gitHubAPI) fetchCommit(ref string) (gitHubCommit, error) { - var commit gitHubCommit - - u := fmt.Sprintf(g.commitsAPITemplate, ref) - - req, err := http.NewRequest("GET", u, nil) - if err != nil { - return commit, err - } - - err = doGitHubRequest(req, &commit) - - return commit, err -} - -func (g *gitHubAPI) fetchRepo() (gitHubRepo, error) { - var repo gitHubRepo - - req, err := http.NewRequest("GET", g.repoAPI, nil) - if err != nil { - return repo, err - } - - err = doGitHubRequest(req, &repo) - if err != nil { - return repo, err - } - - var contributors []gitHubContributor - page := 0 - for { - page++ - var currPage []gitHubContributor - url := fmt.Sprintf(g.contributorsAPITemplate+"?page=%d", page) - - req, err = http.NewRequest("GET", url, nil) - if err != nil { - return repo, err - } - - err = doGitHubRequest(req, &currPage) - if err != nil { - return repo, err - } - if len(currPage) == 0 { - break - } - - contributors = append(contributors, currPage...) - - } - - repo.Contributors = contributors - - return repo, err - -} - -func doGitHubRequest(req *http.Request, v interface{}) error { - addGitHubToken(req) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if isError(resp) { - b, _ := ioutil.ReadAll(resp.Body) - return fmt.Errorf("GitHub lookup failed: %s", string(b)) - } - - return json.NewDecoder(resp.Body).Decode(v) -} - -func isError(resp *http.Response) bool { - return resp.StatusCode < 200 || resp.StatusCode > 299 -} - -func addGitHubToken(req *http.Request) { - gitHubToken := os.Getenv("GITHUB_TOKEN") - if gitHubToken != "" { - req.Header.Add("Authorization", "token "+gitHubToken) - } -} diff --git a/releaser/github_test.go b/releaser/github_test.go deleted file mode 100644 index 23331bf38..000000000 --- a/releaser/github_test.go +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright 2017-present 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 releaser - -import ( - "fmt" - "os" - "testing" - - qt "github.com/frankban/quicktest" -) - -func TestGitHubLookupCommit(t *testing.T) { - skipIfNoToken(t) - c := qt.New(t) - client := newGitHubAPI("hugo") - commit, err := client.fetchCommit("793554108763c0984f1a1b1a6ee5744b560d78d0") - c.Assert(err, qt.IsNil) - fmt.Println(commit) -} - -func TestFetchRepo(t *testing.T) { - skipIfNoToken(t) - c := qt.New(t) - client := newGitHubAPI("hugo") - repo, err := client.fetchRepo() - c.Assert(err, qt.IsNil) - fmt.Println(">>", len(repo.Contributors)) -} - -func skipIfNoToken(t *testing.T) { - if os.Getenv("GITHUB_TOKEN") == "" { - t.Skip("Skip test against GitHub as no GITHUB_TOKEN set.") - } -} diff --git a/releaser/releasenotes_writer.go b/releaser/releasenotes_writer.go deleted file mode 100644 index 3709ff826..000000000 --- a/releaser/releasenotes_writer.go +++ /dev/null @@ -1,328 +0,0 @@ -// Copyright 2017-present 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 releaser implements a set of utilities and a wrapper around Goreleaser -// to help automate the Hugo release process. -package releaser - -import ( - "bytes" - "fmt" - "io" - "io/ioutil" - "net/http" - "os" - "path/filepath" - "strings" - "text/template" - "time" -) - -const ( - issueLinkTemplate = "[#%d](https://github.com/gohugoio/hugo/issues/%d)" - linkTemplate = "[%s](%s)" - releaseNotesMarkdownTemplatePatchRelease = ` -{{ if eq (len .All) 1 }} -This is a bug-fix release with one important fix. -{{ else }} -This is a bug-fix release with a couple of important fixes. -{{ end }} -{{ range .All }} -{{- if .GitHubCommit -}} -* {{ .Subject }} {{ . | commitURL }} {{ . | authorURL }} {{ range .Issues }}{{ . | issue }}{{ end }} -{{ else -}} -* {{ .Subject }} {{ range .Issues }}{{ . | issue }}{{ end }} -{{ end -}} -{{- end }} - - -` - releaseNotesMarkdownTemplate = ` -{{- $contribsPerAuthor := .All.ContribCountPerAuthor -}} -{{- $docsContribsPerAuthor := .Docs.ContribCountPerAuthor -}} - -This release represents **{{ len .All }} contributions by {{ len $contribsPerAuthor }} contributors** to the main Hugo code base. - -{{- if gt (len $contribsPerAuthor) 3 -}} -{{- $u1 := index $contribsPerAuthor 0 -}} -{{- $u2 := index $contribsPerAuthor 1 -}} -{{- $u3 := index $contribsPerAuthor 2 -}} -{{- $u4 := index $contribsPerAuthor 3 -}} -{{- $u1.AuthorLink }} leads the Hugo development with a significant amount of contributions, but also a big shoutout to {{ $u2.AuthorLink }}, {{ $u3.AuthorLink }}, and {{ $u4.AuthorLink }} for their ongoing contributions. -And a big thanks to [@digitalcraftsman](https://github.com/digitalcraftsman) and [@onedrawingperday](https://github.com/onedrawingperday) for their relentless work on keeping the themes site in pristine condition and to [@davidsneighbour](https://github.com/davidsneighbour) and [@kaushalmodi](https://github.com/kaushalmodi) for all the great work on the documentation site. -{{ end }} -Many have also been busy writing and fixing the documentation in [hugoDocs](https://github.com/gohugoio/hugoDocs), -which has received **{{ len .Docs }} contributions by {{ len $docsContribsPerAuthor }} contributors**. -{{- if gt (len $docsContribsPerAuthor) 3 -}} -{{- $u1 := index $docsContribsPerAuthor 0 -}} -{{- $u2 := index $docsContribsPerAuthor 1 -}} -{{- $u3 := index $docsContribsPerAuthor 2 -}} -{{- $u4 := index $docsContribsPerAuthor 3 }} A special thanks to {{ $u1.AuthorLink }}, {{ $u2.AuthorLink }}, {{ $u3.AuthorLink }}, and {{ $u4.AuthorLink }} for their work on the documentation site. -{{ end }} - -Hugo now has: - -{{ with .Repo -}} -* {{ .Stars }}+ [stars](https://github.com/gohugoio/hugo/stargazers) -* {{ len .Contributors }}+ [contributors](https://github.com/gohugoio/hugo/graphs/contributors) -{{- end -}} -{{ with .ThemeCount }} -* {{ . }}+ [themes](http://themes.gohugo.io/) -{{ end }} -{{ with .Notes }} -## Notes -{{ template "change-section" . }} -{{- end -}} -## Enhancements -{{ template "change-headers" .Enhancements -}} -## Fixes -{{ template "change-headers" .Fixes -}} - -{{ define "change-headers" }} -{{ $tmplChanges := index . "templateChanges" -}} -{{- $outChanges := index . "outChanges" -}} -{{- $coreChanges := index . "coreChanges" -}} -{{- $otherChanges := index . "otherChanges" -}} -{{- with $tmplChanges -}} -### Templates -{{ template "change-section" . }} -{{- end -}} -{{- with $outChanges -}} -### Output -{{ template "change-section" . }} -{{- end -}} -{{- with $coreChanges -}} -### Core -{{ template "change-section" . }} -{{- end -}} -{{- with $otherChanges -}} -### Other -{{ template "change-section" . }} -{{- end -}} -{{ end }} - - -{{ define "change-section" }} -{{ range . }} -{{- if .GitHubCommit -}} -* {{ .Subject }} {{ . | commitURL }} {{ . | authorURL }} {{ range .Issues }}{{ . | issue }}{{ end }} -{{ else -}} -* {{ .Subject }} {{ range .Issues }}{{ . | issue }}{{ end }} -{{ end -}} -{{- end }} -{{ end }} -` -) - -var templateFuncs = template.FuncMap{ - "isPatch": func(c changeLog) bool { - return !strings.HasSuffix(c.Version, "0") - }, - "issue": func(id int) string { - return fmt.Sprintf(issueLinkTemplate, id, id) - }, - "commitURL": func(info gitInfo) string { - if info.GitHubCommit.HTMLURL == "" { - return "" - } - return fmt.Sprintf(linkTemplate, info.Hash, info.GitHubCommit.HTMLURL) - }, - "authorURL": func(info gitInfo) string { - if info.GitHubCommit.Author.Login == "" { - return "" - } - return fmt.Sprintf(linkTemplate, "@"+info.GitHubCommit.Author.Login, info.GitHubCommit.Author.HTMLURL) - }, -} - -func writeReleaseNotes(version string, infosMain, infosDocs gitInfos, to io.Writer) error { - client := newGitHubAPI("hugo") - changes := gitInfosToChangeLog(infosMain, infosDocs) - changes.Version = version - repo, err := client.fetchRepo() - if err == nil { - changes.Repo = &repo - } - themeCount, err := fetchThemeCount() - if err == nil { - changes.ThemeCount = themeCount - } - - mtempl := releaseNotesMarkdownTemplate - - if !strings.HasSuffix(version, "0") { - mtempl = releaseNotesMarkdownTemplatePatchRelease - } - - tmpl, err := template.New("").Funcs(templateFuncs).Parse(mtempl) - if err != nil { - return err - } - - err = tmpl.Execute(to, changes) - if err != nil { - return err - } - - return nil - -} - -func fetchThemeCount() (int, error) { - resp, err := http.Get("https://raw.githubusercontent.com/gohugoio/hugoThemes/master/.gitmodules") - if err != nil { - return 0, err - } - defer resp.Body.Close() - - b, _ := ioutil.ReadAll(resp.Body) - return bytes.Count(b, []byte("submodule")), nil -} - -func writeReleaseNotesToTmpFile(version string, infosMain, infosDocs gitInfos) (string, error) { - f, err := ioutil.TempFile("", "hugorelease") - if err != nil { - return "", err - } - - defer f.Close() - - if err := writeReleaseNotes(version, infosMain, infosDocs, f); err != nil { - return "", err - } - - return f.Name(), nil -} - -func getReleaseNotesDocsTempDirAndName(version string, final bool) (string, string) { - if final { - return hugoFilepath("temp"), fmt.Sprintf("%s-relnotes-ready.md", version) - } - return hugoFilepath("temp"), fmt.Sprintf("%s-relnotes.md", version) -} - -func getReleaseNotesDocsTempFilename(version string, final bool) string { - return filepath.Join(getReleaseNotesDocsTempDirAndName(version, final)) -} - -func (r *ReleaseHandler) releaseNotesState(version string) (releaseNotesState, error) { - docsTempPath, name := getReleaseNotesDocsTempDirAndName(version, false) - _, err := os.Stat(filepath.Join(docsTempPath, name)) - - if err == nil { - return releaseNotesCreated, nil - } - - docsTempPath, name = getReleaseNotesDocsTempDirAndName(version, true) - _, err = os.Stat(filepath.Join(docsTempPath, name)) - - if err == nil { - return releaseNotesReady, nil - } - - if !os.IsNotExist(err) { - return releaseNotesNone, err - } - - return releaseNotesNone, nil - -} - -func (r *ReleaseHandler) writeReleaseNotesToTemp(version string, isPatch bool, infosMain, infosDocs gitInfos) (string, error) { - - docsTempPath, name := getReleaseNotesDocsTempDirAndName(version, isPatch) - - var ( - w io.WriteCloser - ) - - if !r.try { - os.Mkdir(docsTempPath, os.ModePerm) - - f, err := os.Create(filepath.Join(docsTempPath, name)) - if err != nil { - return "", err - } - - name = f.Name() - - defer f.Close() - - w = f - - } else { - w = os.Stdout - } - - if err := writeReleaseNotes(version, infosMain, infosDocs, w); err != nil { - return "", err - } - - return name, nil - -} - -func (r *ReleaseHandler) writeReleaseNotesToDocs(title, description, sourceFilename string) (string, error) { - targetFilename := "index.md" - bundleDir := strings.TrimSuffix(filepath.Base(sourceFilename), "-ready.md") - contentDir := hugoFilepath("docs/content/en/news/" + bundleDir) - targetFullFilename := filepath.Join(contentDir, targetFilename) - - if r.try { - fmt.Printf("Write release notes to /docs: Bundle %q Dir: %q\n", bundleDir, contentDir) - return targetFullFilename, nil - } - - if err := os.MkdirAll(contentDir, os.ModePerm); err != nil { - return "", nil - } - - b, err := ioutil.ReadFile(sourceFilename) - if err != nil { - return "", err - } - - f, err := os.Create(targetFullFilename) - if err != nil { - return "", err - } - defer f.Close() - - fmTail := "" - if !strings.HasSuffix(title, ".0") { - // Bug fix release - fmTail = ` -images: -- images/blog/hugo-bug-poster.png -` - } - - if _, err := f.WriteString(fmt.Sprintf(` ---- -date: %s -title: %q -description: %q -categories: ["Releases"]%s ---- - - `, time.Now().Format("2006-01-02"), title, description, fmTail)); err != nil { - return "", err - } - - if _, err := f.Write(b); err != nil { - return "", err - } - - return targetFullFilename, nil - -} diff --git a/releaser/releasenotes_writer_test.go b/releaser/releasenotes_writer_test.go deleted file mode 100644 index 5013c6522..000000000 --- a/releaser/releasenotes_writer_test.go +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright 2017-present 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 commands defines and implements command-line commands and flags -// used by Hugo. Commands and flags are implemented using Cobra. - -package releaser - -import ( - "bytes" - "fmt" - "os" - "testing" - - qt "github.com/frankban/quicktest" -) - -func _TestReleaseNotesWriter(t *testing.T) { - if os.Getenv("CI") != "" { - // Travis has an ancient git with no --invert-grep: https://github.com/travis-ci/travis-ci/issues/6328 - t.Skip("Skip git test on CI to make Travis happy.") - } - c := qt.New(t) - - var b bytes.Buffer - - // TODO(bep) consider to query GitHub directly for the gitlog with author info, probably faster. - infos, err := getGitInfosBefore("HEAD", "v0.20", "hugo", "", false) - c.Assert(err, qt.IsNil) - - c.Assert(writeReleaseNotes("0.21", infos, infos, &b), qt.IsNil) - - fmt.Println(b.String()) - -} diff --git a/releaser/releaser.go b/releaser/releaser.go index 61b9d211f..46cee1b13 100644 --- a/releaser/releaser.go +++ b/releaser/releaser.go @@ -1,4 +1,4 @@ -// Copyright 2017-present The Hugo Authors. All rights reserved. +// Copyright 2024 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. @@ -11,13 +11,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package releaser implements a set of utilities and a wrapper around Goreleaser -// to help automate the Hugo release process. +// Package releaser implements a set of utilities to help automate the +// Hugo release process. package releaser import ( "fmt" - "io/ioutil" "log" "os" "os/exec" @@ -26,24 +25,58 @@ import ( "strings" "github.com/gohugoio/hugo/common/hugo" - "github.com/pkg/errors" ) const commitPrefix = "releaser:" -type releaseNotesState int +// New initializes a ReleaseHandler. +func New(skipPush, try bool, step int) (*ReleaseHandler, error) { + if step < 1 || step > 2 { + return nil, fmt.Errorf("step must be 1 or 2") + } -const ( - releaseNotesNone = iota - releaseNotesCreated - releaseNotesReady -) + prefix := "release-" + branch, err := git("rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + return nil, err + } + branch = strings.TrimSpace(branch) + + if !strings.HasPrefix(branch, prefix) { + return nil, fmt.Errorf("branch %q is not a release branch", branch) + } + + version := strings.TrimPrefix(branch, prefix) + version = strings.TrimPrefix(version, "v") + + logf("Branch: %s|Version: v%s\n", branch, version) + + rh := &ReleaseHandler{branchVersion: version, skipPush: skipPush, try: try, step: step} + + if try { + rh.git = func(args ...string) (string, error) { + logln("git", strings.Join(args, " ")) + return "", nil + } + } else { + rh.git = git + } + + return rh, nil +} // ReleaseHandler provides functionality to release a new version of Hugo. +// Test this locally without doing an actual release: +// go run -tags release main.go release --skip-publish --try -r 0.90.0 +// Or a variation of the above -- the skip-publish flag makes sure that any changes are performed to the local Git only. type ReleaseHandler struct { - cliVersion string + branchVersion string - skipPublish bool + // 1 or 2. + step int + + // No remote pushes. + skipPush bool // Just simulate, no actual changes. try bool @@ -51,194 +84,50 @@ type ReleaseHandler struct { git func(args ...string) (string, error) } -func (r ReleaseHandler) calculateVersions() (hugo.Version, hugo.Version) { - newVersion := hugo.MustParseVersion(r.cliVersion) - finalVersion := newVersion.Next() - finalVersion.PatchLevel = 0 - - if newVersion.Suffix != "-test" { - newVersion.Suffix = "" - } - - finalVersion.Suffix = "-DEV" - - return newVersion, finalVersion -} - -// New initialises a ReleaseHandler. -func New(version string, skipPublish, try bool) *ReleaseHandler { - // When triggered from CI release branch - version = strings.TrimPrefix(version, "release-") - version = strings.TrimPrefix(version, "v") - rh := &ReleaseHandler{cliVersion: version, skipPublish: skipPublish, try: try} - - if try { - rh.git = func(args ...string) (string, error) { - fmt.Println("git", strings.Join(args, " ")) - return "", nil - } - } else { - rh.git = git - } - - return rh -} - // Run creates a new release. func (r *ReleaseHandler) Run() error { - if os.Getenv("GITHUB_TOKEN") == "" { - return errors.New("GITHUB_TOKEN not set, create one here with the repo scope selected: https://github.com/settings/tokens/new") - } - newVersion, finalVersion := r.calculateVersions() - version := newVersion.String() tag := "v" + version - isPatch := newVersion.PatchLevel > 0 mainVersion := newVersion mainVersion.PatchLevel = 0 - // Exit early if tag already exists - exists, err := tagExists(tag) - if err != nil { - return err - } + r.gitPull() - if exists { - return fmt.Errorf("tag %q already exists", tag) - } + defer r.gitPush() - var changeLogFromTag string - - if newVersion.PatchLevel == 0 { - // There may have been patch releases between, so set the tag explicitly. - changeLogFromTag = "v" + newVersion.Prev().String() - exists, _ := tagExists(changeLogFromTag) - if !exists { - // fall back to one that exists. - changeLogFromTag = "" + if r.step == 1 { + if err := r.bumpVersions(newVersion); err != nil { + return err } - } - var ( - gitCommits gitInfos - gitCommitsDocs gitInfos - relNotesState releaseNotesState - ) + if _, err := r.git("commit", "-a", "-m", fmt.Sprintf("%s Bump versions for release of %s\n\n[ci skip]", commitPrefix, newVersion)); err != nil { + return err + } - relNotesState, err = r.releaseNotesState(version) - if err != nil { - return err - } - - prepareRelaseNotes := isPatch || relNotesState == releaseNotesNone - shouldRelease := isPatch || relNotesState == releaseNotesReady - - defer r.gitPush() // TODO(bep) - - if prepareRelaseNotes || shouldRelease { - gitCommits, err = getGitInfos(changeLogFromTag, "hugo", "", !r.try) + // The above commit will be the target for this release, so print it to the console in a env friendly way. + sha, err := git("rev-parse", "HEAD") if err != nil { return err } - // TODO(bep) explicit tag? - gitCommitsDocs, err = getGitInfos("", "hugoDocs", "../hugoDocs", !r.try) - if err != nil { + // Hugoreleaser will do the actual release using these values. + if err := r.replaceInFile("hugoreleaser.env", + `HUGORELEASER_TAG=(\S*)`, "HUGORELEASER_TAG="+tag, + `HUGORELEASER_COMMITISH=(\S*)`, "HUGORELEASER_COMMITISH="+sha, + ); err != nil { return err } - } + logf("HUGORELEASER_TAG=%s\n", tag) + logf("HUGORELEASER_COMMITISH=%s\n", sha) - if relNotesState == releaseNotesCreated { - fmt.Println("Release notes created, but not ready. Reneame to *-ready.md to continue ...") return nil } - if prepareRelaseNotes { - releaseNotesFile, err := r.writeReleaseNotesToTemp(version, isPatch, gitCommits, gitCommitsDocs) - if err != nil { - return err - } - - if _, err := r.git("add", releaseNotesFile); err != nil { - return err - } - - commitMsg := fmt.Sprintf("%s Add release notes for %s", commitPrefix, newVersion) - if !isPatch { - commitMsg += "\n\nRename to *-ready.md to continue." - } - commitMsg += "\n[ci skip]" - - if _, err := r.git("commit", "-m", commitMsg); err != nil { - return err - } - } - - if !shouldRelease { - fmt.Printf("Skip release ... ") - return nil - } - - // For docs, for now we assume that: - // The /docs subtree is up to date and ready to go. - // The hugoDocs/dev and hugoDocs/master must be merged manually after release. - // TODO(bep) improve this when we see how it works. - - if err := r.bumpVersions(newVersion); err != nil { - return err - } - - if _, err := r.git("commit", "-a", "-m", fmt.Sprintf("%s Bump versions for release of %s\n\n[ci skip]", commitPrefix, newVersion)); err != nil { - return err - } - - releaseNotesFile := getReleaseNotesDocsTempFilename(version, true) - - title, description := version, version - if isPatch { - title = "Hugo " + version + ": A couple of Bug Fixes" - description = "This version fixes a couple of bugs introduced in " + mainVersion.String() + "." - } - - // Write the release notes to the docs site as well. - docFile, err := r.writeReleaseNotesToDocs(title, description, releaseNotesFile) - if err != nil { - return err - } - - if _, err := r.git("add", docFile); err != nil { - return err - } - if _, err := r.git("commit", "-m", fmt.Sprintf("%s Add release notes to /docs for release of %s\n\n[ci skip]", commitPrefix, newVersion)); err != nil { - return err - } - - if _, err := r.git("tag", "-a", tag, "-m", fmt.Sprintf("%s %s [ci skip]", commitPrefix, newVersion)); err != nil { - return err - } - - if !r.skipPublish { - if _, err := r.git("push", "origin", tag); err != nil { - return err - } - } - - if err := r.release(releaseNotesFile); err != nil { - return err - } - if err := r.bumpVersions(finalVersion); err != nil { return err } - if !r.try { - // No longer needed. - if err := os.Remove(releaseNotesFile); err != nil { - return err - } - } - if _, err := r.git("commit", "-a", "-m", fmt.Sprintf("%s Prepare repository for %s\n\n[ci skip]", commitPrefix, finalVersion)); err != nil { return err } @@ -246,36 +135,6 @@ func (r *ReleaseHandler) Run() error { return nil } -func (r *ReleaseHandler) gitPush() { - if r.skipPublish { - return - } - if _, err := r.git("push", "origin", "HEAD"); err != nil { - log.Fatal("push failed:", err) - } -} - -func (r *ReleaseHandler) release(releaseNotesFile string) error { - if r.try { - fmt.Println("Skip goreleaser...") - return nil - } - - args := []string{"--rm-dist", "--release-notes", releaseNotesFile} - if r.skipPublish { - args = append(args, "--skip-publish") - } - - cmd := exec.Command("goreleaser", args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - err := cmd.Run() - if err != nil { - return errors.Wrap(err, "goreleaser failed") - } - return nil -} - func (r *ReleaseHandler) bumpVersions(ver hugo.Version) error { toDev := "" @@ -284,19 +143,9 @@ func (r *ReleaseHandler) bumpVersions(ver hugo.Version) error { } if err := r.replaceInFile("common/hugo/version_current.go", - `Number:(\s{4,})(.*),`, fmt.Sprintf(`Number:${1}%.2f,`, ver.Number), - `PatchLevel:(\s*)(.*),`, fmt.Sprintf(`PatchLevel:${1}%d,`, ver.PatchLevel), - `Suffix:(\s{4,})".*",`, fmt.Sprintf(`Suffix:${1}"%s",`, toDev)); err != nil { - return err - } - - snapcraftGrade := "stable" - if ver.Suffix != "" { - snapcraftGrade = "devel" - } - if err := r.replaceInFile("snap/snapcraft.yaml", - `version: "(.*)"`, fmt.Sprintf(`version: "%s"`, ver), - `grade: (.*) #`, fmt.Sprintf(`grade: %s #`, snapcraftGrade)); err != nil { + `Minor:(\s*)(\d*),`, fmt.Sprintf(`Minor:${1}%d,`, ver.Minor), + `PatchLevel:(\s*)(\d*),`, fmt.Sprintf(`PatchLevel:${1}%d,`, ver.PatchLevel), + `Suffix:(\s*)".*",`, fmt.Sprintf(`Suffix:${1}"%s",`, toDev)); err != nil { return err } @@ -317,19 +166,48 @@ func (r *ReleaseHandler) bumpVersions(ver hugo.Version) error { return nil } +func (r ReleaseHandler) calculateVersions() (hugo.Version, hugo.Version) { + newVersion := hugo.MustParseVersion(r.branchVersion) + finalVersion := newVersion.Next() + finalVersion.PatchLevel = 0 + + if newVersion.Suffix != "-test" { + newVersion.Suffix = "" + } + + finalVersion.Suffix = "-DEV" + + return newVersion, finalVersion +} + +func (r *ReleaseHandler) gitPull() { + if _, err := r.git("pull", "origin", "HEAD"); err != nil { + log.Fatal("pull failed:", err) + } +} + +func (r *ReleaseHandler) gitPush() { + if r.skipPush { + return + } + if _, err := r.git("push", "origin", "HEAD"); err != nil { + log.Fatal("push failed:", err) + } +} + func (r *ReleaseHandler) replaceInFile(filename string, oldNew ...string) error { - fullFilename := hugoFilepath(filename) - fi, err := os.Stat(fullFilename) + filename = filepath.FromSlash(filename) + fi, err := os.Stat(filename) if err != nil { return err } if r.try { - fmt.Printf("Replace in %q: %q\n", filename, oldNew) + logf("Replace in %q: %q\n", filename, oldNew) return nil } - b, err := ioutil.ReadFile(fullFilename) + b, err := os.ReadFile(filename) if err != nil { return err } @@ -340,17 +218,22 @@ func (r *ReleaseHandler) replaceInFile(filename string, oldNew ...string) error newContent = re.ReplaceAllString(newContent, oldNew[i+1]) } - return ioutil.WriteFile(fullFilename, []byte(newContent), fi.Mode()) + return os.WriteFile(filename, []byte(newContent), fi.Mode()) } -func hugoFilepath(filename string) string { - pwd, err := os.Getwd() +func git(args ...string) (string, error) { + cmd := exec.Command("git", args...) + out, err := cmd.CombinedOutput() if err != nil { - log.Fatal(err) + return "", fmt.Errorf("git failed: %q: %q (%q)", err, out, args) } - return filepath.Join(pwd, filename) + return string(out), nil } -func isCI() bool { - return os.Getenv("CI") != "" +func logf(format string, args ...any) { + fmt.Fprintf(os.Stderr, format, args...) +} + +func logln(args ...any) { + fmt.Fprintln(os.Stderr, args...) } diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 941f32d5d..000000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -Pygments==2.1.3 -docutils==0.12 diff --git a/hugolib/assets/images/sunset.jpg b/resources/assets/sunset.jpg similarity index 100% rename from hugolib/assets/images/sunset.jpg rename to resources/assets/sunset.jpg diff --git a/resources/docs.go b/resources/docs.go new file mode 100644 index 000000000..16fe34027 --- /dev/null +++ b/resources/docs.go @@ -0,0 +1,15 @@ +// Copyright 2024 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 resources contains Resource related types. +package resources diff --git a/resources/image.go b/resources/image.go index 076f2ae4d..c1f107b59 100644 --- a/resources/image.go +++ b/resources/image.go @@ -19,26 +19,26 @@ import ( "image" "image/color" "image/draw" - _ "image/gif" + "image/gif" _ "image/png" "io" - "io/ioutil" "os" - "path" - "path/filepath" "strings" "sync" - "github.com/disintegration/gift" + color_extractor "github.com/marekm4/color-extractor" "github.com/gohugoio/hugo/cache/filecache" + "github.com/gohugoio/hugo/common/hashing" + "github.com/gohugoio/hugo/common/paths" + + "github.com/disintegration/gift" + "github.com/gohugoio/hugo/resources/images/exif" + "github.com/gohugoio/hugo/resources/internal" "github.com/gohugoio/hugo/resources/resource" - _errors "github.com/pkg/errors" - - "github.com/gohugoio/hugo/helpers" "github.com/gohugoio/hugo/resources/images" // Blind import for image.Decode @@ -46,12 +46,14 @@ import ( ) var ( - _ resource.Image = (*imageResource)(nil) - _ resource.Source = (*imageResource)(nil) - _ resource.Cloner = (*imageResource)(nil) + _ images.ImageResource = (*imageResource)(nil) + _ resource.Source = (*imageResource)(nil) + _ resource.Cloner = (*imageResource)(nil) + _ resource.NameNormalizedProvider = (*imageResource)(nil) + _ targetPathProvider = (*imageResource)(nil) ) -// ImageResource represents an image resource. +// imageResource represents an image resource. type imageResource struct { *images.Image @@ -63,32 +65,33 @@ type imageResource struct { metaInitErr error meta *imageMeta + dominantColorInit sync.Once + dominantColors []images.Color + baseResource } type imageMeta struct { - Exif *exif.Exif + Exif *exif.ExifInfo } -func (i *imageResource) Exif() (*exif.Exif, error) { +func (i *imageResource) Exif() *exif.ExifInfo { return i.root.getExif() } -func (i *imageResource) getExif() (*exif.Exif, error) { - +func (i *imageResource) getExif() *exif.ExifInfo { i.metaInit.Do(func() { - - supportsExif := i.Format == images.JPEG || i.Format == images.TIFF - if !supportsExif { + mf := i.Format.ToImageMetaImageFormatFormat() + if mf == -1 { + // No Exif support for this format. return - } key := i.getImageMetaCacheTargetPath() read := func(info filecache.ItemInfo, r io.ReadSeeker) error { meta := &imageMeta{} - data, err := ioutil.ReadAll(r) + data, err := io.ReadAll(r) if err != nil { return err } @@ -103,7 +106,7 @@ func (i *imageResource) getExif() (*exif.Exif, error) { } create := func(info filecache.ItemInfo, w io.WriteCloser) (err error) { - + defer w.Close() f, err := i.root.ReadSeekCloser() if err != nil { i.metaInitErr = err @@ -111,10 +114,11 @@ func (i *imageResource) getExif() (*exif.Exif, error) { } defer f.Close() - x, err := i.getSpec().imaging.DecodeExif(f) + filename := i.getResourcePaths().Path() + x, err := i.getSpec().imaging.DecodeExif(filename, mf, f) if err != nil { - i.metaInitErr = err - return + i.getSpec().Logger.Warnf("Unable to decode Exif metadata from image: %s", i.Key()) + return nil } i.meta = &imageMeta{Exif: x} @@ -122,20 +126,45 @@ func (i *imageResource) getExif() (*exif.Exif, error) { // Also write it to cache enc := json.NewEncoder(w) return enc.Encode(i.meta) - } - _, i.metaInitErr = i.getSpec().imageCache.fileCache.ReadOrCreate(key, read, create) - + _, i.metaInitErr = i.getSpec().ImageCache.fcache.ReadOrCreate(key, read, create) }) if i.metaInitErr != nil { - return nil, i.metaInitErr + panic(fmt.Sprintf("metadata init failed: %s", i.metaInitErr)) } - return i.meta.Exif, nil + if i.meta == nil { + return nil + } + + return i.meta.Exif } +// Colors returns a slice of the most dominant colors in an image +// using a simple histogram method. +func (i *imageResource) Colors() ([]images.Color, error) { + var err error + i.dominantColorInit.Do(func() { + var img image.Image + img, err = i.DecodeImage() + if err != nil { + return + } + colors := color_extractor.ExtractColors(img) + for _, c := range colors { + i.dominantColors = append(i.dominantColors, images.ColorGoToColor(c)) + } + }) + return i.dominantColors, nil +} + +func (i *imageResource) targetPath() string { + return i.TargetPath() +} + +// Clone is for internal use. func (i *imageResource) Clone() resource.Resource { gr := i.baseResource.Clone().(baseResource) return &imageResource{ @@ -145,6 +174,15 @@ func (i *imageResource) Clone() resource.Resource { } } +func (i *imageResource) cloneTo(targetPath string) resource.Resource { + gr := i.baseResource.cloneTo(targetPath).(baseResource) + return &imageResource{ + root: i.root, + Image: i.WithSpec(gr), + baseResource: gr, + } +} + func (i *imageResource) cloneWithUpdates(u *transformationUpdate) (baseResource, error) { base, err := i.baseResource.cloneWithUpdates(u) if err != nil { @@ -153,7 +191,7 @@ func (i *imageResource) cloneWithUpdates(u *transformationUpdate) (baseResource, var img *images.Image - if u.isContenChanged() { + if u.isContentChanged() { img = i.WithSpec(base) } else { img = i.Image @@ -166,49 +204,42 @@ func (i *imageResource) cloneWithUpdates(u *transformationUpdate) (baseResource, }, nil } +// Process processes the image with the given spec. +// The spec can contain an optional action, one of "resize", "crop", "fit" or "fill". +// This makes this method a more flexible version that covers all of Resize, Crop, Fit and Fill, +// but it also supports e.g. format conversions without any resize action. +func (i *imageResource) Process(spec string) (images.ImageResource, error) { + return i.processActionSpec("", spec) +} + // Resize resizes the image to the specified width and height using the specified resampling // filter and returns the transformed image. If one of width or height is 0, the image aspect // ratio is preserved. -func (i *imageResource) Resize(spec string) (resource.Image, error) { - conf, err := i.decodeImageConfig("resize", spec) - if err != nil { - return nil, err - } +func (i *imageResource) Resize(spec string) (images.ImageResource, error) { + return i.processActionSpec(images.ActionResize, spec) +} - return i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) { - return i.Proc.ApplyFiltersFromConfig(src, conf) - }) +// Crop the image to the specified dimensions without resizing using the given anchor point. +// Space delimited config, e.g. `200x300 TopLeft`. +func (i *imageResource) Crop(spec string) (images.ImageResource, error) { + return i.processActionSpec(images.ActionCrop, spec) } // Fit scales down the image using the specified resample filter to fit the specified // maximum width and height. -func (i *imageResource) Fit(spec string) (resource.Image, error) { - conf, err := i.decodeImageConfig("fit", spec) - if err != nil { - return nil, err - } - - return i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) { - return i.Proc.ApplyFiltersFromConfig(src, conf) - }) +func (i *imageResource) Fit(spec string) (images.ImageResource, error) { + return i.processActionSpec(images.ActionFit, spec) } // Fill scales the image to the smallest possible size that will cover the specified dimensions, // crops the resized image to the specified dimensions using the given anchor point. -// Space delimited config: 200x300 TopLeft -func (i *imageResource) Fill(spec string) (resource.Image, error) { - conf, err := i.decodeImageConfig("fill", spec) - if err != nil { - return nil, err - } - - return i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) { - return i.Proc.ApplyFiltersFromConfig(src, conf) - }) +// Space delimited config, e.g. `200x300 TopLeft`. +func (i *imageResource) Fill(spec string) (images.ImageResource, error) { + return i.processActionSpec(images.ActionFill, spec) } -func (i *imageResource) Filter(filters ...interface{}) (resource.Image, error) { - conf := i.Proc.GetDefaultImageConfig("filter") +func (i *imageResource) Filter(filters ...any) (images.ImageResource, error) { + var confMain images.ImageConfig var gfilters []gift.Filter @@ -216,14 +247,84 @@ func (i *imageResource) Filter(filters ...interface{}) (resource.Image, error) { gfilters = append(gfilters, images.ToFilters(f)...) } - conf.Key = helpers.HashString(gfilters) - conf.TargetFormat = i.Format + var options []string - return i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) { - return i.Proc.Filter(src, gfilters...) + for _, f := range gfilters { + f = images.UnwrapFilter(f) + if specProvider, ok := f.(images.ImageProcessSpecProvider); ok { + options = append(options, strings.Fields(specProvider.ImageProcessSpec())...) + } + } + + confMain, err := images.DecodeImageConfig(options, i.Proc.Cfg, i.Format) + if err != nil { + return nil, err + } + + confMain.Action = "filter" + confMain.Key = hashing.HashString(gfilters) + + return i.doWithImageConfig(confMain, func(src image.Image) (image.Image, error) { + var filters []gift.Filter + for _, f := range gfilters { + f = images.UnwrapFilter(f) + if specProvider, ok := f.(images.ImageProcessSpecProvider); ok { + options := strings.Fields(specProvider.ImageProcessSpec()) + conf, err := images.DecodeImageConfig(options, i.Proc.Cfg, i.Format) + if err != nil { + return nil, err + } + pFilters, err := i.Proc.FiltersFromConfig(src, conf) + if err != nil { + return nil, err + } + filters = append(filters, pFilters...) + } else if orientationProvider, ok := f.(images.ImageFilterFromOrientationProvider); ok { + tf := orientationProvider.AutoOrient(i.Exif()) + if tf != nil { + filters = append(filters, tf) + } + } else { + filters = append(filters, f) + } + } + return i.Proc.Filter(src, filters...) }) } +func (i *imageResource) processActionSpec(action, spec string) (images.ImageResource, error) { + options := append([]string{action}, strings.Fields(strings.ToLower(spec))...) + return i.processOptions(options) +} + +func (i *imageResource) processOptions(options []string) (images.ImageResource, error) { + conf, err := images.DecodeImageConfig(options, i.Proc.Cfg, i.Format) + if err != nil { + return nil, err + } + + img, err := i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) { + return i.Proc.ApplyFiltersFromConfig(src, conf) + }) + if err != nil { + return nil, err + } + + if conf.Action == images.ActionFill { + if conf.Anchor == images.SmartCropAnchor && img.Width() == 0 || img.Height() == 0 { + // See https://github.com/gohugoio/hugo/issues/7955 + // Smartcrop fails silently in some rare cases. + // Fall back to a center fill. + conf = conf.Reanchor(gift.CenterAnchor) + return i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) { + return i.Proc.ApplyFiltersFromConfig(src, conf) + }) + } + } + + return img, nil +} + // Serialize image processing. The imaging library spins up its own set of Go routines, // so there is not much to gain from adding more load to the mix. That // can even have negative effect in low resource scenarios. @@ -233,24 +334,21 @@ const imageProcWorkers = 1 var imageProcSem = make(chan bool, imageProcWorkers) -func (i *imageResource) doWithImageConfig(conf images.ImageConfig, f func(src image.Image) (image.Image, error)) (resource.Image, error) { - return i.getSpec().imageCache.getOrCreate(i, conf, func() (*imageResource, image.Image, error) { +func (i *imageResource) doWithImageConfig(conf images.ImageConfig, f func(src image.Image) (image.Image, error)) (images.ImageResource, error) { + img, err := i.getSpec().ImageCache.getOrCreate(i, conf, func() (*imageResource, image.Image, error) { imageProcSem <- true defer func() { <-imageProcSem }() - errOp := conf.Action - errPath := i.getSourceFilename() - - src, err := i.decodeSource() + src, err := i.DecodeImage() if err != nil { - return nil, nil, &os.PathError{Op: errOp, Path: errPath, Err: err} + return nil, nil, &os.PathError{Op: conf.Action, Path: i.TargetPath(), Err: err} } converted, err := f(src) if err != nil { - return nil, nil, &os.PathError{Op: errOp, Path: errPath, Err: err} + return nil, nil, &os.PathError{Op: conf.Action, Path: i.TargetPath(), Err: err} } hasAlpha := !images.IsOpaque(converted) @@ -261,7 +359,7 @@ func (i *imageResource) doWithImageConfig(conf images.ImageConfig, f func(src im if shouldFill { bgColor = conf.BgColor if bgColor == nil { - bgColor = i.Proc.Cfg.BgColor + bgColor = i.Proc.Cfg.Config.BgColor } tmp := image.NewRGBA(converted.Bounds()) draw.Draw(tmp, tmp.Bounds(), image.NewUniform(bgColor), image.Point{}, draw.Src) @@ -285,46 +383,44 @@ func (i *imageResource) doWithImageConfig(conf images.ImageConfig, f func(src im } ci := i.clone(converted) - ci.setBasePath(conf) + targetPath := i.relTargetPathFromConfig(conf, i.getSpec().imaging.Cfg.SourceHash) + ci.setTargetPath(targetPath) ci.Format = conf.TargetFormat ci.setMediaType(conf.TargetFormat.MediaType()) return ci, converted, nil }) -} - -func (i *imageResource) decodeImageConfig(action, spec string) (images.ImageConfig, error) { - conf, err := images.DecodeImageConfig(action, spec, i.Proc.Cfg.Cfg) if err != nil { - return conf, err + return nil, err } - - // default to the source format - if conf.TargetFormat == 0 { - conf.TargetFormat = i.Format - } - - if conf.Quality <= 0 && conf.TargetFormat.RequiresDefaultQuality() { - // We need a quality setting for all JPEGs - conf.Quality = i.Proc.Cfg.Cfg.Quality - } - - if conf.BgColor == nil && conf.TargetFormat != i.Format { - if i.Format.SupportsTransparency() && !conf.TargetFormat.SupportsTransparency() { - conf.BgColor = i.Proc.Cfg.BgColor - conf.BgColorStr = i.Proc.Cfg.Cfg.BgColor - } - } - - return conf, nil + return img, nil } -func (i *imageResource) decodeSource() (image.Image, error) { +type giphy struct { + image.Image + gif *gif.GIF +} + +func (g *giphy) GIF() *gif.GIF { + return g.gif +} + +// DecodeImage decodes the image source into an Image. +// This for internal use only. +func (i *imageResource) DecodeImage() (image.Image, error) { f, err := i.ReadSeekCloser() if err != nil { - return nil, _errors.Wrap(err, "failed to open image for decode") + return nil, fmt.Errorf("failed to open image for decode: %w", err) } defer f.Close() + + if i.Format == images.GIF { + g, err := gif.DecodeAll(f) + if err != nil { + return nil, fmt.Errorf("failed to decode gif: %w", err) + } + return &giphy{gif: g, Image: g.Image[0]}, nil + } img, _, err := image.Decode(f) return img, err } @@ -346,59 +442,39 @@ func (i *imageResource) clone(img image.Image) *imageResource { } } -func (i *imageResource) setBasePath(conf images.ImageConfig) { - i.getResourcePaths().relTargetDirFile = i.relTargetPathFromConfig(conf) -} - func (i *imageResource) getImageMetaCacheTargetPath() string { - const imageMetaVersionNumber = 1 // Increment to invalidate the meta cache + // Increment to invalidate the meta cache + // Last increment: v0.130.0 when change to the new imagemeta library for Exif. + const imageMetaVersionNumber = 2 - cfg := i.getSpec().imaging.Cfg.Cfg - df := i.getResourcePaths().relTargetDirFile - if fi := i.getFileInfo(); fi != nil { - df.dir = filepath.Dir(fi.Meta().Path()) - } - p1, _ := helpers.FileAndExt(df.file) - h, _ := i.hash() - idStr := helpers.HashString(h, i.size(), imageMetaVersionNumber, cfg) - return path.Join(df.dir, fmt.Sprintf("%s_%s.json", p1, idStr)) + cfgHash := i.getSpec().imaging.Cfg.SourceHash + df := i.getResourcePaths() + p1, _ := paths.FileAndExt(df.File) + h := i.hash() + idStr := hashing.HashStringHex(h, i.size(), imageMetaVersionNumber, cfgHash) + df.File = fmt.Sprintf("%s_%s.json", p1, idStr) + return df.TargetPath() } -func (i *imageResource) relTargetPathFromConfig(conf images.ImageConfig) dirFile { - p1, p2 := helpers.FileAndExt(i.getResourcePaths().relTargetDirFile.file) +func (i *imageResource) relTargetPathFromConfig(conf images.ImageConfig, imagingConfigSourceHash string) internal.ResourcePaths { + p1, p2 := paths.FileAndExt(i.getResourcePaths().File) if conf.TargetFormat != i.Format { p2 = conf.TargetFormat.DefaultExtension() } - h, _ := i.hash() - idStr := fmt.Sprintf("_hu%s_%d", h, i.size()) + // Do not change. + const imageHashPrefix = "_hu_" - // Do not change for no good reason. - const md5Threshold = 100 - - key := conf.GetKey(i.Format) - - // It is useful to have the key in clear text, but when nesting transforms, it - // can easily be too long to read, and maybe even too long - // for the different OSes to handle. - if len(p1)+len(idStr)+len(p2) > md5Threshold { - key = helpers.MD5String(p1 + key + p2) - huIdx := strings.Index(p1, "_hu") - if huIdx != -1 { - p1 = p1[:huIdx] - } else { - // This started out as a very long file name. Making it even longer - // could melt ice in the Arctic. - p1 = "" - } - } else if strings.Contains(p1, idStr) { - // On scaling an already scaled image, we get the file info from the original. - // Repeating the same info in the filename makes it stuttery for no good reason. - idStr = "" + huIdx := strings.LastIndex(p1, imageHashPrefix) + incomingID := "" + if huIdx > -1 { + incomingID = p1[huIdx+len(imageHashPrefix):] + p1 = p1[:huIdx] } - return dirFile{ - dir: i.getResourcePaths().relTargetDirFile.dir, - file: fmt.Sprintf("%s%s_%s%s", p1, idStr, key, p2), - } + hash := hashing.HashStringHex(incomingID, i.hash(), conf.Key, imagingConfigSourceHash) + rp := i.getResourcePaths() + rp.File = fmt.Sprintf("%s%s%s%s", p1, imageHashPrefix, hash, p2) + + return rp } diff --git a/resources/image_cache.go b/resources/image_cache.go index f57d73ede..1fc722609 100644 --- a/resources/image_cache.go +++ b/resources/image_cache.go @@ -16,158 +16,110 @@ package resources import ( "image" "io" - "path/filepath" - "strings" - "sync" + "github.com/gohugoio/hugo/common/hugio" "github.com/gohugoio/hugo/resources/images" + "github.com/gohugoio/hugo/cache/dynacache" "github.com/gohugoio/hugo/cache/filecache" "github.com/gohugoio/hugo/helpers" ) -type imageCache struct { +// ImageCache is a cache for image resources. The backing caches are shared between all sites. +type ImageCache struct { pathSpec *helpers.PathSpec - fileCache *filecache.Cache - - mu sync.RWMutex - store map[string]*resourceAdapter + fcache *filecache.Cache + mcache *dynacache.Partition[string, *resourceAdapter] } -func (c *imageCache) isInCache(key string) bool { - c.mu.RLock() - _, found := c.store[c.normalizeKey(key)] - c.mu.RUnlock() - return found -} - -func (c *imageCache) deleteByPrefix(prefix string) { - c.mu.Lock() - defer c.mu.Unlock() - prefix = c.normalizeKey(prefix) - for k := range c.store { - if strings.HasPrefix(k, prefix) { - delete(c.store, k) - } - } -} - -func (c *imageCache) normalizeKey(key string) string { - // It is a path with Unix style slashes and it always starts with a leading slash. - key = filepath.ToSlash(key) - if !strings.HasPrefix(key, "/") { - key = "/" + key - } - - return key -} - -func (c *imageCache) clear() { - c.mu.Lock() - defer c.mu.Unlock() - c.store = make(map[string]*resourceAdapter) -} - -func (c *imageCache) getOrCreate( +func (c *ImageCache) getOrCreate( parent *imageResource, conf images.ImageConfig, - createImage func() (*imageResource, image.Image, error)) (*resourceAdapter, error) { - relTarget := parent.relTargetPathFromConfig(conf) - memKey := parent.relTargetPathForRel(relTarget.path(), false, false, false) + createImage func() (*imageResource, image.Image, error), +) (*resourceAdapter, error) { + relTarget := parent.relTargetPathFromConfig(conf, parent.getSpec().imaging.Cfg.SourceHash) + relTargetPath := relTarget.TargetPath() + memKey := relTargetPath - // For the file cache we want to generate and store it once if possible. - fileKeyPath := relTarget - if fi := parent.root.getFileInfo(); fi != nil { - fileKeyPath.dir = filepath.ToSlash(filepath.Dir(fi.Meta().Path())) + // For multihost sites, we duplicate language versions of the same resource, + // so we need to include the language in the key. + // Note that we don't need to include the language in the file cache key, + // as the hash will take care of any different content. + if c.pathSpec.Cfg.IsMultihost() { + memKey = c.pathSpec.Lang() + memKey } - fileKey := fileKeyPath.path() + memKey = dynacache.CleanKey(memKey) - // First check the in-memory store, then the disk. - c.mu.RLock() - cachedImage, found := c.store[memKey] - c.mu.RUnlock() + v, err := c.mcache.GetOrCreate(memKey, func(key string) (*resourceAdapter, error) { + var img *imageResource - if found { - return cachedImage, nil - } + // These funcs are protected by a named lock. + // read clones the parent to its new name and copies + // the content to the destinations. + read := func(info filecache.ItemInfo, r io.ReadSeeker) error { + img = parent.clone(nil) + targetPath := img.getResourcePaths() + targetPath.File = relTarget.File + img.setTargetPath(targetPath) + img.setOpenSource(func() (hugio.ReadSeekCloser, error) { + return c.fcache.Fs.Open(info.Name) + }) + img.setSourceFilenameIsHash(true) + img.setMediaType(conf.TargetFormat.MediaType()) - var img *imageResource + if err := img.InitConfig(r); err != nil { + return err + } - // These funcs are protected by a named lock. - // read clones the parent to its new name and copies - // the content to the destinations. - read := func(info filecache.ItemInfo, r io.ReadSeeker) error { - img = parent.clone(nil) - rp := img.getResourcePaths() - rp.relTargetDirFile.file = relTarget.file - img.setSourceFilename(info.Name) - - if err := img.InitConfig(r); err != nil { - return err - } - - r.Seek(0, 0) - - w, err := img.openDestinationsForWriting() - if err != nil { - return err - } - - if w == nil { - // Nothing to write. return nil } - defer w.Close() - _, err = io.Copy(w, r) + // create creates the image and encodes it to the cache (w). + create := func(info filecache.ItemInfo, w io.WriteCloser) (err error) { + defer w.Close() - return err - } - - // create creates the image and encodes it to the cache (w). - create := func(info filecache.ItemInfo, w io.WriteCloser) (err error) { - defer w.Close() - - var conv image.Image - img, conv, err = createImage() - if err != nil { - return + var conv image.Image + img, conv, err = createImage() + if err != nil { + return + } + targetPath := img.getResourcePaths() + targetPath.File = relTarget.File + img.setTargetPath(targetPath) + img.setOpenSource(func() (hugio.ReadSeekCloser, error) { + return c.fcache.Fs.Open(info.Name) + }) + return img.EncodeTo(conf, conv, w) } - rp := img.getResourcePaths() - rp.relTargetDirFile.file = relTarget.file - img.setSourceFilename(info.Name) - return img.EncodeTo(conf, conv, w) - } + // Now look in the file cache. - // Now look in the file cache. + // The definition of this counter is not that we have processed that amount + // (e.g. resized etc.), it can be fetched from file cache, + // but the count of processed image variations for this site. + c.pathSpec.ProcessingStats.Incr(&c.pathSpec.ProcessingStats.ProcessedImages) - // The definition of this counter is not that we have processed that amount - // (e.g. resized etc.), it can be fetched from file cache, - // but the count of processed image variations for this site. - c.pathSpec.ProcessingStats.Incr(&c.pathSpec.ProcessingStats.ProcessedImages) + _, err := c.fcache.ReadOrCreate(relTargetPath, read, create) + if err != nil { + return nil, err + } - _, err := c.fileCache.ReadOrCreate(fileKey, read, create) - if err != nil { - return nil, err - } + imgAdapter := newResourceAdapter(parent.getSpec(), true, img) - // The file is now stored in this cache. - img.setSourceFs(c.fileCache.Fs) + return imgAdapter, nil + }) - c.mu.Lock() - if cachedImage, found = c.store[memKey]; found { - c.mu.Unlock() - return cachedImage, nil - } - - imgAdapter := newResourceAdapter(parent.getSpec(), true, img) - c.store[memKey] = imgAdapter - c.mu.Unlock() - - return imgAdapter, nil + return v, err } -func newImageCache(fileCache *filecache.Cache, ps *helpers.PathSpec) *imageCache { - return &imageCache{fileCache: fileCache, pathSpec: ps, store: make(map[string]*resourceAdapter)} +func newImageCache(fileCache *filecache.Cache, memCache *dynacache.Cache, ps *helpers.PathSpec) *ImageCache { + return &ImageCache{ + fcache: fileCache, + mcache: dynacache.GetOrCreatePartition[string, *resourceAdapter]( + memCache, + "/imgs", + dynacache.OptionsPartition{ClearWhen: dynacache.ClearOnChange, Weight: 70}, + ), + pathSpec: ps, + } } diff --git a/resources/image_extended_test.go b/resources/image_extended_test.go new file mode 100644 index 000000000..5865ce75b --- /dev/null +++ b/resources/image_extended_test.go @@ -0,0 +1,46 @@ +// Copyright 2024 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. + +//go:build extended + +package resources_test + +import ( + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/htesting/hqt" + "github.com/gohugoio/hugo/media" +) + +func TestImageResizeWebP(t *testing.T) { + c := qt.New(t) + + _, image := fetchImage(c, "sunrise.webp") + + c.Assert(image.MediaType(), qt.Equals, media.Builtin.WEBPType) + c.Assert(image.RelPermalink(), qt.Equals, "/a/sunrise.webp") + c.Assert(image.ResourceType(), qt.Equals, "image") + exif := image.Exif() + c.Assert(exif, qt.Not(qt.IsNil)) + c.Assert(exif.Tags["Copyright"], qt.Equals, "Bjørn Erik Pedersen") + c.Assert(exif.Lat, hqt.IsSameFloat64, 36.59744166666667) + c.Assert(exif.Long, hqt.IsSameFloat64, -4.50846) + c.Assert(exif.Date.IsZero(), qt.Equals, false) + + resized, err := image.Resize("123x") + c.Assert(err, qt.IsNil) + c.Assert(image.MediaType(), qt.Equals, media.Builtin.WEBPType) + c.Assert(resized.RelPermalink(), qt.Equals, "/a/sunrise_hu_a1deb893888915d9.webp") + c.Assert(resized.Width(), qt.Equals, 123) +} diff --git a/resources/image_test.go b/resources/image_test.go index c5564a5cb..ee5de8bec 100644 --- a/resources/image_test.go +++ b/resources/image_test.go @@ -11,32 +11,27 @@ // See the License for the specific language governing permissions and // limitations under the License. -package resources +package resources_test import ( + "context" "fmt" - "image" - "io/ioutil" - "math/big" + "io/fs" "math/rand" "os" - "path" - "path/filepath" - "runtime" "strconv" "sync" "testing" "time" + "github.com/bep/imagemeta" + + "github.com/gohugoio/hugo/common/paths" + "github.com/spf13/afero" - "github.com/disintegration/gift" - - "github.com/gohugoio/hugo/helpers" - "github.com/gohugoio/hugo/media" "github.com/gohugoio/hugo/resources/images" - "github.com/gohugoio/hugo/resources/resource" "github.com/google/go-cmp/cmp" "github.com/gohugoio/hugo/htesting/hqt" @@ -45,19 +40,29 @@ import ( ) var eq = qt.CmpEquals( - cmp.Comparer(func(p1, p2 *resourceAdapter) bool { - return p1.resourceAdapterInner == p2.resourceAdapterInner - }), cmp.Comparer(func(p1, p2 os.FileInfo) bool { return p1.Name() == p2.Name() && p1.Size() == p2.Size() && p1.IsDir() == p2.IsDir() }), - cmp.Comparer(func(p1, p2 *genericResource) bool { return p1 == p2 }), + cmp.Comparer(func(d1, d2 fs.DirEntry) bool { + p1, err1 := d1.Info() + p2, err2 := d2.Info() + if err1 != nil || err2 != nil { + return false + } + return p1.Name() == p2.Name() && p1.Size() == p2.Size() && p1.IsDir() == p2.IsDir() + }), + // cmp.Comparer(func(p1, p2 *genericResource) bool { return p1 == p2 }), cmp.Comparer(func(m1, m2 media.Type) bool { - return m1.Type() == m2.Type() + return m1.Type == m2.Type }), cmp.Comparer( - func(v1, v2 *big.Rat) bool { - return v1.RatString() == v2.RatString() + func(v1, v2 imagemeta.Rat[uint32]) bool { + return v1.String() == v2.String() + }, + ), + cmp.Comparer( + func(v1, v2 imagemeta.Rat[int32]) bool { + return v1.String() == v2.String() }, ), cmp.Comparer(func(v1, v2 time.Time) bool { @@ -68,15 +73,21 @@ var eq = qt.CmpEquals( func TestImageTransformBasic(t *testing.T) { c := qt.New(t) - image := fetchSunset(c) + _, image := fetchSunset(c) - fileCache := image.(specProvider).getSpec().FileCaches.ImageCache().Fs + assertWidthHeight := func(img images.ImageResource, w, h int) { + assertWidthHeight(c, img, w, h) + } - assertWidthHeight := func(img resource.Image, w, h int) { - c.Helper() - c.Assert(img, qt.Not(qt.IsNil)) - c.Assert(img.Width(), qt.Equals, w) - c.Assert(img.Height(), qt.Equals, h) + gotColors, err := image.Colors() + c.Assert(err, qt.IsNil) + expectedColors := images.HexStringsToColors("#2d2f33", "#a49e93", "#d39e59", "#a76936", "#737a84", "#7c838b") + c.Assert(len(gotColors), qt.Equals, len(expectedColors)) + for i := range gotColors { + c1, c2 := gotColors[i], expectedColors[i] + c.Assert(c1.ColorHex(), qt.Equals, c2.ColorHex()) + c.Assert(c1.ColorGo(), qt.DeepEquals, c2.ColorGo()) + c.Assert(c1.Luminance(), qt.Equals, c2.Luminance()) } c.Assert(image.RelPermalink(), qt.Equals, "/a/sunset.jpg") @@ -86,66 +97,115 @@ func TestImageTransformBasic(t *testing.T) { resized, err := image.Resize("300x200") c.Assert(err, qt.IsNil) c.Assert(image != resized, qt.Equals, true) - c.Assert(image, qt.Not(eq), resized) assertWidthHeight(resized, 300, 200) assertWidthHeight(image, 900, 562) resized0x, err := image.Resize("x200") c.Assert(err, qt.IsNil) assertWidthHeight(resized0x, 320, 200) - assertFileCache(c, fileCache, path.Base(resized0x.RelPermalink()), 320, 200) resizedx0, err := image.Resize("200x") c.Assert(err, qt.IsNil) assertWidthHeight(resizedx0, 200, 125) - assertFileCache(c, fileCache, path.Base(resizedx0.RelPermalink()), 200, 125) resizedAndRotated, err := image.Resize("x200 r90") c.Assert(err, qt.IsNil) assertWidthHeight(resizedAndRotated, 125, 200) assertWidthHeight(resized, 300, 200) - c.Assert(resized.RelPermalink(), qt.Equals, "/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x200_resize_q68_linear.jpg") + c.Assert(resized.RelPermalink(), qt.Equals, "/a/sunset_hu_d2115125d9324a79.jpg") fitted, err := resized.Fit("50x50") c.Assert(err, qt.IsNil) - c.Assert(fitted.RelPermalink(), qt.Equals, "/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_625708021e2bb281c9f1002f88e4753f.jpg") + c.Assert(fitted.RelPermalink(), qt.Equals, "/a/sunset_hu_c2c98e06123b048e.jpg") assertWidthHeight(fitted, 50, 33) // Check the MD5 key threshold fittedAgain, _ := fitted.Fit("10x20") fittedAgain, err = fittedAgain.Fit("10x20") c.Assert(err, qt.IsNil) - c.Assert(fittedAgain.RelPermalink(), qt.Equals, "/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_3f65ba24dc2b7fba0f56d7f104519157.jpg") + c.Assert(fittedAgain.RelPermalink(), qt.Equals, "/a/sunset_hu_dc9e89c10109de72.jpg") assertWidthHeight(fittedAgain, 10, 7) filled, err := image.Fill("200x100 bottomLeft") c.Assert(err, qt.IsNil) - c.Assert(filled.RelPermalink(), qt.Equals, "/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x100_fill_q68_linear_bottomleft.jpg") + c.Assert(filled.RelPermalink(), qt.Equals, "/a/sunset_hu_b9f6d350738928fe.jpg") assertWidthHeight(filled, 200, 100) smart, err := image.Fill("200x100 smart") c.Assert(err, qt.IsNil) - c.Assert(smart.RelPermalink(), qt.Equals, fmt.Sprintf("/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x100_fill_q68_linear_smart%d.jpg", 1)) + c.Assert(smart.RelPermalink(), qt.Equals, "/a/sunset_hu_6fd390e7b0d26f0b.jpg") assertWidthHeight(smart, 200, 100) // Check cache filledAgain, err := image.Fill("200x100 bottomLeft") c.Assert(err, qt.IsNil) - c.Assert(filled, eq, filledAgain) + c.Assert(filled, qt.Equals, filledAgain) + + cropped, err := image.Crop("300x300 topRight") + c.Assert(err, qt.IsNil) + c.Assert(cropped.RelPermalink(), qt.Equals, "/a/sunset_hu_3df036e11f4ddd43.jpg") + assertWidthHeight(cropped, 300, 300) + + smartcropped, err := image.Crop("200x200 smart") + c.Assert(err, qt.IsNil) + c.Assert(smartcropped.RelPermalink(), qt.Equals, "/a/sunset_hu_12e2d26de89b464b.jpg") + assertWidthHeight(smartcropped, 200, 200) + + // Check cache + croppedAgain, err := image.Crop("300x300 topRight") + c.Assert(err, qt.IsNil) + c.Assert(cropped, qt.Equals, croppedAgain) +} + +func TestImageProcess(t *testing.T) { + c := qt.New(t) + _, img := fetchSunset(c) + resized, err := img.Process("resiZe 300x200") + c.Assert(err, qt.IsNil) + assertWidthHeight(c, resized, 300, 200) + rotated, err := resized.Process("R90") + c.Assert(err, qt.IsNil) + assertWidthHeight(c, rotated, 200, 300) + converted, err := img.Process("png") + c.Assert(err, qt.IsNil) + c.Assert(converted.MediaType().Type, qt.Equals, "image/png") + + checkProcessVsMethod := func(action, spec string) { + var expect images.ImageResource + var err error + switch action { + case images.ActionCrop: + expect, err = img.Crop(spec) + case images.ActionFill: + expect, err = img.Fill(spec) + case images.ActionFit: + expect, err = img.Fit(spec) + case images.ActionResize: + expect, err = img.Resize(spec) + } + c.Assert(err, qt.IsNil) + got, err := img.Process(spec + " " + action) + c.Assert(err, qt.IsNil) + assertWidthHeight(c, got, expect.Width(), expect.Height()) + c.Assert(got.MediaType(), qt.Equals, expect.MediaType()) + } + + checkProcessVsMethod(images.ActionCrop, "300x200 topleFt") + checkProcessVsMethod(images.ActionFill, "300x200 topleft") + checkProcessVsMethod(images.ActionFit, "300x200 png") + checkProcessVsMethod(images.ActionResize, "300x R90") } func TestImageTransformFormat(t *testing.T) { c := qt.New(t) - image := fetchSunset(c) + _, image := fetchSunset(c) - fileCache := image.(specProvider).getSpec().FileCaches.ImageCache().Fs - - assertExtWidthHeight := func(img resource.Image, ext string, w, h int) { + assertExtWidthHeight := func(img images.ImageResource, ext string, w, h int) { c.Helper() c.Assert(img, qt.Not(qt.IsNil)) - c.Assert(helpers.Ext(img.RelPermalink()), qt.Equals, ext) + c.Assert(paths.Ext(img.RelPermalink()), qt.Equals, ext) c.Assert(img.Width(), qt.Equals, w) c.Assert(img.Height(), qt.Equals, h) } @@ -156,53 +216,19 @@ func TestImageTransformFormat(t *testing.T) { imagePng, err := image.Resize("450x png") c.Assert(err, qt.IsNil) - c.Assert(imagePng.RelPermalink(), qt.Equals, "/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_450x0_resize_linear.png") + c.Assert(imagePng.RelPermalink(), qt.Equals, "/a/sunset_hu_e8b9444dcf2e75ef.png") c.Assert(imagePng.ResourceType(), qt.Equals, "image") assertExtWidthHeight(imagePng, ".png", 450, 281) c.Assert(imagePng.Name(), qt.Equals, "sunset.jpg") c.Assert(imagePng.MediaType().String(), qt.Equals, "image/png") - assertFileCache(c, fileCache, path.Base(imagePng.RelPermalink()), 450, 281) - imageGif, err := image.Resize("225x gif") c.Assert(err, qt.IsNil) - c.Assert(imageGif.RelPermalink(), qt.Equals, "/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_225x0_resize_linear.gif") + c.Assert(imageGif.RelPermalink(), qt.Equals, "/a/sunset_hu_f80842d4c3789345.gif") c.Assert(imageGif.ResourceType(), qt.Equals, "image") assertExtWidthHeight(imageGif, ".gif", 225, 141) c.Assert(imageGif.Name(), qt.Equals, "sunset.jpg") c.Assert(imageGif.MediaType().String(), qt.Equals, "image/gif") - - assertFileCache(c, fileCache, path.Base(imageGif.RelPermalink()), 225, 141) -} - -// https://github.com/gohugoio/hugo/issues/4261 -func TestImageTransformLongFilename(t *testing.T) { - c := qt.New(t) - - image := fetchImage(c, "1234567890qwertyuiopasdfghjklzxcvbnm5to6eeeeee7via8eleph.jpg") - c.Assert(image, qt.Not(qt.IsNil)) - - resized, err := image.Resize("200x") - c.Assert(err, qt.IsNil) - c.Assert(resized, qt.Not(qt.IsNil)) - c.Assert(resized.Width(), qt.Equals, 200) - c.Assert(resized.RelPermalink(), qt.Equals, "/a/_hu59e56ffff1bc1d8d122b1403d34e039f_90587_65b757a6e14debeae720fe8831f0a9bc.jpg") - resized, err = resized.Resize("100x") - c.Assert(err, qt.IsNil) - c.Assert(resized, qt.Not(qt.IsNil)) - c.Assert(resized.Width(), qt.Equals, 100) - c.Assert(resized.RelPermalink(), qt.Equals, "/a/_hu59e56ffff1bc1d8d122b1403d34e039f_90587_c876768085288f41211f768147ba2647.jpg") -} - -// Issue 6137 -func TestImageTransformUppercaseExt(t *testing.T) { - c := qt.New(t) - image := fetchImage(c, "sunrise.JPG") - - resized, err := image.Resize("200x") - c.Assert(err, qt.IsNil) - c.Assert(resized, qt.Not(qt.IsNil)) - c.Assert(resized.Width(), qt.Equals, 200) } // https://github.com/gohugoio/hugo/issues/5730 @@ -220,36 +246,96 @@ func TestImagePermalinkPublishOrder(t *testing.T) { os.Remove(workDir) }() - check1 := func(img resource.Image) { - resizedLink := "/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_100x50_resize_q75_box.jpg" + check1 := func(img images.ImageResource) { + resizedLink := "/a/sunset_hu_3910bca82e28c9d6.jpg" c.Assert(img.RelPermalink(), qt.Equals, resizedLink) assertImageFile(c, spec.PublishFs, resizedLink, 100, 50) } - check2 := func(img resource.Image) { + check2 := func(img images.ImageResource) { c.Assert(img.RelPermalink(), qt.Equals, "/a/sunset.jpg") assertImageFile(c, spec.PublishFs, "a/sunset.jpg", 900, 562) } - orignal := fetchImageForSpec(spec, c, "sunset.jpg") - c.Assert(orignal, qt.Not(qt.IsNil)) + original := fetchImageForSpec(spec, c, "sunset.jpg") + c.Assert(original, qt.Not(qt.IsNil)) if checkOriginalFirst { - check2(orignal) + check2(original) } - resized, err := orignal.Resize("100x50") + resized, err := original.Resize("100x50") c.Assert(err, qt.IsNil) - check1(resized.(resource.Image)) + check1(resized) if !checkOriginalFirst { - check2(orignal) + check2(original) } }) } } +func TestImageBugs(t *testing.T) { + c := qt.New(t) + + // Issue #4261 + c.Run("Transform long filename", func(c *qt.C) { + _, image := fetchImage(c, "1234567890qwertyuiopasdfghjklzxcvbnm5to6eeeeee7via8eleph.jpg") + c.Assert(image, qt.Not(qt.IsNil)) + + resized, err := image.Resize("200x") + c.Assert(err, qt.IsNil) + c.Assert(resized, qt.Not(qt.IsNil)) + c.Assert(resized.Width(), qt.Equals, 200) + c.Assert(resized.RelPermalink(), qt.Equals, "/a/1234567890qwertyuiopasdfghjklzxcvbnm5to6eeeeee7via8eleph_hu_951d3980b18c52a9.jpg") + resized, err = resized.Resize("100x") + c.Assert(err, qt.IsNil) + c.Assert(resized, qt.Not(qt.IsNil)) + c.Assert(resized.Width(), qt.Equals, 100) + c.Assert(resized.RelPermalink(), qt.Equals, "/a/1234567890qwertyuiopasdfghjklzxcvbnm5to6eeeeee7via8eleph_hu_1daa203572ecd6ec.jpg") + }) + + // Issue #6137 + c.Run("Transform upper case extension", func(c *qt.C) { + _, image := fetchImage(c, "sunrise.JPG") + + resized, err := image.Resize("200x") + c.Assert(err, qt.IsNil) + c.Assert(resized, qt.Not(qt.IsNil)) + c.Assert(resized.Width(), qt.Equals, 200) + }) + + // Issue #7955 + c.Run("Fill with smartcrop", func(c *qt.C) { + _, sunset := fetchImage(c, "sunset.jpg") + + for _, test := range []struct { + originalDimensions string + targetWH int + }{ + {"408x403", 400}, + {"425x403", 400}, + {"459x429", 400}, + {"476x442", 400}, + {"544x403", 400}, + {"476x468", 400}, + {"578x585", 550}, + {"578x598", 550}, + } { + c.Run(test.originalDimensions, func(c *qt.C) { + image, err := sunset.Resize(test.originalDimensions) + c.Assert(err, qt.IsNil) + resized, err := image.Fill(fmt.Sprintf("%dx%d smart", test.targetWH, test.targetWH)) + c.Assert(err, qt.IsNil) + c.Assert(resized, qt.Not(qt.IsNil)) + c.Assert(resized.Width(), qt.Equals, test.targetWH) + c.Assert(resized.Height(), qt.Equals, test.targetWH) + }) + } + }) +} + func TestImageTransformConcurrent(t *testing.T) { var wg sync.WaitGroup @@ -262,13 +348,13 @@ func TestImageTransformConcurrent(t *testing.T) { image := fetchImageForSpec(spec, c, "sunset.jpg") - for i := 0; i < 4; i++ { + for i := range 4 { wg.Add(1) go func(id int) { defer wg.Done() - for j := 0; j < 5; j++ { + for j := range 5 { img := image - for k := 0; k < 2; k++ { + for k := range 2 { r1, err := img.Resize(fmt.Sprintf("%dx", id-k)) if err != nil { t.Error(err) @@ -292,75 +378,23 @@ func TestImageTransformConcurrent(t *testing.T) { wg.Wait() } -func TestImageWithMetadata(t *testing.T) { - c := qt.New(t) - - image := fetchSunset(c) - - meta := []map[string]interface{}{ - { - "title": "My Sunset", - "name": "Sunset #:counter", - "src": "*.jpg", - }, - } - - c.Assert(AssignMetadata(meta, image), qt.IsNil) - c.Assert(image.Name(), qt.Equals, "Sunset #1") - - resized, err := image.Resize("200x") - c.Assert(err, qt.IsNil) - c.Assert(resized.Name(), qt.Equals, "Sunset #1") -} - func TestImageResize8BitPNG(t *testing.T) { c := qt.New(t) - image := fetchImage(c, "gohugoio.png") + _, image := fetchImage(c, "gohugoio.png") - c.Assert(image.MediaType().Type(), qt.Equals, "image/png") + c.Assert(image.MediaType().Type, qt.Equals, "image/png") c.Assert(image.RelPermalink(), qt.Equals, "/a/gohugoio.png") c.Assert(image.ResourceType(), qt.Equals, "image") + c.Assert(image.Exif(), qt.IsNotNil) resized, err := image.Resize("800x") c.Assert(err, qt.IsNil) - c.Assert(resized.MediaType().Type(), qt.Equals, "image/png") - c.Assert(resized.RelPermalink(), qt.Equals, "/a/gohugoio_hu0e1b9e4a4be4d6f86c7b37b9ccce3fbc_73886_800x0_resize_linear_2.png") + c.Assert(resized.MediaType().Type, qt.Equals, "image/png") + c.Assert(resized.RelPermalink(), qt.Equals, "/a/gohugoio_hu_fe2b762e9cac406c.png") c.Assert(resized.Width(), qt.Equals, 800) } -func TestImageResizeInSubPath(t *testing.T) { - c := qt.New(t) - - image := fetchImage(c, "sub/gohugoio2.png") - - c.Assert(image.MediaType(), eq, media.PNGType) - c.Assert(image.RelPermalink(), qt.Equals, "/a/sub/gohugoio2.png") - c.Assert(image.ResourceType(), qt.Equals, "image") - - resized, err := image.Resize("101x101") - c.Assert(err, qt.IsNil) - c.Assert(resized.MediaType().Type(), qt.Equals, "image/png") - c.Assert(resized.RelPermalink(), qt.Equals, "/a/sub/gohugoio2_hu0e1b9e4a4be4d6f86c7b37b9ccce3fbc_73886_101x101_resize_linear_2.png") - c.Assert(resized.Width(), qt.Equals, 101) - - publishedImageFilename := filepath.Clean(resized.RelPermalink()) - - spec := image.(specProvider).getSpec() - - assertImageFile(c, spec.BaseFs.PublishFs, publishedImageFilename, 101, 101) - c.Assert(spec.BaseFs.PublishFs.Remove(publishedImageFilename), qt.IsNil) - - // Cleare mem cache to simulate reading from the file cache. - spec.imageCache.clear() - - resizedAgain, err := image.Resize("101x101") - c.Assert(err, qt.IsNil) - c.Assert(resizedAgain.RelPermalink(), qt.Equals, "/a/sub/gohugoio2_hu0e1b9e4a4be4d6f86c7b37b9ccce3fbc_73886_101x101_resize_linear_2.png") - c.Assert(resizedAgain.Width(), qt.Equals, 101) - assertImageFile(c, image.(specProvider).getSpec().BaseFs.PublishFs, publishedImageFilename, 101, 101) -} - func TestSVGImage(t *testing.T) { c := qt.New(t) spec := newTestResourceSpec(specDescriptor{c: c}) @@ -374,7 +408,7 @@ func TestSVGImageContent(t *testing.T) { svg := fetchResourceForSpec(spec, c, "circle.svg") c.Assert(svg, qt.Not(qt.IsNil)) - content, err := svg.Content() + content, err := svg.Content(context.Background()) c.Assert(err, qt.IsNil) c.Assert(content, hqt.IsSameType, "") c.Assert(content.(string), qt.Contains, ``) @@ -384,11 +418,10 @@ func TestImageExif(t *testing.T) { c := qt.New(t) fs := afero.NewMemMapFs() spec := newTestResourceSpec(specDescriptor{fs: fs, c: c}) - image := fetchResourceForSpec(spec, c, "sunset.jpg").(resource.Image) + image := fetchResourceForSpec(spec, c, "sunset.jpg").(images.ImageResource) - getAndCheckExif := func(c *qt.C, image resource.Image) { - x, err := image.Exif() - c.Assert(err, qt.IsNil) + getAndCheckExif := func(c *qt.C, image images.ImageResource) { + x := image.Exif() c.Assert(x, qt.Not(qt.IsNil)) c.Assert(x.Date.Format("2006-01-02"), qt.Equals, "2017-10-27") @@ -403,34 +436,49 @@ func TestImageExif(t *testing.T) { c.Assert(ok, qt.Equals, true) c.Assert(lensModel, qt.Equals, "smc PENTAX-DA* 16-50mm F2.8 ED AL [IF] SDM") resized, _ := image.Resize("300x200") - x2, _ := resized.Exif() + x2 := resized.Exif() + c.Assert(x2, eq, x) } getAndCheckExif(c, image) - image = fetchResourceForSpec(spec, c, "sunset.jpg").(resource.Image) + image = fetchResourceForSpec(spec, c, "sunset.jpg").(images.ImageResource) // This will read from file cache. getAndCheckExif(c, image) +} +func TestImageColorsLuminance(t *testing.T) { + c := qt.New(t) + + _, image := fetchSunset(c) + c.Assert(image, qt.Not(qt.IsNil)) + colors, err := image.Colors() + c.Assert(err, qt.IsNil) + c.Assert(len(colors), qt.Equals, 6) + var prevLuminance float64 + for i, color := range colors { + luminance := color.Luminance() + c.Assert(err, qt.IsNil) + c.Assert(luminance > 0, qt.IsTrue) + c.Assert(luminance, qt.Not(qt.Equals), prevLuminance, qt.Commentf("i=%d", i)) + prevLuminance = luminance + } } func BenchmarkImageExif(b *testing.B) { - - getImages := func(c *qt.C, b *testing.B, fs afero.Fs) []resource.Image { + getImages := func(c *qt.C, b *testing.B, fs afero.Fs) []images.ImageResource { spec := newTestResourceSpec(specDescriptor{fs: fs, c: c}) - images := make([]resource.Image, b.N) + imgs := make([]images.ImageResource, b.N) for i := 0; i < b.N; i++ { - images[i] = fetchResourceForSpec(spec, c, "sunset.jpg", strconv.Itoa(i)).(resource.Image) + imgs[i] = fetchResourceForSpec(spec, c, "sunset.jpg", strconv.Itoa(i)).(images.ImageResource) } - return images + return imgs } - getAndCheckExif := func(c *qt.C, image resource.Image) { - x, err := image.Exif() - c.Assert(err, qt.IsNil) + getAndCheckExif := func(c *qt.C, image images.ImageResource) { + x := image.Exif() c.Assert(x, qt.Not(qt.IsNil)) c.Assert(x.Long, qt.Equals, float64(-4.50846)) - } b.Run("Cold cache", func(b *testing.B) { @@ -442,7 +490,6 @@ func BenchmarkImageExif(b *testing.B) { for i := 0; i < b.N; i++ { getAndCheckExif(c, images[i]) } - }) b.Run("Cold cache, 10", func(b *testing.B) { @@ -452,11 +499,10 @@ func BenchmarkImageExif(b *testing.B) { b.StartTimer() for i := 0; i < b.N; i++ { - for j := 0; j < 10; j++ { + for range 10 { getAndCheckExif(c, images[i]) } } - }) b.Run("Warm cache", func(b *testing.B) { @@ -474,216 +520,12 @@ func BenchmarkImageExif(b *testing.B) { for i := 0; i < b.N; i++ { getAndCheckExif(c, images[i]) } - }) - -} - -// usesFMA indicates whether "fused multiply and add" (FMA) instruction is -// used. The command "grep FMADD go/test/codegen/floats.go" can help keep -// the FMA-using architecture list updated. -var usesFMA = runtime.GOARCH == "s390x" || - runtime.GOARCH == "ppc64" || - runtime.GOARCH == "ppc64le" || - runtime.GOARCH == "arm64" - -// goldenEqual compares two NRGBA images. It is used in golden tests only. -// A small tolerance is allowed on architectures using "fused multiply and add" -// (FMA) instruction to accommodate for floating-point rounding differences -// with control golden images that were generated on amd64 architecture. -// See https://golang.org/ref/spec#Floating_point_operators -// and https://github.com/gohugoio/hugo/issues/6387 for more information. -// -// Borrowed from https://github.com/disintegration/gift/blob/a999ff8d5226e5ab14b64a94fca07c4ac3f357cf/gift_test.go#L598-L625 -// Copyright (c) 2014-2019 Grigory Dryapak -// Licensed under the MIT License. -func goldenEqual(img1, img2 *image.NRGBA) bool { - maxDiff := 0 - if usesFMA { - maxDiff = 1 - } - if !img1.Rect.Eq(img2.Rect) { - return false - } - if len(img1.Pix) != len(img2.Pix) { - return false - } - for i := 0; i < len(img1.Pix); i++ { - diff := int(img1.Pix[i]) - int(img2.Pix[i]) - if diff < 0 { - diff = -diff - } - if diff > maxDiff { - return false - } - } - return true -} - -func TestImageOperationsGolden(t *testing.T) { - c := qt.New(t) - c.Parallel() - - devMode := false - - testImages := []string{"sunset.jpg", "gohugoio8.png", "gohugoio24.png"} - - spec, workDir := newTestResourceOsFs(c) - defer func() { - if !devMode { - os.Remove(workDir) - } - }() - - if devMode { - fmt.Println(workDir) - } - - // Test PNGs with alpha channel. - for _, img := range []string{"gopher-hero8.png", "gradient-circle.png"} { - orig := fetchImageForSpec(spec, c, img) - for _, resizeSpec := range []string{"200x #e3e615", "200x jpg #e3e615"} { - resized, err := orig.Resize(resizeSpec) - c.Assert(err, qt.IsNil) - rel := resized.RelPermalink() - c.Log("resize", rel) - c.Assert(rel, qt.Not(qt.Equals), "") - } - } - - for _, img := range testImages { - - orig := fetchImageForSpec(spec, c, img) - for _, resizeSpec := range []string{"200x100", "600x", "200x r90 q50 Box"} { - resized, err := orig.Resize(resizeSpec) - c.Assert(err, qt.IsNil) - rel := resized.RelPermalink() - c.Log("resize", rel) - c.Assert(rel, qt.Not(qt.Equals), "") - } - - for _, fillSpec := range []string{"300x200 Gaussian Smart", "100x100 Center", "300x100 TopLeft NearestNeighbor", "400x200 BottomLeft"} { - resized, err := orig.Fill(fillSpec) - c.Assert(err, qt.IsNil) - rel := resized.RelPermalink() - c.Log("fill", rel) - c.Assert(rel, qt.Not(qt.Equals), "") - } - - for _, fitSpec := range []string{"300x200 Linear"} { - resized, err := orig.Fit(fitSpec) - c.Assert(err, qt.IsNil) - rel := resized.RelPermalink() - c.Log("fit", rel) - c.Assert(rel, qt.Not(qt.Equals), "") - } - - f := &images.Filters{} - - filters := []gift.Filter{ - f.Grayscale(), - f.GaussianBlur(6), - f.Saturation(50), - f.Sepia(100), - f.Brightness(30), - f.ColorBalance(10, -10, -10), - f.Colorize(240, 50, 100), - f.Gamma(1.5), - f.UnsharpMask(1, 1, 0), - f.Sigmoid(0.5, 7), - f.Pixelate(5), - f.Invert(), - f.Hue(22), - f.Contrast(32.5), - } - - resized, err := orig.Fill("400x200 center") - - for _, filter := range filters { - resized, err := resized.Filter(filter) - c.Assert(err, qt.IsNil) - rel := resized.RelPermalink() - c.Logf("filter: %v %s", filter, rel) - c.Assert(rel, qt.Not(qt.Equals), "") - } - - resized, err = resized.Filter(filters[0:4]) - c.Assert(err, qt.IsNil) - rel := resized.RelPermalink() - c.Log("filter all", rel) - c.Assert(rel, qt.Not(qt.Equals), "") - } - - if devMode { - return - } - - dir1 := filepath.Join(workDir, "resources/_gen/images") - dir2 := filepath.FromSlash("testdata/golden") - - // The two dirs above should now be the same. - dirinfos1, err := ioutil.ReadDir(dir1) - c.Assert(err, qt.IsNil) - dirinfos2, err := ioutil.ReadDir(dir2) - c.Assert(err, qt.IsNil) - c.Assert(len(dirinfos1), qt.Equals, len(dirinfos2)) - - for i, fi1 := range dirinfos1 { - fi2 := dirinfos2[i] - c.Assert(fi1.Name(), qt.Equals, fi2.Name()) - - f1, err := os.Open(filepath.Join(dir1, fi1.Name())) - c.Assert(err, qt.IsNil) - f2, err := os.Open(filepath.Join(dir2, fi2.Name())) - c.Assert(err, qt.IsNil) - - img1, _, err := image.Decode(f1) - c.Assert(err, qt.IsNil) - img2, _, err := image.Decode(f2) - c.Assert(err, qt.IsNil) - - nrgba1 := image.NewNRGBA(img1.Bounds()) - gift.New().Draw(nrgba1, img1) - nrgba2 := image.NewNRGBA(img2.Bounds()) - gift.New().Draw(nrgba2, img2) - - if !goldenEqual(nrgba1, nrgba2) { - switch fi1.Name() { - case "gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_4c320010919da2d8b63ed24818b4d8e1.png", - "gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_9d4c2220235b3c2d9fa6506be571560f.png", - "gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_c74bb417b961e09cf1aac2130b7b9b85.png", - "gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_300x200_fill_gaussian_smart1_2.png": - c.Log("expectedly differs from golden due to dithering:", fi1.Name()) - default: - t.Errorf("resulting image differs from golden: %s", fi1.Name()) - } - } - - if !usesFMA { - c.Assert(fi1, eq, fi2) - - _, err = f1.Seek(0, 0) - c.Assert(err, qt.IsNil) - _, err = f2.Seek(0, 0) - c.Assert(err, qt.IsNil) - - hash1, err := helpers.MD5FromReader(f1) - c.Assert(err, qt.IsNil) - hash2, err := helpers.MD5FromReader(f2) - c.Assert(err, qt.IsNil) - - c.Assert(hash1, qt.Equals, hash2) - } - - f1.Close() - f2.Close() - } - } func BenchmarkResizeParallel(b *testing.B) { c := qt.New(b) - img := fetchSunset(c) + _, img := fetchSunset(c) b.RunParallel(func(pb *testing.PB) { for pb.Next() { @@ -699,3 +541,10 @@ func BenchmarkResizeParallel(b *testing.B) { } }) } + +func assertWidthHeight(c *qt.C, img images.ImageResource, w, h int) { + c.Helper() + c.Assert(img, qt.Not(qt.IsNil)) + c.Assert(img.Width(), qt.Equals, w) + c.Assert(img.Height(), qt.Equals, h) +} diff --git a/resources/images/auto_orient.go b/resources/images/auto_orient.go new file mode 100644 index 000000000..a4a61976d --- /dev/null +++ b/resources/images/auto_orient.go @@ -0,0 +1,62 @@ +// Copyright 2024 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 images + +import ( + "image" + "image/draw" + + "github.com/disintegration/gift" + "github.com/gohugoio/hugo/resources/images/exif" + "github.com/spf13/cast" +) + +var _ gift.Filter = (*autoOrientFilter)(nil) + +var transformationFilters = map[int]gift.Filter{ + 2: gift.FlipHorizontal(), + 3: gift.Rotate180(), + 4: gift.FlipVertical(), + 5: gift.Transpose(), + 6: gift.Rotate270(), + 7: gift.Transverse(), + 8: gift.Rotate90(), +} + +type autoOrientFilter struct{} + +type ImageFilterFromOrientationProvider interface { + AutoOrient(exifInfo *exif.ExifInfo) gift.Filter +} + +func (f autoOrientFilter) Draw(dst draw.Image, src image.Image, options *gift.Options) { + panic("not supported") +} + +func (f autoOrientFilter) Bounds(srcBounds image.Rectangle) image.Rectangle { + panic("not supported") +} + +func (f autoOrientFilter) AutoOrient(exifInfo *exif.ExifInfo) gift.Filter { + if exifInfo != nil { + if v, ok := exifInfo.Tags["Orientation"]; ok { + orientation := cast.ToInt(v) + if filter, ok := transformationFilters[orientation]; ok { + return filter + } + } + } + + return nil +} diff --git a/resources/images/color.go b/resources/images/color.go index b17173e26..c7f3b9eb6 100644 --- a/resources/images/color.go +++ b/resources/images/color.go @@ -15,22 +15,85 @@ package images import ( "encoding/hex" + "fmt" + "hash/fnv" "image/color" + "math" "strings" - "github.com/pkg/errors" + "github.com/gohugoio/hugo/common/hstrings" + "slices" ) +type colorGoProvider interface { + ColorGo() color.Color +} + +type Color struct { + // The color. + color color.Color + + // The color prefixed with a #. + hex string + + // The relative luminance of the color. + luminance float64 +} + +// Luminance as defined by w3.org. +// See https://www.w3.org/TR/WCAG21/#dfn-relative-luminance +func (c Color) Luminance() float64 { + return c.luminance +} + +// ColorGo returns the color as a color.Color. +// For internal use only. +func (c Color) ColorGo() color.Color { + return c.color +} + +// ColorHex returns the color as a hex string prefixed with a #. +func (c Color) ColorHex() string { + return c.hex +} + +// String returns the color as a hex string prefixed with a #. +func (c Color) String() string { + return c.hex +} + +// For hashstructure. This struct is used in template func options +// that needs to be able to hash a Color. +// For internal use only. +func (c Color) Hash() (uint64, error) { + h := fnv.New64a() + h.Write([]byte(c.hex)) + return h.Sum64(), nil +} + +func (c *Color) init() error { + c.hex = ColorGoToHexString(c.color) + r, g, b, _ := c.color.RGBA() + c.luminance = 0.2126*c.toSRGB(uint8(r)) + 0.7152*c.toSRGB(uint8(g)) + 0.0722*c.toSRGB(uint8(b)) + return nil +} + +func (c Color) toSRGB(i uint8) float64 { + v := float64(i) / 255 + if v <= 0.04045 { + return v / 12.92 + } else { + return math.Pow((v+0.055)/1.055, 2.4) + } +} + // AddColorToPalette adds c as the first color in p if not already there. // Note that it does no additional checks, so callers must make sure // that the palette is valid for the relevant format. func AddColorToPalette(c color.Color, p color.Palette) color.Palette { var found bool - for _, cc := range p { - if c == cc { - found = true - break - } + if slices.Contains(p, c) { + found = true } if !found { @@ -46,16 +109,69 @@ func ReplaceColorInPalette(c color.Color, p color.Palette) { p[p.Index(c)] = c } -func hexStringToColor(s string) (color.Color, error) { +// ColorGoToHexString converts a color.Color to a hex string. +func ColorGoToHexString(c color.Color) string { + r, g, b, a := c.RGBA() + rgba := color.RGBA{uint8(r), uint8(g), uint8(b), uint8(a)} + if rgba.A == 0xff { + return fmt.Sprintf("#%.2x%.2x%.2x", rgba.R, rgba.G, rgba.B) + } + return fmt.Sprintf("#%.2x%.2x%.2x%.2x", rgba.R, rgba.G, rgba.B, rgba.A) +} + +// ColorGoToColor converts a color.Color to a Color. +func ColorGoToColor(c color.Color) Color { + cc := Color{color: c} + if err := cc.init(); err != nil { + panic(err) + } + return cc +} + +func hexStringToColor(s string) Color { + c, err := hexStringToColorGo(s) + if err != nil { + panic(err) + } + return ColorGoToColor(c) +} + +// HexStringsToColors converts a slice of hex strings to a slice of Colors. +func HexStringsToColors(s ...string) []Color { + var colors []Color + for _, v := range s { + colors = append(colors, hexStringToColor(v)) + } + return colors +} + +func toColorGo(v any) (color.Color, bool, error) { + switch vv := v.(type) { + case colorGoProvider: + return vv.ColorGo(), true, nil + default: + s, ok := hstrings.ToString(v) + if !ok { + return nil, false, nil + } + c, err := hexStringToColorGo(s) + if err != nil { + return nil, false, err + } + return c, true, nil + } +} + +func hexStringToColorGo(s string) (color.Color, error) { s = strings.TrimPrefix(s, "#") - if len(s) != 3 && len(s) != 6 { - return nil, errors.Errorf("invalid color code: %q", s) + if len(s) != 3 && len(s) != 4 && len(s) != 6 && len(s) != 8 { + return nil, fmt.Errorf("invalid color code: %q", s) } s = strings.ToLower(s) - if len(s) == 3 { + if len(s) == 3 || len(s) == 4 { var v string for _, r := range s { v += string(r) + string(r) @@ -73,7 +189,9 @@ func hexStringToColor(s string) (color.Color, error) { } // Set Alfa to white. - s += "ff" + if len(s) == 6 { + s += "ff" + } b, err := hex.DecodeString(s) if err != nil { @@ -81,5 +199,4 @@ func hexStringToColor(s string) (color.Color, error) { } return color.RGBA{b[0], b[1], b[2], b[3]}, nil - } diff --git a/resources/images/color_test.go b/resources/images/color_test.go index 3ef9f76cc..3a75c068e 100644 --- a/resources/images/color_test.go +++ b/resources/images/color_test.go @@ -18,6 +18,7 @@ import ( "testing" qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/htesting/hqt" ) func TestHexStringToColor(t *testing.T) { @@ -25,7 +26,7 @@ func TestHexStringToColor(t *testing.T) { for _, test := range []struct { arg string - expect interface{} + expect any }{ {"f", false}, {"#f", false}, @@ -46,7 +47,7 @@ func TestHexStringToColor(t *testing.T) { c.Run(test.arg, func(c *qt.C) { c.Parallel() - result, err := hexStringToColor(test.arg) + result, err := hexStringToColorGo(test.arg) if b, ok := test.expect.(bool); ok && !b { c.Assert(err, qt.Not(qt.IsNil)) @@ -60,6 +61,35 @@ func TestHexStringToColor(t *testing.T) { } } +func TestColorToHexString(t *testing.T) { + c := qt.New(t) + + for _, test := range []struct { + arg color.Color + expect string + }{ + {color.White, "#ffffff"}, + {color.Black, "#000000"}, + {color.RGBA{R: 0x42, G: 0x87, B: 0xf5, A: 0xff}, "#4287f5"}, + + // 50% opacity. + // Note that the .Colors (dominant colors) received from the Image resource + // will always have an alpha value of 0xff. + {color.RGBA{R: 0x42, G: 0x87, B: 0xf5, A: 0x80}, "#4287f580"}, + } { + + test := test + c.Run(test.expect, func(c *qt.C) { + c.Parallel() + + result := ColorGoToHexString(test.arg) + + c.Assert(result, qt.Equals, test.expect) + }) + + } +} + func TestAddColorToPalette(t *testing.T) { c := qt.New(t) @@ -67,24 +97,31 @@ func TestAddColorToPalette(t *testing.T) { c.Assert(AddColorToPalette(color.White, palette), qt.HasLen, 2) - blue1, _ := hexStringToColor("34c3eb") - blue2, _ := hexStringToColor("34c3eb") - white, _ := hexStringToColor("fff") + blue1, _ := hexStringToColorGo("34c3eb") + blue2, _ := hexStringToColorGo("34c3eb") + white, _ := hexStringToColorGo("fff") c.Assert(AddColorToPalette(white, palette), qt.HasLen, 2) c.Assert(AddColorToPalette(blue1, palette), qt.HasLen, 3) c.Assert(AddColorToPalette(blue2, palette), qt.HasLen, 3) - } func TestReplaceColorInPalette(t *testing.T) { c := qt.New(t) palette := color.Palette{color.White, color.Black} - offWhite, _ := hexStringToColor("fcfcfc") + offWhite, _ := hexStringToColorGo("fcfcfc") ReplaceColorInPalette(offWhite, palette) c.Assert(palette, qt.HasLen, 2) c.Assert(palette[0], qt.Equals, offWhite) } + +func TestColorLuminance(t *testing.T) { + c := qt.New(t) + c.Assert(hexStringToColor("#000000").Luminance(), hqt.IsSameFloat64, 0.0) + c.Assert(hexStringToColor("#768a9a").Luminance(), hqt.IsSameFloat64, 0.24361603589088263) + c.Assert(hexStringToColor("#d5bc9f").Luminance(), hqt.IsSameFloat64, 0.5261577672685374) + c.Assert(hexStringToColor("#ffffff").Luminance(), hqt.IsSameFloat64, 1.0) +} diff --git a/resources/images/config.go b/resources/images/config.go index 7b2ade29f..6fcd2e334 100644 --- a/resources/images/config.go +++ b/resources/images/config.go @@ -20,32 +20,61 @@ import ( "strconv" "strings" - "github.com/disintegration/gift" - + "github.com/gohugoio/hugo/common/hashing" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/media" "github.com/mitchellh/mapstructure" + + "github.com/bep/gowebp/libwebp/webpoptions" + + "github.com/disintegration/gift" ) const ( - defaultJPEGQuality = 75 - defaultResampleFilter = "box" - defaultBgColor = "ffffff" + ActionResize = "resize" + ActionCrop = "crop" + ActionFit = "fit" + ActionFill = "fill" ) +var Actions = map[string]bool{ + ActionResize: true, + ActionCrop: true, + ActionFit: true, + ActionFill: true, +} + var ( imageFormats = map[string]Format{ ".jpg": JPEG, ".jpeg": JPEG, + ".jpe": JPEG, + ".jif": JPEG, + ".jfif": JPEG, ".png": PNG, ".tif": TIFF, ".tiff": TIFF, ".bmp": BMP, ".gif": GIF, + ".webp": WEBP, + } + + imageFormatsBySubType = map[string]Format{ + media.Builtin.JPEGType.SubType: JPEG, + media.Builtin.PNGType.SubType: PNG, + media.Builtin.TIFFType.SubType: TIFF, + media.Builtin.BMPType.SubType: BMP, + media.Builtin.GIFType.SubType: GIF, + media.Builtin.WEBPType.SubType: WEBP, } // Add or increment if changes to an image format's processing requires // re-generation. imageFormatsVersions = map[Format]int{ - PNG: 2, // Floyd Steinberg dithering + PNG: 0, + WEBP: 0, + GIF: 0, } // Increment to mark all processed images as stale. Only use when absolutely needed. @@ -63,10 +92,19 @@ var anchorPositions = map[string]gift.Anchor{ strings.ToLower("BottomLeft"): gift.BottomLeftAnchor, strings.ToLower("Bottom"): gift.BottomAnchor, strings.ToLower("BottomRight"): gift.BottomRightAnchor, + smartCropIdentifier: SmartCropAnchor, +} + +// These encoding hints are currently only relevant for Webp. +var hints = map[string]webpoptions.EncodingPreset{ + "picture": webpoptions.EncodingPresetPicture, + "photo": webpoptions.EncodingPresetPhoto, + "drawing": webpoptions.EncodingPresetDrawing, + "icon": webpoptions.EncodingPresetIcon, + "text": webpoptions.EncodingPresetText, } var imageFilters = map[string]gift.Resampling{ - strings.ToLower("NearestNeighbor"): gift.NearestNeighborResampling, strings.ToLower("Box"): gift.BoxResampling, strings.ToLower("Linear"): gift.LinearResampling, @@ -89,87 +127,117 @@ func ImageFormatFromExt(ext string) (Format, bool) { return f, found } -func DecodeConfig(m map[string]interface{}) (ImagingConfig, error) { - var i Imaging - var ic ImagingConfig - if err := mapstructure.WeakDecode(m, &i); err != nil { - return ic, err - } - - if i.Quality == 0 { - i.Quality = defaultJPEGQuality - } else if i.Quality < 0 || i.Quality > 100 { - return ic, errors.New("JPEG quality must be a number between 1 and 100") - } - - if i.BgColor != "" { - i.BgColor = strings.TrimPrefix(i.BgColor, "#") - } else { - i.BgColor = defaultBgColor - } - var err error - ic.BgColor, err = hexStringToColor(i.BgColor) - if err != nil { - return ic, err - } - - if i.Anchor == "" || strings.EqualFold(i.Anchor, smartCropIdentifier) { - i.Anchor = smartCropIdentifier - } else { - i.Anchor = strings.ToLower(i.Anchor) - if _, found := anchorPositions[i.Anchor]; !found { - return ic, errors.New("invalid anchor value in imaging config") - } - } - - if i.ResampleFilter == "" { - i.ResampleFilter = defaultResampleFilter - } else { - filter := strings.ToLower(i.ResampleFilter) - _, found := imageFilters[filter] - if !found { - return ic, fmt.Errorf("%q is not a valid resample filter", filter) - } - i.ResampleFilter = filter - } - - if strings.TrimSpace(i.Exif.IncludeFields) == "" && strings.TrimSpace(i.Exif.ExcludeFields) == "" { - // Don't change this for no good reason. Please don't. - i.Exif.ExcludeFields = "GPS|Exif|Exposure[M|P|B]|Contrast|Resolution|Sharp|JPEG|Metering|Sensing|Saturation|ColorSpace|Flash|WhiteBalance" - } - - ic.Cfg = i - - return ic, nil +func ImageFormatFromMediaSubType(sub string) (Format, bool) { + f, found := imageFormatsBySubType[sub] + return f, found } -func DecodeImageConfig(action, config string, defaults Imaging) (ImageConfig, error) { +const ( + defaultJPEGQuality = 75 + defaultResampleFilter = "box" + defaultBgColor = "#ffffff" + defaultHint = "photo" +) + +var ( + defaultImaging = map[string]any{ + "resampleFilter": defaultResampleFilter, + "bgColor": defaultBgColor, + "hint": defaultHint, + "quality": defaultJPEGQuality, + } + + defaultImageConfig *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal] +) + +func init() { + var err error + defaultImageConfig, err = DecodeConfig(defaultImaging) + if err != nil { + panic(err) + } +} + +func DecodeConfig(in map[string]any) (*config.ConfigNamespace[ImagingConfig, ImagingConfigInternal], error) { + if in == nil { + in = make(map[string]any) + } + + buildConfig := func(in any) (ImagingConfigInternal, any, error) { + m, err := maps.ToStringMapE(in) + if err != nil { + return ImagingConfigInternal{}, nil, err + } + // Merge in the defaults. + maps.MergeShallow(m, defaultImaging) + + var i ImagingConfigInternal + if err := mapstructure.Decode(m, &i.Imaging); err != nil { + return i, nil, err + } + + if err := i.Imaging.init(); err != nil { + return i, nil, err + } + + i.BgColor, err = hexStringToColorGo(i.Imaging.BgColor) + if err != nil { + return i, nil, err + } + + if i.Imaging.Anchor != "" { + anchor, found := anchorPositions[i.Imaging.Anchor] + if !found { + return i, nil, fmt.Errorf("invalid anchor value %q in imaging config", i.Anchor) + } + i.Anchor = anchor + } + + filter, found := imageFilters[i.Imaging.ResampleFilter] + if !found { + return i, nil, fmt.Errorf("%q is not a valid resample filter", filter) + } + + i.ResampleFilter = filter + + return i, nil, nil + } + + ns, err := config.DecodeNamespace[ImagingConfig](in, buildConfig) + if err != nil { + return nil, fmt.Errorf("failed to decode media types: %w", err) + } + return ns, nil +} + +func DecodeImageConfig(options []string, defaults *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal], sourceFormat Format) (ImageConfig, error) { var ( - c ImageConfig + c ImageConfig = GetDefaultImageConfig(defaults) err error ) - c.Action = action - - if config == "" { - return c, errors.New("image config cannot be empty") + // Make to lower case, trim space and remove any empty strings. + n := 0 + for _, s := range options { + s = strings.TrimSpace(s) + if s != "" { + options[n] = strings.ToLower(s) + n++ + } } + options = options[:n] - parts := strings.Fields(config) - for _, part := range parts { - part = strings.ToLower(part) - - if part == smartCropIdentifier { - c.AnchorStr = smartCropIdentifier + for _, part := range options { + if _, ok := Actions[part]; ok { + c.Action = part } else if pos, ok := anchorPositions[part]; ok { c.Anchor = pos - c.AnchorStr = part } else if filter, ok := imageFilters[part]; ok { c.Filter = filter - c.FilterStr = part + } else if hint, ok := hints[part]; ok { + c.Hint = hint } else if part[0] == '#' { - c.BgColorStr = part[1:] - c.BgColor, err = hexStringToColor(c.BgColorStr) + c.BgColor, err = hexStringToColorGo(part[1:]) if err != nil { return c, err } @@ -181,6 +249,7 @@ func DecodeImageConfig(action, config string, defaults Imaging) (ImageConfig, er if c.Quality < 1 || c.Quality > 100 { return c, errors.New("quality ranges from 1 to 100 inclusive") } + c.qualitySetForImage = true } else if part[0] == 'r' { c.Rotate, err = strconv.Atoi(part[1:]) if err != nil { @@ -214,28 +283,69 @@ func DecodeImageConfig(action, config string, defaults Imaging) (ImageConfig, er } } - if c.Width == 0 && c.Height == 0 { - return c, errors.New("must provide Width or Height") - } - - if c.FilterStr == "" { - c.FilterStr = defaults.ResampleFilter - c.Filter = imageFilters[c.FilterStr] - } - - if c.AnchorStr == "" { - c.AnchorStr = defaults.Anchor - if !strings.EqualFold(c.AnchorStr, smartCropIdentifier) { - c.Anchor = anchorPositions[c.AnchorStr] + switch c.Action { + case ActionCrop, ActionFill, ActionFit: + if c.Width == 0 || c.Height == 0 { + return c, errors.New("must provide Width and Height") + } + case ActionResize: + if c.Width == 0 && c.Height == 0 { + return c, errors.New("must provide Width or Height") + } + default: + if c.Width != 0 || c.Height != 0 { + return c, errors.New("width or height are not supported for this action") } } + if c.Action != "" && c.Filter == nil { + c.Filter = defaults.Config.ResampleFilter + } + + if c.Hint == 0 { + c.Hint = webpoptions.EncodingPresetPhoto + } + + if c.Action != "" && c.Anchor == -1 { + c.Anchor = defaults.Config.Anchor + } + + // default to the source format + if c.TargetFormat == 0 { + c.TargetFormat = sourceFormat + } + + if c.Quality <= 0 && c.TargetFormat.RequiresDefaultQuality() { + // We need a quality setting for all JPEGs and WEBPs. + c.Quality = defaults.Config.Imaging.Quality + } + + if c.BgColor == nil && c.TargetFormat != sourceFormat { + if sourceFormat.SupportsTransparency() && !c.TargetFormat.SupportsTransparency() { + c.BgColor = defaults.Config.BgColor + } + } + + if mainImageVersionNumber > 0 { + options = append(options, strconv.Itoa(mainImageVersionNumber)) + } + + if v, ok := imageFormatsVersions[sourceFormat]; ok && v > 0 { + options = append(options, strconv.Itoa(v)) + } + + if smartCropVersionNumber > 0 && c.Anchor == SmartCropAnchor { + options = append(options, strconv.Itoa(smartCropVersionNumber)) + } + + c.Key = hashing.HashStringHex(options) + return c, nil } // ImageConfig holds configuration to create a new image from an existing one, resize etc. type ImageConfig struct { - // This defines the output format of the output image. It defaults to the source format + // This defines the output format of the output image. It defaults to the source format. TargetFormat Format Action string @@ -244,9 +354,10 @@ type ImageConfig struct { Key string // Quality ranges from 1 to 100 inclusive, higher is better. - // This is only relevant for JPEG images. + // This is only relevant for JPEG and WEBP images. // Default is 75. - Quality int + Quality int + qualitySetForImage bool // Whether the above is set for this image. // Rotate rotates an image by the given angle counter-clockwise. // The rotation will be performed first. @@ -257,76 +368,74 @@ type ImageConfig struct { // not support transparency. // When set per image operation, it's used even for formats that does support // transparency. - BgColor color.Color - BgColorStr string + BgColor color.Color + + // Hint about what type of picture this is. Used to optimize encoding + // when target is set to webp. + Hint webpoptions.EncodingPreset Width int Height int - Filter gift.Resampling - FilterStr string + Filter gift.Resampling - Anchor gift.Anchor - AnchorStr string + Anchor gift.Anchor } -func (i ImageConfig) GetKey(format Format) string { - if i.Key != "" { - return i.Action + "_" + i.Key - } - - k := strconv.Itoa(i.Width) + "x" + strconv.Itoa(i.Height) - if i.Action != "" { - k += "_" + i.Action - } - if i.Quality > 0 { - k += "_q" + strconv.Itoa(i.Quality) - } - if i.Rotate != 0 { - k += "_r" + strconv.Itoa(i.Rotate) - } - if i.BgColorStr != "" { - k += "_bg" + i.BgColorStr - } - - anchor := i.AnchorStr - if anchor == smartCropIdentifier { - anchor = anchor + strconv.Itoa(smartCropVersionNumber) - } - - k += "_" + i.FilterStr - - if strings.EqualFold(i.Action, "fill") { - k += "_" + anchor - } - - if v, ok := imageFormatsVersions[format]; ok { - k += "_" + strconv.Itoa(v) - } - - if mainImageVersionNumber > 0 { - k += "_" + strconv.Itoa(mainImageVersionNumber) - } - - return k +func (cfg ImageConfig) Reanchor(a gift.Anchor) ImageConfig { + cfg.Anchor = a + cfg.Key = hashing.HashStringHex(cfg.Key, "reanchor", a) + return cfg } -type ImagingConfig struct { - BgColor color.Color +type ImagingConfigInternal struct { + BgColor color.Color + Hint webpoptions.EncodingPreset + ResampleFilter gift.Resampling + Anchor gift.Anchor - // Config as provided by the user. - Cfg Imaging + Imaging ImagingConfig } -// Imaging contains default image processing configuration. This will be fetched +func (i *ImagingConfigInternal) Compile(externalCfg *ImagingConfig) error { + var err error + i.BgColor, err = hexStringToColorGo(externalCfg.BgColor) + if err != nil { + return err + } + + if externalCfg.Anchor != "" { + anchor, found := anchorPositions[externalCfg.Anchor] + if !found { + return fmt.Errorf("invalid anchor value %q in imaging config", i.Anchor) + } + i.Anchor = anchor + } + + filter, found := imageFilters[externalCfg.ResampleFilter] + if !found { + return fmt.Errorf("%q is not a valid resample filter", filter) + } + i.ResampleFilter = filter + + return nil +} + +// ImagingConfig contains default image processing configuration. This will be fetched // from site (or language) config. -type Imaging struct { +type ImagingConfig struct { // Default image quality setting (1-100). Only used for JPEG images. Quality int - // Resample filter to use in resize operations.. + // Resample filter to use in resize operations. ResampleFilter string + // Hint about what type of image this is. + // Currently only used when encoding to Webp. + // Default is "photo". + // Valid values are "picture", "photo", "drawing", "icon", or "text". + Hint string + // The anchor to use in Fill. Default is "smart", i.e. Smart Crop. Anchor string @@ -336,8 +445,29 @@ type Imaging struct { Exif ExifConfig } -type ExifConfig struct { +func (cfg *ImagingConfig) init() error { + if cfg.Quality < 0 || cfg.Quality > 100 { + return errors.New("image quality must be a number between 1 and 100") + } + cfg.BgColor = strings.ToLower(strings.TrimPrefix(cfg.BgColor, "#")) + cfg.Anchor = strings.ToLower(cfg.Anchor) + cfg.ResampleFilter = strings.ToLower(cfg.ResampleFilter) + cfg.Hint = strings.ToLower(cfg.Hint) + + if cfg.Anchor == "" { + cfg.Anchor = smartCropIdentifier + } + + if strings.TrimSpace(cfg.Exif.IncludeFields) == "" && strings.TrimSpace(cfg.Exif.ExcludeFields) == "" { + // Don't change this for no good reason. Please don't. + cfg.Exif.ExcludeFields = "GPS|Exif|Exposure[M|P|B]|Contrast|Resolution|Sharp|JPEG|Metering|Sensing|Saturation|ColorSpace|Flash|WhiteBalance" + } + + return nil +} + +type ExifConfig struct { // Regexp matching the Exif fields you want from the (massive) set of Exif info // available. As we cache this info to disk, this is for performance and // disk space reasons more than anything. diff --git a/resources/images/config_test.go b/resources/images/config_test.go index f60cce9ef..d3c9827bd 100644 --- a/resources/images/config_test.go +++ b/resources/images/config_test.go @@ -19,11 +19,12 @@ import ( "testing" qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/common/hashing" ) func TestDecodeConfig(t *testing.T) { c := qt.New(t) - m := map[string]interface{}{ + m := map[string]any{ "quality": 42, "resampleFilter": "NearestNeighbor", "anchor": "topLeft", @@ -32,71 +33,82 @@ func TestDecodeConfig(t *testing.T) { imagingConfig, err := DecodeConfig(m) c.Assert(err, qt.IsNil) - imaging := imagingConfig.Cfg - c.Assert(imaging.Quality, qt.Equals, 42) - c.Assert(imaging.ResampleFilter, qt.Equals, "nearestneighbor") - c.Assert(imaging.Anchor, qt.Equals, "topleft") + conf := imagingConfig.Config + c.Assert(conf.Imaging.Quality, qt.Equals, 42) + c.Assert(conf.Imaging.ResampleFilter, qt.Equals, "nearestneighbor") + c.Assert(conf.Imaging.Anchor, qt.Equals, "topleft") - m = map[string]interface{}{} + m = map[string]any{} imagingConfig, err = DecodeConfig(m) c.Assert(err, qt.IsNil) - imaging = imagingConfig.Cfg - c.Assert(imaging.Quality, qt.Equals, defaultJPEGQuality) - c.Assert(imaging.ResampleFilter, qt.Equals, "box") - c.Assert(imaging.Anchor, qt.Equals, "smart") + conf = imagingConfig.Config + c.Assert(conf.Imaging.ResampleFilter, qt.Equals, "box") + c.Assert(conf.Imaging.Anchor, qt.Equals, "smart") - _, err = DecodeConfig(map[string]interface{}{ + _, err = DecodeConfig(map[string]any{ "quality": 123, }) c.Assert(err, qt.Not(qt.IsNil)) - _, err = DecodeConfig(map[string]interface{}{ + _, err = DecodeConfig(map[string]any{ "resampleFilter": "asdf", }) c.Assert(err, qt.Not(qt.IsNil)) - _, err = DecodeConfig(map[string]interface{}{ + _, err = DecodeConfig(map[string]any{ "anchor": "asdf", }) c.Assert(err, qt.Not(qt.IsNil)) - imagingConfig, err = DecodeConfig(map[string]interface{}{ + imagingConfig, err = DecodeConfig(map[string]any{ "anchor": "Smart", }) - imaging = imagingConfig.Cfg + conf = imagingConfig.Config c.Assert(err, qt.IsNil) - c.Assert(imaging.Anchor, qt.Equals, "smart") + c.Assert(conf.Imaging.Anchor, qt.Equals, "smart") - imagingConfig, err = DecodeConfig(map[string]interface{}{ - "exif": map[string]interface{}{ + imagingConfig, err = DecodeConfig(map[string]any{ + "exif": map[string]any{ "disableLatLong": true, }, }) c.Assert(err, qt.IsNil) - imaging = imagingConfig.Cfg - c.Assert(imaging.Exif.DisableLatLong, qt.Equals, true) - c.Assert(imaging.Exif.ExcludeFields, qt.Equals, "GPS|Exif|Exposure[M|P|B]|Contrast|Resolution|Sharp|JPEG|Metering|Sensing|Saturation|ColorSpace|Flash|WhiteBalance") - + conf = imagingConfig.Config + c.Assert(conf.Imaging.Exif.DisableLatLong, qt.Equals, true) + c.Assert(conf.Imaging.Exif.ExcludeFields, qt.Equals, "GPS|Exif|Exposure[M|P|B]|Contrast|Resolution|Sharp|JPEG|Metering|Sensing|Saturation|ColorSpace|Flash|WhiteBalance") } func TestDecodeImageConfig(t *testing.T) { for i, this := range []struct { + action string in string - expect interface{} + expect any }{ - {"300x400", newImageConfig(300, 400, 0, 0, "", "", "")}, - {"300x400 #fff", newImageConfig(300, 400, 0, 0, "", "", "fff")}, - {"100x200 bottomRight", newImageConfig(100, 200, 0, 0, "", "BottomRight", "")}, - {"10x20 topleft Lanczos", newImageConfig(10, 20, 0, 0, "Lanczos", "topleft", "")}, - {"linear left 10x r180", newImageConfig(10, 0, 0, 180, "linear", "left", "")}, - {"x20 riGht Cosine q95", newImageConfig(0, 20, 95, 0, "cosine", "right", "")}, + {"resize", "300x400", newImageConfig("resize", 300, 400, 75, 0, "box", "smart", "")}, + {"resize", "300x400 #fff", newImageConfig("resize", 300, 400, 75, 0, "box", "smart", "fff")}, + {"resize", "100x200 bottomRight", newImageConfig("resize", 100, 200, 75, 0, "box", "BottomRight", "")}, + {"resize", "10x20 topleft Lanczos", newImageConfig("resize", 10, 20, 75, 0, "Lanczos", "topleft", "")}, + {"resize", "linear left 10x r180", newImageConfig("resize", 10, 0, 75, 180, "linear", "left", "")}, + {"resize", "x20 riGht Cosine q95", newImageConfig("resize", 0, 20, 95, 0, "cosine", "right", "")}, + {"crop", "300x400", newImageConfig("crop", 300, 400, 75, 0, "box", "smart", "")}, + {"fill", "300x400", newImageConfig("fill", 300, 400, 75, 0, "box", "smart", "")}, + {"fit", "300x400", newImageConfig("fit", 300, 400, 75, 0, "box", "smart", "")}, - {"", false}, - {"foo", false}, + {"resize", "", false}, + {"resize", "foo", false}, + {"crop", "100x", false}, + {"fill", "100x", false}, + {"fit", "100x", false}, + {"foo", "100x", false}, } { - result, err := DecodeImageConfig("resize", this.in, Imaging{}) + cfg, err := DecodeConfig(nil) + if err != nil { + t.Fatal(err) + } + options := append([]string{this.action}, strings.Fields(this.in)...) + result, err := DecodeImageConfig(options, cfg, PNG) if b, ok := this.expect.(bool); ok && !b { if err == nil { t.Errorf("[%d] parseImageConfig didn't return an expected error", i) @@ -105,28 +117,33 @@ func TestDecodeImageConfig(t *testing.T) { if err != nil { t.Fatalf("[%d] err: %s", i, err) } - if fmt.Sprint(result) != fmt.Sprint(this.expect) { - t.Fatalf("[%d] got\n%v\n but expected\n%v", i, result, this.expect) + expect := this.expect.(ImageConfig) + expect.Key = hashing.HashStringHex(options) + + if fmt.Sprint(result) != fmt.Sprint(expect) { + t.Fatalf("[%d] got\n%v\n but expected\n%v", i, result, expect) } } } } -func newImageConfig(width, height, quality, rotate int, filter, anchor, bgColor string) ImageConfig { - var c ImageConfig - c.Action = "resize" +func newImageConfig(action string, width, height, quality, rotate int, filter, anchor, bgColor string) ImageConfig { + var c ImageConfig = GetDefaultImageConfig(nil) + c.Action = action + c.TargetFormat = PNG + c.Hint = 2 c.Width = width c.Height = height c.Quality = quality + c.qualitySetForImage = quality != 75 c.Rotate = rotate - c.BgColorStr = bgColor - c.BgColor, _ = hexStringToColor(bgColor) + c.BgColor, _ = hexStringToColorGo(bgColor) + c.Anchor = SmartCropAnchor if filter != "" { filter = strings.ToLower(filter) if v, ok := imageFilters[filter]; ok { c.Filter = v - c.FilterStr = filter } } @@ -134,7 +151,6 @@ func newImageConfig(width, height, quality, rotate int, filter, anchor, bgColor anchor = strings.ToLower(anchor) if v, ok := anchorPositions[anchor]; ok { c.Anchor = v - c.AnchorStr = anchor } } diff --git a/resources/images/dither.go b/resources/images/dither.go new file mode 100644 index 000000000..19d7e088d --- /dev/null +++ b/resources/images/dither.go @@ -0,0 +1,71 @@ +// Copyright 2024 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 images + +import ( + "image" + "image/draw" + + "github.com/disintegration/gift" + "github.com/makeworld-the-better-one/dither/v2" +) + +var _ gift.Filter = (*ditherFilter)(nil) + +type ditherFilter struct { + ditherer *dither.Ditherer +} + +var ditherMethodsErrorDiffusion = map[string]dither.ErrorDiffusionMatrix{ + "atkinson": dither.Atkinson, + "burkes": dither.Burkes, + "falsefloydsteinberg": dither.FalseFloydSteinberg, + "floydsteinberg": dither.FloydSteinberg, + "jarvisjudiceninke": dither.JarvisJudiceNinke, + "sierra": dither.Sierra, + "sierra2": dither.Sierra2, + "sierra2_4a": dither.Sierra2_4A, + "sierra3": dither.Sierra3, + "sierralite": dither.SierraLite, + "simple2d": dither.Simple2D, + "stevenpigeon": dither.StevenPigeon, + "stucki": dither.Stucki, + "tworowsierra": dither.TwoRowSierra, +} + +var ditherMethodsOrdered = map[string]dither.OrderedDitherMatrix{ + "clustereddot4x4": dither.ClusteredDot4x4, + "clustereddot6x6": dither.ClusteredDot6x6, + "clustereddot6x6_2": dither.ClusteredDot6x6_2, + "clustereddot6x6_3": dither.ClusteredDot6x6_3, + "clustereddot8x8": dither.ClusteredDot8x8, + "clustereddotdiagonal16x16": dither.ClusteredDotDiagonal16x16, + "clustereddotdiagonal6x6": dither.ClusteredDotDiagonal6x6, + "clustereddotdiagonal8x8": dither.ClusteredDotDiagonal8x8, + "clustereddotdiagonal8x8_2": dither.ClusteredDotDiagonal8x8_2, + "clustereddotdiagonal8x8_3": dither.ClusteredDotDiagonal8x8_3, + "clustereddothorizontalline": dither.ClusteredDotHorizontalLine, + "clustereddotspiral5x5": dither.ClusteredDotSpiral5x5, + "clustereddotverticalline": dither.ClusteredDotVerticalLine, + "horizontal3x5": dither.Horizontal3x5, + "vertical5x3": dither.Vertical5x3, +} + +func (f ditherFilter) Draw(dst draw.Image, src image.Image, options *gift.Options) { + gift.New().Draw(dst, f.ditherer.Dither(src)) +} + +func (f ditherFilter) Bounds(srcBounds image.Rectangle) image.Rectangle { + return image.Rect(0, 0, srcBounds.Dx(), srcBounds.Dy()) +} diff --git a/resources/images/exif/exif.go b/resources/images/exif/exif.go index b5161f770..a7f0e0757 100644 --- a/resources/images/exif/exif.go +++ b/resources/images/exif/exif.go @@ -14,28 +14,30 @@ package exif import ( - "bytes" "fmt" "io" - "math/big" "regexp" + "strconv" "strings" "time" - "unicode" - "unicode/utf8" + "github.com/bep/imagemeta" + "github.com/bep/logg" "github.com/bep/tmc" - - _exif "github.com/rwcarlsen/goexif/exif" - "github.com/rwcarlsen/goexif/tiff" ) -const exifTimeLayout = "2006:01:02 15:04:05" +// ExifInfo holds the decoded Exif data for an Image. +type ExifInfo struct { + // GPS latitude in degrees. + Lat float64 -type Exif struct { - Lat float64 + // GPS longitude in degrees. Long float64 + + // Image creation date/time. Date time.Time + + // A collection of the available Exif tags for this Image. Tags Tags } @@ -44,6 +46,15 @@ type Decoder struct { excludeFieldsrRe *regexp.Regexp noDate bool noLatLong bool + warnl logg.LevelLogger +} + +func (d *Decoder) shouldInclude(s string) bool { + return (d.includeFieldsRe == nil || d.includeFieldsRe.MatchString(s)) +} + +func (d *Decoder) shouldExclude(s string) bool { + return d.excludeFieldsrRe != nil && d.excludeFieldsrRe.MatchString(s) } func IncludeFields(expression string) func(*Decoder) error { @@ -82,6 +93,13 @@ func WithDateDisabled(disabled bool) func(*Decoder) error { } } +func WithWarnLogger(warnl logg.LevelLogger) func(*Decoder) error { + return func(d *Decoder) error { + d.warnl = warnl + return nil + } +} + func compileRegexp(expression string) (*regexp.Regexp, error) { expression = strings.TrimSpace(expression) if expression == "" { @@ -93,7 +111,6 @@ func compileRegexp(expression string) (*regexp.Regexp, error) { } return regexp.Compile(expression) - } func NewDecoder(options ...func(*Decoder) error) (*Decoder, error) { @@ -107,156 +124,233 @@ func NewDecoder(options ...func(*Decoder) error) (*Decoder, error) { return d, nil } -func (d *Decoder) Decode(r io.Reader) (ex *Exif, err error) { +var ( + isTimeTag = func(s string) bool { + return strings.Contains(s, "Time") + } + isGPSTag = func(s string) bool { + return strings.HasPrefix(s, "GPS") + } +) + +// Filename is only used for logging. +func (d *Decoder) Decode(filename string, format imagemeta.ImageFormat, r io.Reader) (ex *ExifInfo, err error) { defer func() { if r := recover(); r != nil { - err = fmt.Errorf("Exif failed: %v", r) + err = fmt.Errorf("exif failed: %v", r) } }() - var x *_exif.Exif - x, err = _exif.Decode(r) - if err != nil { - if err.Error() == "EOF" { - - // Found no Exif - return nil, nil - } - return + var tagInfos imagemeta.Tags + handleTag := func(ti imagemeta.TagInfo) error { + tagInfos.Add(ti) + return nil } + shouldInclude := func(ti imagemeta.TagInfo) bool { + if ti.Source == imagemeta.EXIF { + if !d.noDate { + // We need the time tags to calculate the date. + if isTimeTag(ti.Tag) { + return true + } + } + if !d.noLatLong { + // We need to GPS tags to calculate the lat/long. + if isGPSTag(ti.Tag) { + return true + } + } + + if !strings.HasPrefix(ti.Namespace, "IFD0") { + // Drop thumbnail tags. + return false + } + } + + if d.shouldExclude(ti.Tag) { + return false + } + + return d.shouldInclude(ti.Tag) + } + + var warnf func(string, ...any) + if d.warnl != nil { + // There should be very little warnings (fingers crossed!), + // but this will typically be unrecognized formats. + // To be able to possibly get rid of these warnings, + // we need to know what images are causing them. + warnf = func(format string, args ...any) { + format = fmt.Sprintf("%q: %s: ", filename, format) + d.warnl.Logf(format, args...) + } + } + + err = imagemeta.Decode( + imagemeta.Options{ + R: r.(io.ReadSeeker), + ImageFormat: format, + ShouldHandleTag: shouldInclude, + HandleTag: handleTag, + Sources: imagemeta.EXIF, // For now. TODO(bep) + Warnf: warnf, + }, + ) + var tm time.Time var lat, long float64 if !d.noDate { - tm, _ = x.DateTime() + tm, _ = tagInfos.GetDateTime() } if !d.noLatLong { - lat, long, _ = x.LatLong() + lat, long, _ = tagInfos.GetLatLong() } - walker := &exifWalker{x: x, vals: make(map[string]interface{}), includeMatcher: d.includeFieldsRe, excludeMatcher: d.excludeFieldsrRe} - if err = x.Walk(walker); err != nil { - return + tags := make(map[string]any) + for k, v := range tagInfos.All() { + if d.shouldExclude(k) { + continue + } + if !d.shouldInclude(k) { + continue + } + tags[k] = v.Value } - ex = &Exif{Lat: lat, Long: long, Date: tm, Tags: walker.vals} + ex = &ExifInfo{Lat: lat, Long: long, Date: tm, Tags: tags} return } -func decodeTag(x *_exif.Exif, f _exif.FieldName, t *tiff.Tag) (interface{}, error) { - switch t.Format() { - case tiff.StringVal, tiff.UndefVal: - s := nullString(t.Val) - if strings.Contains(string(f), "DateTime") { - if d, err := tryParseDate(x, s); err == nil { - return d, nil - } - } - return s, nil - case tiff.OtherVal: - return "unknown", nil - } - - var rv []interface{} - - for i := 0; i < int(t.Count); i++ { - switch t.Format() { - case tiff.RatVal: - n, d, _ := t.Rat2(i) - rat := big.NewRat(n, d) - if n == 1 { - rv = append(rv, rat) - } else { - f, _ := rat.Float64() - rv = append(rv, f) - } - - case tiff.FloatVal: - v, _ := t.Float(i) - rv = append(rv, v) - case tiff.IntVal: - v, _ := t.Int(i) - rv = append(rv, v) - } - } - - if t.Count == 1 { - if len(rv) == 1 { - return rv[0], nil - } - } - - return rv, nil - -} - -// Code borrowed from exif.DateTime and adjusted. -func tryParseDate(x *_exif.Exif, s string) (time.Time, error) { - dateStr := strings.TrimRight(s, "\x00") - // TODO(bep): look for timezone offset, GPS time, etc. - timeZone := time.Local - if tz, _ := x.TimeZone(); tz != nil { - timeZone = tz - } - return time.ParseInLocation(exifTimeLayout, dateStr, timeZone) - -} - -type exifWalker struct { - x *_exif.Exif - vals map[string]interface{} - includeMatcher *regexp.Regexp - excludeMatcher *regexp.Regexp -} - -func (e *exifWalker) Walk(f _exif.FieldName, tag *tiff.Tag) error { - name := string(f) - if e.excludeMatcher != nil && e.excludeMatcher.MatchString(name) { - return nil - } - if e.includeMatcher != nil && !e.includeMatcher.MatchString(name) { - return nil - } - val, err := decodeTag(e.x, f, tag) - if err != nil { - return err - } - e.vals[name] = val - return nil -} - -func nullString(in []byte) string { - var rv bytes.Buffer - for _, b := range in { - if unicode.IsPrint(rune(b)) { - rv.WriteByte(b) - } - } - rvs := rv.String() - if utf8.ValidString(rvs) { - return rvs - } - - return "" -} - var tcodec *tmc.Codec func init() { + newIntadapter := func(target any) tmc.Adapter { + var bitSize int + var isSigned bool + + switch target.(type) { + case int: + bitSize = 0 + isSigned = true + case int8: + bitSize = 8 + isSigned = true + case int16: + bitSize = 16 + isSigned = true + case int32: + bitSize = 32 + isSigned = true + case int64: + bitSize = 64 + isSigned = true + case uint: + bitSize = 0 + case uint8: + bitSize = 8 + case uint16: + bitSize = 16 + case uint32: + bitSize = 32 + case uint64: + bitSize = 64 + } + + intFromString := func(s string) (any, error) { + if bitSize == 0 { + return strconv.Atoi(s) + } + + var v any + var err error + + if isSigned { + v, err = strconv.ParseInt(s, 10, bitSize) + } else { + v, err = strconv.ParseUint(s, 10, bitSize) + } + + if err != nil { + return 0, err + } + + if isSigned { + i := v.(int64) + switch target.(type) { + case int: + return int(i), nil + case int8: + return int8(i), nil + case int16: + return int16(i), nil + case int32: + return int32(i), nil + case int64: + return i, nil + } + } + + i := v.(uint64) + switch target.(type) { + case uint: + return uint(i), nil + case uint8: + return uint8(i), nil + case uint16: + return uint16(i), nil + case uint32: + return uint32(i), nil + case uint64: + return i, nil + + } + + return 0, fmt.Errorf("unsupported target type %T", target) + } + + intToString := func(v any) (string, error) { + return fmt.Sprintf("%d", v), nil + } + + return tmc.NewAdapter(target, intFromString, intToString) + } + + ru, _ := imagemeta.NewRat[uint32](1, 2) + ri, _ := imagemeta.NewRat[int32](1, 2) + tmcAdapters := []tmc.Adapter{ + tmc.NewAdapter(ru, nil, nil), + tmc.NewAdapter(ri, nil, nil), + newIntadapter(int(1)), + newIntadapter(int8(1)), + newIntadapter(int16(1)), + newIntadapter(int32(1)), + newIntadapter(int64(1)), + newIntadapter(uint(1)), + newIntadapter(uint8(1)), + newIntadapter(uint16(1)), + newIntadapter(uint32(1)), + newIntadapter(uint64(1)), + } + + tmcAdapters = append(tmc.DefaultTypeAdapters, tmcAdapters...) + var err error - tcodec, err = tmc.New() + tcodec, err = tmc.New(tmc.WithTypeAdapters(tmcAdapters)) if err != nil { panic(err) } } -type Tags map[string]interface{} +// Tags is a map of EXIF tags. +type Tags map[string]any +// UnmarshalJSON is for internal use only. func (v *Tags) UnmarshalJSON(b []byte) error { - vv := make(map[string]interface{}) + vv := make(map[string]any) if err := tcodec.Unmarshal(b, &vv); err != nil { return err } @@ -266,6 +360,7 @@ func (v *Tags) UnmarshalJSON(b []byte) error { return nil } +// MarshalJSON is for internal use only. func (v Tags) MarshalJSON() ([]byte, error) { return tcodec.Marshal(v) } diff --git a/resources/images/exif/exif_test.go b/resources/images/exif/exif_test.go index c3cfad1cc..278bc761a 100644 --- a/resources/images/exif/exif_test.go +++ b/resources/images/exif/exif_test.go @@ -15,13 +15,12 @@ package exif import ( "encoding/json" - "math/big" "os" "path/filepath" "testing" "time" - "github.com/gohugoio/hugo/htesting/hqt" + "github.com/bep/imagemeta" "github.com/google/go-cmp/cmp" qt "github.com/frankban/quicktest" @@ -35,11 +34,12 @@ func TestExif(t *testing.T) { d, err := NewDecoder(IncludeFields("Lens|Date")) c.Assert(err, qt.IsNil) - x, err := d.Decode(f) + x, err := d.Decode("", imagemeta.JPEG, f) c.Assert(err, qt.IsNil) c.Assert(x.Date.Format("2006-01-02"), qt.Equals, "2017-10-27") // Malaga: https://goo.gl/taazZy + c.Assert(x.Lat, qt.Equals, float64(36.59744166666667)) c.Assert(x.Long, qt.Equals, float64(-4.50846)) @@ -49,18 +49,18 @@ func TestExif(t *testing.T) { c.Assert(ok, qt.Equals, true) c.Assert(lensModel, qt.Equals, "smc PENTAX-DA* 16-50mm F2.8 ED AL [IF] SDM") - v, found = x.Tags["DateTime"] + v, found = x.Tags["ModifyDate"] c.Assert(found, qt.Equals, true) - c.Assert(v, hqt.IsSameType, time.Time{}) + c.Assert(v, qt.Equals, "2017:11:23 09:56:54") // Verify that it survives a round-trip to JSON and back. data, err := json.Marshal(x) c.Assert(err, qt.IsNil) - x2 := &Exif{} + x2 := &ExifInfo{} err = json.Unmarshal(data, x2) + c.Assert(err, qt.IsNil) c.Assert(x2, eq, x) - } func TestExifPNG(t *testing.T) { @@ -72,8 +72,22 @@ func TestExifPNG(t *testing.T) { d, err := NewDecoder() c.Assert(err, qt.IsNil) - _, err = d.Decode(f) - c.Assert(err, qt.Not(qt.IsNil)) + _, err = d.Decode("", imagemeta.PNG, f) + c.Assert(err, qt.IsNil) +} + +func TestIssue8079(t *testing.T) { + c := qt.New(t) + + f, err := os.Open(filepath.FromSlash("../../testdata/iss8079.jpg")) + c.Assert(err, qt.IsNil) + defer f.Close() + + d, err := NewDecoder() + c.Assert(err, qt.IsNil) + x, err := d.Decode("", imagemeta.JPEG, f) + c.Assert(err, qt.IsNil) + c.Assert(x.Tags["ImageDescription"], qt.Equals, "Città del Vaticano #nanoblock #vatican #vaticancity") } func BenchmarkDecodeExif(b *testing.B) { @@ -87,7 +101,7 @@ func BenchmarkDecodeExif(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _, err = d.Decode(f) + _, err = d.Decode("", imagemeta.JPEG, f) c.Assert(err, qt.IsNil) f.Seek(0, 0) } @@ -95,11 +109,197 @@ func BenchmarkDecodeExif(b *testing.B) { var eq = qt.CmpEquals( cmp.Comparer( - func(v1, v2 *big.Rat) bool { - return v1.RatString() == v2.RatString() + func(v1, v2 imagemeta.Rat[uint32]) bool { + return v1.String() == v2.String() + }, + ), + cmp.Comparer( + func(v1, v2 imagemeta.Rat[int32]) bool { + return v1.String() == v2.String() }, ), cmp.Comparer(func(v1, v2 time.Time) bool { return v1.Unix() == v2.Unix() }), ) + +func TestIssue10738(t *testing.T) { + c := qt.New(t) + + testFunc := func(c *qt.C, path, include string) any { + c.Helper() + f, err := os.Open(filepath.FromSlash(path)) + c.Assert(err, qt.IsNil) + defer f.Close() + + d, err := NewDecoder(IncludeFields(include)) + c.Assert(err, qt.IsNil) + x, err := d.Decode("", imagemeta.JPEG, f) + c.Assert(err, qt.IsNil) + + // Verify that it survives a round-trip to JSON and back. + data, err := json.Marshal(x) + c.Assert(err, qt.IsNil) + x2 := &ExifInfo{} + err = json.Unmarshal(data, x2) + c.Assert(err, qt.IsNil) + + c.Assert(x2, eq, x) + + v, found := x.Tags["ExposureTime"] + c.Assert(found, qt.Equals, true) + return v + } + + type args struct { + path string // imagePath + include string // includeFields + } + + type want struct { + vN int64 // numerator + vD int64 // denominator + } + + type testCase struct { + name string + args args + want want + } + + tests := []testCase{ + { + "canon_cr2_fraction", args{ + path: "../../testdata/issue10738/canon_cr2_fraction.jpg", + include: "Lens|Date|ExposureTime", + }, want{ + 1, + 500, + }, + }, + { + "canon_cr2_integer", args{ + path: "../../testdata/issue10738/canon_cr2_integer.jpg", + include: "Lens|Date|ExposureTime", + }, want{ + 10, + 1, + }, + }, + { + "dji_dng_fraction", args{ + path: "../../testdata/issue10738/dji_dng_fraction.jpg", + include: "Lens|Date|ExposureTime", + }, want{ + 1, + 4000, + }, + }, + { + "fuji_raf_fraction", args{ + path: "../../testdata/issue10738/fuji_raf_fraction.jpg", + include: "Lens|Date|ExposureTime", + }, want{ + 1, + 250, + }, + }, + { + "fuji_raf_integer", args{ + path: "../../testdata/issue10738/fuji_raf_integer.jpg", + include: "Lens|Date|ExposureTime", + }, want{ + 1, + 1, + }, + }, + { + "leica_dng_fraction", args{ + path: "../../testdata/issue10738/leica_dng_fraction.jpg", + include: "Lens|Date|ExposureTime", + }, want{ + 1, + 100, + }, + }, + { + "lumix_rw2_fraction", args{ + path: "../../testdata/issue10738/lumix_rw2_fraction.jpg", + include: "Lens|Date|ExposureTime", + }, want{ + 1, + 400, + }, + }, + { + "nikon_nef_d5600", args{ + path: "../../testdata/issue10738/nikon_nef_d5600.jpg", + include: "Lens|Date|ExposureTime", + }, want{ + 1, + 1000, + }, + }, + { + "nikon_nef_fraction", args{ + path: "../../testdata/issue10738/nikon_nef_fraction.jpg", + include: "Lens|Date|ExposureTime", + }, want{ + 1, + 640, + }, + }, + { + "nikon_nef_integer", args{ + path: "../../testdata/issue10738/nikon_nef_integer.jpg", + include: "Lens|Date|ExposureTime", + }, want{ + 30, + 1, + }, + }, + { + "nikon_nef_fraction_2", args{ + path: "../../testdata/issue10738/nikon_nef_fraction_2.jpg", + include: "Lens|Date|ExposureTime", + }, want{ + 1, + 6400, + }, + }, + { + "sony_arw_fraction", args{ + path: "../../testdata/issue10738/sony_arw_fraction.jpg", + include: "Lens|Date|ExposureTime", + }, want{ + 1, + 160, + }, + }, + { + "sony_arw_integer", args{ + path: "../../testdata/issue10738/sony_arw_integer.jpg", + include: "Lens|Date|ExposureTime", + }, want{ + 4, + 1, + }, + }, + } + + for _, tt := range tests { + c.Run(tt.name, func(c *qt.C) { + got := testFunc(c, tt.args.path, tt.args.include) + switch v := got.(type) { + case float64: + c.Assert(v, qt.Equals, float64(tt.want.vN)) + case imagemeta.Rat[uint32]: + r, err := imagemeta.NewRat[uint32](uint32(tt.want.vN), uint32(tt.want.vD)) + c.Assert(err, qt.IsNil) + c.Assert(v, eq, r) + default: + c.Fatalf("unexpected type: %T", got) + } + }) + } +} diff --git a/resources/images/filters.go b/resources/images/filters.go index dd7b58345..1e44f1184 100644 --- a/resources/images/filters.go +++ b/resources/images/filters.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Hugo Authors. All rights reserved. +// Copyright 2024 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. @@ -15,6 +15,16 @@ package images import ( + "fmt" + "image/color" + "strings" + + "github.com/gohugoio/hugo/common/hugio" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/resources/resource" + "github.com/makeworld-the-better-one/dither/v2" + "github.com/mitchellh/mapstructure" + "github.com/disintegration/gift" "github.com/spf13/cast" ) @@ -22,12 +32,238 @@ import ( // Increment for re-generation of images using these filters. const filterAPIVersion = 0 -type Filters struct { +type Filters struct{} + +// Process creates a filter that processes an image using the given specification. +func (*Filters) Process(spec any) gift.Filter { + specs := strings.ToLower(cast.ToString(spec)) + return filter{ + Options: newFilterOpts(specs), + Filter: processFilter{ + spec: specs, + }, + } +} + +// Overlay creates a filter that overlays src at position x y. +func (*Filters) Overlay(src ImageSource, x, y any) gift.Filter { + return filter{ + Options: newFilterOpts(src.Key(), x, y), + Filter: overlayFilter{src: src, x: cast.ToInt(x), y: cast.ToInt(y)}, + } +} + +// Mask creates a filter that applies a mask image to the source image. +func (*Filters) Mask(mask ImageSource) gift.Filter { + return filter{ + Options: newFilterOpts(mask.Key()), + Filter: maskFilter{mask: mask}, + } +} + +// Opacity creates a filter that changes the opacity of an image. +// The opacity parameter must be in range (0, 1). +func (*Filters) Opacity(opacity any) gift.Filter { + return filter{ + Options: newFilterOpts(opacity), + Filter: opacityFilter{opacity: cast.ToFloat32(opacity)}, + } +} + +// Text creates a filter that draws text with the given options. +func (*Filters) Text(text string, options ...any) gift.Filter { + tf := textFilter{ + text: text, + color: color.White, + size: 20, + x: 10, + y: 10, + alignx: "left", + aligny: "top", + linespacing: 2, + } + + var opt maps.Params + if len(options) > 0 { + opt = maps.MustToParamsAndPrepare(options[0]) + for option, v := range opt { + switch option { + case "color": + if color, ok, _ := toColorGo(v); ok { + tf.color = color + } + case "size": + tf.size = cast.ToFloat64(v) + case "x": + tf.x = cast.ToInt(v) + case "y": + tf.y = cast.ToInt(v) + case "alignx": + tf.alignx = cast.ToString(v) + if tf.alignx != "left" && tf.alignx != "center" && tf.alignx != "right" { + panic("alignx must be one of left, center, right") + } + case "aligny": + tf.aligny = cast.ToString(v) + if tf.aligny != "top" && tf.aligny != "center" && tf.aligny != "bottom" { + panic("aligny must be one of top, center, bottom") + } + + case "linespacing": + tf.linespacing = cast.ToInt(v) + case "font": + if err, ok := v.(error); ok { + panic(fmt.Sprintf("invalid font source: %s", err)) + } + fontSource, ok1 := v.(hugio.ReadSeekCloserProvider) + identifier, ok2 := v.(resource.Identifier) + + if !(ok1 && ok2) { + panic(fmt.Sprintf("invalid text font source: %T", v)) + } + + tf.fontSource = fontSource + + // The input value isn't hashable and will not make a stable key. + // Replace it with a string in the map used as basis for the + // hash string. + opt["font"] = identifier.Key() + + } + } + } + + return filter{ + Options: newFilterOpts(text, opt), + Filter: tf, + } +} + +// Padding creates a filter that resizes the image canvas without resizing the +// image. The last argument is the canvas color, expressed as an RGB or RGBA +// hexadecimal color. The default value is `ffffffff` (opaque white). The +// preceding arguments are the padding values, in pixels, using the CSS +// shorthand property syntax. Negative padding values will crop the image. The +// signature is images.Padding V1 [V2] [V3] [V4] [COLOR]. +func (*Filters) Padding(args ...any) gift.Filter { + if len(args) < 1 || len(args) > 5 { + panic("the padding filter requires between 1 and 5 arguments") + } + + var top, right, bottom, left int + var ccolor color.Color = color.White // canvas color + + _args := args // preserve original args for most stable hash + + if vcs, ok, err := toColorGo(args[len(args)-1]); ok || err != nil { + if err != nil { + panic("invalid canvas color: specify RGB or RGBA using hex notation") + } + ccolor = vcs + args = args[:len(args)-1] + if len(args) == 0 { + panic("not enough arguments: provide one or more padding values using the CSS shorthand property syntax") + } + } + + var vals []int + for _, v := range args { + vi := cast.ToInt(v) + if vi > 5000 { + panic("padding values must not exceed 5000 pixels") + } + vals = append(vals, vi) + } + + switch len(args) { + case 1: + top, right, bottom, left = vals[0], vals[0], vals[0], vals[0] + case 2: + top, right, bottom, left = vals[0], vals[1], vals[0], vals[1] + case 3: + top, right, bottom, left = vals[0], vals[1], vals[2], vals[1] + case 4: + top, right, bottom, left = vals[0], vals[1], vals[2], vals[3] + default: + panic(fmt.Sprintf("too many padding values: received %d, expected maximum of 4", len(args))) + } + + return filter{ + Options: newFilterOpts(_args...), + Filter: paddingFilter{ + top: top, + right: right, + bottom: bottom, + left: left, + ccolor: ccolor, + }, + } +} + +// Dither creates a filter that dithers an image. +func (*Filters) Dither(options ...any) gift.Filter { + ditherOptions := struct { + Colors []any + Method string + Serpentine bool + Strength float32 + }{ + Method: "floydsteinberg", + Serpentine: true, + Strength: 1.0, + } + + if len(options) != 0 { + err := mapstructure.WeakDecode(options[0], &ditherOptions) + if err != nil { + panic(fmt.Sprintf("failed to decode options: %s", err)) + } + } + + if len(ditherOptions.Colors) == 0 { + ditherOptions.Colors = []any{"000000ff", "ffffffff"} + } + + if len(ditherOptions.Colors) < 2 { + panic("palette must have at least two colors") + } + + var palette []color.Color + for _, c := range ditherOptions.Colors { + cc, ok, err := toColorGo(c) + if !ok || err != nil { + panic(fmt.Sprintf("%q is an invalid color: specify RGB or RGBA using hexadecimal notation", c)) + } + palette = append(palette, cc) + } + + d := dither.NewDitherer(palette) + if method, ok := ditherMethodsErrorDiffusion[strings.ToLower(ditherOptions.Method)]; ok { + d.Matrix = dither.ErrorDiffusionStrength(method, ditherOptions.Strength) + d.Serpentine = ditherOptions.Serpentine + } else if method, ok := ditherMethodsOrdered[strings.ToLower(ditherOptions.Method)]; ok { + d.Mapper = dither.PixelMapperFromMatrix(method, ditherOptions.Strength) + } else { + panic(fmt.Sprintf("%q is an invalid dithering method: see documentation", ditherOptions.Method)) + } + + return filter{ + Options: newFilterOpts(ditherOptions), + Filter: ditherFilter{ditherer: d}, + } +} + +// AutoOrient creates a filter that rotates and flips an image as needed per +// its EXIF orientation tag. +func (*Filters) AutoOrient() gift.Filter { + return filter{ + Filter: autoOrientFilter{}, + } } // Brightness creates a filter that changes the brightness of an image. // The percentage parameter must be in range (-100, 100). -func (*Filters) Brightness(percentage interface{}) gift.Filter { +func (*Filters) Brightness(percentage any) gift.Filter { return filter{ Options: newFilterOpts(percentage), Filter: gift.Brightness(cast.ToFloat32(percentage)), @@ -36,7 +272,7 @@ func (*Filters) Brightness(percentage interface{}) gift.Filter { // ColorBalance creates a filter that changes the color balance of an image. // The percentage parameters for each color channel (red, green, blue) must be in range (-100, 500). -func (*Filters) ColorBalance(percentageRed, percentageGreen, percentageBlue interface{}) gift.Filter { +func (*Filters) ColorBalance(percentageRed, percentageGreen, percentageBlue any) gift.Filter { return filter{ Options: newFilterOpts(percentageRed, percentageGreen, percentageBlue), Filter: gift.ColorBalance(cast.ToFloat32(percentageRed), cast.ToFloat32(percentageGreen), cast.ToFloat32(percentageBlue)), @@ -47,7 +283,7 @@ func (*Filters) ColorBalance(percentageRed, percentageGreen, percentageBlue inte // The hue parameter is the angle on the color wheel, typically in range (0, 360). // The saturation parameter must be in range (0, 100). // The percentage parameter specifies the strength of the effect, it must be in range (0, 100). -func (*Filters) Colorize(hue, saturation, percentage interface{}) gift.Filter { +func (*Filters) Colorize(hue, saturation, percentage any) gift.Filter { return filter{ Options: newFilterOpts(hue, saturation, percentage), Filter: gift.Colorize(cast.ToFloat32(hue), cast.ToFloat32(saturation), cast.ToFloat32(percentage)), @@ -56,7 +292,7 @@ func (*Filters) Colorize(hue, saturation, percentage interface{}) gift.Filter { // Contrast creates a filter that changes the contrast of an image. // The percentage parameter must be in range (-100, 100). -func (*Filters) Contrast(percentage interface{}) gift.Filter { +func (*Filters) Contrast(percentage any) gift.Filter { return filter{ Options: newFilterOpts(percentage), Filter: gift.Contrast(cast.ToFloat32(percentage)), @@ -66,7 +302,7 @@ func (*Filters) Contrast(percentage interface{}) gift.Filter { // Gamma creates a filter that performs a gamma correction on an image. // The gamma parameter must be positive. Gamma = 1 gives the original image. // Gamma less than 1 darkens the image and gamma greater than 1 lightens it. -func (*Filters) Gamma(gamma interface{}) gift.Filter { +func (*Filters) Gamma(gamma any) gift.Filter { return filter{ Options: newFilterOpts(gamma), Filter: gift.Gamma(cast.ToFloat32(gamma)), @@ -74,7 +310,7 @@ func (*Filters) Gamma(gamma interface{}) gift.Filter { } // GaussianBlur creates a filter that applies a gaussian blur to an image. -func (*Filters) GaussianBlur(sigma interface{}) gift.Filter { +func (*Filters) GaussianBlur(sigma any) gift.Filter { return filter{ Options: newFilterOpts(sigma), Filter: gift.GaussianBlur(cast.ToFloat32(sigma)), @@ -90,7 +326,7 @@ func (*Filters) Grayscale() gift.Filter { // Hue creates a filter that rotates the hue of an image. // The hue angle shift is typically in range -180 to 180. -func (*Filters) Hue(shift interface{}) gift.Filter { +func (*Filters) Hue(shift any) gift.Filter { return filter{ Options: newFilterOpts(shift), Filter: gift.Hue(cast.ToFloat32(shift)), @@ -105,7 +341,7 @@ func (*Filters) Invert() gift.Filter { } // Pixelate creates a filter that applies a pixelation effect to an image. -func (*Filters) Pixelate(size interface{}) gift.Filter { +func (*Filters) Pixelate(size any) gift.Filter { return filter{ Options: newFilterOpts(size), Filter: gift.Pixelate(cast.ToInt(size)), @@ -113,7 +349,7 @@ func (*Filters) Pixelate(size interface{}) gift.Filter { } // Saturation creates a filter that changes the saturation of an image. -func (*Filters) Saturation(percentage interface{}) gift.Filter { +func (*Filters) Saturation(percentage any) gift.Filter { return filter{ Options: newFilterOpts(percentage), Filter: gift.Saturation(cast.ToFloat32(percentage)), @@ -121,7 +357,7 @@ func (*Filters) Saturation(percentage interface{}) gift.Filter { } // Sepia creates a filter that produces a sepia-toned version of an image. -func (*Filters) Sepia(percentage interface{}) gift.Filter { +func (*Filters) Sepia(percentage any) gift.Filter { return filter{ Options: newFilterOpts(percentage), Filter: gift.Sepia(cast.ToFloat32(percentage)), @@ -130,7 +366,7 @@ func (*Filters) Sepia(percentage interface{}) gift.Filter { // Sigmoid creates a filter that changes the contrast of an image using a sigmoidal function and returns the adjusted image. // It's a non-linear contrast change useful for photo adjustments as it preserves highlight and shadow detail. -func (*Filters) Sigmoid(midpoint, factor interface{}) gift.Filter { +func (*Filters) Sigmoid(midpoint, factor any) gift.Filter { return filter{ Options: newFilterOpts(midpoint, factor), Filter: gift.Sigmoid(cast.ToFloat32(midpoint), cast.ToFloat32(factor)), @@ -142,7 +378,7 @@ func (*Filters) Sigmoid(midpoint, factor interface{}) gift.Filter { // Sigma must be positive. Sharpen radius roughly equals 3 * sigma. // The amount parameter controls how much darker and how much lighter the edge borders become. Typically between 0.5 and 1.5. // The threshold parameter controls the minimum brightness change that will be sharpened. Typically between 0 and 0.05. -func (*Filters) UnsharpMask(sigma, amount, threshold interface{}) gift.Filter { +func (*Filters) UnsharpMask(sigma, amount, threshold any) gift.Filter { return filter{ Options: newFilterOpts(sigma, amount, threshold), Filter: gift.UnsharpMask(cast.ToFloat32(sigma), cast.ToFloat32(amount), cast.ToFloat32(threshold)), @@ -157,10 +393,10 @@ type filter struct { // For cache-busting. type filterOpts struct { Version int - Vals interface{} + Vals any } -func newFilterOpts(vals ...interface{}) filterOpts { +func newFilterOpts(vals ...any) filterOpts { return filterOpts{ Version: filterAPIVersion, Vals: vals, diff --git a/resources/images/filters_test.go b/resources/images/filters_test.go index 1243e483b..ce8ae9f5a 100644 --- a/resources/images/filters_test.go +++ b/resources/images/filters_test.go @@ -16,9 +16,8 @@ package images import ( "testing" - "github.com/gohugoio/hugo/helpers" - qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/common/hashing" ) func TestFilterHash(t *testing.T) { @@ -26,9 +25,8 @@ func TestFilterHash(t *testing.T) { f := &Filters{} - c.Assert(helpers.HashString(f.Grayscale()), qt.Equals, helpers.HashString(f.Grayscale())) - c.Assert(helpers.HashString(f.Grayscale()), qt.Not(qt.Equals), helpers.HashString(f.Invert())) - c.Assert(helpers.HashString(f.Gamma(32)), qt.Not(qt.Equals), helpers.HashString(f.Gamma(33))) - c.Assert(helpers.HashString(f.Gamma(32)), qt.Equals, helpers.HashString(f.Gamma(32))) - + c.Assert(hashing.HashString(f.Grayscale()), qt.Equals, hashing.HashString(f.Grayscale())) + c.Assert(hashing.HashString(f.Grayscale()), qt.Not(qt.Equals), hashing.HashString(f.Invert())) + c.Assert(hashing.HashString(f.Gamma(32)), qt.Not(qt.Equals), hashing.HashString(f.Gamma(33))) + c.Assert(hashing.HashString(f.Gamma(32)), qt.Equals, hashing.HashString(f.Gamma(32))) } diff --git a/resources/images/image.go b/resources/images/image.go index a13c1a59e..c891b0168 100644 --- a/resources/images/image.go +++ b/resources/images/image.go @@ -14,15 +14,23 @@ package images import ( + "errors" "fmt" "image" "image/color" + "image/draw" "image/gif" "image/jpeg" "image/png" "io" "sync" + "github.com/bep/gowebp/libwebp/webpoptions" + "github.com/bep/imagemeta" + "github.com/bep/logg" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/resources/images/webp" + "github.com/gohugoio/hugo/media" "github.com/gohugoio/hugo/resources/images/exif" @@ -31,7 +39,6 @@ import ( "golang.org/x/image/tiff" "github.com/gohugoio/hugo/common/hugio" - "github.com/pkg/errors" ) func NewImage(f Format, proc *ImageProcessor, img image.Image, s Spec) *Image { @@ -81,6 +88,10 @@ func (i *Image) EncodeTo(conf ImageConfig, img image.Image, w io.Writer) error { return encoder.Encode(w, img) case GIF: + if giphy, ok := img.(Giphy); ok { + g := giphy.GIF() + return gif.EncodeAll(w, g) + } return gif.Encode(w, img, &gif.Options{ NumColors: 256, }) @@ -89,10 +100,18 @@ func (i *Image) EncodeTo(conf ImageConfig, img image.Image, w io.Writer) error { case BMP: return bmp.Encode(w, img) + case WEBP: + return webp.Encode( + w, + img, webpoptions.EncodingOptions{ + Quality: conf.Quality, + EncodingPreset: webpoptions.EncodingPreset(conf.Hint), + UseSharpYuv: true, + }, + ) default: return errors.New("format not supported") } - } // Height returns i's height. @@ -151,21 +170,21 @@ func (i *Image) initConfig() error { }) if err != nil { - return errors.Wrap(err, "failed to load image config") + return fmt.Errorf("failed to load image config: %w", err) } return nil } -func NewImageProcessor(cfg ImagingConfig) (*ImageProcessor, error) { - e := cfg.Cfg.Exif +func NewImageProcessor(warnl logg.LevelLogger, cfg *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal]) (*ImageProcessor, error) { + e := cfg.Config.Imaging.Exif exifDecoder, err := exif.NewDecoder( exif.WithDateDisabled(e.DisableDate), exif.WithLatLongDisabled(e.DisableLatLong), exif.ExcludeFields(e.ExcludeFields), exif.IncludeFields(e.IncludeFields), + exif.WithWarnLogger(warnl), ) - if err != nil { return nil, err } @@ -174,19 +193,19 @@ func NewImageProcessor(cfg ImagingConfig) (*ImageProcessor, error) { Cfg: cfg, exifDecoder: exifDecoder, }, nil - } type ImageProcessor struct { - Cfg ImagingConfig + Cfg *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal] exifDecoder *exif.Decoder } -func (p *ImageProcessor) DecodeExif(r io.Reader) (*exif.Exif, error) { - return p.exifDecoder.Decode(r) +// Filename is only used for logging. +func (p *ImageProcessor) DecodeExif(filename string, format imagemeta.ImageFormat, r io.Reader) (*exif.ExifInfo, error) { + return p.exifDecoder.Decode(filename, format, r) } -func (p *ImageProcessor) ApplyFiltersFromConfig(src image.Image, conf ImageConfig) (image.Image, error) { +func (p *ImageProcessor) FiltersFromConfig(src image.Image, conf ImageConfig) ([]gift.Filter, error) { var filters []gift.Filter if conf.Rotate != 0 { @@ -197,8 +216,23 @@ func (p *ImageProcessor) ApplyFiltersFromConfig(src image.Image, conf ImageConfi switch conf.Action { case "resize": filters = append(filters, gift.Resize(conf.Width, conf.Height, conf.Filter)) + case "crop": + if conf.Anchor == SmartCropAnchor { + bounds, err := p.smartCrop(src, conf.Width, conf.Height, conf.Filter) + if err != nil { + return nil, err + } + + // First crop using the bounds returned by smartCrop. + filters = append(filters, gift.Crop(bounds)) + // Then center crop the image to get an image the desired size without resizing. + filters = append(filters, gift.CropToSize(conf.Width, conf.Height, gift.CenterAnchor)) + + } else { + filters = append(filters, gift.CropToSize(conf.Width, conf.Height, conf.Anchor)) + } case "fill": - if conf.AnchorStr == smartCropIdentifier { + if conf.Anchor == SmartCropAnchor { bounds, err := p.smartCrop(src, conf.Width, conf.Height, conf.Filter) if err != nil { return nil, err @@ -214,10 +248,22 @@ func (p *ImageProcessor) ApplyFiltersFromConfig(src image.Image, conf ImageConfi case "fit": filters = append(filters, gift.ResizeToFit(conf.Width, conf.Height, conf.Filter)) default: - return nil, errors.Errorf("unsupported action: %q", conf.Action) + + } + return filters, nil +} + +func (p *ImageProcessor) ApplyFiltersFromConfig(src image.Image, conf ImageConfig) (image.Image, error) { + filters, err := p.FiltersFromConfig(src, conf) + if err != nil { + return nil, err } - img, err := p.Filter(src, filters...) + if len(filters) == 0 { + return p.resolveSrc(src, conf.TargetFormat), nil + } + + img, err := p.doFilter(src, conf.TargetFormat, filters...) if err != nil { return nil, err } @@ -226,16 +272,71 @@ func (p *ImageProcessor) ApplyFiltersFromConfig(src image.Image, conf ImageConfi } func (p *ImageProcessor) Filter(src image.Image, filters ...gift.Filter) (image.Image, error) { - g := gift.New(filters...) - dst := image.NewRGBA(g.Bounds(src.Bounds())) - g.Draw(dst, src) + return p.doFilter(src, 0, filters...) +} + +func (p *ImageProcessor) resolveSrc(src image.Image, targetFormat Format) image.Image { + if giph, ok := src.(Giphy); ok { + g := giph.GIF() + if len(g.Image) < 2 || (targetFormat == 0 || targetFormat != GIF) { + src = g.Image[0] + } + } + return src +} + +func (p *ImageProcessor) doFilter(src image.Image, targetFormat Format, filters ...gift.Filter) (image.Image, error) { + filter := gift.New(filters...) + + if giph, ok := src.(Giphy); ok { + g := giph.GIF() + if len(g.Image) < 2 || (targetFormat == 0 || targetFormat != GIF) { + src = g.Image[0] + } else { + var bounds image.Rectangle + firstFrame := g.Image[0] + tmp := image.NewNRGBA(firstFrame.Bounds()) + for i := range g.Image { + gift.New().DrawAt(tmp, g.Image[i], g.Image[i].Bounds().Min, gift.OverOperator) + bounds = filter.Bounds(tmp.Bounds()) + dst := image.NewPaletted(bounds, g.Image[i].Palette) + filter.Draw(dst, tmp) + g.Image[i] = dst + } + g.Config.Width = bounds.Dx() + g.Config.Height = bounds.Dy() + + return giph, nil + } + + } + + bounds := filter.Bounds(src.Bounds()) + + var dst draw.Image + switch src.(type) { + case *image.RGBA: + dst = image.NewRGBA(bounds) + case *image.NRGBA: + dst = image.NewNRGBA(bounds) + case *image.Gray: + dst = image.NewGray(bounds) + default: + dst = image.NewNRGBA(bounds) + } + filter.Draw(dst, src) + return dst, nil } -func (p *ImageProcessor) GetDefaultImageConfig(action string) ImageConfig { +func GetDefaultImageConfig(defaults *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal]) ImageConfig { + if defaults == nil { + defaults = defaultImageConfig + } return ImageConfig{ - Action: action, - Quality: p.Cfg.Cfg.Quality, + Anchor: -1, // The real values start at 0. + Hint: defaults.Config.Hint, + Quality: defaults.Config.Imaging.Quality, } } @@ -253,11 +354,28 @@ const ( GIF TIFF BMP + WEBP ) -// RequiresDefaultQuality returns if the default quality needs to be applied to images of this format +func (f Format) ToImageMetaImageFormatFormat() imagemeta.ImageFormat { + switch f { + case JPEG: + return imagemeta.JPEG + case PNG: + return imagemeta.PNG + case TIFF: + return imagemeta.TIFF + case WEBP: + return imagemeta.WebP + default: + return -1 + } +} + +// RequiresDefaultQuality returns if the default quality needs to be applied to +// images of this format. func (f Format) RequiresDefaultQuality() bool { - return f == JPEG + return f == JPEG || f == WEBP } // SupportsTransparency reports whether it supports transparency in any form. @@ -268,22 +386,24 @@ func (f Format) SupportsTransparency() bool { // DefaultExtension returns the default file extension of this format, starting with a dot. // For example: .jpg for JPEG func (f Format) DefaultExtension() string { - return f.MediaType().FullSuffix() + return f.MediaType().FirstSuffix.FullSuffix } // MediaType returns the media type of this image, e.g. image/jpeg for JPEG func (f Format) MediaType() media.Type { switch f { case JPEG: - return media.JPEGType + return media.Builtin.JPEGType case PNG: - return media.PNGType + return media.Builtin.PNGType case GIF: - return media.GIFType + return media.Builtin.GIFType case TIFF: - return media.TIFFType + return media.Builtin.TIFFType case BMP: - return media.BMPType + return media.Builtin.BMPType + case WEBP: + return media.Builtin.WEBPType default: panic(fmt.Sprintf("%d is not a valid image format", f)) } @@ -296,11 +416,23 @@ type imageConfig struct { } func imageConfigFromImage(img image.Image) image.Config { + if giphy, ok := img.(Giphy); ok { + return giphy.GIF().Config + } b := img.Bounds() return image.Config{Width: b.Max.X, Height: b.Max.Y} } -func ToFilters(in interface{}) []gift.Filter { +// UnwrapFilter unwraps the given filter if it is a filter wrapper. +func UnwrapFilter(in gift.Filter) gift.Filter { + if f, ok := in.(filter); ok { + return f.Filter + } + return in +} + +// ToFilters converts the given input to a slice of gift.Filter. +func ToFilters(in any) []gift.Filter { switch v := in.(type) { case []gift.Filter: return v @@ -328,3 +460,15 @@ func IsOpaque(img image.Image) bool { return false } + +// ImageSource identifies and decodes an image. +type ImageSource interface { + DecodeImage() (image.Image, error) + Key() string +} + +// Giphy represents a GIF Image that may be animated. +type Giphy interface { + image.Image // The first frame. + GIF() *gif.GIF // All frames. +} diff --git a/resources/images/image_resource.go b/resources/images/image_resource.go new file mode 100644 index 000000000..7cede07dd --- /dev/null +++ b/resources/images/image_resource.go @@ -0,0 +1,70 @@ +// Copyright 2024 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 images + +import ( + "image" + + "github.com/gohugoio/hugo/resources/images/exif" + "github.com/gohugoio/hugo/resources/resource" +) + +// ImageResource represents an image resource. +type ImageResource interface { + resource.Resource + ImageResourceOps +} + +type ImageResourceOps interface { + // Height returns the height of the Image. + Height() int + + // Width returns the width of the Image. + Width() int + + // Process applies the given image processing options to the image. + Process(spec string) (ImageResource, error) + + // Crop an image to match the given dimensions without resizing. + // You must provide both width and height. + // Use the anchor option to change the crop box anchor point. + // {{ $image := $image.Crop "600x400" }} + Crop(spec string) (ImageResource, error) + + // Fill scales the image to the smallest possible size that will cover the specified dimensions in spec, + // crops the resized image to the specified dimensions using the given anchor point. + // The spec is space delimited, e.g. `200x300 TopLeft`. + Fill(spec string) (ImageResource, error) + + // Fit scales down the image using the given spec. + Fit(spec string) (ImageResource, error) + + // Resize resizes the image to the given spec. If one of width or height is 0, the image aspect + // ratio is preserved. + Resize(spec string) (ImageResource, error) + + // Filter applies one or more filters to an Image. + // {{ $image := $image.Filter (images.GaussianBlur 6) (images.Pixelate 8) }} + Filter(filters ...any) (ImageResource, error) + + // Exif returns an ExifInfo object containing Image metadata. + Exif() *exif.ExifInfo + + // Colors returns a slice of the most dominant colors in an image + // using a simple histogram method. + Colors() ([]Color, error) + + // For internal use. + DecodeImage() (image.Image, error) +} diff --git a/resources/images/images_golden_integration_test.go b/resources/images/images_golden_integration_test.go new file mode 100644 index 000000000..5397bee23 --- /dev/null +++ b/resources/images/images_golden_integration_test.go @@ -0,0 +1,414 @@ +// Copyright 2024 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 images_test + +import ( + _ "image/jpeg" + "strings" + "testing" + + "github.com/gohugoio/hugo/resources/images/imagetesting" +) + +// Note, if you're enabling writeGoldenFiles on a MacOS ARM 64 you need to run the test with GOARCH=amd64, e.g. +func TestImagesGoldenFiltersMisc(t *testing.T) { + t.Parallel() + + if imagetesting.SkipGoldenTests { + t.Skip("Skip golden test on this architecture") + } + + // Will be used as the base folder for generated images. + name := "filters/misc" + + files := ` +-- hugo.toml -- +-- assets/rotate270.jpg -- +sourcefilename: ../testdata/exif/orientation6.jpg +-- assets/sunset.jpg -- +sourcefilename: ../testdata/sunset.jpg +-- assets/gopher.png -- +sourcefilename: ../testdata/gopher-hero8.png +-- layouts/index.html -- +Home. +{{ $sunset := (resources.Get "sunset.jpg").Resize "x300" }} +{{ $sunsetGrayscale := $sunset.Filter (images.Grayscale) }} +{{ $gopher := (resources.Get "gopher.png").Resize "x80" }} +{{ $overlayFilter := images.Overlay $gopher 20 20 }} + +{{ $textOpts := dict + "color" "#fbfaf5" + "linespacing" 8 + "size" 40 + "x" 25 + "y" 190 +}} + +{{/* These are sorted. */}} +{{ template "filters" (dict "name" "brightness-40.jpg" "img" $sunset "filters" (images.Brightness 40)) }} +{{ template "filters" (dict "name" "contrast-50.jpg" "img" $sunset "filters" (images.Contrast 50)) }} +{{ template "filters" (dict "name" "dither-default.jpg" "img" $sunset "filters" (images.Dither)) }} +{{ template "filters" (dict "name" "gamma-1.667.jpg" "img" $sunset "filters" (images.Gamma 1.667)) }} +{{ template "filters" (dict "name" "gaussianblur-5.jpg" "img" $sunset "filters" (images.GaussianBlur 5)) }} +{{ template "filters" (dict "name" "grayscale.jpg" "img" $sunset "filters" (images.Grayscale)) }} +{{ template "filters" (dict "name" "grayscale+colorize-180-50-20.jpg" "img" $sunset "filters" (slice images.Grayscale (images.Colorize 180 50 20))) }} +{{ template "filters" (dict "name" "colorbalance-180-50-20.jpg" "img" $sunset "filters" (images.ColorBalance 180 50 20)) }} +{{ template "filters" (dict "name" "hue--15.jpg" "img" $sunset "filters" (images.Hue -15)) }} +{{ template "filters" (dict "name" "invert.jpg" "img" $sunset "filters" (images.Invert)) }} +{{ template "filters" (dict "name" "opacity-0.65.jpg" "img" $sunset "filters" (images.Opacity 0.65)) }} +{{ template "filters" (dict "name" "overlay-20-20.jpg" "img" $sunset "filters" ($overlayFilter)) }} +{{ template "filters" (dict "name" "padding-20-40-#976941.jpg" "img" $sunset "filters" (images.Padding 20 40 "#976941" )) }} +{{ template "filters" (dict "name" "pixelate-10.jpg" "img" $sunset "filters" (images.Pixelate 10)) }} +{{ template "filters" (dict "name" "rotate270.jpg" "img" (resources.Get "rotate270.jpg") "filters" images.AutoOrient) }} +{{ template "filters" (dict "name" "saturation-65.jpg" "img" $sunset "filters" (images.Saturation 65)) }} +{{ template "filters" (dict "name" "sepia-80.jpg" "img" $sunsetGrayscale "filters" (images.Sepia 80)) }} +{{ template "filters" (dict "name" "sigmoid-0.6--4.jpg" "img" $sunset "filters" (images.Sigmoid 0.6 -4 )) }} +{{ template "filters" (dict "name" "text.jpg" "img" $sunset "filters" (images.Text "Hugo Rocks!" $textOpts )) }} +{{ template "filters" (dict "name" "unsharpmask.jpg" "img" $sunset "filters" (images.UnsharpMask 10 0.4 0.03)) }} + + +{{ define "filters"}} +{{ if lt (len (path.Ext .name)) 4 }} + {{ errorf "No extension in %q" .name }} +{{ end }} +{{ $img := .img.Filter .filters }} +{{ $name := printf "images/%s" .name }} +{{ with $img | resources.Copy $name }} +{{ .Publish }} +{{ end }} +{{ end }} +` + + opts := imagetesting.DefaultGoldenOpts + opts.T = t + opts.Name = name + opts.Files = files + + imagetesting.RunGolden(opts) +} + +func TestImagesGoldenFiltersMask(t *testing.T) { + t.Parallel() + + if imagetesting.SkipGoldenTests { + t.Skip("Skip golden test on this architecture") + } + + // Will be used as the base folder for generated images. + name := "filters/mask" + + files := ` +-- hugo.toml -- +[imaging] + bgColor = '#ebcc34' + hint = 'photo' + quality = 75 + resampleFilter = 'Lanczos' +-- assets/sunset.jpg -- +sourcefilename: ../testdata/sunset.jpg +-- assets/mask.png -- +sourcefilename: ../testdata/mask.png + +-- layouts/index.html -- +Home. +{{ $sunset := resources.Get "sunset.jpg" }} +{{ $mask := resources.Get "mask.png" }} + +{{ template "mask" (dict "name" "transparant.png" "base" $sunset "mask" $mask) }} +{{ template "mask" (dict "name" "yellow.jpg" "base" $sunset "mask" $mask) }} +{{ template "mask" (dict "name" "wide.jpg" "base" $sunset "mask" $mask "spec" "resize 600x200") }} +{{/* This looks a little odd, but is correct and the recommended way to do this. +This will 1. Scale the image to x300, 2. Apply the mask, 3. Create the final image with background color #323ea. +It's possible to have multiple images.Process filters in the chain, but for the options for the final image (target format, bgGolor etc.), +the last entry will win. +*/}} +{{ template "mask" (dict "name" "blue.jpg" "base" $sunset "mask" $mask "spec" "resize x300 #323ea8") }} + +{{ define "mask"}} +{{ $ext := path.Ext .name }} +{{ if lt (len (path.Ext .name)) 4 }} + {{ errorf "No extension in %q" .name }} +{{ end }} +{{ $format := strings.TrimPrefix "." $ext }} +{{ $spec := .spec | default (printf "resize x300 %s" $format) }} +{{ $filters := slice (images.Process $spec) (images.Mask .mask) }} +{{ $name := printf "images/%s" .name }} +{{ $img := .base.Filter $filters }} +{{ with $img | resources.Copy $name }} +{{ .Publish }} +{{ end }} +{{ end }} +` + + opts := imagetesting.DefaultGoldenOpts + opts.T = t + opts.Name = name + opts.Files = files + + imagetesting.RunGolden(opts) +} + +// Issue 13272, 13273. +func TestImagesGoldenFiltersMaskCacheIssues(t *testing.T) { + if imagetesting.SkipGoldenTests { + t.Skip("Skip golden test on this architecture") + } + + // Will be used as the base folder for generated images. + name := "filters/mask2" + + files := ` +-- hugo.toml -- +[caches] + [caches.images] + dir = ':cacheDir/golden_images' + maxAge = "30s" +[imaging] + bgColor = '#33ff44' + hint = 'photo' + quality = 75 + resampleFilter = 'Lanczos' +-- assets/sunset.jpg -- +sourcefilename: ../testdata/sunset.jpg +-- assets/mask.png -- +sourcefilename: ../testdata/mask.png + +-- layouts/index.html -- +Home. +{{ $sunset := resources.Get "sunset.jpg" }} +{{ $mask := resources.Get "mask.png" }} + + +{{ template "mask" (dict "name" "green.jpg" "base" $sunset "mask" $mask) }} + +{{ define "mask"}} +{{ $ext := path.Ext .name }} +{{ if lt (len (path.Ext .name)) 4 }} + {{ errorf "No extension in %q" .name }} +{{ end }} +{{ $format := strings.TrimPrefix "." $ext }} +{{ $spec := .spec | default (printf "resize x300 %s" $format) }} +{{ $filters := slice (images.Process $spec) (images.Mask .mask) }} +{{ $name := printf "images/%s" .name }} +{{ $img := .base.Filter $filters }} +{{ with $img | resources.Copy $name }} +{{ .Publish }} +{{ end }} +{{ end }} +` + + tempDir := t.TempDir() + + opts := imagetesting.DefaultGoldenOpts + opts.WorkingDir = tempDir + opts.T = t + opts.Name = name + opts.Files = files + opts.SkipAssertions = true + + imagetesting.RunGolden(opts) + + files = strings.Replace(files, "#33ff44", "#a83269", -1) + files = strings.Replace(files, "green", "pink", -1) + files = strings.Replace(files, "mask.png", "mask2.png", -1) + opts.Files = files + opts.SkipAssertions = false + opts.Rebuild = true + + imagetesting.RunGolden(opts) +} + +func TestImagesGoldenFiltersText(t *testing.T) { + t.Parallel() + + if imagetesting.SkipGoldenTests { + t.Skip("Skip golden test on this architecture") + } + + // Will be used as the base folder for generated images. + name := "filters/text" + + files := ` +-- hugo.toml -- +-- assets/sunset.jpg -- +sourcefilename: ../testdata/sunset.jpg + +-- layouts/index.html -- +Home. +{{ $sunset := resources.Get "sunset.jpg" }} +{{ $textOpts := dict + "color" "#fbfaf5" + "linespacing" 8 + "size" 28 + "x" (div $sunset.Width 2 | int) + "y" (div $sunset.Height 2 | int) + "alignx" "center" +}} + +{{ $text := "Pariatur deserunt sunt nisi sunt tempor quis eu. Sint et nulla enim officia sunt cupidatat. Eu amet ipsum qui velit cillum cillum ad Lorem in non ad aute." }} +{{ template "filters" (dict "name" "text_alignx-center.jpg" "img" $sunset "filters" (images.Text $text $textOpts )) }} +{{ $textOpts = (dict "alignx" "right") | merge $textOpts }} +{{ template "filters" (dict "name" "text_alignx-right.jpg" "img" $sunset "filters" (images.Text $text $textOpts )) }} +{{ $textOpts = (dict "alignx" "left") | merge $textOpts }} +{{ template "filters" (dict "name" "text_alignx-left.jpg" "img" $sunset "filters" (images.Text $text $textOpts )) }} +{{ $textOpts = (dict "alignx" "center" "aligny" "center") | merge $textOpts }} +{{ $text = "Est exercitation deserunt exercitation nostrud magna. Eiusmod anim deserunt sit elit dolore ea incididunt nisi. Ea ullamco excepteur voluptate occaecat duis pariatur proident cupidatat. Eu id esse qui consectetur commodo ad ex esse cupidatat velit duis cupidatat. Aliquip irure tempor consequat non amet in mollit ipsum officia tempor laborum." }} +{{ template "filters" (dict "name" "text_alignx-center_aligny-center.jpg" "img" $sunset "filters" (images.Text $text $textOpts )) }} +{{ $textOpts = (dict "alignx" "center" "aligny" "bottom") | merge $textOpts }} +{{ template "filters" (dict "name" "text_alignx-center_aligny-bottom.jpg" "img" $sunset "filters" (images.Text $text $textOpts )) }} + +{{ define "filters"}} +{{ if lt (len (path.Ext .name)) 4 }} + {{ errorf "No extension in %q" .name }} +{{ end }} +{{ $img := .img.Filter .filters }} +{{ $name := printf "images/%s" .name }} +{{ with $img | resources.Copy $name }} +{{ .Publish }} +{{ end }} +{{ end }} +` + + opts := imagetesting.DefaultGoldenOpts + opts.T = t + opts.Name = name + opts.Files = files + // opts.WriteFiles = true + // opts.DevMode = true + + imagetesting.RunGolden(opts) +} + +func TestImagesGoldenProcessMisc(t *testing.T) { + t.Parallel() + + if imagetesting.SkipGoldenTests { + t.Skip("Skip golden test on this architecture") + } + + // Will be used as the base folder for generated images. + name := "process/misc" + + files := ` +-- hugo.toml -- +-- assets/giphy.gif -- +sourcefilename: ../testdata/giphy.gif +-- assets/sunset.jpg -- +sourcefilename: ../testdata/sunset.jpg +-- assets/gopher.png -- +sourcefilename: ../testdata/gopher-hero8.png +-- layouts/index.html -- +Home. +{{ $sunset := resources.Get "sunset.jpg" }} +{{ $sunsetGrayscale := $sunset.Filter (images.Grayscale) }} +{{ $gopher := resources.Get "gopher.png" }} +{{ $giphy := resources.Get "giphy.gif" }} + + +{{/* These are sorted. The end file name will be created from the spec + extension, so make sure these are unique. */}} +{{ template "process" (dict "spec" "crop 500x200 smart" "img" $sunset) }} +{{ template "process" (dict "spec" "fill 500x200 smart" "img" $sunset) }} +{{ template "process" (dict "spec" "fit 500x200 smart" "img" $sunset) }} +{{ template "process" (dict "spec" "resize 100x100 gif" "img" $giphy) }} +{{ template "process" (dict "spec" "resize 100x100 r180" "img" $gopher) }} +{{ template "process" (dict "spec" "resize 300x300 jpg #b31280" "img" $gopher) }} + +{{ define "process"}} +{{ $img := .img.Process .spec }} +{{ $ext := path.Ext $img.RelPermalink }} +{{ $name := printf "images/%s%s" (.spec | anchorize) $ext }} +{{ with $img | resources.Copy $name }} +{{ .Publish }} +{{ end }} +{{ end }} +` + + opts := imagetesting.DefaultGoldenOpts + opts.T = t + opts.Name = name + opts.Files = files + + imagetesting.RunGolden(opts) +} + +func TestImagesGoldenMethods(t *testing.T) { + t.Parallel() + + if imagetesting.SkipGoldenTests { + t.Skip("Skip golden test on this architecture") + } + + // Will be used as the base folder for generated images. + name := "methods" + + files := ` +-- hugo.toml -- +[imaging] + bgColor = '#ebcc34' + hint = 'photo' + quality = 75 + resampleFilter = 'MitchellNetravali' +-- assets/sunset.jpg -- +sourcefilename: ../testdata/sunset.jpg +-- assets/gopher.png -- +sourcefilename: ../testdata/gopher-hero8.png + +-- layouts/index.html -- +Home. +{{ $sunset := resources.Get "sunset.jpg" }} +{{ $gopher := resources.Get "gopher.png" }} + + +{{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "resize" "spec" "300x" ) }} +{{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "resize" "spec" "x200" ) }} +{{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "fill" "spec" "90x120 left" ) }} +{{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "fill" "spec" "90x120 right" ) }} +{{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "fit" "spec" "200x200" ) }} +{{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "crop" "spec" "200x200" ) }} +{{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "crop" "spec" "350x400 center" ) }} + {{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "crop" "spec" "350x400 smart" ) }} +{{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "crop" "spec" "350x400 center r90" ) }} +{{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "crop" "spec" "350x400 center q20" ) }} +{{ template "invoke" (dict "copyFormat" "png" "base" $gopher "method" "resize" "spec" "100x" ) }} +{{ template "invoke" (dict "copyFormat" "png" "base" $gopher "method" "resize" "spec" "100x #fc03ec" ) }} +{{ template "invoke" (dict "copyFormat" "jpg" "base" $gopher "method" "resize" "spec" "100x #03fc56 jpg" ) }} + +{{ define "invoke"}} +{{ $spec := .spec }} +{{ $name := printf "images/%s-%s-%s.%s" .method ((trim .base.Name "/") | lower | anchorize) ($spec | anchorize) .copyFormat }} +{{ $img := ""}} +{{ if eq .method "resize" }} + {{ $img = .base.Resize $spec }} +{{ else if eq .method "fill" }} + {{ $img = .base.Fill $spec }} +{{ else if eq .method "fit" }} + {{ $img = .base.Fit $spec }} +{{ else if eq .method "crop" }} + {{ $img = .base.Crop $spec }} +{{ else }} + {{ errorf "Unknown method %q" .method }} +{{ end }} +{{ with $img | resources.Copy $name }} +{{ .Publish }} +{{ end }} +{{ end }} +` + + opts := imagetesting.DefaultGoldenOpts + opts.T = t + opts.Name = name + opts.Files = files + + imagetesting.RunGolden(opts) +} diff --git a/resources/images/images_integration_test.go b/resources/images/images_integration_test.go new file mode 100644 index 000000000..caba42e03 --- /dev/null +++ b/resources/images/images_integration_test.go @@ -0,0 +1,52 @@ +// Copyright 2024 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 images_test + +import ( + "testing" + + "github.com/gohugoio/hugo/hugolib" +) + +func TestAutoOrient(t *testing.T) { + files := ` +-- hugo.toml -- +-- assets/rotate270.jpg -- +sourcefilename: ../testdata/exif/orientation6.jpg +-- layouts/index.html -- +{{ $img := resources.Get "rotate270.jpg" }} +W/H original: {{ $img.Width }}/{{ $img.Height }} +{{ $rotated := $img.Filter images.AutoOrient }} +W/H rotated: {{ $rotated.Width }}/{{ $rotated.Height }} +` + + b := hugolib.Test(t, files) + b.AssertFileContent("public/index.html", "W/H original: 80/40\n\nW/H rotated: 40/80") +} + +// Issue 12733. +func TestOrientationEq(t *testing.T) { + files := ` +-- hugo.toml -- +-- assets/rotate270.jpg -- +sourcefilename: ../testdata/exif/orientation6.jpg +-- layouts/index.html -- +{{ $img := resources.Get "rotate270.jpg" }} +{{ $orientation := $img.Exif.Tags.Orientation }} +Orientation: {{ $orientation }}|eq 6: {{ eq $orientation 6 }}|Type: {{ printf "%T" $orientation }}| +` + + b := hugolib.Test(t, files) + b.AssertFileContent("public/index.html", "Orientation: 6|eq 6: true|") +} diff --git a/resources/images/imagetesting/testing.go b/resources/images/imagetesting/testing.go new file mode 100644 index 000000000..f7a6af7ea --- /dev/null +++ b/resources/images/imagetesting/testing.go @@ -0,0 +1,235 @@ +// Copyright 2024 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 imagetesting + +import ( + "image" + "image/gif" + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + qt "github.com/frankban/quicktest" + "github.com/google/go-cmp/cmp" + + "github.com/disintegration/gift" + "github.com/gohugoio/hugo/common/hashing" + "github.com/gohugoio/hugo/common/hugio" + "github.com/gohugoio/hugo/htesting" + "github.com/gohugoio/hugo/hugofs" + "github.com/gohugoio/hugo/hugolib" +) + +var eq = qt.CmpEquals( + cmp.Comparer(func(p1, p2 os.FileInfo) bool { + return p1.Name() == p2.Name() && p1.Size() == p2.Size() && p1.IsDir() == p2.IsDir() + }), + cmp.Comparer(func(d1, d2 fs.DirEntry) bool { + p1, err1 := d1.Info() + p2, err2 := d2.Info() + if err1 != nil || err2 != nil { + return false + } + return p1.Name() == p2.Name() && p1.Size() == p2.Size() && p1.IsDir() == p2.IsDir() + }), +) + +// GoldenImageTestOpts provides options for a golden image test. +type GoldenImageTestOpts struct { + // The test. + T testing.TB + + // Name of the test. Will be used as the base folder for generated images. + // Slashes allowed and encouraged. + Name string + + // The test site's files in txttar format. + Files string + + // Set to true to write golden files to disk. + WriteFiles bool + + // If not set, a temporary directory will be created. + WorkingDir string + + // Set to true to skip any assertions. Useful when adding new golden variants to a test. + DevMode bool + + // Set to skip any assertions. + SkipAssertions bool + + // Whether this represents a rebuild of the same site. + // Setting this to true will keep the previous golden image set. + Rebuild bool +} + +// To rebuild all Golden image tests, toggle WriteFiles=true and run: +// GOARCH=amd64 go test -count 1 -timeout 30s -run "^TestImagesGolden" ./... +// TODO(bep) see if we can do this via flags. +var DefaultGoldenOpts = GoldenImageTestOpts{ + WriteFiles: false, + DevMode: false, +} + +func RunGolden(opts GoldenImageTestOpts) *hugolib.IntegrationTestBuilder { + opts.T.Helper() + + c := hugolib.Test(opts.T, opts.Files, hugolib.TestOptWithConfig(func(conf *hugolib.IntegrationTestConfig) { + conf.NeedsOsFS = true + conf.WorkingDir = opts.WorkingDir + })) + c.AssertFileContent("public/index.html", "Home.") + + outputDir := filepath.Join(c.H.Conf.WorkingDir(), "public", "images") + goldenBaseDir := filepath.Join("testdata", "images_golden") + goldenDir := filepath.Join(goldenBaseDir, filepath.FromSlash(opts.Name)) + if opts.WriteFiles { + c.Assert(htesting.IsRealCI(), qt.IsFalse) + if !opts.Rebuild { + c.Assert(os.MkdirAll(goldenBaseDir, 0o777), qt.IsNil) + c.Assert(os.RemoveAll(goldenDir), qt.IsNil) + } + c.Assert(hugio.CopyDir(hugofs.Os, outputDir, goldenDir, nil), qt.IsNil) + return c + } + + if opts.SkipAssertions { + return c + } + + if opts.DevMode { + c.Assert(htesting.IsRealCI(), qt.IsFalse) + return c + } + + decodeAll := func(f *os.File) []image.Image { + c.Helper() + + var images []image.Image + + if strings.HasSuffix(f.Name(), ".gif") { + gif, err := gif.DecodeAll(f) + c.Assert(err, qt.IsNil, qt.Commentf(f.Name())) + images = make([]image.Image, len(gif.Image)) + for i, img := range gif.Image { + images[i] = img + } + } else { + img, _, err := image.Decode(f) + c.Assert(err, qt.IsNil, qt.Commentf(f.Name())) + images = append(images, img) + } + return images + } + + entries1, err := os.ReadDir(outputDir) + c.Assert(err, qt.IsNil) + entries2, err := os.ReadDir(goldenDir) + c.Assert(err, qt.IsNil) + c.Assert(len(entries1), qt.Equals, len(entries2)) + for i, e1 := range entries1 { + c.Assert(filepath.Ext(e1.Name()), qt.Not(qt.Equals), "") + func() { + e2 := entries2[i] + + f1, err := os.Open(filepath.Join(outputDir, e1.Name())) + c.Assert(err, qt.IsNil) + defer f1.Close() + + f2, err := os.Open(filepath.Join(goldenDir, e2.Name())) + c.Assert(err, qt.IsNil) + defer f2.Close() + + imgs2 := decodeAll(f2) + imgs1 := decodeAll(f1) + c.Assert(len(imgs1), qt.Equals, len(imgs2)) + + if !UsesFMA { + c.Assert(e1, eq, e2) + _, err = f1.Seek(0, 0) + c.Assert(err, qt.IsNil) + _, err = f2.Seek(0, 0) + c.Assert(err, qt.IsNil) + + hash1, _, err := hashing.XXHashFromReader(f1) + c.Assert(err, qt.IsNil) + hash2, _, err := hashing.XXHashFromReader(f2) + c.Assert(err, qt.IsNil) + + c.Assert(hash1, qt.Equals, hash2) + } + + for i, img1 := range imgs1 { + img2 := imgs2[i] + nrgba1 := image.NewNRGBA(img1.Bounds()) + gift.New().Draw(nrgba1, img1) + nrgba2 := image.NewNRGBA(img2.Bounds()) + gift.New().Draw(nrgba2, img2) + c.Assert(goldenEqual(nrgba1, nrgba2), qt.Equals, true, qt.Commentf(e1.Name())) + } + }() + } + return c +} + +// goldenEqual compares two NRGBA images. It is used in golden tests only. +// A small tolerance is allowed on architectures using "fused multiply and add" +// (FMA) instruction to accommodate for floating-point rounding differences +// with control golden images that were generated on amd64 architecture. +// See https://golang.org/ref/spec#Floating_point_operators +// and https://github.com/gohugoio/hugo/issues/6387 for more information. +// +// Based on https://github.com/disintegration/gift/blob/a999ff8d5226e5ab14b64a94fca07c4ac3f357cf/gift_test.go#L598-L625 +// Copyright (c) 2014-2019 Grigory Dryapak +// Licensed under the MIT License. +func goldenEqual(img1, img2 *image.NRGBA) bool { + maxDiff := 0 + if runtime.GOARCH != "amd64" { + // The golden files are created using the AMD64 architecture. + // Be lenient on other platforms due to floaging point and dithering differences. + maxDiff = 15 + } + if !img1.Rect.Eq(img2.Rect) { + return false + } + if len(img1.Pix) != len(img2.Pix) { + return false + } + for i := range img1.Pix { + diff := int(img1.Pix[i]) - int(img2.Pix[i]) + if diff < 0 { + diff = -diff + } + if diff > maxDiff { + return false + } + } + return true +} + +// We don't have a CI test environment for these, and there are known dithering issues that makes these time consuming to maintain. +var SkipGoldenTests = runtime.GOARCH == "ppc64" || runtime.GOARCH == "ppc64le" || runtime.GOARCH == "s390x" + +// UsesFMA indicates whether "fused multiply and add" (FMA) instruction is +// used. The command "grep FMADD go/test/codegen/floats.go" can help keep +// the FMA-using architecture list updated. +var UsesFMA = runtime.GOARCH == "s390x" || + runtime.GOARCH == "ppc64" || + runtime.GOARCH == "ppc64le" || + runtime.GOARCH == "arm64" || + runtime.GOARCH == "riscv64" || + runtime.GOARCH == "loong64" diff --git a/resources/images/mask.go b/resources/images/mask.go new file mode 100644 index 000000000..5ce7c5d43 --- /dev/null +++ b/resources/images/mask.go @@ -0,0 +1,63 @@ +package images + +import ( + "fmt" + "image" + "image/color" + "image/draw" + + "github.com/disintegration/gift" +) + +// maskFilter applies a mask image to a base image. +type maskFilter struct { + mask ImageSource +} + +// Draw applies the mask to the base image. +func (f maskFilter) Draw(dst draw.Image, baseImage image.Image, options *gift.Options) { + maskImage, err := f.mask.DecodeImage() + if err != nil { + panic(fmt.Sprintf("failed to decode image: %s", err)) + } + + // Ensure the mask is the same size as the base image + baseBounds := baseImage.Bounds() + maskBounds := maskImage.Bounds() + + // Resize mask to match base image size if necessary + if maskBounds.Dx() != baseBounds.Dx() || maskBounds.Dy() != baseBounds.Dy() { + g := gift.New(gift.Resize(baseBounds.Dx(), baseBounds.Dy(), gift.LanczosResampling)) + resizedMask := image.NewRGBA(g.Bounds(maskImage.Bounds())) + g.Draw(resizedMask, maskImage) + maskImage = resizedMask + } + + // Use gift to convert the resized mask to grayscale + g := gift.New(gift.Grayscale()) + grayscaleMask := image.NewGray(g.Bounds(maskImage.Bounds())) + g.Draw(grayscaleMask, maskImage) + + // Convert grayscale mask to alpha mask + alphaMask := image.NewAlpha(baseBounds) + for y := baseBounds.Min.Y; y < baseBounds.Max.Y; y++ { + for x := baseBounds.Min.X; x < baseBounds.Max.X; x++ { + grayValue := grayscaleMask.GrayAt(x, y).Y + alphaMask.SetAlpha(x, y, color.Alpha{A: grayValue}) + } + } + + // Create an RGBA output image + outputImage := image.NewRGBA(baseBounds) + + // Apply the mask using draw.DrawMask + draw.DrawMask(outputImage, baseBounds, baseImage, image.Point{}, alphaMask, image.Point{}, draw.Over) + + // Copy the result to the destination + gift.New().Draw(dst, outputImage) +} + +// Bounds returns the bounds of the resulting image. +func (f maskFilter) Bounds(imgBounds image.Rectangle) image.Rectangle { + return image.Rect(0, 0, imgBounds.Dx(), imgBounds.Dy()) +} diff --git a/resources/images/opacity.go b/resources/images/opacity.go new file mode 100644 index 000000000..482476c5b --- /dev/null +++ b/resources/images/opacity.go @@ -0,0 +1,39 @@ +// Copyright 2024 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 images + +import ( + "image" + "image/color" + "image/draw" + + "github.com/disintegration/gift" +) + +var _ gift.Filter = (*opacityFilter)(nil) + +type opacityFilter struct { + opacity float32 +} + +func (f opacityFilter) Draw(dst draw.Image, src image.Image, options *gift.Options) { + // 0 is fully transparent and 255 is opaque. + alpha := uint8(f.opacity * 255) + mask := image.NewUniform(color.Alpha{alpha}) + draw.DrawMask(dst, dst.Bounds(), src, image.Point{}, mask, image.Point{}, draw.Over) +} + +func (f opacityFilter) Bounds(srcBounds image.Rectangle) image.Rectangle { + return image.Rect(0, 0, srcBounds.Dx(), srcBounds.Dy()) +} diff --git a/resources/images/overlay.go b/resources/images/overlay.go new file mode 100644 index 000000000..780e28fd1 --- /dev/null +++ b/resources/images/overlay.go @@ -0,0 +1,43 @@ +// Copyright 2020 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 images + +import ( + "fmt" + "image" + "image/draw" + + "github.com/disintegration/gift" +) + +var _ gift.Filter = (*overlayFilter)(nil) + +type overlayFilter struct { + src ImageSource + x, y int +} + +func (f overlayFilter) Draw(dst draw.Image, src image.Image, options *gift.Options) { + overlaySrc, err := f.src.DecodeImage() + if err != nil { + panic(fmt.Sprintf("failed to decode image: %s", err)) + } + + gift.New().Draw(dst, src) + gift.New().DrawAt(dst, overlaySrc, image.Pt(f.x, f.y), gift.OverOperator) +} + +func (f overlayFilter) Bounds(srcBounds image.Rectangle) image.Rectangle { + return image.Rect(0, 0, srcBounds.Dx(), srcBounds.Dy()) +} diff --git a/resources/images/padding.go b/resources/images/padding.go new file mode 100644 index 000000000..4399312f8 --- /dev/null +++ b/resources/images/padding.go @@ -0,0 +1,50 @@ +// Copyright 2024 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 images + +import ( + "image" + "image/color" + "image/draw" + + "github.com/disintegration/gift" +) + +var _ gift.Filter = (*paddingFilter)(nil) + +type paddingFilter struct { + top, right, bottom, left int + ccolor color.Color // canvas color +} + +func (f paddingFilter) Draw(dst draw.Image, src image.Image, options *gift.Options) { + w := src.Bounds().Dx() + f.left + f.right + h := src.Bounds().Dy() + f.top + f.bottom + + if w < 1 { + panic("final image width will be less than 1 pixel: check padding values") + } + if h < 1 { + panic("final image height will be less than 1 pixel: check padding values") + } + + i := image.NewRGBA(image.Rect(0, 0, w, h)) + draw.Draw(i, i.Bounds(), image.NewUniform(f.ccolor), image.Point{}, draw.Src) + gift.New().Draw(dst, i) + gift.New().DrawAt(dst, src, image.Pt(f.left, f.top), gift.OverOperator) +} + +func (f paddingFilter) Bounds(srcBounds image.Rectangle) image.Rectangle { + return image.Rect(0, 0, srcBounds.Dx()+f.left+f.right, srcBounds.Dy()+f.top+f.bottom) +} diff --git a/resources/images/process.go b/resources/images/process.go new file mode 100644 index 000000000..fb2e995ce --- /dev/null +++ b/resources/images/process.go @@ -0,0 +1,43 @@ +// Copyright 2024 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 images + +import ( + "image" + "image/draw" + + "github.com/disintegration/gift" +) + +var _ ImageProcessSpecProvider = (*processFilter)(nil) + +type ImageProcessSpecProvider interface { + ImageProcessSpec() string +} + +type processFilter struct { + spec string +} + +func (f processFilter) Draw(dst draw.Image, src image.Image, options *gift.Options) { + panic("not supported") +} + +func (f processFilter) Bounds(srcBounds image.Rectangle) image.Rectangle { + panic("not supported") +} + +func (f processFilter) ImageProcessSpec() string { + return f.spec +} diff --git a/resources/images/smartcrop.go b/resources/images/smartcrop.go index e0181b671..af45c241c 100644 --- a/resources/images/smartcrop.go +++ b/resources/images/smartcrop.go @@ -15,6 +15,7 @@ package images import ( "image" + "math" "github.com/disintegration/gift" @@ -24,10 +25,10 @@ import ( const ( // Do not change. smartCropIdentifier = "smart" - - // This is just a increment, starting on 1. If Smart Crop improves its cropping, we + SmartCropAnchor = 1000 + // This is just a increment, starting on 0. If Smart Crop improves its cropping, we // need a way to trigger a re-generation of the crops in the wild, so increment this. - smartCropVersionNumber = 1 + smartCropVersionNumber = 0 ) func (p *ImageProcessor) newSmartCropAnalyzer(filter gift.Resampling) smartcrop.Analyzer { @@ -41,6 +42,14 @@ type imagingResizer struct { } func (r imagingResizer) Resize(img image.Image, width, height uint) image.Image { + // See https://github.com/gohugoio/hugo/issues/7955#issuecomment-861710681 + scaleX, scaleY := calcFactorsNfnt(width, height, float64(img.Bounds().Dx()), float64(img.Bounds().Dy())) + if width == 0 { + width = uint(math.Ceil(float64(img.Bounds().Dx()) / scaleX)) + } + if height == 0 { + height = uint(math.Ceil(float64(img.Bounds().Dy()) / scaleY)) + } result, _ := r.p.Filter(img, gift.Resize(int(width), int(height), r.filter)) return result } @@ -70,5 +79,26 @@ func (p *ImageProcessor) smartCrop(img image.Image, width, height int, filter gi } return img.Bounds().Intersect(rect), nil - +} + +// Calculates scaling factors using old and new image dimensions. +// Code borrowed from https://github.com/nfnt/resize/blob/83c6a9932646f83e3267f353373d47347b6036b2/resize.go#L593 +func calcFactorsNfnt(width, height uint, oldWidth, oldHeight float64) (scaleX, scaleY float64) { + if width == 0 { + if height == 0 { + scaleX = 1.0 + scaleY = 1.0 + } else { + scaleY = oldHeight / float64(height) + scaleX = scaleY + } + } else { + scaleX = oldWidth / float64(width) + if height == 0 { + scaleY = scaleX + } else { + scaleY = oldHeight / float64(height) + } + } + return } diff --git a/resources/images/testdata/images_golden/filters/mask/blue.jpg b/resources/images/testdata/images_golden/filters/mask/blue.jpg new file mode 100644 index 000000000..7c8097741 Binary files /dev/null and b/resources/images/testdata/images_golden/filters/mask/blue.jpg differ diff --git a/resources/images/testdata/images_golden/filters/mask/transparant.png b/resources/images/testdata/images_golden/filters/mask/transparant.png new file mode 100644 index 000000000..4d8c57ace Binary files /dev/null and b/resources/images/testdata/images_golden/filters/mask/transparant.png differ diff --git a/resources/images/testdata/images_golden/filters/mask/wide.jpg b/resources/images/testdata/images_golden/filters/mask/wide.jpg new file mode 100644 index 000000000..38ef715ba Binary files /dev/null and b/resources/images/testdata/images_golden/filters/mask/wide.jpg differ diff --git a/resources/images/testdata/images_golden/filters/mask/yellow.jpg b/resources/images/testdata/images_golden/filters/mask/yellow.jpg new file mode 100644 index 000000000..e7b3073db Binary files /dev/null and b/resources/images/testdata/images_golden/filters/mask/yellow.jpg differ diff --git a/resources/images/testdata/images_golden/filters/mask2/green.jpg b/resources/images/testdata/images_golden/filters/mask2/green.jpg new file mode 100644 index 000000000..48a9dd083 Binary files /dev/null and b/resources/images/testdata/images_golden/filters/mask2/green.jpg differ diff --git a/resources/images/testdata/images_golden/filters/mask2/pink.jpg b/resources/images/testdata/images_golden/filters/mask2/pink.jpg new file mode 100644 index 000000000..640e41ab1 Binary files /dev/null and b/resources/images/testdata/images_golden/filters/mask2/pink.jpg differ diff --git a/resources/images/testdata/images_golden/filters/misc/brightness-40.jpg b/resources/images/testdata/images_golden/filters/misc/brightness-40.jpg new file mode 100644 index 000000000..92d03e2f1 Binary files /dev/null and b/resources/images/testdata/images_golden/filters/misc/brightness-40.jpg differ diff --git a/resources/images/testdata/images_golden/filters/misc/colorbalance-180-50-20.jpg b/resources/images/testdata/images_golden/filters/misc/colorbalance-180-50-20.jpg new file mode 100644 index 000000000..1f34922eb Binary files /dev/null and b/resources/images/testdata/images_golden/filters/misc/colorbalance-180-50-20.jpg differ diff --git a/resources/images/testdata/images_golden/filters/misc/contrast-50.jpg b/resources/images/testdata/images_golden/filters/misc/contrast-50.jpg new file mode 100644 index 000000000..24a064338 Binary files /dev/null and b/resources/images/testdata/images_golden/filters/misc/contrast-50.jpg differ diff --git a/resources/images/testdata/images_golden/filters/misc/dither-default.jpg b/resources/images/testdata/images_golden/filters/misc/dither-default.jpg new file mode 100644 index 000000000..3960f94b2 Binary files /dev/null and b/resources/images/testdata/images_golden/filters/misc/dither-default.jpg differ diff --git a/resources/images/testdata/images_golden/filters/misc/gamma-1.667.jpg b/resources/images/testdata/images_golden/filters/misc/gamma-1.667.jpg new file mode 100644 index 000000000..e8fcbe753 Binary files /dev/null and b/resources/images/testdata/images_golden/filters/misc/gamma-1.667.jpg differ diff --git a/resources/images/testdata/images_golden/filters/misc/gaussianblur-5.jpg b/resources/images/testdata/images_golden/filters/misc/gaussianblur-5.jpg new file mode 100644 index 000000000..36783bb6f Binary files /dev/null and b/resources/images/testdata/images_golden/filters/misc/gaussianblur-5.jpg differ diff --git a/resources/images/testdata/images_golden/filters/misc/grayscale+colorize-180-50-20.jpg b/resources/images/testdata/images_golden/filters/misc/grayscale+colorize-180-50-20.jpg new file mode 100644 index 000000000..4902922b3 Binary files /dev/null and b/resources/images/testdata/images_golden/filters/misc/grayscale+colorize-180-50-20.jpg differ diff --git a/resources/images/testdata/images_golden/filters/misc/grayscale.jpg b/resources/images/testdata/images_golden/filters/misc/grayscale.jpg new file mode 100644 index 000000000..06617ee00 Binary files /dev/null and b/resources/images/testdata/images_golden/filters/misc/grayscale.jpg differ diff --git a/resources/images/testdata/images_golden/filters/misc/hue--15.jpg b/resources/images/testdata/images_golden/filters/misc/hue--15.jpg new file mode 100644 index 000000000..68b191ec2 Binary files /dev/null and b/resources/images/testdata/images_golden/filters/misc/hue--15.jpg differ diff --git a/resources/images/testdata/images_golden/filters/misc/invert.jpg b/resources/images/testdata/images_golden/filters/misc/invert.jpg new file mode 100644 index 000000000..69ab0fc1b Binary files /dev/null and b/resources/images/testdata/images_golden/filters/misc/invert.jpg differ diff --git a/resources/images/testdata/images_golden/filters/misc/opacity-0.65.jpg b/resources/images/testdata/images_golden/filters/misc/opacity-0.65.jpg new file mode 100644 index 000000000..6da3c980e Binary files /dev/null and b/resources/images/testdata/images_golden/filters/misc/opacity-0.65.jpg differ diff --git a/resources/images/testdata/images_golden/filters/misc/overlay-20-20.jpg b/resources/images/testdata/images_golden/filters/misc/overlay-20-20.jpg new file mode 100644 index 000000000..3a6ca0b30 Binary files /dev/null and b/resources/images/testdata/images_golden/filters/misc/overlay-20-20.jpg differ diff --git a/resources/images/testdata/images_golden/filters/misc/padding-20-40-#976941.jpg b/resources/images/testdata/images_golden/filters/misc/padding-20-40-#976941.jpg new file mode 100644 index 000000000..14a443f9a Binary files /dev/null and b/resources/images/testdata/images_golden/filters/misc/padding-20-40-#976941.jpg differ diff --git a/resources/images/testdata/images_golden/filters/misc/pixelate-10.jpg b/resources/images/testdata/images_golden/filters/misc/pixelate-10.jpg new file mode 100644 index 000000000..094de575e Binary files /dev/null and b/resources/images/testdata/images_golden/filters/misc/pixelate-10.jpg differ diff --git a/resources/images/testdata/images_golden/filters/misc/rotate270.jpg b/resources/images/testdata/images_golden/filters/misc/rotate270.jpg new file mode 100644 index 000000000..3a5b2483a Binary files /dev/null and b/resources/images/testdata/images_golden/filters/misc/rotate270.jpg differ diff --git a/resources/images/testdata/images_golden/filters/misc/saturation-65.jpg b/resources/images/testdata/images_golden/filters/misc/saturation-65.jpg new file mode 100644 index 000000000..d26585e66 Binary files /dev/null and b/resources/images/testdata/images_golden/filters/misc/saturation-65.jpg differ diff --git a/resources/images/testdata/images_golden/filters/misc/sepia-80.jpg b/resources/images/testdata/images_golden/filters/misc/sepia-80.jpg new file mode 100644 index 000000000..76d08ad8c Binary files /dev/null and b/resources/images/testdata/images_golden/filters/misc/sepia-80.jpg differ diff --git a/resources/images/testdata/images_golden/filters/misc/sigmoid-0.6--4.jpg b/resources/images/testdata/images_golden/filters/misc/sigmoid-0.6--4.jpg new file mode 100644 index 000000000..c6df06715 Binary files /dev/null and b/resources/images/testdata/images_golden/filters/misc/sigmoid-0.6--4.jpg differ diff --git a/resources/images/testdata/images_golden/filters/misc/text.jpg b/resources/images/testdata/images_golden/filters/misc/text.jpg new file mode 100644 index 000000000..20e79dbad Binary files /dev/null and b/resources/images/testdata/images_golden/filters/misc/text.jpg differ diff --git a/resources/images/testdata/images_golden/filters/misc/unsharpmask.jpg b/resources/images/testdata/images_golden/filters/misc/unsharpmask.jpg new file mode 100644 index 000000000..9b04b6701 Binary files /dev/null and b/resources/images/testdata/images_golden/filters/misc/unsharpmask.jpg differ diff --git a/resources/images/testdata/images_golden/filters/text/text_alignx-center.jpg b/resources/images/testdata/images_golden/filters/text/text_alignx-center.jpg new file mode 100644 index 000000000..94bcb811a Binary files /dev/null and b/resources/images/testdata/images_golden/filters/text/text_alignx-center.jpg differ diff --git a/resources/images/testdata/images_golden/filters/text/text_alignx-center_aligny-bottom.jpg b/resources/images/testdata/images_golden/filters/text/text_alignx-center_aligny-bottom.jpg new file mode 100644 index 000000000..ca8893e4e Binary files /dev/null and b/resources/images/testdata/images_golden/filters/text/text_alignx-center_aligny-bottom.jpg differ diff --git a/resources/images/testdata/images_golden/filters/text/text_alignx-center_aligny-center.jpg b/resources/images/testdata/images_golden/filters/text/text_alignx-center_aligny-center.jpg new file mode 100644 index 000000000..828b6e6a3 Binary files /dev/null and b/resources/images/testdata/images_golden/filters/text/text_alignx-center_aligny-center.jpg differ diff --git a/resources/images/testdata/images_golden/filters/text/text_alignx-left.jpg b/resources/images/testdata/images_golden/filters/text/text_alignx-left.jpg new file mode 100644 index 000000000..2894fae42 Binary files /dev/null and b/resources/images/testdata/images_golden/filters/text/text_alignx-left.jpg differ diff --git a/resources/images/testdata/images_golden/filters/text/text_alignx-right.jpg b/resources/images/testdata/images_golden/filters/text/text_alignx-right.jpg new file mode 100644 index 000000000..207e88a49 Binary files /dev/null and b/resources/images/testdata/images_golden/filters/text/text_alignx-right.jpg differ diff --git a/resources/images/testdata/images_golden/methods/crop-sunsetjpg-200x200.jpg b/resources/images/testdata/images_golden/methods/crop-sunsetjpg-200x200.jpg new file mode 100644 index 000000000..10569ab2c Binary files /dev/null and b/resources/images/testdata/images_golden/methods/crop-sunsetjpg-200x200.jpg differ diff --git a/resources/images/testdata/images_golden/methods/crop-sunsetjpg-350x400-center-q20.jpg b/resources/images/testdata/images_golden/methods/crop-sunsetjpg-350x400-center-q20.jpg new file mode 100644 index 000000000..1b316ff17 Binary files /dev/null and b/resources/images/testdata/images_golden/methods/crop-sunsetjpg-350x400-center-q20.jpg differ diff --git a/resources/images/testdata/images_golden/methods/crop-sunsetjpg-350x400-center-r90.jpg b/resources/images/testdata/images_golden/methods/crop-sunsetjpg-350x400-center-r90.jpg new file mode 100644 index 000000000..9e3bc4bf5 Binary files /dev/null and b/resources/images/testdata/images_golden/methods/crop-sunsetjpg-350x400-center-r90.jpg differ diff --git a/resources/images/testdata/images_golden/methods/crop-sunsetjpg-350x400-center.jpg b/resources/images/testdata/images_golden/methods/crop-sunsetjpg-350x400-center.jpg new file mode 100644 index 000000000..aa89eedf2 Binary files /dev/null and b/resources/images/testdata/images_golden/methods/crop-sunsetjpg-350x400-center.jpg differ diff --git a/resources/images/testdata/images_golden/methods/crop-sunsetjpg-350x400-smart.jpg b/resources/images/testdata/images_golden/methods/crop-sunsetjpg-350x400-smart.jpg new file mode 100644 index 000000000..7a203920e Binary files /dev/null and b/resources/images/testdata/images_golden/methods/crop-sunsetjpg-350x400-smart.jpg differ diff --git a/resources/images/testdata/images_golden/methods/fill-sunsetjpg-90x120-left.jpg b/resources/images/testdata/images_golden/methods/fill-sunsetjpg-90x120-left.jpg new file mode 100644 index 000000000..39f1823f5 Binary files /dev/null and b/resources/images/testdata/images_golden/methods/fill-sunsetjpg-90x120-left.jpg differ diff --git a/resources/images/testdata/images_golden/methods/fill-sunsetjpg-90x120-right.jpg b/resources/images/testdata/images_golden/methods/fill-sunsetjpg-90x120-right.jpg new file mode 100644 index 000000000..4afcb6dec Binary files /dev/null and b/resources/images/testdata/images_golden/methods/fill-sunsetjpg-90x120-right.jpg differ diff --git a/resources/images/testdata/images_golden/methods/fit-sunsetjpg-200x200.jpg b/resources/images/testdata/images_golden/methods/fit-sunsetjpg-200x200.jpg new file mode 100644 index 000000000..75c6a30b0 Binary files /dev/null and b/resources/images/testdata/images_golden/methods/fit-sunsetjpg-200x200.jpg differ diff --git a/resources/images/testdata/images_golden/methods/resize-gopherpng-100x-03fc56-jpg.jpg b/resources/images/testdata/images_golden/methods/resize-gopherpng-100x-03fc56-jpg.jpg new file mode 100644 index 000000000..2e78ce461 Binary files /dev/null and b/resources/images/testdata/images_golden/methods/resize-gopherpng-100x-03fc56-jpg.jpg differ diff --git a/resources/images/testdata/images_golden/methods/resize-gopherpng-100x-fc03ec.png b/resources/images/testdata/images_golden/methods/resize-gopherpng-100x-fc03ec.png new file mode 100644 index 000000000..44af25303 Binary files /dev/null and b/resources/images/testdata/images_golden/methods/resize-gopherpng-100x-fc03ec.png differ diff --git a/resources/images/testdata/images_golden/methods/resize-gopherpng-100x.png b/resources/images/testdata/images_golden/methods/resize-gopherpng-100x.png new file mode 100644 index 000000000..9bca47b14 Binary files /dev/null and b/resources/images/testdata/images_golden/methods/resize-gopherpng-100x.png differ diff --git a/resources/images/testdata/images_golden/methods/resize-sunsetjpg-300x.jpg b/resources/images/testdata/images_golden/methods/resize-sunsetjpg-300x.jpg new file mode 100644 index 000000000..319385e9a Binary files /dev/null and b/resources/images/testdata/images_golden/methods/resize-sunsetjpg-300x.jpg differ diff --git a/resources/images/testdata/images_golden/methods/resize-sunsetjpg-x200.jpg b/resources/images/testdata/images_golden/methods/resize-sunsetjpg-x200.jpg new file mode 100644 index 000000000..77b2555cb Binary files /dev/null and b/resources/images/testdata/images_golden/methods/resize-sunsetjpg-x200.jpg differ diff --git a/resources/images/testdata/images_golden/process/misc/crop-500x200-smart.jpg b/resources/images/testdata/images_golden/process/misc/crop-500x200-smart.jpg new file mode 100644 index 000000000..6df66064b Binary files /dev/null and b/resources/images/testdata/images_golden/process/misc/crop-500x200-smart.jpg differ diff --git a/resources/images/testdata/images_golden/process/misc/fill-500x200-smart.jpg b/resources/images/testdata/images_golden/process/misc/fill-500x200-smart.jpg new file mode 100644 index 000000000..cad8927b5 Binary files /dev/null and b/resources/images/testdata/images_golden/process/misc/fill-500x200-smart.jpg differ diff --git a/resources/images/testdata/images_golden/process/misc/fit-500x200-smart.jpg b/resources/images/testdata/images_golden/process/misc/fit-500x200-smart.jpg new file mode 100644 index 000000000..a320cda99 Binary files /dev/null and b/resources/images/testdata/images_golden/process/misc/fit-500x200-smart.jpg differ diff --git a/resources/images/testdata/images_golden/process/misc/resize-100x100-gif.gif b/resources/images/testdata/images_golden/process/misc/resize-100x100-gif.gif new file mode 100644 index 000000000..66a6b73de Binary files /dev/null and b/resources/images/testdata/images_golden/process/misc/resize-100x100-gif.gif differ diff --git a/resources/images/testdata/images_golden/process/misc/resize-100x100-r180.png b/resources/images/testdata/images_golden/process/misc/resize-100x100-r180.png new file mode 100644 index 000000000..ec5042e67 Binary files /dev/null and b/resources/images/testdata/images_golden/process/misc/resize-100x100-r180.png differ diff --git a/resources/images/testdata/images_golden/process/misc/resize-300x300-jpg-b31280.jpg b/resources/images/testdata/images_golden/process/misc/resize-300x300-jpg-b31280.jpg new file mode 100644 index 000000000..1000d8f34 Binary files /dev/null and b/resources/images/testdata/images_golden/process/misc/resize-300x300-jpg-b31280.jpg differ diff --git a/resources/images/text.go b/resources/images/text.go new file mode 100644 index 000000000..f3943a475 --- /dev/null +++ b/resources/images/text.go @@ -0,0 +1,150 @@ +// Copyright 2021 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 images + +import ( + "image" + "image/color" + "image/draw" + "io" + "strings" + + "github.com/disintegration/gift" + "github.com/gohugoio/hugo/common/hugio" + + "golang.org/x/image/font" + "golang.org/x/image/font/gofont/goregular" + "golang.org/x/image/font/opentype" + "golang.org/x/image/math/fixed" +) + +var _ gift.Filter = (*textFilter)(nil) + +type textFilter struct { + text string + color color.Color + x, y int + alignx string + aligny string + size float64 + linespacing int + fontSource hugio.ReadSeekCloserProvider +} + +func (f textFilter) Draw(dst draw.Image, src image.Image, options *gift.Options) { + // Load and parse font + ttf := goregular.TTF + if f.fontSource != nil { + rs, err := f.fontSource.ReadSeekCloser() + if err != nil { + panic(err) + } + defer rs.Close() + ttf, err = io.ReadAll(rs) + if err != nil { + panic(err) + } + } + otf, err := opentype.Parse(ttf) + if err != nil { + panic(err) + } + + // Set font options + face, err := opentype.NewFace(otf, &opentype.FaceOptions{ + Size: f.size, + DPI: 72, + Hinting: font.HintingNone, + }) + if err != nil { + panic(err) + } + + d := font.Drawer{ + Dst: dst, + Src: image.NewUniform(f.color), + Face: face, + } + + gift.New().Draw(dst, src) + + maxWidth := dst.Bounds().Dx() - 20 + + var availableWidth int + switch f.alignx { + case "right": + availableWidth = f.x + case "center": + availableWidth = min((maxWidth-f.x), f.x) * 2 + case "left": + availableWidth = maxWidth - f.x + } + + fontHeight := face.Metrics().Ascent.Ceil() + + // Calculate lines, consider and include linebreaks + finalLines := []string{} + f.text = strings.ReplaceAll(f.text, "\r", "") + for _, line := range strings.Split(f.text, "\n") { + currentLine := "" + // Break each line at the maximum width. + for _, str := range strings.Fields(line) { + fieldStrWidth := font.MeasureString(face, str) + currentLineStrWidth := font.MeasureString(face, currentLine) + + if (currentLineStrWidth.Ceil() + fieldStrWidth.Ceil()) >= availableWidth { + finalLines = append(finalLines, currentLine) + currentLine = "" + } + currentLine += str + " " + } + finalLines = append(finalLines, currentLine) + } + // Total height of the text from the top of the first line to the baseline of the last line + totalHeight := len(finalLines)*fontHeight + (len(finalLines)-1)*f.linespacing + + // Correct y position based on font and size + y := f.y + fontHeight + switch f.aligny { + case "top": + // Do nothing + case "center": + y = y - totalHeight/2 + case "bottom": + y = y - totalHeight + } + + // Draw text line by line + for _, line := range finalLines { + line = strings.TrimSpace(line) + strWidth := font.MeasureString(face, line) + var x int + switch f.alignx { + case "right": + x = f.x - strWidth.Ceil() + case "center": + x = f.x - (strWidth.Ceil() / 2) + + case "left": + x = f.x + } + d.Dot = fixed.P(x, y) + d.DrawString(line) + y = y + fontHeight + f.linespacing + } +} + +func (f textFilter) Bounds(srcBounds image.Rectangle) image.Rectangle { + return image.Rect(0, 0, srcBounds.Dx(), srcBounds.Dy()) +} diff --git a/resources/images/webp/webp.go b/resources/images/webp/webp.go new file mode 100644 index 000000000..d7407214f --- /dev/null +++ b/resources/images/webp/webp.go @@ -0,0 +1,35 @@ +// Copyright 2021 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. + +//go:build extended + +package webp + +import ( + "image" + "io" + + "github.com/bep/gowebp/libwebp" + "github.com/bep/gowebp/libwebp/webpoptions" +) + +// Encode writes the Image m to w in Webp format with the given +// options. +func Encode(w io.Writer, m image.Image, o webpoptions.EncodingOptions) error { + return libwebp.Encode(w, m, o) +} + +// Supports returns whether webp encoding is supported in this build. +func Supports() bool { + return true +} diff --git a/resources/images/webp/webp_notavailable.go b/resources/images/webp/webp_notavailable.go new file mode 100644 index 000000000..b617eeb51 --- /dev/null +++ b/resources/images/webp/webp_notavailable.go @@ -0,0 +1,35 @@ +// Copyright 2021 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. + +//go:build !extended + +package webp + +import ( + "image" + "io" + + "github.com/gohugoio/hugo/common/herrors" + + "github.com/bep/gowebp/libwebp/webpoptions" +) + +// Encode is only available in the extended version. +func Encode(w io.Writer, m image.Image, o webpoptions.EncodingOptions) error { + return herrors.ErrFeatureNotAvailable +} + +// Supports returns whether webp encoding is supported in this build. +func Supports() bool { + return false +} diff --git a/resources/internal/key.go b/resources/internal/key.go index d67d4a7e1..b0ac9703f 100644 --- a/resources/internal/key.go +++ b/resources/internal/key.go @@ -13,7 +13,7 @@ package internal -import "github.com/gohugoio/hugo/helpers" +import "github.com/gohugoio/hugo/common/hashing" // ResourceTransformationKey are provided by the different transformation implementations. // It identifies the transformation (name) and its configuration (elements). @@ -21,13 +21,13 @@ import "github.com/gohugoio/hugo/helpers" // with the target filename and a content hash of the origin to use as cache key. type ResourceTransformationKey struct { Name string - elements []interface{} + elements []any } // NewResourceTransformationKey creates a new ResourceTransformationKey from the transformation // name and elements. We will create a 64 bit FNV hash from the elements, which when combined // with the other key elements should be unique for all practical applications. -func NewResourceTransformationKey(name string, elements ...interface{}) ResourceTransformationKey { +func NewResourceTransformationKey(name string, elements ...any) ResourceTransformationKey { return ResourceTransformationKey{Name: name, elements: elements} } @@ -38,6 +38,5 @@ func (k ResourceTransformationKey) Value() string { return k.Name } - return k.Name + "_" + helpers.HashString(k.elements...) - + return k.Name + "_" + hashing.HashString(k.elements...) } diff --git a/resources/internal/key_test.go b/resources/internal/key_test.go index 38286333d..fcad7d754 100644 --- a/resources/internal/key_test.go +++ b/resources/internal/key_test.go @@ -32,5 +32,5 @@ func TestResourceTransformationKey(t *testing.T) { key := NewResourceTransformationKey("testing", testStruct{Name: "test", V1: int64(10), V2: int32(20), V3: 30, V4: uint64(40)}) c := qt.New(t) - c.Assert(key.Value(), qt.Equals, "testing_518996646957295636") + c.Assert(key.Value(), qt.Equals, "testing_4231238781487357822") } diff --git a/resources/internal/resourcepaths.go b/resources/internal/resourcepaths.go new file mode 100644 index 000000000..71d3538dd --- /dev/null +++ b/resources/internal/resourcepaths.go @@ -0,0 +1,107 @@ +// Copyright 2024 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 internal + +import ( + "path" + "path/filepath" + "strings" + + "github.com/gohugoio/hugo/common/paths" +) + +// ResourcePaths holds path information for a resource. +// All directories in here have Unix-style slashes, with leading slash, but no trailing slash. +// Empty directories are represented with an empty string. +type ResourcePaths struct { + // This is the directory component for the target file or link. + Dir string + + // Any base directory for the target file. Will be prepended to Dir. + BaseDirTarget string + + // This is the directory component for the link will be prepended to Dir. + BaseDirLink string + + // Set when publishing in a multihost setup. + TargetBasePaths []string + + // This is the File component, e.g. "data.json". + File string +} + +func (d ResourcePaths) join(p ...string) string { + var s string + for i, pp := range p { + if pp == "" { + continue + } + if i > 0 && !strings.HasPrefix(pp, "/") { + pp = "/" + pp + } + s += pp + + } + if !strings.HasPrefix(s, "/") { + s = "/" + s + } + return s +} + +func (d ResourcePaths) TargetLink() string { + return d.join(d.BaseDirLink, d.Dir, d.File) +} + +func (d ResourcePaths) TargetPath() string { + return d.join(d.BaseDirTarget, d.Dir, d.File) +} + +func (d ResourcePaths) Path() string { + return d.join(d.Dir, d.File) +} + +func (d ResourcePaths) TargetPaths() []string { + if len(d.TargetBasePaths) == 0 { + return []string{d.TargetPath()} + } + + var paths []string + for _, p := range d.TargetBasePaths { + paths = append(paths, p+d.TargetPath()) + } + return paths +} + +func (d ResourcePaths) TargetFilenames() []string { + filenames := d.TargetPaths() + for i, p := range filenames { + filenames[i] = filepath.FromSlash(p) + } + return filenames +} + +func (d ResourcePaths) FromTargetPath(targetPath string) ResourcePaths { + targetPath = filepath.ToSlash(targetPath) + dir, file := path.Split(targetPath) + dir = paths.ToSlashPreserveLeading(dir) + if dir == "/" { + dir = "" + } + d.Dir = dir + d.File = file + d.BaseDirLink = "" + d.BaseDirTarget = "" + + return d +} diff --git a/resources/jsconfig/jsconfig.go b/resources/jsconfig/jsconfig.go new file mode 100644 index 000000000..b6e867995 --- /dev/null +++ b/resources/jsconfig/jsconfig.go @@ -0,0 +1,92 @@ +// Copyright 2020 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 jsconfig + +import ( + "path/filepath" + "sort" + "sync" +) + +// Builder builds a jsconfig.json file that, currently, is used only to assist +// IntelliSense in editors. +type Builder struct { + sourceRootsMu sync.RWMutex + sourceRoots map[string]bool +} + +// NewBuilder creates a new Builder. +func NewBuilder() *Builder { + return &Builder{sourceRoots: make(map[string]bool)} +} + +// Build builds a new Config with paths relative to dir. +// This method is thread safe. +func (b *Builder) Build(dir string) *Config { + b.sourceRootsMu.RLock() + defer b.sourceRootsMu.RUnlock() + + if len(b.sourceRoots) == 0 { + return nil + } + conf := newJSConfig() + + var roots []string + for root := range b.sourceRoots { + rel, err := filepath.Rel(dir, filepath.Join(root, "*")) + if err == nil { + roots = append(roots, rel) + } + } + sort.Strings(roots) + conf.CompilerOptions.Paths["*"] = roots + + return conf +} + +// AddSourceRoot adds a new source root. +// This method is thread safe. +func (b *Builder) AddSourceRoot(root string) { + b.sourceRootsMu.RLock() + found := b.sourceRoots[root] + b.sourceRootsMu.RUnlock() + + if found { + return + } + + b.sourceRootsMu.Lock() + b.sourceRoots[root] = true + b.sourceRootsMu.Unlock() +} + +// CompilerOptions holds compilerOptions for jsonconfig.json. +type CompilerOptions struct { + BaseURL string `json:"baseUrl"` + Paths map[string][]string `json:"paths"` +} + +// Config holds the data for jsconfig.json. +type Config struct { + CompilerOptions CompilerOptions `json:"compilerOptions"` +} + +func newJSConfig() *Config { + return &Config{ + CompilerOptions: CompilerOptions{ + BaseURL: ".", + Paths: make(map[string][]string), + }, + } +} diff --git a/resources/jsconfig/jsconfig_test.go b/resources/jsconfig/jsconfig_test.go new file mode 100644 index 000000000..9a9657843 --- /dev/null +++ b/resources/jsconfig/jsconfig_test.go @@ -0,0 +1,35 @@ +// Copyright 2020 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 jsconfig + +import ( + "path/filepath" + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestJsConfigBuilder(t *testing.T) { + c := qt.New(t) + + b := NewBuilder() + b.AddSourceRoot("/c/assets") + b.AddSourceRoot("/d/assets") + + conf := b.Build("/a/b") + c.Assert(conf.CompilerOptions.BaseURL, qt.Equals, ".") + c.Assert(conf.CompilerOptions.Paths["*"], qt.DeepEquals, []string{filepath.FromSlash("../../c/assets/*"), filepath.FromSlash("../../d/assets/*")}) + + c.Assert(NewBuilder().Build("/a/b"), qt.IsNil) +} diff --git a/resources/kinds/kinds.go b/resources/kinds/kinds.go new file mode 100644 index 000000000..30bc35e43 --- /dev/null +++ b/resources/kinds/kinds.go @@ -0,0 +1,119 @@ +// Copyright 2024 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 kinds + +import ( + "sort" + "strings" +) + +const ( + KindPage = "page" + + // The rest are node types; home page, sections etc. + + KindHome = "home" + KindSection = "section" + + // Note that before Hugo 0.73 these were confusingly named + // taxonomy (now: term) + // taxonomyTerm (now: taxonomy) + KindTaxonomy = "taxonomy" + KindTerm = "term" + + // The following are (currently) temporary nodes, + // i.e. nodes we create just to render in isolation. + KindTemporary = "temporary" + KindRSS = "rss" + KindSitemap = "sitemap" + KindSitemapIndex = "sitemapindex" + KindRobotsTXT = "robotstxt" + KindStatus404 = "404" +) + +var ( + // This is all the kinds we can expect to find in .Site.Pages. + AllKindsInPages []string + // This is all the kinds, including the temporary ones. + AllKinds []string +) + +func init() { + for k := range kindMapMain { + AllKindsInPages = append(AllKindsInPages, k) + AllKinds = append(AllKinds, k) + } + + for k := range kindMapTemporary { + AllKinds = append(AllKinds, k) + } + + // Sort the slices for determinism. + sort.Strings(AllKindsInPages) + sort.Strings(AllKinds) +} + +var kindMapMain = map[string]string{ + KindPage: KindPage, + KindHome: KindHome, + KindSection: KindSection, + KindTaxonomy: KindTaxonomy, + KindTerm: KindTerm, + + // Legacy, pre v0.53.0. + "taxonomyterm": KindTaxonomy, +} + +var kindMapTemporary = map[string]string{ + KindRSS: KindRSS, + KindSitemap: KindSitemap, + KindRobotsTXT: KindRobotsTXT, + KindStatus404: KindStatus404, +} + +// GetKindMain gets the page kind given a string, empty if not found. +// Note that this will not return any temporary kinds (e.g. robotstxt). +func GetKindMain(s string) string { + return kindMapMain[strings.ToLower(s)] +} + +// GetKindAny gets the page kind given a string, empty if not found. +func GetKindAny(s string) string { + if pkind := GetKindMain(s); pkind != "" { + return pkind + } + return kindMapTemporary[strings.ToLower(s)] +} + +// IsBranch returns whether the given kind is a branch node. +func IsBranch(kind string) bool { + switch kind { + case KindHome, KindSection, KindTaxonomy, KindTerm: + return true + default: + return false + } +} + +// IsDeprecatedAndReplacedWith returns the new kind if the given kind is deprecated. +func IsDeprecatedAndReplacedWith(s string) string { + s = strings.ToLower(s) + + switch s { + case "taxonomyterm": + return KindTaxonomy + default: + return "" + } +} diff --git a/resources/kinds/kinds_test.go b/resources/kinds/kinds_test.go new file mode 100644 index 000000000..a0fe42ff8 --- /dev/null +++ b/resources/kinds/kinds_test.go @@ -0,0 +1,40 @@ +// Copyright 2024 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 kinds + +import ( + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestKind(t *testing.T) { + t.Parallel() + c := qt.New(t) + // Add tests for these constants to make sure they don't change + c.Assert(KindPage, qt.Equals, "page") + c.Assert(KindHome, qt.Equals, "home") + c.Assert(KindSection, qt.Equals, "section") + c.Assert(KindTaxonomy, qt.Equals, "taxonomy") + c.Assert(KindTerm, qt.Equals, "term") + + c.Assert(GetKindMain("TAXONOMYTERM"), qt.Equals, KindTaxonomy) + c.Assert(GetKindMain("Taxonomy"), qt.Equals, KindTaxonomy) + c.Assert(GetKindMain("Page"), qt.Equals, KindPage) + c.Assert(GetKindMain("Home"), qt.Equals, KindHome) + c.Assert(GetKindMain("SEction"), qt.Equals, KindSection) + + c.Assert(GetKindAny("Page"), qt.Equals, KindPage) + c.Assert(GetKindAny("Robotstxt"), qt.Equals, KindRobotsTXT) +} diff --git a/resources/page/page.go b/resources/page/page.go index 28094a4a9..cbcfad557 100644 --- a/resources/page/page.go +++ b/resources/page/page.go @@ -16,14 +16,17 @@ package page import ( + "context" + "fmt" "html/template" - "github.com/bep/gitmap" + "github.com/gohugoio/hugo/markup/converter" + "github.com/gohugoio/hugo/markup/tableofcontents" + "github.com/gohugoio/hugo/config" - "github.com/gohugoio/hugo/common/hugo" "github.com/gohugoio/hugo/common/maps" - + "github.com/gohugoio/hugo/common/paths" "github.com/gohugoio/hugo/compare" "github.com/gohugoio/hugo/navigation" @@ -48,41 +51,75 @@ type AlternativeOutputFormatsProvider interface { AlternativeOutputFormats() OutputFormats } -// AuthorProvider provides author information. -type AuthorProvider interface { - Author() Author - Authors() AuthorList -} - // ChildCareProvider provides accessors to child resources. type ChildCareProvider interface { + // Pages returns a list of pages of all kinds. Pages() Pages // RegularPages returns a list of pages of kind 'Page'. - // In Hugo 0.57 we changed the Pages method so it returns all page - // kinds, even sections. If you want the old behaviour, you can - // use RegularPages. RegularPages() Pages - Resources() resource.Resources + // RegularPagesRecursive returns all regular pages below the current + // section. + RegularPagesRecursive() Pages + + resource.ResourcesProvider +} + +type MarkupProvider interface { + Markup(opts ...any) Markup } // ContentProvider provides the content related values for a Page. type ContentProvider interface { - Content() (interface{}, error) - Plain() string - PlainWords() []string - Summary() template.HTML - Truncated() bool - FuzzyWordCount() int - WordCount() int - ReadingTime() int - Len() int + Content(context.Context) (any, error) + + // ContentWithoutSummary returns the Page Content stripped of the summary. + ContentWithoutSummary(ctx context.Context) (template.HTML, error) + + // Plain returns the Page Content stripped of HTML markup. + Plain(context.Context) string + + // PlainWords returns a string slice from splitting Plain using https://pkg.go.dev/strings#Fields. + PlainWords(context.Context) []string + + // Summary returns a generated summary of the content. + // The breakpoint can be set manually by inserting a summary separator in the source file. + Summary(context.Context) template.HTML + + // Truncated returns whether the Summary is truncated or not. + Truncated(context.Context) bool + + // FuzzyWordCount returns the approximate number of words in the content. + FuzzyWordCount(context.Context) int + + // WordCount returns the number of words in the content. + WordCount(context.Context) int + + // ReadingTime returns the reading time based on the length of plain text. + ReadingTime(context.Context) int + + // Len returns the length of the content. + // This is for internal use only. + Len(context.Context) int +} + +// ContentRenderer provides the content rendering methods for some content. +type ContentRenderer interface { + // ParseAndRenderContent renders the given content. + // For internal use only. + ParseAndRenderContent(ctx context.Context, content []byte, enableTOC bool) (converter.ResultRender, error) + // For internal use only. + ParseContent(ctx context.Context, content []byte) (converter.ResultParse, bool, error) + // For internal use only. + RenderContent(ctx context.Context, content []byte, doc any) (converter.ResultRender, bool, error) } // FileProvider provides the source file. type FileProvider interface { - File() source.File + // File returns the source file for this Page, + // or a zero File if this Page is not backed by a file. + File() *source.File } // GetPageProvider provides the GetPage method. @@ -97,30 +134,55 @@ type GetPageProvider interface { // GitInfoProvider provides Git info. type GitInfoProvider interface { - GitInfo() *gitmap.GitInfo + // GitInfo returns the Git info for this object. + GitInfo() source.GitInfo + // CodeOwners returns the code owners for this object. + CodeOwners() []string } // InSectionPositioner provides section navigation. type InSectionPositioner interface { + // NextInSection returns the next page in the same section. NextInSection() Page + // PrevInSection returns the previous page in the same section. PrevInSection() Page } -// InternalDependencies is considered an internal interface. -type InternalDependencies interface { - GetRelatedDocsHandler() *RelatedDocsHandler +// RelatedDocsHandlerProvider is considered an internal interface. +type RelatedDocsHandlerProvider interface { + // GetInternalRelatedDocsHandler is for internal use only. + GetInternalRelatedDocsHandler() *RelatedDocsHandler } // OutputFormatsProvider provides the OutputFormats of a Page. type OutputFormatsProvider interface { + // OutputFormats returns the OutputFormats for this Page. OutputFormats() OutputFormats } -// Page is the core interface in Hugo. +// PageProvider provides access to a Page. +// Implemented by shortcodes and others. +type PageProvider interface { + Page() Page +} + +// Page is the core interface in Hugo and what you get as the top level data context in your templates. type Page interface { + MarkupProvider ContentProvider TableOfContentsProvider PageWithoutContent + fmt.Stringer +} + +type PageFragment interface { + resource.ResourceLinksProvider + resource.ResourceNameTitleProvider +} + +type PageMetaResource interface { + PageMetaProvider + resource.Resource } // PageMetaProvider provides page metadata, typically provided via front matter. @@ -131,8 +193,7 @@ type PageMetaProvider interface { // Aliases forms the base for redirects generation. Aliases() []string - // BundleType returns the bundle type: "leaf", "branch" or an empty string if it is none. - // See https://gohugo.io/content-management/page-bundles/ + // BundleType returns the bundle type: `leaf`, `branch` or an empty string. BundleType() string // A configured description. @@ -147,7 +208,7 @@ type PageMetaProvider interface { // Configured keywords. Keywords() []string - // The Page Kind. One of page, home, section, taxonomy, taxonomyTerm. + // The Page Kind. One of page, home, section, taxonomy, term. Kind() string // The configured layout to use to render this page. Typically set in front matter. @@ -164,7 +225,7 @@ type PageMetaProvider interface { IsPage() bool // Param looks for a param in Page and then in Site config. - Param(key interface{}) (interface{}, error) + Param(key any) (any, error) // Path gets the relative path, including file name and extension if relevant, // to the source of this Page. It will be relative to any content root. @@ -182,15 +243,9 @@ type PageMetaProvider interface { // Section returns the first path element below the content root. Section() string - // Returns a slice of sections (directories if it's a file) to this - // Page. - SectionsEntries() []string - - // SectionsPath is SectionsEntries joined with a /. - SectionsPath() string - // Sitemap returns the sitemap configuration for this page. - Sitemap() config.Sitemap + // This is for internal use only. + Sitemap() config.SitemapConfig // Type is a discriminator used to select layouts etc. It is typically set // in front matter, but will fall back to the root section. @@ -201,17 +256,95 @@ type PageMetaProvider interface { Weight() int } +// NamedPageMetaValue returns a named metadata value from a PageMetaResource. +// This is currently only used to generate keywords for related content. +// If nameLower is not one of the metadata interface methods, we +// look in Params. +func NamedPageMetaValue(p PageMetaResource, nameLower string) (any, bool, error) { + var ( + v any + err error + ) + + switch nameLower { + case "kind": + v = p.Kind() + case "bundletype": + v = p.BundleType() + case "mediatype": + v = p.MediaType() + case "section": + v = p.Section() + case "lang": + v = p.Lang() + case "aliases": + v = p.Aliases() + case "name": + v = p.Name() + case "keywords": + v = p.Keywords() + case "description": + v = p.Description() + case "title": + v = p.Title() + case "linktitle": + v = p.LinkTitle() + case "slug": + v = p.Slug() + case "date": + v = p.Date() + case "publishdate": + v = p.PublishDate() + case "expirydate": + v = p.ExpiryDate() + case "lastmod": + v = p.Lastmod() + case "draft": + v = p.Draft() + case "type": + v = p.Type() + case "layout": + v = p.Layout() + case "weight": + v = p.Weight() + default: + // Try params. + v, err = resource.Param(p, nil, nameLower) + if v == nil { + return nil, false, nil + } + } + + return v, err == nil, err +} + +// PageMetaInternalProvider provides internal page metadata. +type PageMetaInternalProvider interface { + // This is for internal use only. + PathInfo() *paths.Path +} + // PageRenderProvider provides a way for a Page to render content. type PageRenderProvider interface { - Render(layout ...string) (template.HTML, error) - RenderString(args ...interface{}) (template.HTML, error) + // Render renders the given layout with this Page as context. + Render(ctx context.Context, layout ...string) (template.HTML, error) + // RenderString renders the first value in args with the content renderer defined + // for this Page. + // It takes an optional map as a second argument: + // + // display (“inline”): + // - inline or block. If inline (default), surrounding

    on short snippets will be trimmed. + // markup (defaults to the Page’s markup) + RenderString(ctx context.Context, args ...any) (template.HTML, error) } // PageWithoutContent is the Page without any of the content methods. type PageWithoutContent interface { RawContentProvider + RenderShortcodesProvider resource.Resource PageMetaProvider + PageMetaInternalProvider resource.LanguageProvider // For pages backed by a file. @@ -234,9 +367,6 @@ type PageWithoutContent interface { Positioner navigation.PageMenusProvider - // TODO(bep) - AuthorProvider - // Page lookups/refs GetPageProvider RefProvider @@ -249,15 +379,32 @@ type PageWithoutContent interface { // Helper methods ShortcodeInfoProvider compare.Eqer - maps.Scratcher + + // Scratch returns a Scratch that can be used to store temporary state. + // Note that this Scratch gets reset on server rebuilds. See Store() for a variant that survives. + // Scratch returns a "scratch pad" that can be used to store state. + // Deprecated: From Hugo v0.138.0 this is just an alias for Store. + Scratch() *maps.Scratch + + maps.StoreProvider + RelatedKeywordsProvider - DeprecatedWarningPageMethods + // GetTerms gets the terms of a given taxonomy, + // e.g. GetTerms("categories") + GetTerms(taxonomy string) Pages + + // HeadingsFiltered returns the headings for this page when a filter is set. + // This is currently only triggered with the Related content feature + // and the "fragments" type of index. + HeadingsFiltered(context.Context) tableofcontents.Headings } // Positioner provides next/prev navigation. type Positioner interface { + // Next points up to the next regular page (sorted by Hugo’s default sort). Next() Page + // Prev points down to the previous regular page (sorted by Hugo’s default sort). Prev() Page // Deprecated: Use Prev. Will be removed in Hugo 0.57 @@ -269,20 +416,34 @@ type Positioner interface { // RawContentProvider provides the raw, unprocessed content of the page. type RawContentProvider interface { + // RawContent returns the raw, unprocessed content of the page excluding any front matter. RawContent() string } +type RenderShortcodesProvider interface { + // RenderShortcodes returns RawContent with any shortcodes rendered. + RenderShortcodes(context.Context) (template.HTML, error) +} + // RefProvider provides the methods needed to create reflinks to pages. type RefProvider interface { - Ref(argsm map[string]interface{}) (string, error) - RefFrom(argsm map[string]interface{}, source interface{}) (string, error) - RelRef(argsm map[string]interface{}) (string, error) - RelRefFrom(argsm map[string]interface{}, source interface{}) (string, error) + // Ref returns an absolute URl to a page. + Ref(argsm map[string]any) (string, error) + + // RefFrom is for internal use only. + RefFrom(argsm map[string]any, source any) (string, error) + + // RelRef returns a relative URL to a page. + RelRef(argsm map[string]any) (string, error) + + // RelRefFrom is for internal use only. + RelRefFrom(argsm map[string]any, source any) (string, error) } // RelatedKeywordsProvider allows a Page to be indexed. type RelatedKeywordsProvider interface { // Make it indexable as a related.Document + // RelatedKeywords is meant for internal usage only. RelatedKeywords(cfg related.IndexConfig) ([]related.Keyword, error) } @@ -296,18 +457,23 @@ type ShortcodeInfoProvider interface { // SitesProvider provide accessors to get sites. type SitesProvider interface { + // Site returns the current site. Site() Site + // Sites returns all sites. Sites() Sites } // TableOfContentsProvider provides the table of contents for a Page. type TableOfContentsProvider interface { - TableOfContents() template.HTML + // TableOfContents returns the table of contents for the page rendered as HTML. + TableOfContents(context.Context) template.HTML + + // Fragments returns the fragments for this page. + Fragments(context.Context) *tableofcontents.Fragments } // TranslationsProvider provides access to any translations. type TranslationsProvider interface { - // IsTranslated returns whether this content file is translated to // other language(s). IsTranslated() bool @@ -321,32 +487,34 @@ type TranslationsProvider interface { // TreeProvider provides section tree navigation. type TreeProvider interface { - - // IsAncestor returns whether the current page is an ancestor of the given + // IsAncestor returns whether the current page is an ancestor of other. // Note that this method is not relevant for taxonomy lists and taxonomy terms pages. - IsAncestor(other interface{}) (bool, error) + IsAncestor(other any) bool // CurrentSection returns the page's current section or the page itself if home or a section. // Note that this will return nil for pages that is not regular, home or section pages. CurrentSection() Page - // IsDescendant returns whether the current page is a descendant of the given + // IsDescendant returns whether the current page is a descendant of other. // Note that this method is not relevant for taxonomy lists and taxonomy terms pages. - IsDescendant(other interface{}) (bool, error) + IsDescendant(other any) bool // FirstSection returns the section on level 1 below home, e.g. "/docs". // For the home page, this will return itself. FirstSection() Page - // InSection returns whether the given page is in the current section. + // InSection returns whether other is in the current section. // Note that this will always return false for pages that are // not either regular, home or section pages. - InSection(other interface{}) (bool, error) + InSection(other any) bool // Parent returns a section's parent section or a page's section. // To get a section's subsections, see Page's Sections method. Parent() Page + // Ancestors returns the ancestors of each page + Ancestors() Pages + // Sections returns this section's subsections, if any. // Note that for non-sections, this method will always return an empty list. Sections() Pages @@ -354,26 +522,53 @@ type TreeProvider interface { // Page returns a reference to the Page itself, kept here mostly // for legacy reasons. Page() Page + + // Returns a slice of sections (directories if it's a file) to this + // Page. + SectionsEntries() []string + + // SectionsPath is SectionsEntries joined with a /. + SectionsPath() string } -// DeprecatedWarningPageMethods lists deprecated Page methods that will trigger -// a WARNING if invoked. -// This was added in Hugo 0.55. -type DeprecatedWarningPageMethods interface { - source.FileWithoutOverlap - DeprecatedWarningPageMethods1 +// PageWithContext is a Page with a context.Context. +type PageWithContext struct { + Page + Ctx context.Context } -type DeprecatedWarningPageMethods1 interface { - IsDraft() bool - Hugo() hugo.Info - LanguagePrefix() string - GetParam(key string) interface{} - RSSLink() template.URL - URL() string +func (p PageWithContext) Content() (any, error) { + return p.Page.Content(p.Ctx) } -// Move here to trigger ERROR instead of WARNING. -// TODO(bep) create wrappers and put into the Page once it has some methods. -type DeprecatedErrorPageMethods interface { +func (p PageWithContext) Plain() string { + return p.Page.Plain(p.Ctx) +} + +func (p PageWithContext) PlainWords() []string { + return p.Page.PlainWords(p.Ctx) +} + +func (p PageWithContext) Summary() template.HTML { + return p.Page.Summary(p.Ctx) +} + +func (p PageWithContext) Truncated() bool { + return p.Page.Truncated(p.Ctx) +} + +func (p PageWithContext) FuzzyWordCount() int { + return p.Page.FuzzyWordCount(p.Ctx) +} + +func (p PageWithContext) WordCount() int { + return p.Page.WordCount(p.Ctx) +} + +func (p PageWithContext) ReadingTime() int { + return p.Page.ReadingTime(p.Ctx) +} + +func (p PageWithContext) Len() int { + return p.Page.Len(p.Ctx) } diff --git a/resources/page/page_author.go b/resources/page/page_author.go index 58be20426..2b3282b1e 100644 --- a/resources/page/page_author.go +++ b/resources/page/page_author.go @@ -14,9 +14,11 @@ package page // AuthorList is a list of all authors and their metadata. +// Deprecated: Use taxonomies instead. type AuthorList map[string]Author // Author contains details about the author of a page. +// Deprecated: Use taxonomies instead. type Author struct { GivenName string FamilyName string @@ -41,4 +43,5 @@ type Author struct { // - youtube // - linkedin // - skype +// Deprecated: Use taxonomies instead. type AuthorSocial map[string]string diff --git a/resources/page/page_data.go b/resources/page/page_data.go index 3345a44da..a7806438a 100644 --- a/resources/page/page_data.go +++ b/resources/page/page_data.go @@ -21,7 +21,7 @@ import ( // Data represents the .Data element in a Page in Hugo. We make this // a type so we can do lazy loading of .Data.Pages -type Data map[string]interface{} +type Data map[string]any // Pages returns the pages stored with key "pages". If this is a func, // it will be invoked. diff --git a/resources/page/page_data_test.go b/resources/page/page_data_test.go index f161fad3c..c7d764d8a 100644 --- a/resources/page/page_data_test.go +++ b/resources/page/page_data_test.go @@ -16,7 +16,6 @@ package page import ( "bytes" "testing" - "text/template" qt "github.com/frankban/quicktest" @@ -53,5 +52,4 @@ func TestPageData(t *testing.T) { c.Assert(templ.Execute(&buff, data), qt.IsNil) c.Assert(buff.String(), qt.Contains, "Pages(2)") - } diff --git a/resources/page/page_generate/generate_page_wrappers.go b/resources/page/page_generate/generate_page_wrappers.go index 4c63962fa..d720b8a42 100644 --- a/resources/page/page_generate/generate_page_wrappers.go +++ b/resources/page/page_generate/generate_page_wrappers.go @@ -14,19 +14,14 @@ package page_generate import ( - "bytes" + "errors" "fmt" "os" "path/filepath" "reflect" - "github.com/pkg/errors" - - "github.com/gohugoio/hugo/common/maps" - "github.com/gohugoio/hugo/codegen" "github.com/gohugoio/hugo/resources/page" - "github.com/gohugoio/hugo/source" ) const header = `// Copyright 2019 The Hugo Authors. All rights reserved. @@ -46,25 +41,14 @@ const header = `// Copyright 2019 The Hugo Authors. All rights reserved. ` var ( - fileInterfaceDeprecated = reflect.TypeOf((*source.FileWithoutOverlap)(nil)).Elem() - pageInterfaceDeprecated = reflect.TypeOf((*page.DeprecatedWarningPageMethods)(nil)).Elem() - pageInterface = reflect.TypeOf((*page.Page)(nil)).Elem() + pageInterface = reflect.TypeOf((*page.PageMetaProvider)(nil)).Elem() packageDir = filepath.FromSlash("resources/page") ) func Generate(c *codegen.Inspector) error { if err := generateMarshalJSON(c); err != nil { - return errors.Wrap(err, "failed to generate JSON marshaler") - - } - - if err := generateDeprecatedWrappers(c); err != nil { - return errors.Wrap(err, "failed to generate deprecate wrappers") - } - - if err := generateFileIsZeroWrappers(c); err != nil { - return errors.Wrap(err, "failed to generate file wrappers") + return fmt.Errorf("failed to generate JSON marshaler: %w", err) } return nil @@ -73,7 +57,6 @@ func Generate(c *codegen.Inspector) error { func generateMarshalJSON(c *codegen.Inspector) error { filename := filepath.Join(c.ProjectRootDir, packageDir, "page_marshaljson.autogen.go") f, err := os.Create(filename) - if err != nil { return err } @@ -81,27 +64,7 @@ func generateMarshalJSON(c *codegen.Inspector) error { includes := []reflect.Type{pageInterface} - // Exclude these methods - excludes := []reflect.Type{ - // We need to eveluate the deprecated vs JSON in the future, - // but leave them out for now. - pageInterfaceDeprecated, - - // Leave this out for now. We need to revisit the author issue. - reflect.TypeOf((*page.AuthorProvider)(nil)).Elem(), - - // navigation.PageMenus - - // Prevent loops. - reflect.TypeOf((*page.SitesProvider)(nil)).Elem(), - reflect.TypeOf((*page.Positioner)(nil)).Elem(), - - reflect.TypeOf((*page.ChildCareProvider)(nil)).Elem(), - reflect.TypeOf((*page.TreeProvider)(nil)).Elem(), - reflect.TypeOf((*page.InSectionPositioner)(nil)).Elem(), - reflect.TypeOf((*page.PaginatorProvider)(nil)).Elem(), - reflect.TypeOf((*maps.Scratcher)(nil)).Elem(), - } + excludes := []reflect.Type{} methods := c.MethodsFromTypes( includes, @@ -133,138 +96,6 @@ package page return nil } -func generateDeprecatedWrappers(c *codegen.Inspector) error { - filename := filepath.Join(c.ProjectRootDir, packageDir, "page_wrappers.autogen.go") - f, err := os.Create(filename) - if err != nil { - return err - } - defer f.Close() - - // Generate a wrapper for deprecated page methods - - reasons := map[string]string{ - "IsDraft": "Use .Draft.", - "Hugo": "Use the global hugo function.", - "LanguagePrefix": "Use .Site.LanguagePrefix.", - "GetParam": "Use .Param or .Params.myParam.", - "RSSLink": `Use the Output Format's link, e.g. something like: - {{ with .OutputFormats.Get "RSS" }}{{ .RelPermalink }}{{ end }}`, - "URL": "Use .Permalink or .RelPermalink. If what you want is the front matter URL value, use .Params.url", - } - - deprecated := func(name string, tp reflect.Type) string { - var alternative string - if tp == fileInterfaceDeprecated { - alternative = "Use .File." + name - } else { - var found bool - alternative, found = reasons[name] - if !found { - panic(fmt.Sprintf("no deprecated reason found for %q", name)) - } - } - - return fmt.Sprintf("helpers.Deprecated(%q, %q, false)", "Page."+name, alternative) - } - - var buff bytes.Buffer - - methods := c.MethodsFromTypes([]reflect.Type{fileInterfaceDeprecated, pageInterfaceDeprecated}, nil) - - for _, m := range methods { - fmt.Fprint(&buff, m.Declaration("*pageDeprecated")) - fmt.Fprintln(&buff, " {") - fmt.Fprintf(&buff, "\t%s\n", deprecated(m.Name, m.Owner)) - fmt.Fprintf(&buff, "\t%s\n}\n", m.Delegate("p", "p")) - - } - - pkgImports := append(methods.Imports(), "github.com/gohugoio/hugo/helpers") - - fmt.Fprintf(f, `%s - -package page - -%s -// NewDeprecatedWarningPage adds deprecation warnings to the given implementation. -func NewDeprecatedWarningPage(p DeprecatedWarningPageMethods) DeprecatedWarningPageMethods { - return &pageDeprecated{p: p} -} - -type pageDeprecated struct { - p DeprecatedWarningPageMethods -} - -%s - -`, header, importsString(pkgImports), buff.String()) - - return nil -} - -func generateFileIsZeroWrappers(c *codegen.Inspector) error { - filename := filepath.Join(c.ProjectRootDir, packageDir, "zero_file.autogen.go") - f, err := os.Create(filename) - if err != nil { - return err - } - defer f.Close() - - // Generate warnings for zero file access - - warning := func(name string, tp reflect.Type) string { - msg := fmt.Sprintf(".File.%s on zero object. Wrap it in if or with: {{ with .File }}{{ .%s }}{{ end }}", name, name) - - return fmt.Sprintf("z.log.Println(%q)", msg) - } - - var buff bytes.Buffer - - methods := c.MethodsFromTypes([]reflect.Type{reflect.TypeOf((*source.File)(nil)).Elem()}, nil) - - for _, m := range methods { - if m.Name == "IsZero" { - continue - } - fmt.Fprint(&buff, m.DeclarationNamed("zeroFile")) - fmt.Fprintln(&buff, " {") - fmt.Fprintf(&buff, "\t%s\n", warning(m.Name, m.Owner)) - if len(m.Out) > 0 { - fmt.Fprintln(&buff, "\treturn") - } - fmt.Fprintln(&buff, "}") - - } - - pkgImports := append(methods.Imports(), "github.com/gohugoio/hugo/helpers", "github.com/gohugoio/hugo/source") - - fmt.Fprintf(f, `%s - -package page - -%s - -// ZeroFile represents a zero value of source.File with warnings if invoked. -type zeroFile struct { - log *helpers.DistinctLogger -} - -func NewZeroFile(log *helpers.DistinctLogger) source.File { - return zeroFile{log: log} -} - -func (zeroFile) IsZero() bool { - return true -} - -%s - -`, header, importsString(pkgImports), buff.String()) - - return nil -} - func importsString(imps []string) string { if len(imps) == 0 { return "" diff --git a/resources/page/page_integration_test.go b/resources/page/page_integration_test.go new file mode 100644 index 000000000..763499113 --- /dev/null +++ b/resources/page/page_integration_test.go @@ -0,0 +1,170 @@ +// Copyright 2021 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 page_test + +import ( + "testing" + + "github.com/gohugoio/hugo/hugolib" +) + +func TestGroupByLocalizedDate(t *testing.T) { + files := ` +-- config.toml -- +defaultContentLanguage = 'en' +defaultContentLanguageInSubdir = true +[languages] +[languages.en] +title = 'My blog' +weight = 1 +[languages.fr] +title = 'Mon blogue' +weight = 2 +[languages.nn] +title = 'Bloggen min' +weight = 3 +-- content/p1.md -- +--- +title: "Post 1" +date: "2020-01-01" +--- +-- content/p2.md -- +--- +title: "Post 2" +date: "2020-02-01" +--- +-- content/p1.fr.md -- +--- +title: "Post 1" +date: "2020-01-01" +--- +-- content/p2.fr.md -- +--- +title: "Post 2" +date: "2020-02-01" +--- +-- layouts/index.html -- +{{ range $k, $v := site.RegularPages.GroupByDate "January, 2006" }}{{ $k }}|{{ $v.Key }}|{{ $v.Pages }}{{ end }} + + ` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + NeedsOsFS: true, + }).Build() + + b.AssertFileContent("public/en/index.html", "0|February, 2020|Pages(1)1|January, 2020|Pages(1)") + b.AssertFileContent("public/fr/index.html", "0|février, 2020|Pages(1)1|janvier, 2020|Pages(1)") +} + +func TestPagesSortCollation(t *testing.T) { + files := ` +-- config.toml -- +defaultContentLanguage = 'en' +defaultContentLanguageInSubdir = true +[languages] +[languages.en] +title = 'My blog' +weight = 1 +[languages.fr] +title = 'Mon blogue' +weight = 2 +[languages.nn] +title = 'Bloggen min' +weight = 3 +-- content/p1.md -- +--- +title: "zulu" +date: "2020-01-01" +param1: "xylophone" +tags: ["xylophone", "éclair", "zulu", "emma"] +--- +-- content/p2.md -- +--- +title: "émotion" +date: "2020-01-01" +param1: "violin" +--- +-- content/p3.md -- +--- +title: "alpha" +date: "2020-01-01" +param1: "éclair" +--- +-- layouts/index.html -- +ByTitle: {{ range site.RegularPages.ByTitle }}{{ .Title }}|{{ end }} +ByLinkTitle: {{ range site.RegularPages.ByLinkTitle }}{{ .Title }}|{{ end }} +ByParam: {{ range site.RegularPages.ByParam "param1" }}{{ .Params.param1 }}|{{ end }} +Tags Alphabetical: {{ range site.Taxonomies.tags.Alphabetical }}{{ .Term }}|{{ end }} +GroupBy: {{ range site.RegularPages.GroupBy "Title" }}{{ .Key }}|{{ end }} +{{ with (site.GetPage "p1").Params.tags }} +Sort: {{ sort . }} +ByWeight: {{ range site.RegularPages.ByWeight }}{{ .Title }}|{{ end }} +{{ end }} + + ` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + NeedsOsFS: true, + }).Build() + + b.AssertFileContent("public/en/index.html", ` +ByTitle: alpha|émotion|zulu| +ByLinkTitle: alpha|émotion|zulu| +ByParam: éclair|violin|xylophone +Tags Alphabetical: éclair|emma|xylophone|zulu| +GroupBy: alpha|émotion|zulu| +Sort: [éclair emma xylophone zulu] +ByWeight: alpha|émotion|zulu| +`) +} + +// See #10377 +func TestPermalinkExpansionSectionsRepeated(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ["home", "taxonomy", "taxonomyTerm", "sitemap"] +[outputs] +home = ["HTML"] +page = ["HTML"] +section = ["HTML"] +[outputFormats] +[permalinks] +posts = '/:sections[1]/:sections[last]/:slug' +-- content/posts/_index.md -- +-- content/posts/a/_index.md -- +-- content/posts/a/b/_index.md -- +-- content/posts/a/b/c/_index.md -- +-- content/posts/a/b/c/d.md -- +--- +title: "D" +slug: "d" +--- +D +-- layouts/_default/single.html -- +RelPermalink: {{ .RelPermalink }} + +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/a/c/d/index.html", "RelPermalink: /a/c/d/") +} diff --git a/resources/page/page_kinds.go b/resources/page/page_kinds.go index 1f59ec869..bb846ca5b 100644 --- a/resources/page/page_kinds.go +++ b/resources/page/page_kinds.go @@ -12,29 +12,3 @@ // limitations under the License. package page - -import "strings" - -const ( - KindPage = "page" - - // The rest are node types; home page, sections etc. - - KindHome = "home" - KindSection = "section" - KindTaxonomy = "taxonomy" - KindTaxonomyTerm = "taxonomyTerm" -) - -var kindMap = map[string]string{ - strings.ToLower(KindPage): KindPage, - strings.ToLower(KindHome): KindHome, - strings.ToLower(KindSection): KindSection, - strings.ToLower(KindTaxonomy): KindTaxonomy, - strings.ToLower(KindTaxonomyTerm): KindTaxonomyTerm, -} - -// GetKind gets the page kind given a string, empty if not found. -func GetKind(s string) string { - return kindMap[strings.ToLower(s)] -} diff --git a/resources/page/page_kinds_test.go b/resources/page/page_kinds_test.go deleted file mode 100644 index 7fba7e1d2..000000000 --- a/resources/page/page_kinds_test.go +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright 2019 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 page - -import ( - "testing" - - qt "github.com/frankban/quicktest" -) - -func TestKind(t *testing.T) { - t.Parallel() - c := qt.New(t) - // Add tests for these constants to make sure they don't change - c.Assert(KindPage, qt.Equals, "page") - c.Assert(KindHome, qt.Equals, "home") - c.Assert(KindSection, qt.Equals, "section") - c.Assert(KindTaxonomy, qt.Equals, "taxonomy") - c.Assert(KindTaxonomyTerm, qt.Equals, "taxonomyTerm") - - c.Assert(GetKind("TAXONOMYTERM"), qt.Equals, KindTaxonomyTerm) - c.Assert(GetKind("Taxonomy"), qt.Equals, KindTaxonomy) - c.Assert(GetKind("Page"), qt.Equals, KindPage) - c.Assert(GetKind("Home"), qt.Equals, KindHome) - c.Assert(GetKind("SEction"), qt.Equals, KindSection) - -} diff --git a/resources/page/page_lazy_contentprovider.go b/resources/page/page_lazy_contentprovider.go new file mode 100644 index 000000000..8e66a03e4 --- /dev/null +++ b/resources/page/page_lazy_contentprovider.go @@ -0,0 +1,166 @@ +// Copyright 2019 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 page + +import ( + "context" + "html/template" + + "github.com/gohugoio/hugo/lazy" + "github.com/gohugoio/hugo/markup/converter" + "github.com/gohugoio/hugo/markup/tableofcontents" +) + +// OutputFormatContentProvider represents the method set that is "outputFormat aware" and that we +// provide lazy initialization for in case they get invoked outside of their normal rendering context, e.g. via .Translations. +// Note that this set is currently not complete, but should cover the most common use cases. +// For the others, the implementation will be from the page.NoopPage. +type OutputFormatContentProvider interface { + OutputFormatPageContentProvider + + // for internal use. + ContentRenderer +} + +// OutputFormatPageContentProvider holds the exported methods from Page that are "outputFormat aware". +type OutputFormatPageContentProvider interface { + MarkupProvider + ContentProvider + TableOfContentsProvider + PageRenderProvider +} + +// LazyContentProvider initializes itself when read. Each method of the +// ContentProvider interface initializes a content provider and shares it +// with other methods. +// +// Used in cases where we cannot guarantee whether the content provider +// will be needed. Must create via NewLazyContentProvider. +type LazyContentProvider struct { + init *lazy.Init + cp OutputFormatContentProvider +} + +// NewLazyContentProvider returns a LazyContentProvider initialized with +// function f. The resulting LazyContentProvider calls f in order to +// retrieve a ContentProvider +func NewLazyContentProvider(f func() (OutputFormatContentProvider, error)) *LazyContentProvider { + lcp := LazyContentProvider{ + init: lazy.New(), + cp: NopCPageContentRenderer, + } + lcp.init.Add(func(context.Context) (any, error) { + cp, err := f() + if err != nil { + return nil, err + } + lcp.cp = cp + return nil, nil + }) + return &lcp +} + +func (lcp *LazyContentProvider) Reset() { + lcp.init.Reset() +} + +func (lcp *LazyContentProvider) Markup(opts ...any) Markup { + lcp.init.Do(context.Background()) + return lcp.cp.Markup(opts...) +} + +func (lcp *LazyContentProvider) TableOfContents(ctx context.Context) template.HTML { + lcp.init.Do(ctx) + return lcp.cp.TableOfContents(ctx) +} + +func (lcp *LazyContentProvider) Fragments(ctx context.Context) *tableofcontents.Fragments { + lcp.init.Do(ctx) + return lcp.cp.Fragments(ctx) +} + +func (lcp *LazyContentProvider) Content(ctx context.Context) (any, error) { + lcp.init.Do(ctx) + return lcp.cp.Content(ctx) +} + +func (lcp *LazyContentProvider) ContentWithoutSummary(ctx context.Context) (template.HTML, error) { + lcp.init.Do(ctx) + return lcp.cp.ContentWithoutSummary(ctx) +} + +func (lcp *LazyContentProvider) Plain(ctx context.Context) string { + lcp.init.Do(ctx) + return lcp.cp.Plain(ctx) +} + +func (lcp *LazyContentProvider) PlainWords(ctx context.Context) []string { + lcp.init.Do(ctx) + return lcp.cp.PlainWords(ctx) +} + +func (lcp *LazyContentProvider) Summary(ctx context.Context) template.HTML { + lcp.init.Do(ctx) + return lcp.cp.Summary(ctx) +} + +func (lcp *LazyContentProvider) Truncated(ctx context.Context) bool { + lcp.init.Do(ctx) + return lcp.cp.Truncated(ctx) +} + +func (lcp *LazyContentProvider) FuzzyWordCount(ctx context.Context) int { + lcp.init.Do(ctx) + return lcp.cp.FuzzyWordCount(ctx) +} + +func (lcp *LazyContentProvider) WordCount(ctx context.Context) int { + lcp.init.Do(ctx) + return lcp.cp.WordCount(ctx) +} + +func (lcp *LazyContentProvider) ReadingTime(ctx context.Context) int { + lcp.init.Do(ctx) + return lcp.cp.ReadingTime(ctx) +} + +func (lcp *LazyContentProvider) Len(ctx context.Context) int { + lcp.init.Do(ctx) + return lcp.cp.Len(ctx) +} + +func (lcp *LazyContentProvider) Render(ctx context.Context, layout ...string) (template.HTML, error) { + lcp.init.Do(ctx) + return lcp.cp.Render(ctx, layout...) +} + +func (lcp *LazyContentProvider) RenderString(ctx context.Context, args ...any) (template.HTML, error) { + lcp.init.Do(ctx) + return lcp.cp.RenderString(ctx, args...) +} + +func (lcp *LazyContentProvider) ParseAndRenderContent(ctx context.Context, content []byte, renderTOC bool) (converter.ResultRender, error) { + lcp.init.Do(ctx) + return lcp.cp.ParseAndRenderContent(ctx, content, renderTOC) +} + +func (lcp *LazyContentProvider) ParseContent(ctx context.Context, content []byte) (converter.ResultParse, bool, error) { + lcp.init.Do(ctx) + return lcp.cp.ParseContent(ctx, content) +} + +func (lcp *LazyContentProvider) RenderContent(ctx context.Context, content []byte, doc any) (converter.ResultRender, bool, error) { + lcp.init.Do(ctx) + return lcp.cp.RenderContent(ctx, content, doc) +} diff --git a/resources/page/page_markup.go b/resources/page/page_markup.go new file mode 100644 index 000000000..44980e8b0 --- /dev/null +++ b/resources/page/page_markup.go @@ -0,0 +1,362 @@ +// Copyright 2024 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 page + +import ( + "context" + "html/template" + "regexp" + "strings" + "unicode" + "unicode/utf8" + + "github.com/gohugoio/hugo/common/types" + "github.com/gohugoio/hugo/markup/tableofcontents" + "github.com/gohugoio/hugo/media" + "github.com/gohugoio/hugo/tpl" +) + +type Content interface { + Content(context.Context) (template.HTML, error) + ContentWithoutSummary(context.Context) (template.HTML, error) + Summary(context.Context) (Summary, error) + Plain(context.Context) string + PlainWords(context.Context) []string + WordCount(context.Context) int + FuzzyWordCount(context.Context) int + ReadingTime(context.Context) int + Len(context.Context) int +} + +type Markup interface { + Render(context.Context) (Content, error) + RenderString(ctx context.Context, args ...any) (template.HTML, error) + RenderShortcodes(context.Context) (template.HTML, error) + Fragments(context.Context) *tableofcontents.Fragments +} + +var _ types.PrintableValueProvider = Summary{} + +const ( + SummaryTypeAuto = "auto" + SummaryTypeManual = "manual" + SummaryTypeFrontMatter = "frontmatter" +) + +type Summary struct { + Text template.HTML + Type string // "auto", "manual" or "frontmatter" + Truncated bool +} + +func (s Summary) IsZero() bool { + return s.Text == "" +} + +func (s Summary) PrintableValue() any { + return s.Text +} + +var _ types.PrintableValueProvider = (*Summary)(nil) + +type HtmlSummary struct { + source string + SummaryLowHigh types.LowHigh[string] + SummaryEndTag types.LowHigh[string] + WrapperStart types.LowHigh[string] + WrapperEnd types.LowHigh[string] + Divider types.LowHigh[string] +} + +func (s HtmlSummary) wrap(ss string) string { + if s.WrapperStart.IsZero() { + return ss + } + return s.source[s.WrapperStart.Low:s.WrapperStart.High] + ss + s.source[s.WrapperEnd.Low:s.WrapperEnd.High] +} + +func (s HtmlSummary) wrapLeft(ss string) string { + if s.WrapperStart.IsZero() { + return ss + } + + return s.source[s.WrapperStart.Low:s.WrapperStart.High] + ss +} + +func (s HtmlSummary) Value(l types.LowHigh[string]) string { + return s.source[l.Low:l.High] +} + +func (s HtmlSummary) trimSpace(ss string) string { + return strings.TrimSpace(ss) +} + +func (s HtmlSummary) Content() string { + if s.Divider.IsZero() { + return s.source + } + ss := s.source[:s.Divider.Low] + ss += s.source[s.Divider.High:] + return s.trimSpace(ss) +} + +func (s HtmlSummary) Summary() string { + if s.Divider.IsZero() { + return s.trimSpace(s.wrap(s.Value(s.SummaryLowHigh))) + } + ss := s.source[s.SummaryLowHigh.Low:s.Divider.Low] + if s.SummaryLowHigh.High > s.Divider.High { + ss += s.source[s.Divider.High:s.SummaryLowHigh.High] + } + if !s.SummaryEndTag.IsZero() { + ss += s.Value(s.SummaryEndTag) + } + return s.trimSpace(s.wrap(ss)) +} + +func (s HtmlSummary) ContentWithoutSummary() string { + if s.Divider.IsZero() { + if s.SummaryLowHigh.Low == s.WrapperStart.High && s.SummaryLowHigh.High == s.WrapperEnd.Low { + return "" + } + return s.trimSpace(s.wrapLeft(s.source[s.SummaryLowHigh.High:])) + } + if s.SummaryEndTag.IsZero() { + return s.trimSpace(s.wrapLeft(s.source[s.Divider.High:])) + } + return s.trimSpace(s.wrapLeft(s.source[s.SummaryEndTag.High:])) +} + +func (s HtmlSummary) Truncated() bool { + return s.SummaryLowHigh.High < len(s.source) +} + +func (s *HtmlSummary) resolveParagraphTagAndSetWrapper(mt media.Type) tagReStartEnd { + ptag := startEndP + + switch mt.SubType { + case media.DefaultContentTypes.AsciiDoc.SubType: + ptag = startEndDiv + case media.DefaultContentTypes.ReStructuredText.SubType: + const markerStart = "
    " + const markerEnd = "
    " + i1 := strings.Index(s.source, markerStart) + i2 := strings.LastIndex(s.source, markerEnd) + if i1 > -1 && i2 > -1 { + s.WrapperStart = types.LowHigh[string]{Low: 0, High: i1 + len(markerStart)} + s.WrapperEnd = types.LowHigh[string]{Low: i2, High: len(s.source)} + } + } + return ptag +} + +// Avoid counting words that are most likely HTML tokens. +var ( + isProbablyHTMLTag = regexp.MustCompile(`^<\/?[A-Za-z]+>?$`) + isProablyHTMLAttribute = regexp.MustCompile(`^[A-Za-z]+=["']`) +) + +func isProbablyHTMLToken(s string) bool { + return s == ">" || isProbablyHTMLTag.MatchString(s) || isProablyHTMLAttribute.MatchString(s) +} + +// ExtractSummaryFromHTML extracts a summary from the given HTML content. +func ExtractSummaryFromHTML(mt media.Type, input string, numWords int, isCJK bool) (result HtmlSummary) { + result.source = input + ptag := result.resolveParagraphTagAndSetWrapper(mt) + + if numWords <= 0 { + return result + } + + var count int + + countWord := func(word string) int { + word = strings.TrimSpace(word) + if len(word) == 0 { + return 0 + } + if isProbablyHTMLToken(word) { + return 0 + } + + if isCJK { + word = tpl.StripHTML(word) + runeCount := utf8.RuneCountInString(word) + if len(word) == runeCount { + return 1 + } else { + return runeCount + } + } + + return 1 + } + + high := len(input) + if result.WrapperEnd.Low > 0 { + high = result.WrapperEnd.Low + } + + for j := result.WrapperStart.High; j < high; { + s := input[j:] + closingIndex := strings.Index(s, "") + + if closingIndex == -1 { + break + } + + s = s[:closingIndex] + + // Count the words in the current paragraph. + var wi int + + for i, r := range s { + if unicode.IsSpace(r) || (i+utf8.RuneLen(r) == len(s)) { + word := s[wi:i] + count += countWord(word) + wi = i + if count >= numWords { + break + } + } + } + + if count >= numWords { + result.SummaryLowHigh = types.LowHigh[string]{ + Low: result.WrapperStart.High, + High: j + closingIndex + len(ptag.tagName) + 3, + } + return + } + + j += closingIndex + len(ptag.tagName) + 2 + + } + + result.SummaryLowHigh = types.LowHigh[string]{ + Low: result.WrapperStart.High, + High: high, + } + + return +} + +// ExtractSummaryFromHTMLWithDivider extracts a summary from the given HTML content with +// a manual summary divider. +func ExtractSummaryFromHTMLWithDivider(mt media.Type, input, divider string) (result HtmlSummary) { + result.source = input + result.Divider.Low = strings.Index(input, divider) + result.Divider.High = result.Divider.Low + len(divider) + + if result.Divider.Low == -1 { + // No summary. + return + } + + ptag := result.resolveParagraphTagAndSetWrapper(mt) + + if !mt.IsHTML() { + result.Divider, result.SummaryEndTag = expandSummaryDivider(result.source, ptag, result.Divider) + } + + result.SummaryLowHigh = types.LowHigh[string]{ + Low: result.WrapperStart.High, + High: result.Divider.Low, + } + + return +} + +var ( + pOrDiv = regexp.MustCompile(`]?>|]?>$`) + + startEndDiv = tagReStartEnd{ + startEndOfString: regexp.MustCompile(`]*?>$`), + endEndOfString: regexp.MustCompile(`
    $`), + tagName: "div", + } + + startEndP = tagReStartEnd{ + startEndOfString: regexp.MustCompile(`]*?>$`), + endEndOfString: regexp.MustCompile(`

    $`), + tagName: "p", + } +) + +type tagReStartEnd struct { + startEndOfString *regexp.Regexp + endEndOfString *regexp.Regexp + tagName string +} + +func expandSummaryDivider(s string, re tagReStartEnd, divider types.LowHigh[string]) (types.LowHigh[string], types.LowHigh[string]) { + var endMarkup types.LowHigh[string] + + if divider.IsZero() { + return divider, endMarkup + } + + lo, hi := divider.Low, divider.High + + var preserveEndMarkup bool + + // Find the start of the paragraph. + + for i := lo - 1; i >= 0; i-- { + if s[i] == '>' { + if match := re.startEndOfString.FindString(s[:i+1]); match != "" { + lo = i - len(match) + 1 + break + } + if match := pOrDiv.FindString(s[:i+1]); match != "" { + i -= len(match) - 1 + continue + } + } + + r, _ := utf8.DecodeRuneInString(s[i:]) + if !unicode.IsSpace(r) { + preserveEndMarkup = true + break + } + } + + divider.Low = lo + + // Now walk forward to the end of the paragraph. + for ; hi < len(s); hi++ { + if s[hi] != '>' { + continue + } + if match := re.endEndOfString.FindString(s[:hi+1]); match != "" { + hi++ + break + } + } + + if preserveEndMarkup { + endMarkup.Low = divider.High + endMarkup.High = hi + } else { + divider.High = hi + } + + // Consume trailing newline if any. + if divider.High < len(s) && s[divider.High] == '\n' { + divider.High++ + } + + return divider, endMarkup +} diff --git a/resources/page/page_markup_integration_test.go b/resources/page/page_markup_integration_test.go new file mode 100644 index 000000000..425099215 --- /dev/null +++ b/resources/page/page_markup_integration_test.go @@ -0,0 +1,349 @@ +// Copyright 2024 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 page_test + +import ( + "testing" + + "github.com/gohugoio/hugo/hugolib" + "github.com/gohugoio/hugo/markup/asciidocext" + "github.com/gohugoio/hugo/markup/rst" +) + +func TestPageMarkupMethods(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +summaryLength=2 +-- content/p1.md -- +--- +title: "Post 1" +date: "2020-01-01" +--- +{{% foo %}} +-- layouts/shortcodes/foo.html -- +Two *words*. +{{/* Test that markup scope is set in all relevant constructs. */}} +{{ if eq hugo.Context.MarkupScope "foo" }} + +## Heading 1 +Sint ad mollit qui Lorem ut occaecat culpa officia. Et consectetur aute voluptate non sit ullamco adipisicing occaecat. Sunt deserunt amet sit ad. Deserunt enim voluptate proident ipsum dolore dolor ut sit velit esse est mollit irure esse. Mollit incididunt veniam laboris magna et excepteur sit duis. Magna adipisicing reprehenderit tempor irure. +### Heading 2 +Exercitation quis est consectetur occaecat nostrud. Ullamco aute mollit aliqua est amet. Exercitation ullamco consectetur dolor labore et non irure eu cillum Lorem. +{{ end }} +-- layouts/index.html -- +Home. +{{ .Content }} +-- layouts/_default/single.html -- +Single. +Page.ContentWithoutSummmary: {{ .ContentWithoutSummary }}| +{{ template "render-scope" (dict "page" . "scope" "main") }} +{{ template "render-scope" (dict "page" . "scope" "foo") }} +{{ define "render-scope" }} +{{ $c := .page.Markup .scope }} +{{ with $c.Render }} +{{ $.scope }}: Content: {{ .Content }}| + {{ $.scope }}: ContentWithoutSummary: {{ .ContentWithoutSummary }}| +{{ $.scope }}: Plain: {{ .Plain }}| +{{ $.scope }}: PlainWords: {{ .PlainWords }}| +{{ $.scope }}: WordCount: {{ .WordCount }}| +{{ $.scope }}: FuzzyWordCount: {{ .FuzzyWordCount }}| +{{ $.scope }}: ReadingTime: {{ .ReadingTime }}| +{{ $.scope }}: Len: {{ .Len }}| +{{ $.scope }}: Summary: {{ with .Summary }}{{ . }}{{ else }}nil{{ end }}| +{{ end }} +{{ $.scope }}: Fragments: {{ $c.Fragments.Identifiers }}| +{{ end }} + + + +` + + b := hugolib.Test(t, files) + + // Main scope. + b.AssertFileContent("public/p1/index.html", + "Page.ContentWithoutSummmary: |", + "main: Content:

    Two words.

    \n|", + "main: ContentWithoutSummary: |", + "main: Plain: Two words.\n|", + "PlainWords: [Two words.]|\nmain: WordCount: 2|\nmain: FuzzyWordCount: 100|\nmain: ReadingTime: 1|", + "main: Summary:

    Two words.

    |\n\nmain: Fragments: []|", + "main: Len: 27|", + ) + + // Foo scope (has more content). + b.AssertFileContent("public/p1/index.html", + "foo: Content:

    Two words.

    \nTwo words.

    |", + "foo: Fragments: [heading-1 heading-2]|", + ) +} + +func TestPageMarkupScope(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "rss", "section"] +-- content/p1.md -- +--- +title: "Post 1" +date: "2020-01-01" +--- + +# P1 + +{{< foo >}} + +Begin:{{% includerendershortcodes "p2" %}}:End +Begin:{{< includecontent "p3" >}}:End + +-- content/p2.md -- +--- +title: "Post 2" +date: "2020-01-02" +--- + +# P2 +-- content/p3.md -- +--- +title: "Post 3" +date: "2020-01-03" +--- + +# P3 + +{{< foo >}} + +-- layouts/index.html -- +Home. +{{ with site.GetPage "p1" }} + {{ with .Markup "home" }} + {{ .Render.Content }} + {{ end }} +{{ end }} +-- layouts/_default/single.html -- +Single. +{{ with .Markup }} + {{ with .Render }} + {{ .Content }} + {{ end }} +{{ end }} +-- layouts/_default/_markup/render-heading.html -- +Render heading: title: {{ .Text}} scope: {{ hugo.Context.MarkupScope }}| +-- layouts/shortcodes/foo.html -- +Foo scope: {{ hugo.Context.MarkupScope }}| +-- layouts/shortcodes/includerendershortcodes.html -- +{{ $p := site.GetPage (.Get 0) }} +includerendershortcodes: {{ hugo.Context.MarkupScope }}|{{ $p.Markup.RenderShortcodes }}| +-- layouts/shortcodes/includecontent.html -- +{{ $p := site.GetPage (.Get 0) }} +includecontent: {{ hugo.Context.MarkupScope }}|{{ $p.Markup.Render.Content }}| + +` + + b := hugolib.Test(t, files) + + b.AssertFileContentExact("public/p1/index.html", "Render heading: title: P1 scope: |", "Foo scope: |") + + b.AssertFileContentExact("public/index.html", + "Begin:\nincludecontent: home|Render heading: title: P3 scope: home|Foo scope: home|\n|\n:End", + "Render heading: title: P1 scope: home|", + "Foo scope: home|", + "Begin:\nincluderendershortcodes: home|

    \nRender heading: title: P2 scope: home|

    |:End", + ) +} + +func TestPageContentWithoutSummary(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +summaryLength=5 +-- content/p1.md -- +--- +title: "Post 1" +date: "2020-01-01" +--- +This is summary. + +This is content. +-- content/p2.md -- +--- +title: "Post 2" +date: "2020-01-01" +--- +This is some content about a summary and more. + +Another paragraph. + +Third paragraph. +-- content/p3.md -- +--- +title: "Post 3" +date: "2020-01-01" +summary: "This is summary in front matter." +--- +This is content. +-- layouts/_default/single.html -- +Single. +Page.Summary: {{ .Summary }}| +{{ with .Markup.Render }} +Content: {{ .Content }}| +ContentWithoutSummary: {{ .ContentWithoutSummary }}| +WordCount: {{ .WordCount }}| +FuzzyWordCount: {{ .FuzzyWordCount }}| +{{ with .Summary }} +Summary: {{ . }}| +Summary Type: {{ .Type }}| +Summary Truncated: {{ .Truncated }}| +{{ end }} +{{ end }} + +` + b := hugolib.Test(t, files) + + b.AssertFileContentExact("public/p1/index.html", + "Content:

    This is summary.

    \n

    This is content.

    ", + "ContentWithoutSummary:

    This is content.

    |", + "WordCount: 6|", + "FuzzyWordCount: 100|", + "Summary:

    This is summary.

    |", + "Summary Type: manual|", + "Summary Truncated: true|", + ) + b.AssertFileContent("public/p2/index.html", + "Summary:

    This is some content about a summary and more.

    |", + "WordCount: 13|", + "FuzzyWordCount: 100|", + "Summary Type: auto", + "Summary Truncated: true", + ) + + b.AssertFileContentExact("public/p3/index.html", + "Summary: This is summary in front matter.|", + "ContentWithoutSummary:

    This is content.

    \n|", + ) +} + +func TestPageMarkupWithoutSummaryRST(t *testing.T) { + t.Parallel() + if !rst.Supports() { + t.Skip("Skip RST test as not supported") + } + + files := ` +-- hugo.toml -- +summaryLength=5 +[security.exec] +allow = ["rst", "python"] + +-- content/p1.rst -- +This is a story about a summary and more. + +Another paragraph. +-- content/p2.rst -- +This is summary. + +This is content. +-- layouts/_default/single.html -- +Single. +Page.Summary: {{ .Summary }}| +{{ with .Markup.Render }} +Content: {{ .Content }}| +ContentWithoutSummary: {{ .ContentWithoutSummary }}| +{{ with .Summary }} +Summary: {{ . }}| +Summary Type: {{ .Type }}| +Summary Truncated: {{ .Truncated }}| +{{ end }} +{{ end }} + +` + + b := hugolib.Test(t, files) + + // Auto summary. + b.AssertFileContentExact("public/p1/index.html", + "Content:
    \n\n\n

    This is a story about a summary and more.

    \n

    Another paragraph.

    \n
    |", + "Summary:
    \n\n\n

    This is a story about a summary and more.

    |\nSummary Type: auto|\nSummary Truncated: true|", + "ContentWithoutSummary:
    \n

    Another paragraph.

    \n
    |", + ) + + // Manual summary. + b.AssertFileContentExact("public/p2/index.html", + "Content:
    \n\n\n

    This is summary.

    \n

    This is content.

    \n
    |", + "ContentWithoutSummary:

    This is content.

    \n
    |", + "Summary:
    \n\n\n

    This is summary.

    \n
    |\nSummary Type: manual|\nSummary Truncated: true|", + ) +} + +func TestPageMarkupWithoutSummaryAsciidoc(t *testing.T) { + t.Parallel() + if !asciidocext.Supports() { + t.Skip("Skip asiidoc test as not supported") + } + + files := ` +-- hugo.toml -- +summaryLength=5 +[security.exec] +allow = ["asciidoc", "python"] + +-- content/p1.ad -- +This is a story about a summary and more. + +Another paragraph. +-- content/p2.ad -- +This is summary. + +This is content. +-- layouts/_default/single.html -- +Single. +Page.Summary: {{ .Summary }}| +{{ with .Markup.Render }} +Content: {{ .Content }}| +ContentWithoutSummary: {{ .ContentWithoutSummary }}| +{{ with .Summary }} +Summary: {{ . }}| +Summary Type: {{ .Type }}| +Summary Truncated: {{ .Truncated }}| +{{ end }} +{{ end }} + +` + + b := hugolib.Test(t, files) + + // Auto summary. + b.AssertFileContentExact("public/p1/index.html", + "Content:
    \n

    This is a story about a summary and more.

    \n
    \n
    \n

    Another paragraph.

    \n
    \n|", + "Summary:
    \n

    This is a story about a summary and more.

    \n
    |", + "Summary Type: auto|\nSummary Truncated: true|", + "ContentWithoutSummary:
    \n

    Another paragraph.

    \n
    |", + ) + + // Manual summary. + b.AssertFileContentExact("public/p2/index.html", + "Content:
    \n

    This is summary.

    \n
    \n
    \n

    This is content.

    \n
    |", + "ContentWithoutSummary:
    \n

    This is content.

    \n
    |", + "Summary:
    \n

    This is summary.

    \n
    |\nSummary Type: manual|\nSummary Truncated: true|", + ) +} diff --git a/resources/page/page_markup_test.go b/resources/page/page_markup_test.go new file mode 100644 index 000000000..43eaae6f6 --- /dev/null +++ b/resources/page/page_markup_test.go @@ -0,0 +1,208 @@ +// Copyright 2024 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 page + +import ( + "strings" + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/common/types" + "github.com/gohugoio/hugo/media" +) + +func TestExtractSummaryFromHTML(t *testing.T) { + c := qt.New(t) + + tests := []struct { + mt media.Type + input string + isCJK bool + numWords int + expectSummary string + expectContentWithoutSummary string + }{ + {media.Builtin.ReStructuredTextType, "
    \n\n\n

    Simple Page

    \n
    ", false, 70, "
    \n\n\n

    Simple Page

    \n
    ", ""}, + {media.Builtin.ReStructuredTextType, "

    First paragraph

    Second paragraph

    ", false, 2, `

    First paragraph

    `, "

    Second paragraph

    "}, + {media.Builtin.MarkdownType, "

    First paragraph

    ", false, 10, "

    First paragraph

    ", ""}, + {media.Builtin.MarkdownType, "

    First paragraph

    Second paragraph

    ", false, 2, "

    First paragraph

    ", "

    Second paragraph

    "}, + {media.Builtin.MarkdownType, "

    First paragraph

    Second paragraph

    Third paragraph

    ", false, 3, "

    First paragraph

    Second paragraph

    ", "

    Third paragraph

    "}, + {media.Builtin.AsciiDocType, "

    First paragraph

    Second paragraph

    ", false, 2, "

    First paragraph

    ", "

    Second paragraph

    "}, + {media.Builtin.MarkdownType, "

    这是中文,全中文

    a这是中文,全中文

    ", true, 5, "

    这是中文,全中文

    ", "

    a这是中文,全中文

    "}, + } + + for i, test := range tests { + summary := ExtractSummaryFromHTML(test.mt, test.input, test.numWords, test.isCJK) + c.Assert(summary.Summary(), qt.Equals, test.expectSummary, qt.Commentf("Summary %d", i)) + c.Assert(summary.ContentWithoutSummary(), qt.Equals, test.expectContentWithoutSummary, qt.Commentf("ContentWithoutSummary %d", i)) + } +} + +// See https://discourse.gohugo.io/t/automatic-summarys-summarylength-seems-broken-in-the-case-of-plainify/51466/4 +// Also issue 12837 +func TestExtractSummaryFromHTMLLotsOfHTMLInSummary(t *testing.T) { + c := qt.New(t) + + input := ` +

    +

    + + 1 + + + 2 + + + 3 + + + 4 + + + 5 + +
    +

    +

    +This is a story about a cat. +

    +

    +The cat was white and fluffy. +

    +

    +And it liked milk. +

    +` + + summary := ExtractSummaryFromHTML(media.Builtin.MarkdownType, input, 10, false) + c.Assert(strings.HasSuffix(summary.Summary(), "

    \nThis is a story about a cat.\n

    \n

    \nThe cat was white and fluffy.\n

    "), qt.IsTrue) +} + +func TestExtractSummaryFromHTMLWithDivider(t *testing.T) { + c := qt.New(t) + + const divider = "FOOO" + + tests := []struct { + mt media.Type + input string + expectSummary string + expectContentWithoutSummary string + expectContent string + }{ + {media.Builtin.MarkdownType, "

    First paragraph

    FOOO

    Second paragraph

    ", "

    First paragraph

    ", "

    Second paragraph

    ", "

    First paragraph

    Second paragraph

    "}, + {media.Builtin.MarkdownType, "

    First paragraph

    \n

    FOOO

    \n

    Second paragraph

    ", "

    First paragraph

    ", "

    Second paragraph

    ", "

    First paragraph

    \n

    Second paragraph

    "}, + {media.Builtin.MarkdownType, "

    FOOO

    \n

    First paragraph

    ", "", "

    First paragraph

    ", "

    First paragraph

    "}, + {media.Builtin.MarkdownType, "

    First paragraph

    Second paragraphFOOO

    Third paragraph

    ", "

    First paragraph

    Second paragraph

    ", "

    Third paragraph

    ", "

    First paragraph

    Second paragraph

    Third paragraph

    "}, + {media.Builtin.MarkdownType, "

    这是中文,全中文FOOO

    a这是中文,全中文

    ", "

    这是中文,全中文

    ", "

    a这是中文,全中文

    ", "

    这是中文,全中文

    a这是中文,全中文

    "}, + {media.Builtin.MarkdownType, `

    a b` + "\v" + ` c

    ` + "\n

    FOOO

    ", "

    a b\v c

    ", "", "

    a b\v c

    "}, + + {media.Builtin.HTMLType, "

    First paragraph

    FOOO

    Second paragraph

    ", "

    First paragraph

    ", "

    Second paragraph

    ", "

    First paragraph

    Second paragraph

    "}, + + {media.Builtin.ReStructuredTextType, "
    \n\n\n

    This is summary.

    \n

    FOOO

    \n

    This is content.

    \n
    ", "
    \n\n\n

    This is summary.

    \n
    ", "

    This is content.

    \n
    ", "
    \n\n\n

    This is summary.

    \n

    This is content.

    \n
    "}, + {media.Builtin.ReStructuredTextType, "

    First paragraphFOOO

    Second paragraph

    ", "

    First paragraph

    ", "

    Second paragraph

    ", `

    First paragraph

    Second paragraph

    `}, + + {media.Builtin.AsciiDocType, "

    Summary Next Line

    FOOO

    Some more text

    ", "

    Summary Next Line

    ", "

    Some more text

    ", "

    Summary Next Line

    Some more text

    "}, + {media.Builtin.AsciiDocType, "
    \n

    Summary Next Line

    \n
    \n
    \n

    FOOO

    \n
    \n
    \n

    Some more text

    \n
    \n", "
    \n

    Summary Next Line

    \n
    ", "
    \n

    Some more text

    \n
    ", "
    \n

    Summary Next Line

    \n
    \n
    \n

    Some more text

    \n
    "}, + {media.Builtin.AsciiDocType, "

    FOOO

    First paragraph

    ", "", "

    First paragraph

    ", "

    First paragraph

    "}, + {media.Builtin.AsciiDocType, "

    First paragraphFOOO

    Second paragraph

    ", "

    First paragraph

    ", "

    Second paragraph

    ", "

    First paragraph

    Second paragraph

    "}, + } + + for i, test := range tests { + summary := ExtractSummaryFromHTMLWithDivider(test.mt, test.input, divider) + c.Assert(summary.Summary(), qt.Equals, test.expectSummary, qt.Commentf("Summary %d", i)) + c.Assert(summary.ContentWithoutSummary(), qt.Equals, test.expectContentWithoutSummary, qt.Commentf("ContentWithoutSummary %d", i)) + c.Assert(summary.Content(), qt.Equals, test.expectContent, qt.Commentf("Content %d", i)) + } +} + +func TestExpandDivider(t *testing.T) { + c := qt.New(t) + + for i, test := range []struct { + input string + divider string + ptag tagReStartEnd + expect string + expectEndMarkup string + }{ + {"

    First paragraph

    \n

    FOOO

    \n

    Second paragraph

    ", "FOOO", startEndP, "

    FOOO

    \n", ""}, + {"
    \n

    FOOO

    \n
    ", "FOOO", startEndDiv, "
    \n

    FOOO

    \n
    ", ""}, + {"

    FOOO

    Second paragraph

    ", "FOOO", startEndDiv, "

    FOOO

    ", ""}, + {"

    First paragraphFOOO

    Second paragraph

    ", "FOOO", startEndDiv, "FOOO", "

    "}, + {"

    abc FOOO

    ", "FOOO", startEndP, "FOOO", "

    "}, + {"

    FOOO

    ", "FOOO", startEndP, "

    FOOO

    ", ""}, + {"

    \n \nFOOO

    ", "FOOO", startEndP, "

    \n \nFOOO

    ", ""}, + {"
    FOOO
    ", "FOOO", startEndDiv, "
    FOOO
    ", ""}, + } { + + l := types.LowHigh[string]{Low: strings.Index(test.input, test.divider), High: strings.Index(test.input, test.divider) + len(test.divider)} + e, t := expandSummaryDivider(test.input, test.ptag, l) + c.Assert(test.input[e.Low:e.High], qt.Equals, test.expect, qt.Commentf("[%d] Test.expect %q", i, test.input)) + c.Assert(test.input[t.Low:t.High], qt.Equals, test.expectEndMarkup, qt.Commentf("[%d] Test.expectEndMarkup %q", i, test.input)) + } +} + +func TestIsProbablyHTMLToken(t *testing.T) { + c := qt.New(t) + + for i, test := range []struct { + input string + expect bool + }{ + {"

    ", true}, + {"Æøå", false}, + } { + c.Assert(isProbablyHTMLToken(test.input), qt.Equals, test.expect, qt.Commentf("[%d] Test.expect %q", i, test.input)) + } +} + +func BenchmarkSummaryFromHTML(b *testing.B) { + b.StopTimer() + input := "

    First paragraph

    Second paragraph

    " + b.StartTimer() + for i := 0; i < b.N; i++ { + summary := ExtractSummaryFromHTML(media.Builtin.MarkdownType, input, 2, false) + if s := summary.Content(); s != input { + b.Fatalf("unexpected content: %q", s) + } + if s := summary.ContentWithoutSummary(); s != "

    Second paragraph

    " { + b.Fatalf("unexpected content without summary: %q", s) + } + if s := summary.Summary(); s != "

    First paragraph

    " { + b.Fatalf("unexpected summary: %q", s) + } + } +} + +func BenchmarkSummaryFromHTMLWithDivider(b *testing.B) { + b.StopTimer() + input := "

    First paragraph

    FOOO

    Second paragraph

    " + b.StartTimer() + for i := 0; i < b.N; i++ { + summary := ExtractSummaryFromHTMLWithDivider(media.Builtin.MarkdownType, input, "FOOO") + if s := summary.Content(); s != "

    First paragraph

    Second paragraph

    " { + b.Fatalf("unexpected content: %q", s) + } + if s := summary.ContentWithoutSummary(); s != "

    Second paragraph

    " { + b.Fatalf("unexpected content without summary: %q", s) + } + if s := summary.Summary(); s != "

    First paragraph

    " { + b.Fatalf("unexpected summary: %q", s) + } + } +} diff --git a/resources/page/page_marshaljson.autogen.go b/resources/page/page_marshaljson.autogen.go index 6e08210ac..3b2138801 100644 --- a/resources/page/page_marshaljson.autogen.go +++ b/resources/page/page_marshaljson.autogen.go @@ -17,40 +17,11 @@ package page import ( "encoding/json" - "github.com/bep/gitmap" - "github.com/gohugoio/hugo/common/maps" "github.com/gohugoio/hugo/config" - "github.com/gohugoio/hugo/langs" - "github.com/gohugoio/hugo/media" - "github.com/gohugoio/hugo/navigation" - "github.com/gohugoio/hugo/source" - "html/template" "time" ) func MarshalPageToJSON(p Page) ([]byte, error) { - content, err := p.Content() - if err != nil { - return nil, err - } - plain := p.Plain() - plainWords := p.PlainWords() - summary := p.Summary() - truncated := p.Truncated() - fuzzyWordCount := p.FuzzyWordCount() - wordCount := p.WordCount() - readingTime := p.ReadingTime() - length := p.Len() - tableOfContents := p.TableOfContents() - rawContent := p.RawContent() - mediaType := p.MediaType() - resourceType := p.ResourceType() - permalink := p.Permalink() - relPermalink := p.RelPermalink() - name := p.Name() - title := p.Title() - params := p.Params() - data := p.Data() date := p.Date() lastmod := p.Lastmod() publishDate := p.PublishDate() @@ -71,132 +42,58 @@ func MarshalPageToJSON(p Page) ([]byte, error) { lang := p.Lang() isSection := p.IsSection() section := p.Section() - sectionsEntries := p.SectionsEntries() - sectionsPath := p.SectionsPath() sitemap := p.Sitemap() typ := p.Type() weight := p.Weight() - language := p.Language() - file := p.File() - gitInfo := p.GitInfo() - outputFormats := p.OutputFormats() - alternativeOutputFormats := p.AlternativeOutputFormats() - menus := p.Menus() - translationKey := p.TranslationKey() - isTranslated := p.IsTranslated() - allTranslations := p.AllTranslations() - translations := p.Translations() s := struct { - Content interface{} - Plain string - PlainWords []string - Summary template.HTML - Truncated bool - FuzzyWordCount int - WordCount int - ReadingTime int - Len int - TableOfContents template.HTML - RawContent string - MediaType media.Type - ResourceType string - Permalink string - RelPermalink string - Name string - Title string - Params maps.Params - Data interface{} - Date time.Time - Lastmod time.Time - PublishDate time.Time - ExpiryDate time.Time - Aliases []string - BundleType string - Description string - Draft bool - IsHome bool - Keywords []string - Kind string - Layout string - LinkTitle string - IsNode bool - IsPage bool - Path string - Slug string - Lang string - IsSection bool - Section string - SectionsEntries []string - SectionsPath string - Sitemap config.Sitemap - Type string - Weight int - Language *langs.Language - File source.File - GitInfo *gitmap.GitInfo - OutputFormats OutputFormats - AlternativeOutputFormats OutputFormats - Menus navigation.PageMenus - TranslationKey string - IsTranslated bool - AllTranslations Pages - Translations Pages + Date time.Time + Lastmod time.Time + PublishDate time.Time + ExpiryDate time.Time + Aliases []string + BundleType string + Description string + Draft bool + IsHome bool + Keywords []string + Kind string + Layout string + LinkTitle string + IsNode bool + IsPage bool + Path string + Slug string + Lang string + IsSection bool + Section string + Sitemap config.SitemapConfig + Type string + Weight int }{ - Content: content, - Plain: plain, - PlainWords: plainWords, - Summary: summary, - Truncated: truncated, - FuzzyWordCount: fuzzyWordCount, - WordCount: wordCount, - ReadingTime: readingTime, - Len: length, - TableOfContents: tableOfContents, - RawContent: rawContent, - MediaType: mediaType, - ResourceType: resourceType, - Permalink: permalink, - RelPermalink: relPermalink, - Name: name, - Title: title, - Params: params, - Data: data, - Date: date, - Lastmod: lastmod, - PublishDate: publishDate, - ExpiryDate: expiryDate, - Aliases: aliases, - BundleType: bundleType, - Description: description, - Draft: draft, - IsHome: isHome, - Keywords: keywords, - Kind: kind, - Layout: layout, - LinkTitle: linkTitle, - IsNode: isNode, - IsPage: isPage, - Path: path, - Slug: slug, - Lang: lang, - IsSection: isSection, - Section: section, - SectionsEntries: sectionsEntries, - SectionsPath: sectionsPath, - Sitemap: sitemap, - Type: typ, - Weight: weight, - Language: language, - File: file, - GitInfo: gitInfo, - OutputFormats: outputFormats, - AlternativeOutputFormats: alternativeOutputFormats, - Menus: menus, - TranslationKey: translationKey, - IsTranslated: isTranslated, - AllTranslations: allTranslations, - Translations: translations, + Date: date, + Lastmod: lastmod, + PublishDate: publishDate, + ExpiryDate: expiryDate, + Aliases: aliases, + BundleType: bundleType, + Description: description, + Draft: draft, + IsHome: isHome, + Keywords: keywords, + Kind: kind, + Layout: layout, + LinkTitle: linkTitle, + IsNode: isNode, + IsPage: isPage, + Path: path, + Slug: slug, + Lang: lang, + IsSection: isSection, + Section: section, + Sitemap: sitemap, + Type: typ, + Weight: weight, } return json.Marshal(&s) diff --git a/resources/page/page_matcher.go b/resources/page/page_matcher.go new file mode 100644 index 000000000..858def5ef --- /dev/null +++ b/resources/page/page_matcher.go @@ -0,0 +1,239 @@ +// Copyright 2020 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 page + +import ( + "fmt" + "path/filepath" + "slices" + "strings" + + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/hugofs/glob" + "github.com/gohugoio/hugo/resources/kinds" + "github.com/mitchellh/mapstructure" +) + +// A PageMatcher can be used to match a Page with Glob patterns. +// Note that the pattern matching is case insensitive. +type PageMatcher struct { + // A Glob pattern matching the content path below /content. + // Expects Unix-styled slashes. + // Note that this is the virtual path, so it starts at the mount root + // with a leading "/". + Path string + + // A Glob pattern matching the Page's Kind(s), e.g. "{home,section}" + Kind string + + // A Glob pattern matching the Page's language, e.g. "{en,sv}". + Lang string + + // A Glob pattern matching the Page's Environment, e.g. "{production,development}". + Environment string +} + +// Matches returns whether p matches this matcher. +func (m PageMatcher) Matches(p Page) bool { + if m.Kind != "" { + g, err := glob.GetGlob(m.Kind) + if err == nil && !g.Match(p.Kind()) { + return false + } + } + + if m.Lang != "" { + g, err := glob.GetGlob(m.Lang) + if err == nil && !g.Match(p.Lang()) { + return false + } + } + + if m.Path != "" { + g, err := glob.GetGlob(m.Path) + // TODO(bep) Path() vs filepath vs leading slash. + p := strings.ToLower(filepath.ToSlash(p.Path())) + if !(strings.HasPrefix(p, "/")) { + p = "/" + p + } + if err == nil && !g.Match(p) { + return false + } + } + + if m.Environment != "" { + g, err := glob.GetGlob(m.Environment) + if err == nil && !g.Match(p.Site().Hugo().Environment) { + return false + } + } + + return true +} + +var disallowedCascadeKeys = map[string]bool{ + // These define the structure of the page tree and cannot + // currently be set in the cascade. + "kind": true, + "path": true, + "lang": true, +} + +// See issue 11977. +func isGlobWithExtension(s string) bool { + pathParts := strings.Split(s, "/") + last := pathParts[len(pathParts)-1] + return strings.Count(last, ".") > 0 +} + +func CheckCascadePattern(logger loggers.Logger, m PageMatcher) { + if logger != nil && isGlobWithExtension(m.Path) { + logger.Erroridf("cascade-pattern-with-extension", "cascade target path %q looks like a path with an extension; since Hugo v0.123.0 this will not match anything, see https://gohugo.io/methods/page/path/", m.Path) + } +} + +func DecodeCascadeConfig(logger loggers.Logger, handleLegacyFormat bool, in any) (*config.ConfigNamespace[[]PageMatcherParamsConfig, *maps.Ordered[PageMatcher, PageMatcherParamsConfig]], error) { + buildConfig := func(in any) (*maps.Ordered[PageMatcher, PageMatcherParamsConfig], any, error) { + cascade := maps.NewOrdered[PageMatcher, PageMatcherParamsConfig]() + if in == nil { + return cascade, []map[string]any{}, nil + } + ms, err := maps.ToSliceStringMap(in) + if err != nil { + return nil, nil, err + } + + var cfgs []PageMatcherParamsConfig + + for _, m := range ms { + m = maps.CleanConfigStringMap(m) + var ( + c PageMatcherParamsConfig + err error + ) + c, err = mapToPageMatcherParamsConfig(m) + if err != nil { + return nil, nil, err + } + for k := range m { + if disallowedCascadeKeys[k] { + return nil, nil, fmt.Errorf("key %q not allowed in cascade config", k) + } + } + cfgs = append(cfgs, c) + } + + for _, cfg := range cfgs { + m := cfg.Target + CheckCascadePattern(logger, m) + c, found := cascade.Get(m) + if found { + // Merge + for k, v := range cfg.Params { + if _, found := c.Params[k]; !found { + c.Params[k] = v + } + } + for k, v := range cfg.Fields { + if _, found := c.Fields[k]; !found { + c.Fields[k] = v + } + } + } else { + cascade.Set(m, cfg) + } + } + + return cascade, cfgs, nil + } + + return config.DecodeNamespace[[]PageMatcherParamsConfig, *maps.Ordered[PageMatcher, PageMatcherParamsConfig]](in, buildConfig) +} + +// DecodeCascade decodes in which could be either a map or a slice of maps. +func DecodeCascade(logger loggers.Logger, handleLegacyFormat bool, in any) (*maps.Ordered[PageMatcher, PageMatcherParamsConfig], error) { + conf, err := DecodeCascadeConfig(logger, handleLegacyFormat, in) + if err != nil { + return nil, err + } + return conf.Config, nil +} + +func mapToPageMatcherParamsConfig(m map[string]any) (PageMatcherParamsConfig, error) { + var pcfg PageMatcherParamsConfig + if pcfg.Fields == nil { + pcfg.Fields = make(maps.Params) + } + for k, v := range m { + switch strings.ToLower(k) { + case "_target", "target": + var target PageMatcher + if err := decodePageMatcher(v, &target); err != nil { + return pcfg, err + } + pcfg.Target = target + case "params": + if pcfg.Params == nil { + pcfg.Params = make(maps.Params) + } + params := maps.ToStringMap(v) + for k, v := range params { + if _, found := pcfg.Params[k]; !found { + pcfg.Params[k] = v + } + } + default: + + pcfg.Fields[k] = v + } + } + return pcfg, pcfg.init() +} + +// decodePageMatcher decodes m into v. +func decodePageMatcher(m any, v *PageMatcher) error { + if err := mapstructure.WeakDecode(m, v); err != nil { + return err + } + + v.Kind = strings.ToLower(v.Kind) + if v.Kind != "" { + g, _ := glob.GetGlob(v.Kind) + found := slices.ContainsFunc(kinds.AllKindsInPages, g.Match) + if !found { + return fmt.Errorf("%q did not match a valid Page Kind", v.Kind) + } + } + + v.Path = filepath.ToSlash(strings.ToLower(v.Path)) + + return nil +} + +type PageMatcherParamsConfig struct { + // Apply Params to all Pages matching Target. + Params maps.Params + // Fields holds all fields but Params. + Fields maps.Params + // Target is the PageMatcher that this config applies to. + Target PageMatcher +} + +func (p *PageMatcherParamsConfig) init() error { + maps.PrepareParams(p.Params) + maps.PrepareParams(p.Fields) + return nil +} diff --git a/resources/page/page_matcher_test.go b/resources/page/page_matcher_test.go new file mode 100644 index 000000000..7f441d3ab --- /dev/null +++ b/resources/page/page_matcher_test.go @@ -0,0 +1,191 @@ +// Copyright 2020 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 page + +import ( + "path/filepath" + "testing" + + "github.com/gohugoio/hugo/common/hugo" + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/common/maps" + + qt "github.com/frankban/quicktest" +) + +func TestPageMatcher(t *testing.T) { + c := qt.New(t) + developmentTestSite := testSite{h: hugo.NewInfo(testConfig{environment: "development"}, nil)} + productionTestSite := testSite{h: hugo.NewInfo(testConfig{environment: "production"}, nil)} + + p1, p2, p3 := &testPage{path: "/p1", kind: "section", lang: "en", site: developmentTestSite}, + &testPage{path: "p2", kind: "page", lang: "no", site: productionTestSite}, + &testPage{path: "p3", kind: "page", lang: "en"} + + c.Run("Matches", func(c *qt.C) { + m := PageMatcher{Kind: "section"} + + c.Assert(m.Matches(p1), qt.Equals, true) + c.Assert(m.Matches(p2), qt.Equals, false) + + m = PageMatcher{Kind: "page"} + c.Assert(m.Matches(p1), qt.Equals, false) + c.Assert(m.Matches(p2), qt.Equals, true) + c.Assert(m.Matches(p3), qt.Equals, true) + + m = PageMatcher{Kind: "page", Path: "/p2"} + c.Assert(m.Matches(p1), qt.Equals, false) + c.Assert(m.Matches(p2), qt.Equals, true) + c.Assert(m.Matches(p3), qt.Equals, false) + + m = PageMatcher{Path: "/p*"} + c.Assert(m.Matches(p1), qt.Equals, true) + c.Assert(m.Matches(p2), qt.Equals, true) + c.Assert(m.Matches(p3), qt.Equals, true) + + m = PageMatcher{Lang: "en"} + c.Assert(m.Matches(p1), qt.Equals, true) + c.Assert(m.Matches(p2), qt.Equals, false) + c.Assert(m.Matches(p3), qt.Equals, true) + + m = PageMatcher{Environment: "development"} + c.Assert(m.Matches(p1), qt.Equals, true) + c.Assert(m.Matches(p2), qt.Equals, false) + c.Assert(m.Matches(p3), qt.Equals, false) + + m = PageMatcher{Environment: "production"} + c.Assert(m.Matches(p1), qt.Equals, false) + c.Assert(m.Matches(p2), qt.Equals, true) + c.Assert(m.Matches(p3), qt.Equals, false) + }) + + c.Run("Decode", func(c *qt.C) { + var v PageMatcher + c.Assert(decodePageMatcher(map[string]any{"kind": "foo"}, &v), qt.Not(qt.IsNil)) + c.Assert(decodePageMatcher(map[string]any{"kind": "{foo,bar}"}, &v), qt.Not(qt.IsNil)) + c.Assert(decodePageMatcher(map[string]any{"kind": "taxonomy"}, &v), qt.IsNil) + c.Assert(decodePageMatcher(map[string]any{"kind": "{taxonomy,foo}"}, &v), qt.IsNil) + c.Assert(decodePageMatcher(map[string]any{"kind": "{taxonomy,term}"}, &v), qt.IsNil) + c.Assert(decodePageMatcher(map[string]any{"kind": "*"}, &v), qt.IsNil) + c.Assert(decodePageMatcher(map[string]any{"kind": "home", "path": filepath.FromSlash("/a/b/**")}, &v), qt.IsNil) + c.Assert(v, qt.Equals, PageMatcher{Kind: "home", Path: "/a/b/**"}) + }) + + c.Run("mapToPageMatcherParamsConfig", func(c *qt.C) { + fn := func(m map[string]any) PageMatcherParamsConfig { + v, err := mapToPageMatcherParamsConfig(m) + c.Assert(err, qt.IsNil) + return v + } + c.Assert(fn(map[string]any{"_target": map[string]any{"kind": "page"}, "foo": "bar"}), qt.DeepEquals, PageMatcherParamsConfig{ + Fields: maps.Params{ + "foo": "bar", + }, + Target: PageMatcher{Path: "", Kind: "page", Lang: "", Environment: ""}, + }) + + c.Assert(fn(map[string]any{"target": map[string]any{"kind": "page"}, "params": map[string]any{"foo": "bar"}}), qt.DeepEquals, PageMatcherParamsConfig{ + Params: maps.Params{ + "foo": "bar", + }, + Fields: maps.Params{}, + Target: PageMatcher{Path: "", Kind: "page", Lang: "", Environment: ""}, + }) + }) +} + +func TestDecodeCascadeConfig(t *testing.T) { + c := qt.New(t) + + in := []map[string]any{ + { + "params": map[string]any{ + "a": "av", + }, + "target": map[string]any{ + "kind": "page", + "Environment": "production", + }, + }, + { + "params": map[string]any{ + "b": "bv", + }, + "target": map[string]any{ + "kind": "page", + }, + }, + } + + got, err := DecodeCascadeConfig(loggers.NewDefault(), true, in) + + c.Assert(err, qt.IsNil) + c.Assert(got, qt.IsNotNil) + c.Assert(got.Config.Keys(), qt.DeepEquals, []PageMatcher{{Kind: "page", Environment: "production"}, {Kind: "page"}}) + + c.Assert(got.SourceStructure, qt.DeepEquals, []PageMatcherParamsConfig{ + { + Params: maps.Params{"a": string("av")}, + Fields: maps.Params{}, + Target: PageMatcher{Kind: "page", Environment: "production"}, + }, + {Params: maps.Params{"b": string("bv")}, Fields: maps.Params{}, Target: PageMatcher{Kind: "page"}}, + }) + + got, err = DecodeCascadeConfig(loggers.NewDefault(), true, nil) + c.Assert(err, qt.IsNil) + c.Assert(got, qt.IsNotNil) +} + +type testConfig struct { + environment string + running bool + workingDir string + multihost bool + multilingual bool +} + +func (c testConfig) Environment() string { + return c.environment +} + +func (c testConfig) Running() bool { + return c.running +} + +func (c testConfig) WorkingDir() string { + return c.workingDir +} + +func (c testConfig) IsMultihost() bool { + return c.multihost +} + +func (c testConfig) IsMultilingual() bool { + return c.multilingual +} + +func TestIsGlobWithExtension(t *testing.T) { + c := qt.New(t) + + c.Assert(isGlobWithExtension("index.md"), qt.Equals, true) + c.Assert(isGlobWithExtension("foo/index.html"), qt.Equals, true) + c.Assert(isGlobWithExtension("posts/page"), qt.Equals, false) + c.Assert(isGlobWithExtension("pa.th/foo"), qt.Equals, false) + c.Assert(isGlobWithExtension(""), qt.Equals, false) + c.Assert(isGlobWithExtension("*.md?"), qt.Equals, true) + c.Assert(isGlobWithExtension("*.md*"), qt.Equals, true) + c.Assert(isGlobWithExtension("posts/*"), qt.Equals, false) + c.Assert(isGlobWithExtension("*.md"), qt.Equals, true) +} diff --git a/resources/page/page_nop.go b/resources/page/page_nop.go index 19c7068e0..398a7df02 100644 --- a/resources/page/page_nop.go +++ b/resources/page/page_nop.go @@ -16,16 +16,21 @@ package page import ( + "bytes" + "context" "html/template" "time" + "github.com/gohugoio/hugo/markup/converter" + "github.com/gohugoio/hugo/markup/tableofcontents" + "github.com/gohugoio/hugo/hugofs" - "github.com/bep/gitmap" "github.com/gohugoio/hugo/navigation" "github.com/gohugoio/hugo/common/hugo" "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/common/paths" "github.com/gohugoio/hugo/source" "github.com/gohugoio/hugo/config" @@ -36,7 +41,17 @@ import ( ) var ( - NopPage Page = new(nopPage) + NopPage Page = new(nopPage) + NopContentRenderer ContentRenderer = new(nopContentRenderer) + NopMarkup Markup = new(nopMarkup) + NopContent Content = new(nopContent) + NopCPageContentRenderer = struct { + OutputFormatPageContentProvider + ContentRenderer + }{ + NopPage, + NopContentRenderer, + } NilPage *nopPage ) @@ -47,26 +62,14 @@ func (p *nopPage) Aliases() []string { return nil } -func (p *nopPage) Sitemap() config.Sitemap { - return config.Sitemap{} +func (p *nopPage) Sitemap() config.SitemapConfig { + return config.SitemapConfig{} } func (p *nopPage) Layout() string { return "" } -func (p *nopPage) RSSLink() template.URL { - return "" -} - -func (p *nopPage) Author() Author { - return Author{} - -} -func (p *nopPage) Authors() AuthorList { - return nil -} - func (p *nopPage) AllTranslations() Pages { return nil } @@ -87,7 +90,15 @@ func (p *nopPage) BundleType() string { return "" } -func (p *nopPage) Content() (interface{}, error) { +func (p *nopPage) Markup(...any) Markup { + return NopMarkup +} + +func (p *nopPage) Content(context.Context) (any, error) { + return "", nil +} + +func (p *nopPage) ContentWithoutSummary(ctx context.Context) (template.HTML, error) { return "", nil } @@ -99,7 +110,7 @@ func (p *nopPage) CurrentSection() Page { return nil } -func (p *nopPage) Data() interface{} { +func (p *nopPage) Data() any { return nil } @@ -111,10 +122,11 @@ func (p *nopPage) Description() string { return "" } -func (p *nopPage) RefFrom(argsm map[string]interface{}, source interface{}) (string, error) { +func (p *nopPage) RefFrom(argsm map[string]any, source any) (string, error) { return "", nil } -func (p *nopPage) RelRefFrom(argsm map[string]interface{}, source interface{}) (string, error) { + +func (p *nopPage) RelRefFrom(argsm map[string]any, source any) (string, error) { return "", nil } @@ -126,7 +138,7 @@ func (p *nopPage) Draft() bool { return false } -func (p *nopPage) Eq(other interface{}) bool { +func (p *nopPage) Eq(other any) bool { return p == other } @@ -134,18 +146,8 @@ func (p *nopPage) ExpiryDate() (t time.Time) { return } -func (p *nopPage) Ext() string { - return "" -} - -func (p *nopPage) Extension() string { - return "" -} - -var nilFile *source.FileInfo - -func (p *nopPage) File() source.File { - return nilFile +func (p *nopPage) File() *source.File { + return nil } func (p *nopPage) FileInfo() hugofs.FileMetaInfo { @@ -160,7 +162,7 @@ func (p *nopPage) FirstSection() Page { return nil } -func (p *nopPage) FuzzyWordCount() int { +func (p *nopPage) FuzzyWordCount(context.Context) int { return 0 } @@ -168,11 +170,19 @@ func (p *nopPage) GetPage(ref string) (Page, error) { return nil, nil } -func (p *nopPage) GetParam(key string) interface{} { +func (p *nopPage) GetParam(key string) any { return nil } -func (p *nopPage) GitInfo() *gitmap.GitInfo { +func (p *nopPage) GetTerms(taxonomy string) Pages { + return nil +} + +func (p *nopPage) GitInfo() source.GitInfo { + return source.GitInfo{} +} + +func (p *nopPage) CodeOwners() []string { return nil } @@ -184,20 +194,20 @@ func (p *nopPage) HasShortcode(name string) bool { return false } -func (p *nopPage) Hugo() (h hugo.Info) { +func (p *nopPage) Hugo() (h hugo.HugoInfo) { return } -func (p *nopPage) InSection(other interface{}) (bool, error) { - return false, nil +func (p *nopPage) InSection(other any) bool { + return false } -func (p *nopPage) IsAncestor(other interface{}) (bool, error) { - return false, nil +func (p *nopPage) IsAncestor(other any) bool { + return false } -func (p *nopPage) IsDescendant(other interface{}) (bool, error) { - return false, nil +func (p *nopPage) IsDescendant(other any) bool { + return false } func (p *nopPage) IsDraft() bool { @@ -248,7 +258,7 @@ func (p *nopPage) Lastmod() (t time.Time) { return } -func (p *nopPage) Len() int { +func (p *nopPage) Len(context.Context) int { return 0 } @@ -288,15 +298,19 @@ func (p *nopPage) RegularPages() Pages { return nil } -func (p *nopPage) Paginate(seq interface{}, options ...interface{}) (*Pager, error) { +func (p *nopPage) RegularPagesRecursive() Pages { + return nil +} + +func (p *nopPage) Paginate(seq any, options ...any) (*Pager, error) { return nil, nil } -func (p *nopPage) Paginator(options ...interface{}) (*Pager, error) { +func (p *nopPage) Paginator(options ...any) (*Pager, error) { return nil, nil } -func (p *nopPage) Param(key interface{}) (interface{}, error) { +func (p *nopPage) Param(key any) (any, error) { return nil, nil } @@ -312,19 +326,27 @@ func (p *nopPage) Parent() Page { return nil } +func (p *nopPage) Ancestors() Pages { + return nil +} + func (p *nopPage) Path() string { return "" } +func (p *nopPage) PathInfo() *paths.Path { + return nil +} + func (p *nopPage) Permalink() string { return "" } -func (p *nopPage) Plain() string { +func (p *nopPage) Plain(context.Context) string { return "" } -func (p *nopPage) PlainWords() []string { +func (p *nopPage) PlainWords(context.Context) []string { return nil } @@ -339,6 +361,7 @@ func (p *nopPage) PublishDate() (t time.Time) { func (p *nopPage) PrevInSection() Page { return nil } + func (p *nopPage) NextInSection() Page { return nil } @@ -355,11 +378,15 @@ func (p *nopPage) RawContent() string { return "" } -func (p *nopPage) ReadingTime() int { +func (p *nopPage) RenderShortcodes(ctx context.Context) (template.HTML, error) { + return "", nil +} + +func (p *nopPage) ReadingTime(context.Context) int { return 0 } -func (p *nopPage) Ref(argsm map[string]interface{}) (string, error) { +func (p *nopPage) Ref(argsm map[string]any) (string, error) { return "", nil } @@ -367,15 +394,15 @@ func (p *nopPage) RelPermalink() string { return "" } -func (p *nopPage) RelRef(argsm map[string]interface{}) (string, error) { +func (p *nopPage) RelRef(argsm map[string]any) (string, error) { return "", nil } -func (p *nopPage) Render(layout ...string) (template.HTML, error) { +func (p *nopPage) Render(ctx context.Context, layout ...string) (template.HTML, error) { return "", nil } -func (p *nopPage) RenderString(args ...interface{}) (template.HTML, error) { +func (p *nopPage) RenderString(ctx context.Context, args ...any) (template.HTML, error) { return "", nil } @@ -391,6 +418,10 @@ func (p *nopPage) Scratch() *maps.Scratch { return nil } +func (p *nopPage) Store() *maps.Scratch { + return nil +} + func (p *nopPage) RelatedKeywords(cfg related.IndexConfig) ([]related.Keyword, error) { return nil, nil } @@ -427,11 +458,11 @@ func (p *nopPage) String() string { return "nopPage" } -func (p *nopPage) Summary() template.HTML { +func (p *nopPage) Summary(context.Context) template.HTML { return "" } -func (p *nopPage) TableOfContents() template.HTML { +func (p *nopPage) TableOfContents(context.Context) template.HTML { return "" } @@ -451,7 +482,7 @@ func (p *nopPage) Translations() Pages { return nil } -func (p *nopPage) Truncated() bool { +func (p *nopPage) Truncated(context.Context) bool { return false } @@ -471,6 +502,95 @@ func (p *nopPage) Weight() int { return 0 } -func (p *nopPage) WordCount() int { +func (p *nopPage) WordCount(context.Context) int { return 0 } + +func (p *nopPage) Fragments(context.Context) *tableofcontents.Fragments { + return nil +} + +func (p *nopPage) HeadingsFiltered(context.Context) tableofcontents.Headings { + return nil +} + +type nopContentRenderer int + +func (r *nopContentRenderer) ParseAndRenderContent(ctx context.Context, content []byte, renderTOC bool) (converter.ResultRender, error) { + b := &bytes.Buffer{} + return b, nil +} + +func (r *nopContentRenderer) ParseContent(ctx context.Context, content []byte) (converter.ResultParse, bool, error) { + return nil, false, nil +} + +func (r *nopContentRenderer) RenderContent(ctx context.Context, content []byte, doc any) (converter.ResultRender, bool, error) { + return nil, false, nil +} + +type ( + nopMarkup int + nopContent int +) + +var ( + _ Markup = (*nopMarkup)(nil) + _ Content = (*nopContent)(nil) +) + +func (c *nopMarkup) Render(context.Context) (Content, error) { + return NopContent, nil +} + +func (c *nopMarkup) RenderString(ctx context.Context, args ...any) (template.HTML, error) { + return "", nil +} + +func (c *nopMarkup) RenderShortcodes(context.Context) (template.HTML, error) { + return "", nil +} + +func (c *nopContent) Plain(context.Context) string { + return "" +} + +func (c *nopContent) PlainWords(context.Context) []string { + return nil +} + +func (c *nopContent) WordCount(context.Context) int { + return 0 +} + +func (c *nopContent) FuzzyWordCount(context.Context) int { + return 0 +} + +func (c *nopContent) ReadingTime(context.Context) int { + return 0 +} + +func (c *nopContent) Len(context.Context) int { + return 0 +} + +func (c *nopContent) Content(context.Context) (template.HTML, error) { + return "", nil +} + +func (c *nopContent) ContentWithoutSummary(context.Context) (template.HTML, error) { + return "", nil +} + +func (c *nopMarkup) Fragments(context.Context) *tableofcontents.Fragments { + return nil +} + +func (c *nopMarkup) FragmentsHTML(context.Context) template.HTML { + return "" +} + +func (c *nopContent) Summary(context.Context) (Summary, error) { + return Summary{}, nil +} diff --git a/resources/page/page_outputformat.go b/resources/page/page_outputformat.go index ff4213cc4..44f290025 100644 --- a/resources/page/page_outputformat.go +++ b/resources/page/page_outputformat.go @@ -27,7 +27,7 @@ type OutputFormats []OutputFormat // OutputFormat links to a representation of a resource. type OutputFormat struct { - // Rel constains a value that can be used to construct a rel link. + // Rel contains a value that can be used to construct a rel link. // This is value is fetched from the output format definition. // Note that for pages with only one output format, // this method will always return "canonical". @@ -66,8 +66,18 @@ func (o OutputFormat) RelPermalink() string { } func NewOutputFormat(relPermalink, permalink string, isCanonical bool, f output.Format) OutputFormat { + isUserConfigured := true + for _, d := range output.DefaultFormats { + if strings.EqualFold(d.Name, f.Name) { + isUserConfigured = false + } + } rel := f.Rel - if isCanonical { + // If the output format is the canonical format for the content, we want + // to specify this in the "rel" attribute of an HTML "link" element. + // However, for custom output formats, we don't want to surprise users by + // overwriting "rel" + if isCanonical && !isUserConfigured { rel = "canonical" } return OutputFormat{Rel: rel, Format: f, relPermalink: relPermalink, permalink: permalink} diff --git a/resources/page/page_paths.go b/resources/page/page_paths.go index 247c4dfcb..6b2c8e8b1 100644 --- a/resources/page/page_paths.go +++ b/resources/page/page_paths.go @@ -16,11 +16,14 @@ package page import ( "path" "path/filepath" - "strings" + "sync" + "github.com/gohugoio/hugo/common/paths" + "github.com/gohugoio/hugo/common/urls" "github.com/gohugoio/hugo/helpers" "github.com/gohugoio/hugo/output" + "github.com/gohugoio/hugo/resources/kinds" ) const slash = "/" @@ -31,24 +34,21 @@ const slash = "/" // // The big motivating behind this is to have only one source of truth for URLs, // and by that also get rid of most of the fragile string parsing/encoding etc. -// -// + type TargetPathDescriptor struct { PathSpec *helpers.PathSpec Type output.Format Kind string - Sections []string + Path *paths.Path + Section *paths.Path // For regular content pages this is either // 1) the Slug, if set, // 2) the file base name (TranslationBaseName). BaseName string - // Source directory. - Dir string - // Typically a language prefix added to file paths. PrefixFilePath string @@ -74,7 +74,6 @@ type TargetPathDescriptor struct { // TODO(bep) move this type. type TargetPaths struct { - // Where to store the file on disk relative to the publish dir. OS slashes. TargetFilename string @@ -93,249 +92,368 @@ func (p TargetPaths) RelPermalink(s *helpers.PathSpec) string { } func (p TargetPaths) PermalinkForOutputFormat(s *helpers.PathSpec, f output.Format) string { - var baseURL string + var baseURL urls.BaseURL var err error if f.Protocol != "" { - baseURL, err = s.BaseURL.WithProtocol(f.Protocol) + baseURL, err = s.Cfg.BaseURL().WithProtocol(f.Protocol) if err != nil { return "" } } else { - baseURL = s.BaseURL.String() + baseURL = s.Cfg.BaseURL() } - - return s.PermalinkForBaseURL(p.Link, baseURL) -} - -func isHtmlIndex(s string) bool { - return strings.HasSuffix(s, "/index.html") + baseURLstr := baseURL.String() + return s.PermalinkForBaseURL(p.Link, baseURLstr) } func CreateTargetPaths(d TargetPathDescriptor) (tp TargetPaths) { - - if d.Type.Name == "" { - panic("CreateTargetPath: missing type") - } - // Normalize all file Windows paths to simplify what's next. - if helpers.FilePathSeparator != slash { - d.Dir = filepath.ToSlash(d.Dir) + if helpers.FilePathSeparator != "/" { d.PrefixFilePath = filepath.ToSlash(d.PrefixFilePath) - } - if d.URL != "" && !strings.HasPrefix(d.URL, "/") { + if !d.Type.Root && d.URL != "" && !strings.HasPrefix(d.URL, "/") { // Treat this as a context relative URL d.ForcePrefix = true } - pagePath := slash + if d.URL != "" { + d.URL = filepath.ToSlash(d.URL) + if strings.Contains(d.URL, "..") { + d.URL = path.Join("/", d.URL) + } + } - var ( - pagePathDir string - link string - linkDir string - ) + if d.Type.Root && !d.ForcePrefix { + d.PrefixFilePath = "" + d.PrefixLink = "" + } + + pb := getPagePathBuilder(d) + defer putPagePathBuilder(pb) + + pb.fullSuffix = d.Type.MediaType.FirstSuffix.FullSuffix // The top level index files, i.e. the home page etc., needs // the index base even when uglyURLs is enabled. needsBase := true - isUgly := d.UglyURLs && !d.Type.NoUgly - baseNameSameAsType := d.BaseName != "" && d.BaseName == d.Type.BaseName + pb.isUgly = (d.UglyURLs || d.Type.Ugly) && !d.Type.NoUgly + pb.baseNameSameAsType = !d.Path.IsBundle() && d.BaseName != "" && d.BaseName == d.Type.BaseName - if d.ExpandedPermalink == "" && baseNameSameAsType { - isUgly = true + if d.ExpandedPermalink == "" && pb.baseNameSameAsType { + pb.isUgly = true } - if d.Kind != KindPage && d.URL == "" && len(d.Sections) > 0 { + if d.Type == output.HTTPStatus404HTMLFormat || d.Type == output.SitemapFormat || d.Type == output.RobotsTxtFormat { + pb.noSubResources = true + } else if d.Kind != kinds.KindPage && d.URL == "" && d.Section.Base() != "/" { if d.ExpandedPermalink != "" { - pagePath = pjoin(pagePath, d.ExpandedPermalink) + pb.Add(d.ExpandedPermalink) } else { - pagePath = pjoin(d.Sections...) + pb.Add(d.Section.Base()) } needsBase = false } if d.Type.Path != "" { - pagePath = pjoin(pagePath, d.Type.Path) + pb.Add(d.Type.Path) } - if d.Kind != KindHome && d.URL != "" { - pagePath = pjoin(pagePath, d.URL) + if d.Kind != kinds.KindHome && d.URL != "" { + pb.Add(paths.FieldsSlash(d.URL)...) if d.Addends != "" { - pagePath = pjoin(pagePath, d.Addends) + pb.Add(d.Addends) } - pagePathDir = pagePath - link = pagePath hasDot := strings.Contains(d.URL, ".") - hasSlash := strings.HasSuffix(d.URL, slash) + hasSlash := strings.HasSuffix(d.URL, "/") if hasSlash || !hasDot { - pagePath = pjoin(pagePath, d.Type.BaseName+d.Type.MediaType.FullSuffix()) + pb.Add(d.Type.BaseName + pb.fullSuffix) } else if hasDot { - pagePathDir = path.Dir(pagePathDir) + pb.fullSuffix = paths.Ext(d.URL) } - if !isHtmlIndex(pagePath) { - link = pagePath - } else if !hasSlash { - link += slash + if pb.IsHtmlIndex() { + pb.linkUpperOffset = 1 } - linkDir = pagePathDir - if d.ForcePrefix { // Prepend language prefix if not already set in URL - if d.PrefixFilePath != "" && !strings.HasPrefix(d.URL, slash+d.PrefixFilePath) { - pagePath = pjoin(d.PrefixFilePath, pagePath) - pagePathDir = pjoin(d.PrefixFilePath, pagePathDir) + if d.PrefixFilePath != "" && !strings.HasPrefix(d.URL, "/"+d.PrefixFilePath) { + pb.prefixPath = d.PrefixFilePath } - if d.PrefixLink != "" && !strings.HasPrefix(d.URL, slash+d.PrefixLink) { - link = pjoin(d.PrefixLink, link) - linkDir = pjoin(d.PrefixLink, linkDir) + if d.PrefixLink != "" && !strings.HasPrefix(d.URL, "/"+d.PrefixLink) { + pb.prefixLink = d.PrefixLink } } - - } else if d.Kind == KindPage { - + } else if !kinds.IsBranch(d.Kind) { if d.ExpandedPermalink != "" { - pagePath = pjoin(pagePath, d.ExpandedPermalink) - + pb.Add(d.ExpandedPermalink) } else { - if d.Dir != "" { - pagePath = pjoin(pagePath, d.Dir) + if dir := d.Path.ContainerDir(); dir != "" { + pb.Add(dir) } if d.BaseName != "" { - pagePath = pjoin(pagePath, d.BaseName) + pb.Add(d.BaseName) + } else { + pb.Add(d.Path.BaseNameNoIdentifier()) } } if d.Addends != "" { - pagePath = pjoin(pagePath, d.Addends) + pb.Add(d.Addends) } - link = pagePath - - // TODO(bep) this should not happen after the fix in https://github.com/gohugoio/hugo/issues/4870 - // but we may need some more testing before we can remove it. - if baseNameSameAsType { - link = strings.TrimSuffix(link, d.BaseName) - } - - pagePathDir = link - link = link + slash - linkDir = pagePathDir - - if isUgly { - pagePath = addSuffix(pagePath, d.Type.MediaType.FullSuffix()) + if pb.isUgly { + pb.ConcatLast(pb.fullSuffix) } else { - pagePath = pjoin(pagePath, d.Type.BaseName+d.Type.MediaType.FullSuffix()) + pb.Add(d.Type.BaseName + pb.fullSuffix) } - if !isHtmlIndex(pagePath) { - link = pagePath + if pb.IsHtmlIndex() { + pb.linkUpperOffset = 1 } if d.PrefixFilePath != "" { - pagePath = pjoin(d.PrefixFilePath, pagePath) - pagePathDir = pjoin(d.PrefixFilePath, pagePathDir) + pb.prefixPath = d.PrefixFilePath } if d.PrefixLink != "" { - link = pjoin(d.PrefixLink, link) - linkDir = pjoin(d.PrefixLink, linkDir) + pb.prefixLink = d.PrefixLink } - } else { if d.Addends != "" { - pagePath = pjoin(pagePath, d.Addends) + pb.Add(d.Addends) } needsBase = needsBase && d.Addends == "" - // No permalink expansion etc. for node type pages (for now) - base := "" - - if needsBase || !isUgly { - base = d.Type.BaseName + if needsBase || !pb.isUgly { + pb.Add(d.Type.BaseName + pb.fullSuffix) + } else { + pb.ConcatLast(pb.fullSuffix) } - pagePathDir = pagePath - link = pagePath - linkDir = pagePathDir - - if base != "" { - pagePath = path.Join(pagePath, addSuffix(base, d.Type.MediaType.FullSuffix())) - } else { - pagePath = addSuffix(pagePath, d.Type.MediaType.FullSuffix()) - - } - - if !isHtmlIndex(pagePath) { - link = pagePath - } else { - link += slash + if pb.IsHtmlIndex() { + pb.linkUpperOffset = 1 } if d.PrefixFilePath != "" { - pagePath = pjoin(d.PrefixFilePath, pagePath) - pagePathDir = pjoin(d.PrefixFilePath, pagePathDir) + pb.prefixPath = d.PrefixFilePath } if d.PrefixLink != "" { - link = pjoin(d.PrefixLink, link) - linkDir = pjoin(d.PrefixLink, linkDir) + pb.prefixLink = d.PrefixLink } } - pagePath = pjoin(slash, pagePath) - pagePathDir = strings.TrimSuffix(path.Join(slash, pagePathDir), slash) - - hadSlash := strings.HasSuffix(link, slash) - link = strings.Trim(link, slash) - if hadSlash { - link += slash + // if page URL is explicitly set in frontmatter, + // preserve its value without sanitization + if d.URL == "" { + // Note: MakePathSanitized will lower case the path if + // disablePathToLower isn't set. + pb.Sanitize() } - if !strings.HasPrefix(link, slash) { - link = slash + link - } - - linkDir = strings.TrimSuffix(path.Join(slash, linkDir), slash) - - // Note: MakePathSanitized will lower case the path if - // disablePathToLower isn't set. - pagePath = d.PathSpec.MakePathSanitized(pagePath) - pagePathDir = d.PathSpec.MakePathSanitized(pagePathDir) - link = d.PathSpec.MakePathSanitized(link) - linkDir = d.PathSpec.MakePathSanitized(linkDir) + link := pb.Link() + pagePath := pb.PathFile() tp.TargetFilename = filepath.FromSlash(pagePath) - tp.SubResourceBaseTarget = filepath.FromSlash(pagePathDir) - tp.SubResourceBaseLink = linkDir - tp.Link = d.PathSpec.URLizeFilename(link) + if !pb.noSubResources { + tp.SubResourceBaseTarget = pb.PathDir() + tp.SubResourceBaseLink = pb.LinkDir() + } + + // paths.{URL,Path}Escape rely on url.Parse which + // will consider # a fragment identifier, so it and + // and everything after it will be stripped from + // `link`, so we need to escape it first. + link = strings.ReplaceAll(link, "#", "%23") + + if d.URL != "" { + tp.Link = paths.URLEscape(link) + } else { + // This is slightly faster for when we know we don't have any + // query or scheme etc. + tp.Link = paths.PathEscape(link) + } if tp.Link == "" { - tp.Link = slash + tp.Link = "/" } return } -func addSuffix(s, suffix string) string { - return strings.Trim(s, slash) + suffix +// When adding state here, remember to update putPagePathBuilder. +type pagePathBuilder struct { + els []string + + d TargetPathDescriptor + + // Builder state. + isUgly bool + baseNameSameAsType bool + noSubResources bool + fullSuffix string // File suffix including any ".". + prefixLink string + prefixPath string + linkUpperOffset int } -// Like path.Join, but preserves one trailing slash if present. -func pjoin(elem ...string) string { - hadSlash := strings.HasSuffix(elem[len(elem)-1], slash) - joined := path.Join(elem...) - if hadSlash && !strings.HasSuffix(joined, slash) { - return joined + slash +func (p *pagePathBuilder) Add(el ...string) { + // Filter empty and slashes. + n := 0 + for _, e := range el { + if e != "" && e != slash { + el[n] = e + n++ + } } - return joined + el = el[:n] + + p.els = append(p.els, el...) +} + +func (p *pagePathBuilder) ConcatLast(s string) { + if len(p.els) == 0 { + p.Add(s) + return + } + old := p.els[len(p.els)-1] + if old == "" { + p.els[len(p.els)-1] = s + return + } + if old[len(old)-1] == '/' { + old = old[:len(old)-1] + } + p.els[len(p.els)-1] = old + s +} + +func (p *pagePathBuilder) IsHtmlIndex() bool { + return p.Last() == "index.html" +} + +func (p *pagePathBuilder) Last() string { + if p.els == nil { + return "" + } + return p.els[len(p.els)-1] +} + +func (p *pagePathBuilder) Link() string { + link := p.Path(p.linkUpperOffset) + + if p.baseNameSameAsType { + link = strings.TrimSuffix(link, p.d.BaseName) + } + + if p.prefixLink != "" { + link = "/" + p.prefixLink + link + } + + if p.linkUpperOffset > 0 && !strings.HasSuffix(link, "/") { + link += "/" + } + + return link +} + +func (p *pagePathBuilder) LinkDir() string { + if p.noSubResources { + return "" + } + + pathDir := p.PathDirBase() + + if p.prefixLink != "" { + pathDir = "/" + p.prefixLink + pathDir + } + + return pathDir +} + +func (p *pagePathBuilder) Path(upperOffset int) string { + upper := len(p.els) + if upperOffset > 0 { + upper -= upperOffset + } + pth := path.Join(p.els[:upper]...) + return paths.AddLeadingSlash(pth) +} + +func (p *pagePathBuilder) PathDir() string { + dir := p.PathDirBase() + if p.prefixPath != "" { + dir = "/" + p.prefixPath + dir + } + return dir +} + +func (p *pagePathBuilder) PathDirBase() string { + if p.noSubResources { + return "" + } + + dir := p.Path(0) + isIndex := strings.HasPrefix(p.Last(), p.d.Type.BaseName+".") + + if isIndex { + dir = paths.Dir(dir) + } else { + dir = strings.TrimSuffix(dir, p.fullSuffix) + } + + if dir == "/" { + dir = "" + } + + return dir +} + +func (p *pagePathBuilder) PathFile() string { + dir := p.Path(0) + if p.prefixPath != "" { + dir = "/" + p.prefixPath + dir + } + return dir +} + +func (p *pagePathBuilder) Prepend(el ...string) { + p.els = append(p.els[:0], append(el, p.els[0:]...)...) +} + +func (p *pagePathBuilder) Sanitize() { + for i, el := range p.els { + p.els[i] = p.d.PathSpec.MakePathSanitized(el) + } +} + +var pagePathBuilderPool = &sync.Pool{ + New: func() any { + return &pagePathBuilder{} + }, +} + +func getPagePathBuilder(d TargetPathDescriptor) *pagePathBuilder { + b := pagePathBuilderPool.Get().(*pagePathBuilder) + b.d = d + return b +} + +func putPagePathBuilder(b *pagePathBuilder) { + b.els = b.els[:0] + b.fullSuffix = "" + b.baseNameSameAsType = false + b.isUgly = false + b.noSubResources = false + b.prefixLink = "" + b.prefixPath = "" + b.linkUpperOffset = 0 + pagePathBuilderPool.Put(b) } diff --git a/resources/page/page_paths_test.go b/resources/page/page_paths_test.go deleted file mode 100644 index 4aaa41e8a..000000000 --- a/resources/page/page_paths_test.go +++ /dev/null @@ -1,258 +0,0 @@ -// Copyright 2019 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 page - -import ( - "path/filepath" - "strings" - "testing" - - "github.com/gohugoio/hugo/media" - - "fmt" - - "github.com/gohugoio/hugo/output" -) - -func TestPageTargetPath(t *testing.T) { - - pathSpec := newTestPathSpec() - - noExtNoDelimMediaType := media.TextType - noExtNoDelimMediaType.Suffixes = []string{} - noExtNoDelimMediaType.Delimiter = "" - - // Netlify style _redirects - noExtDelimFormat := output.Format{ - Name: "NER", - MediaType: noExtNoDelimMediaType, - BaseName: "_redirects", - } - - for _, langPrefixPath := range []string{"", "no"} { - for _, langPrefixLink := range []string{"", "no"} { - for _, uglyURLs := range []bool{false, true} { - - tests := []struct { - name string - d TargetPathDescriptor - expected TargetPaths - }{ - {"JSON home", TargetPathDescriptor{Kind: KindHome, Type: output.JSONFormat}, TargetPaths{TargetFilename: "/index.json", SubResourceBaseTarget: "", Link: "/index.json"}}, - {"AMP home", TargetPathDescriptor{Kind: KindHome, Type: output.AMPFormat}, TargetPaths{TargetFilename: "/amp/index.html", SubResourceBaseTarget: "/amp", Link: "/amp/"}}, - {"HTML home", TargetPathDescriptor{Kind: KindHome, BaseName: "_index", Type: output.HTMLFormat}, TargetPaths{TargetFilename: "/index.html", SubResourceBaseTarget: "", Link: "/"}}, - {"Netlify redirects", TargetPathDescriptor{Kind: KindHome, BaseName: "_index", Type: noExtDelimFormat}, TargetPaths{TargetFilename: "/_redirects", SubResourceBaseTarget: "", Link: "/_redirects"}}, - {"HTML section list", TargetPathDescriptor{ - Kind: KindSection, - Sections: []string{"sect1"}, - BaseName: "_index", - Type: output.HTMLFormat}, TargetPaths{TargetFilename: "/sect1/index.html", SubResourceBaseTarget: "/sect1", Link: "/sect1/"}}, - {"HTML taxonomy list", TargetPathDescriptor{ - Kind: KindTaxonomy, - Sections: []string{"tags", "hugo"}, - BaseName: "_index", - Type: output.HTMLFormat}, TargetPaths{TargetFilename: "/tags/hugo/index.html", SubResourceBaseTarget: "/tags/hugo", Link: "/tags/hugo/"}}, - {"HTML taxonomy term", TargetPathDescriptor{ - Kind: KindTaxonomy, - Sections: []string{"tags"}, - BaseName: "_index", - Type: output.HTMLFormat}, TargetPaths{TargetFilename: "/tags/index.html", SubResourceBaseTarget: "/tags", Link: "/tags/"}}, - { - "HTML page", TargetPathDescriptor{ - Kind: KindPage, - Dir: "/a/b", - BaseName: "mypage", - Sections: []string{"a"}, - Type: output.HTMLFormat}, TargetPaths{TargetFilename: "/a/b/mypage/index.html", SubResourceBaseTarget: "/a/b/mypage", Link: "/a/b/mypage/"}}, - - { - "HTML page with index as base", TargetPathDescriptor{ - Kind: KindPage, - Dir: "/a/b", - BaseName: "index", - Sections: []string{"a"}, - Type: output.HTMLFormat}, TargetPaths{TargetFilename: "/a/b/index.html", SubResourceBaseTarget: "/a/b", Link: "/a/b/"}}, - - { - "HTML page with special chars", TargetPathDescriptor{ - Kind: KindPage, - Dir: "/a/b", - BaseName: "My Page!", - Type: output.HTMLFormat}, TargetPaths{TargetFilename: "/a/b/my-page/index.html", SubResourceBaseTarget: "/a/b/my-page", Link: "/a/b/my-page/"}}, - {"RSS home", TargetPathDescriptor{Kind: "rss", Type: output.RSSFormat}, TargetPaths{TargetFilename: "/index.xml", SubResourceBaseTarget: "", Link: "/index.xml"}}, - {"RSS section list", TargetPathDescriptor{ - Kind: "rss", - Sections: []string{"sect1"}, - Type: output.RSSFormat}, TargetPaths{TargetFilename: "/sect1/index.xml", SubResourceBaseTarget: "/sect1", Link: "/sect1/index.xml"}}, - { - "AMP page", TargetPathDescriptor{ - Kind: KindPage, - Dir: "/a/b/c", - BaseName: "myamp", - Type: output.AMPFormat}, TargetPaths{TargetFilename: "/amp/a/b/c/myamp/index.html", SubResourceBaseTarget: "/amp/a/b/c/myamp", Link: "/amp/a/b/c/myamp/"}}, - { - "AMP page with URL with suffix", TargetPathDescriptor{ - Kind: KindPage, - Dir: "/sect/", - BaseName: "mypage", - URL: "/some/other/url.xhtml", - Type: output.HTMLFormat}, TargetPaths{TargetFilename: "/some/other/url.xhtml", SubResourceBaseTarget: "/some/other", Link: "/some/other/url.xhtml"}}, - { - "JSON page with URL without suffix", TargetPathDescriptor{ - Kind: KindPage, - Dir: "/sect/", - BaseName: "mypage", - URL: "/some/other/path/", - Type: output.JSONFormat}, TargetPaths{TargetFilename: "/some/other/path/index.json", SubResourceBaseTarget: "/some/other/path", Link: "/some/other/path/index.json"}}, - { - "JSON page with URL without suffix and no trailing slash", TargetPathDescriptor{ - Kind: KindPage, - Dir: "/sect/", - BaseName: "mypage", - URL: "/some/other/path", - Type: output.JSONFormat}, TargetPaths{TargetFilename: "/some/other/path/index.json", SubResourceBaseTarget: "/some/other/path", Link: "/some/other/path/index.json"}}, - { - "HTML page with URL without suffix and no trailing slash", TargetPathDescriptor{ - Kind: KindPage, - Dir: "/sect/", - BaseName: "mypage", - URL: "/some/other/path", - Type: output.HTMLFormat}, TargetPaths{TargetFilename: "/some/other/path/index.html", SubResourceBaseTarget: "/some/other/path", Link: "/some/other/path/"}}, - { - "HTML page with expanded permalink", TargetPathDescriptor{ - Kind: KindPage, - Dir: "/a/b", - BaseName: "mypage", - ExpandedPermalink: "/2017/10/my-title/", - Type: output.HTMLFormat}, TargetPaths{TargetFilename: "/2017/10/my-title/index.html", SubResourceBaseTarget: "/2017/10/my-title", Link: "/2017/10/my-title/"}}, - { - "Paginated HTML home", TargetPathDescriptor{ - Kind: KindHome, - BaseName: "_index", - Type: output.HTMLFormat, - Addends: "page/3"}, TargetPaths{TargetFilename: "/page/3/index.html", SubResourceBaseTarget: "/page/3", Link: "/page/3/"}}, - { - "Paginated Taxonomy list", TargetPathDescriptor{ - Kind: KindTaxonomy, - BaseName: "_index", - Sections: []string{"tags", "hugo"}, - Type: output.HTMLFormat, - Addends: "page/3"}, TargetPaths{TargetFilename: "/tags/hugo/page/3/index.html", SubResourceBaseTarget: "/tags/hugo/page/3", Link: "/tags/hugo/page/3/"}}, - { - "Regular page with addend", TargetPathDescriptor{ - Kind: KindPage, - Dir: "/a/b", - BaseName: "mypage", - Addends: "c/d/e", - Type: output.HTMLFormat}, TargetPaths{TargetFilename: "/a/b/mypage/c/d/e/index.html", SubResourceBaseTarget: "/a/b/mypage/c/d/e", Link: "/a/b/mypage/c/d/e/"}}, - } - - for i, test := range tests { - t.Run(fmt.Sprintf("langPrefixPath=%s,langPrefixLink=%s,uglyURLs=%t,name=%s", langPrefixPath, langPrefixLink, uglyURLs, test.name), - func(t *testing.T) { - - test.d.ForcePrefix = true - test.d.PathSpec = pathSpec - test.d.UglyURLs = uglyURLs - test.d.PrefixFilePath = langPrefixPath - test.d.PrefixLink = langPrefixLink - test.d.Dir = filepath.FromSlash(test.d.Dir) - isUgly := uglyURLs && !test.d.Type.NoUgly - - expected := test.expected - - // TODO(bep) simplify - if test.d.Kind == KindPage && test.d.BaseName == test.d.Type.BaseName { - } else if test.d.Kind == KindHome && test.d.Type.Path != "" { - } else if test.d.Type.MediaType.Suffix() != "" && (!strings.HasPrefix(expected.TargetFilename, "/index") || test.d.Addends != "") && test.d.URL == "" && isUgly { - expected.TargetFilename = strings.Replace(expected.TargetFilename, - "/"+test.d.Type.BaseName+"."+test.d.Type.MediaType.Suffix(), - "."+test.d.Type.MediaType.Suffix(), 1) - expected.Link = strings.TrimSuffix(expected.Link, "/") + "." + test.d.Type.MediaType.Suffix() - - } - - if test.d.PrefixFilePath != "" && !strings.HasPrefix(test.d.URL, "/"+test.d.PrefixFilePath) { - expected.TargetFilename = "/" + test.d.PrefixFilePath + expected.TargetFilename - expected.SubResourceBaseTarget = "/" + test.d.PrefixFilePath + expected.SubResourceBaseTarget - } - - if test.d.PrefixLink != "" && !strings.HasPrefix(test.d.URL, "/"+test.d.PrefixLink) { - expected.Link = "/" + test.d.PrefixLink + expected.Link - } - - expected.TargetFilename = filepath.FromSlash(expected.TargetFilename) - expected.SubResourceBaseTarget = filepath.FromSlash(expected.SubResourceBaseTarget) - - pagePath := CreateTargetPaths(test.d) - - if !eqTargetPaths(pagePath, expected) { - t.Fatalf("[%d] [%s] targetPath expected\n%#v, got:\n%#v", i, test.name, expected, pagePath) - - } - }) - } - } - - } - } -} - -func TestPageTargetPathPrefix(t *testing.T) { - pathSpec := newTestPathSpec() - tests := []struct { - name string - d TargetPathDescriptor - expected TargetPaths - }{ - {"URL set, prefix both, no force", TargetPathDescriptor{Kind: KindPage, Type: output.JSONFormat, URL: "/mydir/my.json", ForcePrefix: false, PrefixFilePath: "pf", PrefixLink: "pl"}, - TargetPaths{TargetFilename: "/mydir/my.json", SubResourceBaseTarget: "/mydir", SubResourceBaseLink: "/mydir", Link: "/mydir/my.json"}}, - {"URL set, prefix both, force", TargetPathDescriptor{Kind: KindPage, Type: output.JSONFormat, URL: "/mydir/my.json", ForcePrefix: true, PrefixFilePath: "pf", PrefixLink: "pl"}, - TargetPaths{TargetFilename: "/pf/mydir/my.json", SubResourceBaseTarget: "/pf/mydir", SubResourceBaseLink: "/pl/mydir", Link: "/pl/mydir/my.json"}}, - } - - for i, test := range tests { - t.Run(fmt.Sprintf(test.name), - func(t *testing.T) { - test.d.PathSpec = pathSpec - expected := test.expected - expected.TargetFilename = filepath.FromSlash(expected.TargetFilename) - expected.SubResourceBaseTarget = filepath.FromSlash(expected.SubResourceBaseTarget) - - pagePath := CreateTargetPaths(test.d) - - if pagePath != expected { - t.Fatalf("[%d] [%s] targetPath expected\n%#v, got:\n%#v", i, test.name, expected, pagePath) - } - }) - } - -} - -func eqTargetPaths(p1, p2 TargetPaths) bool { - - if p1.Link != p2.Link { - return false - } - - if p1.SubResourceBaseTarget != p2.SubResourceBaseTarget { - return false - } - - if p1.TargetFilename != p2.TargetFilename { - return false - } - - return true -} diff --git a/resources/page/page_wrappers.autogen.go b/resources/page/page_wrappers.autogen.go index bc2cf968c..4b7c034a1 100644 --- a/resources/page/page_wrappers.autogen.go +++ b/resources/page/page_wrappers.autogen.go @@ -14,84 +14,3 @@ // This file is autogenerated. package page - -import ( - "github.com/gohugoio/hugo/common/hugo" - "github.com/gohugoio/hugo/helpers" - "github.com/gohugoio/hugo/hugofs" - "html/template" -) - -// NewDeprecatedWarningPage adds deprecation warnings to the given implementation. -func NewDeprecatedWarningPage(p DeprecatedWarningPageMethods) DeprecatedWarningPageMethods { - return &pageDeprecated{p: p} -} - -type pageDeprecated struct { - p DeprecatedWarningPageMethods -} - -func (p *pageDeprecated) Filename() string { - helpers.Deprecated("Page.Filename", "Use .File.Filename", false) - return p.p.Filename() -} -func (p *pageDeprecated) Dir() string { - helpers.Deprecated("Page.Dir", "Use .File.Dir", false) - return p.p.Dir() -} -func (p *pageDeprecated) IsDraft() bool { - helpers.Deprecated("Page.IsDraft", "Use .Draft.", false) - return p.p.IsDraft() -} -func (p *pageDeprecated) Extension() string { - helpers.Deprecated("Page.Extension", "Use .File.Extension", false) - return p.p.Extension() -} -func (p *pageDeprecated) Hugo() hugo.Info { - helpers.Deprecated("Page.Hugo", "Use the global hugo function.", false) - return p.p.Hugo() -} -func (p *pageDeprecated) Ext() string { - helpers.Deprecated("Page.Ext", "Use .File.Ext", false) - return p.p.Ext() -} -func (p *pageDeprecated) LanguagePrefix() string { - helpers.Deprecated("Page.LanguagePrefix", "Use .Site.LanguagePrefix.", false) - return p.p.LanguagePrefix() -} -func (p *pageDeprecated) GetParam(arg0 string) interface{} { - helpers.Deprecated("Page.GetParam", "Use .Param or .Params.myParam.", false) - return p.p.GetParam(arg0) -} -func (p *pageDeprecated) LogicalName() string { - helpers.Deprecated("Page.LogicalName", "Use .File.LogicalName", false) - return p.p.LogicalName() -} -func (p *pageDeprecated) BaseFileName() string { - helpers.Deprecated("Page.BaseFileName", "Use .File.BaseFileName", false) - return p.p.BaseFileName() -} -func (p *pageDeprecated) RSSLink() template.URL { - helpers.Deprecated("Page.RSSLink", "Use the Output Format's link, e.g. something like: \n {{ with .OutputFormats.Get \"RSS\" }}{{ .RelPermalink }}{{ end }}", false) - return p.p.RSSLink() -} -func (p *pageDeprecated) TranslationBaseName() string { - helpers.Deprecated("Page.TranslationBaseName", "Use .File.TranslationBaseName", false) - return p.p.TranslationBaseName() -} -func (p *pageDeprecated) URL() string { - helpers.Deprecated("Page.URL", "Use .Permalink or .RelPermalink. If what you want is the front matter URL value, use .Params.url", false) - return p.p.URL() -} -func (p *pageDeprecated) ContentBaseName() string { - helpers.Deprecated("Page.ContentBaseName", "Use .File.ContentBaseName", false) - return p.p.ContentBaseName() -} -func (p *pageDeprecated) UniqueID() string { - helpers.Deprecated("Page.UniqueID", "Use .File.UniqueID", false) - return p.p.UniqueID() -} -func (p *pageDeprecated) FileInfo() hugofs.FileMetaInfo { - helpers.Deprecated("Page.FileInfo", "Use .File.FileInfo", false) - return p.p.FileInfo() -} diff --git a/resources/page/pagegroup.go b/resources/page/pagegroup.go index fbb6e7e53..081708d62 100644 --- a/resources/page/pagegroup.go +++ b/resources/page/pagegroup.go @@ -14,6 +14,7 @@ package page import ( + "context" "errors" "fmt" "reflect" @@ -21,8 +22,12 @@ import ( "strings" "time" + "github.com/spf13/cast" + "github.com/gohugoio/hugo/common/collections" + "github.com/gohugoio/hugo/common/hreflect" "github.com/gohugoio/hugo/compare" + "github.com/gohugoio/hugo/langs" "github.com/gohugoio/hugo/resources/resource" ) @@ -36,7 +41,10 @@ var ( // PageGroup represents a group of pages, grouped by the key. // The key is typically a year or similar. type PageGroup struct { - Key interface{} + // The key, typically a year or similar. + Key any + + // The Pages in this group. Pages } @@ -49,13 +57,16 @@ type mapKeyByInt struct{ mapKeyValues } func (s mapKeyByInt) Less(i, j int) bool { return s.mapKeyValues[i].Int() < s.mapKeyValues[j].Int() } -type mapKeyByStr struct{ mapKeyValues } - -func (s mapKeyByStr) Less(i, j int) bool { - return compare.LessStrings(s.mapKeyValues[i].String(), s.mapKeyValues[j].String()) +type mapKeyByStr struct { + less func(a, b string) bool + mapKeyValues } -func sortKeys(v []reflect.Value, order string) []reflect.Value { +func (s mapKeyByStr) Less(i, j int) bool { + return s.less(s.mapKeyValues[i].String(), s.mapKeyValues[j].String()) +} + +func sortKeys(examplePage Page, v []reflect.Value, order string) []reflect.Value { if len(v) <= 1 { return v } @@ -68,10 +79,12 @@ func sortKeys(v []reflect.Value, order string) []reflect.Value { sort.Sort(mapKeyByInt{v}) } case reflect.String: + stringLess, close := collatorStringLess(examplePage) + defer close() if order == "desc" { - sort.Sort(sort.Reverse(mapKeyByStr{v})) + sort.Sort(sort.Reverse(mapKeyByStr{stringLess, v})) } else { - sort.Sort(mapKeyByStr{v}) + sort.Sort(mapKeyByStr{stringLess, v}) } } return v @@ -98,7 +111,7 @@ var ( // GroupBy groups by the value in the given field or method name and with the given order. // Valid values for order is asc, desc, rev and reverse. -func (p Pages) GroupBy(key string, order ...string) (PagesGroup, error) { +func (p Pages) GroupBy(ctx context.Context, key string, order ...string) (PagesGroup, error) { if len(p) < 1 { return nil, nil } @@ -109,9 +122,10 @@ func (p Pages) GroupBy(key string, order ...string) (PagesGroup, error) { direction = "desc" } - var ft interface{} - m, ok := pagePtrType.MethodByName(key) - if ok { + var ft any + index := hreflect.GetMethodIndexByName(pagePtrType, key) + if index != -1 { + m := pagePtrType.Method(index) if m.Type.NumOut() == 0 || m.Type.NumOut() > 2 { return nil, errors.New(key + " is a Page method but you can't use it with GroupBy") } @@ -123,6 +137,7 @@ func (p Pages) GroupBy(key string, order ...string) (PagesGroup, error) { } ft = m } else { + var ok bool ft, ok = pagePtrType.Elem().FieldByName(key) if !ok { return nil, errors.New(key + " is neither a field nor a method of Page") @@ -144,7 +159,7 @@ func (p Pages) GroupBy(key string, order ...string) (PagesGroup, error) { case reflect.StructField: fv = ppv.Elem().FieldByName(key) case reflect.Method: - fv = ppv.MethodByName(key).Call([]reflect.Value{})[0] + fv = hreflect.CallMethodByName(ctx, key, ppv)[0] } if !fv.IsValid() { continue @@ -155,7 +170,7 @@ func (p Pages) GroupBy(key string, order ...string) (PagesGroup, error) { tmp.SetMapIndex(fv, reflect.Append(tmp.MapIndex(fv), ppv)) } - sortedKeys := sortKeys(tmp.MapKeys(), direction) + sortedKeys := sortKeys(p[0], tmp.MapKeys(), direction) r := make([]PageGroup, len(sortedKeys)) for i, k := range sortedKeys { r[i] = PageGroup{Key: k.Interface(), Pages: tmp.MapIndex(k).Interface().(Pages)} @@ -190,7 +205,7 @@ func (p Pages) GroupByParam(key string, order ...string) (PagesGroup, error) { } } if !tmp.IsValid() { - return nil, errors.New("there is no such a param") + return nil, nil } for _, e := range p { @@ -207,14 +222,14 @@ func (p Pages) GroupByParam(key string, order ...string) (PagesGroup, error) { } var r []PageGroup - for _, k := range sortKeys(tmp.MapKeys(), direction) { + for _, k := range sortKeys(p[0], tmp.MapKeys(), direction) { r = append(r, PageGroup{Key: k.Interface(), Pages: tmp.MapIndex(k).Interface().(Pages)}) } return r, nil } -func (p Pages) groupByDateField(sorter func(p Pages) Pages, formatter func(p Page) string, order ...string) (PagesGroup, error) { +func (p Pages) groupByDateField(format string, sorter func(p Pages) Pages, getDate func(p Page) time.Time, order ...string) (PagesGroup, error) { if len(p) < 1 { return nil, nil } @@ -225,16 +240,28 @@ func (p Pages) groupByDateField(sorter func(p Pages) Pages, formatter func(p Pag sp = sp.Reverse() } - date := formatter(sp[0].(Page)) + if sp == nil { + return nil, nil + } + + firstPage := sp[0] + date := getDate(firstPage) + + // Pages may be a mix of multiple languages, so we need to use the language + // for the currently rendered Site. + currentSite := firstPage.Site().Current() + formatter := langs.GetTimeFormatter(currentSite.Language()) + formatted := formatter.Format(date, format) var r []PageGroup - r = append(r, PageGroup{Key: date, Pages: make(Pages, 0)}) + r = append(r, PageGroup{Key: formatted, Pages: make(Pages, 0)}) r[0].Pages = append(r[0].Pages, sp[0]) i := 0 for _, e := range sp[1:] { - date = formatter(e.(Page)) - if r[i].Key.(string) != date { - r = append(r, PageGroup{Key: date}) + date = getDate(e) + formatted := formatter.Format(date, format) + if r[i].Key.(string) != formatted { + r = append(r, PageGroup{Key: formatted}) i++ } r[i].Pages = append(r[i].Pages, e) @@ -250,10 +277,10 @@ func (p Pages) GroupByDate(format string, order ...string) (PagesGroup, error) { sorter := func(p Pages) Pages { return p.ByDate() } - formatter := func(p Page) string { - return p.Date().Format(format) + getDate := func(p Page) time.Time { + return p.Date() } - return p.groupByDateField(sorter, formatter, order...) + return p.groupByDateField(format, sorter, getDate, order...) } // GroupByPublishDate groups by the given page's PublishDate value in @@ -264,10 +291,10 @@ func (p Pages) GroupByPublishDate(format string, order ...string) (PagesGroup, e sorter := func(p Pages) Pages { return p.ByPublishDate() } - formatter := func(p Page) string { - return p.PublishDate().Format(format) + getDate := func(p Page) time.Time { + return p.PublishDate() } - return p.groupByDateField(sorter, formatter, order...) + return p.groupByDateField(format, sorter, getDate, order...) } // GroupByExpiryDate groups by the given page's ExpireDate value in @@ -278,10 +305,24 @@ func (p Pages) GroupByExpiryDate(format string, order ...string) (PagesGroup, er sorter := func(p Pages) Pages { return p.ByExpiryDate() } - formatter := func(p Page) string { - return p.ExpiryDate().Format(format) + getDate := func(p Page) time.Time { + return p.ExpiryDate() } - return p.groupByDateField(sorter, formatter, order...) + return p.groupByDateField(format, sorter, getDate, order...) +} + +// GroupByLastmod groups by the given page's Lastmod value in +// the given format and with the given order. +// Valid values for order is asc, desc, rev and reverse. +// For valid format strings, see https://golang.org/pkg/time/#Time.Format +func (p Pages) GroupByLastmod(format string, order ...string) (PagesGroup, error) { + sorter := func(p Pages) Pages { + return p.ByLastmod() + } + getDate := func(p Page) time.Time { + return p.Lastmod() + } + return p.groupByDateField(format, sorter, getDate, order...) } // GroupByParamDate groups by a date set as a param on the page in @@ -289,29 +330,43 @@ func (p Pages) GroupByExpiryDate(format string, order ...string) (PagesGroup, er // Valid values for order is asc, desc, rev and reverse. // For valid format strings, see https://golang.org/pkg/time/#Time.Format func (p Pages) GroupByParamDate(key string, format string, order ...string) (PagesGroup, error) { - sorter := func(p Pages) Pages { + // Cache the dates. + dates := make(map[Page]time.Time) + + sorter := func(pages Pages) Pages { var r Pages - for _, e := range p { - param := resource.GetParamToLower(e, key) - if _, ok := param.(time.Time); ok { - r = append(r, e) + + for _, p := range pages { + param := resource.GetParam(p, key) + var t time.Time + + if param != nil { + var ok bool + if t, ok = param.(time.Time); !ok { + // Probably a string. Try to convert it to time.Time. + t = cast.ToTime(param) + } } + + dates[p] = t + r = append(r, p) } + pdate := func(p1, p2 Page) bool { - p1p, p2p := p1.(Page), p2.(Page) - return resource.GetParamToLower(p1p, key).(time.Time).Unix() < resource.GetParamToLower(p2p, key).(time.Time).Unix() + return dates[p1].Unix() < dates[p2].Unix() } pageBy(pdate).Sort(r) return r } - formatter := func(p Page) string { - return resource.GetParamToLower(p, key).(time.Time).Format(format) + getDate := func(p Page) time.Time { + return dates[p] } - return p.groupByDateField(sorter, formatter, order...) + return p.groupByDateField(format, sorter, getDate, order...) } -// ProbablyEq wraps comare.ProbablyEqer -func (p PageGroup) ProbablyEq(other interface{}) bool { +// ProbablyEq wraps compare.ProbablyEqer +// For internal use. +func (p PageGroup) ProbablyEq(other any) bool { otherP, ok := other.(PageGroup) if !ok { return false @@ -322,16 +377,15 @@ func (p PageGroup) ProbablyEq(other interface{}) bool { } return p.Pages.ProbablyEq(otherP.Pages) - } -// Slice is not meant to be used externally. It's a bridge function +// Slice is for internal use. // for the template functions. See collections.Slice. -func (p PageGroup) Slice(in interface{}) (interface{}, error) { +func (p PageGroup) Slice(in any) (any, error) { switch items := in.(type) { case PageGroup: return items, nil - case []interface{}: + case []any: groups := make(PagesGroup, len(items)) for i, v := range items { g, ok := v.(PageGroup) @@ -355,8 +409,8 @@ func (psg PagesGroup) Len() int { return l } -// ProbablyEq wraps comare.ProbablyEqer -func (psg PagesGroup) ProbablyEq(other interface{}) bool { +// ProbablyEq wraps compare.ProbablyEqer +func (psg PagesGroup) ProbablyEq(other any) bool { otherPsg, ok := other.(PagesGroup) if !ok { return false @@ -373,19 +427,18 @@ func (psg PagesGroup) ProbablyEq(other interface{}) bool { } return true - } // ToPagesGroup tries to convert seq into a PagesGroup. -func ToPagesGroup(seq interface{}) (PagesGroup, error) { +func ToPagesGroup(seq any) (PagesGroup, bool, error) { switch v := seq.(type) { case nil: - return nil, nil + return nil, true, nil case PagesGroup: - return v, nil + return v, true, nil case []PageGroup: - return PagesGroup(v), nil - case []interface{}: + return PagesGroup(v), true, nil + case []any: l := len(v) if l == 0 { break @@ -397,12 +450,12 @@ func ToPagesGroup(seq interface{}) (PagesGroup, error) { if pg, ok := ipg.(PageGroup); ok { pagesGroup[i] = pg } else { - return nil, fmt.Errorf("unsupported type in paginate from slice, got %T instead of PageGroup", ipg) + return nil, false, fmt.Errorf("unsupported type in paginate from slice, got %T instead of PageGroup", ipg) } } - return pagesGroup, nil + return pagesGroup, true, nil } } - return nil, nil + return nil, false, nil } diff --git a/resources/page/pagegroup_test.go b/resources/page/pagegroup_test.go index 26a25c381..69243ac48 100644 --- a/resources/page/pagegroup_test.go +++ b/resources/page/pagegroup_test.go @@ -14,7 +14,8 @@ package page import ( - "reflect" + "context" + "github.com/google/go-cmp/cmp" "strings" "testing" @@ -34,7 +35,8 @@ var pageGroupTestSources = []pageGroupTestObject{ {"/section1/testpage2.md", 3, "2012-01-01", "bar"}, {"/section1/testpage3.md", 2, "2012-04-06", "foo"}, {"/section2/testpage4.md", 1, "2012-03-02", "bar"}, - {"/section2/testpage5.md", 1, "2012-04-06", "baz"}, + // date might also be a full datetime: + {"/section2/testpage5.md", 1, "2012-04-06T00:00:00Z", "baz"}, } func preparePageGroupTestPages(t *testing.T) Pages { @@ -49,14 +51,27 @@ func preparePageGroupTestPages(t *testing.T) Pages { p.date = cast.ToTime(src.date) p.pubDate = cast.ToTime(src.date) p.expiryDate = cast.ToTime(src.date) + p.lastMod = cast.ToTime(src.date).AddDate(3, 0, 0) p.params["custom_param"] = src.param p.params["custom_date"] = cast.ToTime(src.date) + p.params["custom_string_date"] = src.date + p.params["custom_object"] = map[string]any{ + "param": src.param, + "date": cast.ToTime(src.date), + "string_date": src.date, + } pages = append(pages, p) } return pages } +var comparePageGroup = qt.CmpEquals(cmp.Comparer(func(a, b Page) bool { + return a == b +})) + func TestGroupByWithFieldNameArg(t *testing.T) { + c := qt.New(t) + t.Parallel() pages := preparePageGroupTestPages(t) expect := PagesGroup{ @@ -65,16 +80,14 @@ func TestGroupByWithFieldNameArg(t *testing.T) { {Key: 3, Pages: Pages{pages[0], pages[1]}}, } - groups, err := pages.GroupBy("Weight") - if err != nil { - t.Fatalf("Unable to make PagesGroup array: %s", err) - } - if !reflect.DeepEqual(groups, expect) { - t.Errorf("PagesGroup has unexpected groups. It should be %#v, got %#v", expect, groups) - } + groups, err := pages.GroupBy(context.Background(), "Weight") + c.Assert(err, qt.IsNil) + c.Assert(groups, comparePageGroup, expect) } func TestGroupByWithMethodNameArg(t *testing.T) { + c := qt.New(t) + t.Parallel() pages := preparePageGroupTestPages(t) expect := PagesGroup{ @@ -82,16 +95,14 @@ func TestGroupByWithMethodNameArg(t *testing.T) { {Key: "section2", Pages: Pages{pages[3], pages[4]}}, } - groups, err := pages.GroupBy("Type") - if err != nil { - t.Fatalf("Unable to make PagesGroup array: %s", err) - } - if !reflect.DeepEqual(groups, expect) { - t.Errorf("PagesGroup has unexpected groups. It should be %#v, got %#v", expect, groups) - } + groups, err := pages.GroupBy(context.Background(), "Type") + c.Assert(err, qt.IsNil) + c.Assert(groups, comparePageGroup, expect) } func TestGroupByWithSectionArg(t *testing.T) { + c := qt.New(t) + t.Parallel() pages := preparePageGroupTestPages(t) expect := PagesGroup{ @@ -99,16 +110,14 @@ func TestGroupByWithSectionArg(t *testing.T) { {Key: "section2", Pages: Pages{pages[3], pages[4]}}, } - groups, err := pages.GroupBy("Section") - if err != nil { - t.Fatalf("Unable to make PagesGroup array: %s", err) - } - if !reflect.DeepEqual(groups, expect) { - t.Errorf("PagesGroup has unexpected groups. It should be\n%#v, got\n%#v", expect, groups) - } + groups, err := pages.GroupBy(context.Background(), "Section") + c.Assert(err, qt.IsNil) + c.Assert(groups, comparePageGroup, expect) } func TestGroupByInReverseOrder(t *testing.T) { + c := qt.New(t) + t.Parallel() pages := preparePageGroupTestPages(t) expect := PagesGroup{ @@ -117,57 +126,40 @@ func TestGroupByInReverseOrder(t *testing.T) { {Key: 1, Pages: Pages{pages[3], pages[4]}}, } - groups, err := pages.GroupBy("Weight", "desc") - if err != nil { - t.Fatalf("Unable to make PagesGroup array: %s", err) - } - if !reflect.DeepEqual(groups, expect) { - t.Errorf("PagesGroup has unexpected groups. It should be %#v, got %#v", expect, groups) - } + groups, err := pages.GroupBy(context.Background(), "Weight", "desc") + c.Assert(err, qt.IsNil) + c.Assert(groups, comparePageGroup, expect) } func TestGroupByCalledWithEmptyPages(t *testing.T) { + c := qt.New(t) + t.Parallel() var pages Pages - groups, err := pages.GroupBy("Weight") - if err != nil { - t.Fatalf("Unable to make PagesGroup array: %s", err) - } - if groups != nil { - t.Errorf("PagesGroup isn't empty. It should be %#v, got %#v", nil, groups) - } -} - -func TestGroupByParamCalledWithUnavailableKey(t *testing.T) { - t.Parallel() - pages := preparePageGroupTestPages(t) - _, err := pages.GroupByParam("UnavailableKey") - if err == nil { - t.Errorf("GroupByParam should return an error but didn't") - } + groups, err := pages.GroupBy(context.Background(), "Weight") + c.Assert(err, qt.IsNil) + c.Assert(groups, qt.IsNil) } func TestReverse(t *testing.T) { + c := qt.New(t) + t.Parallel() pages := preparePageGroupTestPages(t) - groups1, err := pages.GroupBy("Weight", "desc") - if err != nil { - t.Fatalf("Unable to make PagesGroup array: %s", err) - } + groups1, err := pages.GroupBy(context.Background(), "Weight", "desc") + c.Assert(err, qt.IsNil) + + groups2, err := pages.GroupBy(context.Background(), "Weight") + c.Assert(err, qt.IsNil) - groups2, err := pages.GroupBy("Weight") - if err != nil { - t.Fatalf("Unable to make PagesGroup array: %s", err) - } groups2 = groups2.Reverse() - - if !reflect.DeepEqual(groups2, groups1) { - t.Errorf("PagesGroup is sorted in unexpected order. It should be %#v, got %#v", groups2, groups1) - } + c.Assert(groups2, comparePageGroup, groups1) } func TestGroupByParam(t *testing.T) { + c := qt.New(t) + t.Parallel() pages := preparePageGroupTestPages(t) expect := PagesGroup{ @@ -177,15 +169,13 @@ func TestGroupByParam(t *testing.T) { } groups, err := pages.GroupByParam("custom_param") - if err != nil { - t.Fatalf("Unable to make PagesGroup array: %s", err) - } - if !reflect.DeepEqual(groups, expect) { - t.Errorf("PagesGroup has unexpected groups. It should be %#v, got %#v", expect, groups) - } + c.Assert(err, qt.IsNil) + c.Assert(groups, comparePageGroup, expect) } func TestGroupByParamInReverseOrder(t *testing.T) { + c := qt.New(t) + t.Parallel() pages := preparePageGroupTestPages(t) expect := PagesGroup{ @@ -195,12 +185,8 @@ func TestGroupByParamInReverseOrder(t *testing.T) { } groups, err := pages.GroupByParam("custom_param", "desc") - if err != nil { - t.Fatalf("Unable to make PagesGroup array: %s", err) - } - if !reflect.DeepEqual(groups, expect) { - t.Errorf("PagesGroup has unexpected groups. It should be %#v, got %#v", expect, groups) - } + c.Assert(err, qt.IsNil) + c.Assert(groups, comparePageGroup, expect) } func TestGroupByParamCalledWithCapitalLetterString(t *testing.T) { @@ -213,11 +199,12 @@ func TestGroupByParamCalledWithCapitalLetterString(t *testing.T) { groups, err := pages.GroupByParam("custom_param") c.Assert(err, qt.IsNil) - c.Assert(groups[0].Key, qt.Equals, testStr) - + c.Assert(groups[0].Key, qt.DeepEquals, testStr) } func TestGroupByParamCalledWithSomeUnavailableParams(t *testing.T) { + c := qt.New(t) + t.Parallel() pages := preparePageGroupTestPages(t) delete(pages[1].Params(), "custom_param") @@ -229,36 +216,49 @@ func TestGroupByParamCalledWithSomeUnavailableParams(t *testing.T) { } groups, err := pages.GroupByParam("custom_param") - if err != nil { - t.Fatalf("Unable to make PagesGroup array: %s", err) - } - if !reflect.DeepEqual(groups, expect) { - t.Errorf("PagesGroup has unexpected groups. It should be %#v, got %#v", expect, groups) - } + c.Assert(err, qt.IsNil) + c.Assert(groups, comparePageGroup, expect) } func TestGroupByParamCalledWithEmptyPages(t *testing.T) { + c := qt.New(t) + t.Parallel() var pages Pages groups, err := pages.GroupByParam("custom_param") - if err != nil { - t.Fatalf("Unable to make PagesGroup array: %s", err) - } - if groups != nil { - t.Errorf("PagesGroup isn't empty. It should be %#v, got %#v", nil, groups) - } + c.Assert(err, qt.IsNil) + c.Assert(groups, qt.IsNil) } func TestGroupByParamCalledWithUnavailableParam(t *testing.T) { + c := qt.New(t) + t.Parallel() pages := preparePageGroupTestPages(t) _, err := pages.GroupByParam("unavailable_param") - if err == nil { - t.Errorf("GroupByParam should return an error but didn't") + c.Assert(err, qt.IsNil) +} + +func TestGroupByParamNested(t *testing.T) { + c := qt.New(t) + + t.Parallel() + pages := preparePageGroupTestPages(t) + + expect := PagesGroup{ + {Key: "bar", Pages: Pages{pages[1], pages[3]}}, + {Key: "baz", Pages: Pages{pages[4]}}, + {Key: "foo", Pages: Pages{pages[0], pages[2]}}, } + + groups, err := pages.GroupByParam("custom_object.param") + c.Assert(err, qt.IsNil) + c.Assert(groups, comparePageGroup, expect) } func TestGroupByDate(t *testing.T) { + c := qt.New(t) + t.Parallel() pages := preparePageGroupTestPages(t) expect := PagesGroup{ @@ -268,15 +268,13 @@ func TestGroupByDate(t *testing.T) { } groups, err := pages.GroupByDate("2006-01") - if err != nil { - t.Fatalf("Unable to make PagesGroup array: %s", err) - } - if !reflect.DeepEqual(groups, expect) { - t.Errorf("PagesGroup has unexpected groups. It should be %#v, got %#v", expect, groups) - } + c.Assert(err, qt.IsNil) + c.Assert(groups, comparePageGroup, expect) } func TestGroupByDateInReverseOrder(t *testing.T) { + c := qt.New(t) + t.Parallel() pages := preparePageGroupTestPages(t) expect := PagesGroup{ @@ -286,15 +284,13 @@ func TestGroupByDateInReverseOrder(t *testing.T) { } groups, err := pages.GroupByDate("2006-01", "asc") - if err != nil { - t.Fatalf("Unable to make PagesGroup array: %s", err) - } - if !reflect.DeepEqual(groups, expect) { - t.Errorf("PagesGroup has unexpected groups. It should be %#v, got %#v", expect, groups) - } + c.Assert(err, qt.IsNil) + c.Assert(groups, comparePageGroup, expect) } func TestGroupByPublishDate(t *testing.T) { + c := qt.New(t) + t.Parallel() pages := preparePageGroupTestPages(t) expect := PagesGroup{ @@ -304,15 +300,13 @@ func TestGroupByPublishDate(t *testing.T) { } groups, err := pages.GroupByPublishDate("2006-01") - if err != nil { - t.Fatalf("Unable to make PagesGroup array: %s", err) - } - if !reflect.DeepEqual(groups, expect) { - t.Errorf("PagesGroup has unexpected groups. It should be %#v, got %#v", expect, groups) - } + c.Assert(err, qt.IsNil) + c.Assert(groups, comparePageGroup, expect) } func TestGroupByPublishDateInReverseOrder(t *testing.T) { + c := qt.New(t) + t.Parallel() pages := preparePageGroupTestPages(t) expect := PagesGroup{ @@ -322,27 +316,23 @@ func TestGroupByPublishDateInReverseOrder(t *testing.T) { } groups, err := pages.GroupByDate("2006-01", "asc") - if err != nil { - t.Fatalf("Unable to make PagesGroup array: %s", err) - } - if !reflect.DeepEqual(groups, expect) { - t.Errorf("PagesGroup has unexpected groups. It should be %#v, got %#v", expect, groups) - } + c.Assert(err, qt.IsNil) + c.Assert(groups, comparePageGroup, expect) } func TestGroupByPublishDateWithEmptyPages(t *testing.T) { + c := qt.New(t) + t.Parallel() var pages Pages groups, err := pages.GroupByPublishDate("2006-01") - if err != nil { - t.Fatalf("Unable to make PagesGroup array: %s", err) - } - if groups != nil { - t.Errorf("PagesGroup isn't empty. It should be %#v, got %#v", nil, groups) - } + c.Assert(err, qt.IsNil) + c.Assert(groups, qt.IsNil) } func TestGroupByExpiryDate(t *testing.T) { + c := qt.New(t) + t.Parallel() pages := preparePageGroupTestPages(t) expect := PagesGroup{ @@ -352,15 +342,13 @@ func TestGroupByExpiryDate(t *testing.T) { } groups, err := pages.GroupByExpiryDate("2006-01") - if err != nil { - t.Fatalf("Unable to make PagesGroup array: %s", err) - } - if !reflect.DeepEqual(groups, expect) { - t.Errorf("PagesGroup has unexpected groups. It should be %#v, got %#v", expect, groups) - } + c.Assert(err, qt.IsNil) + c.Assert(groups, comparePageGroup, expect) } func TestGroupByParamDate(t *testing.T) { + c := qt.New(t) + t.Parallel() pages := preparePageGroupTestPages(t) expect := PagesGroup{ @@ -370,15 +358,94 @@ func TestGroupByParamDate(t *testing.T) { } groups, err := pages.GroupByParamDate("custom_date", "2006-01") - if err != nil { - t.Fatalf("Unable to make PagesGroup array: %s", err) + c.Assert(err, qt.IsNil) + c.Assert(groups, comparePageGroup, expect) +} + +func TestGroupByParamDateNested(t *testing.T) { + c := qt.New(t) + + t.Parallel() + pages := preparePageGroupTestPages(t) + expect := PagesGroup{ + {Key: "2012-04", Pages: Pages{pages[4], pages[2], pages[0]}}, + {Key: "2012-03", Pages: Pages{pages[3]}}, + {Key: "2012-01", Pages: Pages{pages[1]}}, } - if !reflect.DeepEqual(groups, expect) { - t.Errorf("PagesGroup has unexpected groups. It should be %#v, got %#v", expect, groups) + + groups, err := pages.GroupByParamDate("custom_object.date", "2006-01") + c.Assert(err, qt.IsNil) + c.Assert(groups, comparePageGroup, expect) +} + +// https://github.com/gohugoio/hugo/issues/3983 +func TestGroupByParamDateWithStringParams(t *testing.T) { + c := qt.New(t) + + t.Parallel() + pages := preparePageGroupTestPages(t) + expect := PagesGroup{ + {Key: "2012-04", Pages: Pages{pages[4], pages[2], pages[0]}}, + {Key: "2012-03", Pages: Pages{pages[3]}}, + {Key: "2012-01", Pages: Pages{pages[1]}}, } + + groups, err := pages.GroupByParamDate("custom_string_date", "2006-01") + c.Assert(err, qt.IsNil) + c.Assert(groups, comparePageGroup, expect) +} + +func TestGroupByParamDateNestedWithStringParams(t *testing.T) { + c := qt.New(t) + + t.Parallel() + pages := preparePageGroupTestPages(t) + expect := PagesGroup{ + {Key: "2012-04", Pages: Pages{pages[4], pages[2], pages[0]}}, + {Key: "2012-03", Pages: Pages{pages[3]}}, + {Key: "2012-01", Pages: Pages{pages[1]}}, + } + + groups, err := pages.GroupByParamDate("custom_object.string_date", "2006-01") + c.Assert(err, qt.IsNil) + c.Assert(groups, comparePageGroup, expect) +} + +func TestGroupByLastmod(t *testing.T) { + c := qt.New(t) + + t.Parallel() + pages := preparePageGroupTestPages(t) + expect := PagesGroup{ + {Key: "2015-04", Pages: Pages{pages[4], pages[2], pages[0]}}, + {Key: "2015-03", Pages: Pages{pages[3]}}, + {Key: "2015-01", Pages: Pages{pages[1]}}, + } + + groups, err := pages.GroupByLastmod("2006-01") + c.Assert(err, qt.IsNil) + c.Assert(groups, comparePageGroup, expect) +} + +func TestGroupByLastmodInReverseOrder(t *testing.T) { + c := qt.New(t) + + t.Parallel() + pages := preparePageGroupTestPages(t) + expect := PagesGroup{ + {Key: "2015-01", Pages: Pages{pages[1]}}, + {Key: "2015-03", Pages: Pages{pages[3]}}, + {Key: "2015-04", Pages: Pages{pages[0], pages[2], pages[4]}}, + } + + groups, err := pages.GroupByLastmod("2006-01", "asc") + c.Assert(err, qt.IsNil) + c.Assert(groups, comparePageGroup, expect) } func TestGroupByParamDateInReverseOrder(t *testing.T) { + c := qt.New(t) + t.Parallel() pages := preparePageGroupTestPages(t) expect := PagesGroup{ @@ -388,22 +455,16 @@ func TestGroupByParamDateInReverseOrder(t *testing.T) { } groups, err := pages.GroupByParamDate("custom_date", "2006-01", "asc") - if err != nil { - t.Fatalf("Unable to make PagesGroup array: %s", err) - } - if !reflect.DeepEqual(groups, expect) { - t.Errorf("PagesGroup has unexpected groups. It should be %#v, got %#v", expect, groups) - } + c.Assert(err, qt.IsNil) + c.Assert(groups, comparePageGroup, expect) } func TestGroupByParamDateWithEmptyPages(t *testing.T) { + c := qt.New(t) + t.Parallel() var pages Pages groups, err := pages.GroupByParamDate("custom_date", "2006-01") - if err != nil { - t.Fatalf("Unable to make PagesGroup array: %s", err) - } - if groups != nil { - t.Errorf("PagesGroup isn't empty. It should be %#v, got %#v", nil, groups) - } + c.Assert(err, qt.IsNil) + c.Assert(groups, qt.IsNil) } diff --git a/resources/page/pagemeta/page_frontmatter.go b/resources/page/pagemeta/page_frontmatter.go index 7b9f13e62..fd4f7759b 100644 --- a/resources/page/pagemeta/page_frontmatter.go +++ b/resources/page/pagemeta/page_frontmatter.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Hugo Authors. All rights reserved. +// Copyright 2024 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,21 +14,363 @@ package pagemeta import ( + "errors" + "fmt" + "path" "strings" "time" + "github.com/gohugoio/hugo/common/hreflect" + "github.com/gohugoio/hugo/common/htime" + "github.com/gohugoio/hugo/common/hugio" "github.com/gohugoio/hugo/common/loggers" - "github.com/gohugoio/hugo/helpers" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/common/paths" + "github.com/gohugoio/hugo/hugofs/files" + "github.com/gohugoio/hugo/markup" + "github.com/gohugoio/hugo/media" + "github.com/gohugoio/hugo/output" + "github.com/gohugoio/hugo/resources/kinds" + "github.com/gohugoio/hugo/resources/page" "github.com/gohugoio/hugo/resources/resource" + "github.com/mitchellh/mapstructure" + + "github.com/gohugoio/hugo/helpers" "github.com/gohugoio/hugo/config" "github.com/spf13/cast" ) +type DatesStrings struct { + Date string `json:"date"` + Lastmod string `json:"lastMod"` + PublishDate string `json:"publishDate"` + ExpiryDate string `json:"expiryDate"` +} + +type Dates struct { + Date time.Time + Lastmod time.Time + PublishDate time.Time + ExpiryDate time.Time +} + +func (d Dates) IsDateOrLastModAfter(in Dates) bool { + return d.Date.After(in.Date) || d.Lastmod.After(in.Lastmod) +} + +func (d *Dates) UpdateDateAndLastmodAndPublishDateIfAfter(in Dates) { + if in.Date.After(d.Date) { + d.Date = in.Date + } + if in.Lastmod.After(d.Lastmod) { + d.Lastmod = in.Lastmod + } + + if in.PublishDate.After(d.PublishDate) && in.PublishDate.Before(htime.Now()) { + d.PublishDate = in.PublishDate + } +} + +func (d Dates) IsAllDatesZero() bool { + return d.Date.IsZero() && d.Lastmod.IsZero() && d.PublishDate.IsZero() && d.ExpiryDate.IsZero() +} + +// Page config that needs to be set early. These cannot be modified by cascade. +type PageConfigEarly struct { + Kind string // The kind of page, e.g. "page", "section", "home" etc. This is usually derived from the content path. + Path string // The canonical path to the page, e.g. /sect/mypage. Note: Leading slash, no trailing slash, no extensions or language identifiers. + Lang string // The language code for this page. This is usually derived from the module mount or filename. + Cascade []map[string]any + + // Content holds the content for this page. + Content Source +} + +// PageConfig configures a Page, typically from front matter. +// Note that all the top level fields are reserved Hugo keywords. +// Any custom configuration needs to be set in the Params map. +type PageConfig struct { + Dates Dates `json:"-"` // Dates holds the four core dates for this page. + DatesStrings + PageConfigEarly `mapstructure:",squash"` + Title string // The title of the page. + LinkTitle string // The link title of the page. + Type string // The content type of the page. + Layout string // The layout to use for to render this page. + Weight int // The weight of the page, used in sorting if set to a non-zero value. + URL string // The URL to the rendered page, e.g. /sect/mypage.html. + Slug string // The slug for this page. + Description string // The description for this page. + Summary string // The summary for this page. + Draft bool // Whether or not the content is a draft. + Headless bool `json:"-"` // Whether or not the page should be rendered. + IsCJKLanguage bool // Whether or not the content is in a CJK language. + TranslationKey string // The translation key for this page. + Keywords []string // The keywords for this page. + Aliases []string // The aliases for this page. + Outputs []string // The output formats to render this page in. If not set, the site's configured output formats for this page kind will be used. + + FrontMatterOnlyValues `mapstructure:"-" json:"-"` + + Sitemap config.SitemapConfig + Build BuildConfig + Menus any // Can be a string, []string or map[string]any. + + // User defined params. + Params maps.Params + + // The raw data from the content adapter. + // TODO(bep) clean up the ContentAdapterData vs Params. + ContentAdapterData map[string]any `mapstructure:"-" json:"-"` + + // Compiled values. + CascadeCompiled *maps.Ordered[page.PageMatcher, page.PageMatcherParamsConfig] `mapstructure:"-" json:"-"` + ContentMediaType media.Type `mapstructure:"-" json:"-"` + ConfiguredOutputFormats output.Formats `mapstructure:"-" json:"-"` + IsFromContentAdapter bool `mapstructure:"-" json:"-"` +} + +func ClonePageConfigForRebuild(p *PageConfig, params map[string]any) *PageConfig { + pp := &PageConfig{ + PageConfigEarly: p.PageConfigEarly, + IsFromContentAdapter: p.IsFromContentAdapter, + } + if pp.IsFromContentAdapter { + pp.ContentAdapterData = params + } else { + pp.Params = params + } + + return pp +} + +var DefaultPageConfig = PageConfig{ + Build: DefaultBuildConfig, +} + +func (p *PageConfig) Validate(pagesFromData bool) error { + if pagesFromData { + if p.Path == "" { + return errors.New("path must be set") + } + if strings.HasPrefix(p.Path, "/") { + return fmt.Errorf("path %q must not start with a /", p.Path) + } + if p.Lang != "" { + return errors.New("lang must not be set") + } + + if p.Content.Markup != "" { + return errors.New("markup must not be set, use mediaType") + } + } + + if p.Cascade != nil { + if !kinds.IsBranch(p.Kind) { + return errors.New("cascade is only supported for branch nodes") + } + } + + return nil +} + +func (p *PageConfig) CompileForPagesFromDataPre(basePath string, logger loggers.Logger, mediaTypes media.Types) error { + // In content adapters, we always get relative paths. + if basePath != "" { + p.Path = path.Join(basePath, p.Path) + } + + if p.Params == nil { + p.Params = make(maps.Params) + } else { + p.Params = maps.PrepareParamsClone(p.Params) + } + + if p.Kind == "" { + p.Kind = kinds.KindPage + } + + if p.Cascade != nil { + cascade, err := page.DecodeCascade(logger, false, p.Cascade) + if err != nil { + return fmt.Errorf("failed to decode cascade: %w", err) + } + p.CascadeCompiled = cascade + } + + // Note that NormalizePathStringBasic will make sure that we don't preserve the unnormalized path. + // We do that when we create pages from the file system; mostly for backward compatibility, + // but also because people tend to use use the filename to name their resources (with spaces and all), + // and this isn't relevant when creating resources from an API where it's easy to add textual meta data. + p.Path = paths.NormalizePathStringBasic(p.Path) + + return p.compilePrePost("", mediaTypes) +} + +func (p *PageConfig) compilePrePost(ext string, mediaTypes media.Types) error { + if p.Content.Markup == "" && p.Content.MediaType == "" { + if ext == "" { + ext = "md" + } + p.ContentMediaType = MarkupToMediaType(ext, mediaTypes) + if p.ContentMediaType.IsZero() { + return fmt.Errorf("failed to resolve media type for suffix %q", ext) + } + } + + var s string + if p.ContentMediaType.IsZero() { + if p.Content.MediaType != "" { + s = p.Content.MediaType + p.ContentMediaType, _ = mediaTypes.GetByType(s) + } + + if p.ContentMediaType.IsZero() && p.Content.Markup != "" { + s = p.Content.Markup + p.ContentMediaType = MarkupToMediaType(s, mediaTypes) + } + } + + if p.ContentMediaType.IsZero() { + return fmt.Errorf("failed to resolve media type for %q", s) + } + + if p.Content.Markup == "" { + p.Content.Markup = p.ContentMediaType.SubType + } + return nil +} + +// Compile sets up the page configuration after all fields have been set. +func (p *PageConfig) Compile(ext string, logger loggers.Logger, outputFormats output.Formats, mediaTypes media.Types) error { + if p.IsFromContentAdapter { + if err := mapstructure.WeakDecode(p.ContentAdapterData, p); err != nil { + err = fmt.Errorf("failed to decode page map: %w", err) + return err + } + // Not needed anymore. + p.ContentAdapterData = nil + } + + if p.Params == nil { + p.Params = make(maps.Params) + } else { + maps.PrepareParams(p.Params) + } + + if err := p.compilePrePost(ext, mediaTypes); err != nil { + return err + } + + if len(p.Outputs) > 0 { + outFormats, err := outputFormats.GetByNames(p.Outputs...) + if err != nil { + return fmt.Errorf("failed to resolve output formats %v: %w", p.Outputs, err) + } else { + p.ConfiguredOutputFormats = outFormats + } + } + + return nil +} + +// MarkupToMediaType converts a markup string to a media type. +func MarkupToMediaType(s string, mediaTypes media.Types) media.Type { + s = strings.ToLower(s) + mt, _ := mediaTypes.GetBestMatch(markup.ResolveMarkup(s)) + return mt +} + +type ResourceConfig struct { + Path string + Name string + Title string + Params maps.Params + Content Source + + // Compiled values. + PathInfo *paths.Path `mapstructure:"-" json:"-"` + ContentMediaType media.Type +} + +func (rc *ResourceConfig) Validate() error { + if rc.Path == "" { + return errors.New("path must be set") + } + if rc.Content.Markup != "" { + return errors.New("markup must not be set, use mediaType") + } + return nil +} + +func (rc *ResourceConfig) Compile(basePath string, pathParser *paths.PathParser, mediaTypes media.Types) error { + if rc.Params != nil { + maps.PrepareParams(rc.Params) + } + + // Note that NormalizePathStringBasic will make sure that we don't preserve the unnormalized path. + // We do that when we create resources from the file system; mostly for backward compatibility, + // but also because people tend to use use the filename to name their resources (with spaces and all), + // and this isn't relevant when creating resources from an API where it's easy to add textual meta data. + rc.Path = paths.NormalizePathStringBasic(path.Join(basePath, rc.Path)) + rc.PathInfo = pathParser.Parse(files.ComponentFolderContent, rc.Path) + if rc.Content.MediaType != "" { + var found bool + rc.ContentMediaType, found = mediaTypes.GetByType(rc.Content.MediaType) + if !found { + return fmt.Errorf("media type %q not found", rc.Content.MediaType) + } + } + return nil +} + +type Source struct { + // MediaType is the media type of the content. + MediaType string + + // The markup used in Value. Only used in front matter. + Markup string + + // The content. + Value any +} + +func (s Source) IsZero() bool { + return !hreflect.IsTruthful(s.Value) +} + +func (s Source) IsResourceValue() bool { + _, ok := s.Value.(resource.Resource) + return ok +} + +func (s Source) ValueAsString() string { + if s.Value == nil { + return "" + } + ss, err := cast.ToStringE(s.Value) + if err != nil { + panic(fmt.Errorf("content source: failed to convert %T to string: %s", s.Value, err)) + } + return ss +} + +func (s Source) ValueAsOpenReadSeekCloser() hugio.OpenReadSeekCloser { + return hugio.NewOpenReadSeekCloser(hugio.NewReadSeekerNoOpCloserFromString(s.ValueAsString())) +} + +// FrontMatterOnlyValues holds values that can only be set via front matter. +type FrontMatterOnlyValues struct { + ResourcesMeta []map[string]any +} + // FrontMatterHandler maps front matter into Page fields and .Params. // Note that we currently have only extracted the date logic. type FrontMatterHandler struct { - fmConfig frontmatterConfig + fmConfig FrontmatterConfig + + contentAdapterDatesHandler func(d *FrontMatterDescriptor) error dateHandler frontMatterFieldHandler lastModHandler frontMatterFieldHandler @@ -38,53 +380,52 @@ type FrontMatterHandler struct { // A map of all date keys configured, including any custom. allDateKeys map[string]bool - logger *loggers.Logger + logger loggers.Logger } // FrontMatterDescriptor describes how to handle front matter for a given Page. // It has pointers to values in the receiving page which gets updated. type FrontMatterDescriptor struct { - - // This the Page's front matter. - Frontmatter map[string]interface{} - // This is the Page's base filename (BaseFilename), e.g. page.md., or // if page is a leaf bundle, the bundle folder name (ContentBaseName). BaseFilename string + // The Page's path if the page is backed by a file, else its title. + PathOrTitle string + // The content file's mod time. ModTime time.Time // May be set from the author date in Git. GitAuthorDate time.Time - // The below are pointers to values on Page and will be modified. + // The below will be modified. + PageConfig *PageConfig - // This is the Page's params. - Params map[string]interface{} - - // This is the Page's dates. - Dates *resource.Dates - - // This is the Page's Slug etc. - PageURLs *URLPath + // The Location to use to parse dates without time zone info. + Location *time.Location } -var ( - dateFieldAliases = map[string][]string{ - fmDate: {}, - fmLastmod: {"modified"}, - fmPubDate: {"pubdate", "published"}, - fmExpiryDate: {"unpublishdate"}, - } -) +var dateFieldAliases = map[string][]string{ + fmDate: {}, + fmLastmod: {"modified"}, + fmPubDate: {"pubdate", "published"}, + fmExpiryDate: {"unpublishdate"}, +} // HandleDates updates all the dates given the current configuration and the // supplied front matter params. Note that this requires all lower-case keys // in the params map. func (f FrontMatterHandler) HandleDates(d *FrontMatterDescriptor) error { - if d.Dates == nil { - panic("missing dates") + if d.PageConfig == nil { + panic("missing pageConfig") + } + + if d.PageConfig.IsFromContentAdapter { + if f.contentAdapterDatesHandler == nil { + panic("missing content adapter date handler") + } + return f.contentAdapterDatesHandler(d) } if f.dateHandler == nil { @@ -119,17 +460,15 @@ func (f FrontMatterHandler) IsDateKey(key string) bool { // A Zero date is a signal that the name can not be parsed. // This follows the format as outlined in Jekyll, https://jekyllrb.com/docs/posts/: // "Where YEAR is a four-digit number, MONTH and DAY are both two-digit numbers" -func dateAndSlugFromBaseFilename(name string) (time.Time, string) { - withoutExt, _ := helpers.FileAndExt(name) +func dateAndSlugFromBaseFilename(location *time.Location, name string) (time.Time, string) { + withoutExt, _ := paths.FileAndExt(name) if len(withoutExt) < 10 { // This can not be a date. return time.Time{}, "" } - // Note: Hugo currently have no custom timezone support. - // We will have to revisit this when that is in place. - d, err := time.Parse("2006-01-02", withoutExt[:10]) + d, err := htime.ToTimeInDefaultLocationE(withoutExt[:10], location) if err != nil { return time.Time{}, "" } @@ -148,7 +487,7 @@ func (f FrontMatterHandler) newChainedFrontMatterFieldHandler(handlers ...frontM // First successful handler wins. success, err := h(d) if err != nil { - f.logger.ERROR.Println(err) + f.logger.Errorln(err) } else if success { return true, nil } @@ -157,11 +496,15 @@ func (f FrontMatterHandler) newChainedFrontMatterFieldHandler(handlers ...frontM } } -type frontmatterConfig struct { - date []string - lastmod []string - publishDate []string - expiryDate []string +type FrontmatterConfig struct { + // Controls how the Date is set from front matter. + Date []string + // Controls how the Lastmod is set from front matter. + Lastmod []string + // Controls how the PublishDate is set from front matter. + PublishDate []string + // Controls how the ExpiryDate is set from front matter. + ExpiryDate []string } const ( @@ -183,16 +526,16 @@ const ( ) // This is the config you get when doing nothing. -func newDefaultFrontmatterConfig() frontmatterConfig { - return frontmatterConfig{ - date: []string{fmDate, fmPubDate, fmLastmod}, - lastmod: []string{fmGitAuthorDate, fmLastmod, fmDate, fmPubDate}, - publishDate: []string{fmPubDate, fmDate}, - expiryDate: []string{fmExpiryDate}, +func newDefaultFrontmatterConfig() FrontmatterConfig { + return FrontmatterConfig{ + Date: []string{fmDate, fmPubDate, fmLastmod}, + Lastmod: []string{fmGitAuthorDate, fmLastmod, fmDate, fmPubDate}, + PublishDate: []string{fmPubDate, fmDate}, + ExpiryDate: []string{fmExpiryDate}, } } -func newFrontmatterConfig(cfg config.Provider) (frontmatterConfig, error) { +func DecodeFrontMatterConfig(cfg config.Provider) (FrontmatterConfig, error) { c := newDefaultFrontmatterConfig() defaultConfig := c @@ -202,13 +545,13 @@ func newFrontmatterConfig(cfg config.Provider) (frontmatterConfig, error) { loki := strings.ToLower(k) switch loki { case fmDate: - c.date = toLowerSlice(v) + c.Date = toLowerSlice(v) case fmPubDate: - c.publishDate = toLowerSlice(v) + c.PublishDate = toLowerSlice(v) case fmLastmod: - c.lastmod = toLowerSlice(v) + c.Lastmod = toLowerSlice(v) case fmExpiryDate: - c.expiryDate = toLowerSlice(v) + c.ExpiryDate = toLowerSlice(v) } } } @@ -219,10 +562,10 @@ func newFrontmatterConfig(cfg config.Provider) (frontmatterConfig, error) { return out } - c.date = expander(c.date, defaultConfig.date) - c.publishDate = expander(c.publishDate, defaultConfig.publishDate) - c.lastmod = expander(c.lastmod, defaultConfig.lastmod) - c.expiryDate = expander(c.expiryDate, defaultConfig.expiryDate) + c.Date = expander(c.Date, defaultConfig.Date) + c.PublishDate = expander(c.PublishDate, defaultConfig.PublishDate) + c.Lastmod = expander(c.Lastmod, defaultConfig.Lastmod) + c.ExpiryDate = expander(c.ExpiryDate, defaultConfig.ExpiryDate) return c, nil } @@ -251,9 +594,9 @@ func expandDefaultValues(values []string, defaults []string) []string { return out } -func toLowerSlice(in interface{}) []string { +func toLowerSlice(in any) []string { out := cast.ToStringSlice(in) - for i := 0; i < len(out); i++ { + for i := range out { out[i] = strings.ToLower(out[i]) } @@ -262,15 +605,9 @@ func toLowerSlice(in interface{}) []string { // NewFrontmatterHandler creates a new FrontMatterHandler with the given logger and configuration. // If no logger is provided, one will be created. -func NewFrontmatterHandler(logger *loggers.Logger, cfg config.Provider) (FrontMatterHandler, error) { - +func NewFrontmatterHandler(logger loggers.Logger, frontMatterConfig FrontmatterConfig) (FrontMatterHandler, error) { if logger == nil { - logger = loggers.NewErrorLogger() - } - - frontMatterConfig, err := newFrontmatterConfig(cfg) - if err != nil { - return FrontMatterHandler{}, err + logger = loggers.NewDefault() } allDateKeys := make(map[string]bool) @@ -282,10 +619,10 @@ func NewFrontmatterHandler(logger *loggers.Logger, cfg config.Provider) (FrontMa } } - addKeys(frontMatterConfig.date) - addKeys(frontMatterConfig.expiryDate) - addKeys(frontMatterConfig.lastmod) - addKeys(frontMatterConfig.publishDate) + addKeys(frontMatterConfig.Date) + addKeys(frontMatterConfig.ExpiryDate) + addKeys(frontMatterConfig.Lastmod) + addKeys(frontMatterConfig.PublishDate) f := FrontMatterHandler{logger: logger, fmConfig: frontMatterConfig, allDateKeys: allDateKeys} @@ -299,34 +636,38 @@ func NewFrontmatterHandler(logger *loggers.Logger, cfg config.Provider) (FrontMa func (f *FrontMatterHandler) createHandlers() error { var err error - if f.dateHandler, err = f.createDateHandler(f.fmConfig.date, + if f.contentAdapterDatesHandler, err = f.createContentAdapterDatesHandler(f.fmConfig); err != nil { + return err + } + + if f.dateHandler, err = f.createDateHandler(f.fmConfig.Date, func(d *FrontMatterDescriptor, t time.Time) { - d.Dates.FDate = t + d.PageConfig.Dates.Date = t setParamIfNotSet(fmDate, t, d) }); err != nil { return err } - if f.lastModHandler, err = f.createDateHandler(f.fmConfig.lastmod, + if f.lastModHandler, err = f.createDateHandler(f.fmConfig.Lastmod, func(d *FrontMatterDescriptor, t time.Time) { setParamIfNotSet(fmLastmod, t, d) - d.Dates.FLastmod = t + d.PageConfig.Dates.Lastmod = t }); err != nil { return err } - if f.publishDateHandler, err = f.createDateHandler(f.fmConfig.publishDate, + if f.publishDateHandler, err = f.createDateHandler(f.fmConfig.PublishDate, func(d *FrontMatterDescriptor, t time.Time) { setParamIfNotSet(fmPubDate, t, d) - d.Dates.FPublishDate = t + d.PageConfig.Dates.PublishDate = t }); err != nil { return err } - if f.expiryDateHandler, err = f.createDateHandler(f.fmConfig.expiryDate, + if f.expiryDateHandler, err = f.createDateHandler(f.fmConfig.ExpiryDate, func(d *FrontMatterDescriptor, t time.Time) { setParamIfNotSet(fmExpiryDate, t, d) - d.Dates.FExpiryDate = t + d.PageConfig.Dates.ExpiryDate = t }); err != nil { return err } @@ -334,11 +675,91 @@ func (f *FrontMatterHandler) createHandlers() error { return nil } -func setParamIfNotSet(key string, value interface{}, d *FrontMatterDescriptor) { - if _, found := d.Params[key]; found { +func setParamIfNotSet(key string, value any, d *FrontMatterDescriptor) { + if _, found := d.PageConfig.Params[key]; found { return } - d.Params[key] = value + d.PageConfig.Params[key] = value +} + +func (f FrontMatterHandler) createContentAdapterDatesHandler(fmcfg FrontmatterConfig) (func(d *FrontMatterDescriptor) error, error) { + setTime := func(key string, value time.Time, in *PageConfig) { + switch key { + case fmDate: + in.Dates.Date = value + case fmLastmod: + in.Dates.Lastmod = value + case fmPubDate: + in.Dates.PublishDate = value + case fmExpiryDate: + in.Dates.ExpiryDate = value + } + } + + getTime := func(key string, in *PageConfig) time.Time { + switch key { + case fmDate: + return in.Dates.Date + case fmLastmod: + return in.Dates.Lastmod + case fmPubDate: + return in.Dates.PublishDate + case fmExpiryDate: + return in.Dates.ExpiryDate + } + return time.Time{} + } + + createSetter := func(identifiers []string, date string) func(pcfg *PageConfig) { + var getTimes []func(in *PageConfig) time.Time + for _, identifier := range identifiers { + if strings.HasPrefix(identifier, ":") { + continue + } + switch identifier { + case fmDate: + getTimes = append(getTimes, func(in *PageConfig) time.Time { + return getTime(fmDate, in) + }) + case fmLastmod: + getTimes = append(getTimes, func(in *PageConfig) time.Time { + return getTime(fmLastmod, in) + }) + case fmPubDate: + getTimes = append(getTimes, func(in *PageConfig) time.Time { + return getTime(fmPubDate, in) + }) + case fmExpiryDate: + getTimes = append(getTimes, func(in *PageConfig) time.Time { + return getTime(fmExpiryDate, in) + }) + } + } + + return func(pcfg *PageConfig) { + for _, get := range getTimes { + if t := get(pcfg); !t.IsZero() { + setTime(date, t, pcfg) + return + } + } + } + } + + setDate := createSetter(fmcfg.Date, fmDate) + setLastmod := createSetter(fmcfg.Lastmod, fmLastmod) + setPublishDate := createSetter(fmcfg.PublishDate, fmPubDate) + setExpiryDate := createSetter(fmcfg.ExpiryDate, fmExpiryDate) + + fn := func(d *FrontMatterDescriptor) error { + pcfg := d.PageConfig + setDate(pcfg) + setLastmod(pcfg) + setPublishDate(pcfg) + setExpiryDate(pcfg) + return nil + } + return fn, nil } func (f FrontMatterHandler) createDateHandler(identifiers []string, setter func(d *FrontMatterDescriptor, t time.Time)) (frontMatterFieldHandler, error) { @@ -359,47 +780,50 @@ func (f FrontMatterHandler) createDateHandler(identifiers []string, setter func( } return f.newChainedFrontMatterFieldHandler(handlers...), nil - } type frontmatterFieldHandlers int func (f *frontmatterFieldHandlers) newDateFieldHandler(key string, setter func(d *FrontMatterDescriptor, t time.Time)) frontMatterFieldHandler { return func(d *FrontMatterDescriptor) (bool, error) { - v, found := d.Frontmatter[key] + v, found := d.PageConfig.Params[key] - if !found { + if !found || v == "" || v == nil { return false, nil } - date, err := cast.ToTimeE(v) - if err != nil { - return false, nil + var date time.Time + if vt, ok := v.(time.Time); ok && vt.Location() == d.Location { + date = vt + } else { + var err error + date, err = htime.ToTimeInDefaultLocationE(v, d.Location) + if err != nil { + return false, fmt.Errorf("the %q front matter field is not a parsable date: see %s", key, d.PathOrTitle) + } + d.PageConfig.Params[key] = date } // We map several date keys to one, so, for example, // "expirydate", "unpublishdate" will all set .ExpiryDate (first found). setter(d, date) - // This is the params key as set in front matter. - d.Params[key] = date - return true, nil } } func (f *frontmatterFieldHandlers) newDateFilenameHandler(setter func(d *FrontMatterDescriptor, t time.Time)) frontMatterFieldHandler { return func(d *FrontMatterDescriptor) (bool, error) { - date, slug := dateAndSlugFromBaseFilename(d.BaseFilename) + date, slug := dateAndSlugFromBaseFilename(d.Location, d.BaseFilename) if date.IsZero() { return false, nil } setter(d, date) - if _, found := d.Frontmatter["slug"]; !found { + if _, found := d.PageConfig.Params["slug"]; !found { // Use slug from filename - d.PageURLs.Slug = slug + d.PageConfig.Slug = slug } return true, nil diff --git a/resources/page/pagemeta/page_frontmatter_test.go b/resources/page/pagemeta/page_frontmatter_test.go index f96d186da..8d50f9b57 100644 --- a/resources/page/pagemeta/page_frontmatter_test.go +++ b/resources/page/pagemeta/page_frontmatter_test.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Hugo Authors. All rights reserved. +// Copyright 2024 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. @@ -11,107 +11,74 @@ // See the License for the specific language governing permissions and // limitations under the License. -package pagemeta +package pagemeta_test import ( "strings" "testing" "time" - "github.com/gohugoio/hugo/resources/resource" - "github.com/spf13/viper" + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/config/testconfig" + "github.com/gohugoio/hugo/media" + "github.com/gohugoio/hugo/output" + + "github.com/gohugoio/hugo/resources/page/pagemeta" qt "github.com/frankban/quicktest" ) -func TestDateAndSlugFromBaseFilename(t *testing.T) { - - t.Parallel() - - c := qt.New(t) - - tests := []struct { - name string - date string - slug string - }{ - {"page.md", "0001-01-01", ""}, - {"2012-09-12-page.md", "2012-09-12", "page"}, - {"2018-02-28-page.md", "2018-02-28", "page"}, - {"2018-02-28_page.md", "2018-02-28", "page"}, - {"2018-02-28 page.md", "2018-02-28", "page"}, - {"2018-02-28page.md", "2018-02-28", "page"}, - {"2018-02-28-.md", "2018-02-28", ""}, - {"2018-02-28-.md", "2018-02-28", ""}, - {"2018-02-28.md", "2018-02-28", ""}, - {"2018-02-28-page", "2018-02-28", "page"}, - {"2012-9-12-page.md", "0001-01-01", ""}, - {"asdfasdf.md", "0001-01-01", ""}, - } - - for _, test := range tests { - expecteFDate, err := time.Parse("2006-01-02", test.date) - c.Assert(err, qt.IsNil) - - gotDate, gotSlug := dateAndSlugFromBaseFilename(test.name) - - c.Assert(gotDate, qt.Equals, expecteFDate) - c.Assert(gotSlug, qt.Equals, test.slug) - - } -} - -func newTestFd() *FrontMatterDescriptor { - return &FrontMatterDescriptor{ - Frontmatter: make(map[string]interface{}), - Params: make(map[string]interface{}), - Dates: &resource.Dates{}, - PageURLs: &URLPath{}, +func newTestFd() *pagemeta.FrontMatterDescriptor { + return &pagemeta.FrontMatterDescriptor{ + PageConfig: &pagemeta.PageConfig{ + Params: make(map[string]any), + }, + Location: time.UTC, } } func TestFrontMatterNewConfig(t *testing.T) { c := qt.New(t) - cfg := viper.New() + cfg := config.New() - cfg.Set("frontmatter", map[string]interface{}{ + cfg.Set("frontmatter", map[string]any{ "date": []string{"publishDate", "LastMod"}, "Lastmod": []string{"publishDate"}, "expiryDate": []string{"lastMod"}, "publishDate": []string{"date"}, }) - fc, err := newFrontmatterConfig(cfg) + fc, err := pagemeta.DecodeFrontMatterConfig(cfg) c.Assert(err, qt.IsNil) - c.Assert(fc.date, qt.DeepEquals, []string{"publishdate", "pubdate", "published", "lastmod", "modified"}) - c.Assert(fc.lastmod, qt.DeepEquals, []string{"publishdate", "pubdate", "published"}) - c.Assert(fc.expiryDate, qt.DeepEquals, []string{"lastmod", "modified"}) - c.Assert(fc.publishDate, qt.DeepEquals, []string{"date"}) + c.Assert(fc.Date, qt.DeepEquals, []string{"publishdate", "pubdate", "published", "lastmod", "modified"}) + c.Assert(fc.Lastmod, qt.DeepEquals, []string{"publishdate", "pubdate", "published"}) + c.Assert(fc.ExpiryDate, qt.DeepEquals, []string{"lastmod", "modified"}) + c.Assert(fc.PublishDate, qt.DeepEquals, []string{"date"}) // Default - cfg = viper.New() - fc, err = newFrontmatterConfig(cfg) + cfg = config.New() + fc, err = pagemeta.DecodeFrontMatterConfig(cfg) c.Assert(err, qt.IsNil) - c.Assert(fc.date, qt.DeepEquals, []string{"date", "publishdate", "pubdate", "published", "lastmod", "modified"}) - c.Assert(fc.lastmod, qt.DeepEquals, []string{":git", "lastmod", "modified", "date", "publishdate", "pubdate", "published"}) - c.Assert(fc.expiryDate, qt.DeepEquals, []string{"expirydate", "unpublishdate"}) - c.Assert(fc.publishDate, qt.DeepEquals, []string{"publishdate", "pubdate", "published", "date"}) + c.Assert(fc.Date, qt.DeepEquals, []string{"date", "publishdate", "pubdate", "published", "lastmod", "modified"}) + c.Assert(fc.Lastmod, qt.DeepEquals, []string{":git", "lastmod", "modified", "date", "publishdate", "pubdate", "published"}) + c.Assert(fc.ExpiryDate, qt.DeepEquals, []string{"expirydate", "unpublishdate"}) + c.Assert(fc.PublishDate, qt.DeepEquals, []string{"publishdate", "pubdate", "published", "date"}) // :default keyword - cfg.Set("frontmatter", map[string]interface{}{ + cfg.Set("frontmatter", map[string]any{ "date": []string{"d1", ":default"}, "lastmod": []string{"d2", ":default"}, "expiryDate": []string{"d3", ":default"}, "publishDate": []string{"d4", ":default"}, }) - fc, err = newFrontmatterConfig(cfg) + fc, err = pagemeta.DecodeFrontMatterConfig(cfg) c.Assert(err, qt.IsNil) - c.Assert(fc.date, qt.DeepEquals, []string{"d1", "date", "publishdate", "pubdate", "published", "lastmod", "modified"}) - c.Assert(fc.lastmod, qt.DeepEquals, []string{"d2", ":git", "lastmod", "modified", "date", "publishdate", "pubdate", "published"}) - c.Assert(fc.expiryDate, qt.DeepEquals, []string{"d3", "expirydate", "unpublishdate"}) - c.Assert(fc.publishDate, qt.DeepEquals, []string{"d4", "publishdate", "pubdate", "published", "date"}) - + c.Assert(fc.Date, qt.DeepEquals, []string{"d1", "date", "publishdate", "pubdate", "published", "lastmod", "modified"}) + c.Assert(fc.Lastmod, qt.DeepEquals, []string{"d2", ":git", "lastmod", "modified", "date", "publishdate", "pubdate", "published"}) + c.Assert(fc.ExpiryDate, qt.DeepEquals, []string{"d3", "expirydate", "unpublishdate"}) + c.Assert(fc.PublishDate, qt.DeepEquals, []string{"d4", "publishdate", "pubdate", "published", "date"}) } func TestFrontMatterDatesHandlers(t *testing.T) { @@ -119,13 +86,13 @@ func TestFrontMatterDatesHandlers(t *testing.T) { for _, handlerID := range []string{":filename", ":fileModTime", ":git"} { - cfg := viper.New() + cfg := config.New() - cfg.Set("frontmatter", map[string]interface{}{ + cfg.Set("frontmatter", map[string]any{ "date": []string{handlerID, "date"}, }) - - handler, err := NewFrontmatterHandler(nil, cfg) + conf := testconfig.GetTestConfig(nil, cfg) + handler, err := pagemeta.NewFrontmatterHandler(nil, conf.GetConfigSection("frontmatter").(pagemeta.FrontmatterConfig)) c.Assert(err, qt.IsNil) d1, _ := time.Parse("2006-01-02", "2018-02-01") @@ -140,121 +107,76 @@ func TestFrontMatterDatesHandlers(t *testing.T) { case ":git": d.GitAuthorDate = d1 } - d.Frontmatter["date"] = d2 + d.PageConfig.Params["date"] = d2 c.Assert(handler.HandleDates(d), qt.IsNil) - c.Assert(d.Dates.FDate, qt.Equals, d1) - c.Assert(d.Params["date"], qt.Equals, d2) + c.Assert(d.PageConfig.Dates.Date, qt.Equals, d1) + c.Assert(d.PageConfig.Params["date"], qt.Equals, d2) d = newTestFd() - d.Frontmatter["date"] = d2 + d.PageConfig.Params["date"] = d2 c.Assert(handler.HandleDates(d), qt.IsNil) - c.Assert(d.Dates.FDate, qt.Equals, d2) - c.Assert(d.Params["date"], qt.Equals, d2) + c.Assert(d.PageConfig.Dates.Date, qt.Equals, d2) + c.Assert(d.PageConfig.Params["date"], qt.Equals, d2) } } -func TestFrontMatterDatesCustomConfig(t *testing.T) { - t.Parallel() - - c := qt.New(t) - - cfg := viper.New() - cfg.Set("frontmatter", map[string]interface{}{ - "date": []string{"mydate"}, - "lastmod": []string{"publishdate"}, - "publishdate": []string{"publishdate"}, - }) - - handler, err := NewFrontmatterHandler(nil, cfg) - c.Assert(err, qt.IsNil) - - testDate, err := time.Parse("2006-01-02", "2018-02-01") - c.Assert(err, qt.IsNil) - - d := newTestFd() - d.Frontmatter["mydate"] = testDate - testDate = testDate.Add(24 * time.Hour) - d.Frontmatter["date"] = testDate - testDate = testDate.Add(24 * time.Hour) - d.Frontmatter["lastmod"] = testDate - testDate = testDate.Add(24 * time.Hour) - d.Frontmatter["publishdate"] = testDate - testDate = testDate.Add(24 * time.Hour) - d.Frontmatter["expirydate"] = testDate - - c.Assert(handler.HandleDates(d), qt.IsNil) - - c.Assert(d.Dates.FDate.Day(), qt.Equals, 1) - c.Assert(d.Dates.FLastmod.Day(), qt.Equals, 4) - c.Assert(d.Dates.FPublishDate.Day(), qt.Equals, 4) - c.Assert(d.Dates.FExpiryDate.Day(), qt.Equals, 5) - - c.Assert(d.Params["date"], qt.Equals, d.Dates.FDate) - c.Assert(d.Params["mydate"], qt.Equals, d.Dates.FDate) - c.Assert(d.Params["publishdate"], qt.Equals, d.Dates.FPublishDate) - c.Assert(d.Params["expirydate"], qt.Equals, d.Dates.FExpiryDate) - - c.Assert(handler.IsDateKey("date"), qt.Equals, false) // This looks odd, but is configured like this. - c.Assert(handler.IsDateKey("mydate"), qt.Equals, true) - c.Assert(handler.IsDateKey("publishdate"), qt.Equals, true) - c.Assert(handler.IsDateKey("pubdate"), qt.Equals, true) - -} - func TestFrontMatterDatesDefaultKeyword(t *testing.T) { t.Parallel() c := qt.New(t) - cfg := viper.New() + cfg := config.New() - cfg.Set("frontmatter", map[string]interface{}{ + cfg.Set("frontmatter", map[string]any{ "date": []string{"mydate", ":default"}, "publishdate": []string{":default", "mypubdate"}, }) - handler, err := NewFrontmatterHandler(nil, cfg) + conf := testconfig.GetTestConfig(nil, cfg) + handler, err := pagemeta.NewFrontmatterHandler(nil, conf.GetConfigSection("frontmatter").(pagemeta.FrontmatterConfig)) c.Assert(err, qt.IsNil) testDate, _ := time.Parse("2006-01-02", "2018-02-01") d := newTestFd() - d.Frontmatter["mydate"] = testDate - d.Frontmatter["date"] = testDate.Add(1 * 24 * time.Hour) - d.Frontmatter["mypubdate"] = testDate.Add(2 * 24 * time.Hour) - d.Frontmatter["publishdate"] = testDate.Add(3 * 24 * time.Hour) + d.PageConfig.Params["mydate"] = testDate + d.PageConfig.Params["date"] = testDate.Add(1 * 24 * time.Hour) + d.PageConfig.Params["mypubdate"] = testDate.Add(2 * 24 * time.Hour) + d.PageConfig.Params["publishdate"] = testDate.Add(3 * 24 * time.Hour) c.Assert(handler.HandleDates(d), qt.IsNil) - c.Assert(d.Dates.FDate.Day(), qt.Equals, 1) - c.Assert(d.Dates.FLastmod.Day(), qt.Equals, 2) - c.Assert(d.Dates.FPublishDate.Day(), qt.Equals, 4) - c.Assert(d.Dates.FExpiryDate.IsZero(), qt.Equals, true) - + c.Assert(d.PageConfig.Dates.Date.Day(), qt.Equals, 1) + c.Assert(d.PageConfig.Dates.Lastmod.Day(), qt.Equals, 2) + c.Assert(d.PageConfig.Dates.PublishDate.Day(), qt.Equals, 4) + c.Assert(d.PageConfig.Dates.ExpiryDate.IsZero(), qt.Equals, true) } -func TestExpandDefaultValues(t *testing.T) { +func TestContentMediaTypeFromMarkup(t *testing.T) { c := qt.New(t) - c.Assert(expandDefaultValues([]string{"a", ":default", "d"}, []string{"b", "c"}), qt.DeepEquals, []string{"a", "b", "c", "d"}) - c.Assert(expandDefaultValues([]string{"a", "b", "c"}, []string{"a", "b", "c"}), qt.DeepEquals, []string{"a", "b", "c"}) - c.Assert(expandDefaultValues([]string{":default", "a", ":default", "d"}, []string{"b", "c"}), qt.DeepEquals, []string{"b", "c", "a", "b", "c", "d"}) + logger := loggers.NewDefault() -} - -func TestFrontMatterDateFieldHandler(t *testing.T) { - t.Parallel() - - c := qt.New(t) - - handlers := new(frontmatterFieldHandlers) - - fd := newTestFd() - d, _ := time.Parse("2006-01-02", "2018-02-01") - fd.Frontmatter["date"] = d - h := handlers.newDateFieldHandler("date", func(d *FrontMatterDescriptor, t time.Time) { d.Dates.FDate = t }) - - handled, err := h(fd) - c.Assert(handled, qt.Equals, true) - c.Assert(err, qt.IsNil) - c.Assert(fd.Dates.FDate, qt.Equals, d) + for _, test := range []struct { + in string + expected string + }{ + {"", "text/markdown"}, + {"md", "text/markdown"}, + {"markdown", "text/markdown"}, + {"mdown", "text/markdown"}, + {"goldmark", "text/markdown"}, + {"html", "text/html"}, + {"htm", "text/html"}, + {"asciidoc", "text/asciidoc"}, + {"asciidocext", "text/asciidoc"}, + {"adoc", "text/asciidoc"}, + {"pandoc", "text/pandoc"}, + {"pdc", "text/pandoc"}, + {"rst", "text/rst"}, + } { + var pc pagemeta.PageConfig + pc.Content.Markup = test.in + c.Assert(pc.Compile("", logger, output.DefaultFormats, media.DefaultTypes), qt.IsNil) + c.Assert(pc.ContentMediaType.Type, qt.Equals, test.expected) + } } diff --git a/resources/page/pagemeta/pagemeta.go b/resources/page/pagemeta/pagemeta.go index 07e5c5673..b6b953231 100644 --- a/resources/page/pagemeta/pagemeta.go +++ b/resources/page/pagemeta/pagemeta.go @@ -13,9 +13,91 @@ package pagemeta -type URLPath struct { - URL string - Permalink string - Slug string - Section string +import ( + "github.com/mitchellh/mapstructure" +) + +const ( + Never = "never" + Always = "always" + ListLocally = "local" + Link = "link" +) + +var DefaultBuildConfig = BuildConfig{ + List: Always, + Render: Always, + PublishResources: true, + set: true, +} + +// BuildConfig holds configuration options about how to handle a Page in Hugo's +// build process. +type BuildConfig struct { + // Whether to add it to any of the page collections. + // Note that the page can always be found with .Site.GetPage. + // Valid values: never, always, local. + // Setting it to 'local' means they will be available via the local + // page collections, e.g. $section.Pages. + // Note: before 0.57.2 this was a bool, so we accept those too. + List string + + // Whether to render it. + // Valid values: never, always, link. + // The value link means it will not be rendered, but it will get a RelPermalink/Permalink. + // Note that before 0.76.0 this was a bool, so we accept those too. + Render string + + // Whether to publish its resources. These will still be published on demand, + // but enabling this can be useful if the originals (e.g. images) are + // never used. + PublishResources bool + + set bool // BuildCfg is non-zero if this is set to true. +} + +// Disable sets all options to their off value. +func (b *BuildConfig) Disable() { + b.List = Never + b.Render = Never + b.PublishResources = false + b.set = true +} + +func (b BuildConfig) IsZero() bool { + return !b.set +} + +func DecodeBuildConfig(m any) (BuildConfig, error) { + b := DefaultBuildConfig + if m == nil { + return b, nil + } + + err := mapstructure.WeakDecode(m, &b) + + // In 0.67.1 we changed the list attribute from a bool to a string (enum). + // Bool values will become 0 or 1. + switch b.List { + case "0": + b.List = Never + case "1": + b.List = Always + case Always, Never, ListLocally: + default: + b.List = Always + } + + // In 0.76.0 we changed the Render from bool to a string. + switch b.Render { + case "0": + b.Render = Never + case "1": + b.Render = Always + case Always, Never, Link: + default: + b.Render = Always + } + + return b, err } diff --git a/resources/page/pagemeta/pagemeta_integration_test.go b/resources/page/pagemeta/pagemeta_integration_test.go new file mode 100644 index 000000000..d0c550b2e --- /dev/null +++ b/resources/page/pagemeta/pagemeta_integration_test.go @@ -0,0 +1,142 @@ +// Copyright 2024 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 pagemeta_test + +import ( + "strings" + "testing" + + "github.com/gohugoio/hugo/hugolib" +) + +func TestLastModEq(t *testing.T) { + files := ` +-- hugo.toml -- +timeZone = "Europe/London" +-- content/p1.md -- +--- +title: p1 +date: 2024-03-13T06:00:00 +--- +-- layouts/_default/single.html -- +Date: {{ .Date }} +Lastmod: {{ .Lastmod }} +Eq: {{ eq .Date .Lastmod }} + +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/p1/index.html", ` +Date: 2024-03-13 06:00:00 +0000 GMT +Lastmod: 2024-03-13 06:00:00 +0000 GMT +Eq: true +`) +} + +func TestDateValidation(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['page','rss','section','sitemap','taxonomy','term'] +-- content/_index.md -- +FRONT_MATTER +-- layouts/index.html -- +{{ .Date.UTC.Format "2006-01-02" }} +-- +` + errorMsg := `ERROR the "date" front matter field is not a parsable date` + + // TOML: unquoted date/time (valid) + f := strings.ReplaceAll(files, "FRONT_MATTER", ` ++++ +date = 2024-10-01 ++++ + `) + b := hugolib.Test(t, f) + b.AssertFileContent("public/index.html", "2024-10-01") + + // TOML: string (valid) + f = strings.ReplaceAll(files, "FRONT_MATTER", ` ++++ +date = "2024-10-01" ++++ + `) + b = hugolib.Test(t, f) + b.AssertFileContent("public/index.html", "2024-10-01") + + // TOML: empty string (valid) + f = strings.ReplaceAll(files, "FRONT_MATTER", ` ++++ +date = "" ++++ + `) + b = hugolib.Test(t, f) + b.AssertFileContent("public/index.html", "0001-01-01") + + // TOML: int (valid) + f = strings.ReplaceAll(files, "FRONT_MATTER", ` ++++ +date = 0 ++++ + `) + b = hugolib.Test(t, f) + b.AssertFileContent("public/index.html", "1970-01-01") + + // TOML: string (invalid) + f = strings.ReplaceAll(files, "FRONT_MATTER", ` ++++ +date = "2024-42-42" ++++ + `) + b, _ = hugolib.TestE(t, f) + b.AssertLogContains(errorMsg) + + // TOML: bool (invalid) + f = strings.ReplaceAll(files, "FRONT_MATTER", ` ++++ +date = true ++++ + `) + b, _ = hugolib.TestE(t, f) + b.AssertLogContains(errorMsg) + + // TOML: float (invalid) + f = strings.ReplaceAll(files, "FRONT_MATTER", ` ++++ +date = 6.7 ++++ + `) + b, _ = hugolib.TestE(t, f) + b.AssertLogContains(errorMsg) + + // JSON: null (valid) + f = strings.ReplaceAll(files, "FRONT_MATTER", ` +{ + "date": null +} + `) + b = hugolib.Test(t, f) + b.AssertFileContent("public/index.html", "0001-01-01") + + // YAML: null (valid) + f = strings.ReplaceAll(files, "FRONT_MATTER", ` +--- +date: +--- + `) + b = hugolib.Test(t, f) + b.AssertFileContent("public/index.html", "0001-01-01") +} diff --git a/resources/page/pagemeta/pagemeta_test.go b/resources/page/pagemeta/pagemeta_test.go new file mode 100644 index 000000000..f205c74f8 --- /dev/null +++ b/resources/page/pagemeta/pagemeta_test.go @@ -0,0 +1,136 @@ +// Copyright 2020 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 pagemeta + +import ( + "fmt" + "testing" + "time" + + "github.com/gohugoio/hugo/htesting/hqt" + + "github.com/gohugoio/hugo/config" + + qt "github.com/frankban/quicktest" +) + +func TestDecodeBuildConfig(t *testing.T) { + t.Parallel() + + c := qt.New(t) + + configTempl := ` +[build] +render = %s +list = %s +publishResources = true` + + for _, test := range []struct { + args []any + expect BuildConfig + }{ + { + []any{"true", "true"}, + BuildConfig{ + Render: Always, + List: Always, + PublishResources: true, + set: true, + }, + }, + {[]any{"true", "false"}, BuildConfig{ + Render: Always, + List: Never, + PublishResources: true, + set: true, + }}, + {[]any{`"always"`, `"always"`}, BuildConfig{ + Render: Always, + List: Always, + PublishResources: true, + set: true, + }}, + {[]any{`"never"`, `"never"`}, BuildConfig{ + Render: Never, + List: Never, + PublishResources: true, + set: true, + }}, + {[]any{`"link"`, `"local"`}, BuildConfig{ + Render: Link, + List: ListLocally, + PublishResources: true, + set: true, + }}, + {[]any{`"always"`, `"asdfadf"`}, BuildConfig{ + Render: Always, + List: Always, + PublishResources: true, + set: true, + }}, + } { + cfg, err := config.FromConfigString(fmt.Sprintf(configTempl, test.args...), "toml") + c.Assert(err, qt.IsNil) + bcfg, err := DecodeBuildConfig(cfg.Get("build")) + c.Assert(err, qt.IsNil) + + eq := qt.CmpEquals(hqt.DeepAllowUnexported(BuildConfig{})) + + c.Assert(bcfg, eq, test.expect) + + } +} + +func TestDateAndSlugFromBaseFilename(t *testing.T) { + t.Parallel() + + c := qt.New(t) + + tests := []struct { + name string + date string + slug string + }{ + {"page.md", "0001-01-01", ""}, + {"2012-09-12-page.md", "2012-09-12", "page"}, + {"2018-02-28-page.md", "2018-02-28", "page"}, + {"2018-02-28_page.md", "2018-02-28", "page"}, + {"2018-02-28 page.md", "2018-02-28", "page"}, + {"2018-02-28page.md", "2018-02-28", "page"}, + {"2018-02-28-.md", "2018-02-28", ""}, + {"2018-02-28-.md", "2018-02-28", ""}, + {"2018-02-28.md", "2018-02-28", ""}, + {"2018-02-28-page", "2018-02-28", "page"}, + {"2012-9-12-page.md", "0001-01-01", ""}, + {"asdfasdf.md", "0001-01-01", ""}, + } + + for _, test := range tests { + expecteFDate, err := time.Parse("2006-01-02", test.date) + c.Assert(err, qt.IsNil) + + gotDate, gotSlug := dateAndSlugFromBaseFilename(time.UTC, test.name) + + c.Assert(gotDate, qt.Equals, expecteFDate) + c.Assert(gotSlug, qt.Equals, test.slug) + + } +} + +func TestExpandDefaultValues(t *testing.T) { + c := qt.New(t) + c.Assert(expandDefaultValues([]string{"a", ":default", "d"}, []string{"b", "c"}), qt.DeepEquals, []string{"a", "b", "c", "d"}) + c.Assert(expandDefaultValues([]string{"a", "b", "c"}, []string{"a", "b", "c"}), qt.DeepEquals, []string{"a", "b", "c"}) + c.Assert(expandDefaultValues([]string{":default", "a", ":default", "d"}, []string{"b", "c"}), qt.DeepEquals, []string{"b", "c", "a", "b", "c", "d"}) +} diff --git a/resources/page/pages.go b/resources/page/pages.go index ac69a8079..088abb9ac 100644 --- a/resources/page/pages.go +++ b/resources/page/pages.go @@ -22,14 +22,11 @@ import ( "github.com/gohugoio/hugo/resources/resource" ) -var ( - _ resource.ResourcesConverter = Pages{} - _ compare.ProbablyEqer = Pages{} -) - -// Pages is a slice of pages. This is the most common list type in Hugo. +// Pages is a slice of Page objects. This is the most common list type in Hugo. type Pages []Page +// String returns a string representation of the list. +// For internal use. func (ps Pages) String() string { return fmt.Sprintf("Pages(%d)", len(ps)) } @@ -42,7 +39,8 @@ func (ps Pages) shuffle() { } } -// ToResources wraps resource.ResourcesConverter +// ToResources wraps resource.ResourcesConverter. +// For internal use. func (pages Pages) ToResources() resource.Resources { r := make(resource.Resources, len(pages)) for i, p := range pages { @@ -52,7 +50,7 @@ func (pages Pages) ToResources() resource.Resources { } // ToPages tries to convert seq into Pages. -func ToPages(seq interface{}) (Pages, error) { +func ToPages(seq any) (Pages, error) { if seq == nil { return Pages{}, nil } @@ -68,11 +66,9 @@ func ToPages(seq interface{}) (Pages, error) { return v.Pages, nil case []Page: pages := make(Pages, len(v)) - for i, vv := range v { - pages[i] = vv - } + copy(pages, v) return pages, nil - case []interface{}: + case []any: pages := make(Pages, len(v)) success := true for i, vv := range v { @@ -91,10 +87,12 @@ func ToPages(seq interface{}) (Pages, error) { return nil, fmt.Errorf("cannot convert type %T to Pages", seq) } -func (p Pages) Group(key interface{}, in interface{}) (interface{}, error) { +// Group groups the pages in in by key. +// This implements collections.Grouper. +func (p Pages) Group(key any, in any) (any, error) { pages, err := ToPages(in) if err != nil { - return nil, err + return PageGroup{}, err } return PageGroup{Key: key, Pages: pages}, nil } @@ -104,8 +102,9 @@ func (p Pages) Len() int { return len(p) } -// ProbablyEq wraps comare.ProbablyEqer -func (pages Pages) ProbablyEq(other interface{}) bool { +// ProbablyEq wraps compare.ProbablyEqer +// For internal use. +func (pages Pages) ProbablyEq(other any) bool { otherPages, ok := other.(Pages) if !ok { return false @@ -131,21 +130,11 @@ func (pages Pages) ProbablyEq(other interface{}) bool { return true } -func (ps Pages) removeFirstIfFound(p Page) Pages { - ii := -1 - for i, pp := range ps { - if p.Eq(pp) { - ii = i - break - } - } - - if ii != -1 { - ps = append(ps[:ii], ps[ii+1:]...) - } - return ps -} - // PagesFactory somehow creates some Pages. // We do a lot of lazy Pages initialization in Hugo, so we need a type. type PagesFactory func() Pages + +var ( + _ resource.ResourcesConverter = Pages{} + _ compare.ProbablyEqer = Pages{} +) diff --git a/resources/page/pages_cache.go b/resources/page/pages_cache.go index e82d9a8cf..5300d5521 100644 --- a/resources/page/pages_cache.go +++ b/resources/page/pages_cache.go @@ -14,6 +14,7 @@ package page import ( + "slices" "sync" ) @@ -92,7 +93,7 @@ func (c *pageCache) getP(key string, apply func(p *Pages), pageLists ...Pages) ( } p := pageLists[0] - pagesCopy := append(Pages(nil), p...) + pagesCopy := slices.Clone(p) if apply != nil { apply(&pagesCopy) @@ -106,7 +107,6 @@ func (c *pageCache) getP(key string, apply func(p *Pages), pageLists ...Pages) ( } return pagesCopy, false - } // pagesEqual returns whether p1 and p2 are equal. @@ -127,7 +127,7 @@ func pagesEqual(p1, p2 Pages) bool { return true } - for i := 0; i < len(p1); i++ { + for i := range p1 { if p1[i] != p2[i] { return false } diff --git a/resources/page/pages_cache_test.go b/resources/page/pages_cache_test.go index 825bdc31f..9e6af1c28 100644 --- a/resources/page/pages_cache_test.go +++ b/resources/page/pages_cache_test.go @@ -41,11 +41,11 @@ func TestPageCache(t *testing.T) { var testPageSets []Pages - for i := 0; i < 50; i++ { + for i := range 50 { testPageSets = append(testPageSets, createSortTestPages(i+1)) } - for j := 0; j < 100; j++ { + for range 100 { wg.Add(1) go func() { defer wg.Done() @@ -75,7 +75,7 @@ func TestPageCache(t *testing.T) { func BenchmarkPageCache(b *testing.B) { cache := newPageCache() pages := make(Pages, 30) - for i := 0; i < 30; i++ { + for i := range 30 { pages[i] = &testPage{title: "p" + strconv.Itoa(i)} } key := "key" diff --git a/resources/page/pages_language_merge.go b/resources/page/pages_language_merge.go index 11393a754..aa2ec2e0d 100644 --- a/resources/page/pages_language_merge.go +++ b/resources/page/pages_language_merge.go @@ -17,14 +17,12 @@ import ( "fmt" ) -var ( - _ pagesLanguageMerger = (*Pages)(nil) -) +var _ pagesLanguageMerger = (*Pages)(nil) type pagesLanguageMerger interface { MergeByLanguage(other Pages) Pages // Needed for integration with the tpl package. - MergeByLanguageInterface(other interface{}) (interface{}, error) + MergeByLanguageInterface(other any) (any, error) } // MergeByLanguage supplies missing translations in p1 with values from p2. @@ -52,7 +50,8 @@ func (p1 Pages) MergeByLanguage(p2 Pages) Pages { // MergeByLanguageInterface is the generic version of MergeByLanguage. It // is here just so it can be called from the tpl package. -func (p1 Pages) MergeByLanguageInterface(in interface{}) (interface{}, error) { +// This is for internal use. +func (p1 Pages) MergeByLanguageInterface(in any) (any, error) { if in == nil { return p1, nil } diff --git a/resources/page/pages_prev_next.go b/resources/page/pages_prev_next.go index dd767c667..b8084bad9 100644 --- a/resources/page/pages_prev_next.go +++ b/resources/page/pages_prev_next.go @@ -13,7 +13,7 @@ package page -// Next returns the next page reletive to the given +// Next returns the next page relative to the given func (p Pages) Next(cur Page) Page { x := searchPage(cur, p) if x <= 0 { @@ -22,7 +22,7 @@ func (p Pages) Next(cur Page) Page { return p[x-1] } -// Prev returns the previous page reletive to the given +// Prev returns the previous page relative to the given func (p Pages) Prev(cur Page) Page { x := searchPage(cur, p) @@ -31,5 +31,4 @@ func (p Pages) Prev(cur Page) Page { } return p[x+1] - } diff --git a/resources/page/pages_prev_next_integration_test.go b/resources/page/pages_prev_next_integration_test.go new file mode 100644 index 000000000..d61d23cf0 --- /dev/null +++ b/resources/page/pages_prev_next_integration_test.go @@ -0,0 +1,82 @@ +// Copyright 2024 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 page_test + +import ( + "strings" + "testing" + + "github.com/gohugoio/hugo/hugolib" +) + +func TestNextPrevConfig(t *testing.T) { + filesTemplate := ` +-- hugo.toml -- +-- content/mysection/_index.md -- +-- content/mysection/p1.md -- +--- +title: "Page 1" +weight: 10 +--- +-- content/mysection/p2.md -- +--- +title: "Page 2" +weight: 20 +--- +-- content/mysection/p3.md -- +--- +title: "Page 3" +weight: 30 +--- +-- layouts/_default/single.html -- +{{ .Title }}|Next: {{ with .Next}}{{ .Title}}{{ end }}|Prev: {{ with .Prev}}{{ .Title}}{{ end }}|NextInSection: {{ with .NextInSection}}{{ .Title}}{{ end }}|PrevInSection: {{ with .PrevInSection}}{{ .Title}}{{ end }}| + +` + b := hugolib.Test(t, filesTemplate) + + b.AssertFileContent("public/mysection/p1/index.html", "Page 1|Next: |Prev: Page 2|NextInSection: |PrevInSection: Page 2|") + b.AssertFileContent("public/mysection/p2/index.html", "Page 2|Next: Page 1|Prev: Page 3|NextInSection: Page 1|PrevInSection: Page 3|") + b.AssertFileContent("public/mysection/p3/index.html", "Page 3|Next: Page 2|Prev: |NextInSection: Page 2|PrevInSection: |") + + files := strings.ReplaceAll(filesTemplate, "-- hugo.toml --", `-- hugo.toml -- +[page] +nextPrevSortOrder="aSc" +nextPrevInSectionSortOrder="asC" +`) + + b = hugolib.Test(t, files) + + b.AssertFileContent("public/mysection/p1/index.html", "Page 1|Next: Page 2|Prev: |NextInSection: Page 2|PrevInSection: |") + b.AssertFileContent("public/mysection/p2/index.html", "Page 2|Next: Page 3|Prev: Page 1|NextInSection: Page 3|PrevInSection: Page 1|") + b.AssertFileContent("public/mysection/p3/index.html", "Page 3|Next: |Prev: Page 2|NextInSection: |PrevInSection: Page 2|") + + files = strings.ReplaceAll(filesTemplate, "-- hugo.toml --", `-- hugo.toml -- +[page] +nextPrevSortOrder="aSc" +`) + + b = hugolib.Test(t, files) + + b.AssertFileContent("public/mysection/p1/index.html", "Page 1|Next: Page 2|Prev: |NextInSection: |PrevInSection: Page 2|") + b.AssertFileContent("public/mysection/p2/index.html", "Page 2|Next: Page 3|Prev: Page 1|NextInSection: Page 1|PrevInSection: Page 3|") + b.AssertFileContent("public/mysection/p3/index.html", "Page 3|Next: |Prev: Page 2|NextInSection: Page 2|PrevInSection: |") + + files = strings.ReplaceAll(filesTemplate, "-- hugo.toml --", `-- hugo.toml -- +[page] +nextPrevInSectionSortOrder="aSc" +`) + + b = hugolib.Test(t, files) + + b.AssertFileContent("public/mysection/p1/index.html", "Page 1|Next: |Prev: Page 2|NextInSection: Page 2|PrevInSection: |") +} diff --git a/resources/page/pages_prev_next_test.go b/resources/page/pages_prev_next_test.go index 689e102c2..0ee1564cd 100644 --- a/resources/page/pages_prev_next_test.go +++ b/resources/page/pages_prev_next_test.go @@ -78,7 +78,6 @@ func TestWeightedPagesPrev(t *testing.T) { c.Assert(w.Prev(w[0].Page), qt.Equals, w[1].Page) c.Assert(w.Prev(w[1].Page), qt.Equals, w[2].Page) c.Assert(w.Prev(w[4].Page), qt.IsNil) - } func TestWeightedPagesNext(t *testing.T) { @@ -89,5 +88,4 @@ func TestWeightedPagesNext(t *testing.T) { c.Assert(w.Next(w[0].Page), qt.IsNil) c.Assert(w.Next(w[1].Page), qt.Equals, w[0].Page) c.Assert(w.Next(w[4].Page), qt.Equals, w[3].Page) - } diff --git a/resources/page/pages_related.go b/resources/page/pages_related.go index 1a4386135..402ed905f 100644 --- a/resources/page/pages_related.go +++ b/resources/page/pages_related.go @@ -14,11 +14,15 @@ package page import ( + "context" + "fmt" "sync" + "github.com/gohugoio/hugo/common/para" "github.com/gohugoio/hugo/common/types" + "github.com/gohugoio/hugo/config" "github.com/gohugoio/hugo/related" - "github.com/pkg/errors" + "github.com/mitchellh/mapstructure" "github.com/spf13/cast" ) @@ -31,93 +35,103 @@ var ( // A PageGenealogist finds related pages in a page collection. This interface is implemented // by Pages and PageGroup, which makes it available as `{{ .RegularRelated . }}` etc. type PageGenealogist interface { - // Template example: // {{ $related := .RegularPages.Related . }} - Related(doc related.Document) (Pages, error) + Related(ctx context.Context, opts any) (Pages, error) // Template example: // {{ $related := .RegularPages.RelatedIndices . "tags" "date" }} - RelatedIndices(doc related.Document, indices ...interface{}) (Pages, error) + // Deprecated: Use Related instead. + RelatedIndices(ctx context.Context, doc related.Document, indices ...any) (Pages, error) // Template example: // {{ $related := .RegularPages.RelatedTo ( keyVals "tags" "hugo", "rocks") ( keyVals "date" .Date ) }} - RelatedTo(args ...types.KeyValues) (Pages, error) + // Deprecated: Use Related instead. + RelatedTo(ctx context.Context, args ...types.KeyValues) (Pages, error) } // Related searches all the configured indices with the search keywords from the // supplied document. -func (p Pages) Related(doc related.Document) (Pages, error) { - result, err := p.searchDoc(doc) +func (p Pages) Related(ctx context.Context, optsv any) (Pages, error) { + if len(p) == 0 { + return nil, nil + } + + var opts related.SearchOpts + switch v := optsv.(type) { + case related.Document: + opts.Document = v + case map[string]any: + if err := mapstructure.WeakDecode(v, &opts); err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("invalid argument type %T", optsv) + } + + result, err := p.search(ctx, opts) if err != nil { return nil, err } - if page, ok := doc.(Page); ok { - return result.removeFirstIfFound(page), nil - } - return result, nil - } // RelatedIndices searches the given indices with the search keywords from the // supplied document. -func (p Pages) RelatedIndices(doc related.Document, indices ...interface{}) (Pages, error) { +// Deprecated: Use Related instead. +func (p Pages) RelatedIndices(ctx context.Context, doc related.Document, indices ...any) (Pages, error) { indicesStr, err := cast.ToStringSliceE(indices) if err != nil { return nil, err } - result, err := p.searchDoc(doc, indicesStr...) + opts := related.SearchOpts{ + Document: doc, + Indices: indicesStr, + } + + result, err := p.search(ctx, opts) if err != nil { return nil, err } - if page, ok := doc.(Page); ok { - return result.removeFirstIfFound(page), nil - } - return result, nil - } // RelatedTo searches the given indices with the corresponding values. -func (p Pages) RelatedTo(args ...types.KeyValues) (Pages, error) { +// Deprecated: Use Related instead. +func (p Pages) RelatedTo(ctx context.Context, args ...types.KeyValues) (Pages, error) { if len(p) == 0 { return nil, nil } - return p.search(args...) + opts := related.SearchOpts{ + NamedSlices: args, + } + return p.search(ctx, opts) } -func (p Pages) search(args ...types.KeyValues) (Pages, error) { - return p.withInvertedIndex(func(idx *related.InvertedIndex) ([]related.Document, error) { - return idx.SearchKeyValues(args...) - }) - -} - -func (p Pages) searchDoc(doc related.Document, indices ...string) (Pages, error) { - return p.withInvertedIndex(func(idx *related.InvertedIndex) ([]related.Document, error) { - return idx.SearchDoc(doc, indices...) +func (p Pages) search(ctx context.Context, opts related.SearchOpts) (Pages, error) { + return p.withInvertedIndex(ctx, func(idx *related.InvertedIndex) ([]related.Document, error) { + return idx.Search(ctx, opts) }) } -func (p Pages) withInvertedIndex(search func(idx *related.InvertedIndex) ([]related.Document, error)) (Pages, error) { +func (p Pages) withInvertedIndex(ctx context.Context, search func(idx *related.InvertedIndex) ([]related.Document, error)) (Pages, error) { if len(p) == 0 { return nil, nil } - d, ok := p[0].(InternalDependencies) + d, ok := p[0].(RelatedDocsHandlerProvider) if !ok { - return nil, errors.Errorf("invalid type %T in related serch", p[0]) + return nil, fmt.Errorf("invalid type %T in related search", p[0]) } - cache := d.GetRelatedDocsHandler() + cache := d.GetInternalRelatedDocsHandler() - searchIndex, err := cache.getOrCreateIndex(p) + searchIndex, err := cache.getOrCreateIndex(ctx, p) if err != nil { return nil, err } @@ -149,10 +163,12 @@ type RelatedDocsHandler struct { postingLists []*cachedPostingList mu sync.RWMutex + + workers *para.Workers } func NewRelatedDocsHandler(cfg related.Config) *RelatedDocsHandler { - return &RelatedDocsHandler{cfg: cfg} + return &RelatedDocsHandler{cfg: cfg, workers: para.New(config.GetNumWorkerMultiplier())} } func (s *RelatedDocsHandler) Clone() *RelatedDocsHandler { @@ -169,7 +185,7 @@ func (s *RelatedDocsHandler) getIndex(p Pages) *related.InvertedIndex { return nil } -func (s *RelatedDocsHandler) getOrCreateIndex(p Pages) (*related.InvertedIndex, error) { +func (s *RelatedDocsHandler) getOrCreateIndex(ctx context.Context, p Pages) (*related.InvertedIndex, error) { s.mu.RLock() cachedIndex := s.getIndex(p) if cachedIndex != nil { @@ -181,19 +197,47 @@ func (s *RelatedDocsHandler) getOrCreateIndex(p Pages) (*related.InvertedIndex, s.mu.Lock() defer s.mu.Unlock() + // Double check. if cachedIndex := s.getIndex(p); cachedIndex != nil { return cachedIndex, nil } + for _, c := range s.cfg.Indices { + if c.Type == related.TypeFragments { + // This will trigger building the Pages' fragment map. + g, _ := s.workers.Start(ctx) + for _, page := range p { + fp, ok := page.(related.FragmentProvider) + if !ok { + continue + } + g.Run(func() error { + fp.Fragments(ctx) + return nil + }) + } + + if err := g.Wait(); err != nil { + return nil, err + } + + break + } + } + searchIndex := related.NewInvertedIndex(s.cfg) for _, page := range p { - if err := searchIndex.Add(page); err != nil { + if err := searchIndex.Add(ctx, page); err != nil { return nil, err } } s.postingLists = append(s.postingLists, &cachedPostingList{p: p, postingList: searchIndex}) + if err := searchIndex.Finalize(ctx); err != nil { + return nil, err + } + return searchIndex, nil } diff --git a/resources/page/pages_related_test.go b/resources/page/pages_related_test.go index be75a62cd..75ab7ecb9 100644 --- a/resources/page/pages_related_test.go +++ b/resources/page/pages_related_test.go @@ -14,6 +14,7 @@ package page import ( + "context" "testing" "time" @@ -31,46 +32,62 @@ func TestRelated(t *testing.T) { &testPage{ title: "Page 1", pubDate: mustParseDate("2017-01-03"), - params: map[string]interface{}{ + params: map[string]any{ "keywords": []string{"hugo", "says"}, }, }, &testPage{ title: "Page 2", pubDate: mustParseDate("2017-01-02"), - params: map[string]interface{}{ + params: map[string]any{ "keywords": []string{"hugo", "rocks"}, }, }, &testPage{ title: "Page 3", pubDate: mustParseDate("2017-01-01"), - params: map[string]interface{}{ + params: map[string]any{ "keywords": []string{"bep", "says"}, }, }, } - result, err := pages.RelatedTo(types.NewKeyValuesStrings("keywords", "hugo", "rocks")) + ctx := context.Background() + opts := map[string]any{ + "namedSlices": types.NewKeyValuesStrings("keywords", "hugo", "rocks"), + } + result, err := pages.Related(ctx, opts) c.Assert(err, qt.IsNil) c.Assert(len(result), qt.Equals, 2) c.Assert(result[0].Title(), qt.Equals, "Page 2") c.Assert(result[1].Title(), qt.Equals, "Page 1") - result, err = pages.Related(pages[0]) + result, err = pages.Related(ctx, pages[0]) c.Assert(err, qt.IsNil) c.Assert(len(result), qt.Equals, 2) c.Assert(result[0].Title(), qt.Equals, "Page 2") c.Assert(result[1].Title(), qt.Equals, "Page 3") - result, err = pages.RelatedIndices(pages[0], "keywords") + opts = map[string]any{ + "document": pages[0], + "indices": []string{"keywords"}, + } + result, err = pages.Related(ctx, opts) c.Assert(err, qt.IsNil) c.Assert(len(result), qt.Equals, 2) c.Assert(result[0].Title(), qt.Equals, "Page 2") c.Assert(result[1].Title(), qt.Equals, "Page 3") - result, err = pages.RelatedTo(types.NewKeyValuesStrings("keywords", "bep", "rocks")) + opts = map[string]any{ + "namedSlices": []types.KeyValues{ + { + Key: "keywords", + Values: []any{"bep", "rocks"}, + }, + }, + } + result, err = pages.Related(context.Background(), opts) c.Assert(err, qt.IsNil) c.Assert(len(result), qt.Equals, 2) c.Assert(result[0].Title(), qt.Equals, "Page 2") diff --git a/resources/page/pages_sort.go b/resources/page/pages_sort.go index b0e7f8e32..e77bb7e7c 100644 --- a/resources/page/pages_sort.go +++ b/resources/page/pages_sort.go @@ -14,8 +14,12 @@ package page import ( + "context" "sort" + "github.com/gohugoio/hugo/common/collections" + "github.com/gohugoio/hugo/langs" + "github.com/gohugoio/hugo/resources/resource" "github.com/gohugoio/hugo/compare" @@ -37,6 +41,32 @@ type pageSorter struct { // pageBy is a closure used in the Sort.Less method. type pageBy func(p1, p2 Page) bool +func getOrdinals(p1, p2 Page) (int, int) { + p1o, ok1 := p1.(collections.Order) + if !ok1 { + return -1, -1 + } + p2o, ok2 := p2.(collections.Order) + if !ok2 { + return -1, -1 + } + + return p1o.Ordinal(), p2o.Ordinal() +} + +func getWeight0s(p1, p2 Page) (int, int) { + p1w, ok1 := p1.(resource.Weight0Provider) + if !ok1 { + return -1, -1 + } + p2w, ok2 := p2.(resource.Weight0Provider) + if !ok2 { + return -1, -1 + } + + return p1w.Weight0(), p2w.Weight0() +} + // Sort stable sorts the pages given the receiver's sort order. func (by pageBy) Sort(pages Pages) { ps := &pageSorter{ @@ -49,16 +79,25 @@ func (by pageBy) Sort(pages Pages) { var ( // DefaultPageSort is the default sort func for pages in Hugo: - // Order by Weight, Date, LinkTitle and then full file path. + // Order by Ordinal, Weight, Date, LinkTitle and then full file path. DefaultPageSort = func(p1, p2 Page) bool { + o1, o2 := getOrdinals(p1, p2) + if o1 != o2 && o1 != -1 && o2 != -1 { + return o1 < o2 + } + // Weight0, as by the weight of the taxonomy entrie in the front matter. + w01, w02 := getWeight0s(p1, p2) + if w01 != w02 && w01 != -1 && w02 != -1 { + return w01 < w02 + } + if p1.Weight() == p2.Weight() { if p1.Date().Unix() == p2.Date().Unix() { - c := compare.Strings(p1.LinkTitle(), p2.LinkTitle()) + c := collatorStringCompare(func(p Page) string { return p.LinkTitle() }, p1, p2) if c == 0 { - if p1.File().IsZero() || p2.File().IsZero() { - return p1.File().IsZero() - } - return compare.LessStrings(p1.File().Filename(), p2.File().Filename()) + // This is the full normalized path, which will contain extension and any language code preserved, + // which is what we want for sorting. + return compare.LessStrings(p1.PathInfo().Path(), p2.PathInfo().Path()) } return c < 0 } @@ -77,12 +116,11 @@ var ( } lessPageLanguage = func(p1, p2 Page) bool { - if p1.Language().Weight == p2.Language().Weight { if p1.Date().Unix() == p2.Date().Unix() { c := compare.Strings(p1.LinkTitle(), p2.LinkTitle()) if c == 0 { - if !p1.File().IsZero() && !p2.File().IsZero() { + if p1.File() != nil && p2.File() != nil { return compare.LessStrings(p1.File().Filename(), p2.File().Filename()) } } @@ -103,11 +141,11 @@ var ( } lessPageTitle = func(p1, p2 Page) bool { - return compare.LessStrings(p1.Title(), p2.Title()) + return collatorStringCompare(func(p Page) string { return p.Title() }, p1, p2) < 0 } lessPageLinkTitle = func(p1, p2 Page) bool { - return compare.LessStrings(p1.LinkTitle(), p2.LinkTitle()) + return collatorStringCompare(func(p Page) string { return p.LinkTitle() }, p1, p2) < 0 } lessPageDate = func(p1, p2 Page) bool { @@ -133,6 +171,47 @@ func (p Pages) Limit(n int) Pages { return p } +var collatorStringSort = func(getString func(Page) string) func(p Pages) { + return func(p Pages) { + if len(p) == 0 { + return + } + // Pages may be a mix of multiple languages, so we need to use the language + // for the currently rendered Site. + currentSite := p[0].Site().Current() + coll := langs.GetCollator1(currentSite.Language()) + coll.Lock() + defer coll.Unlock() + + sort.SliceStable(p, func(i, j int) bool { + return coll.CompareStrings(getString(p[i]), getString(p[j])) < 0 + }) + } +} + +var collatorStringCompare = func(getString func(Page) string, p1, p2 Page) int { + currentSite := p1.Site().Current() + coll := langs.GetCollator1(currentSite.Language()) + coll.Lock() + c := coll.CompareStrings(getString(p1), getString(p2)) + coll.Unlock() + return c +} + +var collatorStringLess = func(p Page) (less func(s1, s2 string) bool, close func()) { + currentSite := p.Site().Current() + // Make sure to use the second collator to prevent deadlocks. + // See issue 11039. + coll := langs.GetCollator2(currentSite.Language()) + coll.Lock() + return func(s1, s2 string) bool { + return coll.CompareStrings(s1, s2) < 1 + }, + func() { + coll.Unlock() + } +} + // ByWeight sorts the Pages by weight and returns a copy. // // Adjacent invocations on the same receiver will return a cached result. @@ -155,10 +234,10 @@ func SortByDefault(pages Pages) { // // This may safely be executed in parallel. func (p Pages) ByTitle() Pages { - const key = "pageSort.ByTitle" - pages, _ := spc.get(key, pageBy(lessPageTitle).Sort, p) + pages, _ := spc.get(key, collatorStringSort(func(p Page) string { return p.Title() }), p) + return pages } @@ -168,10 +247,9 @@ func (p Pages) ByTitle() Pages { // // This may safely be executed in parallel. func (p Pages) ByLinkTitle() Pages { - const key = "pageSort.ByLinkTitle" - pages, _ := spc.get(key, pageBy(lessPageLinkTitle).Sort, p) + pages, _ := spc.get(key, collatorStringSort(func(p Page) string { return p.LinkTitle() }), p) return pages } @@ -182,7 +260,6 @@ func (p Pages) ByLinkTitle() Pages { // // This may safely be executed in parallel. func (p Pages) ByDate() Pages { - const key = "pageSort.ByDate" pages, _ := spc.get(key, pageBy(lessPageDate).Sort, p) @@ -196,7 +273,6 @@ func (p Pages) ByDate() Pages { // // This may safely be executed in parallel. func (p Pages) ByPublishDate() Pages { - const key = "pageSort.ByPublishDate" pages, _ := spc.get(key, pageBy(lessPagePubDate).Sort, p) @@ -210,7 +286,6 @@ func (p Pages) ByPublishDate() Pages { // // This may safely be executed in parallel. func (p Pages) ByExpiryDate() Pages { - const key = "pageSort.ByExpiryDate" expDate := func(p1, p2 Page) bool { @@ -228,7 +303,6 @@ func (p Pages) ByExpiryDate() Pages { // // This may safely be executed in parallel. func (p Pages) ByLastmod() Pages { - const key = "pageSort.ByLastmod" date := func(p1, p2 Page) bool { @@ -245,12 +319,10 @@ func (p Pages) ByLastmod() Pages { // Adjacent invocations on the same receiver will return a cached result. // // This may safely be executed in parallel. -func (p Pages) ByLength() Pages { - +func (p Pages) ByLength(ctx context.Context) Pages { const key = "pageSort.ByLength" length := func(p1, p2 Page) bool { - p1l, ok1 := p1.(resource.LengthProvider) p2l, ok2 := p2.(resource.LengthProvider) @@ -262,7 +334,7 @@ func (p Pages) ByLength() Pages { return false } - return p1l.Len() < p2l.Len() + return p1l.Len(ctx) < p2l.Len(ctx) } pages, _ := spc.get(key, pageBy(length).Sort, p) @@ -276,7 +348,6 @@ func (p Pages) ByLength() Pages { // // This may safely be executed in parallel. func (p Pages) ByLanguage() Pages { - const key = "pageSort.ByLanguage" pages, _ := spc.get(key, pageBy(lessPageLanguage).Sort, p) @@ -313,10 +384,16 @@ func (p Pages) Reverse() Pages { // Adjacent invocations on the same receiver with the same paramsKey will return a cached result. // // This may safely be executed in parallel. -func (p Pages) ByParam(paramsKey interface{}) Pages { +func (p Pages) ByParam(paramsKey any) Pages { + if len(p) < 2 { + return p + } paramsKeyStr := cast.ToString(paramsKey) key := "pageSort.ByParam." + paramsKeyStr + stringLess, close := collatorStringLess(p[0]) + defer close() + paramsKeyComparator := func(p1, p2 Page) bool { v1, _ := p1.Param(paramsKeyStr) v2, _ := p2.Param(paramsKeyStr) @@ -329,7 +406,7 @@ func (p Pages) ByParam(paramsKey interface{}) Pages { return true } - isNumeric := func(v interface{}) bool { + isNumeric := func(v any) bool { switch v.(type) { case uint8, uint16, uint32, uint64, int, int8, int16, int32, int64, float32, float64: return true @@ -345,8 +422,7 @@ func (p Pages) ByParam(paramsKey interface{}) Pages { s1 := cast.ToString(v1) s2 := cast.ToString(v2) - return compare.LessStrings(s1, s2) - + return stringLess(s1, s2) } pages, _ := spc.get(key, pageBy(paramsKeyComparator).Sort, p) diff --git a/resources/page/pages_sort_search.go b/resources/page/pages_sort_search.go index ff44e42d5..af10117cc 100644 --- a/resources/page/pages_sort_search.go +++ b/resources/page/pages_sort_search.go @@ -69,10 +69,9 @@ func searchPageBinary(p Page, pages Pages, less func(p1, p2 Page) bool) int { } return searchPageLinear(p, pages, i) - } -// isProbablySorted tests if the pages slice is probably sorted. +// isPagesProbablySorted tests if the pages slice is probably sorted. func isPagesProbablySorted(pages Pages, lessFuncs ...func(p1, p2 Page) bool) func(p1, p2 Page) bool { n := len(pages) step := 1 diff --git a/resources/page/pages_sort_search_test.go b/resources/page/pages_sort_search_test.go index 6cc4ed5ea..8f115109c 100644 --- a/resources/page/pages_sort_search_test.go +++ b/resources/page/pages_sort_search_test.go @@ -38,7 +38,6 @@ func TestSearchPage(t *testing.T) { c.Assert(idx, qt.Equals, i) } } - } func BenchmarkSearchPage(b *testing.B) { @@ -72,23 +71,23 @@ func BenchmarkSearchPage(b *testing.B) { } for _, variant := range []Variant{ - Variant{"Shuffled", shufflePages, searchPage}, - Variant{"ByWeight", func(pages Pages) Pages { + {"Shuffled", shufflePages, searchPage}, + {"ByWeight", func(pages Pages) Pages { return pages.ByWeight() }, searchPage}, - Variant{"ByWeight.Reverse", func(pages Pages) Pages { + {"ByWeight.Reverse", func(pages Pages) Pages { return pages.ByWeight().Reverse() }, searchPage}, - Variant{"ByDate", func(pages Pages) Pages { + {"ByDate", func(pages Pages) Pages { return pages.ByDate() }, searchPage}, - Variant{"ByPublishDate", func(pages Pages) Pages { + {"ByPublishDate", func(pages Pages) Pages { return pages.ByPublishDate() }, searchPage}, - Variant{"ByTitle", func(pages Pages) Pages { + {"ByTitle", func(pages Pages) Pages { return pages.ByTitle() }, searchPage}, - Variant{"ByTitle Linear", func(pages Pages) Pages { + {"ByTitle Linear", func(pages Pages) Pages { return pages.ByTitle() }, linearSearch}, } { @@ -120,5 +119,4 @@ func TestIsPagesProbablySorted(t *testing.T) { c.Assert(isPagesProbablySorted(createSortTestPages(300).ByWeight(), DefaultPageSort), qt.Not(qt.IsNil)) c.Assert(isPagesProbablySorted(createSortTestPages(6), DefaultPageSort), qt.IsNil) c.Assert(isPagesProbablySorted(createSortTestPages(300).ByTitle(), pageLessFunctions...), qt.Not(qt.IsNil)) - } diff --git a/resources/page/pages_sort_test.go b/resources/page/pages_sort_test.go index 670abb90a..70c7bc8a8 100644 --- a/resources/page/pages_sort_test.go +++ b/resources/page/pages_sort_test.go @@ -14,22 +14,22 @@ package page import ( + "context" "fmt" "testing" "time" - "github.com/gohugoio/hugo/htesting/hqt" - "github.com/gohugoio/hugo/source" - "github.com/gohugoio/hugo/resources/resource" + "github.com/google/go-cmp/cmp" qt "github.com/frankban/quicktest" ) -var eq = qt.CmpEquals(hqt.DeepAllowUnexported( - &testPage{}, - &source.FileInfo{}, -)) +var eq = qt.CmpEquals( + cmp.Comparer(func(p1, p2 testPage) bool { + return p1.path == p2.path && p1.weight == p2.weight + }), +) func TestDefaultSort(t *testing.T) { t.Parallel() @@ -105,6 +105,11 @@ func TestSortByN(t *testing.T) { d4 := d1.Add(-20 * time.Hour) p := createSortTestPages(4) + ctx := context.Background() + + byLen := func(p Pages) Pages { + return p.ByLength(ctx) + } for i, this := range []struct { sortFunc func(p Pages) Pages @@ -117,7 +122,7 @@ func TestSortByN(t *testing.T) { {(Pages).ByPublishDate, func(p Pages) bool { return p[0].PublishDate() == d4 }}, {(Pages).ByExpiryDate, func(p Pages) bool { return p[0].ExpiryDate() == d4 }}, {(Pages).ByLastmod, func(p Pages) bool { return p[1].Lastmod() == d3 }}, - {(Pages).ByLength, func(p Pages) bool { return p[0].(resource.LengthProvider).Len() == len(p[0].(*testPage).content) }}, + {byLen, func(p Pages) bool { return p[0].(resource.LengthProvider).Len(ctx) == len(p[0].(*testPage).content) }}, } { setSortVals([4]time.Time{d1, d2, d3, d4}, [4]string{"b", "ab", "cde", "fg"}, [4]int{0, 3, 2, 1}, p) @@ -126,7 +131,6 @@ func TestSortByN(t *testing.T) { t.Errorf("[%d] sort error", i) } } - } func TestLimit(t *testing.T) { @@ -135,7 +139,7 @@ func TestLimit(t *testing.T) { p := createSortTestPages(10) firstFive := p.Limit(5) c.Assert(len(firstFive), qt.Equals, 5) - for i := 0; i < 5; i++ { + for i := range 5 { c.Assert(firstFive[i], qt.Equals, p[i]) } c.Assert(p.Limit(10), eq, p) @@ -158,7 +162,7 @@ func TestPageSortReverse(t *testing.T) { func TestPageSortByParam(t *testing.T) { t.Parallel() c := qt.New(t) - var k interface{} = "arbitrarily.nested" + var k any = "arbitrarily.nested" unsorted := createSortTestPages(10) delete(unsorted[9].Params(), "arbitrarily") @@ -189,18 +193,18 @@ func TestPageSortByParamNumeric(t *testing.T) { t.Parallel() c := qt.New(t) - var k interface{} = "arbitrarily.nested" + var k any = "arbitrarily.nested" n := 10 unsorted := createSortTestPages(n) - for i := 0; i < n; i++ { + for i := range n { v := 100 - i if i%2 == 0 { v = 100.0 - i } - unsorted[i].(*testPage).params = map[string]interface{}{ - "arbitrarily": map[string]interface{}{ + unsorted[i].(*testPage).params = map[string]any{ + "arbitrarily": map[string]any{ "nested": v, }, } @@ -260,18 +264,17 @@ func setSortVals(dates [4]time.Time, titles [4]string, weights [4]int, pages Pag for _, p := range pages { p.(*testPage).content = "" } - } func createSortTestPages(num int) Pages { pages := make(Pages, num) - for i := 0; i < num; i++ { + for i := range num { p := newTestPage() p.path = fmt.Sprintf("/x/y/p%d.md", i) - p.title = fmt.Sprintf("Title %d", i%(num+1/2)) - p.params = map[string]interface{}{ - "arbitrarily": map[string]interface{}{ + p.title = fmt.Sprintf("Title %d", i%((num+1)/2)) + p.params = map[string]any{ + "arbitrarily": map[string]any{ "nested": ("xyz" + fmt.Sprintf("%v", 100-i)), }, } diff --git a/resources/page/pages_test.go b/resources/page/pages_test.go index 18b10f5bd..22ee698da 100644 --- a/resources/page/pages_test.go +++ b/resources/page/pages_test.go @@ -20,7 +20,6 @@ import ( ) func TestProbablyEq(t *testing.T) { - p1, p2, p3 := &testPage{title: "p1"}, &testPage{title: "p2"}, &testPage{title: "p3"} pages12 := Pages{p1, p2} pages21 := Pages{p2, p1} @@ -39,7 +38,6 @@ func TestProbablyEq(t *testing.T) { c.Assert(PageGroup{Key: "a", Pages: pages12}.ProbablyEq(PageGroup{Key: "a", Pages: pages12}), qt.Equals, true) c.Assert(PageGroup{Key: "a", Pages: pages12}.ProbablyEq(PageGroup{Key: "b", Pages: pages12}), qt.Equals, false) - }) t.Run("PagesGroup", func(t *testing.T) { @@ -49,9 +47,7 @@ func TestProbablyEq(t *testing.T) { c.Assert(PagesGroup{pg1, pg2}.ProbablyEq(PagesGroup{pg1, pg2}), qt.Equals, true) c.Assert(PagesGroup{pg1, pg2}.ProbablyEq(PagesGroup{pg2, pg1}), qt.Equals, false) - }) - } func TestToPages(t *testing.T) { @@ -60,7 +56,7 @@ func TestToPages(t *testing.T) { p1, p2 := &testPage{title: "p1"}, &testPage{title: "p2"} pages12 := Pages{p1, p2} - mustToPages := func(in interface{}) Pages { + mustToPages := func(in any) Pages { p, err := ToPages(in) c.Assert(err, qt.IsNil) return p @@ -69,7 +65,7 @@ func TestToPages(t *testing.T) { c.Assert(mustToPages(nil), eq, Pages{}) c.Assert(mustToPages(pages12), eq, pages12) c.Assert(mustToPages([]Page{p1, p2}), eq, pages12) - c.Assert(mustToPages([]interface{}{p1, p2}), eq, pages12) + c.Assert(mustToPages([]any{p1, p2}), eq, pages12) _, err := ToPages("not a page") c.Assert(err, qt.Not(qt.IsNil)) diff --git a/resources/page/pagination.go b/resources/page/pagination.go index 6d5da966e..ea49d62f6 100644 --- a/resources/page/pagination.go +++ b/resources/page/pagination.go @@ -16,10 +16,10 @@ package page import ( "errors" "fmt" - "html/template" "math" "reflect" + "github.com/gohugoio/hugo/common/hugo" "github.com/gohugoio/hugo/config" "github.com/spf13/cast" @@ -27,8 +27,22 @@ import ( // PaginatorProvider provides two ways to create a page paginator. type PaginatorProvider interface { - Paginator(options ...interface{}) (*Pager, error) - Paginate(seq interface{}, options ...interface{}) (*Pager, error) + // Paginator creates a paginator with the default page set. + Paginator(options ...any) (*Pager, error) + // Paginate creates a paginator with the given page set in pages. + Paginate(pages any, options ...any) (*Pager, error) +} + +var _ PaginatorProvider = (*PaginatorNotSupportedFunc)(nil) + +type PaginatorNotSupportedFunc func() error + +func (f PaginatorNotSupportedFunc) Paginate(pages any, options ...any) (*Pager, error) { + return nil, f() +} + +func (f PaginatorNotSupportedFunc) Paginator(options ...any) (*Pager, error) { + return nil, f() } // Pager represents one of the elements in a paginator. @@ -69,8 +83,8 @@ func (p *Pager) PageNumber() int { } // URL returns the URL to the current page. -func (p *Pager) URL() template.HTML { - return template.HTML(p.paginationURLFactory(p.PageNumber())) +func (p *Pager) URL() string { + return p.paginationURLFactory(p.PageNumber()) } // Pages returns the Pages on this page. @@ -110,7 +124,6 @@ func (p *Pager) element() paginatedElement { // page returns the Page with the given index func (p *Pager) page(index int) (Page, error) { - if pages, ok := p.element().(Pages); ok { if pages != nil && len(pages) > index { return pages[index], nil @@ -182,7 +195,14 @@ func (p *Paginator) Pagers() pagers { } // PageSize returns the size of each paginator page. +// Deprecated: Use PagerSize instead. func (p *Paginator) PageSize() int { + hugo.Deprecate("PageSize", "Use PagerSize instead.", "v0.128.0") + return p.size +} + +// PagerSize returns the size of each paginator page. +func (p *Paginator) PagerSize() int { return p.size } @@ -207,9 +227,8 @@ func splitPages(pages Pages, size int) []paginatedElement { } func splitPageGroups(pageGroups PagesGroup, size int) []paginatedElement { - type keyPage struct { - key interface{} + key any page Page } @@ -231,7 +250,7 @@ func splitPageGroups(pageGroups PagesGroup, size int) []paginatedElement { var ( pg PagesGroup - key interface{} + key any groupIndex = -1 ) @@ -250,9 +269,9 @@ func splitPageGroups(pageGroups PagesGroup, size int) []paginatedElement { return split } -func ResolvePagerSize(cfg config.Provider, options ...interface{}) (int, error) { +func ResolvePagerSize(conf config.AllProvider, options ...any) (int, error) { if len(options) == 0 { - return cfg.GetInt("paginate"), nil + return conf.Pagination().PagerSize, nil } if len(options) > 1 { @@ -268,8 +287,7 @@ func ResolvePagerSize(cfg config.Provider, options ...interface{}) (int, error) return pas, nil } -func Paginate(td TargetPathDescriptor, seq interface{}, pagerSize int) (*Paginator, error) { - +func Paginate(td TargetPathDescriptor, seq any, pagerSize int) (*Paginator, error) { if pagerSize <= 0 { return nil, errors.New("'paginate' configuration setting must be positive to paginate") } @@ -278,11 +296,11 @@ func Paginate(td TargetPathDescriptor, seq interface{}, pagerSize int) (*Paginat var paginator *Paginator - groups, err := ToPagesGroup(seq) + groups, ok, err := ToPagesGroup(seq) if err != nil { return nil, err } - if groups != nil { + if ok { paginator, _ = newPaginatorFromPageGroups(groups, pagerSize, urlFactory) } else { pages, err := ToPages(seq) @@ -299,8 +317,7 @@ func Paginate(td TargetPathDescriptor, seq interface{}, pagerSize int) (*Paginat // It may return false positives. // The motivation behind this is to avoid potential costly reflect.DeepEqual // when "probably" is good enough. -func probablyEqualPageLists(a1 interface{}, a2 interface{}) bool { - +func probablyEqualPageLists(a1 any, a2 any) bool { if a1 == nil || a2 == nil { return a1 == a2 } @@ -347,7 +364,6 @@ func probablyEqualPageLists(a1 interface{}, a2 interface{}) bool { } func newPaginatorFromPages(pages Pages, size int, urlFactory paginationURLFactory) (*Paginator, error) { - if size <= 0 { return nil, errors.New("Paginator size must be positive") } @@ -358,7 +374,6 @@ func newPaginatorFromPages(pages Pages, size int, urlFactory paginationURLFactor } func newPaginatorFromPageGroups(pageGroups PagesGroup, size int, urlFactory paginationURLFactory) (*Paginator, error) { - if size <= 0 { return nil, errors.New("Paginator size must be positive") } @@ -389,16 +404,14 @@ func newPaginator(elements []paginatedElement, total, size int, urlFactory pagin } func newPaginationURLFactory(d TargetPathDescriptor) paginationURLFactory { - return func(pageNumber int) string { pathDescriptor := d var rel string if pageNumber > 1 { - rel = fmt.Sprintf("/%s/%d/", d.PathSpec.PaginatePath, pageNumber) + rel = fmt.Sprintf("/%s/%d/", d.PathSpec.Cfg.Pagination().Path, pageNumber) pathDescriptor.Addends = rel } return CreateTargetPaths(pathDescriptor).RelPermalink(d.PathSpec) - } } diff --git a/resources/page/pagination_test.go b/resources/page/pagination_test.go index f4441a892..64ee9a998 100644 --- a/resources/page/pagination_test.go +++ b/resources/page/pagination_test.go @@ -14,14 +14,11 @@ package page import ( + "context" "fmt" - "html/template" "testing" - "github.com/spf13/viper" - qt "github.com/frankban/quicktest" - "github.com/gohugoio/hugo/output" ) func TestSplitPages(t *testing.T) { @@ -31,20 +28,19 @@ func TestSplitPages(t *testing.T) { chunks := splitPages(pages, 5) c.Assert(len(chunks), qt.Equals, 5) - for i := 0; i < 4; i++ { + for i := range 4 { c.Assert(chunks[i].Len(), qt.Equals, 5) } lastChunk := chunks[4] c.Assert(lastChunk.Len(), qt.Equals, 1) - } func TestSplitPageGroups(t *testing.T) { t.Parallel() c := qt.New(t) pages := createTestPages(21) - groups, _ := pages.GroupBy("Weight", "desc") + groups, _ := pages.GroupBy(context.Background(), "Weight", "desc") chunks := splitPageGroups(groups, 5) c.Assert(len(chunks), qt.Equals, 5) @@ -57,7 +53,7 @@ func TestSplitPageGroups(t *testing.T) { // first group 10 in weight c.Assert(pg.Key, qt.Equals, 10) for _, p := range pg.Pages { - c.Assert(p.FuzzyWordCount()%2 == 0, qt.Equals, true) // magic test + c.Assert(p.FuzzyWordCount(context.Background())%2 == 0, qt.Equals, true) // magic test } } } else { @@ -72,20 +68,19 @@ func TestSplitPageGroups(t *testing.T) { // last should have 5 in weight c.Assert(pg.Key, qt.Equals, 5) for _, p := range pg.Pages { - c.Assert(p.FuzzyWordCount()%2 != 0, qt.Equals, true) // magic test + c.Assert(p.FuzzyWordCount(context.Background())%2 != 0, qt.Equals, true) // magic test } } } else { t.Fatal("Excepted PageGroup") } - } func TestPager(t *testing.T) { t.Parallel() c := qt.New(t) pages := createTestPages(21) - groups, _ := pages.GroupBy("Weight", "desc") + groups, _ := pages.GroupBy(context.Background(), "Weight", "desc") urlFactory := func(page int) string { return fmt.Sprintf("page/%d/", page) @@ -111,7 +106,6 @@ func TestPager(t *testing.T) { first = pag.Pagers()[0].First() c.Assert(first.PageGroups(), qt.Not(qt.HasLen), 0) c.Assert(first.Pages(), qt.HasLen, 0) - } func doTestPages(t *testing.T, paginator *Paginator) { @@ -120,11 +114,11 @@ func doTestPages(t *testing.T, paginator *Paginator) { c.Assert(len(paginatorPages), qt.Equals, 5) c.Assert(paginator.TotalNumberOfElements(), qt.Equals, 21) - c.Assert(paginator.PageSize(), qt.Equals, 5) + c.Assert(paginator.PagerSize(), qt.Equals, 5) c.Assert(paginator.TotalPages(), qt.Equals, 5) first := paginatorPages[0] - c.Assert(first.URL(), qt.Equals, template.HTML("page/1/")) + c.Assert(first.URL(), qt.Equals, "page/1/") c.Assert(first.First(), qt.Equals, first) c.Assert(first.HasNext(), qt.Equals, true) c.Assert(first.Next(), qt.Equals, paginatorPages[1]) @@ -139,7 +133,7 @@ func doTestPages(t *testing.T, paginator *Paginator) { c.Assert(third.Prev(), qt.Equals, paginatorPages[1]) last := paginatorPages[4] - c.Assert(last.URL(), qt.Equals, template.HTML("page/5/")) + c.Assert(last.URL(), qt.Equals, "page/5/") c.Assert(last.Last(), qt.Equals, last) c.Assert(last.HasNext(), qt.Equals, false) c.Assert(last.Next(), qt.IsNil) @@ -152,7 +146,7 @@ func TestPagerNoPages(t *testing.T) { t.Parallel() c := qt.New(t) pages := createTestPages(0) - groups, _ := pages.GroupBy("Weight", "desc") + groups, _ := pages.GroupBy(context.Background(), "Weight", "desc") urlFactory := func(page int) string { return fmt.Sprintf("page/%d/", page) @@ -171,7 +165,6 @@ func TestPagerNoPages(t *testing.T) { first = paginator.Pagers()[0].First() c.Assert(first.PageGroups(), qt.HasLen, 0) c.Assert(first.Pages(), qt.HasLen, 0) - } func doTestPagerNoPages(t *testing.T, paginator *Paginator) { @@ -179,7 +172,7 @@ func doTestPagerNoPages(t *testing.T, paginator *Paginator) { c := qt.New(t) c.Assert(len(paginatorPages), qt.Equals, 1) c.Assert(paginator.TotalNumberOfElements(), qt.Equals, 0) - c.Assert(paginator.PageSize(), qt.Equals, 5) + c.Assert(paginator.PagerSize(), qt.Equals, 5) c.Assert(paginator.TotalPages(), qt.Equals, 0) // pageOne should be nothing but the first @@ -194,69 +187,20 @@ func doTestPagerNoPages(t *testing.T, paginator *Paginator) { c.Assert(pageOne.TotalNumberOfElements(), qt.Equals, 0) c.Assert(pageOne.TotalPages(), qt.Equals, 0) c.Assert(pageOne.PageNumber(), qt.Equals, 1) - c.Assert(pageOne.PageSize(), qt.Equals, 5) - -} - -func TestPaginationURLFactory(t *testing.T) { - t.Parallel() - c := qt.New(t) - cfg := viper.New() - cfg.Set("paginatePath", "zoo") - - for _, uglyURLs := range []bool{false, true} { - c.Run(fmt.Sprintf("uglyURLs=%t", uglyURLs), func(c *qt.C) { - - tests := []struct { - name string - d TargetPathDescriptor - baseURL string - page int - expected string - expectedUgly string - }{ - {"HTML home page 32", - TargetPathDescriptor{Kind: KindHome, Type: output.HTMLFormat}, "http://example.com/", 32, "/zoo/32/", "/zoo/32.html"}, - {"JSON home page 42", - TargetPathDescriptor{Kind: KindHome, Type: output.JSONFormat}, "http://example.com/", 42, "/zoo/42/index.json", "/zoo/42.json"}, - } - - for _, test := range tests { - d := test.d - cfg.Set("baseURL", test.baseURL) - cfg.Set("uglyURLs", uglyURLs) - d.UglyURLs = uglyURLs - - pathSpec := newTestPathSpecFor(cfg) - d.PathSpec = pathSpec - - factory := newPaginationURLFactory(d) - - got := factory(test.page) - - if uglyURLs { - c.Assert(got, qt.Equals, test.expectedUgly) - } else { - c.Assert(got, qt.Equals, test.expected) - } - - } - }) - - } + c.Assert(pageOne.PagerSize(), qt.Equals, 5) } func TestProbablyEqualPageLists(t *testing.T) { t.Parallel() fivePages := createTestPages(5) zeroPages := createTestPages(0) - zeroPagesByWeight, _ := createTestPages(0).GroupBy("Weight", "asc") - fivePagesByWeight, _ := createTestPages(5).GroupBy("Weight", "asc") - ninePagesByWeight, _ := createTestPages(9).GroupBy("Weight", "asc") + zeroPagesByWeight, _ := createTestPages(0).GroupBy(context.Background(), "Weight", "asc") + fivePagesByWeight, _ := createTestPages(5).GroupBy(context.Background(), "Weight", "asc") + ninePagesByWeight, _ := createTestPages(9).GroupBy(context.Background(), "Weight", "asc") for i, this := range []struct { - v1 interface{} - v2 interface{} + v1 any + v2 any expect bool }{ {nil, nil, true}, @@ -276,7 +220,6 @@ func TestProbablyEqualPageLists(t *testing.T) { if result != this.expect { t.Errorf("[%d] got %t but expected %t", i, result, this.expect) - } } } @@ -289,7 +232,7 @@ func TestPaginationPage(t *testing.T) { } fivePages := createTestPages(7) - fivePagesFuzzyWordCount, _ := createTestPages(7).GroupBy("FuzzyWordCount", "asc") + fivePagesFuzzyWordCount, _ := createTestPages(7).GroupBy(context.Background(), "FuzzyWordCount", "asc") p1, _ := newPaginatorFromPages(fivePages, 2, urlFactory) p2, _ := newPaginatorFromPageGroups(fivePagesFuzzyWordCount, 2, urlFactory) @@ -303,10 +246,10 @@ func TestPaginationPage(t *testing.T) { page21, _ := f2.page(1) page2Nil, _ := f2.page(3) - c.Assert(page11.FuzzyWordCount(), qt.Equals, 3) + c.Assert(page11.FuzzyWordCount(context.Background()), qt.Equals, 3) c.Assert(page1Nil, qt.IsNil) c.Assert(page21, qt.Not(qt.IsNil)) - c.Assert(page21.FuzzyWordCount(), qt.Equals, 3) + c.Assert(page21.FuzzyWordCount(context.Background()), qt.Equals, 3) c.Assert(page2Nil, qt.IsNil) } diff --git a/resources/page/path_integration_test.go b/resources/page/path_integration_test.go new file mode 100644 index 000000000..a1aa1d406 --- /dev/null +++ b/resources/page/path_integration_test.go @@ -0,0 +1,55 @@ +// Copyright 2024 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 page_test + +import ( + "testing" + + "github.com/gohugoio/hugo/hugolib" +) + +// Issue 4926 +// Issue 8232 +// Issue 12342 +func TestHashSignInPermalink(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['section','rss','sitemap','taxonomy'] +[permalinks] +s1 = '/:section/:slug' +-- layouts/_default/list.html -- +{{ range site.Pages }}{{ .RelPermalink }}|{{ end }} +-- layouts/_default/single.html -- +{{ .Title }} +-- content/s1/p1.md -- +--- +title: p#1 +tags: test#tag# +--- +-- content/s2/p#2.md -- +--- +title: p#2 +--- +` + + b := hugolib.Test(t, files) + + b.AssertFileExists("public/s1/p#1/index.html", true) + b.AssertFileExists("public/s2/p#2/index.html", true) + b.AssertFileExists("public/tags/test#tag#/index.html", true) + + b.AssertFileContentExact("public/index.html", "/|/s1/p%231/|/s2/p%232/|/tags/test%23tag%23/|") +} diff --git a/resources/page/permalinks.go b/resources/page/permalinks.go index 0e9b9e212..f8cbcd62c 100644 --- a/resources/page/permalinks.go +++ b/resources/page/permalinks.go @@ -14,29 +14,34 @@ package page import ( + "errors" "fmt" "os" + "path" "path/filepath" "regexp" "strconv" "strings" "time" - "github.com/pkg/errors" - + "github.com/gohugoio/hugo/common/hstrings" + "github.com/gohugoio/hugo/common/maps" "github.com/gohugoio/hugo/helpers" + "github.com/gohugoio/hugo/resources/kinds" ) -// PermalinkExpander holds permalin mappings per section. +// PermalinkExpander holds permalink mappings per section. type PermalinkExpander struct { // knownPermalinkAttributes maps :tags in a permalink specification to a // function which, given a page and the tag, returns the resulting string // to be used to replace that tag. knownPermalinkAttributes map[string]pageToPermaAttribute - expanders map[string]func(Page) (string, error) + expanders map[string]map[string]func(Page) (string, error) - ps *helpers.PathSpec + urlize func(uri string) string + + patternCache *maps.Cache[string, func(Page) (string, error)] } // Time for checking date formats. Every field is different than the @@ -50,6 +55,14 @@ func (p PermalinkExpander) callback(attr string) (pageToPermaAttribute, bool) { return callback, true } + if strings.HasPrefix(attr, "sections[") { + fn := p.toSliceFunc(strings.TrimPrefix(attr, "sections")) + return func(p Page, s string) (string, error) { + return path.Join(fn(p.CurrentSection().SectionsEntries())...), nil + }, true + } + + // Make sure this comes after all the other checks. if referenceTime.Format(attr) != attr { return p.pageToPermalinkDate, true } @@ -58,69 +71,106 @@ func (p PermalinkExpander) callback(attr string) (pageToPermaAttribute, bool) { } // NewPermalinkExpander creates a new PermalinkExpander configured by the given -// PathSpec. -func NewPermalinkExpander(ps *helpers.PathSpec) (PermalinkExpander, error) { - - p := PermalinkExpander{ps: ps} +// urlize func. +func NewPermalinkExpander(urlize func(uri string) string, patterns map[string]map[string]string) (PermalinkExpander, error) { + p := PermalinkExpander{ + urlize: urlize, + patternCache: maps.NewCache[string, func(Page) (string, error)](), + } p.knownPermalinkAttributes = map[string]pageToPermaAttribute{ - "year": p.pageToPermalinkDate, - "month": p.pageToPermalinkDate, - "monthname": p.pageToPermalinkDate, - "day": p.pageToPermalinkDate, - "weekday": p.pageToPermalinkDate, - "weekdayname": p.pageToPermalinkDate, - "yearday": p.pageToPermalinkDate, - "section": p.pageToPermalinkSection, - "sections": p.pageToPermalinkSections, - "title": p.pageToPermalinkTitle, - "slug": p.pageToPermalinkSlugElseTitle, - "filename": p.pageToPermalinkFilename, + "year": p.pageToPermalinkDate, + "month": p.pageToPermalinkDate, + "monthname": p.pageToPermalinkDate, + "day": p.pageToPermalinkDate, + "weekday": p.pageToPermalinkDate, + "weekdayname": p.pageToPermalinkDate, + "yearday": p.pageToPermalinkDate, + "section": p.pageToPermalinkSection, + "sections": p.pageToPermalinkSections, + "title": p.pageToPermalinkTitle, + "slug": p.pageToPermalinkSlugElseTitle, + "slugorfilename": p.pageToPermalinkSlugElseFilename, + "filename": p.pageToPermalinkFilename, + "contentbasename": p.pageToPermalinkContentBaseName, + "slugorcontentbasename": p.pageToPermalinkSlugOrContentBaseName, } - patterns := ps.Cfg.GetStringMapString("permalinks") - if patterns == nil { - return p, nil - } + p.expanders = make(map[string]map[string]func(Page) (string, error)) - e, err := p.parse(patterns) - if err != nil { - return p, err + for kind, patterns := range patterns { + e, err := p.parse(patterns) + if err != nil { + return p, err + } + p.expanders[kind] = e } - p.expanders = e - return p, nil } +// Escape sequence for colons in permalink patterns. +const escapePlaceholderColon = "\x00" + +func (l PermalinkExpander) normalizeEscapeSequencesIn(s string) (string, bool) { + s2 := strings.ReplaceAll(s, "\\:", escapePlaceholderColon) + return s2, s2 != s +} + +func (l PermalinkExpander) normalizeEscapeSequencesOut(result string) string { + return strings.ReplaceAll(result, escapePlaceholderColon, ":") +} + +// ExpandPattern expands the path in p with the specified expand pattern. +func (l PermalinkExpander) ExpandPattern(pattern string, p Page) (string, error) { + expand, err := l.getOrParsePattern(pattern) + if err != nil { + return "", err + } + + return expand(p) +} + // Expand expands the path in p according to the rules defined for the given key. // If no rules are found for the given key, an empty string is returned. func (l PermalinkExpander) Expand(key string, p Page) (string, error) { - expand, found := l.expanders[key] + expanders, found := l.expanders[p.Kind()] + if !found { + return "", nil + } + expand, found := expanders[key] if !found { return "", nil } return expand(p) - } -func (l PermalinkExpander) parse(patterns map[string]string) (map[string]func(Page) (string, error), error) { +// Allow " " and / to represent the root section. +var sectionCutSet = " /" - expanders := make(map[string]func(Page) (string, error)) +func init() { + if string(os.PathSeparator) != "/" { + sectionCutSet += string(os.PathSeparator) + } +} - // Allow " " and / to represent the root section. - const sectionCutSet = " /" + string(os.PathSeparator) +func (l PermalinkExpander) getOrParsePattern(pattern string) (func(Page) (string, error), error) { + return l.patternCache.GetOrCreate(pattern, func() (func(Page) (string, error), error) { + var normalized bool + pattern, normalized = l.normalizeEscapeSequencesIn(pattern) - for k, pattern := range patterns { - k = strings.Trim(k, sectionCutSet) - if !l.validate(pattern) { - return nil, &permalinkExpandError{pattern: pattern, err: errPermalinkIllFormed} - } - - pattern := pattern matches := attributeRegexp.FindAllStringSubmatch(pattern, -1) + if matches == nil { + result := pattern + if normalized { + result = l.normalizeEscapeSequencesOut(result) + } + return func(p Page) (string, error) { + return result, nil + }, nil + } callbacks := make([]pageToPermaAttribute, len(matches)) replacements := make([]string, len(matches)) @@ -137,31 +187,41 @@ func (l PermalinkExpander) parse(patterns map[string]string) (map[string]func(Pa callbacks[i] = callback } - expanders[k] = func(p Page) (string, error) { - - if matches == nil { - return pattern, nil - } - + return func(p Page) (string, error) { newField := pattern for i, replacement := range replacements { attr := replacement[1:] callback := callbacks[i] newAttr, err := callback(p, attr) - if err != nil { return "", &permalinkExpandError{pattern: pattern, err: err} } newField = strings.Replace(newField, replacement, newAttr, 1) + } + if normalized { + newField = l.normalizeEscapeSequencesOut(newField) } return newField, nil + }, nil + }) +} +func (l PermalinkExpander) parse(patterns map[string]string) (map[string]func(Page) (string, error), error) { + expanders := make(map[string]func(Page) (string, error)) + + for k, pattern := range patterns { + k = strings.Trim(k, sectionCutSet) + + expander, err := l.getOrParsePattern(pattern) + if err != nil { + return nil, err } + expanders[k] = expander } return expanders, nil @@ -171,35 +231,7 @@ func (l PermalinkExpander) parse(patterns map[string]string) (map[string]func(Pa // can return a string to go in that position in the page (or an error) type pageToPermaAttribute func(Page, string) (string, error) -var attributeRegexp = regexp.MustCompile(`:\w+`) - -// validate determines if a PathPattern is well-formed -func (l PermalinkExpander) validate(pp string) bool { - fragments := strings.Split(pp[1:], "/") - var bail = false - for i := range fragments { - if bail { - return false - } - if len(fragments[i]) == 0 { - bail = true - continue - } - - matches := attributeRegexp.FindAllStringSubmatch(fragments[i], -1) - if matches == nil { - continue - } - - for _, match := range matches { - k := match[0][1:] - if _, ok := l.callback(k); !ok { - return false - } - } - } - return true -} +var attributeRegexp = regexp.MustCompile(`:\w+(\[.+?\])?`) type permalinkExpandError struct { pattern string @@ -210,10 +242,7 @@ func (pee *permalinkExpandError) Error() string { return fmt.Sprintf("error expanding %q: %s", pee.pattern, pee.err) } -var ( - errPermalinkIllFormed = errors.New("permalink ill-formed") - errPermalinkAttributeUnknown = errors.New("permalink attribute not recognised") -) +var errPermalinkAttributeUnknown = errors.New("permalink attribute not recognised") func (l PermalinkExpander) pageToPermalinkDate(p Page, dateField string) (string, error) { // a Page contains a Node which provides a field Date, time.Time @@ -239,29 +268,39 @@ func (l PermalinkExpander) pageToPermalinkDate(p Page, dateField string) (string // pageToPermalinkTitle returns the URL-safe form of the title func (l PermalinkExpander) pageToPermalinkTitle(p Page, _ string) (string, error) { - return l.ps.URLize(p.Title()), nil + return l.urlize(p.Title()), nil } // pageToPermalinkFilename returns the URL-safe form of the filename func (l PermalinkExpander) pageToPermalinkFilename(p Page, _ string) (string, error) { - name := p.File().TranslationBaseName() + name := l.translationBaseName(p) if name == "index" { // Page bundles; the directory name will hopefully have a better name. dir := strings.TrimSuffix(p.File().Dir(), helpers.FilePathSeparator) _, name = filepath.Split(dir) + } else if name == "_index" { + return "", nil } - return l.ps.URLize(name), nil + return l.urlize(name), nil } // if the page has a slug, return the slug, else return the title func (l PermalinkExpander) pageToPermalinkSlugElseTitle(p Page, a string) (string, error) { if p.Slug() != "" { - return l.ps.URLize(p.Slug()), nil + return l.urlize(p.Slug()), nil } return l.pageToPermalinkTitle(p, a) } +// if the page has a slug, return the slug, else return the filename +func (l PermalinkExpander) pageToPermalinkSlugElseFilename(p Page, a string) (string, error) { + if p.Slug() != "" { + return l.urlize(p.Slug()), nil + } + return l.pageToPermalinkFilename(p, a) +} + func (l PermalinkExpander) pageToPermalinkSection(p Page, _ string) (string, error) { return p.Section(), nil } @@ -269,3 +308,172 @@ func (l PermalinkExpander) pageToPermalinkSection(p Page, _ string) (string, err func (l PermalinkExpander) pageToPermalinkSections(p Page, _ string) (string, error) { return p.CurrentSection().SectionsPath(), nil } + +// pageToPermalinkContentBaseName returns the URL-safe form of the content base name. +func (l PermalinkExpander) pageToPermalinkContentBaseName(p Page, _ string) (string, error) { + return l.urlize(p.PathInfo().Unnormalized().BaseNameNoIdentifier()), nil +} + +// pageToPermalinkSlugOrContentBaseName returns the URL-safe form of the slug, content base name. +func (l PermalinkExpander) pageToPermalinkSlugOrContentBaseName(p Page, a string) (string, error) { + if p.Slug() != "" { + return l.urlize(p.Slug()), nil + } + name, err := l.pageToPermalinkContentBaseName(p, a) + if err != nil { + return "", nil + } + return name, nil +} + +func (l PermalinkExpander) translationBaseName(p Page) string { + if p.File() == nil { + return "" + } + return p.File().TranslationBaseName() +} + +var ( + nilSliceFunc = func(s []string) []string { + return nil + } + allSliceFunc = func(s []string) []string { + return s + } +) + +// toSliceFunc returns a slice func that slices s according to the cut spec. +// The cut spec must be on form [low:high] (one or both can be omitted), +// also allowing single slice indices (e.g. [2]) and the special [last] keyword +// giving the last element of the slice. +// The returned function will be lenient and not panic in out of bounds situation. +// +// The current use case for this is to use parts of the sections path in permalinks. +func (l PermalinkExpander) toSliceFunc(cut string) func(s []string) []string { + cut = strings.ToLower(strings.TrimSpace(cut)) + if cut == "" { + return allSliceFunc + } + + if len(cut) < 3 || (cut[0] != '[' || cut[len(cut)-1] != ']') { + return nilSliceFunc + } + + toNFunc := func(s string, low bool) func(ss []string) int { + if s == "" { + if low { + return func(ss []string) int { + return 0 + } + } else { + return func(ss []string) int { + return len(ss) + } + } + } + + if s == "last" { + return func(ss []string) int { + return len(ss) - 1 + } + } + + n, _ := strconv.Atoi(s) + if n < 0 { + n = 0 + } + return func(ss []string) int { + // Prevent out of bound situations. It would not make + // much sense to panic here. + if n >= len(ss) { + if low { + return -1 + } + return len(ss) + } + return n + } + } + + opsStr := cut[1 : len(cut)-1] + opts := strings.Split(opsStr, ":") + + if !strings.Contains(opsStr, ":") { + toN := toNFunc(opts[0], true) + return func(s []string) []string { + if len(s) == 0 { + return nil + } + n := toN(s) + if n < 0 { + return []string{} + } + v := s[n] + if v == "" { + return nil + } + return []string{v} + } + } + + toN1, toN2 := toNFunc(opts[0], true), toNFunc(opts[1], false) + + return func(s []string) []string { + if len(s) == 0 { + return nil + } + n1, n2 := toN1(s), toN2(s) + if n1 < 0 || n2 < 0 { + return []string{} + } + return s[n1:n2] + } +} + +var permalinksKindsSupport = []string{kinds.KindPage, kinds.KindSection, kinds.KindTaxonomy, kinds.KindTerm} + +// DecodePermalinksConfig decodes the permalinks configuration in the given map +func DecodePermalinksConfig(m map[string]any) (map[string]map[string]string, error) { + permalinksConfig := make(map[string]map[string]string) + + permalinksConfig[kinds.KindPage] = make(map[string]string) + permalinksConfig[kinds.KindSection] = make(map[string]string) + permalinksConfig[kinds.KindTaxonomy] = make(map[string]string) + permalinksConfig[kinds.KindTerm] = make(map[string]string) + + config := maps.CleanConfigStringMap(m) + for k, v := range config { + switch v := v.(type) { + case string: + // [permalinks] + // key = '...' + + // To successfully be backward compatible, "default" patterns need to be set for both page and term + permalinksConfig[kinds.KindPage][k] = v + permalinksConfig[kinds.KindTerm][k] = v + + case maps.Params: + // [permalinks.key] + // xyz = ??? + + if hstrings.InSlice(permalinksKindsSupport, k) { + // TODO: warn if we overwrite an already set value + for k2, v2 := range v { + switch v2 := v2.(type) { + case string: + permalinksConfig[k][k2] = v2 + + default: + return nil, fmt.Errorf("permalinks configuration invalid: unknown value %q for key %q for kind %q", v2, k2, k) + } + } + } else { + return nil, fmt.Errorf("permalinks configuration not supported for kind %q, supported kinds are %v", k, permalinksKindsSupport) + } + + default: + return nil, fmt.Errorf("permalinks configuration invalid: unknown value %q for key %q", v, k) + } + } + return permalinksConfig, nil +} diff --git a/resources/page/permalinks_integration_test.go b/resources/page/permalinks_integration_test.go new file mode 100644 index 000000000..c865e2704 --- /dev/null +++ b/resources/page/permalinks_integration_test.go @@ -0,0 +1,372 @@ +// Copyright 2024 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 page_test + +import ( + "strings" + "testing" + + "github.com/bep/logg" + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/htesting" + "github.com/gohugoio/hugo/hugolib" +) + +func TestPermalinks(t *testing.T) { + t.Parallel() + + files := ` +-- layouts/_default/list.html -- +List|{{ .Kind }}|{{ .RelPermalink }}| +-- layouts/_default/single.html -- +Single|{{ .Kind }}|{{ .RelPermalink }}| +-- hugo.toml -- +[taxonomies] +tag = "tags" +[permalinks.page] +withpageslug = '/pageslug/:slug/' +withallbutlastsection = '/:sections[:last]/:slug/' +[permalinks.section] +withfilefilename = '/sectionwithfilefilename/:filename/' +withfilefiletitle = '/sectionwithfilefiletitle/:title/' +withfileslug = '/sectionwithfileslug/:slug/' +nofileslug = '/sectionnofileslug/:slug/' +nofilefilename = '/sectionnofilefilename/:filename/' +nofiletitle1 = '/sectionnofiletitle1/:title/' +nofiletitle2 = '/sectionnofiletitle2/:sections[:last]/' +[permalinks.term] +tags = '/tagsslug/tag/:slug/' +[permalinks.taxonomy] +tags = '/tagsslug/:slug/' +-- content/withpageslug/p1.md -- +--- +slug: "p1slugvalue" +tags: ["mytag"] +--- +-- content/withfilefilename/_index.md -- +-- content/withfileslug/_index.md -- +--- +slug: "withfileslugvalue" +--- +-- content/nofileslug/p1.md -- +-- content/nofilefilename/p1.md -- +-- content/nofiletitle1/p1.md -- +-- content/nofiletitle2/asdf/p1.md -- +-- content/withallbutlastsection/subsection/p1.md -- +-- content/tags/_index.md -- +--- +slug: "tagsslug" +--- +-- content/tags/mytag/_index.md -- +--- +slug: "mytagslug" +--- + + +` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + LogLevel: logg.LevelWarn, + }).Build() + + t.Log(b.LogString()) + // No .File.TranslationBaseName on zero object etc. warnings. + b.Assert(b.H.Log.LoggCount(logg.LevelWarn), qt.Equals, 0) + b.AssertFileContent("public/pageslug/p1slugvalue/index.html", "Single|page|/pageslug/p1slugvalue/|") + b.AssertFileContent("public/sectionwithfilefilename/index.html", "List|section|/sectionwithfilefilename/|") + b.AssertFileContent("public/sectionwithfileslug/withfileslugvalue/index.html", "List|section|/sectionwithfileslug/withfileslugvalue/|") + b.AssertFileContent("public/sectionnofilefilename/index.html", "List|section|/sectionnofilefilename/|") + b.AssertFileContent("public/sectionnofileslug/nofileslugs/index.html", "List|section|/sectionnofileslug/nofileslugs/|") + b.AssertFileContent("public/sectionnofiletitle1/nofiletitle1s/index.html", "List|section|/sectionnofiletitle1/nofiletitle1s/|") + b.AssertFileContent("public/sectionnofiletitle2/index.html", "List|section|/sectionnofiletitle2/|") + + b.AssertFileContent("public/tagsslug/tag/mytagslug/index.html", "List|term|/tagsslug/tag/mytagslug/|") + b.AssertFileContent("public/tagsslug/tagsslug/index.html", "List|taxonomy|/tagsslug/tagsslug/|") + + permalinksConf := b.H.Configs.Base.Permalinks + b.Assert(permalinksConf, qt.DeepEquals, map[string]map[string]string{ + "page": {"withallbutlastsection": "/:sections[:last]/:slug/", "withpageslug": "/pageslug/:slug/"}, + "section": {"nofilefilename": "/sectionnofilefilename/:filename/", "nofileslug": "/sectionnofileslug/:slug/", "nofiletitle1": "/sectionnofiletitle1/:title/", "nofiletitle2": "/sectionnofiletitle2/:sections[:last]/", "withfilefilename": "/sectionwithfilefilename/:filename/", "withfilefiletitle": "/sectionwithfilefiletitle/:title/", "withfileslug": "/sectionwithfileslug/:slug/"}, + "taxonomy": {"tags": "/tagsslug/:slug/"}, + "term": {"tags": "/tagsslug/tag/:slug/"}, + }) +} + +func TestPermalinksOldSetup(t *testing.T) { + t.Parallel() + + files := ` +-- layouts/_default/list.html -- +List|{{ .Kind }}|{{ .RelPermalink }}| +-- layouts/_default/single.html -- +Single|{{ .Kind }}|{{ .RelPermalink }}| +-- hugo.toml -- +[permalinks] +withpageslug = '/pageslug/:slug/' +-- content/withpageslug/p1.md -- +--- +slug: "p1slugvalue" +--- + + + + +` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + LogLevel: logg.LevelWarn, + }).Build() + + t.Log(b.LogString()) + // No .File.TranslationBaseName on zero object etc. warnings. + b.Assert(b.H.Log.LoggCount(logg.LevelWarn), qt.Equals, 0) + b.AssertFileContent("public/pageslug/p1slugvalue/index.html", "Single|page|/pageslug/p1slugvalue/|") + + permalinksConf := b.H.Configs.Base.Permalinks + b.Assert(permalinksConf, qt.DeepEquals, map[string]map[string]string{ + "page": {"withpageslug": "/pageslug/:slug/"}, + "section": {}, + "taxonomy": {}, + "term": {"withpageslug": "/pageslug/:slug/"}, + }) +} + +func TestPermalinksNestedSections(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +[permalinks.page] +books = '/libros/:sections[1:]/:filename' + +[permalinks.section] +books = '/libros/:sections[1:]' +-- content/books/_index.md -- +--- +title: Books +--- +-- content/books/fiction/_index.md -- +--- +title: Fiction +--- +-- content/books/fiction/2023/_index.md -- +--- +title: 2023 +--- +-- content/books/fiction/2023/book1/index.md -- +--- +title: Book 1 +--- +-- layouts/_default/single.html -- +Single. +-- layouts/_default/list.html -- +List. +` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + LogLevel: logg.LevelWarn, + }).Build() + + t.Log(b.LogString()) + // No .File.TranslationBaseName on zero object etc. warnings. + b.Assert(b.H.Log.LoggCount(logg.LevelWarn), qt.Equals, 0) + + b.AssertFileContent("public/libros/index.html", "List.") + b.AssertFileContent("public/libros/fiction/index.html", "List.") + b.AssertFileContent("public/libros/fiction/2023/book1/index.html", "Single.") +} + +func TestPermalinksUrlCascade(t *testing.T) { + t.Parallel() + + files := ` +-- layouts/_default/list.html -- +List|{{ .Kind }}|{{ .RelPermalink }}| +-- layouts/_default/single.html -- +Single|{{ .Kind }}|{{ .RelPermalink }}| +-- hugo.toml -- +-- content/cooking/delicious-recipes/_index.md -- +--- +url: /delicious-recipe/ +cascade: + url: /delicious-recipe/:slug/ +--- +-- content/cooking/delicious-recipes/example1.md -- +--- +title: Recipe 1 +--- +-- content/cooking/delicious-recipes/example2.md -- +--- +title: Recipe 2 +slug: custom-recipe-2 +--- +` + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + LogLevel: logg.LevelWarn, + }).Build() + + t.Log(b.LogString()) + b.Assert(b.H.Log.LoggCount(logg.LevelWarn), qt.Equals, 0) + b.AssertFileContent("public/delicious-recipe/index.html", "List|section|/delicious-recipe/") + b.AssertFileContent("public/delicious-recipe/recipe-1/index.html", "Single|page|/delicious-recipe/recipe-1/") + b.AssertFileContent("public/delicious-recipe/custom-recipe-2/index.html", "Single|page|/delicious-recipe/custom-recipe-2/") +} + +// Issue 12948. +// Issue 12954. +func TestPermalinksWithEscapedColons(t *testing.T) { + t.Parallel() + + if htesting.IsWindows() { + t.Skip("Windows does not support colons in paths") + } + + files := ` +-- hugo.toml -- +disableKinds = ['home','rss','sitemap','taxonomy','term'] +[permalinks.page] +s2 = "/c\\:d/:slug/" +-- content/s1/_index.md -- +--- +title: s1 +url: "/a\\:b/:slug/" +--- +-- content/s1/p1.md -- +--- +title: p1 +url: "/a\\:b/:slug/" +--- +-- content/s2/p2.md -- +--- +title: p2 +--- +-- layouts/_default/single.html -- +{{ .Title }} +-- layouts/_default/list.html -- +{{ .Title }} +` + + b := hugolib.Test(t, files) + + b.AssertFileExists("public/a:b/p1/index.html", true) + b.AssertFileExists("public/a:b/s1/index.html", true) + + // The above URLs come from the URL front matter field where everything is allowed. + // We strip colons from paths constructed by Hugo (they are not supported on Windows). + b.AssertFileExists("public/cd/p2/index.html", true) +} + +func TestPermalinksContentbasenameContentAdapter(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +[permalinks] +[permalinks.page] +a = "/:slugorcontentbasename/" +b = "/:sections/:contentbasename/" +-- content/_content.gotmpl -- +{{ $.AddPage (dict "kind" "page" "path" "a/b/contentbasename1" "title" "My A Page No Slug") }} +{{ $.AddPage (dict "kind" "page" "path" "a/b/contentbasename2" "slug" "myslug" "title" "My A Page With Slug") }} + {{ $.AddPage (dict "kind" "section" "path" "b/c" "title" "My B Section") }} +{{ $.AddPage (dict "kind" "page" "path" "b/c/contentbasename3" "title" "My B Page No Slug") }} +-- layouts/_default/single.html -- +{{ .Title }}|{{ .RelPermalink }}|{{ .Kind }}| +` + b := hugolib.Test(t, files) + + b.AssertFileContent("public/contentbasename1/index.html", "My A Page No Slug|/contentbasename1/|page|") + b.AssertFileContent("public/myslug/index.html", "My A Page With Slug|/myslug/|page|") +} + +func TestPermalinksContentbasenameWithAndWithoutFile(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +[permalinks.section] +a = "/mya/:contentbasename/" +[permalinks.page] +a = "/myapage/:contentbasename/" +[permalinks.term] +categories = "/myc/:slugorcontentbasename/" +-- content/b/c/_index.md -- +--- +title: "C section" +--- +-- content/a/b/index.md -- +--- +title: "My Title" +categories: ["c1", "c2"] +--- +-- content/categories/c2/_index.md -- +--- +title: "C2" +slug: "c2slug" +--- +-- layouts/_default/single.html -- +{{ .Title }}|{{ .RelPermalink }}|{{ .Kind }}| +-- layouts/_default/list.html -- +{{ .Title }}|{{ .RelPermalink }}|{{ .Kind }}| +` + b := hugolib.Test(t, files) + + // Sections. + b.AssertFileContent("public/mya/a/index.html", "As|/mya/a/|section|") + + // Pages. + b.AssertFileContent("public/myapage/b/index.html", "My Title|/myapage/b/|page|") + + // Taxonomies. + b.AssertFileContent("public/myc/c1/index.html", "C1|/myc/c1/|term|") + b.AssertFileContent("public/myc/c2slug/index.html", "C2|/myc/c2slug/|term|") +} + +func TestIssue13755(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['home','rss','section','sitemap','taxonomy','term'] +disablePathToLower = false +[permalinks.page] +s1 = "/:contentbasename" +-- content/s1/aBc.md -- +--- +title: aBc +--- +-- layouts/all.html -- +{{ .Title }} +` + + b := hugolib.Test(t, files) + b.AssertFileExists("public/abc/index.html", true) + + files = strings.ReplaceAll(files, "disablePathToLower = false", "disablePathToLower = true") + + b = hugolib.Test(t, files) + b.AssertFileExists("public/aBc/index.html", true) +} diff --git a/resources/page/permalinks_test.go b/resources/page/permalinks_test.go index e4eeda748..191259252 100644 --- a/resources/page/permalinks_test.go +++ b/resources/page/permalinks_test.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Hugo Authors. All rights reserved. +// Copyright 2024 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. @@ -15,11 +15,14 @@ package page import ( "fmt" + "regexp" + "strings" "sync" "testing" "time" qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/source" ) // testdataPermalinks is used by a couple of tests; the expandsTo content is @@ -27,26 +30,49 @@ import ( var testdataPermalinks = []struct { spec string valid bool + withPage func(p *testPage) expandsTo string }{ - {":title", true, "spf13-vim-3.0-release-and-new-website"}, - {"/:year-:month-:title", true, "/2012-04-spf13-vim-3.0-release-and-new-website"}, - {"/:year/:yearday/:month/:monthname/:day/:weekday/:weekdayname/", true, "/2012/97/04/April/06/5/Friday/"}, // Dates - {"/:section/", true, "/blue/"}, // Section - {"/:title/", true, "/spf13-vim-3.0-release-and-new-website/"}, // Title - {"/:slug/", true, "/the-slug/"}, // Slug - {"/:filename/", true, "/test-page/"}, // Filename - {"/:06-:1-:2-:Monday", true, "/12-4-6-Friday"}, // Dates with Go formatting - {"/:2006_01_02_15_04_05.000", true, "/2012_04_06_03_01_59.000"}, // Complicated custom date format - // TODO(moorereason): need test scaffolding for this. - //{"/:sections/", false, "/blue/"}, // Sections - + {":title", true, nil, "spf13-vim-3.0-release-and-new-website"}, + {"/:year-:month-:title", true, nil, "/2012-04-spf13-vim-3.0-release-and-new-website"}, + {"/:year/:yearday/:month/:monthname/:day/:weekday/:weekdayname/", true, nil, "/2012/97/04/April/06/5/Friday/"}, // Dates + {"/:section/", true, nil, "/blue/"}, // Section + {"/:title/", true, nil, "/spf13-vim-3.0-release-and-new-website/"}, // Title + {"/:slug/", true, nil, "/the-slug/"}, // Slug + {"/:slugorfilename/", true, nil, "/the-slug/"}, // Slug or filename + {"/:filename/", true, nil, "/test-page/"}, // Filename + {"/:06-:1-:2-:Monday", true, nil, "/12-4-6-Friday"}, // Dates with Go formatting + {"/:2006_01_02_15_04_05.000", true, nil, "/2012_04_06_03_01_59.000"}, // Complicated custom date format + {"/:sections/", true, nil, "/a/b/c/"}, // Sections + {"/:sections[last]/", true, nil, "/c/"}, // Sections + {"/:sections[0]/:sections[last]/", true, nil, "/a/c/"}, // Sections + {"/\\:filename", true, nil, "/:filename"}, // Escape sequence + {"/special\\::slug/", true, nil, "/special:the-slug/"}, + // contentbasename. // Escape sequence + {"/:contentbasename/", true, nil, "/test-page/"}, + // slug, contentbasename. // Content base name + {"/:slugorcontentbasename/", true, func(p *testPage) { + p.slug = "" + }, "/test-page/"}, + {"/:slugorcontentbasename/", true, func(p *testPage) { + p.slug = "myslug" + }, "/myslug/"}, + {"/:slugorcontentbasename/", true, func(p *testPage) { + p.slug = "" + p.title = "mytitle" + p.file = source.NewContentFileInfoFrom("/", "_index.md") + }, "/test-page/"}, // Failures - {"/blog/:fred", false, ""}, - {"/:year//:title", false, ""}, - {"/:TITLE", false, ""}, // case is not normalized - {"/:2017", false, ""}, // invalid date format - {"/:2006-01-02", false, ""}, // valid date format but invalid attribute name + {"/blog/:fred", false, nil, ""}, + {"/:year//:title", false, nil, ""}, + {"/:TITLE", false, nil, ""}, // case is not normalized + {"/:2017", false, nil, ""}, // invalid date format + {"/:2006-01-02", false, nil, ""}, // valid date format but invalid attribute name +} + +func urlize(uri string) string { + // This is just an approximation of the real urlize function. + return strings.ToLower(strings.ReplaceAll(uri, " ", "-")) } func TestPermalinkExpansion(t *testing.T) { @@ -54,31 +80,47 @@ func TestPermalinkExpansion(t *testing.T) { c := qt.New(t) - page := newTestPageWithFile("/test-page/index.md") - page.title = "Spf13 Vim 3.0 Release and new website" - d, _ := time.Parse("2006-01-02 15:04:05", "2012-04-06 03:01:59") - page.date = d - page.section = "blue" - page.slug = "The Slug" + newPage := func() *testPage { + page := newTestPageWithFile("/test-page/index.md") + page.title = "Spf13 Vim 3.0 Release and new website" + d, _ := time.Parse("2006-01-02 15:04:05", "2012-04-06 03:01:59") + page.date = d + page.section = "blue" + page.slug = "The Slug" + page.kind = "page" + // page.pathInfo + return page + } - for _, item := range testdataPermalinks { + for i, item := range testdataPermalinks { if !item.valid { continue } - permalinksConfig := map[string]string{ - "posts": item.spec, + page := newPage() + if item.withPage != nil { + item.withPage(page) } - ps := newTestPathSpec() - ps.Cfg.Set("permalinks", permalinksConfig) + specNameCleaner := regexp.MustCompile(`[\:\/\[\]]`) + name := fmt.Sprintf("[%d] %s", i, specNameCleaner.ReplaceAllString(item.spec, "_")) - expander, err := NewPermalinkExpander(ps) - c.Assert(err, qt.IsNil) + c.Run(name, func(c *qt.C) { + patterns := map[string]map[string]string{ + "page": { + "posts": item.spec, + }, + } + expander, err := NewPermalinkExpander(urlize, patterns) + c.Assert(err, qt.IsNil) + expanded, err := expander.Expand("posts", page) + c.Assert(err, qt.IsNil) + c.Assert(expanded, qt.Equals, item.expandsTo) - expanded, err := expander.Expand("posts", page) - c.Assert(err, qt.IsNil) - c.Assert(expanded, qt.Equals, item.expandsTo) + expanded, err = expander.ExpandPattern(item.spec, page) + c.Assert(err, qt.IsNil) + c.Assert(expanded, qt.Equals, item.expandsTo) + }) } } @@ -94,16 +136,21 @@ func TestPermalinkExpansionMultiSection(t *testing.T) { page.date = d page.section = "blue" page.slug = "The Slug" + page.kind = "page" - permalinksConfig := map[string]string{ - "posts": "/:slug", - "blog": "/:section/:year", + page_slug_fallback := newTestPageWithFile("/page-filename/index.md") + page_slug_fallback.title = "Page Title" + page_slug_fallback.kind = "page" + + permalinksConfig := map[string]map[string]string{ + "page": { + "posts": "/:slug", + "blog": "/:section/:year", + "recipes": "/:slugorfilename", + "special": "/special\\::slug", + }, } - - ps := newTestPathSpec() - ps.Cfg.Set("permalinks", permalinksConfig) - - expander, err := NewPermalinkExpander(ps) + expander, err := NewPermalinkExpander(urlize, permalinksConfig) c.Assert(err, qt.IsNil) expanded, err := expander.Expand("posts", page) @@ -114,6 +161,17 @@ func TestPermalinkExpansionMultiSection(t *testing.T) { c.Assert(err, qt.IsNil) c.Assert(expanded, qt.Equals, "/blue/2012") + expanded, err = expander.Expand("posts", page_slug_fallback) + c.Assert(err, qt.IsNil) + c.Assert(expanded, qt.Equals, "/page-title") + + expanded, err = expander.Expand("recipes", page_slug_fallback) + c.Assert(err, qt.IsNil) + c.Assert(expanded, qt.Equals, "/page-filename") + + expanded, err = expander.Expand("special", page) + c.Assert(err, qt.IsNil) + c.Assert(expanded, qt.Equals, "/special:the-slug") } func TestPermalinkExpansionConcurrent(t *testing.T) { @@ -121,14 +179,13 @@ func TestPermalinkExpansionConcurrent(t *testing.T) { c := qt.New(t) - permalinksConfig := map[string]string{ - "posts": "/:slug/", + permalinksConfig := map[string]map[string]string{ + "page": { + "posts": "/:slug/", + }, } - ps := newTestPathSpec() - ps.Cfg.Set("permalinks", permalinksConfig) - - expander, err := NewPermalinkExpander(ps) + expander, err := NewPermalinkExpander(urlize, permalinksConfig) c.Assert(err, qt.IsNil) var wg sync.WaitGroup @@ -138,6 +195,7 @@ func TestPermalinkExpansionConcurrent(t *testing.T) { go func(i int) { defer wg.Done() page := newTestPage() + page.kind = "page" for j := 1; j < 20; j++ { page.slug = fmt.Sprintf("slug%d", i+j) expanded, err := expander.Expand("posts", page) @@ -150,20 +208,66 @@ func TestPermalinkExpansionConcurrent(t *testing.T) { wg.Wait() } +func TestPermalinkExpansionSliceSyntax(t *testing.T) { + t.Parallel() + + c := qt.New(t) + exp, err := NewPermalinkExpander(urlize, nil) + c.Assert(err, qt.IsNil) + slice4 := []string{"a", "b", "c", "d"} + fn4 := func(s string) []string { + return exp.toSliceFunc(s)(slice4) + } + + slice1 := []string{"a"} + fn1 := func(s string) []string { + return exp.toSliceFunc(s)(slice1) + } + + c.Run("Basic", func(c *qt.C) { + c.Assert(fn4("[1:3]"), qt.DeepEquals, []string{"b", "c"}) + c.Assert(fn4("[1:]"), qt.DeepEquals, []string{"b", "c", "d"}) + c.Assert(fn4("[:2]"), qt.DeepEquals, []string{"a", "b"}) + c.Assert(fn4("[0:2]"), qt.DeepEquals, []string{"a", "b"}) + c.Assert(fn4("[:]"), qt.DeepEquals, []string{"a", "b", "c", "d"}) + c.Assert(fn4(""), qt.DeepEquals, []string{"a", "b", "c", "d"}) + c.Assert(fn4("[last]"), qt.DeepEquals, []string{"d"}) + c.Assert(fn4("[:last]"), qt.DeepEquals, []string{"a", "b", "c"}) + c.Assert(fn1("[last]"), qt.DeepEquals, []string{"a"}) + c.Assert(fn1("[:last]"), qt.DeepEquals, []string{}) + c.Assert(fn1("[1:last]"), qt.DeepEquals, []string{}) + c.Assert(fn1("[1]"), qt.DeepEquals, []string{}) + }) + + c.Run("Out of bounds", func(c *qt.C) { + c.Assert(fn4("[1:5]"), qt.DeepEquals, []string{"b", "c", "d"}) + c.Assert(fn4("[-1:5]"), qt.DeepEquals, []string{"a", "b", "c", "d"}) + c.Assert(fn4("[5:]"), qt.DeepEquals, []string{}) + c.Assert(fn4("[5:]"), qt.DeepEquals, []string{}) + c.Assert(fn4("[5:32]"), qt.DeepEquals, []string{}) + c.Assert(exp.toSliceFunc("[:1]")(nil), qt.DeepEquals, []string(nil)) + c.Assert(exp.toSliceFunc("[:1]")([]string{}), qt.DeepEquals, []string(nil)) + + // These all return nil + c.Assert(fn4("[]"), qt.IsNil) + c.Assert(fn4("[1:}"), qt.IsNil) + c.Assert(fn4("foo"), qt.IsNil) + }) +} + func BenchmarkPermalinkExpand(b *testing.B) { page := newTestPage() page.title = "Hugo Rocks" d, _ := time.Parse("2006-01-02", "2019-02-28") page.date = d + page.kind = "page" - permalinksConfig := map[string]string{ - "posts": "/:year-:month-:title", + permalinksConfig := map[string]map[string]string{ + "page": { + "posts": "/:year-:month-:title", + }, } - - ps := newTestPathSpec() - ps.Cfg.Set("permalinks", permalinksConfig) - - expander, err := NewPermalinkExpander(ps) + expander, err := NewPermalinkExpander(urlize, permalinksConfig) if err != nil { b.Fatal(err) } diff --git a/resources/page/site.go b/resources/page/site.go index 31058637b..3c9e9e78c 100644 --- a/resources/page/site.go +++ b/resources/page/site.go @@ -14,10 +14,12 @@ package page import ( - "html/template" "time" "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/config/privacy" + "github.com/gohugoio/hugo/config/services" + "github.com/gohugoio/hugo/identity" "github.com/gohugoio/hugo/config" @@ -26,42 +28,309 @@ import ( "github.com/gohugoio/hugo/navigation" ) -// Site represents a site in the build. This is currently a very narrow interface, -// but the actual implementation will be richer, see hugolib.SiteInfo. +// Site represents a site. There can be multiple sites in a multilingual setup. type Site interface { + // Returns the Language configured for this Site. Language() *langs.Language + + // Returns all the languages configured for all sites. + Languages() langs.Languages + + GetPage(ref ...string) (Page, error) + + // AllPages returns all pages for all languages. + AllPages() Pages + + // Returns all the regular Pages in this Site. RegularPages() Pages + + // Returns all Pages in this Site. Pages() Pages - IsServer() bool + + // Returns all the top level sections. + Sections() Pages + + // A shortcut to the home + Home() Page + + // Returns the server port. ServerPort() int + + // Returns the configured title for this Site. Title() string + + // Deprecated: Use .Language.LanguageCode instead. + LanguageCode() string + + // Returns the configured copyright information for this Site. + Copyright() string + + // Returns all Sites for all languages. Sites() Sites - Hugo() hugo.Info - BaseURL() template.URL - Taxonomies() interface{} + + // Returns Site currently rendering. + Current() Site + + // Returns a struct with some information about the build. + Hugo() hugo.HugoInfo + + // Returns the BaseURL for this Site. + BaseURL() string + + // Returns a taxonomy map. + Taxonomies() TaxonomyList + + // Deprecated: Use .Lastmod instead. LastChange() time.Time + + // Returns the last modification date of the content. + Lastmod() time.Time + + // Returns the Menus for this site. Menus() navigation.Menus + + // The main sections in the site. + MainSections() []string + + // Returns the Params configured for this site. Params() maps.Params - Data() map[string]interface{} + + // Param is a convenience method to do lookups in Params. + Param(key any) (any, error) + + // Returns a map of all the data inside /data. + Data() map[string]any + + // Returns the site config. + Config() SiteConfig + + // Deprecated: Use taxonomies instead. + Author() map[string]any + + // Deprecated: Use taxonomies instead. + Authors() AuthorList + + // Deprecated: Use .Site.Params instead. + Social() map[string]string + + // BuildDrafts is deprecated and will be removed in a future release. + BuildDrafts() bool + + // Deprecated: Use hugo.IsMultilingual instead. + IsMultiLingual() bool + + // LanguagePrefix returns the language prefix for this site. + LanguagePrefix() string + + maps.StoreProvider + + // For internal use only. + // This will panic if the site is not fully initialized. + // This is typically used to inform the user in the content adapter templates, + // as these are executed before all the page collections etc. are ready to use. + CheckReady() } // Sites represents an ordered list of sites (languages). type Sites []Site -// First is a convenience method to get the first Site, i.e. the main language. +// Deprecated: Use .Sites.Default instead. func (s Sites) First() Site { + hugo.Deprecate(".Sites.First", "Use .Sites.Default instead.", "v0.127.0") + return s.Default() +} + +// Default is a convenience method to get the site corresponding to the default +// content language. +func (s Sites) Default() Site { if len(s) == 0 { return nil } return s[0] } +// Some additional interfaces implemented by siteWrapper that's not on Site. +var _ identity.ForEeachIdentityByNameProvider = (*siteWrapper)(nil) + +type siteWrapper struct { + s Site +} + +func WrapSite(s Site) Site { + if s == nil { + panic("Site is nil") + } + return &siteWrapper{s: s} +} + +func (s *siteWrapper) Key() string { + return s.s.Language().Lang +} + +// Deprecated: Use .Site.Params instead. +func (s *siteWrapper) Social() map[string]string { + return s.s.Social() +} + +// Deprecated: Use taxonomies instead. +func (s *siteWrapper) Author() map[string]any { + return s.s.Author() +} + +// Deprecated: Use taxonomies instead. +func (s *siteWrapper) Authors() AuthorList { + return s.s.Authors() +} + +func (s *siteWrapper) GetPage(ref ...string) (Page, error) { + return s.s.GetPage(ref...) +} + +func (s *siteWrapper) Language() *langs.Language { + return s.s.Language() +} + +func (s *siteWrapper) Languages() langs.Languages { + return s.s.Languages() +} + +func (s *siteWrapper) AllPages() Pages { + return s.s.AllPages() +} + +func (s *siteWrapper) RegularPages() Pages { + return s.s.RegularPages() +} + +func (s *siteWrapper) Pages() Pages { + return s.s.Pages() +} + +func (s *siteWrapper) Sections() Pages { + return s.s.Sections() +} + +func (s *siteWrapper) Home() Page { + return s.s.Home() +} + +func (s *siteWrapper) ServerPort() int { + return s.s.ServerPort() +} + +func (s *siteWrapper) Title() string { + return s.s.Title() +} + +func (s *siteWrapper) LanguageCode() string { + return s.s.LanguageCode() +} + +func (s *siteWrapper) Copyright() string { + return s.s.Copyright() +} + +func (s *siteWrapper) Sites() Sites { + return s.s.Sites() +} + +func (s *siteWrapper) Current() Site { + return s.s.Current() +} + +func (s *siteWrapper) Config() SiteConfig { + return s.s.Config() +} + +func (s *siteWrapper) Hugo() hugo.HugoInfo { + return s.s.Hugo() +} + +func (s *siteWrapper) BaseURL() string { + return s.s.BaseURL() +} + +func (s *siteWrapper) Taxonomies() TaxonomyList { + return s.s.Taxonomies() +} + +// Deprecated: Use .Site.Lastmod instead. +func (s *siteWrapper) LastChange() time.Time { + return s.s.LastChange() +} + +func (s *siteWrapper) Lastmod() time.Time { + return s.s.Lastmod() +} + +func (s *siteWrapper) Menus() navigation.Menus { + return s.s.Menus() +} + +func (s *siteWrapper) MainSections() []string { + return s.s.MainSections() +} + +func (s *siteWrapper) Params() maps.Params { + return s.s.Params() +} + +func (s *siteWrapper) Param(key any) (any, error) { + return s.s.Param(key) +} + +func (s *siteWrapper) Data() map[string]any { + return s.s.Data() +} + +func (s *siteWrapper) BuildDrafts() bool { + return s.s.BuildDrafts() +} + +// Deprecated: Use hugo.IsMultilingual instead. +func (s *siteWrapper) IsMultiLingual() bool { + return s.s.IsMultiLingual() +} + +func (s *siteWrapper) LanguagePrefix() string { + return s.s.LanguagePrefix() +} + +func (s *siteWrapper) Store() *maps.Scratch { + return s.s.Store() +} + +// For internal use only. +func (s *siteWrapper) ForEeachIdentityByName(name string, f func(identity.Identity) bool) { + s.s.(identity.ForEeachIdentityByNameProvider).ForEeachIdentityByName(name, f) +} + +// For internal use only. +func (s *siteWrapper) CheckReady() { + s.s.CheckReady() +} + type testSite struct { - h hugo.Info + h hugo.HugoInfo l *langs.Language } -func (t testSite) Hugo() hugo.Info { +// Deprecated: Use taxonomies instead. +func (s testSite) Author() map[string]any { + return nil +} + +// Deprecated: Use taxonomies instead. +func (s testSite) Authors() AuthorList { + return AuthorList{} +} + +// Deprecated: Use .Site.Params instead. +func (s testSite) Social() map[string]string { + return make(map[string]string) +} + +func (t testSite) Hugo() hugo.HugoInfo { return t.h } @@ -69,30 +338,71 @@ func (t testSite) ServerPort() int { return 1313 } +// Deprecated: Use .Site.Lastmod instead. func (testSite) LastChange() (t time.Time) { return } +func (testSite) Lastmod() (t time.Time) { + return +} + func (t testSite) Title() string { return "foo" } +func (t testSite) LanguageCode() string { + return t.l.Lang +} + +func (t testSite) Copyright() string { + return "" +} + func (t testSite) Sites() Sites { return nil } -func (t testSite) IsServer() bool { - return false +func (t testSite) Sections() Pages { + return nil +} + +func (t testSite) GetPage(ref ...string) (Page, error) { + return nil, nil +} + +func (t testSite) Current() Site { + return t +} + +func (s testSite) LanguagePrefix() string { + return "" +} + +func (t testSite) Languages() langs.Languages { + return nil +} + +func (t testSite) MainSections() []string { + return nil } func (t testSite) Language() *langs.Language { return t.l } +func (t testSite) Home() Page { + return nil +} + func (t testSite) Pages() Pages { return nil } +func (t testSite) AllPages() Pages { + return nil +} + func (t testSite) RegularPages() Pages { return nil } @@ -101,11 +411,11 @@ func (t testSite) Menus() navigation.Menus { return nil } -func (t testSite) Taxonomies() interface{} { +func (t testSite) Taxonomies() TaxonomyList { return nil } -func (t testSite) BaseURL() template.URL { +func (t testSite) BaseURL() string { return "" } @@ -113,14 +423,50 @@ func (t testSite) Params() maps.Params { return nil } -func (t testSite) Data() map[string]interface{} { +func (t testSite) Data() map[string]any { return nil } +func (s testSite) Config() SiteConfig { + return SiteConfig{} +} + +func (s testSite) BuildDrafts() bool { + return false +} + +// Deprecated: Use hugo.IsMultilingual instead. +func (s testSite) IsMultiLingual() bool { + return false +} + +func (s testSite) Param(key any) (any, error) { + return nil, nil +} + +func (s testSite) Store() *maps.Scratch { + return maps.NewScratch() +} + +func (s testSite) CheckReady() { +} + // NewDummyHugoSite creates a new minimal test site. -func NewDummyHugoSite(cfg config.Provider) Site { +func NewDummyHugoSite(conf config.AllProvider) Site { return testSite{ - h: hugo.NewInfo(hugo.EnvironmentProduction), - l: langs.NewLanguage("en", cfg), + h: hugo.NewInfo(conf, nil), + l: &langs.Language{ + Lang: "en", + }, } } + +// SiteConfig holds the config in site.Config. +type SiteConfig struct { + // This contains all privacy related settings that can be used to + // make the YouTube template etc. GDPR compliant. + Privacy privacy.Config + + // Services contains config for services such as Google Analytics etc. + Services services.Config +} diff --git a/resources/page/site_integration_test.go b/resources/page/site_integration_test.go new file mode 100644 index 000000000..60064df3a --- /dev/null +++ b/resources/page/site_integration_test.go @@ -0,0 +1,44 @@ +// Copyright 2024 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 page_test + +import ( + "testing" + + "github.com/gohugoio/hugo/hugolib" +) + +// Issue 12513 +func TestPageSiteSitesDefault(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['page','rss','section','sitemap','taxonomy','term'] +defaultContentLanguage = 'de' +defaultContentLanguageInSubdir = true +[languages.en] +languageName = 'English' +weight = 1 +[languages.de] +languageName = 'Deutsch' +weight = 2 +-- layouts/index.html -- +{{ .Site.Sites.Default.Language.LanguageName }} +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/de/index.html", "Deutsch") +} diff --git a/resources/page/siteidentities/identities.go b/resources/page/siteidentities/identities.go new file mode 100644 index 000000000..8481999cf --- /dev/null +++ b/resources/page/siteidentities/identities.go @@ -0,0 +1,34 @@ +// Copyright 2024 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 siteidentities + +import ( + "github.com/gohugoio/hugo/identity" +) + +const ( + // Identifies site.Data. + // The change detection in /data is currently very coarse grained. + Data = identity.StringIdentity("site.Data") +) + +// FromString returns the identity from the given string, +// or identity.Anonymous if not found. +func FromString(name string) (identity.Identity, bool) { + switch name { + case "Data": + return Data, true + } + return identity.Anonymous, false +} diff --git a/hugolib/taxonomy.go b/resources/page/taxonomy.go similarity index 77% rename from hugolib/taxonomy.go rename to resources/page/taxonomy.go index e3f033109..2732c8040 100644 --- a/hugolib/taxonomy.go +++ b/resources/page/taxonomy.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Hugo Authors. All rights reserved. +// Copyright 2024 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. @@ -11,15 +11,15 @@ // See the License for the specific language governing permissions and // limitations under the License. -package hugolib +package page import ( "fmt" "sort" + "strings" "github.com/gohugoio/hugo/compare" - - "github.com/gohugoio/hugo/resources/page" + "github.com/gohugoio/hugo/langs" ) // The TaxonomyList is a list of all taxonomies and their values @@ -32,32 +32,38 @@ func (tl TaxonomyList) String() string { // A Taxonomy is a map of keywords to a list of pages. // For example -// TagTaxonomy['technology'] = page.WeightedPages -// TagTaxonomy['go'] = page.WeightedPages -type Taxonomy map[string]page.WeightedPages +// +// TagTaxonomy['technology'] = WeightedPages +// TagTaxonomy['go'] = WeightedPages +type Taxonomy map[string]WeightedPages // OrderedTaxonomy is another representation of an Taxonomy using an array rather than a map. // Important because you can't order a map. type OrderedTaxonomy []OrderedTaxonomyEntry +// getOneOPage returns one page in the taxonomy, +// nil if there is none. +func (t OrderedTaxonomy) getOneOPage() Page { + if len(t) == 0 { + return nil + } + return t[0].Pages()[0] +} + // OrderedTaxonomyEntry is similar to an element of a Taxonomy, but with the key embedded (as name) -// e.g: {Name: Technology, page.WeightedPages: TaxonomyPages} +// e.g: {Name: Technology, WeightedPages: TaxonomyPages} type OrderedTaxonomyEntry struct { Name string - page.WeightedPages + WeightedPages } // Get the weighted pages for the given key. -func (i Taxonomy) Get(key string) page.WeightedPages { - return i[key] +func (i Taxonomy) Get(key string) WeightedPages { + return i[strings.ToLower(key)] } // Count the weighted pages for the given key. -func (i Taxonomy) Count(key string) int { return len(i[key]) } - -func (i Taxonomy) add(key string, w page.WeightedPage) { - i[key] = append(i[key], w) -} +func (i Taxonomy) Count(key string) int { return len(i[strings.ToLower(key)]) } // TaxonomyArray returns an ordered taxonomy with a non defined order. func (i Taxonomy) TaxonomyArray() OrderedTaxonomy { @@ -72,11 +78,18 @@ func (i Taxonomy) TaxonomyArray() OrderedTaxonomy { // Alphabetical returns an ordered taxonomy sorted by key name. func (i Taxonomy) Alphabetical() OrderedTaxonomy { - name := func(i1, i2 *OrderedTaxonomyEntry) bool { - return compare.LessStrings(i1.Name, i2.Name) - } - ia := i.TaxonomyArray() + p := ia.getOneOPage() + if p == nil { + return ia + } + currentSite := p.Site().Current() + coll := langs.GetCollator1(currentSite.Language()) + coll.Lock() + defer coll.Unlock() + name := func(i1, i2 *OrderedTaxonomyEntry) bool { + return coll.CompareStrings(i1.Name, i2.Name) < 0 + } oiBy(name).Sort(ia) return ia } @@ -99,8 +112,16 @@ func (i Taxonomy) ByCount() OrderedTaxonomy { return ia } +// Page returns the taxonomy page or nil if the taxonomy has no terms. +func (i Taxonomy) Page() Page { + for _, v := range i { + return v.Page().Parent() + } + return nil +} + // Pages returns the Pages for this taxonomy. -func (ie OrderedTaxonomyEntry) Pages() page.Pages { +func (ie OrderedTaxonomyEntry) Pages() Pages { return ie.WeightedPages.Pages() } diff --git a/resources/page/taxonomy_integration_test.go b/resources/page/taxonomy_integration_test.go new file mode 100644 index 000000000..70e204dd1 --- /dev/null +++ b/resources/page/taxonomy_integration_test.go @@ -0,0 +1,81 @@ +// Copyright 2024 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 page_test + +import ( + "testing" + + "github.com/gohugoio/hugo/hugolib" +) + +func TestTaxonomiesGetAndCount(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['rss','sitemap'] +[taxonomies] +author = 'authors' +-- layouts/_default/home.html -- +John Smith count: {{ site.Taxonomies.authors.Count "John Smith" }} +Robert Jones count: {{ (site.Taxonomies.authors.Get "Robert Jones").Pages.Len }} +-- layouts/_default/single.html -- +{{ .Title }}| +-- layouts/_default/list.html -- +{{ .Title }}| +-- content/p1.md -- +--- +title: p1 +authors: [John Smith,Robert Jones] +--- +-- content/p2.md -- +--- +title: p2 +authors: [John Smith] +--- +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", + "John Smith count: 2", + "Robert Jones count: 1", + ) +} + +func TestTaxonomiesPage(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['rss','section','sitemap'] +[taxonomies] +tag = 'tags' +category = 'categories' +-- content/p1.md -- +--- +title: p1 +tags: [tag-a] +--- +-- layouts/_default/list.html -- +{{- with site.Taxonomies.tags.Page }}{{ .RelPermalink }}{{ end }}| +{{- with site.Taxonomies.categories.Page }}{{ .RelPermalink }}{{ end }}| +-- layouts/_default/single.html -- +{{ .Title }} +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", "/tags/||") +} diff --git a/resources/page/testhelpers_test.go b/resources/page/testhelpers_test.go index 0d21faa51..1d2ee6223 100644 --- a/resources/page/testhelpers_test.go +++ b/resources/page/testhelpers_test.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Hugo Authors. All rights reserved. +// Copyright 2024 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,22 +14,22 @@ package page import ( + "context" "fmt" "html/template" + "path" "path/filepath" "time" - "github.com/gohugoio/hugo/modules" + "github.com/gohugoio/hugo/markup/tableofcontents" - "github.com/bep/gitmap" - "github.com/gohugoio/hugo/helpers" "github.com/gohugoio/hugo/resources/resource" - "github.com/spf13/viper" "github.com/gohugoio/hugo/navigation" "github.com/gohugoio/hugo/common/hugo" "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/common/paths" "github.com/gohugoio/hugo/config" "github.com/gohugoio/hugo/hugofs" "github.com/gohugoio/hugo/langs" @@ -52,46 +52,47 @@ func newTestPage() *testPage { func newTestPageWithFile(filename string) *testPage { filename = filepath.FromSlash(filename) - file := source.NewTestFile(filename) + file := source.NewContentFileInfoFrom(filename, filename) + + l, err := langs.NewLanguage( + "en", + "en", + "UTC", + langs.LanguageConfig{ + LanguageName: "English", + }, + ) + if err != nil { + panic(err) + } + return &testPage{ - params: make(map[string]interface{}), - data: make(map[string]interface{}), - file: file, + params: make(map[string]any), + data: make(map[string]any), + file: file, + pathInfo: file.FileInfo().Meta().PathInfo, + currentSection: &testPage{ + sectionEntries: []string{"a", "b", "c"}, + }, + site: testSite{l: l}, } } -func newTestPathSpec() *helpers.PathSpec { - return newTestPathSpecFor(viper.New()) -} - -func newTestPathSpecFor(cfg config.Provider) *helpers.PathSpec { - config.SetBaseTestDefaults(cfg) - langs.LoadLanguageSettings(cfg, nil) - mod, err := modules.CreateProjectModule(cfg) - if err != nil { - panic(err) - } - cfg.Set("allModules", modules.Modules{mod}) - fs := hugofs.NewMem(cfg) - s, err := helpers.NewPathSpec(fs, cfg, nil) - if err != nil { - panic(err) - } - return s -} - type testPage struct { + kind string description string title string linkTitle string - - section string + lang string + section string + site testSite content string fuzzyWordCount int - path string + path string + pathInfo *paths.Path slug string @@ -103,63 +104,63 @@ type testPage struct { weight int - params map[string]interface{} - data map[string]interface{} + params map[string]any + data map[string]any - file source.File + file *source.File + + currentSection *testPage + sectionEntries []string } func (p *testPage) Aliases() []string { - panic("not implemented") + panic("testpage: not implemented") } func (p *testPage) AllTranslations() Pages { - panic("not implemented") + panic("testpage: not implemented") } func (p *testPage) AlternativeOutputFormats() OutputFormats { - panic("not implemented") -} - -func (p *testPage) Author() Author { - return Author{} - -} -func (p *testPage) Authors() AuthorList { - return nil + panic("testpage: not implemented") } func (p *testPage) BaseFileName() string { - panic("not implemented") + panic("testpage: not implemented") } func (p *testPage) BundleType() string { - panic("not implemented") + panic("testpage: not implemented") } -func (p *testPage) Content() (interface{}, error) { - panic("not implemented") +func (p *testPage) Content(context.Context) (any, error) { + panic("testpage: not implemented") +} + +func (p *testPage) Markup(...any) Markup { + panic("testpage: not implemented") } func (p *testPage) ContentBaseName() string { - panic("not implemented") + panic("testpage: not implemented") } func (p *testPage) CurrentSection() Page { - panic("not implemented") + return p.currentSection } -func (p *testPage) Data() interface{} { +func (p *testPage) Data() any { return p.data } -func (p *testPage) Sitemap() config.Sitemap { - return config.Sitemap{} +func (p *testPage) Sitemap() config.SitemapConfig { + return config.SitemapConfig{} } func (p *testPage) Layout() string { return "" } + func (p *testPage) Date() time.Time { return p.date } @@ -168,15 +169,19 @@ func (p *testPage) Description() string { return "" } +func (p *testPage) ContentWithoutSummary(ctx context.Context) (template.HTML, error) { + return "", nil +} + func (p *testPage) Dir() string { - panic("not implemented") + panic("testpage: not implemented") } func (p *testPage) Draft() bool { - panic("not implemented") + panic("testpage: not implemented") } -func (p *testPage) Eq(other interface{}) bool { +func (p *testPage) Eq(other any) bool { return p == other } @@ -184,72 +189,72 @@ func (p *testPage) ExpiryDate() time.Time { return p.expiryDate } -func (p *testPage) Ext() string { - panic("not implemented") -} - -func (p *testPage) Extension() string { - panic("not implemented") -} - -func (p *testPage) File() source.File { +func (p *testPage) File() *source.File { return p.file } func (p *testPage) FileInfo() hugofs.FileMetaInfo { - panic("not implemented") + panic("testpage: not implemented") } func (p *testPage) Filename() string { - panic("not implemented") + panic("testpage: not implemented") } func (p *testPage) FirstSection() Page { - panic("not implemented") + panic("testpage: not implemented") } -func (p *testPage) FuzzyWordCount() int { +func (p *testPage) FuzzyWordCount(context.Context) int { return p.fuzzyWordCount } func (p *testPage) GetPage(ref string) (Page, error) { - panic("not implemented") + panic("testpage: not implemented") } -func (p *testPage) GetParam(key string) interface{} { - panic("not implemented") +func (p *testPage) GetParam(key string) any { + panic("testpage: not implemented") } -func (p *testPage) GetRelatedDocsHandler() *RelatedDocsHandler { +func (p *testPage) GetTerms(taxonomy string) Pages { + panic("testpage: not implemented") +} + +func (p *testPage) GetInternalRelatedDocsHandler() *RelatedDocsHandler { return relatedDocsHandler } -func (p *testPage) GitInfo() *gitmap.GitInfo { +func (p *testPage) GitInfo() source.GitInfo { + return source.GitInfo{} +} + +func (p *testPage) CodeOwners() []string { return nil } func (p *testPage) HasMenuCurrent(menuID string, me *navigation.MenuEntry) bool { - panic("not implemented") + panic("testpage: not implemented") } func (p *testPage) HasShortcode(name string) bool { - panic("not implemented") + panic("testpage: not implemented") } -func (p *testPage) Hugo() hugo.Info { - panic("not implemented") +func (p *testPage) Hugo() hugo.HugoInfo { + panic("testpage: not implemented") } -func (p *testPage) InSection(other interface{}) (bool, error) { - panic("not implemented") +func (p *testPage) InSection(other any) bool { + panic("testpage: not implemented") } -func (p *testPage) IsAncestor(other interface{}) (bool, error) { - panic("not implemented") +func (p *testPage) IsAncestor(other any) bool { + panic("testpage: not implemented") } -func (p *testPage) IsDescendant(other interface{}) (bool, error) { - panic("not implemented") +func (p *testPage) IsDescendant(other any) bool { + panic("testpage: not implemented") } func (p *testPage) IsDraft() bool { @@ -257,27 +262,31 @@ func (p *testPage) IsDraft() bool { } func (p *testPage) IsHome() bool { - panic("not implemented") + panic("testpage: not implemented") } func (p *testPage) IsMenuCurrent(menuID string, inme *navigation.MenuEntry) bool { - panic("not implemented") + panic("testpage: not implemented") } func (p *testPage) IsNode() bool { - panic("not implemented") + panic("testpage: not implemented") } func (p *testPage) IsPage() bool { - panic("not implemented") + panic("testpage: not implemented") } func (p *testPage) IsSection() bool { - panic("not implemented") + panic("testpage: not implemented") } func (p *testPage) IsTranslated() bool { - panic("not implemented") + panic("testpage: not implemented") +} + +func (p *testPage) Ancestors() Pages { + panic("testpage: not implemented") } func (p *testPage) Keywords() []string { @@ -285,26 +294,34 @@ func (p *testPage) Keywords() []string { } func (p *testPage) Kind() string { - panic("not implemented") + return p.kind } func (p *testPage) Lang() string { - panic("not implemented") + return p.lang } func (p *testPage) Language() *langs.Language { - panic("not implemented") + panic("testpage: not implemented") } func (p *testPage) LanguagePrefix() string { return "" } +func (p *testPage) Fragments(context.Context) *tableofcontents.Fragments { + return nil +} + +func (p *testPage) HeadingsFiltered(context.Context) tableofcontents.Headings { + return nil +} + func (p *testPage) Lastmod() time.Time { return p.lastMod } -func (p *testPage) Len() int { +func (p *testPage) Len(context.Context) int { return len(p.content) } @@ -319,11 +336,11 @@ func (p *testPage) LinkTitle() string { } func (p *testPage) LogicalName() string { - panic("not implemented") + panic("testpage: not implemented") } func (p *testPage) MediaType() media.Type { - panic("not implemented") + panic("testpage: not implemented") } func (p *testPage) Menus() navigation.PageMenus { @@ -331,11 +348,11 @@ func (p *testPage) Menus() navigation.PageMenus { } func (p *testPage) Name() string { - panic("not implemented") + panic("testpage: not implemented") } func (p *testPage) Next() Page { - panic("not implemented") + panic("testpage: not implemented") } func (p *testPage) NextInSection() Page { @@ -347,26 +364,30 @@ func (p *testPage) NextPage() Page { } func (p *testPage) OutputFormats() OutputFormats { - panic("not implemented") + panic("testpage: not implemented") } func (p *testPage) Pages() Pages { - panic("not implemented") + panic("testpage: not implemented") } func (p *testPage) RegularPages() Pages { - panic("not implemented") + panic("testpage: not implemented") } -func (p *testPage) Paginate(seq interface{}, options ...interface{}) (*Pager, error) { +func (p *testPage) RegularPagesRecursive() Pages { + panic("testpage: not implemented") +} + +func (p *testPage) Paginate(seq any, options ...any) (*Pager, error) { return nil, nil } -func (p *testPage) Paginator(options ...interface{}) (*Pager, error) { +func (p *testPage) Paginator(options ...any) (*Pager, error) { return nil, nil } -func (p *testPage) Param(key interface{}) (interface{}, error) { +func (p *testPage) Param(key any) (any, error) { return resource.Param(p, nil, key) } @@ -379,27 +400,31 @@ func (p *testPage) Page() Page { } func (p *testPage) Parent() Page { - panic("not implemented") + panic("testpage: not implemented") } func (p *testPage) Path() string { return p.path } +func (p *testPage) PathInfo() *paths.Path { + return p.pathInfo +} + func (p *testPage) Permalink() string { - panic("not implemented") + panic("testpage: not implemented") } -func (p *testPage) Plain() string { - panic("not implemented") +func (p *testPage) Plain(context.Context) string { + panic("testpage: not implemented") } -func (p *testPage) PlainWords() []string { - panic("not implemented") +func (p *testPage) PlainWords(context.Context) []string { + panic("testpage: not implemented") } func (p *testPage) Prev() Page { - panic("not implemented") + panic("testpage: not implemented") } func (p *testPage) PrevInSection() Page { @@ -414,56 +439,60 @@ func (p *testPage) PublishDate() time.Time { return p.pubDate } -func (p *testPage) RSSLink() template.URL { - return "" -} - func (p *testPage) RawContent() string { - panic("not implemented") + panic("testpage: not implemented") } -func (p *testPage) ReadingTime() int { - panic("not implemented") +func (p *testPage) RenderShortcodes(context.Context) (template.HTML, error) { + panic("testpage: not implemented") } -func (p *testPage) Ref(argsm map[string]interface{}) (string, error) { - panic("not implemented") +func (p *testPage) ReadingTime(context.Context) int { + panic("testpage: not implemented") } -func (p *testPage) RefFrom(argsm map[string]interface{}, source interface{}) (string, error) { +func (p *testPage) Ref(argsm map[string]any) (string, error) { + panic("testpage: not implemented") +} + +func (p *testPage) RefFrom(argsm map[string]any, source any) (string, error) { return "", nil } func (p *testPage) RelPermalink() string { - panic("not implemented") + panic("testpage: not implemented") } -func (p *testPage) RelRef(argsm map[string]interface{}) (string, error) { - panic("not implemented") +func (p *testPage) RelRef(argsm map[string]any) (string, error) { + panic("testpage: not implemented") } -func (p *testPage) RelRefFrom(argsm map[string]interface{}, source interface{}) (string, error) { +func (p *testPage) RelRefFrom(argsm map[string]any, source any) (string, error) { return "", nil } -func (p *testPage) Render(layout ...string) (template.HTML, error) { - panic("not implemented") +func (p *testPage) Render(ctx context.Context, layout ...string) (template.HTML, error) { + panic("testpage: not implemented") } -func (p *testPage) RenderString(args ...interface{}) (template.HTML, error) { - panic("not implemented") +func (p *testPage) RenderString(ctx context.Context, args ...any) (template.HTML, error) { + panic("testpage: not implemented") } func (p *testPage) ResourceType() string { - panic("not implemented") + panic("testpage: not implemented") } func (p *testPage) Resources() resource.Resources { - panic("not implemented") + panic("testpage: not implemented") } func (p *testPage) Scratch() *maps.Scratch { - panic("not implemented") + panic("testpage: not implemented") +} + +func (p *testPage) Store() *maps.Scratch { + panic("testpage: not implemented") } func (p *testPage) RelatedKeywords(cfg related.IndexConfig) ([]related.Keyword, error) { @@ -480,23 +509,23 @@ func (p *testPage) Section() string { } func (p *testPage) Sections() Pages { - panic("not implemented") + panic("testpage: not implemented") } func (p *testPage) SectionsEntries() []string { - panic("not implemented") + return p.sectionEntries } func (p *testPage) SectionsPath() string { - panic("not implemented") + return path.Join(p.sectionEntries...) } func (p *testPage) Site() Site { - panic("not implemented") + return p.site } func (p *testPage) Sites() Sites { - panic("not implemented") + panic("testpage: not implemented") } func (p *testPage) Slug() string { @@ -507,12 +536,12 @@ func (p *testPage) String() string { return p.path } -func (p *testPage) Summary() template.HTML { - panic("not implemented") +func (p *testPage) Summary(context.Context) template.HTML { + panic("testpage: not implemented") } -func (p *testPage) TableOfContents() template.HTML { - panic("not implemented") +func (p *testPage) TableOfContents(context.Context) template.HTML { + panic("testpage: not implemented") } func (p *testPage) Title() string { @@ -520,7 +549,7 @@ func (p *testPage) Title() string { } func (p *testPage) TranslationBaseName() string { - panic("not implemented") + panic("testpage: not implemented") } func (p *testPage) TranslationKey() string { @@ -528,11 +557,11 @@ func (p *testPage) TranslationKey() string { } func (p *testPage) Translations() Pages { - panic("not implemented") + panic("testpage: not implemented") } -func (p *testPage) Truncated() bool { - panic("not implemented") +func (p *testPage) Truncated(context.Context) bool { + panic("testpage: not implemented") } func (p *testPage) Type() string { @@ -544,21 +573,21 @@ func (p *testPage) URL() string { } func (p *testPage) UniqueID() string { - panic("not implemented") + panic("testpage: not implemented") } func (p *testPage) Weight() int { return p.weight } -func (p *testPage) WordCount() int { - panic("not implemented") +func (p *testPage) WordCount(context.Context) int { + panic("testpage: not implemented") } func createTestPages(num int) Pages { pages := make(Pages, num) - for i := 0; i < num; i++ { + for i := range num { m := &testPage{ path: fmt.Sprintf("/x/y/z/p%d.md", i), weight: 5, diff --git a/resources/page/weighted.go b/resources/page/weighted.go index 7e5e25451..39034d26c 100644 --- a/resources/page/weighted.go +++ b/resources/page/weighted.go @@ -20,9 +20,7 @@ import ( "github.com/gohugoio/hugo/common/collections" ) -var ( - _ collections.Slicer = WeightedPage{} -) +var _ collections.Slicer = WeightedPage{} // WeightedPages is a list of Pages with their corresponding (and relative) weight // [{Weight: 30, Page: *1}, {Weight: 40, Page: *2}] @@ -65,13 +63,13 @@ func (w WeightedPage) String() string { return fmt.Sprintf("WeightedPage(%d,%q)", w.Weight, w.Page.Title()) } -// Slice is not meant to be used externally. It's a bridge function +// Slice is for internal use. // for the template functions. See collections.Slice. -func (p WeightedPage) Slice(in interface{}) (interface{}, error) { +func (p WeightedPage) Slice(in any) (any, error) { switch items := in.(type) { case WeightedPages: return items, nil - case []interface{}: + case []any: weighted := make(WeightedPages, len(items)) for i, v := range items { g, ok := v.(WeightedPage) diff --git a/resources/page/zero_file.autogen.go b/resources/page/zero_file.autogen.go index 23e36b764..4b7c034a1 100644 --- a/resources/page/zero_file.autogen.go +++ b/resources/page/zero_file.autogen.go @@ -14,75 +14,3 @@ // This file is autogenerated. package page - -import ( - "github.com/gohugoio/hugo/helpers" - "github.com/gohugoio/hugo/hugofs" - "github.com/gohugoio/hugo/source" -) - -// ZeroFile represents a zero value of source.File with warnings if invoked. -type zeroFile struct { - log *helpers.DistinctLogger -} - -func NewZeroFile(log *helpers.DistinctLogger) source.File { - return zeroFile{log: log} -} - -func (zeroFile) IsZero() bool { - return true -} - -func (z zeroFile) Path() (o0 string) { - z.log.Println(".File.Path on zero object. Wrap it in if or with: {{ with .File }}{{ .Path }}{{ end }}") - return -} -func (z zeroFile) Section() (o0 string) { - z.log.Println(".File.Section on zero object. Wrap it in if or with: {{ with .File }}{{ .Section }}{{ end }}") - return -} -func (z zeroFile) Lang() (o0 string) { - z.log.Println(".File.Lang on zero object. Wrap it in if or with: {{ with .File }}{{ .Lang }}{{ end }}") - return -} -func (z zeroFile) Filename() (o0 string) { - z.log.Println(".File.Filename on zero object. Wrap it in if or with: {{ with .File }}{{ .Filename }}{{ end }}") - return -} -func (z zeroFile) Dir() (o0 string) { - z.log.Println(".File.Dir on zero object. Wrap it in if or with: {{ with .File }}{{ .Dir }}{{ end }}") - return -} -func (z zeroFile) Extension() (o0 string) { - z.log.Println(".File.Extension on zero object. Wrap it in if or with: {{ with .File }}{{ .Extension }}{{ end }}") - return -} -func (z zeroFile) Ext() (o0 string) { - z.log.Println(".File.Ext on zero object. Wrap it in if or with: {{ with .File }}{{ .Ext }}{{ end }}") - return -} -func (z zeroFile) LogicalName() (o0 string) { - z.log.Println(".File.LogicalName on zero object. Wrap it in if or with: {{ with .File }}{{ .LogicalName }}{{ end }}") - return -} -func (z zeroFile) BaseFileName() (o0 string) { - z.log.Println(".File.BaseFileName on zero object. Wrap it in if or with: {{ with .File }}{{ .BaseFileName }}{{ end }}") - return -} -func (z zeroFile) TranslationBaseName() (o0 string) { - z.log.Println(".File.TranslationBaseName on zero object. Wrap it in if or with: {{ with .File }}{{ .TranslationBaseName }}{{ end }}") - return -} -func (z zeroFile) ContentBaseName() (o0 string) { - z.log.Println(".File.ContentBaseName on zero object. Wrap it in if or with: {{ with .File }}{{ .ContentBaseName }}{{ end }}") - return -} -func (z zeroFile) UniqueID() (o0 string) { - z.log.Println(".File.UniqueID on zero object. Wrap it in if or with: {{ with .File }}{{ .UniqueID }}{{ end }}") - return -} -func (z zeroFile) FileInfo() (o0 hugofs.FileMetaInfo) { - z.log.Println(".File.FileInfo on zero object. Wrap it in if or with: {{ with .File }}{{ .FileInfo }}{{ end }}") - return -} diff --git a/resources/post_publish.go b/resources/post_publish.go new file mode 100644 index 000000000..b2adfa5ce --- /dev/null +++ b/resources/post_publish.go @@ -0,0 +1,51 @@ +// Copyright 2020 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 resources + +import ( + "github.com/gohugoio/hugo/resources/postpub" + "github.com/gohugoio/hugo/resources/resource" +) + +type transformationKeyer interface { + TransformationKey() string +} + +// PostProcess wraps the given Resource for later processing. +func (spec *Spec) PostProcess(r resource.Resource) (postpub.PostPublishedResource, error) { + key := r.(transformationKeyer).TransformationKey() + spec.postProcessMu.RLock() + result, found := spec.PostProcessResources[key] + spec.postProcessMu.RUnlock() + if found { + return result, nil + } + + spec.postProcessMu.Lock() + defer spec.postProcessMu.Unlock() + + // Double check + result, found = spec.PostProcessResources[key] + if found { + return result, nil + } + + result = postpub.NewPostPublishResource(spec.incr.Incr(), r) + if result == nil { + panic("got nil result") + } + spec.PostProcessResources[key] = result + + return result, nil +} diff --git a/resources/postpub/fields.go b/resources/postpub/fields.go new file mode 100644 index 000000000..12b3be2eb --- /dev/null +++ b/resources/postpub/fields.go @@ -0,0 +1,59 @@ +// Copyright 2020 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 postpub + +import ( + "reflect" +) + +const ( + FieldNotSupported = "__field_not_supported" +) + +func structToMapWithPlaceholders(root string, in any, createPlaceholder func(s string) string) map[string]any { + m := structToMap(in) + insertFieldPlaceholders(root, m, createPlaceholder) + return m +} + +func structToMap(s any) map[string]any { + m := make(map[string]any) + t := reflect.TypeOf(s) + + for i := range t.NumMethod() { + method := t.Method(i) + if method.PkgPath != "" { + continue + } + if method.Type.NumIn() == 1 { + m[method.Name] = "" + } + } + + for i := range t.NumField() { + field := t.Field(i) + if field.PkgPath != "" { + continue + } + m[field.Name] = "" + } + return m +} + +// insert placeholder for the templates. Do it very shallow for now. +func insertFieldPlaceholders(root string, m map[string]any, createPlaceholder func(s string) string) { + for k := range m { + m[k] = createPlaceholder(root + "." + k) + } +} diff --git a/resources/postpub/fields_test.go b/resources/postpub/fields_test.go new file mode 100644 index 000000000..53875cb34 --- /dev/null +++ b/resources/postpub/fields_test.go @@ -0,0 +1,47 @@ +// Copyright 2020 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 postpub + +import ( + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/media" +) + +func TestCreatePlaceholders(t *testing.T) { + c := qt.New(t) + + m := structToMap(media.Builtin.CSSType) + + insertFieldPlaceholders("foo", m, func(s string) string { + return "pre_" + s + "_post" + }) + + c.Assert(m, qt.DeepEquals, map[string]any{ + "IsZero": "pre_foo.IsZero_post", + "MarshalJSON": "pre_foo.MarshalJSON_post", + "Suffixes": "pre_foo.Suffixes_post", + "SuffixesCSV": "pre_foo.SuffixesCSV_post", + "Delimiter": "pre_foo.Delimiter_post", + "FirstSuffix": "pre_foo.FirstSuffix_post", + "IsHTML": "pre_foo.IsHTML_post", + "IsMarkdown": "pre_foo.IsMarkdown_post", + "IsText": "pre_foo.IsText_post", + "String": "pre_foo.String_post", + "Type": "pre_foo.Type_post", + "MainType": "pre_foo.MainType_post", + "SubType": "pre_foo.SubType_post", + }) +} diff --git a/resources/postpub/postpub.go b/resources/postpub/postpub.go new file mode 100644 index 000000000..65e32145c --- /dev/null +++ b/resources/postpub/postpub.go @@ -0,0 +1,182 @@ +// Copyright 2020 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 postpub + +import ( + "context" + "fmt" + "reflect" + "strconv" + "strings" + + "github.com/spf13/cast" + + "github.com/gohugoio/hugo/common/hreflect" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/media" + "github.com/gohugoio/hugo/resources/resource" +) + +type PostPublishedResource interface { + resource.ResourceTypeProvider + resource.ResourceLinksProvider + resource.ResourceNameTitleProvider + resource.ResourceParamsProvider + resource.ResourceDataProvider + resource.OriginProvider + + MediaType() map[string]any +} + +const ( + PostProcessPrefix = "__h_pp_l1" + + // The suffix has an '=' in it to prevent the minifier to remove any enclosing + // quoutes around the attribute values. + // See issue #8884. + PostProcessSuffix = "__e=" +) + +func NewPostPublishResource(id int, r resource.Resource) PostPublishedResource { + return &PostPublishResource{ + prefix: PostProcessPrefix + "_" + strconv.Itoa(id) + "_", + delegate: r, + } +} + +// PostPublishResource holds a Resource to be transformed post publishing. +type PostPublishResource struct { + prefix string + delegate resource.Resource +} + +func (r *PostPublishResource) field(name string) string { + return r.prefix + name + PostProcessSuffix +} + +func (r *PostPublishResource) Permalink() string { + return r.field("Permalink") +} + +func (r *PostPublishResource) RelPermalink() string { + return r.field("RelPermalink") +} + +func (r *PostPublishResource) Origin() resource.Resource { + return r.delegate +} + +func (r *PostPublishResource) GetFieldString(pattern string) (string, bool) { + if r == nil { + panic("resource is nil") + } + prefixIdx := strings.Index(pattern, r.prefix) + if prefixIdx == -1 { + // Not a method on this resource. + return "", false + } + + fieldAccessor := pattern[prefixIdx+len(r.prefix) : strings.Index(pattern, PostProcessSuffix)] + + d := r.delegate + switch { + case fieldAccessor == "RelPermalink": + return d.RelPermalink(), true + case fieldAccessor == "Permalink": + return d.Permalink(), true + case fieldAccessor == "Name": + return d.Name(), true + case fieldAccessor == "Title": + return d.Title(), true + case fieldAccessor == "ResourceType": + return d.ResourceType(), true + case fieldAccessor == "Content": + content, err := d.(resource.ContentProvider).Content(context.Background()) + if err != nil { + return "", true + } + return cast.ToString(content), true + case strings.HasPrefix(fieldAccessor, "MediaType"): + return r.fieldToString(d.MediaType(), fieldAccessor), true + case fieldAccessor == "Data.Integrity": + return cast.ToString((d.Data().(map[string]any)["Integrity"])), true + default: + panic(fmt.Sprintf("unknown field accessor %q", fieldAccessor)) + } +} + +func (r *PostPublishResource) fieldToString(receiver any, path string) string { + fieldname := strings.Split(path, ".")[1] + + receiverv := reflect.ValueOf(receiver) + switch receiverv.Kind() { + case reflect.Map: + v := receiverv.MapIndex(reflect.ValueOf(fieldname)) + return cast.ToString(v.Interface()) + default: + v := receiverv.FieldByName(fieldname) + if !v.IsValid() { + method := hreflect.GetMethodByName(receiverv, fieldname) + if method.IsValid() { + vals := method.Call(nil) + if len(vals) > 0 { + v = vals[0] + } + + } + } + + if v.IsValid() { + return cast.ToString(v.Interface()) + } + return "" + } +} + +func (r *PostPublishResource) Data() any { + m := map[string]any{ + "Integrity": "", + } + insertFieldPlaceholders("Data", m, r.field) + return m +} + +func (r *PostPublishResource) MediaType() map[string]any { + m := structToMapWithPlaceholders("MediaType", media.Type{}, r.field) + return m +} + +func (r *PostPublishResource) ResourceType() string { + return r.field("ResourceType") +} + +func (r *PostPublishResource) Name() string { + return r.field("Name") +} + +func (r *PostPublishResource) Title() string { + return r.field("Title") +} + +func (r *PostPublishResource) Params() maps.Params { + panic(r.fieldNotSupported("Params")) +} + +func (r *PostPublishResource) Content(context.Context) (any, error) { + return r.field("Content"), nil +} + +func (r *PostPublishResource) fieldNotSupported(name string) string { + return fmt.Sprintf("method .%s is currently not supported in post-publish transformations.", name) +} diff --git a/resources/resource.go b/resources/resource.go index d206c17b5..f6e5b9d73 100644 --- a/resources/resource.go +++ b/resources/resource.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Hugo Authors. All rights reserved. +// Copyright 2024 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,59 +14,65 @@ package resources import ( + "context" + "errors" "fmt" "io" - "io/ioutil" - "os" - "path" - "path/filepath" + "mime" + "strings" "sync" + "sync/atomic" - "github.com/gohugoio/hugo/hugofs" + "github.com/gohugoio/hugo/identity" + "github.com/gohugoio/hugo/lazy" + "github.com/gohugoio/hugo/resources/internal" + + "github.com/gohugoio/hugo/common/hashing" + "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/common/paths" "github.com/gohugoio/hugo/media" - "github.com/gohugoio/hugo/source" - - "github.com/pkg/errors" "github.com/gohugoio/hugo/common/hugio" "github.com/gohugoio/hugo/common/maps" - "github.com/gohugoio/hugo/resources/page" "github.com/gohugoio/hugo/resources/resource" - "github.com/spf13/afero" "github.com/gohugoio/hugo/helpers" ) var ( - _ resource.ContentResource = (*genericResource)(nil) - _ resource.ReadSeekCloserResource = (*genericResource)(nil) - _ resource.Resource = (*genericResource)(nil) - _ resource.Source = (*genericResource)(nil) - _ resource.Cloner = (*genericResource)(nil) - _ resource.ResourcesLanguageMerger = (*resource.Resources)(nil) - _ permalinker = (*genericResource)(nil) - _ resource.Identifier = (*genericResource)(nil) - _ fileInfo = (*genericResource)(nil) + _ resource.ContentResource = (*genericResource)(nil) + _ resource.ReadSeekCloserResource = (*genericResource)(nil) + _ resource.Resource = (*genericResource)(nil) + _ resource.Source = (*genericResource)(nil) + _ resource.Cloner = (*genericResource)(nil) + _ resource.ResourcesLanguageMerger = (*resource.Resources)(nil) + _ resource.Identifier = (*genericResource)(nil) + _ resource.TransientIdentifier = (*genericResource)(nil) + _ targetPathProvider = (*genericResource)(nil) + _ sourcePathProvider = (*genericResource)(nil) + _ identity.IdentityGroupProvider = (*genericResource)(nil) + _ identity.DependencyManagerProvider = (*genericResource)(nil) + _ identity.Identity = (*genericResource)(nil) + _ fileInfo = (*genericResource)(nil) + _ isPublishedProvider = (*genericResource)(nil) ) type ResourceSourceDescriptor struct { - // TargetPaths is a callback to fetch paths's relative to its owner. - TargetPaths func() page.TargetPaths + // The source content. + OpenReadSeekCloser hugio.OpenReadSeekCloser - // Need one of these to load the resource content. - SourceFile source.File - OpenReadSeekCloser resource.OpenReadSeekCloser + // The canonical source path. + Path *paths.Path - FileInfo os.FileInfo + // The normalized name of the resource. + NameNormalized string - // If OpenReadSeekerCloser is not set, we use this to open the file. - SourceFilename string + // The name of the resource as it was read from the source. + NameOriginal string - Fs afero.Fs - - // The relative target filename without any language code. - RelTargetFilename string + // The title of the resource. + Title string // Any base paths prepended to the target path. This will also typically be the // language code, but setting it here means that it should not have any effect on @@ -75,15 +81,114 @@ type ResourceSourceDescriptor struct { // multiple targets. TargetBasePaths []string + TargetPath string + BasePathRelPermalink string + BasePathTargetPath string + SourceFilenameOrPath string // Used for error logging. + + // The Data to associate with this resource. + Data map[string]any + + // The Params to associate with this resource. + Params maps.Params + // Delay publishing until either Permalink or RelPermalink is called. Maybe never. LazyPublish bool + + // Set when its known up front, else it's resolved from the target filename. + MediaType media.Type + + // Used to track dependencies (e.g. imports). May be nil if that's of no concern. + DependencyManager identity.Manager + + // A shared identity for this resource and all its clones. + // If this is not set, an Identity is created. + GroupIdentity identity.Identity } -func (r ResourceSourceDescriptor) Filename() string { - if r.SourceFile != nil { - return r.SourceFile.Filename() +func (fd *ResourceSourceDescriptor) init(r *Spec) error { + if len(fd.TargetBasePaths) == 0 { + // If not set, we publish the same resource to all hosts. + fd.TargetBasePaths = r.MultihostTargetBasePaths } - return r.SourceFilename + + if fd.OpenReadSeekCloser == nil { + panic(errors.New("OpenReadSeekCloser is nil")) + } + + if fd.TargetPath == "" { + panic(errors.New("RelPath is empty")) + } + + if fd.Params == nil { + fd.Params = make(maps.Params) + } + + if fd.Path == nil { + fd.Path = r.Cfg.PathParser().Parse("", fd.TargetPath) + } + + if fd.TargetPath == "" { + fd.TargetPath = fd.Path.Path() + } else { + fd.TargetPath = paths.ToSlashPreserveLeading(fd.TargetPath) + } + + fd.BasePathRelPermalink = paths.ToSlashPreserveLeading(fd.BasePathRelPermalink) + if fd.BasePathRelPermalink == "/" { + fd.BasePathRelPermalink = "" + } + fd.BasePathTargetPath = paths.ToSlashPreserveLeading(fd.BasePathTargetPath) + if fd.BasePathTargetPath == "/" { + fd.BasePathTargetPath = "" + } + + fd.TargetPath = paths.ToSlashPreserveLeading(fd.TargetPath) + + if fd.NameNormalized == "" { + fd.NameNormalized = fd.TargetPath + } + + if fd.NameOriginal == "" { + fd.NameOriginal = fd.NameNormalized + } + + if fd.Title == "" { + fd.Title = fd.NameOriginal + } + + mediaType := fd.MediaType + if mediaType.IsZero() { + ext := fd.Path.Ext() + var ( + found bool + suffixInfo media.SuffixInfo + ) + mediaType, suffixInfo, found = r.MediaTypes().GetFirstBySuffix(ext) + // TODO(bep) we need to handle these ambiguous types better, but in this context + // we most likely want the application/xml type. + if suffixInfo.Suffix == "xml" && mediaType.SubType == "rss" { + mediaType, found = r.MediaTypes().GetByType("application/xml") + } + + if !found { + // A fallback. Note that mime.TypeByExtension is slow by Hugo standards, + // so we should configure media types to avoid this lookup for most + // situations. + mimeStr := mime.TypeByExtension("." + ext) + if mimeStr != "" { + mediaType, _ = media.FromStringAndExt(mimeStr, ext) + } + } + } + + fd.MediaType = mediaType + + if fd.DependencyManager == nil { + fd.DependencyManager = r.Cfg.NewIdentityManager("resource") + } + + return nil } type ResourceTransformer interface { @@ -93,10 +198,40 @@ type ResourceTransformer interface { type Transformer interface { Transform(...ResourceTransformation) (ResourceTransformer, error) + TransformWithContext(context.Context, ...ResourceTransformation) (ResourceTransformer, error) +} + +func NewFeatureNotAvailableTransformer(key string, elements ...any) ResourceTransformation { + return transformerNotAvailable{ + key: internal.NewResourceTransformationKey(key, elements...), + } +} + +type transformerNotAvailable struct { + key internal.ResourceTransformationKey +} + +func (t transformerNotAvailable) Transform(ctx *ResourceTransformationCtx) error { + return herrors.ErrFeatureNotAvailable +} + +func (t transformerNotAvailable) Key() internal.ResourceTransformationKey { + return t.key +} + +// resourceCopier is for internal use. +type resourceCopier interface { + cloneTo(targetPath string) resource.Resource +} + +// Copy copies r to the targetPath given. +func Copy(r resource.Resource, targetPath string) resource.Resource { + return r.(resourceCopier).cloneTo(targetPath) } type baseResourceResource interface { resource.Cloner + resourceCopier resource.ContentProvider resource.Resource resource.Identifier @@ -104,24 +239,26 @@ type baseResourceResource interface { type baseResourceInternal interface { resource.Source + resource.NameNormalizedProvider fileInfo - metaAssigner + mediaTypeAssigner targetPather + isPublishedProvider ReadSeekCloser() (hugio.ReadSeekCloser, error) - // Internal + identity.IdentityGroupProvider + identity.DependencyManagerProvider + + // For internal use. cloneWithUpdates(*transformationUpdate) (baseResource, error) tryTransformedFileCache(key string, u *transformationUpdate) io.ReadCloser - specProvider - getResourcePaths() *resourcePathDescriptor - getTargetFilenames() []string - openDestinationsForWriting() (io.WriteCloser, error) - openPublishFileForWriting(relTargetPath string) (io.WriteCloser, error) + getResourcePaths() internal.ResourcePaths - relTargetPathForRel(rel string, addBaseTargetPath, isAbs, isURL bool) string + specProvider + openPublishFileForWriting(relTargetPath string) (io.WriteCloser, error) } type specProvider interface { @@ -131,18 +268,18 @@ type specProvider interface { type baseResource interface { baseResourceResource baseResourceInternal + resource.Staler } -type commonResource struct { -} +type commonResource struct{} -// Slice is not meant to be used externally. It's a bridge function +// Slice is for internal use. // for the template functions. See collections.Slice. -func (commonResource) Slice(in interface{}) (interface{}, error) { +func (commonResource) Slice(in any) (any, error) { switch items := in.(type) { case resource.Resources: return items, nil - case []interface{}: + case []any: groups := make(resource.Resources, len(items)) for i, v := range items { g, ok := v.(resource.Resource) @@ -159,86 +296,229 @@ func (commonResource) Slice(in interface{}) (interface{}, error) { } } -type dirFile struct { - // This is the directory component with Unix-style slashes. - dir string - // This is the file component. - file string -} - -func (d dirFile) path() string { - return path.Join(d.dir, d.file) -} - type fileInfo interface { - getSourceFilename() string - setSourceFilename(string) - setSourceFs(afero.Fs) - getFileInfo() hugofs.FileMetaInfo - hash() (string, error) - size() int + setOpenSource(hugio.OpenReadSeekCloser) + setSourceFilenameIsHash(bool) + setTargetPath(internal.ResourcePaths) + size() int64 + hashProvider +} + +type hashProvider interface { + hash() uint64 +} + +var _ resource.StaleInfo = (*StaleValue[any])(nil) + +type StaleValue[V any] struct { + // The value. + Value V + + // StaleVersionFunc reports the current version of the value. + // This always starts out at 0 and get incremented on staleness. + StaleVersionFunc func() uint32 +} + +func (s *StaleValue[V]) StaleVersion() uint32 { + return s.StaleVersionFunc() +} + +type AtomicStaler struct { + stale uint32 +} + +func (s *AtomicStaler) MarkStale() { + atomic.AddUint32(&s.stale, 1) +} + +func (s *AtomicStaler) StaleVersion() uint32 { + return atomic.LoadUint32(&(s.stale)) +} + +// For internal use. +type GenericResourceTestInfo struct { + Paths internal.ResourcePaths +} + +// For internal use. +func GetTestInfoForResource(r resource.Resource) GenericResourceTestInfo { + var gr *genericResource + switch v := r.(type) { + case *genericResource: + gr = v + case *resourceAdapter: + gr = v.target.(*genericResource) + default: + panic(fmt.Sprintf("unknown resource type: %T", r)) + } + return GenericResourceTestInfo{ + Paths: gr.paths, + } } // genericResource represents a generic linkable resource. type genericResource struct { - *resourcePathDescriptor - *resourceFileInfo - *resourceContent + publishInit *lazy.OnceMore - spec *Spec + key string + keyInit *sync.Once + + sd ResourceSourceDescriptor + paths internal.ResourcePaths + + includeHashInKey bool + sourceFilenameIsHash bool + + h *resourceHash // A hash of the source content. Is only calculated in caching situations. + + resource.Staler title string name string - params map[string]interface{} - data map[string]interface{} + params map[string]any - resourceType string - mediaType media.Type + spec *Spec +} + +func (l *genericResource) IdentifierBase() string { + return l.sd.Path.IdentifierBase() +} + +func (l *genericResource) GetIdentityGroup() identity.Identity { + return l.sd.GroupIdentity +} + +func (l *genericResource) GetDependencyManager() identity.Manager { + return l.sd.DependencyManager +} + +func (l *genericResource) ReadSeekCloser() (hugio.ReadSeekCloser, error) { + return l.sd.OpenReadSeekCloser() } func (l *genericResource) Clone() resource.Resource { return l.clone() } -func (l *genericResource) Content() (interface{}, error) { - if err := l.initContent(); err != nil { - return nil, err - } - - return l.content, nil +func (l *genericResource) size() int64 { + l.hash() + return l.h.size } -func (l *genericResource) Data() interface{} { - return l.data +func (l *genericResource) hash() uint64 { + if err := l.h.init(l); err != nil { + panic(err) + } + return l.h.value +} + +func (l *genericResource) setOpenSource(openSource hugio.OpenReadSeekCloser) { + l.sd.OpenReadSeekCloser = openSource +} + +func (l *genericResource) setSourceFilenameIsHash(b bool) { + l.sourceFilenameIsHash = b +} + +func (l *genericResource) setTargetPath(d internal.ResourcePaths) { + l.paths = d +} + +func (l *genericResource) cloneTo(targetPath string) resource.Resource { + c := l.clone() + c.paths = c.paths.FromTargetPath(targetPath) + return c +} + +func (l *genericResource) Content(context.Context) (any, error) { + r, err := l.ReadSeekCloser() + if err != nil { + return "", err + } + defer r.Close() + + return hugio.ReadString(r) +} + +func (l *genericResource) Data() any { + return l.sd.Data } func (l *genericResource) Key() string { - return l.RelPermalink() + l.keyInit.Do(func() { + basePath := l.spec.Cfg.BaseURL().BasePathNoTrailingSlash + if basePath == "" { + l.key = l.RelPermalink() + } else { + l.key = strings.TrimPrefix(l.RelPermalink(), basePath) + } + + if l.spec.Cfg.IsMultihost() { + l.key = l.spec.Lang() + l.key + } + + if l.includeHashInKey && !l.sourceFilenameIsHash { + l.key += fmt.Sprintf("_%d", l.hash()) + } + }) + + return l.key +} + +func (l *genericResource) TransientKey() string { + return l.Key() +} + +func (l *genericResource) targetPath() string { + return l.paths.TargetPath() +} + +func (l *genericResource) sourcePath() string { + if p := l.sd.SourceFilenameOrPath; p != "" { + return p + } + return "" } func (l *genericResource) MediaType() media.Type { - return l.mediaType + return l.sd.MediaType } func (l *genericResource) setMediaType(mediaType media.Type) { - l.mediaType = mediaType + l.sd.MediaType = mediaType } func (l *genericResource) Name() string { return l.name } -func (l *genericResource) Params() maps.Params { - return l.params +func (l *genericResource) NameNormalized() string { + return l.sd.NameNormalized } -func (l *genericResource) Permalink() string { - return l.spec.PermalinkForBaseURL(l.relPermalinkForRel(l.relTargetDirFile.path(), true), l.spec.BaseURL.HostURL()) +func (l *genericResource) Params() maps.Params { + return l.params } func (l *genericResource) Publish() error { var err error l.publishInit.Do(func() { + targetFilenames := l.getResourcePaths().TargetFilenames() + + if l.sourceFilenameIsHash { + // This is a processed image. We want to avoid copying it if it hasn't changed. + var changedFilenames []string + for _, targetFilename := range targetFilenames { + if _, err := l.getSpec().BaseFs.PublishFs.Stat(targetFilename); err == nil { + continue + } + changedFilenames = append(changedFilenames, targetFilename) + } + if len(changedFilenames) == 0 { + return + } + targetFilenames = changedFilenames + } var fr hugio.ReadSeekCloser fr, err = l.ReadSeekCloser() if err != nil { @@ -247,98 +527,53 @@ func (l *genericResource) Publish() error { defer fr.Close() var fw io.WriteCloser - fw, err = helpers.OpenFilesForWriting(l.spec.BaseFs.PublishFs, l.getTargetFilenames()...) + fw, err = helpers.OpenFilesForWriting(l.spec.BaseFs.PublishFs, targetFilenames...) if err != nil { return } defer fw.Close() _, err = io.Copy(fw, fr) - }) return err } +func (l *genericResource) isPublished() bool { + return l.publishInit.Done() +} + func (l *genericResource) RelPermalink() string { - return l.relPermalinkFor(l.relTargetDirFile.path()) + return l.spec.PathSpec.GetBasePath(false) + paths.PathEscape(l.paths.TargetLink()) +} + +func (l *genericResource) Permalink() string { + return l.spec.Cfg.BaseURL().WithPathNoTrailingSlash + paths.PathEscape(l.paths.TargetPath()) } func (l *genericResource) ResourceType() string { - return l.resourceType + return l.MediaType().MainType } func (l *genericResource) String() string { - return fmt.Sprintf("Resource(%s: %s)", l.resourceType, l.name) + return fmt.Sprintf("Resource(%s: %s)", l.ResourceType(), l.name) } // Path is stored with Unix style slashes. func (l *genericResource) TargetPath() string { - return l.relTargetDirFile.path() + return l.paths.TargetPath() } func (l *genericResource) Title() string { return l.title } -func (l *genericResource) createBasePath(rel string, isURL bool) string { - if l.targetPathBuilder == nil { - return rel - } - tp := l.targetPathBuilder() - - if isURL { - return path.Join(tp.SubResourceBaseLink, rel) - } - - // TODO(bep) path - return path.Join(filepath.ToSlash(tp.SubResourceBaseTarget), rel) -} - -func (l *genericResource) initContent() error { - var err error - l.contentInit.Do(func() { - var r hugio.ReadSeekCloser - r, err = l.ReadSeekCloser() - if err != nil { - return - } - defer r.Close() - - var b []byte - b, err = ioutil.ReadAll(r) - if err != nil { - return - } - - l.content = string(b) - }) - - return err -} - -func (l *genericResource) setName(name string) { - l.name = name -} - -func (l *genericResource) getResourcePaths() *resourcePathDescriptor { - return l.resourcePathDescriptor -} - func (l *genericResource) getSpec() *Spec { return l.spec } -func (l *genericResource) getTargetFilenames() []string { - paths := l.relTargetPaths() - for i, p := range paths { - paths[i] = filepath.Clean(p) - } - return paths -} - -func (l *genericResource) setTitle(title string) { - l.title = title +func (l *genericResource) getResourcePaths() internal.ResourcePaths { + return l.paths } func (r *genericResource) tryTransformedFileCache(key string, u *transformationUpdate) io.ReadCloser { @@ -347,23 +582,23 @@ func (r *genericResource) tryTransformedFileCache(key string, u *transformationU return nil } u.sourceFilename = &fi.Name - mt, _ := r.spec.MediaTypes.GetByType(meta.MediaTypeV) + mt, _ := r.spec.MediaTypes().GetByType(meta.MediaTypeV) u.mediaType = mt u.data = meta.MetaData u.targetPath = meta.Target return f } -func (r *genericResource) mergeData(in map[string]interface{}) { +func (r *genericResource) mergeData(in map[string]any) { if len(in) == 0 { return } - if r.data == nil { - r.data = make(map[string]interface{}) + if r.sd.Data == nil { + r.sd.Data = make(map[string]any) } for k, v := range in { - if _, found := r.data[k]; !found { - r.data[k] = v + if _, found := r.sd.Data[k]; !found { + r.sd.Data[k] = v } } } @@ -372,281 +607,127 @@ func (rc *genericResource) cloneWithUpdates(u *transformationUpdate) (baseResour r := rc.clone() if u.content != nil { - r.contentInit.Do(func() { - r.content = *u.content - r.openReadSeekerCloser = func() (hugio.ReadSeekCloser, error) { - return hugio.NewReadSeekerNoOpCloserFromString(r.content), nil - } - }) + r.sd.OpenReadSeekCloser = func() (hugio.ReadSeekCloser, error) { + return hugio.NewReadSeekerNoOpCloserFromString(*u.content), nil + } } - r.mediaType = u.mediaType + r.sd.MediaType = u.mediaType if u.sourceFilename != nil { - r.setSourceFilename(*u.sourceFilename) - } - - if u.sourceFs != nil { - r.setSourceFs(u.sourceFs) + if u.sourceFs == nil { + return nil, errors.New("sourceFs is nil") + } + r.setOpenSource(func() (hugio.ReadSeekCloser, error) { + return u.sourceFs.Open(*u.sourceFilename) + }) + } else if u.sourceFs != nil { + return nil, errors.New("sourceFs is set without sourceFilename") } if u.targetPath == "" { return nil, errors.New("missing targetPath") } - fpath, fname := path.Split(u.targetPath) - r.resourcePathDescriptor.relTargetDirFile = dirFile{dir: fpath, file: fname} - + r.setTargetPath(r.paths.FromTargetPath(u.targetPath)) r.mergeData(u.data) return r, nil } func (l genericResource) clone() *genericResource { - gi := *l.resourceFileInfo - rp := *l.resourcePathDescriptor - l.resourceFileInfo = &gi - l.resourcePathDescriptor = &rp - l.resourceContent = &resourceContent{} + l.publishInit = &lazy.OnceMore{} + l.keyInit = &sync.Once{} return &l } -// returns an opened file or nil if nothing to write (it may already be published). -func (l *genericResource) openDestinationsForWriting() (w io.WriteCloser, err error) { - - l.publishInit.Do(func() { - targetFilenames := l.getTargetFilenames() - var changedFilenames []string - - // Fast path: - // This is a processed version of the original; - // check if it already existis at the destination. - for _, targetFilename := range targetFilenames { - if _, err := l.getSpec().BaseFs.PublishFs.Stat(targetFilename); err == nil { - continue - } - - changedFilenames = append(changedFilenames, targetFilename) - } - - if len(changedFilenames) == 0 { - return - } - - w, err = helpers.OpenFilesForWriting(l.getSpec().BaseFs.PublishFs, changedFilenames...) - - }) - - return - -} - func (r *genericResource) openPublishFileForWriting(relTargetPath string) (io.WriteCloser, error) { - return helpers.OpenFilesForWriting(r.spec.BaseFs.PublishFs, r.relTargetPathsFor(relTargetPath)...) -} - -func (l *genericResource) permalinkFor(target string) string { - return l.spec.PermalinkForBaseURL(l.relPermalinkForRel(target, true), l.spec.BaseURL.HostURL()) -} - -func (l *genericResource) relPermalinkFor(target string) string { - return l.relPermalinkForRel(target, false) -} - -func (l *genericResource) relPermalinkForRel(rel string, isAbs bool) string { - return l.spec.PathSpec.URLizeFilename(l.relTargetPathForRel(rel, false, isAbs, true)) -} - -func (l *genericResource) relTargetPathForRel(rel string, addBaseTargetPath, isAbs, isURL bool) string { - if addBaseTargetPath && len(l.baseTargetPathDirs) > 1 { - panic("multiple baseTargetPathDirs") - } - var basePath string - if addBaseTargetPath && len(l.baseTargetPathDirs) > 0 { - basePath = l.baseTargetPathDirs[0] - } - - return l.relTargetPathForRelAndBasePath(rel, basePath, isAbs, isURL) -} - -func (l *genericResource) relTargetPathForRelAndBasePath(rel, basePath string, isAbs, isURL bool) string { - rel = l.createBasePath(rel, isURL) - - if basePath != "" { - rel = path.Join(basePath, rel) - } - - if l.baseOffset != "" { - rel = path.Join(l.baseOffset, rel) - } - - if isURL { - bp := l.spec.PathSpec.GetBasePath(!isAbs) - if bp != "" { - rel = path.Join(bp, rel) - } - } - - if len(rel) == 0 || rel[0] != '/' { - rel = "/" + rel - } - - return rel -} - -func (l *genericResource) relTargetPaths() []string { - return l.relTargetPathsForRel(l.TargetPath()) -} - -func (l *genericResource) relTargetPathsFor(target string) []string { - return l.relTargetPathsForRel(target) -} - -func (l *genericResource) relTargetPathsForRel(rel string) []string { - if len(l.baseTargetPathDirs) == 0 { - return []string{l.relTargetPathForRelAndBasePath(rel, "", false, false)} - } - - targetPaths := make([]string, len(l.baseTargetPathDirs)) - for i, dir := range l.baseTargetPathDirs { - targetPaths[i] = l.relTargetPathForRelAndBasePath(rel, dir, false, false) - } - return targetPaths -} - -func (l *genericResource) updateParams(params map[string]interface{}) { - if l.params == nil { - l.params = params - return - } - - // Sets the params not already set - for k, v := range params { - if _, found := l.params[k]; !found { - l.params[k] = v - } - } + filenames := r.paths.FromTargetPath(relTargetPath).TargetFilenames() + return helpers.OpenFilesForWriting(r.spec.BaseFs.PublishFs, filenames...) } type targetPather interface { TargetPath() string } -type permalinker interface { - targetPather - permalinkFor(target string) string - relPermalinkFor(target string) string - relTargetPaths() []string - relTargetPathsFor(target string) []string -} - -type resourceContent struct { - content string - contentInit sync.Once - - publishInit sync.Once -} - -type resourceFileInfo struct { - // Will be set if this resource is backed by something other than a file. - openReadSeekerCloser resource.OpenReadSeekCloser - - // This may be set to tell us to look in another filesystem for this resource. - // We, by default, use the sourceFs filesystem in the spec below. - sourceFs afero.Fs - - // Absolute filename to the source, including any content folder path. - // Note that this is absolute in relation to the filesystem it is stored in. - // It can be a base path filesystem, and then this filename will not match - // the path to the file on the real filesystem. - sourceFilename string - - fi hugofs.FileMetaInfo - - // A hash of the source content. Is only calculated in caching situations. - h *resourceHash -} - -func (fi *resourceFileInfo) ReadSeekCloser() (hugio.ReadSeekCloser, error) { - if fi.openReadSeekerCloser != nil { - return fi.openReadSeekerCloser() - } - - f, err := fi.getSourceFs().Open(fi.getSourceFilename()) - if err != nil { - return nil, err - } - return f, nil -} - -func (fi *resourceFileInfo) getFileInfo() hugofs.FileMetaInfo { - return fi.fi -} - -func (fi *resourceFileInfo) getSourceFilename() string { - return fi.sourceFilename -} - -func (fi *resourceFileInfo) setSourceFilename(s string) { - // Make sure it's always loaded by sourceFilename. - fi.openReadSeekerCloser = nil - fi.sourceFilename = s -} - -func (fi *resourceFileInfo) getSourceFs() afero.Fs { - return fi.sourceFs -} - -func (fi *resourceFileInfo) setSourceFs(fs afero.Fs) { - fi.sourceFs = fs -} - -func (fi *resourceFileInfo) hash() (string, error) { - var err error - fi.h.init.Do(func() { - var hash string - var f hugio.ReadSeekCloser - f, err = fi.ReadSeekCloser() - if err != nil { - err = errors.Wrap(err, "failed to open source file") - return - } - defer f.Close() - - hash, err = helpers.MD5FromFileFast(f) - if err != nil { - return - } - fi.h.value = hash - }) - - return fi.h.value, err -} - -func (fi *resourceFileInfo) size() int { - if fi.fi == nil { - return 0 - } - - return int(fi.fi.Size()) +type isPublishedProvider interface { + isPublished() bool } type resourceHash struct { - value string - init sync.Once + value uint64 + size int64 + initOnce sync.Once } -type resourcePathDescriptor struct { - // The relative target directory and filename. - relTargetDirFile dirFile +func (r *resourceHash) init(l hugio.ReadSeekCloserProvider) error { + var initErr error + r.initOnce.Do(func() { + var hash uint64 + var size int64 + f, err := l.ReadSeekCloser() + if err != nil { + initErr = fmt.Errorf("failed to open source: %w", err) + return + } + defer f.Close() + hash, size, err = hashImage(f) + if err != nil { + initErr = fmt.Errorf("failed to calculate hash: %w", err) + return + } + r.value = hash + r.size = size + }) - // Callback used to construct a target path relative to its owner. - targetPathBuilder func() page.TargetPaths - - // This will normally be the same as above, but this will only apply to publishing - // of resources. It may be mulltiple values when in multihost mode. - baseTargetPathDirs []string - - // baseOffset is set when the output format's path has a offset, e.g. for AMP. - baseOffset string + return initErr +} + +func hashImage(r io.ReadSeeker) (uint64, int64, error) { + return hashing.XXHashFromReader(r) +} + +// InternalResourceTargetPath is used internally to get the target path for a Resource. +func InternalResourceTargetPath(r resource.Resource) string { + return r.(targetPathProvider).targetPath() +} + +// InternalResourceSourcePathBestEffort is used internally to get the source path for a Resource. +// It returns an empty string if the source path is not available. +func InternalResourceSourcePath(r resource.Resource) string { + if sp, ok := r.(sourcePathProvider); ok { + if p := sp.sourcePath(); p != "" { + return p + } + } + return "" +} + +// InternalResourceSourcePathBestEffort is used internally to get the source path for a Resource. +// Used for error messages etc. +// It will fall back to the target path if the source path is not available. +func InternalResourceSourcePathBestEffort(r resource.Resource) string { + if s := InternalResourceSourcePath(r); s != "" { + return s + } + return InternalResourceTargetPath(r) +} + +// isPublished returns true if the resource is published. +func IsPublished(r resource.Resource) bool { + return r.(isPublishedProvider).isPublished() +} + +type targetPathProvider interface { + // targetPath is the relative path to this resource. + // In most cases this will be the same as the RelPermalink(), + // but it will not trigger any lazy publishing. + targetPath() string +} + +// Optional interface implemented by resources that can provide the source path. +type sourcePathProvider interface { + // sourcePath is the source path to this resource's source. + // This is used in error messages etc. + sourcePath() string } diff --git a/resources/resource/dates.go b/resources/resource/dates.go index f26c44787..d84e26d57 100644 --- a/resources/resource/dates.go +++ b/resources/resource/dates.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Hugo Authors. All rights reserved. +// Copyright 2024 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. @@ -13,42 +13,35 @@ package resource -import "time" +import ( + "time" -var _ Dated = Dates{} + "github.com/gohugoio/hugo/common/htime" +) // Dated wraps a "dated resource". These are the 4 dates that makes // the date logic in Hugo. type Dated interface { + // Date returns the date of the resource. Date() time.Time + + // Lastmod returns the last modification date of the resource. Lastmod() time.Time + + // PublishDate returns the publish date of the resource. PublishDate() time.Time + + // ExpiryDate returns the expiration date of the resource. ExpiryDate() time.Time } -// Dates holds the 4 Hugo dates. -type Dates struct { - FDate time.Time - FLastmod time.Time - FPublishDate time.Time - FExpiryDate time.Time -} - -func (d *Dates) UpdateDateAndLastmodIfAfter(in Dated) { - if in.Date().After(d.Date()) { - d.FDate = in.Date() - } - if in.Lastmod().After(d.Lastmod()) { - d.FLastmod = in.Lastmod() - } -} - // IsFuture returns whether the argument represents the future. func IsFuture(d Dated) bool { if d.PublishDate().IsZero() { return false } - return d.PublishDate().After(time.Now()) + + return d.PublishDate().After(htime.Now()) } // IsExpired returns whether the argument is expired. @@ -56,26 +49,10 @@ func IsExpired(d Dated) bool { if d.ExpiryDate().IsZero() { return false } - return d.ExpiryDate().Before(time.Now()) + return d.ExpiryDate().Before(htime.Now()) } // IsZeroDates returns true if all of the dates are zero. func IsZeroDates(d Dated) bool { return d.Date().IsZero() && d.Lastmod().IsZero() && d.ExpiryDate().IsZero() && d.PublishDate().IsZero() } - -func (p Dates) Date() time.Time { - return p.FDate -} - -func (p Dates) Lastmod() time.Time { - return p.FLastmod -} - -func (p Dates) PublishDate() time.Time { - return p.FPublishDate -} - -func (p Dates) ExpiryDate() time.Time { - return p.FExpiryDate -} diff --git a/resources/resource/params.go b/resources/resource/params.go index 89da718ec..d88424e9d 100644 --- a/resources/resource/params.go +++ b/resources/resource/params.go @@ -19,7 +19,7 @@ import ( "github.com/spf13/cast" ) -func Param(r ResourceParamsProvider, fallback maps.Params, key interface{}) (interface{}, error) { +func Param(r ResourceParamsProvider, fallback maps.Params, key any) (any, error) { keyStr, err := cast.ToStringE(key) if err != nil { return nil, err @@ -30,5 +30,4 @@ func Param(r ResourceParamsProvider, fallback maps.Params, key interface{}) (int } return maps.GetNestedParam(keyStr, ".", r.Params(), fallback) - } diff --git a/resources/resource/resource_helpers.go b/resources/resource/resource_helpers.go index b0830a83c..8575ae79e 100644 --- a/resources/resource/resource_helpers.go +++ b/resources/resource/resource_helpers.go @@ -17,27 +17,30 @@ import ( "strings" "time" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/helpers" + "github.com/pelletier/go-toml/v2" "github.com/spf13/cast" ) // GetParam will return the param with the given key from the Resource, // nil if not found. -func GetParam(r Resource, key string) interface{} { +func GetParam(r Resource, key string) any { return getParam(r, key, false) } // GetParamToLower is the same as GetParam but it will lower case any string // result, including string slices. -func GetParamToLower(r Resource, key string) interface{} { +func GetParamToLower(r Resource, key string) any { return getParam(r, key, true) } -func getParam(r Resource, key string, stringToLower bool) interface{} { - v := r.Params()[strings.ToLower(key)] +func getParam(r Resource, key string, stringToLower bool) any { + v, err := maps.GetNestedParam(key, ".", r.Params()) - if v == nil { + if v == nil || err != nil { return nil } @@ -55,16 +58,19 @@ func getParam(r Resource, key string, stringToLower bool) interface{} { return cast.ToFloat64(v) case time.Time: return val + case toml.LocalDate: + return val.AsTime(time.UTC) + case toml.LocalDateTime: + return val.AsTime(time.UTC) case []string: if stringToLower { return helpers.SliceToLower(val) } return v - case map[string]interface{}: // JSON and TOML + case map[string]any: return v - case map[interface{}]interface{}: // YAML + case map[any]any: return v } - return nil } diff --git a/resources/resource/resources.go b/resources/resource/resources.go index ac5dd0b2b..6b7311bad 100644 --- a/resources/resource/resources.go +++ b/resources/resource/resources.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Hugo Authors. All rights reserved. +// Copyright 2024 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. @@ -11,50 +11,170 @@ // See the License for the specific language governing permissions and // limitations under the License. +// Package resource contains Resource related types. package resource import ( "fmt" + "path" "strings" + "github.com/gohugoio/hugo/common/hreflect" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/common/paths" "github.com/gohugoio/hugo/hugofs/glob" + "github.com/spf13/cast" + "slices" ) +var _ ResourceFinder = (*Resources)(nil) + // Resources represents a slice of resources, which can be a mix of different types. // I.e. both pages and images etc. type Resources []Resource +// Mount mounts the given resources from base to the given target path. +// Note that leading slashes in target marks an absolute path. +// This method is currently only useful in js.Batch. +func (r Resources) Mount(base, target string) ResourceGetter { + return resourceGetterFunc(func(namev any) Resource { + name1, err := cast.ToStringE(namev) + if err != nil { + panic(err) + } + + isTargetAbs := strings.HasPrefix(target, "/") + + if target != "" { + name1 = strings.TrimPrefix(name1, target) + if !isTargetAbs { + name1 = paths.TrimLeading(name1) + } + } + + if base != "" && isTargetAbs { + name1 = path.Join(base, name1) + } + + for _, res := range r { + name2 := res.Name() + + if base != "" && !isTargetAbs { + name2 = paths.TrimLeading(strings.TrimPrefix(name2, base)) + } + + if strings.EqualFold(name1, name2) { + return res + } + + } + + return nil + }) +} + +type ResourcesProvider interface { + // Resources returns a list of all resources. + Resources() Resources +} + +// var _ resource.ResourceFinder = (*Namespace)(nil) // ResourcesConverter converts a given slice of Resource objects to Resources. type ResourcesConverter interface { + // For internal use. ToResources() Resources } -// ByType returns resources of a given resource type (ie. "image"). -func (r Resources) ByType(tp string) Resources { +// ByType returns resources of a given resource type (e.g. "image"). +func (r Resources) ByType(typ any) Resources { + tpstr, err := cast.ToStringE(typ) + if err != nil { + panic(err) + } var filtered Resources for _, resource := range r { - if resource.ResourceType() == tp { + if resource.ResourceType() == tpstr { filtered = append(filtered, resource) } } return filtered } +// Get locates the name given in Resources. +// The search is case insensitive. +func (r Resources) Get(name any) Resource { + if r == nil { + return nil + } + namestr, err := cast.ToStringE(name) + if err != nil { + panic(err) + } + + isDotCurrent := strings.HasPrefix(namestr, "./") + if isDotCurrent { + namestr = strings.TrimPrefix(namestr, "./") + } else { + namestr = paths.AddLeadingSlash(namestr) + } + + check := func(name string) bool { + if !isDotCurrent { + name = paths.AddLeadingSlash(name) + } + return strings.EqualFold(namestr, name) + } + + // First check the Name. + // Note that this can be modified by the user in the front matter, + // also, it does not contain any language code. + for _, resource := range r { + if check(resource.Name()) { + return resource + } + } + + // Finally, check the normalized name. + for _, resource := range r { + if nop, ok := resource.(NameNormalizedProvider); ok { + if check(nop.NameNormalized()) { + return resource + } + } + } + + return nil +} + // GetMatch finds the first Resource matching the given pattern, or nil if none found. // See Match for a more complete explanation about the rules used. -func (r Resources) GetMatch(pattern string) Resource { - g, err := glob.GetGlob(pattern) +func (r Resources) GetMatch(pattern any) Resource { + patternstr, err := cast.ToStringE(pattern) if err != nil { - return nil + panic(err) + } + + g, err := glob.GetGlob(paths.AddLeadingSlash(patternstr)) + if err != nil { + panic(err) } for _, resource := range r { - if g.Match(strings.ToLower(resource.Name())) { + if g.Match(paths.AddLeadingSlash(resource.Name())) { return resource } } + // Finally, check the normalized name. + for _, resource := range r { + if nop, ok := resource.(NameNormalizedProvider); ok { + if g.Match(paths.AddLeadingSlash(nop.NameNormalized())) { + return resource + } + } + } + return nil } @@ -67,18 +187,33 @@ func (r Resources) GetMatch(pattern string) Resource { // Match matches by using the value of Resource.Name, which, by default, is a filename with // path relative to the bundle root with Unix style slashes (/) and no leading slash, e.g. "images/logo.png". // See https://github.com/gobwas/glob for the full rules set. -func (r Resources) Match(pattern string) Resources { - g, err := glob.GetGlob(pattern) +func (r Resources) Match(pattern any) Resources { + patternstr, err := cast.ToStringE(pattern) if err != nil { - return nil + panic(err) + } + + g, err := glob.GetGlob(paths.AddLeadingSlash(patternstr)) + if err != nil { + panic(err) } var matches Resources for _, resource := range r { - if g.Match(strings.ToLower(resource.Name())) { + if g.Match(paths.AddLeadingSlash(resource.Name())) { matches = append(matches, resource) } } + if len(matches) == 0 { + // Fall back to the normalized name. + for _, resource := range r { + if nop, ok := resource.(NameNormalizedProvider); ok { + if g.Match(paths.AddLeadingSlash(nop.NameNormalized())) { + matches = append(matches, resource) + } + } + } + } return matches } @@ -88,7 +223,7 @@ type translatedResource interface { // MergeByLanguage adds missing translations in r1 from r2. func (r Resources) MergeByLanguage(r2 Resources) Resources { - result := append(Resources(nil), r...) + result := slices.Clone(r) m := make(map[string]bool) for _, rr := range r { if translated, ok := rr.(translatedResource); ok { @@ -108,7 +243,8 @@ func (r Resources) MergeByLanguage(r2 Resources) Resources { // MergeByLanguageInterface is the generic version of MergeByLanguage. It // is here just so it can be called from the tpl package. -func (r Resources) MergeByLanguageInterface(in interface{}) (interface{}, error) { +// This is for internal use. +func (r Resources) MergeByLanguageInterface(in any) (any, error) { r2, ok := in.(Resources) if !ok { return nil, fmt.Errorf("%T cannot be merged by language", in) @@ -121,3 +257,152 @@ func (r Resources) MergeByLanguageInterface(in interface{}) (interface{}, error) type Source interface { Publish() error } + +type ResourceGetter interface { + // Get locates the Resource with the given name in the current context (e.g. in .Page.Resources). + // + // It returns nil if no Resource could found, panics if name is invalid. + Get(name any) Resource +} + +type IsProbablySameResourceGetter interface { + IsProbablySameResourceGetter(other ResourceGetter) bool +} + +// StaleInfoResourceGetter is a ResourceGetter that also provides information about +// whether the underlying resources are stale. +type StaleInfoResourceGetter interface { + StaleInfo + ResourceGetter +} + +type resourceGetterFunc func(name any) Resource + +func (f resourceGetterFunc) Get(name any) Resource { + return f(name) +} + +// ResourceFinder provides methods to find Resources. +// Note that GetRemote (as found in resources.GetRemote) is +// not covered by this interface, as this is only available as a global template function. +type ResourceFinder interface { + ResourceGetter + + // GetMatch finds the first Resource matching the given pattern, or nil if none found. + // + // See Match for a more complete explanation about the rules used. + // + // It returns nil if no Resource could found, panics if pattern is invalid. + GetMatch(pattern any) Resource + + // Match gets all resources matching the given base path prefix, e.g + // "*.png" will match all png files. The "*" does not match path delimiters (/), + // so if you organize your resources in sub-folders, you need to be explicit about it, e.g.: + // "images/*.png". To match any PNG image anywhere in the bundle you can do "**.png", and + // to match all PNG images below the images folder, use "images/**.jpg". + // + // The matching is case insensitive. + // + // Match matches by using a relative pathwith Unix style slashes (/) and no + // leading slash, e.g. "images/logo.png". + // + // See https://github.com/gobwas/glob for the full rules set. + // + // See Match for a more complete explanation about the rules used. + // + // It returns nil if no Resources could found, panics if pattern is invalid. + Match(pattern any) Resources + + // ByType returns resources of a given resource type (e.g. "image"). + // It returns nil if no Resources could found, panics if typ is invalid. + ByType(typ any) Resources +} + +// NewCachedResourceGetter creates a new ResourceGetter from the given objects. +// If multiple objects are provided, they are merged into one where +// the first match wins. +func NewCachedResourceGetter(os ...any) *cachedResourceGetter { + var getters multiResourceGetter + for _, o := range os { + if g, ok := unwrapResourceGetter(o); ok { + getters = append(getters, g) + } + } + + return &cachedResourceGetter{ + cache: maps.NewCache[string, Resource](), + delegate: getters, + } +} + +type multiResourceGetter []ResourceGetter + +func (m multiResourceGetter) Get(name any) Resource { + for _, g := range m { + if res := g.Get(name); res != nil { + return res + } + } + return nil +} + +var ( + _ ResourceGetter = (*cachedResourceGetter)(nil) + _ IsProbablySameResourceGetter = (*cachedResourceGetter)(nil) +) + +type cachedResourceGetter struct { + cache *maps.Cache[string, Resource] + delegate ResourceGetter +} + +func (c *cachedResourceGetter) Get(name any) Resource { + namestr, err := cast.ToStringE(name) + if err != nil { + panic(err) + } + v, _ := c.cache.GetOrCreate(namestr, func() (Resource, error) { + v := c.delegate.Get(name) + return v, nil + }) + return v +} + +func (c *cachedResourceGetter) IsProbablySameResourceGetter(other ResourceGetter) bool { + isProbablyEq := true + c.cache.ForEeach(func(k string, v Resource) bool { + if v != other.Get(k) { + isProbablyEq = false + return false + } + return true + }) + + return isProbablyEq +} + +func unwrapResourceGetter(v any) (ResourceGetter, bool) { + if v == nil { + return nil, false + } + switch vv := v.(type) { + case ResourceGetter: + return vv, true + case ResourcesProvider: + return vv.Resources(), true + case func(name any) Resource: + return resourceGetterFunc(vv), true + default: + vvv, ok := hreflect.ToSliceAny(v) + if !ok { + return nil, false + } + var getters multiResourceGetter + for _, vv := range vvv { + if g, ok := unwrapResourceGetter(vv); ok { + getters = append(getters, g) + } + } + return getters, len(getters) > 0 + } +} diff --git a/resources/resource/resources_integration_test.go b/resources/resource/resources_integration_test.go new file mode 100644 index 000000000..920fc7397 --- /dev/null +++ b/resources/resource/resources_integration_test.go @@ -0,0 +1,105 @@ +// Copyright 2024 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 resource_test + +import ( + "testing" + + "github.com/gohugoio/hugo/hugolib" +) + +func TestResourcesMount(t *testing.T) { + files := ` +-- hugo.toml -- +-- assets/text/txt1.txt -- +Text 1. +-- assets/text/txt2.txt -- +Text 2. +-- assets/text/sub/txt3.txt -- +Text 3. +-- assets/text/sub/txt4.txt -- +Text 4. +-- content/mybundle/index.md -- +--- +title: "My Bundle" +--- +-- content/mybundle/txt1.txt -- +Text 1. +-- content/mybundle/sub/txt2.txt -- +Text 1. +-- layouts/index.html -- +{{ $mybundle := site.GetPage "mybundle" }} +{{ $subResources := resources.Match "/text/sub/*.*" }} +{{ $subResourcesMount := $subResources.Mount "/text/sub" "/newroot" }} +resources:text/txt1.txt:{{ with resources.Get "text/txt1.txt" }}{{ .Name }}{{ end }}| +resources:text/txt2.txt:{{ with resources.Get "text/txt2.txt" }}{{ .Name }}{{ end }}| +resources:text/sub/txt3.txt:{{ with resources.Get "text/sub/txt3.txt" }}{{ .Name }}{{ end }}| +subResources.range:{{ range $subResources }}{{ .Name }}|{{ end }}| +subResources:"text/sub/txt3.txt:{{ with $subResources.Get "text/sub/txt3.txt" }}{{ .Name }}{{ end }}| +subResourcesMount:/newroot/txt3.txt:{{ with $subResourcesMount.Get "/newroot/txt3.txt" }}{{ .Name }}{{ end }}| +page:txt1.txt:{{ with $mybundle.Resources.Get "txt1.txt" }}{{ .Name }}{{ end }}| +page:./txt1.txt:{{ with $mybundle.Resources.Get "./txt1.txt" }}{{ .Name }}{{ end }}| +page:sub/txt2.txt:{{ with $mybundle.Resources.Get "sub/txt2.txt" }}{{ .Name }}{{ end }}| +` + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", ` +resources:text/txt1.txt:/text/txt1.txt| +resources:text/txt2.txt:/text/txt2.txt| +resources:text/sub/txt3.txt:/text/sub/txt3.txt| +subResources:"text/sub/txt3.txt:/text/sub/txt3.txt| +subResourcesMount:/newroot/txt3.txt:/text/sub/txt3.txt| +page:txt1.txt:txt1.txt| +page:./txt1.txt:txt1.txt| +page:sub/txt2.txt:sub/txt2.txt| +`) +} + +func TestResourcesMountOnRename(t *testing.T) { + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "home", "sitemap"] +-- content/mybundle/index.md -- +--- +title: "My Bundle" +resources: +- name: /foo/bars.txt + src: foo/txt1.txt +- name: foo/bars2.txt + src: foo/txt2.txt +--- +-- content/mybundle/foo/txt1.txt -- +Text 1. +-- content/mybundle/foo/txt2.txt -- +Text 2. +-- layouts/_default/single.html -- +Single. +{{ $mybundle := site.GetPage "mybundle" }} +Resources:{{ range $mybundle.Resources }}Name: {{ .Name }}|{{ end }}$ +{{ $subResourcesMount := $mybundle.Resources.Mount "/foo" "/newroot" }} + {{ $subResourcesMount2 := $mybundle.Resources.Mount "foo" "/newroot" }} +{{ $subResourcesMount3 := $mybundle.Resources.Mount "foo" "." }} +subResourcesMount:/newroot/bars.txt:{{ with $subResourcesMount.Get "/newroot/bars.txt" }}{{ .Name }}{{ end }}| +subResourcesMount:/newroot/bars2.txt:{{ with $subResourcesMount.Get "/newroot/bars2.txt" }}{{ .Name }}{{ end }}| +subResourcesMount2:/newroot/bars2.txt:{{ with $subResourcesMount2.Get "/newroot/bars2.txt" }}{{ .Name }}{{ end }}| +subResourcesMount3:bars2.txt:{{ with $subResourcesMount3.Get "bars2.txt" }}{{ .Name }}{{ end }}| +` + b := hugolib.Test(t, files) + b.AssertFileContent("public/mybundle/index.html", + "Resources:Name: foo/bars.txt|Name: foo/bars2.txt|$", + "subResourcesMount:/newroot/bars.txt:|\nsubResourcesMount:/newroot/bars2.txt:|", + "subResourcesMount2:/newroot/bars2.txt:foo/bars2.txt|", + "subResourcesMount3:bars2.txt:foo/bars2.txt|", + ) +} diff --git a/resources/resource/resources_test.go b/resources/resource/resources_test.go new file mode 100644 index 000000000..ebadbb312 --- /dev/null +++ b/resources/resource/resources_test.go @@ -0,0 +1,122 @@ +// Copyright 2024 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 resource + +import ( + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestResourcesMount(t *testing.T) { + c := qt.New(t) + c.Assert(true, qt.IsTrue) + + var m ResourceGetter + var r Resources + + check := func(in, expect string) { + c.Helper() + r := m.Get(in) + c.Assert(r, qt.Not(qt.IsNil)) + c.Assert(r.Name(), qt.Equals, expect) + } + + checkNil := func(in string) { + c.Helper() + r := m.Get(in) + c.Assert(r, qt.IsNil) + } + + // Misc tests. + r = Resources{ + testResource{name: "/foo/theme.css"}, + } + + m = r.Mount("/foo", ".") + check("./theme.css", "/foo/theme.css") + + // Relative target. + r = Resources{ + testResource{name: "/a/b/c/d.txt"}, + testResource{name: "/a/b/c/e/f.txt"}, + testResource{name: "/a/b/d.txt"}, + testResource{name: "/a/b/e.txt"}, + } + + m = r.Mount("/a/b/c", "z") + check("z/d.txt", "/a/b/c/d.txt") + check("z/e/f.txt", "/a/b/c/e/f.txt") + + m = r.Mount("/a/b", "") + check("d.txt", "/a/b/d.txt") + m = r.Mount("/a/b", ".") + check("d.txt", "/a/b/d.txt") + m = r.Mount("/a/b", "./") + check("d.txt", "/a/b/d.txt") + check("./d.txt", "/a/b/d.txt") + + m = r.Mount("/a/b", ".") + check("./d.txt", "/a/b/d.txt") + + // Absolute target. + m = r.Mount("/a/b/c", "/z") + check("/z/d.txt", "/a/b/c/d.txt") + check("/z/e/f.txt", "/a/b/c/e/f.txt") + checkNil("/z/f.txt") + + m = r.Mount("/a/b", "/z") + check("/z/c/d.txt", "/a/b/c/d.txt") + check("/z/c/e/f.txt", "/a/b/c/e/f.txt") + check("/z/d.txt", "/a/b/d.txt") + checkNil("/z/f.txt") + + m = r.Mount("", "") + check("/a/b/c/d.txt", "/a/b/c/d.txt") + check("/a/b/c/e/f.txt", "/a/b/c/e/f.txt") + check("/a/b/d.txt", "/a/b/d.txt") + checkNil("/a/b/f.txt") + + m = r.Mount("/a/b", "/a/b") + check("/a/b/c/d.txt", "/a/b/c/d.txt") + check("/a/b/c/e/f.txt", "/a/b/c/e/f.txt") + check("/a/b/d.txt", "/a/b/d.txt") + checkNil("/a/b/f.txt") + + // Resources with relative paths. + r = Resources{ + testResource{name: "a/b/c/d.txt"}, + testResource{name: "a/b/c/e/f.txt"}, + testResource{name: "a/b/d.txt"}, + testResource{name: "a/b/e.txt"}, + testResource{name: "n.txt"}, + } + + m = r.Mount("a/b", "z") + check("z/d.txt", "a/b/d.txt") + checkNil("/z/d.txt") +} + +type testResource struct { + Resource + name string +} + +func (r testResource) Name() string { + return r.name +} + +func (r testResource) NameNormalized() string { + return r.name +} diff --git a/resources/resource/resourcetypes.go b/resources/resource/resourcetypes.go index b525d7d55..51255c612 100644 --- a/resources/resource/resourcetypes.go +++ b/resources/resource/resourcetypes.go @@ -14,55 +14,90 @@ package resource import ( + "context" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/common/types" "github.com/gohugoio/hugo/langs" "github.com/gohugoio/hugo/media" - "github.com/gohugoio/hugo/resources/images/exif" "github.com/gohugoio/hugo/common/hugio" ) -// Cloner is an internal template and not meant for use in the templates. It -// may change without notice. +var ( + _ ResourceDataProvider = (*resourceError)(nil) + _ ResourceError = (*resourceError)(nil) +) + +// Cloner is for internal use. type Cloner interface { Clone() Resource } -// Resource represents a linkable resource, i.e. a content page, image etc. -type Resource interface { - ResourceTypesProvider - ResourceLinksProvider - ResourceMetaProvider - ResourceParamsProvider +// OriginProvider provides the original Resource if this is wrapped. +// This is an internal Hugo interface and not meant for use in the templates. +type OriginProvider interface { + Origin() Resource + GetFieldString(pattern string) (string, bool) +} + +// NewResourceError creates a new ResourceError. +func NewResourceError(err error, data any) ResourceError { + if data == nil { + data = map[string]any{} + } + return &resourceError{ + error: err, + data: data, + } +} + +type resourceError struct { + error + data any +} + +// The data associated with this error. +func (e *resourceError) Data() any { + return e.data +} + +// ResourceError is the error return from .Err in Resource in error situations. +type ResourceError interface { + error ResourceDataProvider } -// Image represents an image resource. -type Image interface { - Resource - ImageOps +// Resource represents a linkable resource, i.e. a content page, image etc. +type Resource interface { + ResourceWithoutMeta + ResourceMetaProvider } -type ImageOps interface { - Height() int - Width() int - Fill(spec string) (Image, error) - Fit(spec string) (Image, error) - Resize(spec string) (Image, error) - Filter(filters ...interface{}) (Image, error) - Exif() (*exif.Exif, error) +type ResourceWithoutMeta interface { + ResourceTypeProvider + MediaTypeProvider + ResourceLinksProvider + ResourceDataProvider } -type ResourceTypesProvider interface { - // MediaType is this resource's MIME type. - MediaType() media.Type - +type ResourceTypeProvider interface { // ResourceType is the resource type. For most file types, this is the main // part of the MIME type, e.g. "image", "application", "text" etc. // For content pages, this value is "page". ResourceType() string } +type ResourceTypesProvider interface { + ResourceTypeProvider + MediaTypeProvider +} + +type MediaTypeProvider interface { + // MediaType is this resource's MIME type. + MediaType() media.Type +} + type ResourceLinksProvider interface { // Permalink represents the absolute link to this resource. Permalink() string @@ -71,7 +106,19 @@ type ResourceLinksProvider interface { RelPermalink() string } +// ResourceMetaProvider provides metadata about a resource. type ResourceMetaProvider interface { + ResourceNameTitleProvider + ResourceParamsProvider +} + +type WithResourceMetaProvider interface { + // WithResourceMeta creates a new Resource with the given metadata. + // For internal use. + WithResourceMeta(ResourceMetaProvider) Resource +} + +type ResourceNameTitleProvider interface { // Name is the logical name of this resource. This can be set in the front matter // metadata for this resource. If not set, Hugo will assign a value. // This will in most cases be the base filename. @@ -84,6 +131,12 @@ type ResourceMetaProvider interface { Title() string } +type NameNormalizedProvider interface { + // NameNormalized is the normalized name of this resource. + // For internal use (for now). + NameNormalized() string +} + type ResourceParamsProvider interface { // Params set in front matter for this resource. Params() maps.Params @@ -91,23 +144,46 @@ type ResourceParamsProvider interface { type ResourceDataProvider interface { // Resource specific data set by Hugo. - // One example would be.Data.Digest for fingerprinted resources. - Data() interface{} + // One example would be .Data.Integrity for fingerprinted resources. + Data() any } // ResourcesLanguageMerger describes an interface for merging resources from a // different language. type ResourcesLanguageMerger interface { MergeByLanguage(other Resources) Resources + // Needed for integration with the tpl package. - MergeByLanguageInterface(other interface{}) (interface{}, error) + // For internal use. + MergeByLanguageInterface(other any) (any, error) } // Identifier identifies a resource. type Identifier interface { + // Key is mostly for internal use and should be considered opaque. + // This value may change between Hugo versions. Key() string } +// TransientIdentifier identifies a transient resource. +type TransientIdentifier interface { + // TransientKey is mostly for internal use and should be considered opaque. + // This value is implemented by transient resources where pointers may be short lived and + // not suitable for use as a map keys. + TransientKey() string +} + +// WeightProvider provides a weight. +type WeightProvider interface { + Weight() int +} + +// Weight0Provider provides a weight that's considered before the WeightProvider in sorting. +// This allows the weight set on a given term to win. +type Weight0Provider interface { + Weight0() int +} + // ContentResource represents a Resource that provides a way to get to its content. // Most Resource types in Hugo implements this interface, including Page. type ContentResource interface { @@ -125,27 +201,19 @@ type ContentProvider interface { // * Page: template.HTML // * JSON: String // * Etc. - Content() (interface{}, error) + Content(context.Context) (any, error) } -// OpenReadSeekCloser allows setting some other way (than reading from a filesystem) -// to open or create a ReadSeekCloser. -type OpenReadSeekCloser func() (hugio.ReadSeekCloser, error) - // ReadSeekCloserResource is a Resource that supports loading its content. type ReadSeekCloserResource interface { MediaType() media.Type - ReadSeekCloserProvider -} - -type ReadSeekCloserProvider interface { - ReadSeekCloser() (hugio.ReadSeekCloser, error) + hugio.ReadSeekCloserProvider } // LengthProvider is a Resource that provides a length // (typically the length of the content). type LengthProvider interface { - Len() int + Len(context.Context) int } // LanguageProvider is a Resource in a language. @@ -158,6 +226,60 @@ type TranslationKeyProvider interface { TranslationKey() string } +// Staler controls stale state of a Resource. A stale resource should be discarded. +type Staler interface { + StaleMarker + StaleInfo +} + +// StaleMarker marks a Resource as stale. +type StaleMarker interface { + MarkStale() +} + +// StaleInfo tells if a resource is marked as stale. +type StaleInfo interface { + StaleVersion() uint32 +} + +// StaleVersion returns the StaleVersion for the given os, +// or 0 if not set. +func StaleVersion(os any) uint32 { + if s, ok := os.(StaleInfo); ok { + return s.StaleVersion() + } + return 0 +} + +// StaleVersionSum calculates the sum of the StaleVersionSum for the given oss. +func StaleVersionSum(oss ...any) uint32 { + var version uint32 + for _, o := range oss { + if s, ok := o.(StaleInfo); ok && s.StaleVersion() > 0 { + version += s.StaleVersion() + } + } + return version +} + +// MarkStale will mark any of the oses as stale, if possible. +func MarkStale(os ...any) { + for _, o := range os { + if types.IsNil(o) { + continue + } + if s, ok := o.(StaleMarker); ok { + s.MarkStale() + } + } +} + +// UnmarshableResource represents a Resource that can be unmarshaled to some other format. +type UnmarshableResource interface { + ReadSeekCloserResource + Identifier +} + type resourceTypesHolder struct { mediaType media.Type resourceType string @@ -175,14 +297,10 @@ func NewResourceTypesProvider(mediaType media.Type, resourceType string) Resourc return resourceTypesHolder{mediaType: mediaType, resourceType: resourceType} } -type languageHolder struct { - lang *langs.Language -} - -func (l languageHolder) Language() *langs.Language { - return l.lang -} - -func NewLanguageProvider(lang *langs.Language) LanguageProvider { - return languageHolder{lang: lang} +// NameNormalizedOrName returns the normalized name if available, otherwise the name. +func NameNormalizedOrName(r Resource) string { + if nn, ok := r.(NameNormalizedProvider); ok { + return nn.NameNormalized() + } + return r.Name() } diff --git a/resources/resource_cache.go b/resources/resource_cache.go index 47822a7f5..898cd4c31 100644 --- a/resources/resource_cache.go +++ b/resources/resource_cache.go @@ -14,6 +14,7 @@ package resources import ( + "context" "encoding/json" "io" "path" @@ -21,186 +22,79 @@ import ( "strings" "sync" - "github.com/gohugoio/hugo/helpers" - - "github.com/gohugoio/hugo/hugofs/glob" - "github.com/gohugoio/hugo/resources/resource" + "github.com/gohugoio/hugo/cache/dynacache" "github.com/gohugoio/hugo/cache/filecache" - - "github.com/BurntSushi/locker" ) -const ( - CACHE_CLEAR_ALL = "clear_all" - CACHE_OTHER = "other" -) +func newResourceCache(rs *Spec, memCache *dynacache.Cache) *ResourceCache { + return &ResourceCache{ + fileCache: rs.FileCaches.AssetsCache(), + cacheResource: dynacache.GetOrCreatePartition[string, resource.Resource]( + memCache, + "/res1", + dynacache.OptionsPartition{ClearWhen: dynacache.ClearOnChange, Weight: 40}, + ), + cacheResourceFile: dynacache.GetOrCreatePartition[string, resource.Resource]( + memCache, + "/res2", + dynacache.OptionsPartition{ClearWhen: dynacache.ClearOnChange, Weight: 40}, + ), + CacheResourceRemote: dynacache.GetOrCreatePartition[string, resource.Resource]( + memCache, + "/resr", + dynacache.OptionsPartition{ClearWhen: dynacache.ClearOnChange, Weight: 40}, + ), + cacheResources: dynacache.GetOrCreatePartition[string, resource.Resources]( + memCache, + "/ress", + dynacache.OptionsPartition{ClearWhen: dynacache.ClearOnRebuild, Weight: 40}, + ), + cacheResourceTransformation: dynacache.GetOrCreatePartition[string, *resourceAdapterInner]( + memCache, + "/res1/tra", + dynacache.OptionsPartition{ClearWhen: dynacache.ClearOnChange, Weight: 40}, + ), + } +} type ResourceCache struct { - rs *Spec - sync.RWMutex - // Either resource.Resource or resource.Resources. - cache map[string]interface{} + cacheResource *dynacache.Partition[string, resource.Resource] + cacheResourceFile *dynacache.Partition[string, resource.Resource] + CacheResourceRemote *dynacache.Partition[string, resource.Resource] + cacheResources *dynacache.Partition[string, resource.Resources] + cacheResourceTransformation *dynacache.Partition[string, *resourceAdapterInner] fileCache *filecache.Cache - - // Provides named resource locks. - nlocker *locker.Locker -} - -// ResourceCacheKey converts the filename into the format used in the resource -// cache. -func ResourceCacheKey(filename string) string { - filename = filepath.ToSlash(filename) - return path.Join(resourceKeyPartition(filename), filename) -} - -func resourceKeyPartition(filename string) string { - ext := strings.TrimPrefix(path.Ext(filepath.ToSlash(filename)), ".") - if ext == "" { - ext = CACHE_OTHER - } - return ext -} - -// Commonly used aliases and directory names used for some types. -var extAliasKeywords = map[string][]string{ - "sass": []string{"scss"}, - "scss": []string{"sass"}, -} - -// ResourceKeyPartitions resolves a ordered slice of partitions that is -// used to do resource cache invalidations. -// -// We use the first directory path element and the extension, so: -// a/b.json => "a", "json" -// b.json => "json" -// -// For some of the extensions we will also map to closely related types, -// e.g. "scss" will also return "sass". -// -func ResourceKeyPartitions(filename string) []string { - var partitions []string - filename = glob.NormalizePath(filename) - dir, name := path.Split(filename) - ext := strings.TrimPrefix(path.Ext(filepath.ToSlash(name)), ".") - - if dir != "" { - partitions = append(partitions, strings.Split(dir, "/")[0]) - } - - if ext != "" { - partitions = append(partitions, ext) - } - - if aliases, found := extAliasKeywords[ext]; found { - partitions = append(partitions, aliases...) - } - - if len(partitions) == 0 { - partitions = []string{CACHE_OTHER} - } - - return helpers.UniqueStringsSorted(partitions) -} - -// ResourceKeyContainsAny returns whether the key is a member of any of the -// given partitions. -// -// This is used for resource cache invalidation. -func ResourceKeyContainsAny(key string, partitions []string) bool { - parts := strings.Split(key, "/") - for _, p1 := range partitions { - for _, p2 := range parts { - if p1 == p2 { - return true - } - } - } - return false -} - -func newResourceCache(rs *Spec) *ResourceCache { - return &ResourceCache{ - rs: rs, - fileCache: rs.FileCaches.AssetsCache(), - cache: make(map[string]interface{}), - nlocker: locker.NewLocker(), - } -} - -func (c *ResourceCache) clear() { - c.Lock() - defer c.Unlock() - - c.cache = make(map[string]interface{}) - c.nlocker = locker.NewLocker() -} - -func (c *ResourceCache) Contains(key string) bool { - key = c.cleanKey(filepath.ToSlash(key)) - _, found := c.get(key) - return found } func (c *ResourceCache) cleanKey(key string) string { - return strings.TrimPrefix(path.Clean(strings.ToLower(key)), "/") + return strings.TrimPrefix(path.Clean(strings.ToLower(filepath.ToSlash(key))), "/") } -func (c *ResourceCache) get(key string) (interface{}, bool) { - c.RLock() - defer c.RUnlock() - r, found := c.cache[key] - return r, found +func (c *ResourceCache) Get(ctx context.Context, key string) (resource.Resource, bool) { + return c.cacheResource.Get(ctx, key) } func (c *ResourceCache) GetOrCreate(key string, f func() (resource.Resource, error)) (resource.Resource, error) { - r, err := c.getOrCreate(key, func() (interface{}, error) { return f() }) - if r == nil || err != nil { - return nil, err - } - return r.(resource.Resource), nil + return c.cacheResource.GetOrCreate(key, func(key string) (resource.Resource, error) { + return f() + }) +} + +func (c *ResourceCache) GetOrCreateFile(key string, f func() (resource.Resource, error)) (resource.Resource, error) { + return c.cacheResourceFile.GetOrCreate(key, func(key string) (resource.Resource, error) { + return f() + }) } func (c *ResourceCache) GetOrCreateResources(key string, f func() (resource.Resources, error)) (resource.Resources, error) { - r, err := c.getOrCreate(key, func() (interface{}, error) { return f() }) - if r == nil || err != nil { - return nil, err - } - return r.(resource.Resources), nil -} - -func (c *ResourceCache) getOrCreate(key string, f func() (interface{}, error)) (interface{}, error) { - key = c.cleanKey(key) - // First check in-memory cache. - r, found := c.get(key) - if found { - return r, nil - } - // This is a potentially long running operation, so get a named lock. - c.nlocker.Lock(key) - - // Double check in-memory cache. - r, found = c.get(key) - if found { - c.nlocker.Unlock(key) - return r, nil - } - - defer c.nlocker.Unlock(key) - - r, err := f() - if err != nil { - return nil, err - } - - c.set(key, r) - - return r, nil - + return c.cacheResources.GetOrCreate(key, func(key string) (resource.Resources, error) { + return f() + }) } func (c *ResourceCache) getFilenames(key string) (string, string) { @@ -229,7 +123,6 @@ func (c *ResourceCache) getFromFile(key string) (filecache.ItemInfo, io.ReadClos fi, rc, _ := c.fileCache.Get(filenameContent) return fi, rc, meta, rc != nil - } // writeMeta writes the metadata to file and returns a writer for the content part. @@ -253,45 +146,4 @@ func (c *ResourceCache) writeMeta(key string, meta transformedResourceMetadata) fi, fc, err := c.fileCache.WriteCloser(filenameContent) return fi, fc, err - -} - -func (c *ResourceCache) set(key string, r interface{}) { - c.Lock() - defer c.Unlock() - c.cache[key] = r -} - -func (c *ResourceCache) DeletePartitions(partitions ...string) { - partitionsSet := map[string]bool{ - // Always clear out the resources not matching any partition. - "other": true, - } - for _, p := range partitions { - partitionsSet[p] = true - } - - if partitionsSet[CACHE_CLEAR_ALL] { - c.clear() - return - } - - c.Lock() - defer c.Unlock() - - for k := range c.cache { - clear := false - for p := range partitionsSet { - if strings.Contains(k, p) { - // There will be some false positive, but that's fine. - clear = true - break - } - } - - if clear { - delete(c.cache, k) - } - } - } diff --git a/resources/resource_cache_test.go b/resources/resource_cache_test.go deleted file mode 100644 index bcb241025..000000000 --- a/resources/resource_cache_test.go +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright 2019 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 resources - -import ( - "path/filepath" - "testing" - - qt "github.com/frankban/quicktest" -) - -func TestResourceKeyPartitions(t *testing.T) { - c := qt.New(t) - - for _, test := range []struct { - input string - expected []string - }{ - {"a.js", []string{"js"}}, - {"a.scss", []string{"sass", "scss"}}, - {"a.sass", []string{"sass", "scss"}}, - {"d/a.js", []string{"d", "js"}}, - {"js/a.js", []string{"js"}}, - {"D/a.JS", []string{"d", "js"}}, - {"d/a", []string{"d"}}, - {filepath.FromSlash("/d/a.js"), []string{"d", "js"}}, - {filepath.FromSlash("/d/e/a.js"), []string{"d", "js"}}, - } { - c.Assert(ResourceKeyPartitions(test.input), qt.DeepEquals, test.expected, qt.Commentf(test.input)) - } -} - -func TestResourceKeyContainsAny(t *testing.T) { - c := qt.New(t) - - for _, test := range []struct { - key string - filename string - expected bool - }{ - {"styles/css", "asdf.css", true}, - {"styles/css", "styles/asdf.scss", true}, - {"js/foo.bar", "asdf.css", false}, - } { - c.Assert(ResourceKeyContainsAny(test.key, ResourceKeyPartitions(test.filename)), qt.Equals, test.expected) - } -} diff --git a/resources/resource_factories/bundler/bundler.go b/resources/resource_factories/bundler/bundler.go index 1ea92bea3..aef644b7f 100644 --- a/resources/resource_factories/bundler/bundler.go +++ b/resources/resource_factories/bundler/bundler.go @@ -18,9 +18,9 @@ import ( "fmt" "io" "path" - "path/filepath" "github.com/gohugoio/hugo/common/hugio" + "github.com/gohugoio/hugo/identity" "github.com/gohugoio/hugo/media" "github.com/gohugoio/hugo/resources" "github.com/gohugoio/hugo/resources/resource" @@ -81,19 +81,42 @@ func (r *multiReadSeekCloser) Close() error { // Concat concatenates the list of Resource objects. func (c *Client) Concat(targetPath string, r resource.Resources) (resource.Resource, error) { - // The CACHE_OTHER will make sure this will be re-created and published on rebuilds. - return c.rs.ResourceCache.GetOrCreate(path.Join(resources.CACHE_OTHER, targetPath), func() (resource.Resource, error) { + targetPath = path.Clean(targetPath) + return c.rs.ResourceCache.GetOrCreate(targetPath, func() (resource.Resource, error) { var resolvedm media.Type // The given set of resources must be of the same Media Type. // We may improve on that in the future, but then we need to know more. - for i, r := range r { - if i > 0 && r.MediaType().Type() != resolvedm.Type() { - return nil, fmt.Errorf("resources in Concat must be of the same Media Type, got %q and %q", r.MediaType().Type(), resolvedm.Type()) + for i, rr := range r { + if i > 0 && rr.MediaType().Type != resolvedm.Type { + return nil, fmt.Errorf("resources in Concat must be of the same Media Type, got %q and %q", rr.MediaType().Type, resolvedm.Type) } - resolvedm = r.MediaType() + resolvedm = rr.MediaType() } + idm := c.rs.Cfg.NewIdentityManager("concat") + + // Re-create on structural changes. + idm.AddIdentity(identity.StructuralChangeAdd, identity.StructuralChangeRemove) + + // Add the concatenated resources as dependencies to the composite resource + // so that we can track changes to the individual resources. + idm.AddIdentityForEach(identity.ForEeachIdentityProviderFunc( + func(f func(identity.Identity) bool) bool { + var terminate bool + for _, rr := range r { + identity.WalkIdentitiesShallow(rr, func(depth int, id identity.Identity) bool { + terminate = f(id) + return terminate + }) + if terminate { + break + } + } + return terminate + }, + )) + concatr := func() (hugio.ReadSeekCloser, error) { var rcsources []hugio.ReadSeekCloser for _, s := range r { @@ -115,10 +138,10 @@ func (c *Client) Concat(targetPath string, r resource.Resources) (resource.Resou // Arbitrary JavaScript files require a barrier between them to be safely concatenated together. // Without this, the last line of one file can affect the first line of the next file and change how both files are interpreted. - if resolvedm.MainType == media.JavascriptType.MainType && resolvedm.SubType == media.JavascriptType.SubType { + if resolvedm.MainType == media.Builtin.JavascriptType.MainType && resolvedm.SubType == media.Builtin.JavascriptType.SubType { readers := make([]hugio.ReadSeekCloser, 2*len(rcsources)-1) j := 0 - for i := 0; i < len(rcsources); i++ { + for i := range rcsources { if i > 0 { readers[j] = hugio.NewReadSeekerNoOpCloserFromString("\n;\n") j++ @@ -130,21 +153,19 @@ func (c *Client) Concat(targetPath string, r resource.Resources) (resource.Resou } return newMultiReadSeekCloser(rcsources...), nil - } - composite, err := c.rs.New( + composite, err := c.rs.NewResource( resources.ResourceSourceDescriptor{ - Fs: c.rs.FileCaches.AssetsCache().Fs, LazyPublish: true, OpenReadSeekCloser: concatr, - RelTargetFilename: filepath.Clean(targetPath)}) - + TargetPath: targetPath, + DependencyManager: idm, + }) if err != nil { return nil, err } return composite, nil }) - } diff --git a/resources/resource_factories/bundler/bundler_test.go b/resources/resource_factories/bundler/bundler_test.go index 16a5215ba..66f0c2340 100644 --- a/resources/resource_factories/bundler/bundler_test.go +++ b/resources/resource_factories/bundler/bundler_test.go @@ -31,11 +31,10 @@ func TestMultiReadSeekCloser(t *testing.T) { hugio.NewReadSeekerNoOpCloserFromString("C"), ) - for i := 0; i < 3; i++ { + for range 3 { s1 := helpers.ReaderToString(rc) c.Assert(s1, qt.Equals, "ABC") _, err := rc.Seek(0, 0) c.Assert(err, qt.IsNil) } - } diff --git a/resources/resource_factories/create/create.go b/resources/resource_factories/create/create.go index 4ac20d36e..2aecb5a93 100644 --- a/resources/resource_factories/create/create.go +++ b/resources/resource_factories/create/create.go @@ -16,15 +16,27 @@ package create import ( + "net/http" + "os" "path" "path/filepath" "strings" + "time" + "github.com/bep/logg" + "github.com/gohugoio/httpcache" + hhttpcache "github.com/gohugoio/hugo/cache/httpcache" "github.com/gohugoio/hugo/hugofs/glob" + "github.com/gohugoio/hugo/identity" "github.com/gohugoio/hugo/hugofs" + "github.com/gohugoio/hugo/cache/dynacache" + "github.com/gohugoio/hugo/cache/filecache" + "github.com/gohugoio/hugo/common/hashing" + "github.com/gohugoio/hugo/common/hcontext" "github.com/gohugoio/hugo/common/hugio" + "github.com/gohugoio/hugo/common/tasks" "github.com/gohugoio/hugo/resources" "github.com/gohugoio/hugo/resources/resource" ) @@ -32,53 +44,155 @@ import ( // Client contains methods to create Resource objects. // tasks to Resource objects. type Client struct { - rs *resources.Spec + rs *resources.Spec + httpClient *http.Client + httpCacheConfig hhttpcache.ConfigCompiled + cacheGetResource *filecache.Cache + resourceIDDispatcher hcontext.ContextDispatcher[string] + + // Set when watching. + remoteResourceChecker *tasks.RunEvery + remoteResourceLogger logg.LevelLogger } +type contextKey uint8 + +const ( + contextKeyResourceID contextKey = iota +) + // New creates a new Client with the given specification. func New(rs *resources.Spec) *Client { - return &Client{rs: rs} + fileCache := rs.FileCaches.GetResourceCache() + resourceIDDispatcher := hcontext.NewContextDispatcher[string](contextKeyResourceID) + httpCacheConfig := rs.Cfg.GetConfigSection("httpCacheCompiled").(hhttpcache.ConfigCompiled) + var remoteResourceChecker *tasks.RunEvery + if rs.Cfg.Watching() && !httpCacheConfig.IsPollingDisabled() { + remoteResourceChecker = &tasks.RunEvery{ + HandleError: func(name string, err error) { + rs.Logger.Warnf("Failed to check remote resource: %s", err) + }, + RunImmediately: false, + } + + if err := remoteResourceChecker.Start(); err != nil { + panic(err) + } + + rs.BuildClosers.Add(remoteResourceChecker) + } + + httpTimeout := 2 * time.Minute // Need to cover retries. + if httpTimeout < (rs.Cfg.Timeout() + 30*time.Second) { + httpTimeout = rs.Cfg.Timeout() + 30*time.Second + } + + return &Client{ + rs: rs, + httpCacheConfig: httpCacheConfig, + resourceIDDispatcher: resourceIDDispatcher, + remoteResourceChecker: remoteResourceChecker, + remoteResourceLogger: rs.Logger.InfoCommand("remote"), + httpClient: &http.Client{ + Timeout: httpTimeout, + Transport: &httpcache.Transport{ + Cache: fileCache.AsHTTPCache(), + CacheKey: func(req *http.Request) string { + return resourceIDDispatcher.Get(req.Context()) + }, + Around: func(req *http.Request, key string) func() { + return fileCache.NamedLock(key) + }, + AlwaysUseCachedResponse: func(req *http.Request, key string) bool { + return !httpCacheConfig.For(req.URL.String()) + }, + ShouldCache: func(req *http.Request, resp *http.Response, key string) bool { + return shouldCache(resp.StatusCode) + }, + MarkCachedResponses: true, + EnableETagPair: true, + Transport: &transport{ + Cfg: rs.Cfg, + Logger: rs.Logger, + }, + }, + }, + cacheGetResource: fileCache, + } } -// Get creates a new Resource by opening the given filename in the assets filesystem. -func (c *Client) Get(filename string) (resource.Resource, error) { - filename = filepath.Clean(filename) - return c.rs.ResourceCache.GetOrCreate(resources.ResourceCacheKey(filename), func() (resource.Resource, error) { - return c.rs.New(resources.ResourceSourceDescriptor{ - Fs: c.rs.BaseFs.Assets.Fs, - LazyPublish: true, - SourceFilename: filename}) +// Copy copies r to the new targetPath. +func (c *Client) Copy(r resource.Resource, targetPath string) (resource.Resource, error) { + key := dynacache.CleanKey(targetPath) + "__copy" + return c.rs.ResourceCache.GetOrCreate(key, func() (resource.Resource, error) { + return resources.Copy(r, targetPath), nil }) +} +// Get creates a new Resource by opening the given pathname in the assets filesystem. +func (c *Client) Get(pathname string) (resource.Resource, error) { + pathname = path.Clean(pathname) + key := dynacache.CleanKey(pathname) + "__get" + + return c.rs.ResourceCache.GetOrCreate(key, func() (resource.Resource, error) { + // The resource file will not be read before it gets used (e.g. in .Content), + // so we need to check that the file exists here. + filename := filepath.FromSlash(pathname) + fi, err := c.rs.BaseFs.Assets.Fs.Stat(filename) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + // A real error. + return nil, err + } + + return c.getOrCreateFileResource(fi.(hugofs.FileMetaInfo)) + }) } // Match gets the resources matching the given pattern from the assets filesystem. func (c *Client) Match(pattern string) (resource.Resources, error) { - return c.match(pattern, false) + return c.match("__match", pattern, nil, false) +} + +func (c *Client) ByType(tp string) resource.Resources { + res, err := c.match(path.Join("_byType", tp), "**", func(r resource.Resource) bool { return r.ResourceType() == tp }, false) + if err != nil { + panic(err) + } + return res } // GetMatch gets first resource matching the given pattern from the assets filesystem. func (c *Client) GetMatch(pattern string) (resource.Resource, error) { - res, err := c.match(pattern, true) + res, err := c.match("__get-match", pattern, nil, true) if err != nil || len(res) == 0 { return nil, err } return res[0], err } -func (c *Client) match(pattern string, firstOnly bool) (resource.Resources, error) { - var name string - if firstOnly { - name = "__get-match" - } else { - name = "__match" - } +func (c *Client) getOrCreateFileResource(info hugofs.FileMetaInfo) (resource.Resource, error) { + meta := info.Meta() + return c.rs.ResourceCache.GetOrCreateFile(filepath.ToSlash(meta.Filename), func() (resource.Resource, error) { + return c.rs.NewResource(resources.ResourceSourceDescriptor{ + LazyPublish: true, + OpenReadSeekCloser: func() (hugio.ReadSeekCloser, error) { + return meta.Open() + }, + NameNormalized: meta.PathInfo.Path(), + NameOriginal: meta.PathInfo.Unnormalized().Path(), + GroupIdentity: meta.PathInfo, + TargetPath: meta.PathInfo.Unnormalized().Path(), + SourceFilenameOrPath: meta.Filename, + }) + }) +} +func (c *Client) match(name, pattern string, matchFunc func(r resource.Resource) bool, firstOnly bool) (resource.Resources, error) { pattern = glob.NormalizePath(pattern) partitions := glob.FilterGlobParts(strings.Split(pattern, "/")) - if len(partitions) == 0 { - partitions = []string{resources.CACHE_OTHER} - } key := path.Join(name, path.Join(partitions...)) key = path.Join(key, pattern) @@ -86,23 +200,18 @@ func (c *Client) match(pattern string, firstOnly bool) (resource.Resources, erro var res resource.Resources handle := func(info hugofs.FileMetaInfo) (bool, error) { - meta := info.Meta() - r, err := c.rs.New(resources.ResourceSourceDescriptor{ - LazyPublish: true, - FileInfo: info, - OpenReadSeekCloser: func() (hugio.ReadSeekCloser, error) { - return meta.Open() - }, - RelTargetFilename: meta.Path()}) - + r, err := c.getOrCreateFileResource(info) if err != nil { return true, err } + if matchFunc != nil && !matchFunc(r) { + return false, nil + } + res = append(res, r) return firstOnly, nil - } if err := hugofs.Glob(c.rs.BaseFs.Assets.Fs, pattern, handle); err != nil { @@ -110,22 +219,86 @@ func (c *Client) match(pattern string, firstOnly bool) (resource.Resources, erro } return res, nil - }) } +type Options struct { + // The target path relative to the publish directory. + // Unix style path, i.e. "images/logo.png". + TargetPath string + + // Whether the TargetPath has a hash in it which will change if the resource changes. + // If not, we will calculate a hash from the content. + TargetPathHasHash bool + + // The content to create the Resource from. + CreateContent func() (func() (hugio.ReadSeekCloser, error), error) +} + +// FromOpts creates a new Resource from the given Options. +// Make sure to set optis.TargetPathHasHash if the TargetPath already contains a hash, +// as this avoids the need to calculate it. +// To create a new ReadSeekCloser from a string, use hugio.NewReadSeekerNoOpCloserFromString, +// or hugio.NewReadSeekerNoOpCloserFromBytes for a byte slice. +// See FromString. +func (c *Client) FromOpts(opts Options) (resource.Resource, error) { + opts.TargetPath = path.Clean(opts.TargetPath) + var hash string + var newReadSeeker func() (hugio.ReadSeekCloser, error) = nil + if !opts.TargetPathHasHash { + var err error + newReadSeeker, err = opts.CreateContent() + if err != nil { + return nil, err + } + if err := func() error { + r, err := newReadSeeker() + if err != nil { + return err + } + defer r.Close() + + hash, err = hashing.XxHashFromReaderHexEncoded(r) + if err != nil { + return err + } + return nil + }(); err != nil { + return nil, err + } + } + + key := dynacache.CleanKey(opts.TargetPath) + hash + r, err := c.rs.ResourceCache.GetOrCreate(key, func() (resource.Resource, error) { + if newReadSeeker == nil { + var err error + newReadSeeker, err = opts.CreateContent() + if err != nil { + return nil, err + } + } + return c.rs.NewResource( + resources.ResourceSourceDescriptor{ + LazyPublish: true, + GroupIdentity: identity.Anonymous, // All usage of this resource are tracked via its string content. + OpenReadSeekCloser: func() (hugio.ReadSeekCloser, error) { + return newReadSeeker() + }, + TargetPath: opts.TargetPath, + }) + }) + + return r, err +} + // FromString creates a new Resource from a string with the given relative target path. func (c *Client) FromString(targetPath, content string) (resource.Resource, error) { - return c.rs.ResourceCache.GetOrCreate(path.Join(resources.CACHE_OTHER, targetPath), func() (resource.Resource, error) { - return c.rs.New( - resources.ResourceSourceDescriptor{ - Fs: c.rs.FileCaches.AssetsCache().Fs, - LazyPublish: true, - OpenReadSeekCloser: func() (hugio.ReadSeekCloser, error) { - return hugio.NewReadSeekerNoOpCloserFromString(content), nil - }, - RelTargetFilename: filepath.Clean(targetPath)}) - + return c.FromOpts(Options{ + TargetPath: targetPath, + CreateContent: func() (func() (hugio.ReadSeekCloser, error), error) { + return func() (hugio.ReadSeekCloser, error) { + return hugio.NewReadSeekerNoOpCloserFromString(content), nil + }, nil + }, }) - } diff --git a/resources/resource_factories/create/create_integration_test.go b/resources/resource_factories/create/create_integration_test.go new file mode 100644 index 000000000..0ed43721c --- /dev/null +++ b/resources/resource_factories/create/create_integration_test.go @@ -0,0 +1,175 @@ +// Copyright 2024 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 create_test + +import ( + "fmt" + "math/rand" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gohugoio/hugo/hugolib" +) + +func TestGetRemoteHead(t *testing.T) { + files := ` +-- config.toml -- +[security] + [security.http] + methods = ['(?i)GET|POST|HEAD'] + urls = ['.*gohugo\.io.*'] +-- layouts/index.html -- +{{ $url := "https://gohugo.io/img/hugo.png" }} +{{ $opts := dict "method" "head" }} +{{ with try (resources.GetRemote $url $opts) }} + {{ with .Err }} + {{ errorf "Unable to get remote resource: %s" . }} + {{ else with .Value }} + Head Content: {{ .Content }}. Head Data: {{ .Data }} + {{ else }} + {{ errorf "Unable to get remote resource: %s" $url }} + {{ end }} +{{ end }} +` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + }, + ) + + b.Build() + + b.AssertFileContent("public/index.html", + "Head Content: .", + "Head Data: map[ContentLength:18210 ContentType:image/png Headers:map[] Status:200 OK StatusCode:200 TransferEncoding:[]]", + ) +} + +func TestGetRemoteResponseHeaders(t *testing.T) { + files := ` +-- config.toml -- +[security] + [security.http] + methods = ['(?i)GET|POST|HEAD'] + urls = ['.*gohugo\.io.*'] +-- layouts/index.html -- +{{ $url := "https://gohugo.io/img/hugo.png" }} +{{ $opts := dict "method" "head" "responseHeaders" (slice "X-Frame-Options" "Server") }} +{{ with try (resources.GetRemote $url $opts) }} + {{ with .Err }} + {{ errorf "Unable to get remote resource: %s" . }} + {{ else with .Value }} + Response Headers: {{ .Data.Headers }} + {{ else }} + {{ errorf "Unable to get remote resource: %s" $url }} + {{ end }} +{{ end }} +` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + }, + ) + + b.Build() + + b.AssertFileContent("public/index.html", + "Response Headers: map[Server:[Netlify] X-Frame-Options:[DENY]]", + ) +} + +func TestGetRemoteRetry(t *testing.T) { + t.Parallel() + + temporaryHTTPCodes := []int{408, 429, 500, 502, 503, 504} + numPages := 20 + + handler := func(w http.ResponseWriter, r *http.Request) { + if rand.Intn(3) == 0 { + w.WriteHeader(temporaryHTTPCodes[rand.Intn(len(temporaryHTTPCodes))]) + return + } + w.Header().Add("Content-Type", "text/plain") + w.Write([]byte("Response for " + r.URL.Path + ".")) + } + + srv := httptest.NewServer(http.HandlerFunc(handler)) + t.Cleanup(func() { srv.Close() }) + + filesTemplate := ` +-- hugo.toml -- +disableKinds = ["home", "taxonomy", "term"] +timeout = "TIMEOUT" +[security] +[security.http] +urls = ['.*'] +mediaTypes = ['text/plain'] +-- layouts/_default/single.html -- +{{ $url := printf "%s%s" "URL" .RelPermalink}} +{{ $opts := dict }} +{{ with try (resources.GetRemote $url $opts) }} + {{ with .Err }} + {{ errorf "Got Err: %s" . }} + {{ with .Cause }}{{ errorf "Data: %s" .Data }}{{ end }} + {{ else with .Value }} + Content: {{ .Content }} + {{ else }} + {{ errorf "Unable to get remote resource: %s" $url }} + {{ end }} +{{ end }} +` + + for i := range numPages { + filesTemplate += fmt.Sprintf("-- content/post/p%d.md --\n", i) + } + + filesTemplate = strings.ReplaceAll(filesTemplate, "URL", srv.URL) + + t.Run("OK", func(t *testing.T) { + files := strings.ReplaceAll(filesTemplate, "TIMEOUT", "60s") + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + }, + ) + + b.Build() + + for i := range numPages { + b.AssertFileContent(fmt.Sprintf("public/post/p%d/index.html", i), fmt.Sprintf("Content: Response for /post/p%d/.", i)) + } + }) + + t.Run("Timeout", func(t *testing.T) { + files := strings.ReplaceAll(filesTemplate, "TIMEOUT", "100ms") + b, err := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + }, + ).BuildE() + // This is hard to get stable on GitHub Actions, it sometimes succeeds due to timing issues. + if err != nil { + b.AssertLogContains("Got Err") + b.AssertLogContains("retry timeout") + } + }) +} diff --git a/resources/resource_factories/create/remote.go b/resources/resource_factories/create/remote.go new file mode 100644 index 000000000..51fd0bf8e --- /dev/null +++ b/resources/resource_factories/create/remote.go @@ -0,0 +1,469 @@ +// Copyright 2021 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 create + +import ( + "bytes" + "context" + "fmt" + "io" + "math/rand" + "mime" + "net/http" + "net/url" + "path" + "strings" + "time" + + gmaps "maps" + + "github.com/gohugoio/httpcache" + "github.com/gohugoio/hugo/common/hashing" + "github.com/gohugoio/hugo/common/hstrings" + "github.com/gohugoio/hugo/common/hugio" + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/common/tasks" + "github.com/gohugoio/hugo/common/types" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/identity" + "github.com/gohugoio/hugo/media" + "github.com/gohugoio/hugo/resources" + "github.com/gohugoio/hugo/resources/resource" + "github.com/mitchellh/mapstructure" +) + +type HTTPError struct { + error + Data map[string]any + + StatusCode int + Body string +} + +func responseToData(res *http.Response, readBody bool, includeHeaders []string) map[string]any { + var body []byte + if readBody { + body, _ = io.ReadAll(res.Body) + } + + responseHeaders := make(map[string][]string) + if len(includeHeaders) > 0 { + for k, v := range res.Header { + if hstrings.InSlicEqualFold(includeHeaders, k) { + responseHeaders[k] = v + } + } + } + + m := map[string]any{ + "StatusCode": res.StatusCode, + "Status": res.Status, + "TransferEncoding": res.TransferEncoding, + "ContentLength": res.ContentLength, + "ContentType": res.Header.Get("Content-Type"), + "Headers": responseHeaders, + } + + if readBody { + m["Body"] = string(body) + } + + return m +} + +func toHTTPError(err error, res *http.Response, readBody bool, responseHeaders []string) *HTTPError { + if err == nil { + panic("err is nil") + } + if res == nil { + return &HTTPError{ + error: err, + Data: map[string]any{}, + } + } + + return &HTTPError{ + error: err, + Data: responseToData(res, readBody, responseHeaders), + } +} + +var temporaryHTTPStatusCodes = map[int]bool{ + 408: true, + 429: true, + 500: true, + 502: true, + 503: true, + 504: true, +} + +func (c *Client) configurePollingIfEnabled(uri, optionsKey string, getRes func() (*http.Response, error)) { + if c.remoteResourceChecker == nil { + return + } + + // Set up polling for changes to this resource. + pollingConfig := c.httpCacheConfig.PollConfigFor(uri) + if pollingConfig.IsZero() || pollingConfig.Config.Disable { + return + } + + if c.remoteResourceChecker.Has(optionsKey) { + return + } + + var lastChange time.Time + c.remoteResourceChecker.Add(optionsKey, + tasks.Func{ + IntervalLow: pollingConfig.Config.Low, + IntervalHigh: pollingConfig.Config.High, + F: func(interval time.Duration) (time.Duration, error) { + start := time.Now() + defer func() { + duration := time.Since(start) + c.rs.Logger.Debugf("Polled remote resource for changes in %13s. Interval: %4s (low: %4s high: %4s) resource: %q ", duration, interval, pollingConfig.Config.Low, pollingConfig.Config.High, uri) + }() + // TODO(bep) figure out a ways to remove unused tasks. + res, err := getRes() + if err != nil { + return pollingConfig.Config.High, err + } + // The caching is delayed until the body is read. + io.Copy(io.Discard, res.Body) + res.Body.Close() + x1, x2 := res.Header.Get(httpcache.XETag1), res.Header.Get(httpcache.XETag2) + if x1 != x2 { + lastChange = time.Now() + c.remoteResourceLogger.Logf("detected change in remote resource %q", uri) + c.rs.Rebuilder.SignalRebuild(identity.StringIdentity(optionsKey)) + } + + if time.Since(lastChange) < 10*time.Second { + // The user is typing, check more often. + return 0, nil + } + + // Increase the interval to avoid hammering the server. + interval += 1 * time.Second + + return interval, nil + }, + }) +} + +// FromRemote expects one or n-parts of a URL to a resource +// If you provide multiple parts they will be joined together to the final URL. +func (c *Client) FromRemote(uri string, optionsm map[string]any) (resource.Resource, error) { + rURL, err := url.Parse(uri) + if err != nil { + return nil, fmt.Errorf("failed to parse URL for resource %s: %w", uri, err) + } + + method := "GET" + if s, _, ok := maps.LookupEqualFold(optionsm, "method"); ok { + method = strings.ToUpper(s.(string)) + } + isHeadMethod := method == "HEAD" + + optionsm = gmaps.Clone(optionsm) + userKey, optionsKey := remoteResourceKeys(uri, optionsm) + + // A common pattern is to use the key in the options map as + // a way to control cache eviction, + // so make sure we use any user provided key as the file cache key, + // but the auto generated and more stable key for everything else. + filecacheKey := userKey + + return c.rs.ResourceCache.CacheResourceRemote.GetOrCreate(optionsKey, func(key string) (resource.Resource, error) { + options, err := decodeRemoteOptions(optionsm) + if err != nil { + return nil, fmt.Errorf("failed to decode options for resource %s: %w", uri, err) + } + + if err := c.validateFromRemoteArgs(uri, options); err != nil { + return nil, err + } + + getRes := func() (*http.Response, error) { + ctx := context.Background() + ctx = c.resourceIDDispatcher.Set(ctx, filecacheKey) + + req, err := options.NewRequest(uri) + if err != nil { + return nil, fmt.Errorf("failed to create request for resource %s: %w", uri, err) + } + + req = req.WithContext(ctx) + + return c.httpClient.Do(req) + } + + res, err := getRes() + if err != nil { + return nil, err + } + defer res.Body.Close() + + c.configurePollingIfEnabled(uri, optionsKey, getRes) + + if res.StatusCode == http.StatusNotFound { + // Not found. This matches how lookups for local resources work. + return nil, nil + } + + if res.StatusCode < 200 || res.StatusCode > 299 { + return nil, toHTTPError(fmt.Errorf("failed to fetch remote resource from '%s': %s", uri, http.StatusText(res.StatusCode)), res, !isHeadMethod, options.ResponseHeaders) + } + + var ( + body []byte + mediaType media.Type + ) + // A response to a HEAD method should not have a body. If it has one anyway, that body must be ignored. + // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/HEAD + if !isHeadMethod && res.Body != nil { + body, err = io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("failed to read remote resource %q: %w", uri, err) + } + } + + filename := path.Base(rURL.Path) + if _, params, _ := mime.ParseMediaType(res.Header.Get("Content-Disposition")); params != nil { + if _, ok := params["filename"]; ok { + filename = params["filename"] + } + } + + contentType := res.Header.Get("Content-Type") + + // For HEAD requests we have no body to work with, so we need to use the Content-Type header. + if isHeadMethod || c.rs.ExecHelper.Sec().HTTP.MediaTypes.Accept(contentType) { + var found bool + mediaType, found = c.rs.MediaTypes().GetByType(contentType) + if !found { + // A media type not configured in Hugo, just create one from the content type string. + mediaType, _ = media.FromString(contentType) + } + } + + if mediaType.IsZero() { + + var extensionHints []string + + // mime.ExtensionsByType gives a long list of extensions for text/plain, + // just use ".txt". + if strings.HasPrefix(contentType, "text/plain") { + extensionHints = []string{".txt"} + } else { + exts, _ := mime.ExtensionsByType(contentType) + if exts != nil { + extensionHints = exts + } + } + + // Look for a file extension. If it's .txt, look for a more specific. + if extensionHints == nil || extensionHints[0] == ".txt" { + if ext := path.Ext(filename); ext != "" { + extensionHints = []string{ext} + } + } + + // Now resolve the media type primarily using the content. + mediaType = media.FromContent(c.rs.MediaTypes(), extensionHints, body) + + } + + if mediaType.IsZero() { + return nil, fmt.Errorf("failed to resolve media type for remote resource %q", uri) + } + + userKey = filename[:len(filename)-len(path.Ext(filename))] + "_" + userKey + mediaType.FirstSuffix.FullSuffix + data := responseToData(res, false, options.ResponseHeaders) + + return c.rs.NewResource( + resources.ResourceSourceDescriptor{ + MediaType: mediaType, + Data: data, + GroupIdentity: identity.StringIdentity(optionsKey), + LazyPublish: true, + OpenReadSeekCloser: func() (hugio.ReadSeekCloser, error) { + return hugio.NewReadSeekerNoOpCloser(bytes.NewReader(body)), nil + }, + TargetPath: userKey, + }) + }) +} + +func (c *Client) validateFromRemoteArgs(uri string, options fromRemoteOptions) error { + if err := c.rs.ExecHelper.Sec().CheckAllowedHTTPURL(uri); err != nil { + return err + } + + if err := c.rs.ExecHelper.Sec().CheckAllowedHTTPMethod(options.Method); err != nil { + return err + } + + return nil +} + +func remoteResourceKeys(uri string, optionsm map[string]any) (string, string) { + var userKey string + if key, k, found := maps.LookupEqualFold(optionsm, "key"); found { + userKey = hashing.HashString(key) + delete(optionsm, k) + } + optionsKey := hashing.HashString(uri, optionsm) + if userKey == "" { + userKey = optionsKey + } + return userKey, optionsKey +} + +func addDefaultHeaders(req *http.Request) { + if !hasHeaderKey(req.Header, "User-Agent") { + req.Header.Add("User-Agent", "Hugo Static Site Generator") + } +} + +func addUserProvidedHeaders(headers map[string]any, req *http.Request) { + if headers == nil { + return + } + for key, val := range headers { + vals := types.ToStringSlicePreserveString(val) + for _, s := range vals { + req.Header.Add(key, s) + } + } +} + +func hasHeaderKey(m http.Header, key string) bool { + _, ok := m[key] + return ok +} + +type fromRemoteOptions struct { + Method string + Headers map[string]any + Body []byte + ResponseHeaders []string +} + +func (o fromRemoteOptions) BodyReader() io.Reader { + if o.Body == nil { + return nil + } + return bytes.NewBuffer(o.Body) +} + +func (o fromRemoteOptions) NewRequest(url string) (*http.Request, error) { + req, err := http.NewRequest(o.Method, url, o.BodyReader()) + if err != nil { + return nil, err + } + + // First add any user provided headers. + if o.Headers != nil { + addUserProvidedHeaders(o.Headers, req) + } + + // Then add default headers not provided by the user. + addDefaultHeaders(req) + + return req, nil +} + +func decodeRemoteOptions(optionsm map[string]any) (fromRemoteOptions, error) { + options := fromRemoteOptions{ + Method: "GET", + } + + err := mapstructure.WeakDecode(optionsm, &options) + if err != nil { + return options, err + } + options.Method = strings.ToUpper(options.Method) + + return options, nil +} + +var _ http.RoundTripper = (*transport)(nil) + +type transport struct { + Cfg config.AllProvider + Logger loggers.Logger +} + +func (t *transport) RoundTrip(req *http.Request) (resp *http.Response, err error) { + defer func() { + if resp != nil && resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusNotModified { + t.Logger.Debugf("Fetched remote resource: %s", req.URL.String()) + } + }() + + var ( + start time.Time + nextSleep = time.Duration((rand.Intn(1000) + 100)) * time.Millisecond + nextSleepLimit = time.Duration(5) * time.Second + retry bool + ) + + for { + resp, retry, err = func() (*http.Response, bool, error) { + resp2, err := http.DefaultTransport.RoundTrip(req) + if err != nil { + return resp2, false, err + } + + if resp2.StatusCode != http.StatusNotFound && resp2.StatusCode != http.StatusNotModified { + if resp2.StatusCode < 200 || resp2.StatusCode > 299 { + return resp2, temporaryHTTPStatusCodes[resp2.StatusCode], nil + } + } + return resp2, false, nil + }() + + if retry { + if start.IsZero() { + start = time.Now() + } else if d := time.Since(start) + nextSleep; d >= t.Cfg.Timeout() { + msg := "" + if resp != nil { + msg = resp.Status + } + err := toHTTPError(fmt.Errorf("retry timeout (configured to %s) fetching remote resource: %s", t.Cfg.Timeout(), msg), resp, req.Method != "HEAD", nil) + return resp, err + } + time.Sleep(nextSleep) + if nextSleep < nextSleepLimit { + nextSleep *= 2 + } + continue + } + + return + } +} + +// We need to send the redirect responses back to the HTTP client from RoundTrip, +// but we don't want to cache them. +func shouldCache(statusCode int) bool { + switch statusCode { + case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect: + return false + } + return true +} diff --git a/resources/resource_factories/create/remote_test.go b/resources/resource_factories/create/remote_test.go new file mode 100644 index 000000000..293845107 --- /dev/null +++ b/resources/resource_factories/create/remote_test.go @@ -0,0 +1,136 @@ +// Copyright 2021 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 create + +import ( + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestDecodeRemoteOptions(t *testing.T) { + t.Parallel() + + c := qt.New(t) + + for _, test := range []struct { + name string + args map[string]any + want fromRemoteOptions + wantErr bool + }{ + { + "POST", + map[string]any{ + "meThod": "PoST", + "headers": map[string]any{ + "foo": "bar", + }, + }, + fromRemoteOptions{ + Method: "POST", + Headers: map[string]any{ + "foo": "bar", + }, + }, + false, + }, + { + "Body", + map[string]any{ + "meThod": "POST", + "body": []byte("foo"), + }, + fromRemoteOptions{ + Method: "POST", + Body: []byte("foo"), + }, + false, + }, + { + "Body, string", + map[string]any{ + "meThod": "POST", + "body": "foo", + }, + fromRemoteOptions{ + Method: "POST", + Body: []byte("foo"), + }, + false, + }, + } { + c.Run(test.name, func(c *qt.C) { + got, err := decodeRemoteOptions(test.args) + isErr := qt.IsNil + if test.wantErr { + isErr = qt.IsNotNil + } + + c.Assert(err, isErr) + c.Assert(got, qt.DeepEquals, test.want) + }) + } +} + +func TestOptionsNewRequest(t *testing.T) { + t.Parallel() + + c := qt.New(t) + + opts := fromRemoteOptions{ + Method: "GET", + Body: []byte("foo"), + } + + req, err := opts.NewRequest("https://example.com/api") + + c.Assert(err, qt.IsNil) + c.Assert(req.Method, qt.Equals, "GET") + c.Assert(req.Header["User-Agent"], qt.DeepEquals, []string{"Hugo Static Site Generator"}) + + opts = fromRemoteOptions{ + Method: "GET", + Body: []byte("foo"), + Headers: map[string]any{ + "User-Agent": "foo", + }, + } + + req, err = opts.NewRequest("https://example.com/api") + + c.Assert(err, qt.IsNil) + c.Assert(req.Method, qt.Equals, "GET") + c.Assert(req.Header["User-Agent"], qt.DeepEquals, []string{"foo"}) +} + +func TestRemoteResourceKeys(t *testing.T) { + t.Parallel() + + c := qt.New(t) + + check := func(uri string, optionsm map[string]any, expect1, expect2 string) { + c.Helper() + got1, got2 := remoteResourceKeys(uri, optionsm) + c.Assert(got1, qt.Equals, expect1) + c.Assert(got2, qt.Equals, expect2) + } + + check("foo", nil, "7763396052142361238", "7763396052142361238") + check("foo", map[string]any{"bar": "baz"}, "5783339285578751849", "5783339285578751849") + check("foo", map[string]any{"key": "1234", "bar": "baz"}, "15578353952571222948", "5783339285578751849") + check("foo", map[string]any{"key": "12345", "bar": "baz"}, "14335752410685132726", "5783339285578751849") + check("asdf", map[string]any{"key": "1234", "bar": "asdf"}, "15578353952571222948", "15615023578599429261") + check("asdf", map[string]any{"key": "12345", "bar": "asdf"}, "14335752410685132726", "15615023578599429261") +} diff --git a/resources/resource_metadata.go b/resources/resource_metadata.go index 7bf7479a3..2a4faa315 100644 --- a/resources/resource_metadata.go +++ b/resources/resource_metadata.go @@ -15,126 +15,204 @@ package resources import ( "fmt" + "path/filepath" "strconv" + "strings" "github.com/gohugoio/hugo/hugofs/glob" "github.com/gohugoio/hugo/media" + "github.com/gohugoio/hugo/resources/page/pagemeta" "github.com/gohugoio/hugo/resources/resource" - "github.com/pkg/errors" "github.com/spf13/cast" - "strings" - "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/common/paths" + maps0 "maps" ) var ( - _ metaAssigner = (*genericResource)(nil) - _ metaAssigner = (*imageResource)(nil) - _ metaAssignerProvider = (*resourceAdapter)(nil) + _ mediaTypeAssigner = (*genericResource)(nil) + _ mediaTypeAssigner = (*imageResource)(nil) + _ resource.Staler = (*genericResource)(nil) + _ resource.NameNormalizedProvider = (*genericResource)(nil) ) -type metaAssignerProvider interface { - getMetaAssigner() metaAssigner -} - // metaAssigner allows updating metadata in resources that supports it. type metaAssigner interface { setTitle(title string) setName(name string) + updateParams(params map[string]any) +} + +// metaAssigner allows updating the media type in resources that supports it. +type mediaTypeAssigner interface { setMediaType(mediaType media.Type) - updateParams(params map[string]interface{}) } const counterPlaceHolder = ":counter" +var _ metaAssigner = (*metaResource)(nil) + +// metaResource is a resource with metadata that can be updated. +type metaResource struct { + changed bool + title string + name string + params maps.Params +} + +func (r *metaResource) Name() string { + return r.name +} + +func (r *metaResource) Title() string { + return r.title +} + +func (r *metaResource) Params() maps.Params { + return r.params +} + +func (r *metaResource) setTitle(title string) { + r.title = title + r.changed = true +} + +func (r *metaResource) setName(name string) { + r.name = name + r.changed = true +} + +func (r *metaResource) updateParams(params map[string]any) { + if r.params == nil { + r.params = make(map[string]any) + } + maps0.Copy(r.params, params) + r.changed = true +} + +// cloneWithMetadataFromResourceConfigIfNeeded clones the given resource with the given metadata if the resource supports it. +func cloneWithMetadataFromResourceConfigIfNeeded(rc *pagemeta.ResourceConfig, r resource.Resource) resource.Resource { + wmp, ok := r.(resource.WithResourceMetaProvider) + if !ok { + return r + } + + if rc.Name == "" && rc.Title == "" && len(rc.Params) == 0 { + // No metadata. + return r + } + + if rc.Title == "" { + rc.Title = rc.Name + } + + wrapped := &metaResource{ + name: rc.Name, + title: rc.Title, + params: rc.Params, + } + + return wmp.WithResourceMeta(wrapped) +} + +// CloneWithMetadataFromMapIfNeeded clones the given resource with the given metadata if the resource supports it. +func CloneWithMetadataFromMapIfNeeded(m []map[string]any, r resource.Resource) resource.Resource { + wmp, ok := r.(resource.WithResourceMetaProvider) + if !ok { + return r + } + + wrapped := &metaResource{ + name: r.Name(), + title: r.Title(), + params: r.Params(), + } + + assignMetadata(m, wrapped) + if !wrapped.changed { + return r + } + + return wmp.WithResourceMeta(wrapped) +} + // AssignMetadata assigns the given metadata to those resources that supports updates // and matching by wildcard given in `src` using `filepath.Match` with lower cased values. // This assignment is additive, but the most specific match needs to be first. // The `name` and `title` metadata field support shell-matched collection it got a match in. // See https://golang.org/pkg/path/#Match -func AssignMetadata(metadata []map[string]interface{}, resources ...resource.Resource) error { +func assignMetadata(metadata []map[string]any, ma *metaResource) error { counters := make(map[string]int) - for _, r := range resources { - var ma metaAssigner - mp, ok := r.(metaAssignerProvider) - if ok { - ma = mp.getMetaAssigner() - } else { - ma, ok = r.(metaAssigner) - if !ok { - continue - } + var ( + nameSet, titleSet bool + nameCounter, titleCounter = 0, 0 + nameCounterFound, titleCounterFound bool + resourceSrcKey = strings.ToLower(ma.Name()) + ) + + for _, meta := range metadata { + src, found := meta["src"] + if !found { + return fmt.Errorf("missing 'src' in metadata for resource") } - var ( - nameSet, titleSet bool - nameCounter, titleCounter = 0, 0 - nameCounterFound, titleCounterFound bool - resourceSrcKey = strings.ToLower(r.Name()) - ) + srcKey := strings.ToLower(cast.ToString(src)) - for _, meta := range metadata { - src, found := meta["src"] - if !found { - return fmt.Errorf("missing 'src' in metadata for resource") - } + glob, err := glob.GetGlob(srcKey) + if err != nil { + return fmt.Errorf("failed to match resource with metadata: %w", err) + } - srcKey := strings.ToLower(cast.ToString(src)) + match := glob.Match(resourceSrcKey) - glob, err := glob.GetGlob(srcKey) - if err != nil { - return errors.Wrap(err, "failed to match resource with metadata") - } - - match := glob.Match(resourceSrcKey) - - if match { - if !nameSet { - name, found := meta["name"] - if found { - name := cast.ToString(name) - if !nameCounterFound { - nameCounterFound = strings.Contains(name, counterPlaceHolder) - } - if nameCounterFound && nameCounter == 0 { - counterKey := "name_" + srcKey - nameCounter = counters[counterKey] + 1 - counters[counterKey] = nameCounter - } - - ma.setName(replaceResourcePlaceholders(name, nameCounter)) - nameSet = true - } - } - - if !titleSet { - title, found := meta["title"] - if found { - title := cast.ToString(title) - if !titleCounterFound { - titleCounterFound = strings.Contains(title, counterPlaceHolder) - } - if titleCounterFound && titleCounter == 0 { - counterKey := "title_" + srcKey - titleCounter = counters[counterKey] + 1 - counters[counterKey] = titleCounter - } - ma.setTitle((replaceResourcePlaceholders(title, titleCounter))) - titleSet = true - } - } - - params, found := meta["params"] + if match { + if !nameSet { + name, found := meta["name"] if found { - m := maps.ToStringMap(params) - // Needed for case insensitive fetching of params values - maps.ToLower(m) - ma.updateParams(m) + name := cast.ToString(name) + // Bundled resources in sub folders are relative paths with forward slashes. Make sure any renames also matches that format: + name = paths.TrimLeading(filepath.ToSlash(name)) + if !nameCounterFound { + nameCounterFound = strings.Contains(name, counterPlaceHolder) + } + if nameCounterFound && nameCounter == 0 { + counterKey := "name_" + srcKey + nameCounter = counters[counterKey] + 1 + counters[counterKey] = nameCounter + } + + ma.setName(replaceResourcePlaceholders(name, nameCounter)) + nameSet = true } } + + if !titleSet { + title, found := meta["title"] + if found { + title := cast.ToString(title) + if !titleCounterFound { + titleCounterFound = strings.Contains(title, counterPlaceHolder) + } + if titleCounterFound && titleCounter == 0 { + counterKey := "title_" + srcKey + titleCounter = counters[counterKey] + 1 + counters[counterKey] = titleCounter + } + ma.setTitle((replaceResourcePlaceholders(title, titleCounter))) + titleSet = true + } + } + + params, found := meta["params"] + if found { + m := maps.ToStringMap(params) + // Needed for case insensitive fetching of params values + maps.PrepareParams(m) + ma.updateParams(m) + } } } diff --git a/resources/resource_metadata_test.go b/resources/resource_metadata_test.go deleted file mode 100644 index c79a50021..000000000 --- a/resources/resource_metadata_test.go +++ /dev/null @@ -1,231 +0,0 @@ -// Copyright 2019 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 resources - -import ( - "testing" - - "github.com/gohugoio/hugo/media" - "github.com/gohugoio/hugo/resources/resource" - - qt "github.com/frankban/quicktest" -) - -func TestAssignMetadata(t *testing.T) { - c := qt.New(t) - spec := newTestResourceSpec(specDescriptor{c: c}) - - var foo1, foo2, foo3, logo1, logo2, logo3 resource.Resource - var resources resource.Resources - - for _, this := range []struct { - metaData []map[string]interface{} - assertFunc func(err error) - }{ - {[]map[string]interface{}{ - { - "title": "My Resource", - "name": "My Name", - "src": "*", - }, - }, func(err error) { - c.Assert(logo1.Title(), qt.Equals, "My Resource") - c.Assert(logo1.Name(), qt.Equals, "My Name") - c.Assert(foo2.Name(), qt.Equals, "My Name") - - }}, - {[]map[string]interface{}{ - { - "title": "My Logo", - "src": "*loGo*", - }, - { - "title": "My Resource", - "name": "My Name", - "src": "*", - }, - }, func(err error) { - c.Assert(logo1.Title(), qt.Equals, "My Logo") - c.Assert(logo2.Title(), qt.Equals, "My Logo") - c.Assert(logo1.Name(), qt.Equals, "My Name") - c.Assert(foo2.Name(), qt.Equals, "My Name") - c.Assert(foo3.Name(), qt.Equals, "My Name") - c.Assert(foo3.Title(), qt.Equals, "My Resource") - - }}, - {[]map[string]interface{}{ - { - "title": "My Logo", - "src": "*loGo*", - "params": map[string]interface{}{ - "Param1": true, - "icon": "logo", - }, - }, - { - "title": "My Resource", - "src": "*", - "params": map[string]interface{}{ - "Param2": true, - "icon": "resource", - }, - }, - }, func(err error) { - c.Assert(err, qt.IsNil) - c.Assert(logo1.Title(), qt.Equals, "My Logo") - c.Assert(foo3.Title(), qt.Equals, "My Resource") - _, p1 := logo2.Params()["param1"] - _, p2 := foo2.Params()["param2"] - _, p1_2 := foo2.Params()["param1"] - _, p2_2 := logo2.Params()["param2"] - - icon1 := logo2.Params()["icon"] - icon2 := foo2.Params()["icon"] - - c.Assert(p1, qt.Equals, true) - c.Assert(p2, qt.Equals, true) - - // Check merge - c.Assert(p2_2, qt.Equals, true) - c.Assert(p1_2, qt.Equals, false) - - c.Assert(icon1, qt.Equals, "logo") - c.Assert(icon2, qt.Equals, "resource") - - }}, - {[]map[string]interface{}{ - { - "name": "Logo Name #:counter", - "src": "*logo*", - }, - { - "title": "Resource #:counter", - "name": "Name #:counter", - "src": "*", - }, - }, func(err error) { - c.Assert(err, qt.IsNil) - c.Assert(logo2.Title(), qt.Equals, "Resource #2") - c.Assert(logo2.Name(), qt.Equals, "Logo Name #1") - c.Assert(logo1.Title(), qt.Equals, "Resource #4") - c.Assert(logo1.Name(), qt.Equals, "Logo Name #2") - c.Assert(foo2.Title(), qt.Equals, "Resource #1") - c.Assert(foo1.Title(), qt.Equals, "Resource #3") - c.Assert(foo1.Name(), qt.Equals, "Name #2") - c.Assert(foo3.Title(), qt.Equals, "Resource #5") - - c.Assert(resources.GetMatch("logo name #1*"), qt.Equals, logo2) - - }}, - {[]map[string]interface{}{ - { - "title": "Third Logo #:counter", - "src": "logo3.png", - }, - { - "title": "Other Logo #:counter", - "name": "Name #:counter", - "src": "logo*", - }, - }, func(err error) { - c.Assert(err, qt.IsNil) - c.Assert(logo3.Title(), qt.Equals, "Third Logo #1") - c.Assert(logo3.Name(), qt.Equals, "Name #3") - c.Assert(logo2.Title(), qt.Equals, "Other Logo #1") - c.Assert(logo2.Name(), qt.Equals, "Name #1") - c.Assert(logo1.Title(), qt.Equals, "Other Logo #2") - c.Assert(logo1.Name(), qt.Equals, "Name #2") - - }}, - {[]map[string]interface{}{ - { - "title": "Third Logo", - "src": "logo3.png", - }, - { - "title": "Other Logo #:counter", - "name": "Name #:counter", - "src": "logo*", - }, - }, func(err error) { - c.Assert(err, qt.IsNil) - c.Assert(logo3.Title(), qt.Equals, "Third Logo") - c.Assert(logo3.Name(), qt.Equals, "Name #3") - c.Assert(logo2.Title(), qt.Equals, "Other Logo #1") - c.Assert(logo2.Name(), qt.Equals, "Name #1") - c.Assert(logo1.Title(), qt.Equals, "Other Logo #2") - c.Assert(logo1.Name(), qt.Equals, "Name #2") - - }}, - {[]map[string]interface{}{ - { - "name": "third-logo", - "src": "logo3.png", - }, - { - "title": "Logo #:counter", - "name": "Name #:counter", - "src": "logo*", - }, - }, func(err error) { - c.Assert(err, qt.IsNil) - c.Assert(logo3.Title(), qt.Equals, "Logo #3") - c.Assert(logo3.Name(), qt.Equals, "third-logo") - c.Assert(logo2.Title(), qt.Equals, "Logo #1") - c.Assert(logo2.Name(), qt.Equals, "Name #1") - c.Assert(logo1.Title(), qt.Equals, "Logo #2") - c.Assert(logo1.Name(), qt.Equals, "Name #2") - - }}, - {[]map[string]interface{}{ - { - "title": "Third Logo #:counter", - }, - }, func(err error) { - // Missing src - c.Assert(err, qt.Not(qt.IsNil)) - - }}, - {[]map[string]interface{}{ - { - "title": "Title", - "src": "[]", - }, - }, func(err error) { - // Invalid pattern - c.Assert(err, qt.Not(qt.IsNil)) - - }}, - } { - - foo2 = spec.newGenericResource(nil, nil, nil, "/b/foo2.css", "foo2.css", media.CSSType) - logo2 = spec.newGenericResource(nil, nil, nil, "/b/Logo2.png", "Logo2.png", pngType) - foo1 = spec.newGenericResource(nil, nil, nil, "/a/foo1.css", "foo1.css", media.CSSType) - logo1 = spec.newGenericResource(nil, nil, nil, "/a/logo1.png", "logo1.png", pngType) - foo3 = spec.newGenericResource(nil, nil, nil, "/b/foo3.css", "foo3.css", media.CSSType) - logo3 = spec.newGenericResource(nil, nil, nil, "/b/logo3.png", "logo3.png", pngType) - - resources = resource.Resources{ - foo2, - logo2, - foo1, - logo1, - foo3, - logo3, - } - - this.assertFunc(AssignMetadata(this.metaData, resources...)) - } - -} diff --git a/resources/resource_spec.go b/resources/resource_spec.go index a992df355..806a5ff8d 100644 --- a/resources/resource_spec.go +++ b/resources/resource_spec.go @@ -14,302 +14,218 @@ package resources import ( - "errors" "fmt" - "mime" - "os" "path" - "path/filepath" - "strings" + "sync" - "github.com/gohugoio/hugo/hugofs" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/config/allconfig" + "github.com/gohugoio/hugo/lazy" + "github.com/gohugoio/hugo/output" + "github.com/gohugoio/hugo/resources/internal" + "github.com/gohugoio/hugo/resources/jsconfig" + "github.com/gohugoio/hugo/resources/page/pagemeta" + + "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/common/hexec" + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/common/paths" + "github.com/gohugoio/hugo/common/types" + + "github.com/gohugoio/hugo/identity" "github.com/gohugoio/hugo/helpers" + "github.com/gohugoio/hugo/resources/postpub" + "github.com/gohugoio/hugo/cache/dynacache" "github.com/gohugoio/hugo/cache/filecache" - "github.com/gohugoio/hugo/common/loggers" "github.com/gohugoio/hugo/media" - "github.com/gohugoio/hugo/output" "github.com/gohugoio/hugo/resources/images" "github.com/gohugoio/hugo/resources/page" "github.com/gohugoio/hugo/resources/resource" - "github.com/gohugoio/hugo/tpl" - "github.com/spf13/afero" ) func NewSpec( s *helpers.PathSpec, + common *SpecCommon, // may be nil fileCaches filecache.Caches, - logger *loggers.Logger, - outputFormats output.Formats, - mimeTypes media.Types) (*Spec, error) { + memCache *dynacache.Cache, + incr identity.Incrementer, + logger loggers.Logger, + errorHandler herrors.ErrorSender, + execHelper *hexec.Exec, + buildClosers types.CloseAdder, + rebuilder identity.SignalRebuilder, +) (*Spec, error) { + conf := s.Cfg.GetConfig().(*allconfig.Config) + imgConfig := conf.Imaging - imgConfig, err := images.DecodeConfig(s.Cfg.GetStringMap("imaging")) + imagesWarnl := logger.WarnCommand("images") + + imaging, err := images.NewImageProcessor(imagesWarnl, imgConfig) if err != nil { return nil, err } - imaging, err := images.NewImageProcessor(imgConfig) - if err != nil { - return nil, err + if incr == nil { + incr = &identity.IncrementByOne{} } if logger == nil { - logger = loggers.NewErrorLogger() + logger = loggers.NewDefault() } - permalinks, err := page.NewPermalinkExpander(s) + permalinks, err := page.NewPermalinkExpander(s.URLize, conf.Permalinks) if err != nil { return nil, err } - rs := &Spec{PathSpec: s, - Logger: logger, - imaging: imaging, - MediaTypes: mimeTypes, - OutputFormats: outputFormats, - Permalinks: permalinks, - FileCaches: fileCaches, - imageCache: newImageCache( + if common == nil { + common = &SpecCommon{ + incr: incr, + FileCaches: fileCaches, + PostBuildAssets: &PostBuildAssets{ + PostProcessResources: make(map[string]postpub.PostPublishedResource), + JSConfigBuilder: jsconfig.NewBuilder(), + }, + } + } + + rs := &Spec{ + PathSpec: s, + Logger: logger, + ErrorSender: errorHandler, + BuildClosers: buildClosers, + Rebuilder: rebuilder, + imaging: imaging, + ImageCache: newImageCache( fileCaches.ImageCache(), - + memCache, s, - )} + ), + ExecHelper: execHelper, - rs.ResourceCache = newResourceCache(rs) + Permalinks: permalinks, + + SpecCommon: common, + } + + rs.ResourceCache = newResourceCache(rs, memCache) return rs, nil - } type Spec struct { *helpers.PathSpec - MediaTypes media.Types - OutputFormats output.Formats - - Logger *loggers.Logger - - TextTemplates tpl.TemplateParseFinder + Logger loggers.Logger + ErrorSender herrors.ErrorSender + BuildClosers types.CloseAdder + Rebuilder identity.SignalRebuilder Permalinks page.PermalinkExpander + ImageCache *ImageCache + // Holds default filter settings etc. imaging *images.ImageProcessor - imageCache *imageCache + ExecHelper *hexec.Exec + + *SpecCommon +} + +// The parts of Spec that's common for all sites. +type SpecCommon struct { + incr identity.Incrementer ResourceCache *ResourceCache FileCaches filecache.Caches + + // Assets used after the build is done. + // This is shared between all sites. + *PostBuildAssets } -func (r *Spec) New(fd ResourceSourceDescriptor) (resource.Resource, error) { - return r.newResourceFor(fd) +type PostBuildAssets struct { + postProcessMu sync.RWMutex + PostProcessResources map[string]postpub.PostPublishedResource + JSConfigBuilder *jsconfig.Builder } -func (r *Spec) CacheStats() string { - r.imageCache.mu.RLock() - defer r.imageCache.mu.RUnlock() +func (r *Spec) NewResourceWrapperFromResourceConfig(rc *pagemeta.ResourceConfig) (resource.Resource, error) { + content := rc.Content + switch r := content.Value.(type) { + case resource.Resource: + return cloneWithMetadataFromResourceConfigIfNeeded(rc, r), nil + default: + return nil, fmt.Errorf("failed to create resource for path %q, expected a resource.Resource, got %T", rc.PathInfo.Path(), content.Value) + } +} - s := fmt.Sprintf("Cache entries: %d", len(r.imageCache.store)) - - count := 0 - for k := range r.imageCache.store { - if count > 5 { - break - } - s += "\n" + k - count++ +// NewResource creates a new Resource from the given ResourceSourceDescriptor. +func (r *Spec) NewResource(rd ResourceSourceDescriptor) (resource.Resource, error) { + if err := rd.init(r); err != nil { + return nil, err } - return s + dir, name := path.Split(rd.TargetPath) + dir = paths.ToSlashPreserveLeading(dir) + if dir == "/" { + dir = "" + } + rp := internal.ResourcePaths{ + File: name, + Dir: dir, + BaseDirTarget: rd.BasePathTargetPath, + BaseDirLink: rd.BasePathRelPermalink, + TargetBasePaths: rd.TargetBasePaths, + } + + isImage := rd.MediaType.MainType == "image" + var imgFormat images.Format + if isImage { + imgFormat, isImage = images.ImageFormatFromMediaSubType(rd.MediaType.SubType) + } + + gr := &genericResource{ + Staler: &AtomicStaler{}, + h: &resourceHash{}, + publishInit: &lazy.OnceMore{}, + keyInit: &sync.Once{}, + includeHashInKey: isImage, + paths: rp, + spec: r, + sd: rd, + params: rd.Params, + name: rd.NameOriginal, + title: rd.Title, + } + + if isImage { + ir := &imageResource{ + Image: images.NewImage(imgFormat, r.imaging, nil, gr), + baseResource: gr, + } + ir.root = ir + return newResourceAdapter(gr.spec, rd.LazyPublish, ir), nil + + } + + return newResourceAdapter(gr.spec, rd.LazyPublish, gr), nil } -func (r *Spec) ClearCaches() { - r.imageCache.clear() - r.ResourceCache.clear() +func (r *Spec) MediaTypes() media.Types { + return r.Cfg.GetConfigSection("mediaTypes").(media.Types) } -func (r *Spec) DeleteCacheByPrefix(prefix string) { - r.imageCache.deleteByPrefix(prefix) +func (r *Spec) OutputFormats() output.Formats { + return r.Cfg.GetConfigSection("outputFormats").(output.Formats) } -// TODO(bep) unify -func (r *Spec) IsInImageCache(key string) bool { - // This is used for cache pruning. We currently only have images, but we could - // imagine expanding on this. - return r.imageCache.isInCache(key) +func (r *Spec) BuildConfig() config.BuildConfig { + return r.Cfg.GetConfigSection("build").(config.BuildConfig) } func (s *Spec) String() string { return "spec" } - -// TODO(bep) clean up below -func (r *Spec) newGenericResource(sourceFs afero.Fs, - targetPathBuilder func() page.TargetPaths, - osFileInfo os.FileInfo, - sourceFilename, - baseFilename string, - mediaType media.Type) *genericResource { - return r.newGenericResourceWithBase( - sourceFs, - nil, - nil, - targetPathBuilder, - osFileInfo, - sourceFilename, - baseFilename, - mediaType, - ) - -} - -func (r *Spec) newGenericResourceWithBase( - sourceFs afero.Fs, - openReadSeekerCloser resource.OpenReadSeekCloser, - targetPathBaseDirs []string, - targetPathBuilder func() page.TargetPaths, - osFileInfo os.FileInfo, - sourceFilename, - baseFilename string, - mediaType media.Type) *genericResource { - - if osFileInfo != nil && osFileInfo.IsDir() { - panic(fmt.Sprintf("dirs not supported resource types: %v", osFileInfo)) - } - - // This value is used both to construct URLs and file paths, but start - // with a Unix-styled path. - baseFilename = helpers.ToSlashTrimLeading(baseFilename) - fpath, fname := path.Split(baseFilename) - - var resourceType string - if mediaType.MainType == "image" { - resourceType = mediaType.MainType - } else { - resourceType = mediaType.SubType - } - - pathDescriptor := &resourcePathDescriptor{ - baseTargetPathDirs: helpers.UniqueStringsReuse(targetPathBaseDirs), - targetPathBuilder: targetPathBuilder, - relTargetDirFile: dirFile{dir: fpath, file: fname}, - } - - var fim hugofs.FileMetaInfo - if osFileInfo != nil { - fim = osFileInfo.(hugofs.FileMetaInfo) - } - - gfi := &resourceFileInfo{ - fi: fim, - openReadSeekerCloser: openReadSeekerCloser, - sourceFs: sourceFs, - sourceFilename: sourceFilename, - h: &resourceHash{}, - } - - g := &genericResource{ - resourceFileInfo: gfi, - resourcePathDescriptor: pathDescriptor, - mediaType: mediaType, - resourceType: resourceType, - spec: r, - params: make(map[string]interface{}), - name: baseFilename, - title: baseFilename, - resourceContent: &resourceContent{}, - } - - return g - -} - -func (r *Spec) newResource(sourceFs afero.Fs, fd ResourceSourceDescriptor) (resource.Resource, error) { - fi := fd.FileInfo - var sourceFilename string - - if fd.OpenReadSeekCloser != nil { - } else if fd.SourceFilename != "" { - var err error - fi, err = sourceFs.Stat(fd.SourceFilename) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - sourceFilename = fd.SourceFilename - } else { - sourceFilename = fd.SourceFile.Filename() - } - - if fd.RelTargetFilename == "" { - fd.RelTargetFilename = sourceFilename - } - - ext := strings.ToLower(filepath.Ext(fd.RelTargetFilename)) - mimeType, found := r.MediaTypes.GetFirstBySuffix(strings.TrimPrefix(ext, ".")) - // TODO(bep) we need to handle these ambigous types better, but in this context - // we most likely want the application/xml type. - if mimeType.Suffix() == "xml" && mimeType.SubType == "rss" { - mimeType, found = r.MediaTypes.GetByType("application/xml") - } - - if !found { - // A fallback. Note that mime.TypeByExtension is slow by Hugo standards, - // so we should configure media types to avoid this lookup for most - // situations. - mimeStr := mime.TypeByExtension(ext) - if mimeStr != "" { - mimeType, _ = media.FromStringAndExt(mimeStr, ext) - } - } - - gr := r.newGenericResourceWithBase( - sourceFs, - fd.OpenReadSeekCloser, - fd.TargetBasePaths, - fd.TargetPaths, - fi, - sourceFilename, - fd.RelTargetFilename, - mimeType) - - if mimeType.MainType == "image" { - imgFormat, ok := images.ImageFormatFromExt(ext) - if ok { - ir := &imageResource{ - Image: images.NewImage(imgFormat, r.imaging, nil, gr), - baseResource: gr, - } - ir.root = ir - return newResourceAdapter(gr.spec, fd.LazyPublish, ir), nil - } - - } - - return newResourceAdapter(gr.spec, fd.LazyPublish, gr), nil - -} - -func (r *Spec) newResourceFor(fd ResourceSourceDescriptor) (resource.Resource, error) { - if fd.OpenReadSeekCloser == nil { - if fd.SourceFile != nil && fd.SourceFilename != "" { - return nil, errors.New("both SourceFile and AbsSourceFilename provided") - } else if fd.SourceFile == nil && fd.SourceFilename == "" { - return nil, errors.New("either SourceFile or AbsSourceFilename must be provided") - } - } - - if fd.RelTargetFilename == "" { - fd.RelTargetFilename = fd.Filename() - } - - if len(fd.TargetBasePaths) == 0 { - // If not set, we publish the same resource to all hosts. - fd.TargetBasePaths = r.MultihostTargetBasePaths - } - - return r.newResource(fd.Fs, fd) -} diff --git a/resources/resource_spec_test.go b/resources/resource_spec_test.go new file mode 100644 index 000000000..67fe09992 --- /dev/null +++ b/resources/resource_spec_test.go @@ -0,0 +1,48 @@ +// Copyright 2024 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 resources_test + +import ( + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/common/hugio" + "github.com/gohugoio/hugo/identity" + "github.com/gohugoio/hugo/resources" +) + +func TestNewResource(t *testing.T) { + c := qt.New(t) + + spec := newTestResourceSpec(specDescriptor{c: c}) + + open := hugio.NewOpenReadSeekCloser(hugio.NewReadSeekerNoOpCloserFromString("content")) + + rd := resources.ResourceSourceDescriptor{ + OpenReadSeekCloser: open, + TargetPath: "a/b.txt", + BasePathRelPermalink: "c/d", + BasePathTargetPath: "e/f", + GroupIdentity: identity.Anonymous, + } + + r, err := spec.NewResource(rd) + c.Assert(err, qt.IsNil) + c.Assert(r, qt.Not(qt.IsNil)) + c.Assert(r.RelPermalink(), qt.Equals, "/c/d/a/b.txt") + + info := resources.GetTestInfoForResource(r) + c.Assert(info.Paths.TargetLink(), qt.Equals, "/c/d/a/b.txt") + c.Assert(info.Paths.TargetPath(), qt.Equals, "/e/f/a/b.txt") +} diff --git a/resources/resource_test.go b/resources/resource_test.go index 7a0b8069d..d07770456 100644 --- a/resources/resource_test.go +++ b/resources/resource_test.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Hugo Authors. All rights reserved. +// Copyright 2024 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,265 +14,41 @@ package resources import ( - "fmt" - "math/rand" - "path/filepath" - "strings" + "os" "testing" - "time" - - "github.com/spf13/afero" - - "github.com/gohugoio/hugo/resources/resource" - - "github.com/gohugoio/hugo/media" qt "github.com/frankban/quicktest" ) -func TestGenericResource(t *testing.T) { +func TestAtomicStaler(t *testing.T) { c := qt.New(t) - spec := newTestResourceSpec(specDescriptor{c: c}) - r := spec.newGenericResource(nil, nil, nil, "/a/foo.css", "foo.css", media.CSSType) - - c.Assert(r.Permalink(), qt.Equals, "https://example.com/foo.css") - c.Assert(r.RelPermalink(), qt.Equals, "/foo.css") - c.Assert(r.ResourceType(), qt.Equals, "css") - -} - -func TestGenericResourceWithLinkFacory(t *testing.T) { - c := qt.New(t) - spec := newTestResourceSpec(specDescriptor{c: c}) - - factory := newTargetPaths("/foo") - - r := spec.newGenericResource(nil, factory, nil, "/a/foo.css", "foo.css", media.CSSType) - - c.Assert(r.Permalink(), qt.Equals, "https://example.com/foo/foo.css") - c.Assert(r.RelPermalink(), qt.Equals, "/foo/foo.css") - c.Assert(r.Key(), qt.Equals, "/foo/foo.css") - c.Assert(r.ResourceType(), qt.Equals, "css") -} - -func TestNewResourceFromFilename(t *testing.T) { - c := qt.New(t) - spec := newTestResourceSpec(specDescriptor{c: c}) - - writeSource(t, spec.Fs, "content/a/b/logo.png", "image") - writeSource(t, spec.Fs, "content/a/b/data.json", "json") - - bfs := afero.NewBasePathFs(spec.Fs.Source, "content") - - r, err := spec.New(ResourceSourceDescriptor{Fs: bfs, SourceFilename: "a/b/logo.png"}) - - c.Assert(err, qt.IsNil) - c.Assert(r, qt.Not(qt.IsNil)) - c.Assert(r.ResourceType(), qt.Equals, "image") - c.Assert(r.RelPermalink(), qt.Equals, "/a/b/logo.png") - c.Assert(r.Permalink(), qt.Equals, "https://example.com/a/b/logo.png") - - r, err = spec.New(ResourceSourceDescriptor{Fs: bfs, SourceFilename: "a/b/data.json"}) - - c.Assert(err, qt.IsNil) - c.Assert(r, qt.Not(qt.IsNil)) - c.Assert(r.ResourceType(), qt.Equals, "json") - -} - -func TestNewResourceFromFilenameSubPathInBaseURL(t *testing.T) { - c := qt.New(t) - spec := newTestResourceSpec(specDescriptor{c: c, baseURL: "https://example.com/docs"}) - - writeSource(t, spec.Fs, "content/a/b/logo.png", "image") - bfs := afero.NewBasePathFs(spec.Fs.Source, "content") - - fmt.Println() - r, err := spec.New(ResourceSourceDescriptor{Fs: bfs, SourceFilename: filepath.FromSlash("a/b/logo.png")}) - - c.Assert(err, qt.IsNil) - c.Assert(r, qt.Not(qt.IsNil)) - c.Assert(r.ResourceType(), qt.Equals, "image") - c.Assert(r.RelPermalink(), qt.Equals, "/docs/a/b/logo.png") - c.Assert(r.Permalink(), qt.Equals, "https://example.com/docs/a/b/logo.png") - -} - -var pngType, _ = media.FromStringAndExt("image/png", "png") - -func TestResourcesByType(t *testing.T) { - c := qt.New(t) - spec := newTestResourceSpec(specDescriptor{c: c}) - resources := resource.Resources{ - spec.newGenericResource(nil, nil, nil, "/a/foo1.css", "foo1.css", media.CSSType), - spec.newGenericResource(nil, nil, nil, "/a/logo.png", "logo.css", pngType), - spec.newGenericResource(nil, nil, nil, "/a/foo2.css", "foo2.css", media.CSSType), - spec.newGenericResource(nil, nil, nil, "/a/foo3.css", "foo3.css", media.CSSType)} - - c.Assert(len(resources.ByType("css")), qt.Equals, 3) - c.Assert(len(resources.ByType("image")), qt.Equals, 1) - -} - -func TestResourcesGetByPrefix(t *testing.T) { - c := qt.New(t) - spec := newTestResourceSpec(specDescriptor{c: c}) - resources := resource.Resources{ - spec.newGenericResource(nil, nil, nil, "/a/foo1.css", "foo1.css", media.CSSType), - spec.newGenericResource(nil, nil, nil, "/a/logo1.png", "logo1.png", pngType), - spec.newGenericResource(nil, nil, nil, "/b/Logo2.png", "Logo2.png", pngType), - spec.newGenericResource(nil, nil, nil, "/b/foo2.css", "foo2.css", media.CSSType), - spec.newGenericResource(nil, nil, nil, "/b/foo3.css", "foo3.css", media.CSSType)} - - c.Assert(resources.GetMatch("asdf*"), qt.IsNil) - c.Assert(resources.GetMatch("logo*").RelPermalink(), qt.Equals, "/logo1.png") - c.Assert(resources.GetMatch("loGo*").RelPermalink(), qt.Equals, "/logo1.png") - c.Assert(resources.GetMatch("logo2*").RelPermalink(), qt.Equals, "/Logo2.png") - c.Assert(resources.GetMatch("foo2*").RelPermalink(), qt.Equals, "/foo2.css") - c.Assert(resources.GetMatch("foo1*").RelPermalink(), qt.Equals, "/foo1.css") - c.Assert(resources.GetMatch("foo1*").RelPermalink(), qt.Equals, "/foo1.css") - c.Assert(resources.GetMatch("asdfasdf*"), qt.IsNil) - - c.Assert(len(resources.Match("logo*")), qt.Equals, 2) - c.Assert(len(resources.Match("logo2*")), qt.Equals, 1) - - logo := resources.GetMatch("logo*") - c.Assert(logo.Params(), qt.Not(qt.IsNil)) - c.Assert(logo.Name(), qt.Equals, "logo1.png") - c.Assert(logo.Title(), qt.Equals, "logo1.png") - -} - -func TestResourcesGetMatch(t *testing.T) { - c := qt.New(t) - spec := newTestResourceSpec(specDescriptor{c: c}) - resources := resource.Resources{ - spec.newGenericResource(nil, nil, nil, "/a/foo1.css", "foo1.css", media.CSSType), - spec.newGenericResource(nil, nil, nil, "/a/logo1.png", "logo1.png", pngType), - spec.newGenericResource(nil, nil, nil, "/b/Logo2.png", "Logo2.png", pngType), - spec.newGenericResource(nil, nil, nil, "/b/foo2.css", "foo2.css", media.CSSType), - spec.newGenericResource(nil, nil, nil, "/b/foo3.css", "foo3.css", media.CSSType), - spec.newGenericResource(nil, nil, nil, "/b/c/foo4.css", "c/foo4.css", media.CSSType), - spec.newGenericResource(nil, nil, nil, "/b/c/foo5.css", "c/foo5.css", media.CSSType), - spec.newGenericResource(nil, nil, nil, "/b/c/d/foo6.css", "c/d/foo6.css", media.CSSType), + type test struct { + AtomicStaler } - c.Assert(resources.GetMatch("logo*").RelPermalink(), qt.Equals, "/logo1.png") - c.Assert(resources.GetMatch("loGo*").RelPermalink(), qt.Equals, "/logo1.png") - c.Assert(resources.GetMatch("logo2*").RelPermalink(), qt.Equals, "/Logo2.png") - c.Assert(resources.GetMatch("foo2*").RelPermalink(), qt.Equals, "/foo2.css") - c.Assert(resources.GetMatch("foo1*").RelPermalink(), qt.Equals, "/foo1.css") - c.Assert(resources.GetMatch("foo1*").RelPermalink(), qt.Equals, "/foo1.css") - c.Assert(resources.GetMatch("*/foo*").RelPermalink(), qt.Equals, "/c/foo4.css") - - c.Assert(resources.GetMatch("asdfasdf"), qt.IsNil) - - c.Assert(len(resources.Match("Logo*")), qt.Equals, 2) - c.Assert(len(resources.Match("logo2*")), qt.Equals, 1) - c.Assert(len(resources.Match("c/*")), qt.Equals, 2) - - c.Assert(len(resources.Match("**.css")), qt.Equals, 6) - c.Assert(len(resources.Match("**/*.css")), qt.Equals, 3) - c.Assert(len(resources.Match("c/**/*.css")), qt.Equals, 1) - - // Matches only CSS files in c/ - c.Assert(len(resources.Match("c/**.css")), qt.Equals, 3) - - // Matches all CSS files below c/ (including in c/d/) - c.Assert(len(resources.Match("c/**.css")), qt.Equals, 3) - - // Patterns beginning with a slash will not match anything. - // We could maybe consider trimming that slash, but let's be explicit about this. - // (it is possible for users to do a rename) - // This is analogous to standing in a directory and doing "ls *.*". - c.Assert(len(resources.Match("/c/**.css")), qt.Equals, 0) + var v test + c.Assert(v.StaleVersion(), qt.Equals, uint32(0)) + v.MarkStale() + c.Assert(v.StaleVersion(), qt.Equals, uint32(1)) + v.MarkStale() + c.Assert(v.StaleVersion(), qt.Equals, uint32(2)) } -func BenchmarkResourcesMatch(b *testing.B) { - resources := benchResources(b) - prefixes := []string{"abc*", "jkl*", "nomatch*", "sub/*"} - rnd := rand.New(rand.NewSource(time.Now().Unix())) - - b.RunParallel(func(pb *testing.PB) { - for pb.Next() { - resources.Match(prefixes[rnd.Intn(len(prefixes))]) - } - }) -} - -// This adds a benchmark for the a100 test case as described by Russ Cox here: -// https://research.swtch.com/glob (really interesting article) -// I don't expect Hugo users to "stumble upon" this problem, so this is more to satisfy -// my own curiosity. -func BenchmarkResourcesMatchA100(b *testing.B) { - c := qt.New(b) - spec := newTestResourceSpec(specDescriptor{c: c}) - a100 := strings.Repeat("a", 100) - pattern := "a*a*a*a*a*a*a*a*b" - - resources := resource.Resources{spec.newGenericResource(nil, nil, nil, "/a/"+a100, a100, media.CSSType)} +func BenchmarkHashImage(b *testing.B) { + f, err := os.Open("testdata/sunset.jpg") + if err != nil { + b.Fatal(err) + } + defer f.Close() b.ResetTimer() for i := 0; i < b.N; i++ { - resources.Match(pattern) - } - -} - -func benchResources(b *testing.B) resource.Resources { - c := qt.New(b) - spec := newTestResourceSpec(specDescriptor{c: c}) - var resources resource.Resources - - for i := 0; i < 30; i++ { - name := fmt.Sprintf("abcde%d_%d.css", i%5, i) - resources = append(resources, spec.newGenericResource(nil, nil, nil, "/a/"+name, name, media.CSSType)) - } - - for i := 0; i < 30; i++ { - name := fmt.Sprintf("efghi%d_%d.css", i%5, i) - resources = append(resources, spec.newGenericResource(nil, nil, nil, "/a/"+name, name, media.CSSType)) - } - - for i := 0; i < 30; i++ { - name := fmt.Sprintf("jklmn%d_%d.css", i%5, i) - resources = append(resources, spec.newGenericResource(nil, nil, nil, "/b/sub/"+name, "sub/"+name, media.CSSType)) - } - - return resources - -} - -func BenchmarkAssignMetadata(b *testing.B) { - c := qt.New(b) - spec := newTestResourceSpec(specDescriptor{c: c}) - - for i := 0; i < b.N; i++ { - b.StopTimer() - var resources resource.Resources - var meta = []map[string]interface{}{ - { - "title": "Foo #:counter", - "name": "Foo Name #:counter", - "src": "foo1*", - }, - { - "title": "Rest #:counter", - "name": "Rest Name #:counter", - "src": "*", - }, - } - for i := 0; i < 20; i++ { - name := fmt.Sprintf("foo%d_%d.css", i%5, i) - resources = append(resources, spec.newGenericResource(nil, nil, nil, "/a/"+name, name, media.CSSType)) - } - b.StartTimer() - - if err := AssignMetadata(meta, resources...); err != nil { + _, _, err := hashImage(f) + if err != nil { b.Fatal(err) } - + f.Seek(0, 0) } } diff --git a/resources/resource_transformers/babel/babel.go b/resources/resource_transformers/babel/babel.go new file mode 100644 index 000000000..e00969516 --- /dev/null +++ b/resources/resource_transformers/babel/babel.go @@ -0,0 +1,239 @@ +// Copyright 2020 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 babel + +import ( + "bytes" + "fmt" + "io" + "os" + "path" + "path/filepath" + "regexp" + "strconv" + + "github.com/gohugoio/hugo/common/hexec" + "github.com/gohugoio/hugo/common/loggers" + + "github.com/gohugoio/hugo/common/hugo" + "github.com/gohugoio/hugo/resources/internal" + + "github.com/mitchellh/mapstructure" + + "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/resources" + "github.com/gohugoio/hugo/resources/resource" +) + +// Options from https://babeljs.io/docs/en/options +type Options struct { + Config string // Custom path to config file + + Minified bool + NoComments bool + Compact *bool + Verbose bool + NoBabelrc bool + SourceMap string +} + +// DecodeOptions decodes options to and generates command flags +func DecodeOptions(m map[string]any) (opts Options, err error) { + if m == nil { + return + } + err = mapstructure.WeakDecode(m, &opts) + return +} + +func (opts Options) toArgs() []any { + var args []any + + // external is not a known constant on the babel command line + // .sourceMaps must be a boolean, "inline", "both", or undefined + switch opts.SourceMap { + case "external": + args = append(args, "--source-maps") + case "inline": + args = append(args, "--source-maps=inline") + } + if opts.Minified { + args = append(args, "--minified") + } + if opts.NoComments { + args = append(args, "--no-comments") + } + if opts.Compact != nil { + args = append(args, "--compact="+strconv.FormatBool(*opts.Compact)) + } + if opts.Verbose { + args = append(args, "--verbose") + } + if opts.NoBabelrc { + args = append(args, "--no-babelrc") + } + return args +} + +// Client is the client used to do Babel transformations. +type Client struct { + rs *resources.Spec +} + +// New creates a new Client with the given specification. +func New(rs *resources.Spec) *Client { + return &Client{rs: rs} +} + +type babelTransformation struct { + options Options + rs *resources.Spec +} + +func (t *babelTransformation) Key() internal.ResourceTransformationKey { + return internal.NewResourceTransformationKey("babel", t.options) +} + +// Transform shells out to babel-cli to do the heavy lifting. +// For this to work, you need some additional tools. To install them globally: +// npm install -g @babel/core @babel/cli +// If you want to use presets or plugins such as @babel/preset-env +// Then you should install those globally as well. e.g: +// npm install -g @babel/preset-env +// Instead of installing globally, you can also install everything as a dev-dependency (--save-dev instead of -g) +func (t *babelTransformation) Transform(ctx *resources.ResourceTransformationCtx) error { + const binaryName = "babel" + + ex := t.rs.ExecHelper + + if err := ex.Sec().CheckAllowedExec(binaryName); err != nil { + return err + } + + var configFile string + infol := t.rs.Logger.InfoCommand(binaryName) + infoW := loggers.LevelLoggerToWriter(infol) + + var errBuf bytes.Buffer + + if t.options.Config != "" { + configFile = t.options.Config + } else { + configFile = "babel.config.js" + } + + configFile = filepath.Clean(configFile) + + // We need an absolute filename to the config file. + if !filepath.IsAbs(configFile) { + configFile = t.rs.BaseFs.ResolveJSConfigFile(configFile) + if configFile == "" && t.options.Config != "" { + // Only fail if the user specified config file is not found. + return fmt.Errorf("babel config %q not found", configFile) + } + } + + ctx.ReplaceOutPathExtension(".js") + + var cmdArgs []any + + if configFile != "" { + infol.Logf("use config file %q", configFile) + cmdArgs = []any{"--config-file", configFile} + } + + if optArgs := t.options.toArgs(); len(optArgs) > 0 { + cmdArgs = append(cmdArgs, optArgs...) + } + cmdArgs = append(cmdArgs, "--filename="+ctx.SourcePath) + + // Create compile into a real temp file: + // 1. separate stdout/stderr messages from babel (https://github.com/gohugoio/hugo/issues/8136) + // 2. allow generation and retrieval of external source map. + compileOutput, err := os.CreateTemp("", "compileOut-*.js") + if err != nil { + return err + } + + cmdArgs = append(cmdArgs, "--out-file="+compileOutput.Name()) + stderr := io.MultiWriter(infoW, &errBuf) + cmdArgs = append(cmdArgs, hexec.WithStderr(stderr)) + cmdArgs = append(cmdArgs, hexec.WithStdout(stderr)) + cmdArgs = append(cmdArgs, hexec.WithEnviron(hugo.GetExecEnviron(t.rs.Cfg.BaseConfig().WorkingDir, t.rs.Cfg, t.rs.BaseFs.Assets.Fs))) + + defer func() { + compileOutput.Close() + os.Remove(compileOutput.Name()) + }() + + // ARGA [--no-install babel --config-file /private/var/folders/_g/j3j21hts4fn7__h04w2x8gb40000gn/T/hugo-test-babel812882892/babel.config.js --source-maps --filename=js/main2.js --out-file=/var/folders/_g/j3j21hts4fn7__h04w2x8gb40000gn/T/compileOut-2237820197.js] + // [--no-install babel --config-file /private/var/folders/_g/j3j21hts4fn7__h04w2x8gb40000gn/T/hugo-test-babel332846848/babel.config.js --filename=js/main.js --out-file=/var/folders/_g/j3j21hts4fn7__h04w2x8gb40000gn/T/compileOut-1451390834.js 0x10304ee60 0x10304ed60 0x10304f060] + cmd, err := ex.Npx(binaryName, cmdArgs...) + if err != nil { + if hexec.IsNotFound(err) { + // This may be on a CI server etc. Will fall back to pre-built assets. + return &herrors.FeatureNotAvailableError{Cause: err} + } + return err + } + + stdin, err := cmd.StdinPipe() + if err != nil { + return err + } + + go func() { + defer stdin.Close() + io.Copy(stdin, ctx.From) + }() + + err = cmd.Run() + if err != nil { + if hexec.IsNotFound(err) { + return &herrors.FeatureNotAvailableError{Cause: err} + } + return fmt.Errorf(errBuf.String()+": %w", err) + } + + content, err := io.ReadAll(compileOutput) + if err != nil { + return err + } + + mapFile := compileOutput.Name() + ".map" + if _, err := os.Stat(mapFile); err == nil { + defer os.Remove(mapFile) + sourceMap, err := os.ReadFile(mapFile) + if err != nil { + return err + } + if err = ctx.PublishSourceMap(string(sourceMap)); err != nil { + return err + } + targetPath := path.Base(ctx.OutPath) + ".map" + re := regexp.MustCompile(`//# sourceMappingURL=.*\n?`) + content = []byte(re.ReplaceAllString(string(content), "//# sourceMappingURL="+targetPath+"\n")) + } + + ctx.To.Write(content) + + return nil +} + +// Process transforms the given Resource with the Babel processor. +func (c *Client) Process(res resources.ResourceTransformer, options Options) (resource.Resource, error) { + return res.Transform( + &babelTransformation{rs: c.rs, options: options}, + ) +} diff --git a/resources/resource_transformers/babel/babel_integration_test.go b/resources/resource_transformers/babel/babel_integration_test.go new file mode 100644 index 000000000..44a13f103 --- /dev/null +++ b/resources/resource_transformers/babel/babel_integration_test.go @@ -0,0 +1,93 @@ +// Copyright 2021 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 babel_test + +import ( + "testing" + + "github.com/bep/logg" + "github.com/gohugoio/hugo/htesting" + "github.com/gohugoio/hugo/hugolib" +) + +func TestTransformBabel(t *testing.T) { + if !htesting.IsCI() { + t.Skip("Skip long running test when running locally") + } + + files := ` +-- assets/js/main.js -- +/* A Car */ +class Car { + constructor(brand) { + this.carname = brand; + } +} +-- assets/js/main2.js -- +/* A Car2 */ +class Car2 { + constructor(brand) { + this.carname = brand; + } +} +-- babel.config.js -- +console.error("Hugo Environment:", process.env.HUGO_ENVIRONMENT ); + +module.exports = { + presets: ["@babel/preset-env"], +}; +-- config.toml -- +disablekinds = ['taxonomy', 'term', 'page'] +[security] + [security.exec] + allow = ['^npx$', '^babel$'] +-- layouts/index.html -- +{{ $options := dict "noComments" true }} +{{ $transpiled := resources.Get "js/main.js" | babel -}} +Transpiled: {{ $transpiled.Content | safeJS }} + +{{ $transpiled := resources.Get "js/main2.js" | babel (dict "sourceMap" "inline") -}} +Transpiled2: {{ $transpiled.Content | safeJS }} + +{{ $transpiled := resources.Get "js/main2.js" | babel (dict "sourceMap" "external") -}} +Transpiled3: {{ $transpiled.Permalink }} +-- package.json -- +{ + "scripts": {}, + + "devDependencies": { + "@babel/cli": "7.8.4", + "@babel/core": "7.9.0", + "@babel/preset-env": "7.9.5" + } +} + + ` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + NeedsOsFS: true, + NeedsNpmInstall: true, + LogLevel: logg.LevelInfo, + }).Build() + + b.AssertLogContains("babel: Hugo Environment: production") + b.AssertFileContent("public/index.html", `var Car2 =`) + b.AssertFileContent("public/js/main2.js", `var Car2 =`) + b.AssertFileContent("public/js/main2.js.map", `{"version":3,`) + b.AssertFileContent("public/index.html", ` +//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozL`) +} diff --git a/resources/resource_transformers/cssjs/inline_imports.go b/resources/resource_transformers/cssjs/inline_imports.go new file mode 100644 index 000000000..98e3292cd --- /dev/null +++ b/resources/resource_transformers/cssjs/inline_imports.go @@ -0,0 +1,247 @@ +// Copyright 2024 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 cssjs + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "path" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/common/text" + "github.com/gohugoio/hugo/hugofs" + "github.com/gohugoio/hugo/identity" + "github.com/spf13/afero" +) + +const importIdentifier = "@import" + +var ( + cssSyntaxErrorRe = regexp.MustCompile(`> (\d+) \|`) + shouldImportRe = regexp.MustCompile(`^@import ["'](.*?)["'];?\s*(/\*.*\*/)?$`) +) + +type fileOffset struct { + Filename string + Offset int +} + +type importResolver struct { + r io.Reader + inPath string + opts InlineImports + + contentSeen map[string]bool + dependencyManager identity.Manager + linemap map[int]fileOffset + fs afero.Fs + logger loggers.Logger +} + +func newImportResolver(r io.Reader, inPath string, opts InlineImports, fs afero.Fs, logger loggers.Logger, dependencyManager identity.Manager) *importResolver { + return &importResolver{ + r: r, + dependencyManager: dependencyManager, + inPath: inPath, + fs: fs, logger: logger, + linemap: make(map[int]fileOffset), contentSeen: make(map[string]bool), + opts: opts, + } +} + +func (imp *importResolver) contentHash(filename string) ([]byte, string) { + b, err := afero.ReadFile(imp.fs, filename) + if err != nil { + return nil, "" + } + h := sha256.New() + h.Write(b) + return b, hex.EncodeToString(h.Sum(nil)) +} + +func (imp *importResolver) importRecursive( + lineNum int, + content string, + inPath string, +) (int, string, error) { + basePath := path.Dir(inPath) + + var replacements []string + lines := strings.Split(content, "\n") + + trackLine := func(i, offset int, line string) { + // TODO(bep) this is not very efficient. + imp.linemap[i+lineNum] = fileOffset{Filename: inPath, Offset: offset} + } + + i := 0 + for offset, line := range lines { + i++ + lineTrimmed := strings.TrimSpace(line) + column := strings.Index(line, lineTrimmed) + line = lineTrimmed + + if !imp.shouldImport(line) { + trackLine(i, offset, line) + } else { + path := strings.Trim(strings.TrimPrefix(line, importIdentifier), " \"';") + filename := filepath.Join(basePath, path) + imp.dependencyManager.AddIdentity(identity.CleanStringIdentity(filename)) + importContent, hash := imp.contentHash(filename) + + if importContent == nil { + if imp.opts.SkipInlineImportsNotFound { + trackLine(i, offset, line) + continue + } + pos := text.Position{ + Filename: inPath, + LineNumber: offset + 1, + ColumnNumber: column + 1, + } + return 0, "", herrors.NewFileErrorFromFileInPos(fmt.Errorf("failed to resolve CSS @import \"%s\"", filename), pos, imp.fs, nil) + } + + i-- + + if imp.contentSeen[hash] { + i++ + // Just replace the line with an empty string. + replacements = append(replacements, []string{line, ""}...) + trackLine(i, offset, "IMPORT") + continue + } + + imp.contentSeen[hash] = true + + // Handle recursive imports. + l, nested, err := imp.importRecursive(i+lineNum, string(importContent), filepath.ToSlash(filename)) + if err != nil { + return 0, "", err + } + + trackLine(i, offset, line) + + i += l + + importContent = []byte(nested) + + replacements = append(replacements, []string{line, string(importContent)}...) + } + } + + if len(replacements) > 0 { + repl := strings.NewReplacer(replacements...) + content = repl.Replace(content) + } + + return i, content, nil +} + +func (imp *importResolver) resolve() (io.Reader, error) { + content, err := io.ReadAll(imp.r) + if err != nil { + return nil, err + } + + contents := string(content) + + _, newContent, err := imp.importRecursive(0, contents, imp.inPath) + if err != nil { + return nil, err + } + + return strings.NewReader(newContent), nil +} + +// See https://www.w3schools.com/cssref/pr_import_rule.asp +// We currently only support simple file imports, no urls, no media queries. +// So this is OK: +// +// @import "navigation.css"; +// +// This is not: +// +// @import url("navigation.css"); +// @import "mobstyle.css" screen and (max-width: 768px); +func (imp *importResolver) shouldImport(s string) bool { + if !strings.HasPrefix(s, importIdentifier) { + return false + } + if strings.Contains(s, "url(") { + return false + } + + m := shouldImportRe.FindStringSubmatch(s) + if m == nil { + return false + } + + if len(m) != 3 { + return false + } + + if tailwindImportExclude(m[1]) { + return false + } + + return true +} + +func (imp *importResolver) toFileError(output string) error { + inErr := errors.New(output) + + match := cssSyntaxErrorRe.FindStringSubmatch(output) + if match == nil { + return inErr + } + + lineNum, err := strconv.Atoi(match[1]) + if err != nil { + return inErr + } + + file, ok := imp.linemap[lineNum] + if !ok { + return inErr + } + + fi, err := imp.fs.Stat(file.Filename) + if err != nil { + return inErr + } + + meta := fi.(hugofs.FileMetaInfo).Meta() + realFilename := meta.Filename + f, err := meta.Open() + if err != nil { + return inErr + } + defer f.Close() + + ferr := herrors.NewFileErrorFromName(inErr, realFilename) + pos := ferr.Position() + pos.LineNumber = file.Offset + 1 + return ferr.UpdatePosition(pos).UpdateContent(f, nil) + + // return herrors.NewFileErrorFromFile(inErr, file.Filename, realFilename, hugofs.Os, herrors.SimpleLineMatcher) +} diff --git a/resources/resource_transformers/cssjs/inline_imports_test.go b/resources/resource_transformers/cssjs/inline_imports_test.go new file mode 100644 index 000000000..9bcb7f9a3 --- /dev/null +++ b/resources/resource_transformers/cssjs/inline_imports_test.go @@ -0,0 +1,179 @@ +// Copyright 2024 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 cssjs + +import ( + "regexp" + "strings" + "testing" + + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/htesting/hqt" + "github.com/gohugoio/hugo/identity" + + "github.com/gohugoio/hugo/helpers" + + "github.com/spf13/afero" + + qt "github.com/frankban/quicktest" +) + +// Issue 6166 +func TestDecodeOptions(t *testing.T) { + c := qt.New(t) + opts1, err := decodePostCSSOptions(map[string]any{ + "no-map": true, + }) + + c.Assert(err, qt.IsNil) + c.Assert(opts1.NoMap, qt.Equals, true) + + opts2, err := decodePostCSSOptions(map[string]any{ + "noMap": true, + }) + + c.Assert(err, qt.IsNil) + c.Assert(opts2.NoMap, qt.Equals, true) +} + +func TestShouldImport(t *testing.T) { + c := qt.New(t) + var imp *importResolver + + for _, test := range []struct { + input string + expect bool + }{ + {input: `@import "navigation.css";`, expect: true}, + {input: `@import "navigation.css"; /* Using a string */`, expect: true}, + {input: `@import "navigation.css"`, expect: true}, + {input: `@import 'navigation.css';`, expect: true}, + {input: `@import url("navigation.css");`, expect: false}, + {input: `@import url('https://fonts.googleapis.com/css?family=Open+Sans:400,400i,800,800i&display=swap');`, expect: false}, + {input: `@import "printstyle.css" print;`, expect: false}, + } { + c.Assert(imp.shouldImport(test.input), qt.Equals, test.expect) + } +} + +func TestShouldImportExcludes(t *testing.T) { + c := qt.New(t) + var imp *importResolver + + c.Assert(imp.shouldImport(`@import "navigation.css";`), qt.Equals, true) + c.Assert(imp.shouldImport(`@import "tailwindcss";`), qt.Equals, false) + c.Assert(imp.shouldImport(`@import "tailwindcss.css";`), qt.Equals, true) + c.Assert(imp.shouldImport(`@import "tailwindcss/preflight";`), qt.Equals, false) +} + +func TestImportResolver(t *testing.T) { + c := qt.New(t) + fs := afero.NewMemMapFs() + + writeFile := func(name, content string) { + c.Assert(afero.WriteFile(fs, name, []byte(content), 0o777), qt.IsNil) + } + + writeFile("a.css", `@import "b.css"; +@import "c.css"; +A_STYLE1 +A_STYLE2 +`) + + writeFile("b.css", `B_STYLE`) + writeFile("c.css", "@import \"d.css\"\nC_STYLE") + writeFile("d.css", "@import \"a.css\"\n\nD_STYLE") + writeFile("e.css", "E_STYLE") + + mainStyles := strings.NewReader(`@import "a.css"; +@import "b.css"; +LOCAL_STYLE +@import "c.css"; +@import "e.css";`) + + imp := newImportResolver( + mainStyles, + "styles.css", + InlineImports{}, + fs, loggers.NewDefault(), + identity.NopManager, + ) + + r, err := imp.resolve() + c.Assert(err, qt.IsNil) + rs := helpers.ReaderToString(r) + result := regexp.MustCompile(`\n+`).ReplaceAllString(rs, "\n") + + c.Assert(result, hqt.IsSameString, `B_STYLE +D_STYLE +C_STYLE +A_STYLE1 +A_STYLE2 +LOCAL_STYLE +E_STYLE`) + + dline := imp.linemap[3] + c.Assert(dline, qt.DeepEquals, fileOffset{ + Offset: 1, + Filename: "d.css", + }) +} + +func BenchmarkImportResolver(b *testing.B) { + c := qt.New(b) + fs := afero.NewMemMapFs() + + writeFile := func(name, content string) { + c.Assert(afero.WriteFile(fs, name, []byte(content), 0o777), qt.IsNil) + } + + writeFile("a.css", `@import "b.css"; +@import "c.css"; +A_STYLE1 +A_STYLE2 +`) + + writeFile("b.css", `B_STYLE`) + writeFile("c.css", "@import \"d.css\"\nC_STYLE"+strings.Repeat("\nSTYLE", 12)) + writeFile("d.css", "@import \"a.css\"\n\nD_STYLE"+strings.Repeat("\nSTYLE", 55)) + writeFile("e.css", "E_STYLE") + + mainStyles := `@import "a.css"; +@import "b.css"; +LOCAL_STYLE +@import "c.css"; +@import "e.css"; +@import "missing.css";` + + logger := loggers.NewDefault() + + for i := 0; i < b.N; i++ { + b.StopTimer() + imp := newImportResolver( + strings.NewReader(mainStyles), + "styles.css", + InlineImports{}, + fs, logger, + identity.NopManager, + ) + + b.StartTimer() + + _, err := imp.resolve() + if err != nil { + b.Fatal(err) + } + + } +} diff --git a/resources/resource_transformers/cssjs/postcss.go b/resources/resource_transformers/cssjs/postcss.go new file mode 100644 index 000000000..98bdc9249 --- /dev/null +++ b/resources/resource_transformers/cssjs/postcss.go @@ -0,0 +1,239 @@ +// Copyright 2024 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 cssjs provides resource transformations backed by some popular JS based frameworks. +package cssjs + +import ( + "bytes" + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/gohugoio/hugo/common/collections" + "github.com/gohugoio/hugo/common/hexec" + "github.com/gohugoio/hugo/common/loggers" + + "github.com/gohugoio/hugo/common/hugo" + + "github.com/gohugoio/hugo/resources/internal" + "github.com/spf13/cast" + + "github.com/mitchellh/mapstructure" + + "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/resources" + "github.com/gohugoio/hugo/resources/resource" +) + +// NewPostCSSClient creates a new PostCSSClient with the given specification. +func NewPostCSSClient(rs *resources.Spec) *PostCSSClient { + return &PostCSSClient{rs: rs} +} + +func decodePostCSSOptions(m map[string]any) (opts PostCSSOptions, err error) { + if m == nil { + return + } + err = mapstructure.WeakDecode(m, &opts) + + if !opts.NoMap { + // There was for a long time a discrepancy between documentation and + // implementation for the noMap property, so we need to support both + // camel and snake case. + opts.NoMap = cast.ToBool(m["no-map"]) + } + + return +} + +// PostCSSClient is the client used to do PostCSS transformations. +type PostCSSClient struct { + rs *resources.Spec +} + +// Process transforms the given Resource with the PostCSS processor. +func (c *PostCSSClient) Process(res resources.ResourceTransformer, options map[string]any) (resource.Resource, error) { + return res.Transform(&postcssTransformation{rs: c.rs, optionsm: options}) +} + +type InlineImports struct { + // Enable inlining of @import statements. + // Does so recursively, but currently once only per file; + // that is, it's not possible to import the same file in + // different scopes (root, media query...) + // Note that this import routine does not care about the CSS spec, + // so you can have @import anywhere in the file. + InlineImports bool + + // See issue https://github.com/gohugoio/hugo/issues/13719 + // Disable inlining of @import statements + // This is currenty only used for css.TailwindCSS. + DisableInlineImports bool + + // When InlineImports is enabled, we fail the build if an import cannot be resolved. + // You can enable this to allow the build to continue and leave the import statement in place. + // Note that the inline importer does not process url location or imports with media queries, + // so those will be left as-is even without enabling this option. + SkipInlineImportsNotFound bool +} + +// Some of the options from https://github.com/postcss/postcss-cli +type PostCSSOptions struct { + // Set a custom path to look for a config file. + Config string + + NoMap bool // Disable the default inline sourcemaps + + InlineImports `mapstructure:",squash"` + + // Options for when not using a config file + Use string // List of postcss plugins to use + Parser string // Custom postcss parser + Stringifier string // Custom postcss stringifier + Syntax string // Custom postcss syntax +} + +func (opts PostCSSOptions) toArgs() []string { + var args []string + if opts.NoMap { + args = append(args, "--no-map") + } + if opts.Use != "" { + args = append(args, "--use") + args = append(args, strings.Fields(opts.Use)...) + } + if opts.Parser != "" { + args = append(args, "--parser", opts.Parser) + } + if opts.Stringifier != "" { + args = append(args, "--stringifier", opts.Stringifier) + } + if opts.Syntax != "" { + args = append(args, "--syntax", opts.Syntax) + } + return args +} + +type postcssTransformation struct { + optionsm map[string]any + rs *resources.Spec +} + +func (t *postcssTransformation) Key() internal.ResourceTransformationKey { + return internal.NewResourceTransformationKey("postcss", t.optionsm) +} + +// Transform shells out to postcss-cli to do the heavy lifting. +// For this to work, you need some additional tools. To install them globally: +// npm install -g postcss-cli +// npm install -g autoprefixer +func (t *postcssTransformation) Transform(ctx *resources.ResourceTransformationCtx) error { + const binaryName = "postcss" + + infol := t.rs.Logger.InfoCommand(binaryName) + infow := loggers.LevelLoggerToWriter(infol) + + ex := t.rs.ExecHelper + + var configFile string + + options, err := decodePostCSSOptions(t.optionsm) + if err != nil { + return err + } + + if options.Config != "" { + configFile = options.Config + } else { + configFile = "postcss.config.js" + } + + configFile = filepath.Clean(configFile) + + // We need an absolute filename to the config file. + if !filepath.IsAbs(configFile) { + configFile = t.rs.BaseFs.ResolveJSConfigFile(configFile) + if configFile == "" && options.Config != "" { + // Only fail if the user specified config file is not found. + return fmt.Errorf("postcss config %q not found", options.Config) + } + } + + var cmdArgs []any + + if configFile != "" { + infol.Logf("use config file %q", configFile) + cmdArgs = []any{"--config", configFile} + } + + if optArgs := options.toArgs(); len(optArgs) > 0 { + cmdArgs = append(cmdArgs, collections.StringSliceToInterfaceSlice(optArgs)...) + } + + var errBuf bytes.Buffer + + stderr := io.MultiWriter(infow, &errBuf) + cmdArgs = append(cmdArgs, hexec.WithStderr(stderr)) + cmdArgs = append(cmdArgs, hexec.WithStdout(ctx.To)) + cmdArgs = append(cmdArgs, hexec.WithEnviron(hugo.GetExecEnviron(t.rs.Cfg.BaseConfig().WorkingDir, t.rs.Cfg, t.rs.BaseFs.Assets.Fs))) + + cmd, err := ex.Npx(binaryName, cmdArgs...) + if err != nil { + if hexec.IsNotFound(err) { + // This may be on a CI server etc. Will fall back to pre-built assets. + return &herrors.FeatureNotAvailableError{Cause: err} + } + return err + } + + stdin, err := cmd.StdinPipe() + if err != nil { + return err + } + + src := ctx.From + + imp := newImportResolver( + ctx.From, + ctx.InPath, + options.InlineImports, + t.rs.Assets.Fs, t.rs.Logger, ctx.DependencyManager, + ) + + if options.InlineImports.InlineImports { + var err error + src, err = imp.resolve() + if err != nil { + return err + } + } + + go func() { + defer stdin.Close() + io.Copy(stdin, src) + }() + + err = cmd.Run() + if err != nil { + if hexec.IsNotFound(err) { + return &herrors.FeatureNotAvailableError{ + Cause: err, + } + } + return imp.toFileError(errBuf.String()) + } + + return nil +} diff --git a/resources/resource_transformers/cssjs/postcss_integration_test.go b/resources/resource_transformers/cssjs/postcss_integration_test.go new file mode 100644 index 000000000..a05f340fd --- /dev/null +++ b/resources/resource_transformers/cssjs/postcss_integration_test.go @@ -0,0 +1,265 @@ +// Copyright 2024 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 cssjs_test + +import ( + "fmt" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/bep/logg" + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/htesting" + "github.com/gohugoio/hugo/hugofs" + "github.com/gohugoio/hugo/hugolib" +) + +const postCSSIntegrationTestFiles = ` +-- assets/css/components/a.css -- +/* A comment. */ +/* Another comment. */ +class-in-a { + color: blue; +} + +-- assets/css/components/all.css -- +@import "a.css"; +@import "b.css"; +-- assets/css/components/b.css -- +@import "a.css"; + +class-in-b { + color: blue; +} + +-- assets/css/styles.css -- +@tailwind base; +@tailwind components; +@tailwind utilities; + @import "components/all.css"; +h1 { + @apply text-2xl font-bold; +} + +-- config.toml -- +disablekinds = ['taxonomy', 'term', 'page'] +baseURL = "https://example.com" +[build] +useResourceCacheWhen = 'never' +-- content/p1.md -- +-- data/hugo.toml -- +slogan = "Hugo Rocks!" +-- i18n/en.yaml -- +hello: + other: "Hello" +-- i18n/fr.yaml -- +hello: + other: "Bonjour" +-- layouts/index.html -- +{{ $options := dict "inlineImports" true }} +{{ $styles := resources.Get "css/styles.css" | css.PostCSS $options }} +Styles RelPermalink: {{ $styles.RelPermalink }} +{{ $cssContent := $styles.Content }} +Styles Content: Len: {{ len $styles.Content }}| +-- package.json -- +{ + "scripts": {}, + + "devDependencies": { + "postcss-cli": "7.1.0", + "tailwindcss": "1.2.0" + } +} +-- postcss.config.js -- +console.error("Hugo Environment:", process.env.HUGO_ENVIRONMENT ); +console.error("Hugo PublishDir:", process.env.HUGO_PUBLISHDIR ); +// https://github.com/gohugoio/hugo/issues/7656 +console.error("package.json:", process.env.HUGO_FILE_PACKAGE_JSON ); +console.error("PostCSS Config File:", process.env.HUGO_FILE_POSTCSS_CONFIG_JS ); + +module.exports = { + plugins: [ + require('tailwindcss') + ] +} + +` + +func TestTransformPostCSS(t *testing.T) { + if !htesting.IsCI() { + t.Skip("Skip long running test when running locally") + } + + c := qt.New(t) + tempDir, clean, err := htesting.CreateTempDir(hugofs.Os, "hugo-integration-test") + c.Assert(err, qt.IsNil) + c.Cleanup(clean) + + for _, s := range []string{"never", "always"} { + + repl := strings.NewReplacer( + "https://example.com", + "https://example.com/foo", + "useResourceCacheWhen = 'never'", + fmt.Sprintf("useResourceCacheWhen = '%s'", s), + ) + + files := repl.Replace(postCSSIntegrationTestFiles) + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: c, + NeedsOsFS: true, + NeedsNpmInstall: true, + LogLevel: logg.LevelInfo, + WorkingDir: tempDir, + TxtarString: files, + }).Build() + + b.AssertFileContent("public/index.html", ` +Styles RelPermalink: /foo/css/styles.css +Styles Content: Len: 770917| +`) + + if s == "never" { + b.AssertLogContains("Hugo Environment: production") + b.AssertLogContains("Hugo PublishDir: " + filepath.Join(tempDir, "public")) + } + } +} + +// 9880 +func TestTransformPostCSSError(t *testing.T) { + if !htesting.IsCI() { + t.Skip("Skip long running test when running locally") + } + + if runtime.GOOS == "windows" { + // TODO(bep) This has started to fail on Windows with Go 1.19 on GitHub Actions for some mysterious reason. + t.Skip("Skip on Windows") + } + + c := qt.New(t) + + s, err := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: c, + NeedsOsFS: true, + NeedsNpmInstall: true, + TxtarString: strings.ReplaceAll(postCSSIntegrationTestFiles, "color: blue;", "@apply foo;"), // Syntax error + }).BuildE() + + s.AssertIsFileError(err) + c.Assert(err.Error(), qt.Contains, "a.css:4:2") +} + +func TestTransformPostCSSNotInstalledError(t *testing.T) { + if !htesting.IsCI() { + t.Skip("Skip long running test when running locally") + } + + c := qt.New(t) + + s, err := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: c, + NeedsOsFS: true, + TxtarString: postCSSIntegrationTestFiles, + }).BuildE() + + s.AssertIsFileError(err) + c.Assert(err.Error(), qt.Contains, `binary with name "postcss" not found using npx`) +} + +// #9895 +func TestTransformPostCSSImportError(t *testing.T) { + if !htesting.IsCI() { + t.Skip("Skip long running test when running locally") + } + + c := qt.New(t) + + s, err := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: c, + NeedsOsFS: true, + NeedsNpmInstall: true, + LogLevel: logg.LevelInfo, + TxtarString: strings.ReplaceAll(postCSSIntegrationTestFiles, `@import "components/all.css";`, `@import "components/doesnotexist.css";`), + }).BuildE() + + s.AssertIsFileError(err) + c.Assert(err.Error(), qt.Contains, "styles.css:4:3") + c.Assert(err.Error(), qt.Contains, filepath.FromSlash(`failed to resolve CSS @import "/css/components/doesnotexist.css"`)) +} + +func TestTransformPostCSSImporSkipInlineImportsNotFound(t *testing.T) { + if !htesting.IsCI() { + t.Skip("Skip long running test when running locally") + } + + c := qt.New(t) + + files := strings.ReplaceAll(postCSSIntegrationTestFiles, `@import "components/all.css";`, `@import "components/doesnotexist.css";`) + files = strings.ReplaceAll(files, `{{ $options := dict "inlineImports" true }}`, `{{ $options := dict "inlineImports" true "skipInlineImportsNotFound" true }}`) + + s := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: c, + NeedsOsFS: true, + NeedsNpmInstall: true, + LogLevel: logg.LevelInfo, + TxtarString: files, + }).Build() + + s.AssertFileContent("public/css/styles.css", `@import "components/doesnotexist.css";`) +} + +// Issue 9787 +func TestTransformPostCSSResourceCacheWithPathInBaseURL(t *testing.T) { + if !htesting.IsCI() { + t.Skip("Skip long running test when running locally") + } + + c := qt.New(t) + tempDir, clean, err := htesting.CreateTempDir(hugofs.Os, "hugo-integration-test") + c.Assert(err, qt.IsNil) + c.Cleanup(clean) + + for i := range 2 { + files := postCSSIntegrationTestFiles + + if i == 1 { + files = strings.ReplaceAll(files, "https://example.com", "https://example.com/foo") + files = strings.ReplaceAll(files, "useResourceCacheWhen = 'never'", " useResourceCacheWhen = 'always'") + } + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: c, + NeedsOsFS: true, + NeedsNpmInstall: true, + LogLevel: logg.LevelInfo, + TxtarString: files, + WorkingDir: tempDir, + }).Build() + + b.AssertFileContent("public/index.html", ` +Styles Content: Len: 770917 +`) + + } +} diff --git a/resources/resource_transformers/cssjs/tailwindcss.go b/resources/resource_transformers/cssjs/tailwindcss.go new file mode 100644 index 000000000..a60a16222 --- /dev/null +++ b/resources/resource_transformers/cssjs/tailwindcss.go @@ -0,0 +1,167 @@ +// Copyright 2024 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 cssjs + +import ( + "bytes" + "io" + "regexp" + "strings" + + "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/common/hexec" + "github.com/gohugoio/hugo/common/hugo" + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/resources" + "github.com/gohugoio/hugo/resources/internal" + "github.com/gohugoio/hugo/resources/resource" + "github.com/mitchellh/mapstructure" +) + +var ( + tailwindcssImportRe = regexp.MustCompile(`^tailwindcss/?`) + tailwindImportExclude = func(s string) bool { + return tailwindcssImportRe.MatchString(s) && !strings.Contains(s, ".") + } +) + +// NewTailwindCSSClient creates a new TailwindCSSClient with the given specification. +func NewTailwindCSSClient(rs *resources.Spec) *TailwindCSSClient { + return &TailwindCSSClient{rs: rs} +} + +// Client is the client used to do TailwindCSS transformations. +type TailwindCSSClient struct { + rs *resources.Spec +} + +// Process transforms the given Resource with the TailwindCSS processor. +func (c *TailwindCSSClient) Process(res resources.ResourceTransformer, options map[string]any) (resource.Resource, error) { + return res.Transform(&tailwindcssTransformation{rs: c.rs, optionsm: options}) +} + +type tailwindcssTransformation struct { + optionsm map[string]any + rs *resources.Spec +} + +func (t *tailwindcssTransformation) Key() internal.ResourceTransformationKey { + return internal.NewResourceTransformationKey("tailwindcss", t.optionsm) +} + +type TailwindCSSOptions struct { + Minify bool // Optimize and minify the output + Optimize bool // Optimize the output without minifying + InlineImports `mapstructure:",squash"` +} + +func (opts TailwindCSSOptions) toArgs() []any { + var args []any + if opts.Minify { + args = append(args, "--minify") + } + if opts.Optimize { + args = append(args, "--optimize") + } + return args +} + +func (t *tailwindcssTransformation) Transform(ctx *resources.ResourceTransformationCtx) error { + const binaryName = "tailwindcss" + + options, err := decodeTailwindCSSOptions(t.optionsm) + if err != nil { + return err + } + + infol := t.rs.Logger.InfoCommand(binaryName) + infow := loggers.LevelLoggerToWriter(infol) + + ex := t.rs.ExecHelper + + workingDir := t.rs.Cfg.BaseConfig().WorkingDir + + var cmdArgs []any = []any{ + "--input=-", // Read from stdin. + "--cwd", workingDir, + } + + cmdArgs = append(cmdArgs, options.toArgs()...) + + var errBuf bytes.Buffer + + stderr := io.MultiWriter(infow, &errBuf) + cmdArgs = append(cmdArgs, hexec.WithStderr(stderr)) + cmdArgs = append(cmdArgs, hexec.WithStdout(ctx.To)) + cmdArgs = append(cmdArgs, hexec.WithEnviron(hugo.GetExecEnviron(workingDir, t.rs.Cfg, t.rs.BaseFs.Assets.Fs))) + + cmd, err := ex.Npx(binaryName, cmdArgs...) + if err != nil { + if hexec.IsNotFound(err) { + // This may be on a CI server etc. Will fall back to pre-built assets. + return &herrors.FeatureNotAvailableError{Cause: err} + } + return err + } + + stdin, err := cmd.StdinPipe() + if err != nil { + return err + } + + src := ctx.From + + imp := newImportResolver( + ctx.From, + ctx.InPath, + options.InlineImports, + t.rs.Assets.Fs, t.rs.Logger, ctx.DependencyManager, + ) + + if !options.InlineImports.DisableInlineImports { + src, err = imp.resolve() + if err != nil { + return err + } + } + + go func() { + defer stdin.Close() + io.Copy(stdin, src) + }() + + err = cmd.Run() + if err != nil { + if hexec.IsNotFound(err) { + return &herrors.FeatureNotAvailableError{ + Cause: err, + } + } + s := errBuf.String() + if options.InlineImports.DisableInlineImports && strings.Contains(s, "Can't resolve") { + s += "You may want to set the 'disableInlineImports' option to false to inline imports, see https://gohugo.io/functions/css/tailwindcss/#disableinlineimports" + } + return imp.toFileError(s) + } + + return nil +} + +func decodeTailwindCSSOptions(m map[string]any) (opts TailwindCSSOptions, err error) { + if m == nil { + return + } + err = mapstructure.WeakDecode(m, &opts) + return +} diff --git a/resources/resource_transformers/cssjs/tailwindcss_integration_test.go b/resources/resource_transformers/cssjs/tailwindcss_integration_test.go new file mode 100644 index 000000000..734ffe759 --- /dev/null +++ b/resources/resource_transformers/cssjs/tailwindcss_integration_test.go @@ -0,0 +1,136 @@ +// Copyright 2024 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 cssjs_test + +import ( + "testing" + + "github.com/bep/logg" + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/htesting" + "github.com/gohugoio/hugo/hugolib" +) + +func TestTailwindV4Basic(t *testing.T) { + if !htesting.IsCI() { + t.Skip("Skip long running test when running locally") + } + + files := ` +-- hugo.toml -- +-- package.json -- +{ + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/bep/hugo-starter-tailwind-basic.git" + }, + "devDependencies": { + "@tailwindcss/cli": "^4.0.1", + "tailwindcss": "^4.0.1" + }, + "name": "hugo-starter-tailwind-basic", + "version": "0.1.0" +} +-- assets/css/styles.css -- +@import "tailwindcss"; + +@theme { + --font-family-display: "Satoshi", "sans-serif"; + + --breakpoint-3xl: 1920px; + + --color-neon-pink: oklch(71.7% 0.25 360); + --color-neon-lime: oklch(91.5% 0.258 129); + --color-neon-cyan: oklch(91.3% 0.139 195.8); +} +-- layouts/index.html -- +{{ $css := resources.Get "css/styles.css" | css.TailwindCSS }} +CSS: {{ $css.Content | safeCSS }}| +` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + NeedsOsFS: true, + NeedsNpmInstall: true, + LogLevel: logg.LevelInfo, + }).Build() + + b.AssertFileContent("public/index.html", "/*! tailwindcss v4.") +} + +func TestTailwindCSSNoInlineImportsIssue13719(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['page','rss','section','sitemap','taxonomy','term'] +theme = 'my-theme' + +[[module.mounts]] +source = 'assets' +target = 'assets' + +[[module.mounts]] +source = 'other' +target = 'assets/css' +-- assets/css/main.css -- +@import "tailwindcss"; + +@import "colors/red.css"; +@import "colors/blue.css"; +@import "colors/purple.css"; +-- assets/css/colors/red.css -- +@import "green.css"; + +.red {color: red;} +-- assets/css/colors/green.css -- +.green {color: green;} +-- themes/my-theme/assets/css/colors/blue.css -- +.blue {color: blue;} +-- other/colors/purple.css -- +.purple {color: purple;} +-- layouts/home.html -- +{{ with (templates.Defer (dict "key" "global")) }} + {{ with resources.Get "css/main.css" }} + {{ $opts := dict "disableInlineImports" true }} + {{ with . | css.TailwindCSS $opts }} + + {{ end }} + {{ end }} +{{ end }} +-- package.json -- +{ + "devDependencies": { + "@tailwindcss/cli": "^4.1.7", + "tailwindcss": "^4.1.7" + } +} +` + + b, err := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + NeedsOsFS: true, + NeedsNpmInstall: true, + LogLevel: logg.LevelInfo, + }).BuildE() + + b.Assert(err, qt.IsNotNil) + b.Assert(err.Error(), qt.Contains, "Can't resolve 'colors/red.css'") + b.Assert(err.Error(), qt.Contains, "You may want to set the 'disableInlineImports' option to false") +} diff --git a/resources/resource_transformers/htesting/testhelpers.go b/resources/resource_transformers/htesting/testhelpers.go index 4dfc9855a..c9382b828 100644 --- a/resources/resource_transformers/htesting/testhelpers.go +++ b/resources/resource_transformers/htesting/testhelpers.go @@ -16,62 +16,25 @@ package htesting import ( "path/filepath" - "github.com/gohugoio/hugo/cache/filecache" - "github.com/gohugoio/hugo/helpers" - "github.com/gohugoio/hugo/hugofs" - "github.com/gohugoio/hugo/media" - "github.com/gohugoio/hugo/output" + "github.com/gohugoio/hugo/common/hugio" + "github.com/gohugoio/hugo/identity" "github.com/gohugoio/hugo/resources" "github.com/spf13/afero" - "github.com/spf13/viper" ) -func NewTestResourceSpec() (*resources.Spec, error) { - cfg := viper.New() - cfg.Set("baseURL", "https://example.org") - cfg.Set("publishDir", "public") - - imagingCfg := map[string]interface{}{ - "resampleFilter": "linear", - "quality": 68, - "anchor": "left", - } - - cfg.Set("imaging", imagingCfg) - - fs := hugofs.NewFrom(hugofs.NewBaseFileDecorator(afero.NewMemMapFs()), cfg) - - s, err := helpers.NewPathSpec(fs, cfg, nil) - if err != nil { - return nil, err - } - - filecaches, err := filecache.NewCaches(s) - if err != nil { - return nil, err - } - - spec, err := resources.NewSpec(s, filecaches, nil, output.DefaultFormats, media.DefaultTypes) - return spec, err -} - -func NewResourceTransformer(filename, content string) (resources.ResourceTransformer, error) { - spec, err := NewTestResourceSpec() - if err != nil { - return nil, err - } - return NewResourceTransformerForSpec(spec, filename, content) -} - func NewResourceTransformerForSpec(spec *resources.Spec, filename, content string) (resources.ResourceTransformer, error) { filename = filepath.FromSlash(filename) fs := spec.Fs.Source - if err := afero.WriteFile(fs, filename, []byte(content), 0777); err != nil { + if err := afero.WriteFile(fs, filename, []byte(content), 0o777); err != nil { return nil, err } - r, err := spec.New(resources.ResourceSourceDescriptor{Fs: fs, SourceFilename: filename}) + var open hugio.OpenReadSeekCloser = func() (hugio.ReadSeekCloser, error) { + return fs.Open(filename) + } + + r, err := spec.NewResource(resources.ResourceSourceDescriptor{TargetPath: filepath.FromSlash(filename), OpenReadSeekCloser: open, GroupIdentity: identity.Anonymous}) if err != nil { return nil, err } diff --git a/resources/resource_transformers/integrity/integrity.go b/resources/resource_transformers/integrity/integrity.go index 1b74de7eb..aef744443 100644 --- a/resources/resource_transformers/integrity/integrity.go +++ b/resources/resource_transformers/integrity/integrity.go @@ -19,14 +19,13 @@ import ( "crypto/sha512" "encoding/base64" "encoding/hex" + "fmt" "hash" - "html/template" "io" + "github.com/gohugoio/hugo/common/constants" "github.com/gohugoio/hugo/resources/internal" - "github.com/pkg/errors" - "github.com/gohugoio/hugo/resources" "github.com/gohugoio/hugo/resources/resource" ) @@ -49,13 +48,12 @@ type fingerprintTransformation struct { } func (t *fingerprintTransformation) Key() internal.ResourceTransformationKey { - return internal.NewResourceTransformationKey("fingerprint", t.algo) + return internal.NewResourceTransformationKey(constants.ResourceTransformationFingerprint, t.algo) } // Transform creates a MD5 hash of the Resource content and inserts that hash before // the extension in the filename. func (t *fingerprintTransformation) Transform(ctx *resources.ResourceTransformationCtx) error { - h, err := newHash(t.algo) if err != nil { return err @@ -93,7 +91,7 @@ func newHash(algo string) (hash.Hash, error) { case "sha512": return sha512.New(), nil default: - return nil, errors.Errorf("unsupported crypto algo: %q, use either md5, sha256, sha384 or sha512", algo) + return nil, fmt.Errorf("unsupported hash algorithm: %q, use either md5, sha256, sha384 or sha512", algo) } } @@ -111,9 +109,9 @@ func (c *Client) Fingerprint(res resources.ResourceTransformer, algo string) (re return res.Transform(&fingerprintTransformation{algo: algo}) } -func integrity(algo string, sum []byte) template.HTMLAttr { +func integrity(algo string, sum []byte) string { encoded := base64.StdEncoding.EncodeToString(sum) - return template.HTMLAttr(algo + "-" + encoded) + return algo + "-" + encoded } func digest(h hash.Hash) ([]byte, error) { diff --git a/resources/resource_transformers/integrity/integrity_test.go b/resources/resource_transformers/integrity/integrity_test.go index 3759e6313..e0af68ae9 100644 --- a/resources/resource_transformers/integrity/integrity_test.go +++ b/resources/resource_transformers/integrity/integrity_test.go @@ -14,9 +14,10 @@ package integrity import ( - "html/template" + "context" "testing" + "github.com/gohugoio/hugo/config/testconfig" "github.com/gohugoio/hugo/resources/resource" qt "github.com/frankban/quicktest" @@ -24,7 +25,6 @@ import ( ) func TestHashFromAlgo(t *testing.T) { - for _, algo := range []struct { name string bits int @@ -35,7 +35,6 @@ func TestHashFromAlgo(t *testing.T) { {"sha512", 512}, {"shaman", -1}, } { - t.Run(algo.name, func(t *testing.T) { c := qt.New(t) h, err := newHash(algo.name) @@ -46,7 +45,6 @@ func TestHashFromAlgo(t *testing.T) { c.Assert(err, qt.Not(qt.IsNil)) c.Assert(err.Error(), qt.Contains, "use either md5, sha256, sha384 or sha512") } - }) } } @@ -54,19 +52,20 @@ func TestHashFromAlgo(t *testing.T) { func TestTransform(t *testing.T) { c := qt.New(t) - spec, err := htesting.NewTestResourceSpec() - c.Assert(err, qt.IsNil) - client := New(spec) + d := testconfig.GetTestDeps(nil, nil) + t.Cleanup(func() { c.Assert(d.Close(), qt.IsNil) }) - r, err := htesting.NewResourceTransformerForSpec(spec, "hugo.txt", "Hugo Rocks!") + client := New(d.ResourceSpec) + + r, err := htesting.NewResourceTransformerForSpec(d.ResourceSpec, "hugo.txt", "Hugo Rocks!") c.Assert(err, qt.IsNil) transformed, err := client.Fingerprint(r, "") c.Assert(err, qt.IsNil) c.Assert(transformed.RelPermalink(), qt.Equals, "/hugo.a5ad1c6961214a55de53c1ce6e60d27b6b761f54851fa65e33066460dfa6a0db.txt") - c.Assert(transformed.Data(), qt.DeepEquals, map[string]interface{}{"Integrity": template.HTMLAttr("sha256-pa0caWEhSlXeU8HObmDSe2t2H1SFH6ZeMwZkYN+moNs=")}) - content, err := transformed.(resource.ContentProvider).Content() + c.Assert(transformed.Data(), qt.DeepEquals, map[string]any{"Integrity": "sha256-pa0caWEhSlXeU8HObmDSe2t2H1SFH6ZeMwZkYN+moNs="}) + content, err := transformed.(resource.ContentProvider).Content(context.Background()) c.Assert(err, qt.IsNil) c.Assert(content, qt.Equals, "Hugo Rocks!") } diff --git a/resources/resource_transformers/js/build.go b/resources/resource_transformers/js/build.go new file mode 100644 index 000000000..bd943461f --- /dev/null +++ b/resources/resource_transformers/js/build.go @@ -0,0 +1,82 @@ +// Copyright 2024 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 js + +import ( + "path" + "regexp" + + "github.com/evanw/esbuild/pkg/api" + "github.com/gohugoio/hugo/hugolib/filesystems" + "github.com/gohugoio/hugo/internal/js/esbuild" + + "github.com/gohugoio/hugo/resources" + "github.com/gohugoio/hugo/resources/resource" +) + +// Client context for ESBuild. +type Client struct { + c *esbuild.BuildClient +} + +// New creates a new client context. +func New(fs *filesystems.SourceFilesystem, rs *resources.Spec) *Client { + return &Client{ + c: esbuild.NewBuildClient(fs, rs), + } +} + +// Process processes a resource with the user provided options. +func (c *Client) Process(res resources.ResourceTransformer, opts map[string]any) (resource.Resource, error) { + return res.Transform( + &buildTransformation{c: c, optsm: opts}, + ) +} + +func (c *Client) transform(opts esbuild.Options, transformCtx *resources.ResourceTransformationCtx) (api.BuildResult, error) { + if transformCtx.DependencyManager != nil { + opts.DependencyManager = transformCtx.DependencyManager + } + + opts.StdinSourcePath = transformCtx.SourcePath + + result, err := c.c.Build(opts) + if err != nil { + return result, err + } + + if opts.ExternalOptions.SourceMap == "linked" || opts.ExternalOptions.SourceMap == "external" { + content := string(result.OutputFiles[1].Contents) + if opts.ExternalOptions.SourceMap == "linked" { + symPath := path.Base(transformCtx.OutPath) + ".map" + re := regexp.MustCompile(`//# sourceMappingURL=.*\n?`) + content = re.ReplaceAllString(content, "//# sourceMappingURL="+symPath+"\n") + } + + if err = transformCtx.PublishSourceMap(string(result.OutputFiles[0].Contents)); err != nil { + return result, err + } + _, err := transformCtx.To.Write([]byte(content)) + if err != nil { + return result, err + } + } else { + _, err := transformCtx.To.Write(result.OutputFiles[0].Contents) + if err != nil { + return result, err + } + + } + return result, nil +} diff --git a/resources/resource_transformers/js/js_integration_test.go b/resources/resource_transformers/js/js_integration_test.go new file mode 100644 index 000000000..9cee19a86 --- /dev/null +++ b/resources/resource_transformers/js/js_integration_test.go @@ -0,0 +1,422 @@ +// Copyright 2024 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 js_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/common/paths" + "github.com/gohugoio/hugo/htesting" + "github.com/gohugoio/hugo/hugolib" + "github.com/gohugoio/hugo/internal/js/esbuild" +) + +func TestBuildVariants(t *testing.T) { + c := qt.New(t) + + mainWithImport := ` +-- config.toml -- +disableKinds=["page", "section", "taxonomy", "term", "sitemap", "robotsTXT"] +disableLiveReload = true +-- assets/js/main.js -- +import { hello1, hello2 } from './util1'; +hello1(); +hello2(); +-- assets/js/util1.js -- +import { hello3 } from './util2'; +export function hello1() { + return 'abcd'; +} +export function hello2() { + return hello3(); +} +-- assets/js/util2.js -- +export function hello3() { + return 'efgh'; +} +-- layouts/index.html -- +{{ $js := resources.Get "js/main.js" | js.Build }} +JS Content:{{ $js.Content }}:End: + + ` + + c.Run("Basic", func(c *qt.C) { + b := hugolib.NewIntegrationTestBuilder(hugolib.IntegrationTestConfig{T: c, NeedsOsFS: true, TxtarString: mainWithImport}).Build() + + b.AssertFileContent("public/index.html", `abcd`) + }) + + c.Run("Edit Import", func(c *qt.C) { + b := hugolib.NewIntegrationTestBuilder(hugolib.IntegrationTestConfig{T: c, Running: true, NeedsOsFS: true, TxtarString: mainWithImport}).Build() + + b.AssertFileContent("public/index.html", `abcd`) + b.EditFileReplaceFunc("assets/js/util1.js", func(s string) string { return strings.ReplaceAll(s, "abcd", "1234") }).Build() + b.AssertFileContent("public/index.html", `1234`) + }) + + c.Run("Edit Import Nested", func(c *qt.C) { + b := hugolib.NewIntegrationTestBuilder(hugolib.IntegrationTestConfig{T: c, Running: true, NeedsOsFS: true, TxtarString: mainWithImport}).Build() + + b.AssertFileContent("public/index.html", `efgh`) + b.EditFileReplaceFunc("assets/js/util2.js", func(s string) string { return strings.ReplaceAll(s, "efgh", "1234") }).Build() + b.AssertFileContent("public/index.html", `1234`) + }) +} + +func TestBuildWithModAndNpm(t *testing.T) { + if !htesting.IsCI() { + t.Skip("skip (relative) long running modules test when running locally") + } + + c := qt.New(t) + + files := ` +-- config.toml -- +baseURL = "https://example.org" +disableKinds=["page", "section", "taxonomy", "term", "sitemap", "robotsTXT"] +[module] +[[module.imports]] +path="github.com/gohugoio/hugoTestProjectJSModImports" +-- go.mod -- +module github.com/gohugoio/tests/testHugoModules + +go 1.16 + +require github.com/gohugoio/hugoTestProjectJSModImports v0.10.0 // indirect +-- package.json -- +{ + "dependencies": { + "date-fns": "^2.16.1" + } +} + +` + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: c, + NeedsOsFS: true, + NeedsNpmInstall: true, + TxtarString: files, + Verbose: true, + }).Build() + + b.AssertFileContent("public/js/main.js", ` +greeting: "greeting configured in mod2" +Hello1 from mod1: $ +return "Hello2 from mod1"; +var Hugo = "Rocks!"; +Hello3 from mod2. Date from date-fns: ${today} +Hello from lib in the main project +Hello5 from mod2. +var myparam = "Hugo Rocks!"; +shim cwd +`) + + // React JSX, verify the shimming. + b.AssertFileContent("public/js/like.js", filepath.FromSlash(`@v0.10.0/assets/js/shims/react.js +module.exports = window.ReactDOM; +`)) +} + +func TestBuildWithNpm(t *testing.T) { + if !htesting.IsCI() { + t.Skip("skip (relative) long running modules test when running locally") + } + + c := qt.New(t) + + files := ` +-- assets/js/included.js -- +console.log("included"); +-- assets/js/main.js -- +import "./included"; + import { toCamelCase } from "to-camel-case"; + + console.log("main"); + console.log("To camel:", toCamelCase("space case")); +-- assets/js/myjsx.jsx -- +import * as React from 'react' +import * as ReactDOM from 'react-dom' + + ReactDOM.render( +

    Hello, world!

    , + document.getElementById('root') + ); +-- assets/js/myts.ts -- +function greeter(person: string) { + return "Hello, " + person; +} +let user = [0, 1, 2]; +document.body.textContent = greeter(user); +-- config.toml -- +disablekinds = ['taxonomy', 'term', 'page'] +-- content/p1.md -- +Content. +-- data/hugo.toml -- +slogan = "Hugo Rocks!" +-- i18n/en.yaml -- +hello: + other: "Hello" +-- i18n/fr.yaml -- +hello: + other: "Bonjour" +-- layouts/index.html -- +{{ $options := dict "minify" false "externals" (slice "react" "react-dom") "sourcemap" "linked" }} +{{ $js := resources.Get "js/main.js" | js.Build $options }} +JS: {{ template "print" $js }} +{{ $jsx := resources.Get "js/myjsx.jsx" | js.Build $options }} +JSX: {{ template "print" $jsx }} +{{ $ts := resources.Get "js/myts.ts" | js.Build (dict "sourcemap" "inline")}} +TS: {{ template "print" $ts }} +{{ $ts2 := resources.Get "js/myts.ts" | js.Build (dict "sourcemap" "external" "TargetPath" "js/myts2.js")}} +TS2: {{ template "print" $ts2 }} +{{ define "print" }}RelPermalink: {{.RelPermalink}}|MIME: {{ .MediaType }}|Content: {{ .Content | safeJS }}{{ end }} +-- package.json -- +{ + "scripts": {}, + + "dependencies": { + "to-camel-case": "1.0.0" + } +} +` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: c, + NeedsOsFS: true, + NeedsNpmInstall: true, + TxtarString: files, + }).Build() + + b.AssertFileContent("public/js/main.js", `//# sourceMappingURL=main.js.map`) + b.AssertFileContent("public/js/main.js.map", `"version":3`, "! ns-hugo") // linked + b.AssertFileContent("public/js/myts.js", `//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJz`) // inline + b.AssertFileContent("public/index.html", ` + console.log("included"); + if (hasSpace.test(string)) + var React = __toESM(__require("react")); + function greeter(person) { +`) + + checkMap := func(p string, expectLen int) { + s := b.FileContent(p) + sources := esbuild.SourcesFromSourceMap(s) + b.Assert(sources, qt.HasLen, expectLen) + + // Check that all source files exist. + for _, src := range sources { + filename, ok := paths.UrlStringToFilename(src) + b.Assert(ok, qt.IsTrue) + _, err := os.Stat(filename) + b.Assert(err, qt.IsNil, qt.Commentf("src: %q", src)) + } + } + + checkMap("public/js/main.js.map", 4) +} + +func TestBuildError(t *testing.T) { + c := qt.New(t) + + filesTemplate := ` +-- config.toml -- +disableKinds=["page", "section", "taxonomy", "term", "sitemap", "robotsTXT"] +-- assets/js/main.js -- +// A comment. +import { hello1, hello2 } from './util1'; +hello1(); +hello2(); +-- assets/js/util1.js -- +/* Some +comments. +*/ +import { hello3 } from './util2'; +export function hello1() { + return 'abcd'; +} +export function hello2() { + return hello3(); +} +-- assets/js/util2.js -- +export function hello3() { + return 'efgh'; +} +-- layouts/index.html -- +{{ $js := resources.Get "js/main.js" | js.Build }} +JS Content:{{ $js.Content }}:End: + + ` + + c.Run("Import from main not found", func(c *qt.C) { + c.Parallel() + files := strings.Replace(filesTemplate, "import { hello1, hello2 }", "import { hello1, hello2, FOOBAR }", 1) + b, err := hugolib.NewIntegrationTestBuilder(hugolib.IntegrationTestConfig{T: c, NeedsOsFS: true, TxtarString: files}).BuildE() + b.Assert(err, qt.IsNotNil) + b.Assert(err.Error(), qt.Contains, `main.js:2:25": No matching export`) + }) + + c.Run("Import from import not found", func(c *qt.C) { + c.Parallel() + files := strings.Replace(filesTemplate, "import { hello3 } from './util2';", "import { hello3, FOOBAR } from './util2';", 1) + b, err := hugolib.NewIntegrationTestBuilder(hugolib.IntegrationTestConfig{T: c, NeedsOsFS: true, TxtarString: files}).BuildE() + b.Assert(err, qt.IsNotNil) + b.Assert(err.Error(), qt.Contains, `util1.js:4:17": No matching export in`) + }) +} + +// See issue 10527. +func TestImportHugoVsESBuild(t *testing.T) { + c := qt.New(t) + + for _, importSrcDir := range []string{"node_modules", "assets"} { + c.Run(importSrcDir, func(c *qt.C) { + files := ` +-- IMPORT_SRC_DIR/imp1/index.js -- +console.log("IMPORT_SRC_DIR:imp1/index.js"); +-- IMPORT_SRC_DIR/imp2/index.ts -- +console.log("IMPORT_SRC_DIR:imp2/index.ts"); +-- IMPORT_SRC_DIR/imp3/foo.ts -- +console.log("IMPORT_SRC_DIR:imp3/foo.ts"); +-- assets/js/main.js -- +import 'imp1/index.js'; +import 'imp2/index.js'; +import 'imp3/foo.js'; +-- layouts/index.html -- +{{ $js := resources.Get "js/main.js" | js.Build }} +{{ $js.RelPermalink }} + ` + + files = strings.ReplaceAll(files, "IMPORT_SRC_DIR", importSrcDir) + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: c, + NeedsOsFS: true, + TxtarString: files, + }).Build() + + expected := ` +IMPORT_SRC_DIR:imp1/index.js +IMPORT_SRC_DIR:imp2/index.ts +IMPORT_SRC_DIR:imp3/foo.ts +` + expected = strings.ReplaceAll(expected, "IMPORT_SRC_DIR", importSrcDir) + + b.AssertFileContent("public/js/main.js", expected) + }) + } +} + +// See https://github.com/evanw/esbuild/issues/2745 +func TestPreserveLegalComments(t *testing.T) { + t.Parallel() + + files := ` +-- assets/js/main.js -- +/* @license + * Main license. + */ +import * as foo from 'js/utils'; +console.log("Hello Main"); +-- assets/js/utils/index.js -- +export * from './util1'; +export * from './util2'; +-- assets/js/utils/util1.js -- +/*! License util1 */ +console.log("Hello 1"); +-- assets/js/utils/util2.js -- +//! License util2 */ +console.log("Hello 2"); +-- layouts/index.html -- +{{ $js := resources.Get "js/main.js" | js.Build (dict "minify" false) }} +{{ $js.RelPermalink }} +` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + NeedsOsFS: true, + TxtarString: files, + }).Build() + + b.AssertFileContent("public/js/main.js", ` +License util1 +License util2 +Main license + + `) +} + +// Issue #11232 +func TestTypeScriptExperimentalDecorators(t *testing.T) { + t.Parallel() + files := ` +-- hugo.toml -- +disableKinds = ['RSS','sitemap','taxonomy','term'] +-- tsconfig.json -- +{ + "compilerOptions": { + "experimentalDecorators": true, + } +} +-- assets/ts/main.ts -- +function addFoo(target: any) {target.prototype.foo = 'bar'} +@addFoo +class A {} +-- layouts/index.html -- +{{ $opts := dict "target" "es2020" "targetPath" "js/main.js" }} +{{ (resources.Get "ts/main.ts" | js.Build $opts).Publish }} +` + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + NeedsOsFS: true, + TxtarString: files, + }).Build() + b.AssertFileContent("public/js/main.js", "__decorateClass") +} + +// Issue 13183. +func TestExternalsInAssets(t *testing.T) { + files := ` +-- assets/js/util1.js -- +export function hello1() { + return 'abcd'; +} +-- assets/js/util2.js -- +export function hello2() { + return 'efgh'; +} +-- assets/js/main.js -- +import { hello1 } from './util1.js'; +import { hello2 } from './util2.js'; + +hello1(); +hello2(); +-- layouts/index.html -- +Home. +{{ $js := resources.Get "js/main.js" | js.Build (dict "externals" (slice "./util1.js")) }} +{{ $js.Publish }} +` + + b := hugolib.Test(t, files, hugolib.TestOptOsFs()) + + b.AssertFileContent("public/js/main.js", "efgh") + b.AssertFileContent("public/js/main.js", "! abcd") +} diff --git a/resources/resource_transformers/js/transform.go b/resources/resource_transformers/js/transform.go new file mode 100644 index 000000000..13909e54c --- /dev/null +++ b/resources/resource_transformers/js/transform.go @@ -0,0 +1,68 @@ +// Copyright 2024 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 js + +import ( + "io" + "path" + "path/filepath" + + "github.com/gohugoio/hugo/internal/js/esbuild" + "github.com/gohugoio/hugo/media" + "github.com/gohugoio/hugo/resources" + "github.com/gohugoio/hugo/resources/internal" +) + +type buildTransformation struct { + optsm map[string]any + c *Client +} + +func (t *buildTransformation) Key() internal.ResourceTransformationKey { + return internal.NewResourceTransformationKey("jsbuild", t.optsm) +} + +func (t *buildTransformation) Transform(ctx *resources.ResourceTransformationCtx) error { + ctx.OutMediaType = media.Builtin.JavascriptType + + var opts esbuild.Options + + if t.optsm != nil { + optsExt, err := esbuild.DecodeExternalOptions(t.optsm) + if err != nil { + return err + } + opts.ExternalOptions = optsExt + } + + if opts.TargetPath != "" { + ctx.OutPath = opts.TargetPath + } else { + ctx.ReplaceOutPathExtension(".js") + } + + src, err := io.ReadAll(ctx.From) + if err != nil { + return err + } + + opts.SourceDir = filepath.FromSlash(path.Dir(ctx.SourcePath)) + opts.Contents = string(src) + opts.MediaType = ctx.InMediaType + opts.Stdin = true + + _, err = t.c.transform(opts, ctx) + + return err +} diff --git a/resources/resource_transformers/minifier/minifier_integration_test.go b/resources/resource_transformers/minifier/minifier_integration_test.go new file mode 100644 index 000000000..fb4cc7a65 --- /dev/null +++ b/resources/resource_transformers/minifier/minifier_integration_test.go @@ -0,0 +1,47 @@ +// Copyright 2021 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 minifier_test + +import ( + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/hugolib" +) + +// Issue 8954 +func TestTransformMinify(t *testing.T) { + c := qt.New(t) + + files := ` +-- assets/js/test.js -- +new Date(2002, 04, 11) +-- config.toml -- +-- layouts/index.html -- +{{ $js := resources.Get "js/test.js" | minify }} + +` + + b, err := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: c, + TxtarString: files, + }, + ).BuildE() + + b.Assert(err, qt.IsNotNil) + b.Assert(err, qt.ErrorMatches, "(?s).*legacy octal numbers.*line 1.*") +} diff --git a/resources/resource_transformers/minifier/minify.go b/resources/resource_transformers/minifier/minify.go index 38e3fc93a..872d284c6 100644 --- a/resources/resource_transformers/minifier/minify.go +++ b/resources/resource_transformers/minifier/minify.go @@ -20,7 +20,7 @@ import ( "github.com/gohugoio/hugo/resources/resource" ) -// Client for minification of Resource objects. Supported minfiers are: +// Client for minification of Resource objects. Supported minifiers are: // css, html, js, json, svg and xml. type Client struct { rs *resources.Spec @@ -29,8 +29,12 @@ type Client struct { // New creates a new Client given a specification. Note that it is the media types // configured for the site that is used to match files to the correct minifier. -func New(rs *resources.Spec) *Client { - return &Client{rs: rs, m: minifiers.New(rs.MediaTypes, rs.OutputFormats)} +func New(rs *resources.Spec) (*Client, error) { + m, err := minifiers.New(rs.MediaTypes(), rs.OutputFormats(), rs.Cfg) + if err != nil { + return nil, err + } + return &Client{rs: rs, m: m}, nil } type minifyTransformation struct { @@ -43,11 +47,8 @@ func (t *minifyTransformation) Key() internal.ResourceTransformationKey { } func (t *minifyTransformation) Transform(ctx *resources.ResourceTransformationCtx) error { - if err := t.m.Minify(ctx.InMediaType, ctx.To, ctx.From); err != nil { - return err - } ctx.AddOutPathIdentifier(".min") - return nil + return t.m.Minify(ctx.InMediaType, ctx.To, ctx.From) } func (c *Client) Minify(res resources.ResourceTransformer) (resource.Resource, error) { @@ -55,5 +56,4 @@ func (c *Client) Minify(res resources.ResourceTransformer) (resource.Resource, e rs: c.rs, m: c.m, }) - } diff --git a/resources/resource_transformers/minifier/minify_test.go b/resources/resource_transformers/minifier/minify_test.go index 3f8853520..030abf426 100644 --- a/resources/resource_transformers/minifier/minify_test.go +++ b/resources/resource_transformers/minifier/minify_test.go @@ -14,8 +14,10 @@ package minifier import ( + "context" "testing" + "github.com/gohugoio/hugo/config/testconfig" "github.com/gohugoio/hugo/resources/resource" qt "github.com/frankban/quicktest" @@ -25,19 +27,18 @@ import ( func TestTransform(t *testing.T) { c := qt.New(t) - spec, err := htesting.NewTestResourceSpec() - c.Assert(err, qt.IsNil) - client := New(spec) + d := testconfig.GetTestDeps(nil, nil) + t.Cleanup(func() { c.Assert(d.Close(), qt.IsNil) }) - r, err := htesting.NewResourceTransformerForSpec(spec, "hugo.html", "

    Hugo Rocks!

    ") + client, _ := New(d.ResourceSpec) + r, err := htesting.NewResourceTransformerForSpec(d.ResourceSpec, "hugo.html", "

    Hugo Rocks!

    ") c.Assert(err, qt.IsNil) transformed, err := client.Minify(r) c.Assert(err, qt.IsNil) c.Assert(transformed.RelPermalink(), qt.Equals, "/hugo.min.html") - content, err := transformed.(resource.ContentProvider).Content() + content, err := transformed.(resource.ContentProvider).Content(context.Background()) c.Assert(err, qt.IsNil) c.Assert(content, qt.Equals, "

    Hugo Rocks!

    ") - } diff --git a/resources/resource_transformers/postcss/postcss.go b/resources/resource_transformers/postcss/postcss.go deleted file mode 100644 index f262a5c91..000000000 --- a/resources/resource_transformers/postcss/postcss.go +++ /dev/null @@ -1,193 +0,0 @@ -// Copyright 2018 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 postcss - -import ( - "io" - "path/filepath" - - "github.com/gohugoio/hugo/resources/internal" - "github.com/spf13/cast" - - "github.com/gohugoio/hugo/hugofs" - "github.com/pkg/errors" - - "os" - "os/exec" - - "github.com/mitchellh/mapstructure" - - "github.com/gohugoio/hugo/common/herrors" - "github.com/gohugoio/hugo/resources" - "github.com/gohugoio/hugo/resources/resource" -) - -// Some of the options from https://github.com/postcss/postcss-cli -type Options struct { - - // Set a custom path to look for a config file. - Config string - - NoMap bool // Disable the default inline sourcemaps - - // Options for when not using a config file - Use string // List of postcss plugins to use - Parser string // Custom postcss parser - Stringifier string // Custom postcss stringifier - Syntax string // Custom postcss syntax -} - -func DecodeOptions(m map[string]interface{}) (opts Options, err error) { - if m == nil { - return - } - err = mapstructure.WeakDecode(m, &opts) - - if !opts.NoMap { - // There was for a long time a discrepancy between documentation and - // implementation for the noMap property, so we need to support both - // camel and snake case. - opts.NoMap = cast.ToBool(m["no-map"]) - } - - return -} - -func (opts Options) toArgs() []string { - var args []string - if opts.NoMap { - args = append(args, "--no-map") - } - if opts.Use != "" { - args = append(args, "--use", opts.Use) - } - if opts.Parser != "" { - args = append(args, "--parser", opts.Parser) - } - if opts.Stringifier != "" { - args = append(args, "--stringifier", opts.Stringifier) - } - if opts.Syntax != "" { - args = append(args, "--syntax", opts.Syntax) - } - return args -} - -// Client is the client used to do PostCSS transformations. -type Client struct { - rs *resources.Spec -} - -// New creates a new Client with the given specification. -func New(rs *resources.Spec) *Client { - return &Client{rs: rs} -} - -type postcssTransformation struct { - options Options - rs *resources.Spec -} - -func (t *postcssTransformation) Key() internal.ResourceTransformationKey { - return internal.NewResourceTransformationKey("postcss", t.options) -} - -// Transform shells out to postcss-cli to do the heavy lifting. -// For this to work, you need some additional tools. To install them globally: -// npm install -g postcss-cli -// npm install -g autoprefixer -func (t *postcssTransformation) Transform(ctx *resources.ResourceTransformationCtx) error { - - const localPostCSSPath = "node_modules/.bin/" - const binaryName = "postcss" - - // Try first in the project's node_modules. - csiBinPath := filepath.Join(t.rs.WorkingDir, localPostCSSPath, binaryName) - - binary := csiBinPath - - if _, err := exec.LookPath(binary); err != nil { - // Try PATH - binary = binaryName - if _, err := exec.LookPath(binary); err != nil { - // This may be on a CI server etc. Will fall back to pre-built assets. - return herrors.ErrFeatureNotAvailable - } - } - - var configFile string - logger := t.rs.Logger - - if t.options.Config != "" { - configFile = t.options.Config - } else { - configFile = "postcss.config.js" - } - - configFile = filepath.Clean(configFile) - - // We need an abolute filename to the config file. - if !filepath.IsAbs(configFile) { - // We resolve this against the virtual Work filesystem, to allow - // this config file to live in one of the themes if needed. - fi, err := t.rs.BaseFs.Work.Stat(configFile) - if err != nil { - if t.options.Config != "" { - // Only fail if the user specificed config file is not found. - return errors.Wrapf(err, "postcss config %q not found:", configFile) - } - configFile = "" - } else { - configFile = fi.(hugofs.FileMetaInfo).Meta().Filename() - } - } - - var cmdArgs []string - - if configFile != "" { - logger.INFO.Println("postcss: use config file", configFile) - cmdArgs = []string{"--config", configFile} - } - - if optArgs := t.options.toArgs(); len(optArgs) > 0 { - cmdArgs = append(cmdArgs, optArgs...) - } - - cmd := exec.Command(binary, cmdArgs...) - - cmd.Stdout = ctx.To - cmd.Stderr = os.Stderr - - stdin, err := cmd.StdinPipe() - if err != nil { - return err - } - - go func() { - defer stdin.Close() - io.Copy(stdin, ctx.From) - }() - - err = cmd.Run() - if err != nil { - return err - } - - return nil -} - -// Process transforms the given Resource with the PostCSS processor. -func (c *Client) Process(res resources.ResourceTransformer, options Options) (resource.Resource, error) { - return res.Transform(&postcssTransformation{rs: c.rs, options: options}) -} diff --git a/resources/resource_transformers/postcss/postcss_test.go b/resources/resource_transformers/postcss/postcss_test.go deleted file mode 100644 index 39936d6b4..000000000 --- a/resources/resource_transformers/postcss/postcss_test.go +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright 2020 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 postcss - -import ( - "testing" - - qt "github.com/frankban/quicktest" -) - -// Issue 6166 -func TestDecodeOptions(t *testing.T) { - c := qt.New(t) - opts1, err := DecodeOptions(map[string]interface{}{ - "no-map": true, - }) - - c.Assert(err, qt.IsNil) - c.Assert(opts1.NoMap, qt.Equals, true) - - opts2, err := DecodeOptions(map[string]interface{}{ - "noMap": true, - }) - - c.Assert(err, qt.IsNil) - c.Assert(opts2.NoMap, qt.Equals, true) - -} diff --git a/resources/resource_transformers/templates/execute_as_template.go b/resources/resource_transformers/templates/execute_as_template.go index 115b3d047..cd421e08f 100644 --- a/resources/resource_transformers/templates/execute_as_template.go +++ b/resources/resource_transformers/templates/execute_as_template.go @@ -15,24 +15,27 @@ package templates import ( + "context" + "fmt" + + "github.com/gohugoio/hugo/common/paths" "github.com/gohugoio/hugo/helpers" "github.com/gohugoio/hugo/resources" "github.com/gohugoio/hugo/resources/internal" "github.com/gohugoio/hugo/resources/resource" - "github.com/gohugoio/hugo/tpl" - "github.com/pkg/errors" + "github.com/gohugoio/hugo/tpl/tplimpl" ) // Client contains methods to perform template processing of Resource objects. type Client struct { rs *resources.Spec - t tpl.TemplatesProvider + t tplimpl.TemplateStoreProvider } // New creates a new Client with the given specification. -func New(rs *resources.Spec, t tpl.TemplatesProvider) *Client { +func New(rs *resources.Spec, t tplimpl.TemplateStoreProvider) *Client { if rs == nil { - panic("must provice a resource Spec") + panic("must provide a resource Spec") } if t == nil { panic("must provide a template provider") @@ -42,9 +45,9 @@ func New(rs *resources.Spec, t tpl.TemplatesProvider) *Client { type executeAsTemplateTransform struct { rs *resources.Spec - t tpl.TemplatesProvider + t tplimpl.TemplateStoreProvider targetPath string - data interface{} + data any } func (t *executeAsTemplateTransform) Key() internal.ResourceTransformationKey { @@ -53,20 +56,19 @@ func (t *executeAsTemplateTransform) Key() internal.ResourceTransformationKey { func (t *executeAsTemplateTransform) Transform(ctx *resources.ResourceTransformationCtx) error { tplStr := helpers.ReaderToString(ctx.From) - templ, err := t.t.TextTmpl().Parse(ctx.InPath, tplStr) + th := t.t.GetTemplateStore() + ti, err := th.TextParse(ctx.InPath, tplStr) if err != nil { - return errors.Wrapf(err, "failed to parse Resource %q as Template:", ctx.InPath) + return fmt.Errorf("failed to parse Resource %q as Template:: %w", ctx.InPath, err) } - ctx.OutPath = t.targetPath - - return t.t.Tmpl().Execute(templ, ctx.To, t.data) + return th.ExecuteWithContext(ctx.Ctx, ti, ctx.To, t.data) } -func (c *Client) ExecuteAsTemplate(res resources.ResourceTransformer, targetPath string, data interface{}) (resource.Resource, error) { - return res.Transform(&executeAsTemplateTransform{ +func (c *Client) ExecuteAsTemplate(ctx context.Context, res resources.ResourceTransformer, targetPath string, data any) (resource.Resource, error) { + return res.TransformWithContext(ctx, &executeAsTemplateTransform{ rs: c.rs, - targetPath: helpers.ToSlashTrimLeading(targetPath), + targetPath: paths.ToSlashTrimLeading(targetPath), t: c.t, data: data, }) diff --git a/resources/resource_transformers/templates/templates_integration_test.go b/resources/resource_transformers/templates/templates_integration_test.go new file mode 100644 index 000000000..969e09c36 --- /dev/null +++ b/resources/resource_transformers/templates/templates_integration_test.go @@ -0,0 +1,73 @@ +// Copyright 2021 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 templates_test + +import ( + "testing" + + "github.com/gohugoio/hugo/hugolib" +) + +func TestExecuteAsTemplateMultipleLanguages(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +baseURL = "http://example.com/blog" +defaultContentLanguage = "fr" +defaultContentLanguageInSubdir = true +[Languages] +[Languages.en] +weight = 10 +title = "In English" +languageName = "English" +[Languages.fr] +weight = 20 +title = "Le Français" +languageName = "Français" +-- i18n/en.toml -- +[hello] +other = "Hello" +-- i18n/fr.toml -- +[hello] +other = "Bonjour" +-- layouts/index.fr.html -- +Lang: {{ site.Language.Lang }} +{{ $templ := "{{T \"hello\"}}" | resources.FromString "f1.html" }} +{{ $helloResource := $templ | resources.ExecuteAsTemplate (print "f%s.html" .Lang) . }} +Hello1: {{T "hello"}} +Hello2: {{ $helloResource.Content }} +LangURL: {{ relLangURL "foo" }} +-- layouts/index.html -- +Lang: {{ site.Language.Lang }} +{{ $templ := "{{T \"hello\"}}" | resources.FromString "f1.html" }} +{{ $helloResource := $templ | resources.ExecuteAsTemplate (print "f%s.html" .Lang) . }} +Hello1: {{T "hello"}} +Hello2: {{ $helloResource.Content }} +LangURL: {{ relLangURL "foo" }} + + ` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/en/index.html", ` + Hello1: Hello + Hello2: Hello + `) + + b.AssertFileContent("public/fr/index.html", ` + Hello1: Bonjour + Hello2: Bonjour + `) +} diff --git a/resources/resource_transformers/tocss/dartsass/client.go b/resources/resource_transformers/tocss/dartsass/client.go new file mode 100644 index 000000000..965232ad4 --- /dev/null +++ b/resources/resource_transformers/tocss/dartsass/client.go @@ -0,0 +1,182 @@ +// Copyright 2020 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 dartsass integrates with the Dart Sass Embedded protocol to transpile +// SCSS/SASS. +package dartsass + +import ( + "fmt" + "io" + "strings" + + "github.com/bep/godartsass/v2" + "github.com/bep/logg" + "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/common/hugo" + "github.com/gohugoio/hugo/common/paths" + "github.com/gohugoio/hugo/helpers" + "github.com/gohugoio/hugo/hugofs" + "github.com/gohugoio/hugo/hugolib/filesystems" + "github.com/gohugoio/hugo/resources" + "github.com/gohugoio/hugo/resources/resource" + "github.com/spf13/afero" + + "github.com/mitchellh/mapstructure" +) + +// used as part of the cache key. +const transformationName = "tocss-dart" + +// See https://github.com/sass/dart-sass-embedded/issues/24 +// Note: This prefix must be all lower case. +const dartSassStdinPrefix = "hugostdin:" + +func New(fs *filesystems.SourceFilesystem, rs *resources.Spec) (*Client, error) { + if !Supports() { + return &Client{}, nil + } + + if hugo.DartSassBinaryName == "" { + return nil, fmt.Errorf("no Dart Sass binary found in $PATH") + } + + if !hugo.IsDartSassGeV2() { + return nil, fmt.Errorf("unsupported Dart Sass version detected, please upgrade to Dart Sass 1.63.0 or later, see https://gohugo.io/functions/css/sass/#dart-sass") + } + + if err := rs.ExecHelper.Sec().CheckAllowedExec(hugo.DartSassBinaryName); err != nil { + return nil, err + } + + var ( + transpiler *godartsass.Transpiler + err error + infol = rs.Logger.InfoCommand("Dart Sass") + warnl = rs.Logger.WarnCommand("Dart Sass") + ) + + transpiler, err = godartsass.Start(godartsass.Options{ + DartSassEmbeddedFilename: hugo.DartSassBinaryName, + LogEventHandler: func(event godartsass.LogEvent) { + message := strings.ReplaceAll(event.Message, dartSassStdinPrefix, "") + switch event.Type { + case godartsass.LogEventTypeDebug: + // Log as Info for now, we may adjust this if it gets too chatty. + infol.Log(logg.String(message)) + case godartsass.LogEventTypeDeprecated: + warnl.Logf("DEPRECATED [%s]: %s", event.DeprecationType, message) + default: + // The rest are @warn statements. + warnl.Log(logg.String(message)) + } + }, + }) + if err != nil { + return nil, err + } + return &Client{sfs: fs, workFs: rs.BaseFs.Work, rs: rs, transpiler: transpiler}, nil +} + +type Client struct { + rs *resources.Spec + sfs *filesystems.SourceFilesystem + workFs afero.Fs + + // This may be nil if Dart Sass is not available. + transpiler *godartsass.Transpiler +} + +func (c *Client) ToCSS(res resources.ResourceTransformer, args map[string]any) (resource.Resource, error) { + if c.transpiler == nil { + return res.Transform(resources.NewFeatureNotAvailableTransformer(transformationName, args)) + } + return res.Transform(&transform{c: c, optsm: args}) +} + +func (c *Client) Close() error { + if c.transpiler == nil { + return nil + } + return c.transpiler.Close() +} + +func (c *Client) toCSS(args godartsass.Args, src io.Reader) (godartsass.Result, error) { + in := helpers.ReaderToString(src) + + args.Source = in + + res, err := c.transpiler.Execute(args) + if err != nil { + if err.Error() == "unexpected EOF" { + //lint:ignore ST1005 end user message. + return res, fmt.Errorf("got unexpected EOF when executing %q. The user running hugo must have read and execute permissions on this program. With execute permissions only, this error is thrown.", hugo.DartSassBinaryName) + } + return res, herrors.NewFileErrorFromFileInErr(err, hugofs.Os, herrors.OffsetMatcher) + } + + return res, err +} + +type Options struct { + // Hugo, will by default, just replace the extension of the source + // to .css, e.g. "scss/main.scss" becomes "scss/main.css". You can + // control this by setting this, e.g. "styles/main.css" will create + // a Resource with that as a base for RelPermalink etc. + TargetPath string + + // Hugo automatically adds the entry directories (where the main.scss lives) + // for project and themes to the list of include paths sent to LibSASS. + // Any paths set in this setting will be appended. Note that these will be + // treated as relative to the working dir, i.e. no include paths outside the + // project/themes. + IncludePaths []string + + // Default is nested. + // One of nested, expanded, compact, compressed. + OutputStyle string + + // When enabled, Hugo will generate a source map. + EnableSourceMap bool + + // If enabled, sources will be embedded in the generated source map. + SourceMapIncludeSources bool + + // Vars will be available in 'hugo:vars', e.g: + // @use "hugo:vars"; + // $color: vars.$color; + Vars map[string]any + + // Deprecations IDs in this slice will be silenced. + // The IDs can be found in the Dart Sass log output, e.g. "import" in + // WARN Dart Sass: DEPRECATED [import]. + SilenceDeprecations []string + + // Whether to silence deprecation warnings from dependencies, where a + // dependency is considered any file transitively imported through a load + // path. This does not apply to @warn or @debug rules. + SilenceDependencyDeprecations bool +} + +func decodeOptions(m map[string]any) (opts Options, err error) { + if m == nil { + return + } + err = mapstructure.WeakDecode(m, &opts) + + if opts.TargetPath != "" { + opts.TargetPath = paths.ToSlashTrimLeading(opts.TargetPath) + } + + return +} diff --git a/resources/resource_transformers/tocss/dartsass/dartsass_integration_test.go b/resources/resource_transformers/tocss/dartsass/dartsass_integration_test.go new file mode 100644 index 000000000..89d503d36 --- /dev/null +++ b/resources/resource_transformers/tocss/dartsass/dartsass_integration_test.go @@ -0,0 +1,706 @@ +// Copyright 2021 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 dartsass_test + +import ( + "strings" + "testing" + + "github.com/bep/logg" + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/htesting" + "github.com/gohugoio/hugo/hugolib" + "github.com/gohugoio/hugo/resources/resource_transformers/tocss/dartsass" +) + +func TestTransformIncludePaths(t *testing.T) { + t.Parallel() + if !dartsass.Supports() { + t.Skip() + } + + files := ` +-- assets/scss/main.scss -- +@import "moo"; +-- node_modules/foo/_moo.scss -- +$moolor: #fff; + +moo { + color: $moolor; +} +-- config.toml -- +-- layouts/index.html -- +{{ $cssOpts := (dict "includePaths" (slice "node_modules/foo") "transpiler" "dartsass" ) }} +{{ $r := resources.Get "scss/main.scss" | toCSS $cssOpts | minify }} +T1: {{ $r.Content }} + ` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + NeedsOsFS: true, + }).Build() + + b.AssertFileContent("public/index.html", `T1: moo{color:#fff}`) +} + +func TestTransformImportRegularCSS(t *testing.T) { + t.Parallel() + if !dartsass.Supports() { + t.Skip() + } + + files := ` +-- assets/scss/_moo.scss -- +$moolor: #fff; + +moo { + color: $moolor; +} +-- assets/scss/another.css -- + +-- assets/scss/main.scss -- +@import "moo"; +@import "regular.css"; +@import "moo"; +@import "another.css"; + +/* foo */ +-- assets/scss/regular.css -- + +-- config.toml -- +-- layouts/index.html -- +{{ $r := resources.Get "scss/main.scss" | toCSS (dict "transpiler" "dartsass") }} +T1: {{ $r.Content | safeHTML }} + + ` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + NeedsOsFS: true, + }, + ).Build() + + // Dart Sass does not follow regular CSS import, but they + // get pulled to the top. + b.AssertFileContent("public/index.html", `T1: @import "regular.css"; + @import "another.css"; + moo { + color: #fff; + } + + moo { + color: #fff; + } + + /* foo */`) +} + +func TestTransformImportIndentedSASS(t *testing.T) { + t.Parallel() + if !dartsass.Supports() { + t.Skip() + } + + files := ` +-- assets/scss/_moo.sass -- +#main + color: blue +-- assets/scss/main.scss -- +@import "moo"; + +/* foo */ +-- config.toml -- +-- layouts/index.html -- +{{ $r := resources.Get "scss/main.scss" | toCSS (dict "transpiler" "dartsass") }} +T1: {{ $r.Content | safeHTML }} + + ` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + NeedsOsFS: true, + }, + ).Build() + + b.AssertFileContent("public/index.html", "T1: #main {\n color: blue;\n}\n\n/* foo */") +} + +// Issue 10592 +func TestTransformImportMountedCSS(t *testing.T) { + t.Parallel() + if !dartsass.Supports() { + t.Skip() + } + + files := ` +-- assets/main.scss -- +@import "import-this-file.css"; +@import "foo/import-this-mounted-file.css"; +@import "compile-this-file"; +@import "foo/compile-this-mounted-file"; +a {color: main-scss;} +-- assets/_compile-this-file.css -- +a {color: compile-this-file-css;} +-- assets/_import-this-file.css -- +a {color: import-this-file-css;} +-- foo/_compile-this-mounted-file.css -- +a {color: compile-this-mounted-file-css;} +-- foo/_import-this-mounted-file.css -- +a {color: import-this-mounted-file-css;} +-- layouts/index.html -- +{{- $opts := dict "transpiler" "dartsass" }} +{{- with resources.Get "main.scss" | toCSS $opts }}{{ .Content | safeHTML }}{{ end }} +-- config.toml -- +disableKinds = ['RSS','sitemap','taxonomy','term','page','section'] + +[[module.mounts]] +source = 'assets' +target = 'assets' + +[[module.mounts]] +source = 'foo' +target = 'assets/foo' + ` + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + NeedsOsFS: true, + }, + ).Build() + + b.AssertFileContent("public/index.html", ` + @import "import-this-file.css"; + @import "foo/import-this-mounted-file.css"; + a { + color: compile-this-file-css; + } + + a { + color: compile-this-mounted-file-css; + } + + a { + color: main-scss; + } + `) +} + +func TestTransformThemeOverrides(t *testing.T) { + t.Parallel() + if !dartsass.Supports() { + t.Skip() + } + + files := ` +-- assets/scss/components/_boo.scss -- +$boolor: green; + +boo { + color: $boolor; +} +-- assets/scss/components/_moo.scss -- +$moolor: #ccc; + +moo { + color: $moolor; +} +-- config.toml -- +theme = 'mytheme' +-- layouts/index.html -- +{{ $cssOpts := (dict "includePaths" (slice "node_modules/foo" ) "transpiler" "dartsass" ) }} +{{ $r := resources.Get "scss/main.scss" | toCSS $cssOpts | minify }} +T1: {{ $r.Content }} +-- themes/mytheme/assets/scss/components/_boo.scss -- +$boolor: orange; + +boo { + color: $boolor; +} +-- themes/mytheme/assets/scss/components/_imports.scss -- +@import "moo"; +@import "_boo"; +@import "_zoo"; +-- themes/mytheme/assets/scss/components/_moo.scss -- +$moolor: #fff; + +moo { + color: $moolor; +} +-- themes/mytheme/assets/scss/components/_zoo.scss -- +$zoolor: pink; + +zoo { + color: $zoolor; +} +-- themes/mytheme/assets/scss/main.scss -- +@import "components/imports"; + ` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + NeedsOsFS: true, + }, + ).Build() + + b.AssertFileContent("public/index.html", `T1: moo{color:#ccc}boo{color:green}zoo{color:pink}`) +} + +func TestTransformLogging(t *testing.T) { + t.Parallel() + if !dartsass.Supports() { + t.Skip() + } + + files := ` +-- assets/scss/main.scss -- +@warn "foo"; +@debug "bar"; + +-- config.toml -- +disableKinds = ["term", "taxonomy", "section", "page"] +-- layouts/index.html -- +{{ $cssOpts := (dict "transpiler" "dartsass" ) }} +{{ $r := resources.Get "scss/main.scss" | toCSS $cssOpts }} +T1: {{ $r.Content }} + ` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + NeedsOsFS: true, + LogLevel: logg.LevelInfo, + }).Build() + + b.AssertLogMatches(`Dart Sass: foo`) + b.AssertLogMatches(`Dart Sass: .*assets.*main.scss:1:0: bar`) +} + +func TestTransformErrors(t *testing.T) { + t.Parallel() + if !dartsass.Supports() { + t.Skip() + } + + c := qt.New(t) + + const filesTemplate = ` +-- config.toml -- +-- assets/scss/components/_foo.scss -- +/* comment line 1 */ +$foocolor: #ccc; + +foo { + color: $foocolor; +} +-- assets/scss/main.scss -- +/* comment line 1 */ +/* comment line 2 */ +@import "components/foo"; +/* comment line 4 */ + + $maincolor: #eee; + +body { + color: $maincolor; +} + +-- layouts/index.html -- +{{ $cssOpts := dict "transpiler" "dartsass" }} +{{ $r := resources.Get "scss/main.scss" | toCSS $cssOpts | minify }} +T1: {{ $r.Content }} + + ` + + c.Run("error in main", func(c *qt.C) { + b, err := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: c, + TxtarString: strings.Replace(filesTemplate, "$maincolor: #eee;", "$maincolor #eee;", 1), + NeedsOsFS: true, + }).BuildE() + + b.Assert(err, qt.IsNotNil) + b.Assert(err.Error(), qt.Contains, `main.scss:8:13":`) + b.Assert(err.Error(), qt.Contains, `: expected ":".`) + fe := b.AssertIsFileError(err) + b.Assert(fe.ErrorContext(), qt.IsNotNil) + b.Assert(fe.ErrorContext().Lines, qt.DeepEquals, []string{" $maincolor #eee;", "", "body {", "\tcolor: $maincolor;", "}"}) + b.Assert(fe.ErrorContext().ChromaLexer, qt.Equals, "scss") + }) + + c.Run("error in import", func(c *qt.C) { + b, err := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: c, + TxtarString: strings.Replace(filesTemplate, "$foocolor: #ccc;", "$foocolor #ccc;", 1), + NeedsOsFS: true, + }).BuildE() + + b.Assert(err, qt.IsNotNil) + b.Assert(err.Error(), qt.Contains, `_foo.scss:2:10":`) + b.Assert(err.Error(), qt.Contains, `: expected ":".`) + fe := b.AssertIsFileError(err) + b.Assert(fe.ErrorContext(), qt.IsNotNil) + b.Assert(fe.ErrorContext().Lines, qt.DeepEquals, []string{"/* comment line 1 */", "$foocolor #ccc;", "", "foo {"}) + b.Assert(fe.ErrorContext().ChromaLexer, qt.Equals, "scss") + }) +} + +func TestOptionVars(t *testing.T) { + t.Parallel() + if !dartsass.Supports() { + t.Skip() + } + + files := ` +-- assets/scss/main.scss -- +@use "hugo:vars"; + +body { + body { + background: url(vars.$image) no-repeat center/cover; + font-family: vars.$font; + } +} + +p { + color: vars.$color1; + font-size: vars.$font_size; +} + +b { + color: vars.$color2; +} +-- layouts/index.html -- +{{ $image := "images/hero.jpg" }} +{{ $font := "Hugo's New Roman" }} +{{ $vars := dict "$color1" "blue" "$color2" "green" "font_size" "24px" "image" $image "font" $font }} +{{ $cssOpts := (dict "transpiler" "dartsass" "outputStyle" "compressed" "vars" $vars ) }} +{{ $r := resources.Get "scss/main.scss" | toCSS $cssOpts }} +T1: {{ $r.Content }} + ` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + NeedsOsFS: true, + }).Build() + + b.AssertFileContent("public/index.html", `T1: body body{background:url(images/hero.jpg) no-repeat center/cover;font-family:Hugo's New Roman}p{color:blue;font-size:24px}b{color:green}`) +} + +func TestOptionVarsParams(t *testing.T) { + t.Parallel() + if !dartsass.Supports() { + t.Skip() + } + + files := ` +-- config.toml -- +[params] +[params.sassvars] +color1 = "blue" +color2 = "green" +font_size = "24px" +image = "images/hero.jpg" +-- assets/scss/main.scss -- +@use "hugo:vars"; + +body { + body { + background: url(vars.$image) no-repeat center/cover; + } +} + +p { + color: vars.$color1; + font-size: vars.$font_size; +} + +b { + color: vars.$color2; +} +-- layouts/index.html -- +{{ $vars := site.Params.sassvars}} +{{ $cssOpts := (dict "transpiler" "dartsass" "outputStyle" "compressed" "vars" $vars ) }} +{{ $r := resources.Get "scss/main.scss" | toCSS $cssOpts }} +T1: {{ $r.Content }} + ` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + NeedsOsFS: true, + }).Build() + + b.AssertFileContent("public/index.html", `T1: body body{background:url(images/hero.jpg) no-repeat center/cover}p{color:blue;font-size:24px}b{color:green}`) +} + +func TestVarsCasting(t *testing.T) { + t.Parallel() + if !dartsass.Supports() { + t.Skip() + } + + files := ` +-- config.toml -- +disableKinds = ["term", "taxonomy", "section", "page"] + +[params] +[params.sassvars] +color_hex = "#fff" +color_rgb = "rgb(255, 255, 255)" +color_hsl = "hsl(0, 0%, 100%)" +dimension = "24px" +percentage = "10%" +flex = "5fr" +name = "Hugo" +url = "https://gohugo.io" +integer = 32 +float = 3.14 +-- assets/scss/main.scss -- +@use "hugo:vars"; +@use "sass:meta"; + +@debug meta.type-of(vars.$color_hex); +@debug meta.type-of(vars.$color_rgb); +@debug meta.type-of(vars.$color_hsl); +@debug meta.type-of(vars.$dimension); +@debug meta.type-of(vars.$percentage); +@debug meta.type-of(vars.$flex); +@debug meta.type-of(vars.$name); +@debug meta.type-of(vars.$url); +@debug meta.type-of(vars.$not_a_number); +@debug meta.type-of(vars.$integer); +@debug meta.type-of(vars.$float); +@debug meta.type-of(vars.$a_number); +-- layouts/index.html -- +{{ $vars := site.Params.sassvars}} +{{ $vars = merge $vars (dict "not_a_number" ("32xxx" | css.Quoted) "a_number" ("234" | css.Unquoted) )}} +{{ $cssOpts := (dict "transpiler" "dartsass" "vars" $vars ) }} +{{ $r := resources.Get "scss/main.scss" | toCSS $cssOpts }} +T1: {{ $r.Content }} + ` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + NeedsOsFS: true, + LogLevel: logg.LevelInfo, + }).Build() + + b.AssertLogMatches(`Dart Sass: .*assets.*main.scss:3:0: color`) + b.AssertLogMatches(`Dart Sass: .*assets.*main.scss:4:0: color`) + b.AssertLogMatches(`Dart Sass: .*assets.*main.scss:5:0: color`) + b.AssertLogMatches(`Dart Sass: .*assets.*main.scss:6:0: number`) + b.AssertLogMatches(`Dart Sass: .*assets.*main.scss:7:0: number`) + b.AssertLogMatches(`Dart Sass: .*assets.*main.scss:8:0: number`) + b.AssertLogMatches(`Dart Sass: .*assets.*main.scss:9:0: string`) + b.AssertLogMatches(`Dart Sass: .*assets.*main.scss:10:0: string`) + b.AssertLogMatches(`Dart Sass: .*assets.*main.scss:11:0: string`) + b.AssertLogMatches(`Dart Sass: .*assets.*main.scss:12:0: number`) + b.AssertLogMatches(`Dart Sass: .*assets.*main.scss:13:0: number`) + b.AssertLogMatches(`Dart Sass: .*assets.*main.scss:14:0: number`) +} + +// Note: This test is more or less duplicated in both of the SCSS packages (libsass and dartsass). +func TestBootstrap(t *testing.T) { + t.Parallel() + if !dartsass.Supports() { + t.Skip() + } + if !htesting.IsCI() { + t.Skip("skip (slow) test in non-CI environment") + } + + files := ` +-- hugo.toml -- +disableKinds = ["term", "taxonomy", "section", "page"] +[module] +[[module.imports]] +path="github.com/gohugoio/hugo-mod-bootstrap-scss/v5" +-- go.mod -- +module github.com/gohugoio/tests/testHugoModules +-- assets/scss/main.scss -- +@import "bootstrap/bootstrap"; +-- layouts/index.html -- +{{ $cssOpts := (dict "transpiler" "dartsass" ) }} +{{ $r := resources.Get "scss/main.scss" | toCSS $cssOpts }} +Styles: {{ $r.RelPermalink }} + ` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + NeedsOsFS: true, + }).Build() + + b.AssertFileContent("public/index.html", "Styles: /scss/main.css") +} + +// Issue 12849 +func TestDirectoryIndexes(t *testing.T) { + t.Parallel() + if !dartsass.Supports() { + t.Skip() + } + + files := ` +-- hugo.toml -- +disableKinds = ['page','section','rss','sitemap','taxonomy','term'] + +[[module.mounts]] +source = 'assets' +target = 'assets' +[[module.mounts]] +source = "miscellaneous/sass" +target = "assets/sass" +-- layouts/index.html -- +{{ $opts := dict "transpiler" "dartsass" "outputStyle" "compressed" }} +{{ (resources.Get "sass/main.scss" | toCSS $opts).Content }} +-- assets/sass/main.scss -- +@use "foo1"; // directory with _index file from OS file system +@use "bar1"; // directory with _index file from module mount +@use "foo2"; // directory with index file from OS file system +@use "bar2"; // directory with index file from module mount +-- assets/sass/foo1/_index.scss -- +.foo1 {color: red;} +-- miscellaneous/sass/bar1/_index.scss -- +.bar1 {color: blue;} +-- assets/sass/foo2/index.scss -- +.foo2 {color: red;} +-- miscellaneous/sass/bar2/index.scss -- +.bar2 {color: blue;} +` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + NeedsOsFS: true, + TxtarString: files, + }).Build() + + b.AssertFileContent("public/index.html", ".foo1{color:red}.bar1{color:blue}.foo2{color:red}.bar2{color:blue}") +} + +func TestIgnoreDeprecationWarnings(t *testing.T) { + t.Parallel() + if !dartsass.Supports() { + t.Skip() + } + + files := ` +-- hugo.toml -- +disableKinds = ['page','section','rss','sitemap','taxonomy','term'] +-- assets/scss/main.scss -- +@import "moo"; +-- node_modules/foo/_moo.scss -- +$moolor: #fff; + +moo { + color: $moolor; +} +-- config.toml -- +-- layouts/index.html -- +{{ $cssOpts := (dict "includePaths" (slice "node_modules/foo") "transpiler" "dartsass" ) }} +{{ $r := resources.Get "scss/main.scss" | toCSS $cssOpts | minify }} +T1: {{ $r.Content }} + ` + + b := hugolib.Test(t, files, hugolib.TestOptOsFs(), hugolib.TestOptWarn()) + b.AssertLogContains("Dart Sass: DEPRECATED [import]") + b.AssertFileContent("public/index.html", `moo{color:#fff}`) + + files = strings.ReplaceAll(files, `"transpiler" "dartsass"`, `"transpiler" "dartsass" "silenceDeprecations" (slice "import")`) + + b = hugolib.Test(t, files, hugolib.TestOptOsFs(), hugolib.TestOptWarn()) + b.AssertLogContains("! Dart Sass: DEPRECATED [import]") + b.AssertFileContent("public/index.html", `moo{color:#fff}`) +} + +func TestSilenceDependencyDeprecations(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['page','rss','section','sitemap','taxonomy','term'] +-- layouts/index.html -- +{{ $opts := dict + "transpiler" "dartsass" + "outputStyle" "compressed" + "includePaths" (slice "node_modules") + KVPAIR +}} +{{ (resources.Get "sass/main.scss" | css.Sass $opts).Content }} +-- assets/sass/main.scss -- +@use "sass:color"; +@use "foo/deprecated.scss"; +h3 { color: rgb(color.channel(#ccc, "red", $space: rgb), 0, 0); } +// COMMENT +-- node_modules/foo/deprecated.scss -- +@use "sass:color"; +h1 { color: rgb(color.channel(#eee, "red", $space: rgb), 0, 0); } +h2 { color: rgb(color.red(#ddd), 0, 0); } // deprecated +` + + expectedCSS := "h1{color:#e00}h2{color:#d00}h3{color:#c00}" + + // Do not silence dependency deprecation warnings (default). + f := strings.ReplaceAll(files, "KVPAIR", "") + b := hugolib.Test(t, f, hugolib.TestOptWarn(), hugolib.TestOptOsFs()) + b.AssertFileContent("public/index.html", expectedCSS) + b.AssertLogContains( + "WARN Dart Sass: DEPRECATED [color-functions]", + "color.red() is deprecated", + ) + + // Do not silence dependency deprecation warnings (explicit). + f = strings.ReplaceAll(files, "KVPAIR", `"silenceDependencyDeprecations" false`) + b = hugolib.Test(t, f, hugolib.TestOptWarn(), hugolib.TestOptOsFs()) + b.AssertFileContent("public/index.html", expectedCSS) + b.AssertLogContains( + "WARN Dart Sass: DEPRECATED [color-functions]", + "color.red() is deprecated", + ) + + // Silence dependency deprecation warnings. + f = strings.ReplaceAll(files, "KVPAIR", `"silenceDependencyDeprecations" true`) + b = hugolib.Test(t, f, hugolib.TestOptWarn(), hugolib.TestOptOsFs()) + b.AssertFileContent("public/index.html", expectedCSS) + b.AssertLogContains("! WARN") + + // Make sure that we are not silencing non-dependency deprecation warnings. + f = strings.ReplaceAll(files, "KVPAIR", `"silenceDependencyDeprecations" true`) + f = strings.ReplaceAll(f, "// COMMENT", "h4 { color: rgb(0, color.green(#bbb), 0); }") + b = hugolib.Test(t, f, hugolib.TestOptWarn(), hugolib.TestOptOsFs()) + b.AssertFileContent("public/index.html", expectedCSS+"h4{color:#0b0}") + b.AssertLogContains( + "WARN Dart Sass: DEPRECATED [color-functions]", + "color.green() is deprecated", + ) +} diff --git a/resources/resource_transformers/tocss/dartsass/transform.go b/resources/resource_transformers/tocss/dartsass/transform.go new file mode 100644 index 000000000..e1e9b0be0 --- /dev/null +++ b/resources/resource_transformers/tocss/dartsass/transform.go @@ -0,0 +1,210 @@ +// Copyright 2024 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 dartsass + +import ( + "fmt" + "io" + "path" + "path/filepath" + "strings" + + "github.com/gohugoio/hugo/common/hugo" + "github.com/gohugoio/hugo/common/paths" + "github.com/gohugoio/hugo/htesting" + "github.com/gohugoio/hugo/identity" + "github.com/gohugoio/hugo/media" + + "github.com/gohugoio/hugo/resources" + + "github.com/gohugoio/hugo/resources/internal" + "github.com/gohugoio/hugo/resources/resource_transformers/tocss/sass" + + "github.com/spf13/afero" + + "github.com/gohugoio/hugo/hugofs" + + "github.com/bep/godartsass/v2" +) + +// Supports returns whether sass, dart-sass, or dart-sass-embedded is found in $PATH. +func Supports() bool { + if htesting.SupportsAll() { + return true + } + return hugo.DartSassBinaryName != "" +} + +type transform struct { + optsm map[string]any + c *Client +} + +func (t *transform) Key() internal.ResourceTransformationKey { + return internal.NewResourceTransformationKey(transformationName, t.optsm) +} + +func (t *transform) Transform(ctx *resources.ResourceTransformationCtx) error { + ctx.OutMediaType = media.Builtin.CSSType + + opts, err := decodeOptions(t.optsm) + if err != nil { + return err + } + + if opts.TargetPath != "" { + ctx.OutPath = opts.TargetPath + } else { + ctx.ReplaceOutPathExtension(".css") + } + + baseDir := path.Dir(ctx.SourcePath) + filename := dartSassStdinPrefix + + if ctx.SourcePath != "" { + filename += t.c.sfs.RealFilename(ctx.SourcePath) + } + + args := godartsass.Args{ + URL: filename, + IncludePaths: t.c.sfs.RealDirs(baseDir), + ImportResolver: importResolver{ + baseDir: baseDir, + c: t.c, + dependencyManager: ctx.DependencyManager, + + varsStylesheet: godartsass.Import{Content: sass.CreateVarsStyleSheet(sass.TranspilerDart, opts.Vars)}, + }, + OutputStyle: godartsass.ParseOutputStyle(opts.OutputStyle), + EnableSourceMap: opts.EnableSourceMap, + SourceMapIncludeSources: opts.SourceMapIncludeSources, + SilenceDeprecations: opts.SilenceDeprecations, + SilenceDependencyDeprecations: opts.SilenceDependencyDeprecations, + } + + // Append any workDir relative include paths + for _, ip := range opts.IncludePaths { + info, err := t.c.workFs.Stat(filepath.Clean(ip)) + if err == nil { + filename := info.(hugofs.FileMetaInfo).Meta().Filename + args.IncludePaths = append(args.IncludePaths, filename) + } + } + + if ctx.InMediaType.SubType == media.Builtin.SASSType.SubType { + args.SourceSyntax = godartsass.SourceSyntaxSASS + } + + res, err := t.c.toCSS(args, ctx.From) + if err != nil { + return err + } + + out := res.CSS + + _, err = io.WriteString(ctx.To, out) + if err != nil { + return err + } + + if opts.EnableSourceMap && res.SourceMap != "" { + if err := ctx.PublishSourceMap(res.SourceMap); err != nil { + return err + } + _, err = fmt.Fprintf(ctx.To, "\n\n/*# sourceMappingURL=%s */", path.Base(ctx.OutPath)+".map") + } + + return err +} + +type importResolver struct { + baseDir string + c *Client + dependencyManager identity.Manager + varsStylesheet godartsass.Import +} + +func (t importResolver) CanonicalizeURL(url string) (string, error) { + if url == sass.HugoVarsNamespace { + return url, nil + } + + filePath, isURL := paths.UrlStringToFilename(url) + var prevDir string + var pathDir string + if isURL { + var found bool + prevDir, found = t.c.sfs.MakePathRelative(filepath.Dir(filePath), true) + + if !found { + // Not a member of this filesystem, let Dart Sass handle it. + return "", nil + } + } else { + prevDir = t.baseDir + pathDir = path.Dir(url) + } + + basePath := filepath.Join(prevDir, pathDir) + name := filepath.Base(filePath) + + // Pick the first match. + var namePatterns []string + if strings.Contains(name, ".") { + namePatterns = []string{"_%s", "%s"} + } else if strings.HasPrefix(name, "_") { + namePatterns = []string{"_%s.scss", "_%s.sass", "_%s.css"} + } else { + namePatterns = []string{ + "_%s.scss", "%s.scss", + "_%s.sass", "%s.sass", + "_%s.css", "%s.css", + "%s/_index.scss", "%s/_index.sass", + "%s/index.scss", "%s/index.sass", + } + } + + name = strings.TrimPrefix(name, "_") + + for _, namePattern := range namePatterns { + filenameToCheck := filepath.Join(basePath, fmt.Sprintf(namePattern, name)) + fi, err := t.c.sfs.Fs.Stat(filenameToCheck) + if err == nil { + if fim, ok := fi.(hugofs.FileMetaInfo); ok { + t.dependencyManager.AddIdentity(identity.CleanStringIdentity(filenameToCheck)) + return "file://" + filepath.ToSlash(fim.Meta().Filename), nil + } + } + } + + // Not found, let Dart Sass handle it + return "", nil +} + +func (t importResolver) Load(url string) (godartsass.Import, error) { + if url == sass.HugoVarsNamespace { + return t.varsStylesheet, nil + } + filename, _ := paths.UrlStringToFilename(url) + b, err := afero.ReadFile(hugofs.Os, filename) + + sourceSyntax := godartsass.SourceSyntaxSCSS + if strings.HasSuffix(filename, ".sass") { + sourceSyntax = godartsass.SourceSyntaxSASS + } else if strings.HasSuffix(filename, ".css") { + sourceSyntax = godartsass.SourceSyntaxCSS + } + + return godartsass.Import{Content: string(b), SourceSyntax: sourceSyntax}, err +} diff --git a/resources/resource_transformers/tocss/sass/helpers.go b/resources/resource_transformers/tocss/sass/helpers.go new file mode 100644 index 000000000..d4091a39c --- /dev/null +++ b/resources/resource_transformers/tocss/sass/helpers.go @@ -0,0 +1,102 @@ +// Copyright 2024 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 sass + +import ( + "fmt" + "regexp" + "sort" + "strings" + + "github.com/gohugoio/hugo/common/types/css" +) + +const ( + HugoVarsNamespace = "hugo:vars" + // Transpiler implementation can be controlled from the client by + // setting the 'transpiler' option. + // Default is currently 'libsass', but that may change. + TranspilerDart = "dartsass" + TranspilerLibSass = "libsass" +) + +func CreateVarsStyleSheet(transpiler string, vars map[string]any) string { + if vars == nil { + return "" + } + var varsStylesheet string + + var varsSlice []string + for k, v := range vars { + var prefix string + if !strings.HasPrefix(k, "$") { + prefix = "$" + } + + switch v.(type) { + case css.QuotedString: + // Marked by the user as a string that needs to be quoted. + varsSlice = append(varsSlice, fmt.Sprintf("%s%s: %q;", prefix, k, v)) + default: + if isTypedCSSValue(v) { + // E.g. 24px, 1.5rem, 10%, hsl(0, 0%, 100%), calc(24px + 36px), #fff, #ffffff. + varsSlice = append(varsSlice, fmt.Sprintf("%s%s: %v;", prefix, k, v)) + } else { + // unquote will preserve quotes around URLs etc. if needed. + if transpiler == TranspilerDart { + varsSlice = append(varsSlice, fmt.Sprintf("%s%s: string.unquote(%q);", prefix, k, v)) + } else { + varsSlice = append(varsSlice, fmt.Sprintf("%s%s: unquote(%q);", prefix, k, v)) + } + } + } + } + sort.Strings(varsSlice) + + if transpiler == TranspilerDart { + varsStylesheet = `@use "sass:string";` + "\n" + strings.Join(varsSlice, "\n") + } else { + varsStylesheet = strings.Join(varsSlice, "\n") + } + + return varsStylesheet +} + +var ( + isCSSColor = regexp.MustCompile(`^#[0-9a-fA-F]{3,6}$`) + isCSSFunc = regexp.MustCompile(`^([a-zA-Z-]+)\(`) + isCSSUnit = regexp.MustCompile(`^([0-9]+)(\.[0-9]+)?([a-zA-Z-%]+)$`) +) + +// isTypedCSSValue returns true if the given string is a CSS value that +// we should preserve the type of, as in: Not wrap it in quotes. +func isTypedCSSValue(v any) bool { + switch s := v.(type) { + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, css.UnquotedString: + return true + case string: + if isCSSColor.MatchString(s) { + return true + } + if isCSSFunc.MatchString(s) { + return true + } + if isCSSUnit.MatchString(s) { + return true + } + + } + + return false +} diff --git a/resources/resource_transformers/tocss/sass/helpers_test.go b/resources/resource_transformers/tocss/sass/helpers_test.go new file mode 100644 index 000000000..ef31fdd8f --- /dev/null +++ b/resources/resource_transformers/tocss/sass/helpers_test.go @@ -0,0 +1,43 @@ +// Copyright 2024 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 sass + +import ( + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestIsUnquotedCSSValue(t *testing.T) { + c := qt.New(t) + + for _, test := range []struct { + in any + out bool + }{ + {"24px", true}, + {"1.5rem", true}, + {"10%", true}, + {"hsl(0, 0%, 100%)", true}, + {"calc(24px + 36px)", true}, + {"24xxx", true}, // a false positive. + {123, true}, + {123.12, true}, + {"#fff", true}, + {"#ffffff", true}, + {"#ffffffff", false}, + } { + c.Assert(isTypedCSSValue(test.in), qt.Equals, test.out) + } +} diff --git a/resources/resource_transformers/tocss/scss/client.go b/resources/resource_transformers/tocss/scss/client.go index ddf51f7fe..aead6279b 100644 --- a/resources/resource_transformers/tocss/scss/client.go +++ b/resources/resource_transformers/tocss/scss/client.go @@ -14,17 +14,18 @@ package scss import ( - "github.com/bep/go-tocss/scss" - "github.com/gohugoio/hugo/helpers" + "regexp" + + "github.com/gohugoio/hugo/common/paths" "github.com/gohugoio/hugo/hugolib/filesystems" "github.com/gohugoio/hugo/resources" - "github.com/gohugoio/hugo/resources/internal" - "github.com/gohugoio/hugo/resources/resource" "github.com/spf13/afero" "github.com/mitchellh/mapstructure" ) +const transformationName = "tocss" + type Client struct { rs *resources.Spec sfs *filesystems.SourceFilesystem @@ -36,7 +37,6 @@ func New(fs *filesystems.SourceFilesystem, rs *resources.Spec) (*Client, error) } type Options struct { - // Hugo, will by default, just replace the extension of the source // to .css, e.g. "scss/main.scss" becomes "scss/main.css". You can // control this by setting this, e.g. "styles/main.css" will create @@ -59,52 +59,35 @@ type Options struct { // When enabled, Hugo will generate a source map. EnableSourceMap bool + + // Vars will be available in 'hugo:vars', e.g: + // @import "hugo:vars"; + Vars map[string]any } -type options struct { - // The options we receive from the end user. - from Options - - // The options we send to the SCSS library. - to scss.Options -} - -func (c *Client) ToCSS(res resources.ResourceTransformer, opts Options) (resource.Resource, error) { - internalOptions := options{ - from: opts, - } - - // Transfer values from client. - internalOptions.to.Precision = opts.Precision - internalOptions.to.OutputStyle = scss.OutputStyleFromString(opts.OutputStyle) - - if internalOptions.to.Precision == 0 { - // bootstrap-sass requires 8 digits precision. The libsass default is 5. - // https://github.com/twbs/bootstrap-sass/blob/master/README.md#sass-number-precision - internalOptions.to.Precision = 8 - } - - return res.Transform(&toCSSTransformation{c: c, options: internalOptions}) -} - -type toCSSTransformation struct { - c *Client - options options -} - -func (t *toCSSTransformation) Key() internal.ResourceTransformationKey { - return internal.NewResourceTransformationKey("tocss", t.options.from) -} - -func DecodeOptions(m map[string]interface{}) (opts Options, err error) { +func DecodeOptions(m map[string]any) (opts Options, err error) { if m == nil { return } err = mapstructure.WeakDecode(m, &opts) if opts.TargetPath != "" { - opts.TargetPath = helpers.ToSlashTrimLeading(opts.TargetPath) + opts.TargetPath = paths.ToSlashTrimLeading(opts.TargetPath) } return } + +var ( + regularCSSImportTo = regexp.MustCompile(`.*(@import "(.*\.css)";).*`) + regularCSSImportFrom = regexp.MustCompile(`.*(\/\* HUGO_IMPORT_START (.*) HUGO_IMPORT_END \*\/).*`) +) + +func replaceRegularImportsIn(s string) (string, bool) { + replaced := regularCSSImportTo.ReplaceAllString(s, "/* HUGO_IMPORT_START $2 HUGO_IMPORT_END */") + return replaced, s != replaced +} + +func replaceRegularImportsOut(s string) string { + return regularCSSImportFrom.ReplaceAllString(s, "@import \"$2\";") +} diff --git a/resources/resource_transformers/tocss/scss/client_extended.go b/resources/resource_transformers/tocss/scss/client_extended.go new file mode 100644 index 000000000..bd2330f6d --- /dev/null +++ b/resources/resource_transformers/tocss/scss/client_extended.go @@ -0,0 +1,58 @@ +// Copyright 2018 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. + +//go:build extended + +package scss + +import ( + "github.com/bep/golibsass/libsass" + "github.com/gohugoio/hugo/resources" + "github.com/gohugoio/hugo/resources/internal" + "github.com/gohugoio/hugo/resources/resource" +) + +type options struct { + // The options we receive from the end user. + from Options + + // The options we send to the SCSS library. + to libsass.Options +} + +func (c *Client) ToCSS(res resources.ResourceTransformer, opts Options) (resource.Resource, error) { + internalOptions := options{ + from: opts, + } + + // Transfer values from client. + internalOptions.to.Precision = opts.Precision + internalOptions.to.OutputStyle = libsass.ParseOutputStyle(opts.OutputStyle) + + if internalOptions.to.Precision == 0 { + // bootstrap-sass requires 8 digits precision. The libsass default is 5. + // https://github.com/twbs/bootstrap-sass/blob/master/README.md#sass-number-precision + internalOptions.to.Precision = 8 + } + + return res.Transform(&toCSSTransformation{c: c, options: internalOptions}) +} + +type toCSSTransformation struct { + c *Client + options options +} + +func (t *toCSSTransformation) Key() internal.ResourceTransformationKey { + return internal.NewResourceTransformationKey(transformationName, t.options.from) +} diff --git a/resources/resource_transformers/tocss/scss/client_notavailable.go b/resources/resource_transformers/tocss/scss/client_notavailable.go new file mode 100644 index 000000000..783a7d7db --- /dev/null +++ b/resources/resource_transformers/tocss/scss/client_notavailable.go @@ -0,0 +1,30 @@ +// Copyright 2020 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. + +//go:build !extended + +package scss + +import ( + "github.com/gohugoio/hugo/resources" + "github.com/gohugoio/hugo/resources/resource" +) + +func (c *Client) ToCSS(res resources.ResourceTransformer, opts Options) (resource.Resource, error) { + return res.Transform(resources.NewFeatureNotAvailableTransformer(transformationName, opts)) +} + +// Used in tests. +func Supports() bool { + return false +} diff --git a/resources/resource_transformers/tocss/scss/client_test.go b/resources/resource_transformers/tocss/scss/client_test.go new file mode 100644 index 000000000..9dddd3869 --- /dev/null +++ b/resources/resource_transformers/tocss/scss/client_test.go @@ -0,0 +1,49 @@ +// Copyright 2020 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 scss + +import ( + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestReplaceRegularCSSImports(t *testing.T) { + c := qt.New(t) + + scssWithImport := ` + +@import "moo"; +@import "regular.css"; +@import "moo"; +@import "another.css"; +@import "foo.scss"; + +/* foo */` + + scssWithoutImport := ` +@import "moo"; +/* foo */` + + res, replaced := replaceRegularImportsIn(scssWithImport) + c.Assert(replaced, qt.Equals, true) + c.Assert(res, qt.Equals, "\n\t\n@import \"moo\";\n/* HUGO_IMPORT_START regular.css HUGO_IMPORT_END */\n@import \"moo\";\n/* HUGO_IMPORT_START another.css HUGO_IMPORT_END */\n@import \"foo.scss\";\n\n/* foo */") + + res2, replaced2 := replaceRegularImportsIn(scssWithoutImport) + c.Assert(replaced2, qt.Equals, false) + c.Assert(res2, qt.Equals, scssWithoutImport) + + reverted := replaceRegularImportsOut(res) + c.Assert(reverted, qt.Equals, scssWithImport) +} diff --git a/resources/resource_transformers/tocss/scss/scss_integration_test.go b/resources/resource_transformers/tocss/scss/scss_integration_test.go new file mode 100644 index 000000000..0154a4634 --- /dev/null +++ b/resources/resource_transformers/tocss/scss/scss_integration_test.go @@ -0,0 +1,464 @@ +// Copyright 2024 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 scss_test + +import ( + "path/filepath" + "strings" + "testing" + + qt "github.com/frankban/quicktest" + + "github.com/gohugoio/hugo/htesting" + "github.com/gohugoio/hugo/hugolib" + "github.com/gohugoio/hugo/resources/resource_transformers/tocss/scss" +) + +func TestTransformIncludePaths(t *testing.T) { + t.Parallel() + if !scss.Supports() { + t.Skip() + } + c := qt.New(t) + + files := ` +-- assets/scss/main.scss -- +@import "moo"; +-- node_modules/foo/_moo.scss -- +$moolor: #fff; + +moo { + color: $moolor; +} +-- config.toml -- +-- layouts/index.html -- +{{ $cssOpts := (dict "includePaths" (slice "node_modules/foo") ) }} +{{ $r := resources.Get "scss/main.scss" | toCSS $cssOpts | minify }} +T1: {{ $r.Content }} + ` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: c, + TxtarString: files, + NeedsOsFS: true, + }).Build() + + b.AssertFileContent("public/index.html", `T1: moo{color:#fff}`) +} + +func TestTransformImportRegularCSS(t *testing.T) { + t.Parallel() + if !scss.Supports() { + t.Skip() + } + + c := qt.New(t) + + files := ` +-- assets/scss/_moo.scss -- +$moolor: #fff; + +moo { + color: $moolor; +} +-- assets/scss/another.css -- + +-- assets/scss/main.scss -- +@import "moo"; +@import "regular.css"; +@import "moo"; +@import "another.css"; + +/* foo */ +-- assets/scss/regular.css -- + +-- config.toml -- +-- layouts/index.html -- +{{ $r := resources.Get "scss/main.scss" | toCSS }} +T1: {{ $r.Content | safeHTML }} + + ` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: c, + TxtarString: files, + NeedsOsFS: true, + }).Build() + + // LibSass does not support regular CSS imports. There + // is an open bug about it that probably will never be resolved. + // Hugo works around this by preserving them in place: + b.AssertFileContent("public/index.html", ` + T1: moo { + color: #fff; } + +@import "regular.css"; +moo { + color: #fff; } + +@import "another.css"; +/* foo */ + +`) +} + +func TestTransformThemeOverrides(t *testing.T) { + t.Parallel() + if !scss.Supports() { + t.Skip() + } + + c := qt.New(t) + + files := ` +-- assets/scss/components/_boo.scss -- +$boolor: green; + +boo { + color: $boolor; +} +-- assets/scss/components/_moo.scss -- +$moolor: #ccc; + +moo { + color: $moolor; +} +-- config.toml -- +theme = 'mytheme' +-- layouts/index.html -- +{{ $cssOpts := (dict "includePaths" (slice "node_modules/foo" ) ) }} +{{ $r := resources.Get "scss/main.scss" | toCSS $cssOpts | minify }} +T1: {{ $r.Content }} +-- themes/mytheme/assets/scss/components/_boo.scss -- +$boolor: orange; + +boo { + color: $boolor; +} +-- themes/mytheme/assets/scss/components/_imports.scss -- +@import "moo"; +@import "_boo"; +@import "_zoo"; +-- themes/mytheme/assets/scss/components/_moo.scss -- +$moolor: #fff; + +moo { + color: $moolor; +} +-- themes/mytheme/assets/scss/components/_zoo.scss -- +$zoolor: pink; + +zoo { + color: $zoolor; +} +-- themes/mytheme/assets/scss/main.scss -- +@import "components/imports"; + ` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: c, + TxtarString: files, + NeedsOsFS: true, + }).Build() + + b.AssertFileContent("public/index.html", `T1: moo{color:#ccc}boo{color:green}zoo{color:pink}`) +} + +func TestTransformErrors(t *testing.T) { + t.Parallel() + if !scss.Supports() { + t.Skip() + } + + c := qt.New(t) + + const filesTemplate = ` +-- config.toml -- +theme = 'mytheme' +-- assets/scss/components/_foo.scss -- +/* comment line 1 */ +$foocolor: #ccc; + +foo { + color: $foocolor; +} +-- themes/mytheme/assets/scss/main.scss -- +/* comment line 1 */ +/* comment line 2 */ +@import "components/foo"; +/* comment line 4 */ + +$maincolor: #eee; + +body { + color: $maincolor; +} + +-- layouts/index.html -- +{{ $cssOpts := dict }} +{{ $r := resources.Get "scss/main.scss" | toCSS $cssOpts | minify }} +T1: {{ $r.Content }} + + ` + + c.Run("error in main", func(c *qt.C) { + b, err := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: c, + TxtarString: strings.Replace(filesTemplate, "$maincolor: #eee;", "$maincolor #eee;", 1), + NeedsOsFS: true, + }).BuildE() + + b.Assert(err, qt.IsNotNil) + b.Assert(err.Error(), qt.Contains, filepath.FromSlash(`themes/mytheme/assets/scss/main.scss:6:1": expected ':' after $maincolor in assignment statement`)) + fe := b.AssertIsFileError(err) + b.Assert(fe.ErrorContext(), qt.IsNotNil) + b.Assert(fe.ErrorContext().Lines, qt.DeepEquals, []string{"/* comment line 4 */", "", "$maincolor #eee;", "", "body {"}) + b.Assert(fe.ErrorContext().ChromaLexer, qt.Equals, "scss") + }) + + c.Run("error in import", func(c *qt.C) { + b, err := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: c, + TxtarString: strings.Replace(filesTemplate, "$foocolor: #ccc;", "$foocolor #ccc;", 1), + NeedsOsFS: true, + }).BuildE() + + b.Assert(err, qt.IsNotNil) + b.Assert(err.Error(), qt.Contains, `assets/scss/components/_foo.scss:2:1": expected ':' after $foocolor in assignment statement`) + fe := b.AssertIsFileError(err) + b.Assert(fe.ErrorContext(), qt.IsNotNil) + b.Assert(fe.ErrorContext().Lines, qt.DeepEquals, []string{"/* comment line 1 */", "$foocolor #ccc;", "", "foo {"}) + b.Assert(fe.ErrorContext().ChromaLexer, qt.Equals, "scss") + }) +} + +func TestOptionVars(t *testing.T) { + t.Parallel() + if !scss.Supports() { + t.Skip() + } + + files := ` +-- assets/scss/main.scss -- +@import "hugo:vars"; + +body { + body { + background: url($image) no-repeat center/cover; + font-family: $font; + } +} + +p { + color: $color1; + font-size: var$font_size; +} + +b { + color: $color2; +} +-- layouts/index.html -- +{{ $image := "images/hero.jpg" }} +{{ $font := "Hugo's New Roman" }} +{{ $vars := dict "$color1" "blue" "$color2" "green" "font_size" "24px" "image" $image "font" $font }} +{{ $cssOpts := (dict "transpiler" "libsass" "outputStyle" "compressed" "vars" $vars ) }} +{{ $r := resources.Get "scss/main.scss" | toCSS $cssOpts }} +T1: {{ $r.Content }} + ` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + NeedsOsFS: true, + }).Build() + + b.AssertFileContent("public/index.html", `T1: body body{background:url(images/hero.jpg) no-repeat center/cover;font-family:Hugo's New Roman}p{color:blue;font-size:var 24px}b{color:green}`) +} + +// Note: This test is more or less duplicated in both of the SCSS packages (libsass and dartsass). +func TestBootstrap(t *testing.T) { + t.Parallel() + if !scss.Supports() { + t.Skip() + } + if !htesting.IsCI() { + t.Skip("skip (slow) test in non-CI environment") + } + + files := ` +-- hugo.toml -- +disableKinds = ["term", "taxonomy", "section", "page"] +[module] +[[module.imports]] +path="github.com/gohugoio/hugo-mod-bootstrap-scss/v5" +-- go.mod -- +module github.com/gohugoio/tests/testHugoModules +-- assets/scss/main.scss -- +@import "bootstrap/bootstrap"; +-- layouts/index.html -- +{{ $cssOpts := (dict "transpiler" "libsass" ) }} +{{ $r := resources.Get "scss/main.scss" | toCSS $cssOpts }} +Styles: {{ $r.RelPermalink }} + ` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + NeedsOsFS: true, + }).Build() + + b.AssertFileContent("public/index.html", "Styles: /scss/main.css") +} + +// Issue #1239. +func TestRebuildAssetGetMatch(t *testing.T) { + t.Parallel() + if !scss.Supports() { + t.Skip() + } + + files := ` +-- assets/scss/main.scss -- +b { + color: red; +} +-- layouts/index.html -- +{{ $r := resources.GetMatch "scss/main.scss" | toCSS }} +T1: {{ $r.Content }} + ` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + NeedsOsFS: true, + Running: true, + }).Build() + + b.AssertFileContent("public/index.html", `color: red`) + + b.EditFiles("assets/scss/main.scss", `b { color: blue; }`).Build() + + b.AssertFileContent("public/index.html", `color: blue`) +} + +func TestRebuildAssetMatchIssue12456(t *testing.T) { + t.Parallel() + if !scss.Supports() { + t.Skip() + } + + files := ` +-- hugo.toml -- +disableKinds = ["term", "taxonomy", "section", "page"] +disableLiveReload = true +-- assets/a.scss -- +h1 { + color: red; +} +-- assets/dir/b.scss -- +h2 { + color: blue; +} +-- assets/dir/c.scss -- +h3 { + color: green; +} +-- layouts/index.html -- +{{ $a := slice (resources.Get "a.scss") }} +{{ $b := resources.Match "dir/*.scss" }} + +{{/* Add styles in a specific order. */}} +{{ $styles := slice $a $b }} + +{{ $stylesheets := slice }} + {{ range $styles }} + {{ $stylesheets = $stylesheets | collections.Append . }} +{{ end }} + + +{{ range $stylesheets }} + {{ with . | css.Sass | fingerprint }} + + {{ end }} +{{ end }} + ` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + NeedsOsFS: true, + Running: true, + // LogLevel: logg.LevelTrace, + }).Build() + + b.AssertFileContent("public/index.html", `b.60a9f3bdc189ee8a857afd5b7e1b93ad1644de0873761a7c9bc84f781a821942.css`) + + b.EditFiles("assets/dir/b.scss", `h2 { color: orange; }`).Build() + + b.AssertFileContent("public/index.html", `b.46b2d77c7ffe37ee191678f72df991ecb1319f849957151654362f09b0ef467f.css`) +} + +// Issue 12851 +func TestDirectoryIndexes(t *testing.T) { + t.Parallel() + if !scss.Supports() { + t.Skip() + } + + files := ` +-- hugo.toml -- +disableKinds = ['page','section','rss','sitemap','taxonomy','term'] + +[[module.mounts]] +source = 'assets' +target = 'assets' +[[module.mounts]] +source = "miscellaneous/sass" +target = "assets/sass" +-- layouts/index.html -- +{{ $opts := dict "transpiler" "libsass" "outputStyle" "compressed" }} +{{ (resources.Get "sass/main.scss" | toCSS $opts).Content }} +-- assets/sass/main.scss -- +@import "foo1"; // directory with _index file from OS file system +@import "bar1"; // directory with _index file from module mount +@import "foo2"; // directory with index file from OS file system +@import "bar2"; // directory with index file from module mount +-- assets/sass/foo1/_index.scss -- +.foo1 {color: red;} +-- miscellaneous/sass/bar1/_index.scss -- +.bar1 {color: blue;} +-- assets/sass/foo2/index.scss -- +.foo2 {color: red;} +-- miscellaneous/sass/bar2/index.scss -- +.bar2 {color: blue;} +` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + NeedsOsFS: true, + TxtarString: files, + }).Build() + + b.AssertFileContent("public/index.html", ".foo1{color:red}.bar1{color:blue}.foo2{color:red}.bar2{color:blue}") +} diff --git a/resources/resource_transformers/tocss/scss/tocss.go b/resources/resource_transformers/tocss/scss/tocss.go index ad581d681..2976578af 100644 --- a/resources/resource_transformers/tocss/scss/tocss.go +++ b/resources/resource_transformers/tocss/scss/tocss.go @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -// +build extended +//go:build extended package scss @@ -22,14 +22,15 @@ import ( "path/filepath" "strings" - "github.com/bep/go-tocss/scss" - "github.com/bep/go-tocss/scss/libsass" - "github.com/bep/go-tocss/tocss" + "github.com/bep/golibsass/libsass" + "github.com/bep/golibsass/libsass/libsasserrors" + "github.com/gohugoio/hugo/common/herrors" "github.com/gohugoio/hugo/helpers" "github.com/gohugoio/hugo/hugofs" + "github.com/gohugoio/hugo/identity" "github.com/gohugoio/hugo/media" "github.com/gohugoio/hugo/resources" - "github.com/pkg/errors" + "github.com/gohugoio/hugo/resources/resource_transformers/tocss/sass" ) // Used in tests. This feature requires Hugo to be built with the extended tag. @@ -38,7 +39,7 @@ func Supports() bool { } func (t *toCSSTransformation) Transform(ctx *resources.ResourceTransformationCtx) error { - ctx.OutMediaType = media.CSSType + ctx.OutMediaType = media.Builtin.CSSType var outName string if t.options.from.TargetPath != "" { @@ -57,16 +58,22 @@ func (t *toCSSTransformation) Transform(ctx *resources.ResourceTransformationCtx for _, ip := range options.from.IncludePaths { info, err := t.c.workFs.Stat(filepath.Clean(ip)) if err == nil { - filename := info.(hugofs.FileMetaInfo).Meta().Filename() + filename := info.(hugofs.FileMetaInfo).Meta().Filename options.to.IncludePaths = append(options.to.IncludePaths, filename) } } + varsStylesheet := sass.CreateVarsStyleSheet(sass.TranspilerLibSass, options.from.Vars) + // To allow for overrides of SCSS files anywhere in the project/theme hierarchy, we need // to help libsass revolve the filename by looking in the composite filesystem first. // We add the entry directories for both project and themes to the include paths list, but // that only work for overrides on the top level. options.to.ImportResolver = func(url string, prev string) (newUrl string, body string, resolved bool) { + if url == sass.HugoVarsNamespace { + return url, varsStylesheet, true + } + // We get URL paths from LibSASS, but we need file paths. url = filepath.FromSlash(url) prev = filepath.FromSlash(prev) @@ -74,10 +81,11 @@ func (t *toCSSTransformation) Transform(ctx *resources.ResourceTransformationCtx var basePath string urlDir := filepath.Dir(url) var prevDir string + if prev == "stdin" { prevDir = baseDir } else { - prevDir = t.c.sfs.MakePathRelative(filepath.Dir(prev)) + prevDir, _ = t.c.sfs.MakePathRelative(filepath.Dir(prev), true) if prevDir == "" { // Not a member of this filesystem. Let LibSASS handle it. @@ -96,7 +104,12 @@ func (t *toCSSTransformation) Transform(ctx *resources.ResourceTransformationCtx } else if strings.HasPrefix(name, "_") { namePatterns = []string{"_%s.scss", "_%s.sass"} } else { - namePatterns = []string{"_%s.scss", "%s.scss", "_%s.sass", "%s.sass"} + namePatterns = []string{ + "_%s.scss", "%s.scss", + "_%s.sass", "%s.sass", + "%s/_index.scss", "%s/_index.sass", + "%s/index.scss", "%s/index.sass", + } } name = strings.TrimPrefix(name, "_") @@ -106,7 +119,8 @@ func (t *toCSSTransformation) Transform(ctx *resources.ResourceTransformationCtx fi, err := t.c.sfs.Fs.Stat(filenameToCheck) if err == nil { if fim, ok := fi.(hugofs.FileMetaInfo); ok { - return fim.Meta().Filename(), "", true + ctx.DependencyManager.AddIdentity(identity.CleanStringIdentity(filenameToCheck)) + return fim.Meta().Filename, "", true } } } @@ -115,36 +129,43 @@ func (t *toCSSTransformation) Transform(ctx *resources.ResourceTransformationCtx return "", "", false } - if ctx.InMediaType.SubType == media.SASSType.SubType { + if ctx.InMediaType.SubType == media.Builtin.SASSType.SubType { options.to.SassSyntax = true } if options.from.EnableSourceMap { - options.to.SourceMapFilename = outName + ".map" - options.to.SourceMapRoot = t.c.rs.WorkingDir + options.to.SourceMapOptions.Filename = outName + ".map" + options.to.SourceMapOptions.Root = t.c.rs.Cfg.BaseConfig().WorkingDir // Setting this to the relative input filename will get the source map // more correct for the main entry path (main.scss typically), but // it will mess up the import mappings. As a workaround, we do a replacement // in the source map itself (see below). - //options.InputPath = inputPath - options.to.OutputPath = outName - options.to.SourceMapContents = true - options.to.OmitSourceMapURL = false - options.to.EnableEmbeddedSourceMap = false + // options.InputPath = inputPath + options.to.SourceMapOptions.OutputPath = outName + options.to.SourceMapOptions.Contents = true + options.to.SourceMapOptions.OmitURL = false + options.to.SourceMapOptions.EnableEmbedded = false } res, err := t.c.toCSS(options.to, ctx.To, ctx.From) if err != nil { - return err + if sasserr, ok := err.(libsasserrors.Error); ok { + if sasserr.File == "stdin" && ctx.SourcePath != "" { + sasserr.File = t.c.sfs.RealFilename(ctx.SourcePath) + err = sasserr + } + } + return herrors.NewFileErrorFromFileInErr(err, hugofs.Os, nil) + } if options.from.EnableSourceMap && res.SourceMapContent != "" { sourcePath := t.c.sfs.RealFilename(ctx.SourcePath) - if strings.HasPrefix(sourcePath, t.c.rs.WorkingDir) { - sourcePath = strings.TrimPrefix(sourcePath, t.c.rs.WorkingDir+helpers.FilePathSeparator) + if strings.HasPrefix(sourcePath, t.c.rs.Cfg.BaseConfig().WorkingDir) { + sourcePath = strings.TrimPrefix(sourcePath, t.c.rs.Cfg.BaseConfig().WorkingDir+helpers.FilePathSeparator) } // This needs to be Unix-style slashes, even on Windows. @@ -154,25 +175,42 @@ func (t *toCSSTransformation) Transform(ctx *resources.ResourceTransformationCtx // This is a workaround for what looks like a bug in Libsass. But // getting this resolution correct in tools like Chrome Workspaces // is important enough to go this extra mile. - mapContent := strings.Replace(res.SourceMapContent, `stdin",`, fmt.Sprintf("%s\",", sourcePath), 1) + mapContent := strings.Replace(res.SourceMapContent, `stdin"`, fmt.Sprintf("%s\"", sourcePath), 1) return ctx.PublishSourceMap(mapContent) } return nil } -func (c *Client) toCSS(options scss.Options, dst io.Writer, src io.Reader) (tocss.Result, error) { - var res tocss.Result +func (c *Client) toCSS(options libsass.Options, dst io.Writer, src io.Reader) (libsass.Result, error) { + var res libsass.Result transpiler, err := libsass.New(options) if err != nil { return res, err } - res, err = transpiler.Execute(dst, src) + in := helpers.ReaderToString(src) + + // See https://github.com/gohugoio/hugo/issues/7059 + // We need to preserve the regular CSS imports. This is by far + // a perfect solution, and only works for the main entry file, but + // that should cover many use cases, e.g. using SCSS as a preprocessor + // for Tailwind. + var importsReplaced bool + in, importsReplaced = replaceRegularImportsIn(in) + + res, err = transpiler.Execute(in) if err != nil { - return res, errors.Wrap(err, "SCSS processing failed") + return res, err } - return res, nil + out := res.CSS + if importsReplaced { + out = replaceRegularImportsOut(out) + } + + _, err = io.WriteString(dst, out) + + return res, err } diff --git a/resources/resource_transformers/tocss/scss/tocss_notavailable.go b/resources/resource_transformers/tocss/scss/tocss_notavailable.go deleted file mode 100644 index ad6b42b98..000000000 --- a/resources/resource_transformers/tocss/scss/tocss_notavailable.go +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright 2018 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. - -// +build !extended - -package scss - -import ( - "github.com/gohugoio/hugo/common/herrors" - "github.com/gohugoio/hugo/resources" -) - -// Used in tests. -func Supports() bool { - return false -} - -func (t *toCSSTransformation) Transform(ctx *resources.ResourceTransformationCtx) error { - return herrors.ErrFeatureNotAvailable -} diff --git a/resources/resources_integration_test.go b/resources/resources_integration_test.go new file mode 100644 index 000000000..0d02b45d5 --- /dev/null +++ b/resources/resources_integration_test.go @@ -0,0 +1,277 @@ +// Copyright 2024 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 resources_test + +import ( + "strings" + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/hugolib" +) + +// Issue 8931 +func TestImageCache(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +disableLiveReload = true +baseURL = "https://example.org" +-- content/mybundle/index.md -- +--- +title: "My Bundle" +--- +-- content/mybundle/pixel.png -- +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg== +-- content/mybundle/giphy.gif -- +sourcefilename: testdata/giphy.gif +-- layouts/foo.html -- +-- layouts/index.html -- +{{ $p := site.GetPage "mybundle"}} +{{ $img := $p.Resources.Get "pixel.png" }} +{{ $giphy := $p.Resources.Get "giphy.gif" }} +{{ $gif := $img.Resize "1x2 gif" }} +{{ $bmp := $img.Resize "2x3 bmp" }} +{{ $anigif := $giphy.Resize "4x5" }} + + +gif: {{ $gif.RelPermalink }}|}|{{ $gif.Width }}|{{ $gif.Height }}|{{ $gif.MediaType }}| +bmp: {{ $bmp.RelPermalink }}|}|{{ $bmp.Width }}|{{ $bmp.Height }}|{{ $bmp.MediaType }}| +anigif: {{ $anigif.RelPermalink }}|{{ $anigif.Width }}|{{ $anigif.Height }}|{{ $anigif.MediaType }}| +` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + NeedsOsFS: true, + Running: true, + }).Build() + + assertImages := func() { + b.AssertFileContent("public/index.html", ` + gif: /mybundle/pixel_hu_93429543fc146fce.gif|}|1|2|image/gif| +bmp: /mybundle/pixel_hu_f9bf2acd6578e2c6.bmp|}|2|3|image/bmp| +anigif: /mybundle/giphy_hu_652d28653068b48f.gif|4|5|image/gif| + `) + } + + assertImages() + + b.EditFileReplaceFunc("content/mybundle/index.md", func(s string) string { return strings.ReplaceAll(s, "Bundle", "BUNDLE") }) + b.Build() + + assertImages() +} + +func TestSVGError(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +-- assets/circle.svg -- + +-- layouts/index.html -- +{{ $svg := resources.Get "circle.svg" }} +Width: {{ $svg.Width }} +` + + b, err := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + NeedsOsFS: true, + Running: true, + }).BuildE() + + b.Assert(err, qt.IsNotNil) + b.Assert(err.Error(), qt.Contains, `error calling Width: this method is only available for raster images. To determine if an image is SVG, you can do {{ if eq .MediaType.SubType "svg" }}{{ end }}`) +} + +// Issue 10255. +func TestNoPublishOfUnusedProcessedImage(t *testing.T) { + t.Parallel() + + workingDir := t.TempDir() + + files := ` +-- assets/images/pixel.png -- +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg== +-- layouts/index.html -- +{{ $image := resources.Get "images/pixel.png" }} +{{ $image = $image.Resize "400x" }} +{{ $image = $image.Resize "300x" }} +{{ $image = $image.Resize "200x" }} +{{ $image = $image.Resize "100x" }} +{{ $image = $image.Crop "50x50" }} +{{ $image = $image.Filter (images.GaussianBlur 6) }} +{{ ($image | fingerprint).Permalink }} + + +` + + for range 3 { + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + NeedsOsFS: true, + WorkingDir: workingDir, + }).Build() + + b.AssertFileCount("resources/_gen/images", 6) + b.AssertFileCount("public/images", 1) + b.Build() + } +} + +func TestProcessFilter(t *testing.T) { + t.Parallel() + + files := ` +-- assets/images/pixel.png -- +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg== +-- layouts/index.html -- +{{ $pixel := resources.Get "images/pixel.png" }} +{{ $filters := slice (images.GaussianBlur 6) (images.Pixelate 8) (images.Process "jpg") }} +{{ $image := $pixel.Filter $filters }} +jpg|RelPermalink: {{ $image.RelPermalink }}|MediaType: {{ $image.MediaType }}|Width: {{ $image.Width }}|Height: {{ $image.Height }}| +{{ $filters := slice (images.GaussianBlur 6) (images.Pixelate 8) (images.Process "jpg resize 20x30") }} +{{ $image := $pixel.Filter $filters }} +resize 1|RelPermalink: {{ $image.RelPermalink }}|MediaType: {{ $image.MediaType }}|Width: {{ $image.Width }}|Height: {{ $image.Height }}| +{{ $image := $pixel.Filter $filters }} +resize 2|RelPermalink: {{ $image.RelPermalink }}|MediaType: {{ $image.MediaType }}|Width: {{ $image.Width }}|Height: {{ $image.Height }}| + +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", + "jpg|RelPermalink: /images/pixel_hu_38c3f257174fc757.jpg|MediaType: image/jpeg|Width: 1|Height: 1|", + "resize 1|RelPermalink: /images/pixel_hu_b5c2a3d88991f65a.jpg|MediaType: image/jpeg|Width: 20|Height: 30|", + "resize 2|RelPermalink: /images/pixel_hu_b5c2a3d88991f65a.jpg|MediaType: image/jpeg|Width: 20|Height: 30|", + ) +} + +// Issue #11563 +func TestGroupByParamDate(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +disableKinds = ['section','rss','sitemap','taxonomy','term'] +-- layouts/index.html -- +{{- range site.RegularPages.GroupByParamDate "eventDate" "2006-01" }} + {{- .Key }}|{{ range .Pages }}{{ .Title }}|{{ end }} +{{- end }} +-- content/p1.md -- ++++ +title = 'p1' +eventDate = 2023-09-01 ++++ +-- content/p2.md -- ++++ +title = 'p2' +eventDate = '2023-09-01' ++++ +-- content/p3.md -- +--- +title: p3 +eventDate: 2023-09-01 +--- +-- content/p4.md -- ++++ +title = 'p4' +eventDate = 2023-10-01T08:00:00 ++++ +-- content/p5.md -- ++++ +title = 'p5' +eventDate = '2023-10-01T08:00:00' ++++ +-- content/p6.md -- +--- +title: p6 +eventDate: 2023-10-01T08:00:00 +--- +-- content/p7.md -- ++++ +title = 'p7' +eventDate = 2023-11-01T07:00:00-08:00 ++++ +-- content/p8.md -- ++++ +title = 'p8' +eventDate = '2023-11-01T07:00:00-08:00' ++++ +-- content/p9.md -- +--- +title: p9 +eventDate: 2023-11-01T07:00:00-08:00 +--- + ` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", "2023-11|p9|p8|p7|2023-10|p6|p5|p4|2023-09|p3|p2|p1|") +} + +// Issue 10412 +func TestImageTransformThenCopy(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['page','rss','section','sitemap','taxonomy','term'] +-- assets/pixel.png -- +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg== +-- layouts/index.html -- +{{- with resources.Get "pixel.png" }} + {{- with .Resize "200x" | resources.Copy "pixel.png" }} + |{{ .Key }} + {{- end }} +{{- end }} +` + + b := hugolib.Test(t, files) + + b.AssertFileExists("public/pixel.png", true) + b.AssertFileContent("public/index.html", + `|/pixel.png`, + ) +} + +// Issue 12310 +func TestUseDifferentCacheKeyForResourceCopy(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['page','section','rss','sitemap','taxonomy','term'] +-- assets/a.txt -- +This was assets/a.txt +-- layouts/index.html -- +{{ $nilResource := resources.Get "/p1/b.txt" }} +{{ $r := resources.Get "a.txt" }} +{{ $r = resources.Copy "/p1/b.txt" $r }} +{{ $r.RelPermalink }} +` + + b, err := hugolib.TestE(t, files) + + b.Assert(err, qt.IsNil) + b.AssertFileContent("public/p1/b.txt", "This was assets/a.txt") +} diff --git a/resources/testdata/exif/orientation6.jpg b/resources/testdata/exif/orientation6.jpg new file mode 100644 index 000000000..4e2c86415 Binary files /dev/null and b/resources/testdata/exif/orientation6.jpg differ diff --git a/resources/testdata/fuzzy-cirlcle.png b/resources/testdata/fuzzy-cirlcle.png new file mode 100644 index 000000000..95497d822 Binary files /dev/null and b/resources/testdata/fuzzy-cirlcle.png differ diff --git a/resources/testdata/giphy.gif b/resources/testdata/giphy.gif new file mode 100644 index 000000000..f82b32cbe Binary files /dev/null and b/resources/testdata/giphy.gif differ diff --git a/resources/testdata/gohugoio-card.gif b/resources/testdata/gohugoio-card.gif new file mode 100644 index 000000000..6bc20d83a Binary files /dev/null and b/resources/testdata/gohugoio-card.gif differ diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_100x100_fill_box_center_2.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_100x100_fill_box_center_2.png deleted file mode 100644 index d2f0afd27..000000000 Binary files a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_100x100_fill_box_center_2.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_14fabac035a010e707ee3733f6590555.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_14fabac035a010e707ee3733f6590555.png deleted file mode 100644 index 25ac82485..000000000 Binary files a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_14fabac035a010e707ee3733f6590555.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_200x0_resize_q50_r90_box_2.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_200x0_resize_q50_r90_box_2.png deleted file mode 100644 index 5abf378b4..000000000 Binary files a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_200x0_resize_q50_r90_box_2.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_200x100_resize_box_2.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_200x100_resize_box_2.png deleted file mode 100644 index cd56200ea..000000000 Binary files a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_200x100_resize_box_2.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_300x100_fill_nearestneighbor_topleft_2.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_300x100_fill_nearestneighbor_topleft_2.png deleted file mode 100644 index dd11ce7ed..000000000 Binary files a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_300x100_fill_nearestneighbor_topleft_2.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_300x200_fill_gaussian_smart1_2.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_300x200_fill_gaussian_smart1_2.png deleted file mode 100644 index 59ac93c1c..000000000 Binary files a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_300x200_fill_gaussian_smart1_2.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_300x200_fit_linear_2.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_300x200_fit_linear_2.png deleted file mode 100644 index 5ad74bf79..000000000 Binary files a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_300x200_fit_linear_2.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_400x200_fill_box_bottomleft_2.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_400x200_fill_box_bottomleft_2.png deleted file mode 100644 index 76deeabc7..000000000 Binary files a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_400x200_fill_box_bottomleft_2.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_400x200_fill_box_center_2.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_400x200_fill_box_center_2.png deleted file mode 100644 index 76deeabc7..000000000 Binary files a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_400x200_fill_box_center_2.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_55b828db27003cb979bac711748f4789.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_55b828db27003cb979bac711748f4789.png deleted file mode 100644 index 362be673b..000000000 Binary files a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_55b828db27003cb979bac711748f4789.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_600x0_resize_box_2.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_600x0_resize_box_2.png deleted file mode 100644 index 28028b72d..000000000 Binary files a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_600x0_resize_box_2.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_621ae6f4010e2eb164521f54f653df1f.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_621ae6f4010e2eb164521f54f653df1f.png deleted file mode 100644 index 0991ca984..000000000 Binary files a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_621ae6f4010e2eb164521f54f653df1f.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_65ffdad1306cecec4d21bac1edd47c44.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_65ffdad1306cecec4d21bac1edd47c44.png deleted file mode 100644 index 841d369ef..000000000 Binary files a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_65ffdad1306cecec4d21bac1edd47c44.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_84b0614b9f84c94c0773ef49ae868d0b.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_84b0614b9f84c94c0773ef49ae868d0b.png deleted file mode 100644 index 174649232..000000000 Binary files a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_84b0614b9f84c94c0773ef49ae868d0b.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_874d58b1c4b4b538f7ade152b3e57df8.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_874d58b1c4b4b538f7ade152b3e57df8.png deleted file mode 100644 index eba4b1e66..000000000 Binary files a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_874d58b1c4b4b538f7ade152b3e57df8.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_958fee7992cf502355355c021148638b.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_958fee7992cf502355355c021148638b.png deleted file mode 100644 index dde14757c..000000000 Binary files a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_958fee7992cf502355355c021148638b.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_9c5c204a4fc82e861344066bc8d0c7db.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_9c5c204a4fc82e861344066bc8d0c7db.png deleted file mode 100644 index 32c5b49d8..000000000 Binary files a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_9c5c204a4fc82e861344066bc8d0c7db.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_a0088abf33fdbf6be1651a71e7d4dc33.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_a0088abf33fdbf6be1651a71e7d4dc33.png deleted file mode 100644 index 93f8dfda2..000000000 Binary files a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_a0088abf33fdbf6be1651a71e7d4dc33.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_cdb3de8b01145d94ba41047655e42695.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_cdb3de8b01145d94ba41047655e42695.png deleted file mode 100644 index a48a0f25a..000000000 Binary files a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_cdb3de8b01145d94ba41047655e42695.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_cfc2eacca4b2748852f953954207d615.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_cfc2eacca4b2748852f953954207d615.png deleted file mode 100644 index 0ce82e49c..000000000 Binary files a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_cfc2eacca4b2748852f953954207d615.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_d1ad299f68cb4b3e1eba2ab7633e7857.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_d1ad299f68cb4b3e1eba2ab7633e7857.png deleted file mode 100644 index 2fece7804..000000000 Binary files a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_d1ad299f68cb4b3e1eba2ab7633e7857.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_d1f39c78ba8a0ada8233161edeed27ee.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_d1f39c78ba8a0ada8233161edeed27ee.png deleted file mode 100644 index 603b95ae0..000000000 Binary files a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_d1f39c78ba8a0ada8233161edeed27ee.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_dd36fa3cc8ae7cf4d686caf1a171284b.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_dd36fa3cc8ae7cf4d686caf1a171284b.png deleted file mode 100644 index 46fa3fd1b..000000000 Binary files a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_dd36fa3cc8ae7cf4d686caf1a171284b.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_f5d42d1797f90edd6379e0b082fdd53b.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_f5d42d1797f90edd6379e0b082fdd53b.png deleted file mode 100644 index 697ac914e..000000000 Binary files a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_f5d42d1797f90edd6379e0b082fdd53b.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_100x100_fill_box_center_2.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_100x100_fill_box_center_2.png deleted file mode 100644 index 0eef0aaf3..000000000 Binary files a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_100x100_fill_box_center_2.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_1bf2d9610b385893204d0a57ef8d1532.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_1bf2d9610b385893204d0a57ef8d1532.png deleted file mode 100644 index 69aa35885..000000000 Binary files a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_1bf2d9610b385893204d0a57ef8d1532.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_200x0_resize_q50_r90_box_2.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_200x0_resize_q50_r90_box_2.png deleted file mode 100644 index c35f00722..000000000 Binary files a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_200x0_resize_q50_r90_box_2.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_200x100_resize_box_2.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_200x100_resize_box_2.png deleted file mode 100644 index 6ddb55158..000000000 Binary files a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_200x100_resize_box_2.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_300x100_fill_nearestneighbor_topleft_2.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_300x100_fill_nearestneighbor_topleft_2.png deleted file mode 100644 index 08eccf7cd..000000000 Binary files a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_300x100_fill_nearestneighbor_topleft_2.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_300x200_fill_gaussian_smart1_2.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_300x200_fill_gaussian_smart1_2.png deleted file mode 100644 index f62d093a0..000000000 Binary files a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_300x200_fill_gaussian_smart1_2.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_300x200_fit_linear_2.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_300x200_fit_linear_2.png deleted file mode 100644 index 0660c20d7..000000000 Binary files a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_300x200_fit_linear_2.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_400x200_fill_box_bottomleft_2.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_400x200_fill_box_bottomleft_2.png deleted file mode 100644 index acde6a0f7..000000000 Binary files a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_400x200_fill_box_bottomleft_2.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_400x200_fill_box_center_2.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_400x200_fill_box_center_2.png deleted file mode 100644 index acde6a0f7..000000000 Binary files a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_400x200_fill_box_center_2.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_41369feac467f9ecec9ef46911b04fa1.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_41369feac467f9ecec9ef46911b04fa1.png deleted file mode 100644 index 53dd0b224..000000000 Binary files a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_41369feac467f9ecec9ef46911b04fa1.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_4c320010919da2d8b63ed24818b4d8e1.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_4c320010919da2d8b63ed24818b4d8e1.png deleted file mode 100644 index c8f782598..000000000 Binary files a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_4c320010919da2d8b63ed24818b4d8e1.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_600x0_resize_box_2.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_600x0_resize_box_2.png deleted file mode 100644 index 40fffa23a..000000000 Binary files a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_600x0_resize_box_2.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_7852bca7fb011b36d030e4d35d8e1d90.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_7852bca7fb011b36d030e4d35d8e1d90.png deleted file mode 100644 index c96e04108..000000000 Binary files a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_7852bca7fb011b36d030e4d35d8e1d90.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_798ebb7a9e9dc7edd40e2832eb77e457.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_798ebb7a9e9dc7edd40e2832eb77e457.png deleted file mode 100644 index 156b42f43..000000000 Binary files a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_798ebb7a9e9dc7edd40e2832eb77e457.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_84a8d324276a96584446750f06d04bd4.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_84a8d324276a96584446750f06d04bd4.png deleted file mode 100644 index 7134de473..000000000 Binary files a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_84a8d324276a96584446750f06d04bd4.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_8544b956dc08b714975ae52d4dcfdd78.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_8544b956dc08b714975ae52d4dcfdd78.png deleted file mode 100644 index 5a27e2fad..000000000 Binary files a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_8544b956dc08b714975ae52d4dcfdd78.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_888208ddeeeb3dcfe84697903ddffe30.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_888208ddeeeb3dcfe84697903ddffe30.png deleted file mode 100644 index 1fa2bc9de..000000000 Binary files a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_888208ddeeeb3dcfe84697903ddffe30.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_9660b4bf59aeb8ac8714d3e466af6197.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_9660b4bf59aeb8ac8714d3e466af6197.png deleted file mode 100644 index 414acff3b..000000000 Binary files a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_9660b4bf59aeb8ac8714d3e466af6197.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_9a86fee686dd5973923f5ef5c3b0bc74.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_9a86fee686dd5973923f5ef5c3b0bc74.png deleted file mode 100644 index 37dc0f798..000000000 Binary files a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_9a86fee686dd5973923f5ef5c3b0bc74.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_9d4c2220235b3c2d9fa6506be571560f.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_9d4c2220235b3c2d9fa6506be571560f.png deleted file mode 100644 index 2def214c8..000000000 Binary files a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_9d4c2220235b3c2d9fa6506be571560f.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_bac1f274c6786fdb63dd215df2226cd9.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_bac1f274c6786fdb63dd215df2226cd9.png deleted file mode 100644 index 325c31acd..000000000 Binary files a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_bac1f274c6786fdb63dd215df2226cd9.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_c1ced24877f4b1baf563997e33cadcfa.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_c1ced24877f4b1baf563997e33cadcfa.png deleted file mode 100644 index 1a229a429..000000000 Binary files a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_c1ced24877f4b1baf563997e33cadcfa.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_c74bb417b961e09cf1aac2130b7b9b85.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_c74bb417b961e09cf1aac2130b7b9b85.png deleted file mode 100644 index 51f6cfa7e..000000000 Binary files a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_c74bb417b961e09cf1aac2130b7b9b85.png and /dev/null differ diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_de67126dc370f606d57f2c229b3accab.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_de67126dc370f606d57f2c229b3accab.png deleted file mode 100644 index a5852e14c..000000000 Binary files a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_de67126dc370f606d57f2c229b3accab.png and /dev/null differ diff --git a/resources/testdata/golden/gopher-hero8_huaa0cd7d2cfc14ff32a57f171896f2285_13327_200x0_resize_bge3e615_box_2.png b/resources/testdata/golden/gopher-hero8_huaa0cd7d2cfc14ff32a57f171896f2285_13327_200x0_resize_bge3e615_box_2.png deleted file mode 100644 index 830ee906b..000000000 Binary files a/resources/testdata/golden/gopher-hero8_huaa0cd7d2cfc14ff32a57f171896f2285_13327_200x0_resize_bge3e615_box_2.png and /dev/null differ diff --git a/resources/testdata/golden/gopher-hero8_huaa0cd7d2cfc14ff32a57f171896f2285_13327_200x0_resize_q75_bge3e615_box_2.jpg b/resources/testdata/golden/gopher-hero8_huaa0cd7d2cfc14ff32a57f171896f2285_13327_200x0_resize_q75_bge3e615_box_2.jpg deleted file mode 100644 index 4ae6f5173..000000000 Binary files a/resources/testdata/golden/gopher-hero8_huaa0cd7d2cfc14ff32a57f171896f2285_13327_200x0_resize_q75_bge3e615_box_2.jpg and /dev/null differ diff --git a/resources/testdata/golden/gradient-circle_huf3d35257a40a8d6f525263a856c5ecfd_20069_200x0_resize_bge3e615_box_2.png b/resources/testdata/golden/gradient-circle_huf3d35257a40a8d6f525263a856c5ecfd_20069_200x0_resize_bge3e615_box_2.png deleted file mode 100644 index 3c861e922..000000000 Binary files a/resources/testdata/golden/gradient-circle_huf3d35257a40a8d6f525263a856c5ecfd_20069_200x0_resize_bge3e615_box_2.png and /dev/null differ diff --git a/resources/testdata/golden/gradient-circle_huf3d35257a40a8d6f525263a856c5ecfd_20069_200x0_resize_q75_bge3e615_box_2.jpg b/resources/testdata/golden/gradient-circle_huf3d35257a40a8d6f525263a856c5ecfd_20069_200x0_resize_q75_bge3e615_box_2.jpg deleted file mode 100644 index beb80bb12..000000000 Binary files a/resources/testdata/golden/gradient-circle_huf3d35257a40a8d6f525263a856c5ecfd_20069_200x0_resize_q75_bge3e615_box_2.jpg and /dev/null differ diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_0d1b300da7a815ed567b6dadb6f2ce5e.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_0d1b300da7a815ed567b6dadb6f2ce5e.jpg deleted file mode 100644 index 1e2cb535b..000000000 Binary files a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_0d1b300da7a815ed567b6dadb6f2ce5e.jpg and /dev/null differ diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_100x100_fill_q75_box_center.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_100x100_fill_q75_box_center.jpg deleted file mode 100644 index 8e6164e32..000000000 Binary files a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_100x100_fill_q75_box_center.jpg and /dev/null differ diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_17fd3c558d78ce249b5f0bcbe1ddbffb.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_17fd3c558d78ce249b5f0bcbe1ddbffb.jpg deleted file mode 100644 index 2aa3dad2b..000000000 Binary files a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_17fd3c558d78ce249b5f0bcbe1ddbffb.jpg and /dev/null differ diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x0_resize_q50_r90_box.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x0_resize_q50_r90_box.jpg deleted file mode 100644 index 05d98c67a..000000000 Binary files a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x0_resize_q50_r90_box.jpg and /dev/null differ diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x100_resize_q75_box.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x100_resize_q75_box.jpg deleted file mode 100644 index f12dd18fc..000000000 Binary files a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x100_resize_q75_box.jpg and /dev/null differ diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x100_fill_q75_nearestneighbor_topleft.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x100_fill_q75_nearestneighbor_topleft.jpg deleted file mode 100644 index 8ac3b2524..000000000 Binary files a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x100_fill_q75_nearestneighbor_topleft.jpg and /dev/null differ diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x200_fill_q75_gaussian_smart1.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x200_fill_q75_gaussian_smart1.jpg deleted file mode 100644 index 03de912fb..000000000 Binary files a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x200_fill_q75_gaussian_smart1.jpg and /dev/null differ diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x200_fit_q75_linear.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x200_fit_q75_linear.jpg deleted file mode 100644 index 3801c17d9..000000000 Binary files a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x200_fit_q75_linear.jpg and /dev/null differ diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_30fc2aab35ca0861bf396d09aebc85a4.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_30fc2aab35ca0861bf396d09aebc85a4.jpg deleted file mode 100644 index 60207a829..000000000 Binary files a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_30fc2aab35ca0861bf396d09aebc85a4.jpg and /dev/null differ diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_352eb0101b7c88107520ba719432bbb2.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_352eb0101b7c88107520ba719432bbb2.jpg deleted file mode 100644 index f7e84e33d..000000000 Binary files a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_352eb0101b7c88107520ba719432bbb2.jpg and /dev/null differ diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_3efc2d0f29a8e12c5a690fc6c9288854.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_3efc2d0f29a8e12c5a690fc6c9288854.jpg deleted file mode 100644 index 17a5927e2..000000000 Binary files a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_3efc2d0f29a8e12c5a690fc6c9288854.jpg and /dev/null differ diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_3f1b1455c4a7d13c5aeb7510f9a6a581.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_3f1b1455c4a7d13c5aeb7510f9a6a581.jpg deleted file mode 100644 index 93b914161..000000000 Binary files a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_3f1b1455c4a7d13c5aeb7510f9a6a581.jpg and /dev/null differ diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_400x200_fill_q75_box_bottomleft.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_400x200_fill_q75_box_bottomleft.jpg deleted file mode 100644 index 9a6255687..000000000 Binary files a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_400x200_fill_q75_box_bottomleft.jpg and /dev/null differ diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_400x200_fill_q75_box_center.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_400x200_fill_q75_box_center.jpg deleted file mode 100644 index b2db97485..000000000 Binary files a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_400x200_fill_q75_box_center.jpg and /dev/null differ diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_600x0_resize_q75_box.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_600x0_resize_q75_box.jpg deleted file mode 100644 index a5ad199d8..000000000 Binary files a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_600x0_resize_q75_box.jpg and /dev/null differ diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_6c5c12ac79d3455ccb1993d51eec3cdf.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_6c5c12ac79d3455ccb1993d51eec3cdf.jpg deleted file mode 100644 index e77e78d7b..000000000 Binary files a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_6c5c12ac79d3455ccb1993d51eec3cdf.jpg and /dev/null differ diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_7d9bc4700565266807dc476421066137.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_7d9bc4700565266807dc476421066137.jpg deleted file mode 100644 index ee246814d..000000000 Binary files a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_7d9bc4700565266807dc476421066137.jpg and /dev/null differ diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_9f00027c376fe8556cc9996c47f23f78.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_9f00027c376fe8556cc9996c47f23f78.jpg deleted file mode 100644 index e7db706c2..000000000 Binary files a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_9f00027c376fe8556cc9996c47f23f78.jpg and /dev/null differ diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_abf356affd7d70d6bec3b3498b572191.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_abf356affd7d70d6bec3b3498b572191.jpg deleted file mode 100644 index 9688c99c3..000000000 Binary files a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_abf356affd7d70d6bec3b3498b572191.jpg and /dev/null differ diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_c36da6818db1ab630c3f87f65170003b.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_c36da6818db1ab630c3f87f65170003b.jpg deleted file mode 100644 index 41b42a883..000000000 Binary files a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_c36da6818db1ab630c3f87f65170003b.jpg and /dev/null differ diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_cb45fcba865177290c89dc9f41d6ff7a.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_cb45fcba865177290c89dc9f41d6ff7a.jpg deleted file mode 100644 index f09ff9e33..000000000 Binary files a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_cb45fcba865177290c89dc9f41d6ff7a.jpg and /dev/null differ diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_d30c10468b33df9010d185a8fe8f0491.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_d30c10468b33df9010d185a8fe8f0491.jpg deleted file mode 100644 index 0b7d4e5d0..000000000 Binary files a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_d30c10468b33df9010d185a8fe8f0491.jpg and /dev/null differ diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_de1fe6c0f40e7165355507d0f1748083.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_de1fe6c0f40e7165355507d0f1748083.jpg deleted file mode 100644 index 7e35750db..000000000 Binary files a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_de1fe6c0f40e7165355507d0f1748083.jpg and /dev/null differ diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_f6d8fe32ce3e83abf130e91e33456914.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_f6d8fe32ce3e83abf130e91e33456914.jpg deleted file mode 100644 index b67650061..000000000 Binary files a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_f6d8fe32ce3e83abf130e91e33456914.jpg and /dev/null differ diff --git a/resources/testdata/iss8079.jpg b/resources/testdata/iss8079.jpg new file mode 100644 index 000000000..a9049e81b Binary files /dev/null and b/resources/testdata/iss8079.jpg differ diff --git a/resources/testdata/issue10738/canon_cr2_fraction.jpg b/resources/testdata/issue10738/canon_cr2_fraction.jpg new file mode 100644 index 000000000..4ee6a23e9 Binary files /dev/null and b/resources/testdata/issue10738/canon_cr2_fraction.jpg differ diff --git a/resources/testdata/issue10738/canon_cr2_integer.jpg b/resources/testdata/issue10738/canon_cr2_integer.jpg new file mode 100644 index 000000000..145a9935d Binary files /dev/null and b/resources/testdata/issue10738/canon_cr2_integer.jpg differ diff --git a/resources/testdata/issue10738/dji_dng_fraction.jpg b/resources/testdata/issue10738/dji_dng_fraction.jpg new file mode 100644 index 000000000..fdbad4aaf Binary files /dev/null and b/resources/testdata/issue10738/dji_dng_fraction.jpg differ diff --git a/resources/testdata/issue10738/fuji_raf_fraction.jpg b/resources/testdata/issue10738/fuji_raf_fraction.jpg new file mode 100644 index 000000000..1a588229e Binary files /dev/null and b/resources/testdata/issue10738/fuji_raf_fraction.jpg differ diff --git a/resources/testdata/issue10738/fuji_raf_integer.jpg b/resources/testdata/issue10738/fuji_raf_integer.jpg new file mode 100644 index 000000000..3902be379 Binary files /dev/null and b/resources/testdata/issue10738/fuji_raf_integer.jpg differ diff --git a/resources/testdata/issue10738/leica_dng_fraction.jpg b/resources/testdata/issue10738/leica_dng_fraction.jpg new file mode 100644 index 000000000..a1ccf452e Binary files /dev/null and b/resources/testdata/issue10738/leica_dng_fraction.jpg differ diff --git a/resources/testdata/issue10738/lumix_rw2_fraction.jpg b/resources/testdata/issue10738/lumix_rw2_fraction.jpg new file mode 100644 index 000000000..5da5943d6 Binary files /dev/null and b/resources/testdata/issue10738/lumix_rw2_fraction.jpg differ diff --git a/resources/testdata/issue10738/nikon_nef_d5600.jpg b/resources/testdata/issue10738/nikon_nef_d5600.jpg new file mode 100644 index 000000000..26d791f39 Binary files /dev/null and b/resources/testdata/issue10738/nikon_nef_d5600.jpg differ diff --git a/resources/testdata/issue10738/nikon_nef_fraction.jpg b/resources/testdata/issue10738/nikon_nef_fraction.jpg new file mode 100644 index 000000000..535addad1 Binary files /dev/null and b/resources/testdata/issue10738/nikon_nef_fraction.jpg differ diff --git a/resources/testdata/issue10738/nikon_nef_fraction_2.jpg b/resources/testdata/issue10738/nikon_nef_fraction_2.jpg new file mode 100644 index 000000000..15d9bda92 Binary files /dev/null and b/resources/testdata/issue10738/nikon_nef_fraction_2.jpg differ diff --git a/resources/testdata/issue10738/nikon_nef_integer.jpg b/resources/testdata/issue10738/nikon_nef_integer.jpg new file mode 100644 index 000000000..00a880511 Binary files /dev/null and b/resources/testdata/issue10738/nikon_nef_integer.jpg differ diff --git a/resources/testdata/issue10738/sony_arw_fraction.jpg b/resources/testdata/issue10738/sony_arw_fraction.jpg new file mode 100644 index 000000000..6550b5d39 Binary files /dev/null and b/resources/testdata/issue10738/sony_arw_fraction.jpg differ diff --git a/resources/testdata/issue10738/sony_arw_integer.jpg b/resources/testdata/issue10738/sony_arw_integer.jpg new file mode 100644 index 000000000..8548c0a18 Binary files /dev/null and b/resources/testdata/issue10738/sony_arw_integer.jpg differ diff --git a/resources/testdata/mask.png b/resources/testdata/mask.png new file mode 100644 index 000000000..26ac85791 Binary files /dev/null and b/resources/testdata/mask.png differ diff --git a/resources/testdata/mask2.png b/resources/testdata/mask2.png new file mode 100644 index 000000000..b58a5e4b0 Binary files /dev/null and b/resources/testdata/mask2.png differ diff --git a/resources/testdata/pix.gif b/resources/testdata/pix.gif new file mode 100644 index 000000000..f191b280c Binary files /dev/null and b/resources/testdata/pix.gif differ diff --git a/resources/testdata/sunrise.webp b/resources/testdata/sunrise.webp new file mode 100644 index 000000000..25ea7b046 Binary files /dev/null and b/resources/testdata/sunrise.webp differ diff --git a/resources/testdata/sunset.webp b/resources/testdata/sunset.webp new file mode 100644 index 000000000..4365e7b9f Binary files /dev/null and b/resources/testdata/sunset.webp differ diff --git a/resources/testhelpers_test.go b/resources/testhelpers_test.go index 5fab0eca0..60cfae0c5 100644 --- a/resources/testhelpers_test.go +++ b/resources/testhelpers_test.go @@ -1,29 +1,24 @@ -package resources +package resources_test import ( - "path/filepath" - "testing" - "image" - "io" - "io/ioutil" "os" + "path/filepath" "runtime" "strings" - "github.com/gohugoio/hugo/langs" - "github.com/gohugoio/hugo/modules" + "github.com/gohugoio/hugo/common/hugio" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/config/testconfig" + "github.com/gohugoio/hugo/deps" + "github.com/gohugoio/hugo/identity" + "github.com/gohugoio/hugo/resources" qt "github.com/frankban/quicktest" - "github.com/gohugoio/hugo/cache/filecache" - "github.com/gohugoio/hugo/helpers" "github.com/gohugoio/hugo/hugofs" - "github.com/gohugoio/hugo/media" - "github.com/gohugoio/hugo/output" - "github.com/gohugoio/hugo/resources/page" + "github.com/gohugoio/hugo/resources/images" "github.com/gohugoio/hugo/resources/resource" "github.com/spf13/afero" - "github.com/spf13/viper" ) type specDescriptor struct { @@ -32,30 +27,7 @@ type specDescriptor struct { fs afero.Fs } -func createTestCfg() *viper.Viper { - cfg := viper.New() - cfg.Set("resourceDir", "resources") - cfg.Set("contentDir", "content") - cfg.Set("dataDir", "data") - cfg.Set("i18nDir", "i18n") - cfg.Set("layoutDir", "layouts") - cfg.Set("assetDir", "assets") - cfg.Set("archetypeDir", "archetypes") - cfg.Set("publishDir", "public") - - langs.LoadLanguageSettings(cfg, nil) - mod, err := modules.CreateProjectModule(cfg) - if err != nil { - panic(err) - } - cfg.Set("allModules", modules.Modules{mod}) - - return cfg - -} - -func newTestResourceSpec(desc specDescriptor) *Spec { - +func newTestResourceSpec(desc specDescriptor) *resources.Spec { baseURL := desc.baseURL if baseURL == "" { baseURL = "https://example.com/" @@ -66,49 +38,44 @@ func newTestResourceSpec(desc specDescriptor) *Spec { afs = afero.NewMemMapFs() } - afs = hugofs.NewBaseFileDecorator(afs) + if hugofs.IsOsFs(afs) { + panic("osFs not supported for this test") + } - c := desc.c + if err := afs.MkdirAll("assets", 0o755); err != nil { + panic(err) + } - cfg := createTestCfg() + cfg := config.New() cfg.Set("baseURL", baseURL) + cfg.Set("publishDir", "public") - imagingCfg := map[string]interface{}{ + imagingCfg := map[string]any{ "resampleFilter": "linear", "quality": 68, "anchor": "left", } cfg.Set("imaging", imagingCfg) + d := testconfig.GetTestDeps( + afs, cfg, + func(d *deps.Deps) { d.Fs.PublishDir = hugofs.NewCreateCountingFs(d.Fs.PublishDir) }, + ) - fs := hugofs.NewFrom(afs, cfg) - fs.Destination = hugofs.NewCreateCountingFs(fs.Destination) - - s, err := helpers.NewPathSpec(fs, cfg, nil) - c.Assert(err, qt.IsNil) - - filecaches, err := filecache.NewCaches(s) - c.Assert(err, qt.IsNil) - - spec, err := NewSpec(s, filecaches, nil, output.DefaultFormats, media.DefaultTypes) - c.Assert(err, qt.IsNil) - return spec -} - -func newTargetPaths(link string) func() page.TargetPaths { - return func() page.TargetPaths { - return page.TargetPaths{ - SubResourceBaseTarget: filepath.FromSlash(link), - SubResourceBaseLink: link, + desc.c.Cleanup(func() { + if err := d.Close(); err != nil { + panic(err) } - } + }) + + return d.ResourceSpec } -func newTestResourceOsFs(c *qt.C) (*Spec, string) { - cfg := createTestCfg() +func newTestResourceOsFs(c *qt.C) (*resources.Spec, string) { + cfg := config.New() cfg.Set("baseURL", "https://example.com") - workDir, err := ioutil.TempDir("", "hugores") + workDir, err := os.MkdirTemp("", "hugores") c.Assert(err, qt.IsNil) c.Assert(workDir, qt.Not(qt.Equals), "") @@ -120,61 +87,40 @@ func newTestResourceOsFs(c *qt.C) (*Spec, string) { cfg.Set("workingDir", workDir) - fs := hugofs.NewFrom(hugofs.NewBaseFileDecorator(hugofs.Os), cfg) - fs.Destination = &afero.MemMapFs{} + os.MkdirAll(filepath.Join(workDir, "assets"), 0o755) - s, err := helpers.NewPathSpec(fs, cfg, nil) - c.Assert(err, qt.IsNil) - - filecaches, err := filecache.NewCaches(s) - c.Assert(err, qt.IsNil) - - spec, err := NewSpec(s, filecaches, nil, output.DefaultFormats, media.DefaultTypes) - c.Assert(err, qt.IsNil) - - return spec, workDir + d := testconfig.GetTestDeps(hugofs.Os, cfg) + return d.ResourceSpec, workDir } -func fetchSunset(c *qt.C) resource.Image { +func fetchSunset(c *qt.C) (*resources.Spec, images.ImageResource) { return fetchImage(c, "sunset.jpg") } -func fetchImage(c *qt.C, name string) resource.Image { +func fetchImage(c *qt.C, name string) (*resources.Spec, images.ImageResource) { spec := newTestResourceSpec(specDescriptor{c: c}) - return fetchImageForSpec(spec, c, name) + return spec, fetchImageForSpec(spec, c, name) } -func fetchImageForSpec(spec *Spec, c *qt.C, name string) resource.Image { +func fetchImageForSpec(spec *resources.Spec, c *qt.C, name string) images.ImageResource { r := fetchResourceForSpec(spec, c, name) - - img := r.(resource.Image) - + img := r.(images.ImageResource) c.Assert(img, qt.Not(qt.IsNil)) - c.Assert(img.(specProvider).getSpec(), qt.Not(qt.IsNil)) - return img } -func fetchResourceForSpec(spec *Spec, c *qt.C, name string, targetPathAddends ...string) resource.ContentResource { - src, err := os.Open(filepath.FromSlash("testdata/" + name)) +func fetchResourceForSpec(spec *resources.Spec, c *qt.C, name string, targetPathAddends ...string) resource.ContentResource { + b, err := os.ReadFile(filepath.FromSlash("testdata/" + name)) c.Assert(err, qt.IsNil) - workDir := spec.WorkingDir - if len(targetPathAddends) > 0 { - addends := strings.Join(targetPathAddends, "_") - name = addends + "_" + name - } - targetFilename := filepath.Join(workDir, name) - out, err := helpers.OpenFileForWriting(spec.Fs.Source, targetFilename) - c.Assert(err, qt.IsNil) - _, err = io.Copy(out, src) - out.Close() - src.Close() - c.Assert(err, qt.IsNil) - - factory := newTargetPaths("/a") - - r, err := spec.New(ResourceSourceDescriptor{Fs: spec.Fs.Source, TargetPaths: factory, LazyPublish: true, RelTargetFilename: name, SourceFilename: targetFilename}) + open := hugio.NewOpenReadSeekCloser(hugio.NewReadSeekerNoOpCloserFromBytes(b)) + targetPath := name + base := "/a/" + r, err := spec.NewResource(resources.ResourceSourceDescriptor{ + LazyPublish: true, + NameNormalized: name, TargetPath: targetPath, BasePathRelPermalink: base, BasePathTargetPath: base, OpenReadSeekCloser: open, + GroupIdentity: identity.Anonymous, + }) c.Assert(err, qt.IsNil) c.Assert(r, qt.Not(qt.IsNil)) @@ -193,17 +139,3 @@ func assertImageFile(c *qt.C, fs afero.Fs, filename string, width, height int) { c.Assert(config.Width, qt.Equals, width) c.Assert(config.Height, qt.Equals, height) } - -func assertFileCache(c *qt.C, fs afero.Fs, filename string, width, height int) { - assertImageFile(c, fs, filepath.Clean(filename), width, height) -} - -func writeSource(t testing.TB, fs *hugofs.Fs, filename, content string) { - writeToFs(t, fs.Source, filename, content) -} - -func writeToFs(t testing.TB, fs afero.Fs, filename, content string) { - if err := afero.WriteFile(fs, filepath.FromSlash(filename), []byte(content), 0755); err != nil { - t.Fatalf("Failed to write file: %s", err) - } -} diff --git a/resources/transform.go b/resources/transform.go index 0e44c6bbc..572143d49 100644 --- a/resources/transform.go +++ b/resources/transform.go @@ -15,12 +15,20 @@ package resources import ( "bytes" + "context" "fmt" + "image" "io" "path" "strings" "sync" + "github.com/gohugoio/hugo/common/constants" + "github.com/gohugoio/hugo/common/hashing" + "github.com/gohugoio/hugo/common/paths" + "github.com/gohugoio/hugo/identity" + + "github.com/gohugoio/hugo/resources/images" "github.com/gohugoio/hugo/resources/images/exif" "github.com/spf13/afero" @@ -29,7 +37,6 @@ import ( "github.com/gohugoio/hugo/common/herrors" "github.com/gohugoio/hugo/common/hugio" "github.com/gohugoio/hugo/common/maps" - "github.com/gohugoio/hugo/helpers" "github.com/gohugoio/hugo/resources/internal" "github.com/gohugoio/hugo/resources/resource" @@ -37,20 +44,34 @@ import ( ) var ( - _ resource.ContentResource = (*resourceAdapter)(nil) - _ resource.ReadSeekCloserResource = (*resourceAdapter)(nil) - _ resource.Resource = (*resourceAdapter)(nil) - _ resource.Source = (*resourceAdapter)(nil) - _ resource.Identifier = (*resourceAdapter)(nil) - _ resource.ResourceMetaProvider = (*resourceAdapter)(nil) + _ resource.ContentResource = (*resourceAdapter)(nil) + _ resourceCopier = (*resourceAdapter)(nil) + _ resource.ReadSeekCloserResource = (*resourceAdapter)(nil) + _ resource.Resource = (*resourceAdapter)(nil) + _ resource.Staler = (*resourceAdapterInner)(nil) + _ identity.IdentityGroupProvider = (*resourceAdapterInner)(nil) + _ resource.Source = (*resourceAdapter)(nil) + _ resource.Identifier = (*resourceAdapter)(nil) + _ resource.TransientIdentifier = (*resourceAdapter)(nil) + _ targetPathProvider = (*resourceAdapter)(nil) + _ sourcePathProvider = (*resourceAdapter)(nil) + _ resource.Identifier = (*resourceAdapter)(nil) + _ resource.ResourceNameTitleProvider = (*resourceAdapter)(nil) + _ resource.WithResourceMetaProvider = (*resourceAdapter)(nil) + _ identity.DependencyManagerProvider = (*resourceAdapter)(nil) + _ identity.IdentityGroupProvider = (*resourceAdapter)(nil) + _ resource.NameNormalizedProvider = (*resourceAdapter)(nil) + _ isPublishedProvider = (*resourceAdapter)(nil) ) // These are transformations that need special support in Hugo that may not // be available when building the theme/site so we write the transformation // result to disk and reuse if needed for these, +// TODO(bep) it's a little fragile having these constants redefined here. var transformationsToCacheOnDisk = map[string]bool{ - "postcss": true, - "tocss": true, + "postcss": true, + "tocss": true, + "tocss-dart": true, } func newResourceAdapter(spec *Spec, lazyPublish bool, target transformableResource) *resourceAdapter { @@ -60,10 +81,13 @@ func newResourceAdapter(spec *Spec, lazyPublish bool, target transformableResour } return &resourceAdapter{ resourceTransformations: &resourceTransformations{}, + metaProvider: target, resourceAdapterInner: &resourceAdapterInner{ + ctx: context.Background(), spec: spec, publishOnce: po, target: target, + Staler: &AtomicStaler{}, }, } } @@ -76,6 +100,12 @@ type ResourceTransformation interface { } type ResourceTransformationCtx struct { + // The context that started the transformation. + Ctx context.Context + + // The dependency manager to use for dependency tracking. + DependencyManager identity.Manager + // The content to transform. From io.Reader @@ -101,9 +131,9 @@ type ResourceTransformationCtx struct { // Data data can be set on the transformed Resource. Not that this need // to be simple types, as it needs to be serialized to JSON and back. - Data map[string]interface{} + Data map[string]any - // This is used to publis additional artifacts, e.g. source maps. + // This is used to publish additional artifacts, e.g. source maps. // We may improve this. OpenResourcePublisher func(relTargetPath string) (io.WriteCloser, error) } @@ -131,13 +161,13 @@ func (ctx *ResourceTransformationCtx) PublishSourceMap(content string) error { // extension, e.g. ".scss" func (ctx *ResourceTransformationCtx) ReplaceOutPathExtension(newExt string) { dir, file := path.Split(ctx.InPath) - base, _ := helpers.PathAndExt(file) + base, _ := paths.PathAndExt(file) ctx.OutPath = path.Join(dir, (base + newExt)) } func (ctx *ResourceTransformationCtx) addPathIdentifier(inPath, identifier string) string { dir, file := path.Split(inPath) - base, ext := helpers.PathAndExt(file) + base, ext := paths.PathAndExt(file) return path.Join(dir, (base + identifier + ext)) } @@ -150,46 +180,121 @@ type resourceAdapter struct { commonResource *resourceTransformations *resourceAdapterInner + metaProvider resource.ResourceMetaProvider } -func (r *resourceAdapter) Content() (interface{}, error) { +var _ identity.ForEeachIdentityByNameProvider = (*resourceAdapter)(nil) + +func (r *resourceAdapter) Content(ctx context.Context) (any, error) { r.init(false, true) if r.transformationsErr != nil { return nil, r.transformationsErr } - return r.target.Content() + return r.target.Content(ctx) } -func (r *resourceAdapter) Data() interface{} { +func (r *resourceAdapter) GetIdentity() identity.Identity { + return identity.FirstIdentity(r.target) +} + +func (r *resourceAdapter) Data() any { r.init(false, false) return r.target.Data() } -func (r *resourceAdapter) Fill(spec string) (resource.Image, error) { +func (r *resourceAdapter) ForEeachIdentityByName(name string, f func(identity.Identity) bool) { + if constants.IsFieldRelOrPermalink(name) && !r.resourceTransformations.hasTransformationPermalinkHash() { + // Special case for links without any content hash in the URL. + // We don't need to rebuild all pages that use this resource, + // but we want to make sure that the resource is accessed at least once. + f(identity.NewFindFirstManagerIdentityProvider(r.target.GetDependencyManager(), r.target.GetIdentityGroup())) + return + } + f(r.target.GetIdentityGroup()) + f(r.target.GetDependencyManager()) +} + +func (r *resourceAdapter) GetIdentityGroup() identity.Identity { + return r.target.GetIdentityGroup() +} + +func (r *resourceAdapter) GetDependencyManager() identity.Manager { + return r.target.GetDependencyManager() +} + +func (r resourceAdapter) cloneTo(targetPath string) resource.Resource { + newtTarget := r.target.cloneTo(targetPath) + newInner := &resourceAdapterInner{ + ctx: r.ctx, + spec: r.spec, + Staler: r.Staler, + target: newtTarget.(transformableResource), + } + if r.resourceAdapterInner.publishOnce != nil { + newInner.publishOnce = &publishOnce{} + } + r.resourceAdapterInner = newInner + return &r +} + +func (r *resourceAdapter) Process(spec string) (images.ImageResource, error) { + return r.getImageOps().Process(spec) +} + +func (r *resourceAdapter) Crop(spec string) (images.ImageResource, error) { + return r.getImageOps().Crop(spec) +} + +func (r *resourceAdapter) Fill(spec string) (images.ImageResource, error) { return r.getImageOps().Fill(spec) } -func (r *resourceAdapter) Fit(spec string) (resource.Image, error) { +func (r *resourceAdapter) Fit(spec string) (images.ImageResource, error) { return r.getImageOps().Fit(spec) } -func (r *resourceAdapter) Filter(filters ...interface{}) (resource.Image, error) { +func (r *resourceAdapter) Filter(filters ...any) (images.ImageResource, error) { return r.getImageOps().Filter(filters...) } +func (r *resourceAdapter) Resize(spec string) (images.ImageResource, error) { + return r.getImageOps().Resize(spec) +} + func (r *resourceAdapter) Height() int { return r.getImageOps().Height() } -func (r *resourceAdapter) Exif() (*exif.Exif, error) { +func (r *resourceAdapter) Exif() *exif.ExifInfo { return r.getImageOps().Exif() } +func (r *resourceAdapter) Colors() ([]images.Color, error) { + return r.getImageOps().Colors() +} + func (r *resourceAdapter) Key() string { r.init(false, false) return r.target.(resource.Identifier).Key() } +func (r *resourceAdapter) TransientKey() string { + return r.Key() +} + +func (r *resourceAdapter) targetPath() string { + r.init(false, false) + return r.target.(targetPathProvider).targetPath() +} + +func (r *resourceAdapter) sourcePath() string { + r.init(false, false) + if sp, ok := r.target.(sourcePathProvider); ok { + return sp.sourcePath() + } + return "" +} + func (r *resourceAdapter) MediaType() media.Type { r.init(false, false) return r.target.MediaType() @@ -197,12 +302,17 @@ func (r *resourceAdapter) MediaType() media.Type { func (r *resourceAdapter) Name() string { r.init(false, false) - return r.target.Name() + return r.metaProvider.Name() +} + +func (r *resourceAdapter) NameNormalized() string { + r.init(false, false) + return r.target.(resource.NameNormalizedProvider).NameNormalized() } func (r *resourceAdapter) Params() maps.Params { r.init(false, false) - return r.target.Params() + return r.metaProvider.Params() } func (r *resourceAdapter) Permalink() string { @@ -216,6 +326,11 @@ func (r *resourceAdapter) Publish() error { return r.target.Publish() } +func (r *resourceAdapter) isPublished() bool { + r.init(false, false) + return r.target.isPublished() +} + func (r *resourceAdapter) ReadSeekCloser() (hugio.ReadSeekCloser, error) { r.init(false, false) return r.target.ReadSeekCloser() @@ -226,10 +341,6 @@ func (r *resourceAdapter) RelPermalink() string { return r.target.RelPermalink() } -func (r *resourceAdapter) Resize(spec string) (resource.Image, error) { - return r.getImageOps().Resize(spec) -} - func (r *resourceAdapter) ResourceType() string { r.init(false, false) return r.target.ResourceType() @@ -241,16 +352,22 @@ func (r *resourceAdapter) String() string { func (r *resourceAdapter) Title() string { r.init(false, false) - return r.target.Title() + return r.metaProvider.Title() } func (r resourceAdapter) Transform(t ...ResourceTransformation) (ResourceTransformer, error) { + return r.TransformWithContext(context.Background(), t...) +} + +func (r resourceAdapter) TransformWithContext(ctx context.Context, t ...ResourceTransformation) (ResourceTransformer, error) { r.resourceTransformations = &resourceTransformations{ transformations: append(r.transformations, t...), } r.resourceAdapterInner = &resourceAdapterInner{ + ctx: ctx, spec: r.spec, + Staler: r.Staler, publishOnce: &publishOnce{}, target: r.target, } @@ -262,23 +379,27 @@ func (r *resourceAdapter) Width() int { return r.getImageOps().Width() } -func (r *resourceAdapter) getImageOps() resource.ImageOps { - img, ok := r.target.(resource.ImageOps) +func (r *resourceAdapter) DecodeImage() (image.Image, error) { + return r.getImageOps().DecodeImage() +} + +func (r resourceAdapter) WithResourceMeta(mp resource.ResourceMetaProvider) resource.Resource { + r.metaProvider = mp + return &r +} + +func (r *resourceAdapter) getImageOps() images.ImageResourceOps { + img, ok := r.target.(images.ImageResourceOps) if !ok { - panic(fmt.Sprintf("%T is not an image", r.target)) + if r.MediaType().SubType == "svg" { + panic("this method is only available for raster images. To determine if an image is SVG, you can do {{ if eq .MediaType.SubType \"svg\" }}{{ end }}") + } + panic("this method is only available for image resources") } r.init(false, false) return img } -func (r *resourceAdapter) getMetaAssigner() metaAssigner { - return r.target -} - -func (r *resourceAdapter) getSpec() *Spec { - return r.spec -} - func (r *resourceAdapter) publish() { if r.publishOnce == nil { return @@ -288,45 +409,34 @@ func (r *resourceAdapter) publish() { r.publisherErr = r.target.Publish() if r.publisherErr != nil { - r.spec.Logger.ERROR.Printf("Failed to publish Resource: %s", r.publisherErr) + r.spec.Logger.Errorf("Failed to publish Resource: %s", r.publisherErr) } }) - } -func (r *resourceAdapter) transform(publish, setContent bool) error { - cache := r.spec.ResourceCache - - // Files with a suffix will be stored in cache (both on disk and in memory) - // partitioned by their suffix. +func (r *resourceAdapter) TransformationKey() string { var key string for _, tr := range r.transformations { key = key + "_" + tr.Key().Value() } + return r.spec.ResourceCache.cleanKey(r.target.Key()) + "_" + hashing.MD5FromStringHexEncoded(key) +} - base := ResourceCacheKey(r.target.Key()) - - key = cache.cleanKey(base) + "_" + helpers.MD5String(key) - - cached, found := cache.get(key) - - if found { - r.resourceAdapterInner = cached.(*resourceAdapterInner) - return nil +func (r *resourceAdapter) getOrTransform(publish, setContent bool) error { + key := r.TransformationKey() + res, err := r.spec.ResourceCache.cacheResourceTransformation.GetOrCreate(key, func(string) (*resourceAdapterInner, error) { + return r.transform(key, publish, setContent) + }) + if err != nil { + return err } - // Acquire a write lock for the named transformation. - cache.nlocker.Lock(key) - // Check the cache again. - cached, found = cache.get(key) - if found { - r.resourceAdapterInner = cached.(*resourceAdapterInner) - cache.nlocker.Unlock(key) - return nil - } + r.resourceAdapterInner = res + return nil +} - defer cache.nlocker.Unlock(key) - defer cache.set(key, r.resourceAdapterInner) +func (r *resourceAdapter) transform(key string, publish, setContent bool) (*resourceAdapterInner, error) { + cache := r.spec.ResourceCache b1 := bp.GetBuffer() b2 := bp.GetBuffer() @@ -334,8 +444,10 @@ func (r *resourceAdapter) transform(publish, setContent bool) error { defer bp.PutBuffer(b2) tctx := &ResourceTransformationCtx{ - Data: make(map[string]interface{}), + Ctx: r.ctx, + Data: make(map[string]any), OpenResourcePublisher: r.target.openPublishFileForWriting, + DependencyManager: r.target.GetDependencyManager(), } tctx.InMediaType = r.target.MediaType() @@ -348,7 +460,7 @@ func (r *resourceAdapter) transform(publish, setContent bool) error { contentrc, err := contentReadSeekerCloser(r.target) if err != nil { - return err + return nil, err } defer contentrc.Close() @@ -357,7 +469,7 @@ func (r *resourceAdapter) transform(publish, setContent bool) error { tctx.To = b1 tctx.InPath = r.target.TargetPath() - tctx.SourcePath = tctx.InPath + tctx.SourcePath = strings.TrimPrefix(tctx.InPath, "/") counter := 0 writeToFileCache := false @@ -369,8 +481,9 @@ func (r *resourceAdapter) transform(publish, setContent bool) error { tctx.InMediaType = tctx.OutMediaType } + mayBeCachedOnDisk := transformationsToCacheOnDisk[tr.Key().Name] if !writeToFileCache { - writeToFileCache = transformationsToCacheOnDisk[tr.Key().Name] + writeToFileCache = mayBeCachedOnDisk } if i > 0 { @@ -390,29 +503,65 @@ func (r *resourceAdapter) transform(publish, setContent bool) error { } } - if err = tr.Transform(tctx); err != nil { - if writeToFileCache && err == herrors.ErrFeatureNotAvailable { - // This transformation is not available in this - // Hugo installation (scss not compiled in, PostCSS not available etc.) - // If a prepared bundle for this transformation chain is available, use that. - f := r.target.tryTransformedFileCache(key, updates) - if f == nil { - errMsg := err.Error() - if tr.Key().Name == "postcss" { - errMsg = "PostCSS not found; install with \"npm install postcss-cli\". See https://gohugo.io/hugo-pipes/postcss/" - } - return fmt.Errorf("%s: failed to transform %q (%s): %s", strings.ToUpper(tr.Key().Name), tctx.InPath, tctx.InMediaType.Type(), errMsg) - } - transformedContentr = f - updates.sourceFs = cache.fileCache.Fs - defer f.Close() + newErr := func(err error) error { + msg := fmt.Sprintf("%s: failed to transform %q (%s)", strings.ToUpper(tr.Key().Name), tctx.InPath, tctx.InMediaType.Type) - // The reader above is all we need. - break + if herrors.IsFeatureNotAvailableError(err) { + var errMsg string + switch strings.ToLower(tr.Key().Name) { + case "postcss": + // This transformation is not available in this + // Most likely because PostCSS is not installed. + errMsg = ". You need to install PostCSS. See https://gohugo.io/functions/css/postcss/" + case "tailwindcss": + errMsg = ". You need to install TailwindCSS CLI. See https://gohugo.io/functions/css/tailwindcss/" + case "tocss": + errMsg = ". Check your Hugo installation; you need the extended version to build SCSS/SASS with transpiler set to 'libsass'." + case "tocss-dart": + errMsg = ". You need to install Dart Sass, see https://gohugo.io//functions/css/sass/#dart-sass" + case "babel": + errMsg = ". You need to install Babel, see https://gohugo.io/functions/js/babel/" + + } + + return fmt.Errorf(msg+errMsg+": %w", err) } - // Abort. - return err + return fmt.Errorf(msg+": %w", err) + } + + bcfg := r.spec.BuildConfig() + var tryFileCache bool + if mayBeCachedOnDisk && bcfg.UseResourceCache(nil) { + tryFileCache = true + } else { + err = tr.Transform(tctx) + if err != nil && err != herrors.ErrFeatureNotAvailable { + return nil, newErr(err) + } + + if mayBeCachedOnDisk { + tryFileCache = bcfg.UseResourceCache(err) + } + if err != nil && !tryFileCache { + return nil, newErr(err) + } + } + + if tryFileCache { + f := r.target.tryTransformedFileCache(key, updates) + if f == nil { + if err != nil { + return nil, newErr(err) + } + return nil, newErr(fmt.Errorf("resource %q not found in file cache", key)) + } + transformedContentr = f + updates.sourceFs = cache.fileCache.Fs + defer f.Close() + + // The reader above is all we need. + break } if tctx.OutPath != "" { @@ -430,7 +579,7 @@ func (r *resourceAdapter) transform(publish, setContent bool) error { if publish { publicw, err := r.target.openPublishFileForWriting(updates.targetPath) if err != nil { - return err + return nil, err } publishwriters = append(publishwriters, publicw) } @@ -440,14 +589,14 @@ func (r *resourceAdapter) transform(publish, setContent bool) error { // Also write it to the cache fi, metaw, err := cache.writeMeta(key, updates.toTransformedResourceMetadata()) if err != nil { - return err + return nil, err } updates.sourceFilename = &fi.Name updates.sourceFs = cache.fileCache.Fs publishwriters = append(publishwriters, metaw) } - // Any transofrmations reading from From must also write to To. + // Any transformations reading from From must also write to To. // This means that if the target buffer is empty, we can just reuse // the original reader. if b, ok := tctx.To.(*bytes.Buffer); ok && b.Len() > 0 { @@ -471,7 +620,7 @@ func (r *resourceAdapter) transform(publish, setContent bool) error { publishw := hugio.NewMultiWriteCloser(publishwriters...) _, err = io.Copy(publishw, transformedContentr) if err != nil { - return err + return nil, err } publishw.Close() @@ -482,11 +631,11 @@ func (r *resourceAdapter) transform(publish, setContent bool) error { newTarget, err := r.target.cloneWithUpdates(updates) if err != nil { - return err + return nil, err } r.target = newTarget - return nil + return r.resourceAdapterInner, nil } func (r *resourceAdapter) init(publish, setContent bool) { @@ -506,9 +655,13 @@ func (r *resourceAdapter) initTransform(publish, setContent bool) { r.publishOnce = nil } - r.transformationsErr = r.transform(publish, setContent) + r.transformationsErr = r.getOrTransform(publish, setContent) if r.transformationsErr != nil { - r.spec.Logger.ERROR.Printf("Transformation failed: %s", r.transformationsErr) + if r.spec.ErrorSender != nil { + r.spec.ErrorSender.SendError(r.transformationsErr) + } else { + r.spec.Logger.Errorf("Transformation failed: %s", r.transformationsErr) + } } }) @@ -518,26 +671,53 @@ func (r *resourceAdapter) initTransform(publish, setContent bool) { } type resourceAdapterInner struct { + // The context that started this transformation. + ctx context.Context + target transformableResource + resource.Staler + spec *Spec // Handles publishing (to /public) if needed. *publishOnce } +func (r *resourceAdapterInner) GetIdentityGroup() identity.Identity { + return r.target.GetIdentityGroup() +} + +func (r *resourceAdapterInner) StaleVersion() uint32 { + // Both of these are incremented on change. + return r.Staler.StaleVersion() + r.target.StaleVersion() +} + type resourceTransformations struct { transformationsInit sync.Once transformationsErr error transformations []ResourceTransformation } +// hasTransformationPermalinkHash reports whether any of the transformations +// in the chain creates a permalink that's based on the content, e.g. fingerprint. +func (r *resourceTransformations) hasTransformationPermalinkHash() bool { + for _, t := range r.transformations { + if constants.IsResourceTransformationPermalinkHash(t.Key().Name) { + return true + } + } + return false +} + type transformableResource interface { baseResourceInternal resource.ContentProvider resource.Resource resource.Identifier + resource.Staler + resourceCopier } type transformationUpdate struct { @@ -546,18 +726,18 @@ type transformationUpdate struct { sourceFs afero.Fs targetPath string mediaType media.Type - data map[string]interface{} + data map[string]any startCtx ResourceTransformationCtx } -func (u *transformationUpdate) isContenChanged() bool { +func (u *transformationUpdate) isContentChanged() bool { return u.content != nil || u.sourceFilename != nil } func (u *transformationUpdate) toTransformedResourceMetadata() transformedResourceMetadata { return transformedResourceMetadata{ - MediaTypeV: u.mediaType.Type(), + MediaTypeV: u.mediaType.Type, Target: u.targetPath, MetaData: u.data, } @@ -572,9 +752,9 @@ func (u *transformationUpdate) updateFromCtx(ctx *ResourceTransformationCtx) { // We will persist this information to disk. type transformedResourceMetadata struct { - Target string `json:"Target"` - MediaTypeV string `json:"MediaType"` - MetaData map[string]interface{} `json:"Data"` + Target string `json:"Target"` + MediaTypeV string `json:"MediaType"` + MetaData map[string]any `json:"Data"` } // contentReadSeekerCloser returns a ReadSeekerCloser if possible for a given Resource. diff --git a/resources/transform_integration_test.go b/resources/transform_integration_test.go new file mode 100644 index 000000000..4404f1642 --- /dev/null +++ b/resources/transform_integration_test.go @@ -0,0 +1,50 @@ +// Copyright 2024 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 resources_test + +import ( + "testing" + + "github.com/gohugoio/hugo/hugolib" +) + +func TestTransformCached(t *testing.T) { + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term"] +-- assets/css/main.css -- +body { + background: #fff; +} +-- content/p1.md -- +--- +title: "P1" +--- +P1. +-- content/p2.md -- +--- +title: "P2" +--- +P2. +-- layouts/_default/list.html -- +List. +-- layouts/_default/single.html -- +{{ $css := resources.Get "css/main.css" | resources.Minify }} +CSS: {{ $css.Content }} +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/p1/index.html", "CSS: body{background:#fff}") +} diff --git a/resources/transform_test.go b/resources/transform_test.go index e7235bc8c..eac85ada9 100644 --- a/resources/transform_test.go +++ b/resources/transform_test.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Hugo Authors. All rights reserved. +// Copyright 2024 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. @@ -11,9 +11,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -package resources +package resources_test import ( + "context" "encoding/base64" "fmt" "io" @@ -24,11 +25,15 @@ import ( "testing" "github.com/gohugoio/hugo/htesting" + "github.com/gohugoio/hugo/identity" + "github.com/gohugoio/hugo/media" + "github.com/gohugoio/hugo/resources" "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/common/hugio" "github.com/gohugoio/hugo/hugofs" - "github.com/gohugoio/hugo/media" + "github.com/gohugoio/hugo/resources/images" "github.com/gohugoio/hugo/resources/internal" "github.com/gohugoio/hugo/helpers" @@ -44,20 +49,22 @@ const gopher = `iVBORw0KGgoAAAANSUhEUgAAAEsAAAA8CAAAAAALAhhPAAAFfUlEQVRYw62XeWwU func gopherPNG() io.Reader { return base64.NewDecoder(base64.StdEncoding, strings.NewReader(gopher)) } func TestTransform(t *testing.T) { - c := qt.New(t) - - createTransformer := func(spec *Spec, filename, content string) Transformer { - filename = filepath.FromSlash(filename) - fs := spec.Fs.Source - afero.WriteFile(fs, filename, []byte(content), 0777) - r, _ := spec.New(ResourceSourceDescriptor{Fs: fs, SourceFilename: filename}) - return r.(Transformer) + createTransformer := func(c *qt.C, spec *resources.Spec, filename, content string) resources.Transformer { + targetPath := identity.CleanString(filename) + r, err := spec.NewResource(resources.ResourceSourceDescriptor{ + TargetPath: targetPath, + OpenReadSeekCloser: hugio.NewOpenReadSeekCloser(hugio.NewReadSeekerNoOpCloserFromString(content)), + GroupIdentity: identity.StringIdentity(targetPath), + }) + c.Assert(err, qt.IsNil) + c.Assert(r, qt.Not(qt.IsNil), qt.Commentf(filename)) + return r.(resources.Transformer) } - createContentReplacer := func(name, old, new string) ResourceTransformation { + createContentReplacer := func(name, old, new string) resources.ResourceTransformation { return &testTransformation{ name: name, - transform: func(ctx *ResourceTransformationCtx) error { + transform: func(ctx *resources.ResourceTransformationCtx) error { in := helpers.ReaderToString(ctx.From) in = strings.Replace(in, old, new, 1) ctx.AddOutPathIdentifier("." + name) @@ -68,18 +75,24 @@ func TestTransform(t *testing.T) { } // Verify that we publish the same file once only. - assertNoDuplicateWrites := func(c *qt.C, spec *Spec) { + assertNoDuplicateWrites := func(c *qt.C, spec *resources.Spec) { c.Helper() - d := spec.Fs.Destination.(hugofs.DuplicatesReporter) - c.Assert(d.ReportDuplicates(), qt.Equals, "") + hugofs.WalkFilesystems(spec.Fs.PublishDir, func(fs afero.Fs) bool { + if dfs, ok := fs.(hugofs.DuplicatesReporter); ok { + c.Assert(dfs.ReportDuplicates(), qt.Equals, "") + } + return false + }) } - assertShouldExist := func(c *qt.C, spec *Spec, filename string, should bool) { + assertShouldExist := func(c *qt.C, spec *resources.Spec, filename string, should bool) { c.Helper() - exists, _ := helpers.Exists(filepath.FromSlash(filename), spec.Fs.Destination) + exists, _ := helpers.Exists(filepath.FromSlash(filename), spec.Fs.WorkingDirReadOnly) c.Assert(exists, qt.Equals, should) } + c := qt.New(t) + c.Run("All values", func(c *qt.C) { c.Parallel() @@ -87,14 +100,14 @@ func TestTransform(t *testing.T) { transformation := &testTransformation{ name: "test", - transform: func(ctx *ResourceTransformationCtx) error { + transform: func(ctx *resources.ResourceTransformationCtx) error { // Content in := helpers.ReaderToString(ctx.From) in = strings.Replace(in, "blue", "green", 1) fmt.Fprint(ctx.To, in) // Media type - ctx.OutMediaType = media.CSVType + ctx.OutMediaType = media.Builtin.CSVType // Change target ctx.ReplaceOutPathExtension(".csv") @@ -106,19 +119,19 @@ func TestTransform(t *testing.T) { }, } - r := createTransformer(spec, "f1.txt", "color is blue") + r := createTransformer(c, spec, "f1.txt", "color is blue") tr, err := r.Transform(transformation) c.Assert(err, qt.IsNil) - content, err := tr.(resource.ContentProvider).Content() + content, err := tr.(resource.ContentProvider).Content(context.Background()) c.Assert(err, qt.IsNil) c.Assert(content, qt.Equals, "color is green") - c.Assert(tr.MediaType(), eq, media.CSVType) + c.Assert(tr.MediaType(), eq, media.Builtin.CSVType) c.Assert(tr.RelPermalink(), qt.Equals, "/f1.csv") assertShouldExist(c, spec, "public/f1.csv", true) - data := tr.Data().(map[string]interface{}) + data := tr.Data().(map[string]any) c.Assert(data["mydata"], qt.Equals, "Hugo Rocks!") assertNoDuplicateWrites(c, spec) @@ -131,28 +144,28 @@ func TestTransform(t *testing.T) { transformation := &testTransformation{ name: "test", - transform: func(ctx *ResourceTransformationCtx) error { + transform: func(ctx *resources.ResourceTransformationCtx) error { // Change media type only - ctx.OutMediaType = media.CSVType + ctx.OutMediaType = media.Builtin.CSVType ctx.ReplaceOutPathExtension(".csv") return nil }, } - r := createTransformer(spec, "f1.txt", "color is blue") + r := createTransformer(c, spec, "f1.txt", "color is blue") tr, err := r.Transform(transformation) c.Assert(err, qt.IsNil) - content, err := tr.(resource.ContentProvider).Content() + content, err := tr.(resource.ContentProvider).Content(context.Background()) c.Assert(err, qt.IsNil) c.Assert(content, qt.Equals, "color is blue") - c.Assert(tr.MediaType(), eq, media.CSVType) + c.Assert(tr.MediaType(), eq, media.Builtin.CSVType) // The transformed file should only be published if RelPermalink // or Permalink is called. - n := htesting.RandIntn(3) + n := htesting.Rnd.Intn(3) shouldExist := true switch n { case 0: @@ -172,14 +185,14 @@ func TestTransform(t *testing.T) { spec := newTestResourceSpec(specDescriptor{c: c}) - // Two transformations with same id, different behaviour. + // Two transformations with same id, different behavior. t1 := createContentReplacer("t1", "blue", "green") t2 := createContentReplacer("t1", "color", "car") - for i, transformation := range []ResourceTransformation{t1, t2} { - r := createTransformer(spec, "f1.txt", "color is blue") + for i, transformation := range []resources.ResourceTransformation{t1, t2} { + r := createTransformer(c, spec, "f1.txt", "color is blue") tr, _ := r.Transform(transformation) - content, err := tr.(resource.ContentProvider).Content() + content, err := tr.(resource.ContentProvider).Content(context.Background()) c.Assert(err, qt.IsNil) c.Assert(content, qt.Equals, "color is green", qt.Commentf("i=%d", i)) @@ -194,24 +207,24 @@ func TestTransform(t *testing.T) { fs := afero.NewMemMapFs() - for i := 0; i < 2; i++ { + for i := range 2 { spec := newTestResourceSpec(specDescriptor{c: c, fs: fs}) - r := createTransformer(spec, "f1.txt", "color is blue") + r := createTransformer(c, spec, "f1.txt", "color is blue") - var transformation ResourceTransformation + var transformation resources.ResourceTransformation if i == 0 { // There is currently a hardcoded list of transformations that we // persist to disk (tocss, postcss). transformation = &testTransformation{ name: "tocss", - transform: func(ctx *ResourceTransformationCtx) error { + transform: func(ctx *resources.ResourceTransformationCtx) error { in := helpers.ReaderToString(ctx.From) in = strings.Replace(in, "blue", "green", 1) ctx.AddOutPathIdentifier("." + "cached") - ctx.OutMediaType = media.CSVType - ctx.Data = map[string]interface{}{ + ctx.OutMediaType = media.Builtin.CSVType + ctx.Data = map[string]any{ "Hugo": "Rocks!", } fmt.Fprint(ctx.To, in) @@ -222,7 +235,7 @@ func TestTransform(t *testing.T) { // Force read from file cache. transformation = &testTransformation{ name: "tocss", - transform: func(ctx *ResourceTransformationCtx) error { + transform: func(ctx *resources.ResourceTransformationCtx) error { return herrors.ErrFeatureNotAvailable }, } @@ -232,11 +245,11 @@ func TestTransform(t *testing.T) { tr, _ := r.Transform(transformation) c.Assert(tr.RelPermalink(), qt.Equals, "/f1.cached.txt", msg) - content, err := tr.(resource.ContentProvider).Content() + content, err := tr.(resource.ContentProvider).Content(context.Background()) c.Assert(err, qt.IsNil) c.Assert(content, qt.Equals, "color is green", msg) - c.Assert(tr.MediaType(), eq, media.CSVType) - c.Assert(tr.Data(), qt.DeepEquals, map[string]interface{}{ + c.Assert(tr.MediaType(), eq, media.Builtin.CSVType) + c.Assert(tr.Data(), qt.DeepEquals, map[string]any{ "Hugo": "Rocks!", }) @@ -253,18 +266,18 @@ func TestTransform(t *testing.T) { t1 := createContentReplacer("t1", "blue", "green") - r := createTransformer(spec, "f1.txt", "color is blue") + r := createTransformer(c, spec, "f1.txt", "color is blue") tr, _ := r.Transform(t1) relPermalink := tr.RelPermalink() - content, err := tr.(resource.ContentProvider).Content() + content, err := tr.(resource.ContentProvider).Content(context.Background()) c.Assert(err, qt.IsNil) c.Assert(relPermalink, qt.Equals, "/f1.t1.txt") c.Assert(content, qt.Equals, "color is green") - c.Assert(tr.MediaType(), eq, media.TextType) + c.Assert(tr.MediaType(), eq, media.Builtin.TextType) assertNoDuplicateWrites(c, spec) assertShouldExist(c, spec, "public/f1.t1.txt", true) @@ -278,14 +291,14 @@ func TestTransform(t *testing.T) { t1 := createContentReplacer("t1", "blue", "green") t2 := createContentReplacer("t1", "color", "car") - r := createTransformer(spec, "f1.txt", "color is blue") + r := createTransformer(c, spec, "f1.txt", "color is blue") tr, _ := r.Transform(t1, t2) - content, err := tr.(resource.ContentProvider).Content() + content, err := tr.(resource.ContentProvider).Content(context.Background()) c.Assert(err, qt.IsNil) c.Assert(content, qt.Equals, "car is green") - c.Assert(tr.MediaType(), eq, media.TextType) + c.Assert(tr.MediaType(), eq, media.Builtin.TextType) assertNoDuplicateWrites(c, spec) }) @@ -298,14 +311,16 @@ func TestTransform(t *testing.T) { t1 := createContentReplacer("t1", "blue", "green") t2 := createContentReplacer("t2", "color", "car") - r := createTransformer(spec, "f1.txt", "color is blue") + r := createTransformer(c, spec, "f1.txt", "color is blue") - tr1, _ := r.Transform(t1) - tr2, _ := tr1.Transform(t2) - - content1, err := tr1.(resource.ContentProvider).Content() + tr1, err := r.Transform(t1) c.Assert(err, qt.IsNil) - content2, err := tr2.(resource.ContentProvider).Content() + tr2, err := tr1.Transform(t2) + c.Assert(err, qt.IsNil) + + content1, err := tr1.(resource.ContentProvider).Content(context.Background()) + c.Assert(err, qt.IsNil) + content2, err := tr2.(resource.ContentProvider).Content(context.Background()) c.Assert(err, qt.IsNil) c.Assert(content1, qt.Equals, "color is green") @@ -321,20 +336,20 @@ func TestTransform(t *testing.T) { const count = 26 // A-Z - transformations := make([]ResourceTransformation, count) - for i := 0; i < count; i++ { - transformations[i] = createContentReplacer(fmt.Sprintf("t%d", i), fmt.Sprint(i), string(i+65)) + transformations := make([]resources.ResourceTransformation, count) + for i := range count { + transformations[i] = createContentReplacer(fmt.Sprintf("t%d", i), fmt.Sprint(i), string(rune(i+65))) } var countstr strings.Builder - for i := 0; i < count; i++ { + for i := range count { countstr.WriteString(fmt.Sprint(i)) } - r := createTransformer(spec, "f1.txt", countstr.String()) + r := createTransformer(c, spec, "f1.txt", countstr.String()) tr, _ := r.Transform(transformations...) - content, err := tr.(resource.ContentProvider).Content() + content, err := tr.(resource.ContentProvider).Content(context.Background()) c.Assert(err, qt.IsNil) c.Assert(content, qt.Equals, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") @@ -349,19 +364,19 @@ func TestTransform(t *testing.T) { transformation := &testTransformation{ name: "test", - transform: func(ctx *ResourceTransformationCtx) error { + transform: func(ctx *resources.ResourceTransformationCtx) error { ctx.AddOutPathIdentifier(".changed") return nil }, } - r := createTransformer(spec, "gopher.png", helpers.ReaderToString(gopherPNG())) + r := createTransformer(c, spec, "gopher.png", helpers.ReaderToString(gopherPNG())) tr, err := r.Transform(transformation) c.Assert(err, qt.IsNil) - c.Assert(tr.MediaType(), eq, media.PNGType) + c.Assert(tr.MediaType(), eq, media.Builtin.PNGType) - img, ok := tr.(resource.Image) + img, ok := tr.(images.ImageResource) c.Assert(ok, qt.Equals, true) c.Assert(img.Width(), qt.Equals, 75) @@ -371,49 +386,41 @@ func TestTransform(t *testing.T) { resizedPublished1, err := img.Resize("40x40") c.Assert(err, qt.IsNil) c.Assert(resizedPublished1.Height(), qt.Equals, 40) - c.Assert(resizedPublished1.RelPermalink(), qt.Equals, "/gopher.changed_hu2e827f5a78333ebc04166dd643235dea_1462_40x40_resize_linear_2.png") - assertShouldExist(c, spec, "public/gopher.changed_hu2e827f5a78333ebc04166dd643235dea_1462_40x40_resize_linear_2.png", true) + c.Assert(resizedPublished1.RelPermalink(), qt.Equals, "/gopher.changed_hu_85920388a7ff96fa.png") + assertShouldExist(c, spec, "public/gopher.changed_hu_85920388a7ff96fa.png", true) // Permalink called. resizedPublished2, err := img.Resize("30x30") c.Assert(err, qt.IsNil) c.Assert(resizedPublished2.Height(), qt.Equals, 30) - c.Assert(resizedPublished2.Permalink(), qt.Equals, "https://example.com/gopher.changed_hu2e827f5a78333ebc04166dd643235dea_1462_30x30_resize_linear_2.png") - assertShouldExist(c, spec, "public/gopher.changed_hu2e827f5a78333ebc04166dd643235dea_1462_30x30_resize_linear_2.png", true) - - // Not published because none of RelPermalink or Permalink was called. - resizedNotPublished, err := img.Resize("50x50") - c.Assert(err, qt.IsNil) - c.Assert(resizedNotPublished.Height(), qt.Equals, 50) - //c.Assert(resized.RelPermalink(), qt.Equals, "/gopher.changed_hu2e827f5a78333ebc04166dd643235dea_1462_50x50_resize_linear_2.png") - assertShouldExist(c, spec, "public/gopher.changed_hu2e827f5a78333ebc04166dd643235dea_1462_50x50_resize_linear_2.png", false) + c.Assert(resizedPublished2.Permalink(), qt.Equals, "https://example.com/gopher.changed_hu_c8d8163c08643a7f.png") + assertShouldExist(c, spec, "public/gopher.changed_hu_c8d8163c08643a7f.png", true) assertNoDuplicateWrites(c, spec) - }) c.Run("Concurrent", func(c *qt.C) { spec := newTestResourceSpec(specDescriptor{c: c}) - transformers := make([]Transformer, 10) - transformations := make([]ResourceTransformation, 10) + transformers := make([]resources.Transformer, 10) + transformations := make([]resources.ResourceTransformation, 10) - for i := 0; i < 10; i++ { - transformers[i] = createTransformer(spec, fmt.Sprintf("f%d.txt", i), fmt.Sprintf("color is %d", i)) + for i := range 10 { + transformers[i] = createTransformer(c, spec, fmt.Sprintf("f%d.txt", i), fmt.Sprintf("color is %d", i)) transformations[i] = createContentReplacer("test", strconv.Itoa(i), "blue") } var wg sync.WaitGroup - for i := 0; i < 13; i++ { + for i := range 13 { wg.Add(1) go func(i int) { defer wg.Done() - for j := 0; j < 23; j++ { + for j := range 23 { id := (i + j) % 10 tr, err := transformers[id].Transform(transformations[id]) c.Assert(err, qt.IsNil) - content, err := tr.(resource.ContentProvider).Content() + content, err := tr.(resource.ContentProvider).Content(context.Background()) c.Assert(err, qt.IsNil) c.Assert(content, qt.Equals, "color is blue") c.Assert(tr.RelPermalink(), qt.Equals, fmt.Sprintf("/f%d.test.txt", id)) @@ -428,13 +435,13 @@ func TestTransform(t *testing.T) { type testTransformation struct { name string - transform func(ctx *ResourceTransformationCtx) error + transform func(ctx *resources.ResourceTransformationCtx) error } func (t *testTransformation) Key() internal.ResourceTransformationKey { return internal.NewResourceTransformationKey(t.name) } -func (t *testTransformation) Transform(ctx *ResourceTransformationCtx) error { +func (t *testTransformation) Transform(ctx *resources.ResourceTransformationCtx) error { return t.transform(ctx) } diff --git a/scripts/docker/entrypoint.sh b/scripts/docker/entrypoint.sh new file mode 100755 index 000000000..20ffbe5f7 --- /dev/null +++ b/scripts/docker/entrypoint.sh @@ -0,0 +1,21 @@ +#!/bin/sh + +# Check if a custom hugo-docker-entrypoint.sh file exists. +if [ -f hugo-docker-entrypoint.sh ]; then + # Execute the custom entrypoint file. + sh hugo-docker-entrypoint.sh "$@" + exit $? +fi + +# Check if a package.json file exists. +if [ -f package.json ]; then + # Check if node_modules exists. + if [ ! -d node_modules ]; then + # Install npm packages. + # Note that we deliberately do not use `npm ci` here, as it would fail if the package-lock.json file is not up-to-date, + # which would be the case if you run the container with a different OS or architecture than the one used to create the package-lock.json file. + npm i + fi +fi + +exec "hugo" "$@" \ No newline at end of file diff --git a/scripts/fork_go_templates/main.go b/scripts/fork_go_templates/main.go index d9d056797..e4895c87a 100644 --- a/scripts/fork_go_templates/main.go +++ b/scripts/fork_go_templates/main.go @@ -2,7 +2,6 @@ package main import ( "fmt" - "io/ioutil" "log" "os" "os/exec" @@ -16,8 +15,8 @@ import ( ) func main() { - // TODO(bep) git checkout tag - // The current is built with Go version 9341fe073e6f7742c9d61982084874560dac2014 / go1.13.5 + // The current is built with 3901409b5d [release-branch.go1.24] go1.24.0 + // TODO(bep) preserve the staticcheck.conf file. fmt.Println("Forking ...") defer fmt.Println("Done ...") @@ -35,12 +34,11 @@ func main() { goimports(htmlRoot) gofmt(forkRoot) - } const ( // TODO(bep) - goSource = "/Users/bep/dev/go/dump/go/src" + goSource = "/Users/bep/dev/go/misc/go/src" forkRoot = "../../tpl/internal/go_templates" ) @@ -55,6 +53,8 @@ var ( textTemplateReplacers = strings.NewReplacer( `"text/template/`, `"github.com/gohugoio/hugo/tpl/internal/go_templates/texttemplate/`, `"internal/fmtsort"`, `"github.com/gohugoio/hugo/tpl/internal/go_templates/fmtsort"`, + `"internal/testenv"`, `"github.com/gohugoio/hugo/tpl/internal/go_templates/testenv"`, + "TestLinkerGC", "_TestLinkerGC", // Rename types and function that we want to overload. "type state struct", "type stateOld struct", "func (s *state) evalFunction", "func (s *state) evalFunctionOld", @@ -63,12 +63,17 @@ var ( "func isTrue(val reflect.Value) (truth, ok bool) {", "func isTrueOld(val reflect.Value) (truth, ok bool) {", ) + testEnvReplacers = strings.NewReplacer( + `"internal/cfg"`, `"github.com/gohugoio/hugo/tpl/internal/go_templates/cfg"`, + ) + htmlTemplateReplacers = strings.NewReplacer( `. "html/template"`, `. "github.com/gohugoio/hugo/tpl/internal/go_templates/htmltemplate"`, `"html/template"`, `template "github.com/gohugoio/hugo/tpl/internal/go_templates/htmltemplate"`, "\"text/template\"\n", "template \"github.com/gohugoio/hugo/tpl/internal/go_templates/texttemplate\"\n", `"html/template"`, `htmltemplate "html/template"`, `"fmt"`, `htmltemplate "html/template"`, + `t.Skip("this test currently fails with -race; see issue #39807")`, `// t.Skip("this test currently fails with -race; see issue #39807")`, ) ) @@ -91,31 +96,43 @@ package parse } return content - } var goPackages = []goPackage{ - goPackage{srcPkg: "text/template", dstPkg: "texttemplate", - replacer: func(name, content string) string { return textTemplateReplacers.Replace(commonReplace(name, content)) }}, - goPackage{srcPkg: "html/template", dstPkg: "htmltemplate", replacer: func(name, content string) string { - if strings.HasSuffix(name, "content.go") { - // Remove template.HTML types. We need to use the Go types. - content = removeAll(`(?s)// Strings of content.*?\)\n`, content) - } - - content = commonReplace(name, content) - - return htmlTemplateReplacers.Replace(content) + { + srcPkg: "text/template", dstPkg: "texttemplate", + replacer: func(name, content string) string { return textTemplateReplacers.Replace(commonReplace(name, content)) }, }, + { + srcPkg: "html/template", dstPkg: "htmltemplate", replacer: func(name, content string) string { + if strings.HasSuffix(name, "content.go") { + // Remove template.HTML types. We need to use the Go types. + content = removeAll(`(?s)// Strings of content.*?\)\n`, content) + } + + content = commonReplace(name, content) + + return htmlTemplateReplacers.Replace(content) + }, rewriter: func(name string) { for _, s := range []string{"CSS", "HTML", "HTMLAttr", "JS", "JSStr", "URL", "Srcset"} { rewrite(name, fmt.Sprintf("%s -> htmltemplate.%s", s, s)) } rewrite(name, `"text/template/parse" -> "github.com/gohugoio/hugo/tpl/internal/go_templates/texttemplate/parse"`) - }}, - goPackage{srcPkg: "internal/fmtsort", dstPkg: "fmtsort", rewriter: func(name string) { + }, + }, + {srcPkg: "internal/fmtsort", dstPkg: "fmtsort", rewriter: func(name string) { rewrite(name, `"internal/fmtsort" -> "github.com/gohugoio/hugo/tpl/internal/go_templates/fmtsort"`) }}, + { + srcPkg: "internal/testenv", dstPkg: "testenv", + replacer: func(name, content string) string { return testEnvReplacers.Replace(content) }, rewriter: func(name string) { + rewrite(name, `"internal/testenv" -> "github.com/gohugoio/hugo/tpl/internal/go_templates/testenv"`) + }, + }, + {srcPkg: "internal/cfg", dstPkg: "cfg", rewriter: func(name string) { + rewrite(name, `"internal/cfg" -> "github.com/gohugoio/hugo/tpl/internal/go_templates/cfg"`) + }}, } var fs = afero.NewOsFs() @@ -145,11 +162,15 @@ func copyGoPackage(dst, src string) { func doWithGoFiles(dir string, rewrite func(name string), - transform func(name, in string) string) { + transform func(name, in string) string, +) { if rewrite == nil && transform == nil { return } must(filepath.Walk(filepath.Join(forkRoot, dir), func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } if info.IsDir() { return nil } @@ -168,7 +189,7 @@ func doWithGoFiles(dir string, return nil } - data, err := ioutil.ReadFile(path) + data, err := os.ReadFile(path) must(err) f, err := os.Create(path) must(err) @@ -183,7 +204,6 @@ func doWithGoFiles(dir string, func removeAll(expression, content string) string { re := regexp.MustCompile(expression) return re.ReplaceAllString(content, "") - } func rewrite(filename, rule string) { @@ -195,6 +215,7 @@ func rewrite(filename, rule string) { } func goimports(dir string) { + // Needs go install golang.org/x/tools/cmd/goimports@latest cmf := exec.Command("goimports", "-w", dir) out, err := cmf.CombinedOutput() if err != nil { diff --git a/snap/local/logo.png b/snap/local/logo.png new file mode 100644 index 000000000..76d463600 Binary files /dev/null and b/snap/local/logo.png differ diff --git a/snap/plugins/x-nodejs.yaml b/snap/plugins/x-nodejs.yaml deleted file mode 100644 index 60b465459..000000000 --- a/snap/plugins/x-nodejs.yaml +++ /dev/null @@ -1,8 +0,0 @@ -options: - source: - required: true - source-type: - source-tag: - source-branch: - nodejs-target: - required: true diff --git a/snap/plugins/x_nodejs.py b/snap/plugins/x_nodejs.py deleted file mode 100644 index 848cac596..000000000 --- a/snap/plugins/x_nodejs.py +++ /dev/null @@ -1,332 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Modified by Anthony Fok on 2018-10-01 to add support for ppc64el and s390x -# -# Copyright (C) 2015-2017 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""The nodejs plugin is useful for node/npm based parts. - -The plugin uses node to install dependencies from `package.json`. It -also sets up binaries defined in `package.json` into the `PATH`. - -This plugin uses the common plugin keywords as well as those for "sources". -For more information check the 'plugins' topic for the former and the -'sources' topic for the latter. - -Additionally, this plugin uses the following plugin-specific keywords: - - - node-packages: - (list) - A list of dependencies to fetch using npm. - - node-engine: - (string) - The version of nodejs you want the snap to run on. - - npm-run: - (list) - A list of targets to `npm run`. - These targets will be run in order, after `npm install` - - npm-flags: - (list) - A list of flags for npm. - - node-package-manager - (string; default: npm) - The language package manager to use to drive installation - of node packages. Can be either `npm` (default) or `yarn`. -""" - -import collections -import contextlib -import json -import logging -import os -import shutil -import subprocess -import sys - -import snapcraft -from snapcraft import sources -from snapcraft.file_utils import link_or_copy_tree -from snapcraft.internal import errors - -logger = logging.getLogger(__name__) - -_NODEJS_BASE = "node-v{version}-linux-{arch}" -_NODEJS_VERSION = "8.12.0" -_NODEJS_TMPL = "https://nodejs.org/dist/v{version}/{base}.tar.gz" -_NODEJS_ARCHES = {"i386": "x86", "amd64": "x64", "armhf": "armv7l", "arm64": "arm64", "ppc64el": "ppc64le", "s390x": "s390x"} -_YARN_URL = "https://yarnpkg.com/latest.tar.gz" - - -class NodePlugin(snapcraft.BasePlugin): - @classmethod - def schema(cls): - schema = super().schema() - - schema["properties"]["node-packages"] = { - "type": "array", - "minitems": 1, - "uniqueItems": True, - "items": {"type": "string"}, - "default": [], - } - schema["properties"]["node-engine"] = { - "type": "string", - "default": _NODEJS_VERSION, - } - schema["properties"]["node-package-manager"] = { - "type": "string", - "default": "npm", - "enum": ["npm", "yarn"], - } - schema["properties"]["npm-run"] = { - "type": "array", - "minitems": 1, - "uniqueItems": False, - "items": {"type": "string"}, - "default": [], - } - schema["properties"]["npm-flags"] = { - "type": "array", - "minitems": 1, - "uniqueItems": False, - "items": {"type": "string"}, - "default": [], - } - - if "required" in schema: - del schema["required"] - - return schema - - @classmethod - def get_build_properties(cls): - # Inform Snapcraft of the properties associated with building. If these - # change in the YAML Snapcraft will consider the build step dirty. - return ["node-packages", "npm-run", "npm-flags"] - - @classmethod - def get_pull_properties(cls): - # Inform Snapcraft of the properties associated with pulling. If these - # change in the YAML Snapcraft will consider the build step dirty. - return ["node-engine", "node-package-manager"] - - @property - def _nodejs_tar(self): - if self._nodejs_tar_handle is None: - self._nodejs_tar_handle = sources.Tar( - self._nodejs_release_uri, self._npm_dir - ) - return self._nodejs_tar_handle - - @property - def _yarn_tar(self): - if self._yarn_tar_handle is None: - self._yarn_tar_handle = sources.Tar(_YARN_URL, self._npm_dir) - return self._yarn_tar_handle - - def __init__(self, name, options, project): - super().__init__(name, options, project) - self._source_package_json = os.path.join( - os.path.abspath(self.options.source), "package.json" - ) - self._npm_dir = os.path.join(self.partdir, "npm") - self._manifest = collections.OrderedDict() - self._nodejs_release_uri = get_nodejs_release( - self.options.node_engine, self.project.deb_arch - ) - self._nodejs_tar_handle = None - self._yarn_tar_handle = None - - def pull(self): - super().pull() - os.makedirs(self._npm_dir, exist_ok=True) - self._nodejs_tar.download() - if self.options.node_package_manager == "yarn": - self._yarn_tar.download() - # do the install in the pull phase to download all dependencies. - if self.options.node_package_manager == "npm": - self._npm_install(rootdir=self.sourcedir) - else: - self._yarn_install(rootdir=self.sourcedir) - - def clean_pull(self): - super().clean_pull() - - # Remove the npm directory (if any) - if os.path.exists(self._npm_dir): - shutil.rmtree(self._npm_dir) - - def build(self): - super().build() - if self.options.node_package_manager == "npm": - installed_node_packages = self._npm_install(rootdir=self.builddir) - # Copy the content of the symlink to the build directory - # LP: #1702661 - modules_dir = os.path.join(self.installdir, "lib", "node_modules") - _copy_symlinked_content(modules_dir) - else: - installed_node_packages = self._yarn_install(rootdir=self.builddir) - lock_file_path = os.path.join(self.sourcedir, "yarn.lock") - if os.path.isfile(lock_file_path): - with open(lock_file_path) as lock_file: - self._manifest["yarn-lock-contents"] = lock_file.read() - - self._manifest["node-packages"] = [ - "{}={}".format(name, installed_node_packages[name]) - for name in installed_node_packages - ] - - def _npm_install(self, rootdir): - self._nodejs_tar.provision( - self.installdir, clean_target=False, keep_tarball=True - ) - npm_cmd = ["npm"] + self.options.npm_flags - npm_install = npm_cmd + ["--cache-min=Infinity", "install"] - for pkg in self.options.node_packages: - self.run(npm_install + ["--global"] + [pkg], cwd=rootdir) - if os.path.exists(os.path.join(rootdir, "package.json")): - self.run(npm_install, cwd=rootdir) - self.run(npm_install + ["--global"], cwd=rootdir) - for target in self.options.npm_run: - self.run(npm_cmd + ["run", target], cwd=rootdir) - return self._get_installed_node_packages("npm", self.installdir) - - def _yarn_install(self, rootdir): - self._nodejs_tar.provision( - self.installdir, clean_target=False, keep_tarball=True - ) - self._yarn_tar.provision(self._npm_dir, clean_target=False, keep_tarball=True) - yarn_cmd = [os.path.join(self._npm_dir, "bin", "yarn")] - yarn_cmd.extend(self.options.npm_flags) - if "http_proxy" in os.environ: - yarn_cmd.extend(["--proxy", os.environ["http_proxy"]]) - if "https_proxy" in os.environ: - yarn_cmd.extend(["--https-proxy", os.environ["https_proxy"]]) - flags = [] - if rootdir == self.builddir: - yarn_add = yarn_cmd + ["global", "add"] - flags.extend( - [ - "--offline", - "--prod", - "--global-folder", - self.installdir, - "--prefix", - self.installdir, - ] - ) - else: - yarn_add = yarn_cmd + ["add"] - for pkg in self.options.node_packages: - self.run(yarn_add + [pkg] + flags, cwd=rootdir) - - # local packages need to be added as if they were remote, we - # remove the local package.json so `yarn add` doesn't pollute it. - if os.path.exists(self._source_package_json): - with contextlib.suppress(FileNotFoundError): - os.unlink(os.path.join(rootdir, "package.json")) - shutil.copy( - self._source_package_json, os.path.join(rootdir, "package.json") - ) - self.run(yarn_add + ["file:{}".format(rootdir)] + flags, cwd=rootdir) - - # npm run would require to bring back package.json - if self.options.npm_run and os.path.exists(self._source_package_json): - # The current package.json is the yarn prefilled one. - with contextlib.suppress(FileNotFoundError): - os.unlink(os.path.join(rootdir, "package.json")) - os.link(self._source_package_json, os.path.join(rootdir, "package.json")) - for target in self.options.npm_run: - self.run( - yarn_cmd + ["run", target], - cwd=rootdir, - env=self._build_environment(rootdir), - ) - return self._get_installed_node_packages("npm", self.installdir) - - def _get_installed_node_packages(self, package_manager, cwd): - try: - output = self.run_output( - [package_manager, "ls", "--global", "--json"], cwd=cwd - ) - except subprocess.CalledProcessError as error: - # XXX When dependencies have missing dependencies, an error like - # this is printed to stderr: - # npm ERR! peer dep missing: glob@*, required by glob-promise@3.1.0 - # retcode is not 0, which raises an exception. - output = error.output.decode(sys.getfilesystemencoding()).strip() - packages = collections.OrderedDict() - dependencies = json.loads(output, object_pairs_hook=collections.OrderedDict)[ - "dependencies" - ] - while dependencies: - key, value = dependencies.popitem(last=False) - # XXX Just as above, dependencies without version are the ones - # missing. - if "version" in value: - packages[key] = value["version"] - if "dependencies" in value: - dependencies.update(value["dependencies"]) - return packages - - def get_manifest(self): - return self._manifest - - def _build_environment(self, rootdir): - env = os.environ.copy() - if rootdir.endswith("src"): - hidden_path = os.path.join(rootdir, "node_modules", ".bin") - if env.get("PATH"): - new_path = "{}:{}".format(hidden_path, env.get("PATH")) - else: - new_path = hidden_path - env["PATH"] = new_path - return env - - -def _get_nodejs_base(node_engine, machine): - if machine not in _NODEJS_ARCHES: - raise errors.SnapcraftEnvironmentError( - "architecture not supported ({})".format(machine) - ) - return _NODEJS_BASE.format(version=node_engine, arch=_NODEJS_ARCHES[machine]) - - -def get_nodejs_release(node_engine, arch): - return _NODEJS_TMPL.format( - version=node_engine, base=_get_nodejs_base(node_engine, arch) - ) - - -def _copy_symlinked_content(modules_dir): - """Copy symlinked content. - - When running newer versions of npm, symlinks to the local tree are - created from the part's installdir to the root of the builddir of the - part (this only affects some build configurations in some projects) - which is valid when running from the context of the part but invalid - as soon as the artifacts migrate across the steps, - i.e.; stage and prime. - - If modules_dir does not exist we simply return. - """ - if not os.path.exists(modules_dir): - return - modules = [os.path.join(modules_dir, d) for d in os.listdir(modules_dir)] - symlinks = [l for l in modules if os.path.islink(l)] - for link_path in symlinks: - link_target = os.path.realpath(link_path) - os.unlink(link_path) - link_or_copy_tree(link_target, link_path) diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 7ed88c560..6a64bd2d4 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,95 +1,216 @@ name: hugo -version: "0.64.0" +base: core22 +confinement: strict +adopt-info: hugo +title: Hugo +icon: snap/local/logo.png summary: Fast and Flexible Static Site Generator description: | - Hugo is a static HTML and CSS website generator written in Go. It is - optimized for speed, easy use and configurability. Hugo takes a directory - with content and templates and renders them into a full HTML website. -confinement: strict -grade: stable # "devel" or "stable" + Hugo is a static site generator written in Go, optimized for speed and + designed for flexibility. With its advanced templating system and fast asset + pipelines, Hugo renders a complete site in seconds, often less. + + Due to its flexible framework, multilingual support, and powerful taxonomy + system, Hugo is widely used to create: + + * Corporate, government, nonprofit, education, news, event, and project sites + * Documentation sites + * Image portfolios + * Landing pages + * Business, professional, and personal blogs + * Resumes and CVs + + Use Hugo's embedded web server during development to instantly see changes to + content, structure, behavior, and presentation. Then deploy the site to your + host, or push changes to your Git provider for automated builds and + deployment. +issues: https://github.com/gohugoio/hugo/issues +license: "Apache-2.0" +source-code: https://github.com/gohugoio/hugo.git +website: https://gohugo.io/ + +plugs: + etc-gitconfig: + interface: system-files + read: + - /etc/gitconfig + gitconfig: + interface: personal-files + read: + - $HOME/.gitconfig + - $HOME/.config/git + - $HOME/.gitconfig.local + dot-aws: + interface: personal-files + read: + - $HOME/.aws + dot-azure: + interface: personal-files + read: + - $HOME/.azure + dot-config-gcloud: + interface: personal-files + read: + - $HOME/.config/gcloud + dot-cache-hugo: + interface: personal-files + write: + - $HOME/.cache/hugo_cache + ssh-keys: + interface: ssh-keys + +environment: + HOME: $SNAP_REAL_HOME + GIT_EXEC_PATH: $SNAP/usr/lib/git-core + GOCACHE: $SNAP_USER_DATA/.cache/go-build + npm_config_cache: $SNAP_USER_DATA/.npm + npm_config_init_module: $SNAP_USER_DATA/.npm-init.js + npm_config_userconfig: $SNAP_USER_DATA/.npmrc + pandoc_datadir: $SNAP/usr/share/pandoc + PYTHONHOME: /usr:$SNAP/usr + RUBYLIB: $SNAP/usr/lib/ruby/vendor_ruby/3.0.0:$SNAP/usr/lib/$CRAFT_ARCH_TRIPLET/ruby/vendor_ruby/3.0.0:$SNAP/usr/lib/ruby/vendor_ruby:$SNAP/usr/lib/ruby/3.0.0:$SNAP/usr/lib/$CRAFT_ARCH_TRIPLET/ruby/3.0.0 + + # HUGO_SECURITY_EXEC_OSENV + # + # Default value: + # (?i)^((HTTPS?|NO)_PROXY|PATH(EXT)?|APPDATA|TE?MP|TERM|GO\w+|(XDG_CONFIG_)?HOME|USERPROFILE|SSH_AUTH_SOCK|DISPLAY|LANG|SYSTEMDRIVE)$ + # Bundled applications require additional access: + # git: GIT_EXEC_PATH and LD_LIBRARY_PATH + # npx: npm_config_{cache,init_module,userconfig} + # pandoc: pandoc_datadir + # rst2html: PYTHONHOME and SNAP + # asciidoctor: RUBYLIB + HUGO_SECURITY_EXEC_OSENV: (?i)^((HTTPS?|NO)_PROXY|PATH(EXT)?|APPDATA|TE?MP|TERM|GO\w+|(XDG_CONFIG_)?HOME|USERPROFILE|SSH_AUTH_SOCK|DISPLAY|LANG|SYSTEMDRIVE|GIT_EXEC_PATH|LD_LIBRARY_PATH|npm_config_(cache|init_module|userconfig)|pandoc_datadir|PYTHONHOME|SNAP|RUBYLIB)$ apps: hugo: command: bin/hugo completer: hugo-completion - plugs: [home, network-bind, removable-media] + plugs: + - home + - network-bind + - removable-media + - etc-gitconfig + - gitconfig + - dot-aws + - dot-azure + - dot-config-gcloud + - dot-cache-hugo + - ssh-keys parts: git: plugin: nil stage-packages: - git - organize: - usr/bin/: bin/ prime: - - bin/git + - usr/bin/git + - usr/lib + + go: + plugin: nil + stage-snaps: + - go/latest/stable + prime: + - bin/go + - pkg/tool + - -pkg/tool/* hugo: plugin: nil - build-snaps: [go/1.13/stable] source: . + after: + - git + - go + override-pull: | + craftctl default + craftctl set version=$(git describe --tags --always --match 'v[0-9]*' | sed 's/^v//; s/-/+git/; s/-g/./') + if grep -q 'Suffix:\s*""' common/hugo/version_current.go; then + craftctl set grade=stable + else + craftctl set grade=devel + fi override-build: | + echo "\nStarting override-build:" set -ex - echo "\nStarting override-build:" export GOPATH=$(realpath ../go) export PATH=$GOPATH/bin:$PATH - echo ' * Running "go get -v github.com/magefile/mage"...' - GO111MODULE=off go get -v github.com/magefile/mage - - echo ' * Running "mage -v test"...' - export GO111MODULE=on - mage -v test - - echo " * SNAPCRAFT_IMAGE_INFO=$SNAPCRAFT_IMAGE_INFO" - # Example: SNAPCRAFT_IMAGE_INFO='{"build_url": "https://launchpad.net/~gohugoio/+snap/hugo-extended-dev/+build/344022"}' - if echo $SNAPCRAFT_IMAGE_INFO | grep -q '/+snap/hugo-extended'; then - export HUGO_BUILD_TAGS="extended" - fi + HUGO_BUILD_TAGS="extended" echo " * Building hugo (HUGO_BUILD_TAGS=\"$HUGO_BUILD_TAGS\")..." - [ "$SNAPCRAFT_PROJECT_GRADE" = "stable" ] && mage -v hugoNoGitInfo || mage -v hugo + go build -v -ldflags "-s -w -X github.com/gohugoio/hugo/common/hugo.vendorInfo=snap:$(git describe --tags --always --match 'v[0-9]*' | sed 's/^v//; s/-/+git/; s/-g/./')" -tags "$HUGO_BUILD_TAGS" ./hugo version ldd hugo || : echo " * Building shell completion..." - ./hugo gen autocomplete --completionfile=hugo-completion + ./hugo completion bash > hugo-completion - echo " * Installing to ${SNAPCRAFT_PART_INSTALL}..." - install -d $SNAPCRAFT_PART_INSTALL/bin - cp -av hugo $SNAPCRAFT_PART_INSTALL/bin/ - mv -v hugo-completion $SNAPCRAFT_PART_INSTALL/ + echo " * Installing to ${CRAFT_PART_INSTALL}..." + install -d $CRAFT_PART_INSTALL/bin + cp -av hugo $CRAFT_PART_INSTALL/bin/ + mv -v hugo-completion $CRAFT_PART_INSTALL/ echo " * Stripping binary..." - ls -l $SNAPCRAFT_PART_INSTALL/bin/hugo - strip --remove-section=.comment --remove-section=.note $SNAPCRAFT_PART_INSTALL/bin/hugo - ls -l $SNAPCRAFT_PART_INSTALL/bin/hugo + ls -l $CRAFT_PART_INSTALL/bin/hugo + strip --remove-section=.comment --remove-section=.note $CRAFT_PART_INSTALL/bin/hugo + ls -l $CRAFT_PART_INSTALL/bin/hugo + + asciidoctor: + plugin: nil + stage-packages: + - asciidoctor + override-build: | + set -ex + craftctl default + sed -i '1s|#!/usr/bin/ruby|#!/usr/bin/env ruby|' $CRAFT_PART_INSTALL/usr/bin/asciidoctor + # don't try and flock() gemspecs since this is blocked by AppArmor - see + # https://github.com/rubygems/rubygems/pull/5278 in particular + # https://github.com/rubygems/rubygems/pull/5278/commits/27b682c81226838b1254ac5843a3f5b1cb20f076 + sed -i 's|!solaris_platform|win_platform|' $CRAFT_PART_INSTALL/usr/lib/ruby/vendor_ruby/rubygems.rb + + dart-sass: + plugin: nil + build-packages: + - curl + override-build: | + set -ex + craftctl default + case "$CRAFT_TARGET_ARCH" in + amd64) arch=x64 ;; + arm64) arch=arm64 ;; + armhf) arch=arm ;; + i386) arch=ia32 ;; + *) arch="" ;; + esac + if [[ -n $arch ]]; then + url=$(curl -s https://api.github.com/repos/sass/dart-sass/releases/latest | awk -F\" "/browser_download_url.*-linux-${arch}.tar.gz/{print \$(NF-1)}") + curl -LO --retry-connrefused --retry 10 "${url}" + tar xf dart-sass-*-linux-${arch}.tar.gz + install -d $CRAFT_PART_INSTALL/bin + cp -av dart-sass/* $CRAFT_PART_INSTALL/bin/ + fi node: - plugin: x-nodejs - node-packages: [postcss-cli] - filesets: - node: - - bin/node - postcss: - - bin/postcss - - lib/node_modules/postcss-cli/* - prime: - - $node - - $postcss + plugin: nil + stage-snaps: + - node/22/stable + organize: + "LICENSE": "LICENSE_NODE" # rename to prevent conflict with Go snap + "README.md": "README_NODE.md" # rename to prevent conflict with Go snap - pygments: - plugin: python - python-packages: [Pygments] - prime: - - bin/pygmentize - - lib/python*/site-packages/Pygments-*.dist-info/* - - lib/python*/site-packages/pygments/* - - usr/bin/python* - - -usr/bin/python*m - - usr/lib/python*/* - - -usr/lib/python*/distutils/* - - -usr/lib/python*/email/* - - -usr/lib/python*/lib2to3/* - - -usr/lib/python*/tkinter/* - - -usr/lib/python*/unittest/* + pandoc: + plugin: nil + stage-packages: + - pandoc + + rst2html: + plugin: nil + stage-packages: + - python3-docutils + override-build: | + set -ex + craftctl default + sed -i "s|'/usr/share/docutils/'|os.path.expandvars('\$SNAP/usr/share/docutils/')|" $CRAFT_PART_INSTALL/usr/lib/python3/dist-packages/docutils/__init__.py + organize: + usr/share/docutils/scripts/python3: usr/bin diff --git a/source/content_directory_test.go b/source/content_directory_test.go index d3723c6b1..96ee22bc7 100644 --- a/source/content_directory_test.go +++ b/source/content_directory_test.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Hugo Authors. All rights reserved. +// Copyright 2024 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. @@ -11,13 +11,18 @@ // See the License for the specific language governing permissions and // limitations under the License. -package source +package source_test import ( + "fmt" "path/filepath" "testing" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/config/testconfig" "github.com/gohugoio/hugo/helpers" + "github.com/gohugoio/hugo/source" + "github.com/spf13/afero" qt "github.com/frankban/quicktest" "github.com/gohugoio/hugo/hugofs" @@ -29,7 +34,7 @@ func TestIgnoreDotFilesAndDirectories(t *testing.T) { tests := []struct { path string ignore bool - ignoreFilesRegexpes interface{} + ignoreFilesRegexpes any }{ {".foobar/", true, nil}, {"foobar/.barfoo/", true, nil}, @@ -45,22 +50,30 @@ func TestIgnoreDotFilesAndDirectories(t *testing.T) { {"foobar/foo.md", true, []string{"\\.md$", "\\.boo$"}}, {"foobar/foo.html", false, []string{"\\.md$", "\\.boo$"}}, {"foobar/foo.md", true, []string{"foo.md$"}}, - {"foobar/foo.md", true, []string{"*", "\\.md$", "\\.boo$"}}, + {"foobar/foo.md", true, []string{".*", "\\.md$", "\\.boo$"}}, {"foobar/.#content.md", true, []string{"/\\.#"}}, {".#foobar.md", true, []string{"^\\.#"}}, } for i, test := range tests { - v := newTestConfig() - v.Set("ignoreFiles", test.ignoreFilesRegexpes) - fs := hugofs.NewMem(v) - ps, err := helpers.NewPathSpec(fs, v, nil) - c.Assert(err, qt.IsNil) + test := test + c.Run(fmt.Sprintf("[%d] %s", i, test.path), func(c *qt.C) { + c.Parallel() + v := config.New() + v.Set("ignoreFiles", test.ignoreFilesRegexpes) + v.Set("publishDir", "public") + afs := afero.NewMemMapFs() + conf := testconfig.GetTestConfig(afs, v) + fs := hugofs.NewFromOld(afs, v) + ps, err := helpers.NewPathSpec(fs, conf, nil) + c.Assert(err, qt.IsNil) - s := NewSourceSpec(ps, fs.Source) + s := source.NewSourceSpec(ps, nil, fs.Source) + + if ignored := s.IgnoreFile(filepath.FromSlash(test.path)); test.ignore != ignored { + t.Errorf("[%d] File not ignored", i) + } + }) - if ignored := s.IgnoreFile(filepath.FromSlash(test.path)); test.ignore != ignored { - t.Errorf("[%d] File not ignored", i) - } } } diff --git a/source/fileInfo.go b/source/fileInfo.go index 849afa45e..dfa5cda26 100644 --- a/source/fileInfo.go +++ b/source/fileInfo.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Hugo Authors. All rights reserved. +// Copyright 2021 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. @@ -15,280 +15,171 @@ package source import ( "path/filepath" - "strings" "sync" + "time" + "github.com/bep/gitmap" + "github.com/gohugoio/hugo/common/hashing" + "github.com/gohugoio/hugo/common/hugo" + "github.com/gohugoio/hugo/common/paths" "github.com/gohugoio/hugo/hugofs/files" - "github.com/pkg/errors" - "github.com/gohugoio/hugo/common/hugio" "github.com/gohugoio/hugo/hugofs" - - "github.com/gohugoio/hugo/helpers" ) -// fileInfo implements the File interface. -var ( - _ File = (*FileInfo)(nil) -) - -// File represents a source file. -// This is a temporary construct until we resolve page.Page conflicts. -// TODO(bep) remove this construct once we have resolved page deprecations -type File interface { - fileOverlap - FileWithoutOverlap -} - -// Temporary to solve duplicate/deprecated names in page.Page -type fileOverlap interface { - // Path gets the relative path including file name and extension. - // The directory is relative to the content root. - Path() string - - // Section is first directory below the content root. - // For page bundles in root, the Section will be empty. - Section() string - - // Lang is the language code for this page. It will be the - // same as the site's language code. - Lang() string - - IsZero() bool -} - -type FileWithoutOverlap interface { - - // Filename gets the full path and filename to the file. - Filename() string - - // Dir gets the name of the directory that contains this file. - // The directory is relative to the content root. - Dir() string - - // Extension gets the file extension, i.e "myblogpost.md" will return "md". - Extension() string - - // Ext is an alias for Extension. - Ext() string // Hmm... Deprecate Extension - - // LogicalName is filename and extension of the file. - LogicalName() string - - // BaseFileName is a filename without extension. - BaseFileName() string - - // TranslationBaseName is a filename with no extension, - // not even the optional language extension part. - TranslationBaseName() string - - // ContentBaseName is a either TranslationBaseName or name of containing folder - // if file is a leaf bundle. - ContentBaseName() string - - // UniqueID is the MD5 hash of the file's path and is for most practical applications, - // Hugo content files being one of them, considered to be unique. - UniqueID() string - - FileInfo() hugofs.FileMetaInfo -} - -// FileInfo describes a source file. -type FileInfo struct { - - // Absolute filename to the file on disk. - filename string - - sp *SourceSpec - - fi hugofs.FileMetaInfo - - // Derived from filename - ext string // Extension without any "." - lang string - - name string - - dir string - relDir string - relPath string - baseName string - translationBaseName string - contentBaseName string - section string - isLeafBundle bool +// File describes a source file. +type File struct { + fim hugofs.FileMetaInfo uniqueID string - lazyInit sync.Once } +// IsContentAdapter returns whether the file represents a content adapter. +// This means that there may be more than one Page associated with this file. +func (fi *File) IsContentAdapter() bool { + return fi.fim.Meta().PathInfo.IsContentData() +} + // Filename returns a file's absolute path and filename on disk. -func (fi *FileInfo) Filename() string { return fi.filename } +func (fi *File) Filename() string { return fi.fim.Meta().Filename } // Path gets the relative path including file name and extension. The directory // is relative to the content root. -func (fi *FileInfo) Path() string { return fi.relPath } +func (fi *File) Path() string { return filepath.Join(fi.p().Dir()[1:], fi.p().Name()) } // Dir gets the name of the directory that contains this file. The directory is // relative to the content root. -func (fi *FileInfo) Dir() string { return fi.relDir } +func (fi *File) Dir() string { + return fi.pathToDir(fi.p().Dir()) +} -// Extension is an alias to Ext(). -func (fi *FileInfo) Extension() string { return fi.Ext() } +// Ext returns a file's extension without the leading period (e.g. "md"). +func (fi *File) Ext() string { return fi.p().Ext() } -// Ext returns a file's extension without the leading period (ie. "md"). -func (fi *FileInfo) Ext() string { return fi.ext } +// Lang returns a file's language (e.g. "sv"). +// Deprecated: Use .Page.Language.Lang instead. +func (fi *File) Lang() string { + hugo.Deprecate(".Page.File.Lang", "Use .Page.Language.Lang instead.", "v0.123.0") + return fi.fim.Meta().Lang +} -// Lang returns a file's language (ie. "sv"). -func (fi *FileInfo) Lang() string { return fi.lang } +// LogicalName returns a file's name and extension (e.g. "page.sv.md"). +func (fi *File) LogicalName() string { + return fi.p().Name() +} -// LogicalName returns a file's name and extension (ie. "page.sv.md"). -func (fi *FileInfo) LogicalName() string { return fi.name } - -// BaseFileName returns a file's name without extension (ie. "page.sv"). -func (fi *FileInfo) BaseFileName() string { return fi.baseName } +// BaseFileName returns a file's name without extension (e.g. "page.sv"). +func (fi *File) BaseFileName() string { + return fi.p().NameNoExt() +} // TranslationBaseName returns a file's translation base name without the -// language segement (ie. "page"). -func (fi *FileInfo) TranslationBaseName() string { return fi.translationBaseName } +// language segment (e.g. "page"). +func (fi *File) TranslationBaseName() string { return fi.p().NameNoIdentifier() } // ContentBaseName is a either TranslationBaseName or name of containing folder -// if file is a leaf bundle. -func (fi *FileInfo) ContentBaseName() string { - fi.init() - return fi.contentBaseName +// if file is a bundle. +func (fi *File) ContentBaseName() string { + return fi.p().BaseNameNoIdentifier() } // Section returns a file's section. -func (fi *FileInfo) Section() string { - fi.init() - return fi.section +func (fi *File) Section() string { + return fi.p().Section() } // UniqueID returns a file's unique, MD5 hash identifier. -func (fi *FileInfo) UniqueID() string { +func (fi *File) UniqueID() string { fi.init() return fi.uniqueID } // FileInfo returns a file's underlying os.FileInfo. -func (fi *FileInfo) FileInfo() hugofs.FileMetaInfo { return fi.fi } +func (fi *File) FileInfo() hugofs.FileMetaInfo { return fi.fim } -func (fi *FileInfo) String() string { return fi.BaseFileName() } +func (fi *File) String() string { return fi.BaseFileName() } // Open implements ReadableFile. -func (fi *FileInfo) Open() (hugio.ReadSeekCloser, error) { - f, err := fi.fi.Meta().Open() +func (fi *File) Open() (hugio.ReadSeekCloser, error) { + f, err := fi.fim.Meta().Open() return f, err } -func (fi *FileInfo) IsZero() bool { +func (fi *File) IsZero() bool { return fi == nil } // We create a lot of these FileInfo objects, but there are parts of it used only // in some cases that is slightly expensive to construct. -func (fi *FileInfo) init() { +func (fi *File) init() { fi.lazyInit.Do(func() { - relDir := strings.Trim(fi.relDir, helpers.FilePathSeparator) - parts := strings.Split(relDir, helpers.FilePathSeparator) - var section string - if (!fi.isLeafBundle && len(parts) == 1) || len(parts) > 1 { - section = parts[0] - } - fi.section = section - - if fi.isLeafBundle && len(parts) > 0 { - fi.contentBaseName = parts[len(parts)-1] - } else { - fi.contentBaseName = fi.translationBaseName - } - - fi.uniqueID = helpers.MD5String(filepath.ToSlash(fi.relPath)) + fi.uniqueID = hashing.MD5FromStringHexEncoded(filepath.ToSlash(fi.Path())) }) } -// NewTestFile creates a partially filled File used in unit tests. -// TODO(bep) improve this package -func NewTestFile(filename string) *FileInfo { - base := filepath.Base(filepath.Dir(filename)) - return &FileInfo{ - filename: filename, - translationBaseName: base, +func (fi *File) pathToDir(s string) string { + if s == "" { + return s + } + return filepath.FromSlash(s[1:] + "/") +} + +func (fi *File) p() *paths.Path { + return fi.fim.Meta().PathInfo.Unnormalized() +} + +var contentPathParser = &paths.PathParser{ + IsContentExt: func(ext string) bool { + return true + }, +} + +// Used in tests. +func NewContentFileInfoFrom(path, filename string) *File { + meta := &hugofs.FileMeta{ + Filename: filename, + PathInfo: contentPathParser.Parse(files.ComponentFolderContent, filepath.ToSlash(path)), + } + + return NewFileInfo(hugofs.NewFileMetaInfo(nil, meta)) +} + +func NewFileInfo(fi hugofs.FileMetaInfo) *File { + return &File{ + fim: fi, } } -func (sp *SourceSpec) NewFileInfoFrom(path, filename string) (*FileInfo, error) { - meta := hugofs.FileMeta{ - "filename": filename, - "path": path, - } - - return sp.NewFileInfo(hugofs.NewFileMetaInfo(nil, meta)) +func NewGitInfo(info gitmap.GitInfo) GitInfo { + return GitInfo(info) } -func (sp *SourceSpec) NewFileInfo(fi hugofs.FileMetaInfo) (*FileInfo, error) { - - m := fi.Meta() - - filename := m.Filename() - relPath := m.Path() - isLeafBundle := m.Classifier() == files.ContentClassLeaf - - if relPath == "" { - return nil, errors.Errorf("no Path provided by %v (%T)", m, m.Fs()) - } - - if filename == "" { - return nil, errors.Errorf("no Filename provided by %v (%T)", m, m.Fs()) - } - - relDir := filepath.Dir(relPath) - if relDir == "." { - relDir = "" - } - if !strings.HasSuffix(relDir, helpers.FilePathSeparator) { - relDir = relDir + helpers.FilePathSeparator - } - - lang := m.Lang() - translationBaseName := m.GetString("translationBaseName") - - dir, name := filepath.Split(relPath) - if !strings.HasSuffix(dir, helpers.FilePathSeparator) { - dir = dir + helpers.FilePathSeparator - } - - ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), ".")) - baseName := helpers.Filename(name) - - if translationBaseName == "" { - // This is usyally provided by the filesystem. But this FileInfo is also - // created in a standalone context when doing "hugo new". This is - // an approximate implementation, which is "good enough" in that case. - fileLangExt := filepath.Ext(baseName) - translationBaseName = strings.TrimSuffix(baseName, fileLangExt) - } - - f := &FileInfo{ - sp: sp, - filename: filename, - fi: fi, - lang: lang, - ext: ext, - dir: dir, - relDir: relDir, // Dir() - relPath: relPath, // Path() - name: name, - baseName: baseName, // BaseFileName() - translationBaseName: translationBaseName, - isLeafBundle: isLeafBundle, - } - - return f, nil - +// GitInfo provides information about a version controlled source file. +type GitInfo struct { + // Commit hash. + Hash string `json:"hash"` + // Abbreviated commit hash. + AbbreviatedHash string `json:"abbreviatedHash"` + // The commit message's subject/title line. + Subject string `json:"subject"` + // The author name, respecting .mailmap. + AuthorName string `json:"authorName"` + // The author email address, respecting .mailmap. + AuthorEmail string `json:"authorEmail"` + // The author date. + AuthorDate time.Time `json:"authorDate"` + // The commit date. + CommitDate time.Time `json:"commitDate"` + // The commit message's body. + Body string `json:"body"` +} + +// IsZero returns true if the GitInfo is empty, +// meaning it will also be falsy in the Go templates. +func (g GitInfo) IsZero() bool { + return g.Hash == "" } diff --git a/source/fileInfo_test.go b/source/fileInfo_test.go deleted file mode 100644 index 1c9da7e41..000000000 --- a/source/fileInfo_test.go +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright 2017-present 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 source - -import ( - "path/filepath" - "strings" - "testing" - - qt "github.com/frankban/quicktest" -) - -func TestFileInfo(t *testing.T) { - c := qt.New(t) - - s := newTestSourceSpec() - - for _, this := range []struct { - base string - filename string - assert func(f *FileInfo) - }{ - {filepath.FromSlash("/a/"), filepath.FromSlash("/a/b/page.md"), func(f *FileInfo) { - c.Assert(f.Filename(), qt.Equals, filepath.FromSlash("/a/b/page.md")) - c.Assert(f.Dir(), qt.Equals, filepath.FromSlash("b/")) - c.Assert(f.Path(), qt.Equals, filepath.FromSlash("b/page.md")) - c.Assert(f.Section(), qt.Equals, "b") - c.Assert(f.TranslationBaseName(), qt.Equals, filepath.FromSlash("page")) - c.Assert(f.BaseFileName(), qt.Equals, filepath.FromSlash("page")) - - }}, - {filepath.FromSlash("/a/"), filepath.FromSlash("/a/b/c/d/page.md"), func(f *FileInfo) { - c.Assert(f.Section(), qt.Equals, "b") - - }}, - {filepath.FromSlash("/a/"), filepath.FromSlash("/a/b/page.en.MD"), func(f *FileInfo) { - c.Assert(f.Section(), qt.Equals, "b") - c.Assert(f.Path(), qt.Equals, filepath.FromSlash("b/page.en.MD")) - c.Assert(f.TranslationBaseName(), qt.Equals, filepath.FromSlash("page")) - c.Assert(f.BaseFileName(), qt.Equals, filepath.FromSlash("page.en")) - - }}, - } { - path := strings.TrimPrefix(this.filename, this.base) - f, err := s.NewFileInfoFrom(path, this.filename) - c.Assert(err, qt.IsNil) - this.assert(f) - } - -} diff --git a/source/filesystem.go b/source/filesystem.go deleted file mode 100644 index ce62c15a4..000000000 --- a/source/filesystem.go +++ /dev/null @@ -1,124 +0,0 @@ -// Copyright 2016 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 source - -import ( - "path/filepath" - "sync" - - "github.com/pkg/errors" - - "github.com/gohugoio/hugo/hugofs" -) - -// Filesystem represents a source filesystem. -type Filesystem struct { - files []File - filesInit sync.Once - filesInitErr error - - Base string - - fi hugofs.FileMetaInfo - - SourceSpec -} - -// NewFilesystem returns a new filesytem for a given source spec. -func (sp SourceSpec) NewFilesystem(base string) *Filesystem { - return &Filesystem{SourceSpec: sp, Base: base} -} - -func (sp SourceSpec) NewFilesystemFromFileMetaInfo(fi hugofs.FileMetaInfo) *Filesystem { - return &Filesystem{SourceSpec: sp, fi: fi} -} - -// Files returns a slice of readable files. -func (f *Filesystem) Files() ([]File, error) { - f.filesInit.Do(func() { - err := f.captureFiles() - if err != nil { - f.filesInitErr = errors.Wrap(err, "capture files") - } - }) - return f.files, f.filesInitErr -} - -// add populates a file in the Filesystem.files -func (f *Filesystem) add(name string, fi hugofs.FileMetaInfo) (err error) { - var file File - - file, err = f.SourceSpec.NewFileInfo(fi) - if err != nil { - return err - } - - f.files = append(f.files, file) - - return err -} - -func (f *Filesystem) captureFiles() error { - walker := func(path string, fi hugofs.FileMetaInfo, err error) error { - if err != nil { - return err - } - - if fi.IsDir() { - return nil - } - - meta := fi.Meta() - filename := meta.Filename() - - b, err := f.shouldRead(filename, fi) - if err != nil { - return err - } - - if b { - err = f.add(filename, fi) - } - - return err - } - - w := hugofs.NewWalkway(hugofs.WalkwayConfig{ - Fs: f.SourceFs, - Info: f.fi, - Root: f.Base, - WalkFn: walker, - }) - - return w.Walk() - -} - -func (f *Filesystem) shouldRead(filename string, fi hugofs.FileMetaInfo) (bool, error) { - - ignore := f.SourceSpec.IgnoreFile(fi.Meta().Filename()) - - if fi.IsDir() { - if ignore { - return false, filepath.SkipDir - } - return false, nil - } - - if ignore { - return false, nil - } - - return true, nil -} diff --git a/source/filesystem_test.go b/source/filesystem_test.go deleted file mode 100644 index ec7a305dc..000000000 --- a/source/filesystem_test.go +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright 2015 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 source - -import ( - "fmt" - "path/filepath" - "runtime" - "testing" - - "github.com/gohugoio/hugo/modules" - - "github.com/gohugoio/hugo/langs" - - "github.com/spf13/afero" - - qt "github.com/frankban/quicktest" - "github.com/gohugoio/hugo/helpers" - "github.com/gohugoio/hugo/hugofs" - - "github.com/spf13/viper" -) - -func TestEmptySourceFilesystem(t *testing.T) { - c := qt.New(t) - ss := newTestSourceSpec() - src := ss.NewFilesystem("") - files, err := src.Files() - c.Assert(err, qt.IsNil) - if len(files) != 0 { - t.Errorf("new filesystem should contain 0 files.") - } -} - -func TestUnicodeNorm(t *testing.T) { - if runtime.GOOS != "darwin" { - // Normalization code is only for Mac OS, since it is not necessary for other OSes. - return - } - - c := qt.New(t) - - paths := []struct { - NFC string - NFD string - }{ - {NFC: "å", NFD: "\x61\xcc\x8a"}, - {NFC: "é", NFD: "\x65\xcc\x81"}, - } - - ss := newTestSourceSpec() - fi := hugofs.NewFileMetaInfo(nil, hugofs.FileMeta{}) - - for i, path := range paths { - base := fmt.Sprintf("base%d", i) - c.Assert(afero.WriteFile(ss.Fs.Source, filepath.Join(base, path.NFD), []byte("some data"), 0777), qt.IsNil) - src := ss.NewFilesystem(base) - _ = src.add(path.NFD, fi) - files, err := src.Files() - c.Assert(err, qt.IsNil) - f := files[0] - if f.BaseFileName() != path.NFC { - t.Fatalf("file %q name in NFD form should be normalized (%s)", f.BaseFileName(), path.NFC) - } - } - -} - -func newTestConfig() *viper.Viper { - v := viper.New() - v.Set("contentDir", "content") - v.Set("dataDir", "data") - v.Set("i18nDir", "i18n") - v.Set("layoutDir", "layouts") - v.Set("archetypeDir", "archetypes") - v.Set("resourceDir", "resources") - v.Set("publishDir", "public") - v.Set("assetDir", "assets") - _, err := langs.LoadLanguageSettings(v, nil) - if err != nil { - panic(err) - } - mod, err := modules.CreateProjectModule(v) - if err != nil { - panic(err) - } - v.Set("allModules", modules.Modules{mod}) - - return v -} - -func newTestSourceSpec() *SourceSpec { - v := newTestConfig() - fs := hugofs.NewFrom(hugofs.NewBaseFileDecorator(afero.NewMemMapFs()), v) - ps, err := helpers.NewPathSpec(fs, v, nil) - if err != nil { - panic(err) - } - return NewSourceSpec(ps, fs.Source) -} diff --git a/source/sourceSpec.go b/source/sourceSpec.go index 504a3a22d..ea1b977f3 100644 --- a/source/sourceSpec.go +++ b/source/sourceSpec.go @@ -1,4 +1,4 @@ -// Copyright 2017-present The Hugo Authors. All rights reserved. +// Copyright 2024 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. @@ -11,19 +11,18 @@ // See the License for the specific language governing permissions and // limitations under the License. +// Package source contains the types and functions related to source files. package source import ( - "os" "path/filepath" - "regexp" "runtime" - "github.com/gohugoio/hugo/langs" + "github.com/gohugoio/hugo/hugofs/glob" + "github.com/spf13/afero" "github.com/gohugoio/hugo/helpers" - "github.com/spf13/cast" ) // SourceSpec abstracts language-specific file creation. @@ -33,48 +32,23 @@ type SourceSpec struct { SourceFs afero.Fs - // This is set if the ignoreFiles config is set. - ignoreFilesRe []*regexp.Regexp - - Languages map[string]interface{} - DefaultContentLanguage string - DisabledLanguages map[string]bool + shouldInclude func(filename string) bool } // NewSourceSpec initializes SourceSpec using languages the given filesystem and PathSpec. -func NewSourceSpec(ps *helpers.PathSpec, fs afero.Fs) *SourceSpec { - cfg := ps.Cfg - defaultLang := cfg.GetString("defaultContentLanguage") - languages := cfg.GetStringMap("languages") - - disabledLangsSet := make(map[string]bool) - - for _, disabledLang := range cfg.GetStringSlice("disableLanguages") { - disabledLangsSet[disabledLang] = true - } - - if len(languages) == 0 { - l := langs.NewDefaultLanguage(cfg) - languages[l.Lang] = l - defaultLang = l.Lang - } - - ignoreFiles := cast.ToStringSlice(cfg.Get("ignoreFiles")) - var regexps []*regexp.Regexp - if len(ignoreFiles) > 0 { - for _, ignorePattern := range ignoreFiles { - re, err := regexp.Compile(ignorePattern) - if err != nil { - helpers.DistinctErrorLog.Printf("Invalid regexp %q in ignoreFiles: %s", ignorePattern, err) - } else { - regexps = append(regexps, re) - } - +func NewSourceSpec(ps *helpers.PathSpec, inclusionFilter *glob.FilenameFilter, fs afero.Fs) *SourceSpec { + shouldInclude := func(filename string) bool { + if !inclusionFilter.Match(filename, false) { + return false } + if ps.Cfg.IgnoreFile(filename) { + return false + } + + return true } - return &SourceSpec{ignoreFilesRe: regexps, PathSpec: ps, SourceFs: fs, Languages: languages, DefaultContentLanguage: defaultLang, DisabledLanguages: disabledLangsSet} - + return &SourceSpec{shouldInclude: shouldInclude, PathSpec: ps, SourceFs: fs} } // IgnoreFile returns whether a given file should be ignored. @@ -98,58 +72,19 @@ func (s *SourceSpec) IgnoreFile(filename string) bool { } } - if len(s.ignoreFilesRe) == 0 { - return false - } - - for _, re := range s.ignoreFilesRe { - if re.MatchString(filename) { - return true - } + if !s.shouldInclude(filename) { + return true } if runtime.GOOS == "windows" { // Also check the forward slash variant if different. unixFilename := filepath.ToSlash(filename) if unixFilename != filename { - for _, re := range s.ignoreFilesRe { - if re.MatchString(unixFilename) { - return true - } + if !s.shouldInclude(unixFilename) { + return true } } } return false } - -// IsRegularSourceFile returns whether filename represents a regular file in the -// source filesystem. -func (s *SourceSpec) IsRegularSourceFile(filename string) (bool, error) { - fi, err := helpers.LstatIfPossible(s.SourceFs, filename) - if err != nil { - return false, err - } - - if fi.IsDir() { - return false, nil - } - - if fi.Mode()&os.ModeSymlink == os.ModeSymlink { - link, err := filepath.EvalSymlinks(filename) - if err != nil { - return false, err - } - - fi, err = helpers.LstatIfPossible(s.SourceFs, link) - if err != nil { - return false, err - } - - if fi.IsDir() { - return false, nil - } - } - - return true, nil -} diff --git a/temp/0.64.0-relnotes-ready.md b/temp/0.64.0-relnotes-ready.md deleted file mode 100644 index 2a077e875..000000000 --- a/temp/0.64.0-relnotes-ready.md +++ /dev/null @@ -1,46 +0,0 @@ -Hugo **0.64.0** is mostly a bugfix-release, but well worth the download. The main reason this release comes so soon after the previous is my (me being [@bep](https://github.com/bep)) ongoing work on getting solid support for third-party libraries in [Hugo Modules](https://gohugo.io/hugo-modules/). In particular, this release makes the Hugo server's live-reload work with [Turbolinks](https://github.com/bep/hugo-alpine-test/blob/27927832630be588eab0be2197cc8c0cb5725540/config.toml#L11) and similar. Also worth mentioning is that `hugo mod get -u` (without any path) now correctly updates every module imported in `config.toml` even with Go 1.13. - -This release represents **16 contributions by 2 contributors** to the main Hugo code base. -Many have also been busy writing and fixing the documentation in [hugoDocs](https://github.com/gohugoio/hugoDocs), -which has received **6 contributions by 4 contributors**. A special thanks to [@bep](https://github.com/bep), [@peterkappus](https://github.com/peterkappus), [@kc0bfv](https://github.com/kc0bfv), and [@inwardmovement](https://github.com/inwardmovement) for their work on the documentation site. - - -Hugo now has: - -* 41348+ [stars](https://github.com/gohugoio/hugo/stargazers) -* 439+ [contributors](https://github.com/gohugoio/hugo/graphs/contributors) -* 289+ [themes](http://themes.gohugo.io/) - -## Enhancements - -### Output - -* Do not render alias paginator pages for non-HTML outputs [2d159e9c](https://github.com/gohugoio/hugo/commit/2d159e9cc7a25832e4b0cad226b149f7c4624708) [@bep](https://github.com/bep) [#6797](https://github.com/gohugoio/hugo/issues/6797) - -### Other - -* Mention a "no CGO rule" [29973101](https://github.com/gohugoio/hugo/commit/299731012441378bb9c057ceb0a3c277108aaf01) [@bep](https://github.com/bep) [#6842](https://github.com/gohugoio/hugo/issues/6842) -* Update to Go 1.13.7 and Go 1.12.16 [0792cfa9](https://github.com/gohugoio/hugo/commit/0792cfa9fae94a06a31e393a46fed3b1dd73b66a) [@bep](https://github.com/bep) [#6830](https://github.com/gohugoio/hugo/issues/6830) -* Add defer to livereload script tag [b3f0674b](https://github.com/gohugoio/hugo/commit/b3f0674b80a32425aeb4412f318c720391bbf773) [@bep](https://github.com/bep) -* Don't use document.write to inject livereload [ef78a0d1](https://github.com/gohugoio/hugo/commit/ef78a0d18a13098bcea1ff2b2d45d7388b8d41a0) [@bep](https://github.com/bep) [#6507](https://github.com/gohugoio/hugo/issues/6507) -* Add a render hook whitespace test [58595864](https://github.com/gohugoio/hugo/commit/585958645372e6219239247dbac02e447d2b355b) [@bep](https://github.com/bep) [#6832](https://github.com/gohugoio/hugo/issues/6832) -* Inject livereload script right after head if possible [8f08cdd0](https://github.com/gohugoio/hugo/commit/8f08cdd0ac6a2decd5aa5c9c12c0b2c264f9a989) [@bep](https://github.com/bep) [#6821](https://github.com/gohugoio/hugo/issues/6821) -* Update goldmark to v1.1.22 [281abb18](https://github.com/gohugoio/hugo/commit/281abb18ee39fa2b5d4782b64f27cffcbf4e0240) [@bhavin192](https://github.com/bhavin192) -* Make the build flags shared between sites [0df7bd62](https://github.com/gohugoio/hugo/commit/0df7bd62df460a49544845d5332f33b2020b48a1) [@bep](https://github.com/bep) [#6789](https://github.com/gohugoio/hugo/issues/6789) - -## Fixes - -### Other - -* Fix module mount in sub folder [80dd6ddd](https://github.com/gohugoio/hugo/commit/80dd6ddde27ce36f5432fb780e94d4974b5277c7) [@bep](https://github.com/bep) [#6730](https://github.com/gohugoio/hugo/issues/6730) -* Fix config environment handling [2bbc865f](https://github.com/gohugoio/hugo/commit/2bbc865f7bb713b2d0d2dbb02b90ae2621ad5367) [@bep](https://github.com/bep) [#6503](https://github.com/gohugoio/hugo/issues/6503)[#6824](https://github.com/gohugoio/hugo/issues/6824) -* Fix base template handling with preceding comments [f45cb317](https://github.com/gohugoio/hugo/commit/f45cb3172862140883cfa08bd401c17e1ada5b39) [@bep](https://github.com/bep) [#6816](https://github.com/gohugoio/hugo/issues/6816) -* Fix "hugo mod get -u" with no arguments [49ef6472](https://github.com/gohugoio/hugo/commit/49ef6472039ede7d485242eba511207a8274495a) [@bep](https://github.com/bep) [#6826](https://github.com/gohugoio/hugo/issues/6826)[#6825](https://github.com/gohugoio/hugo/issues/6825) -* And now finally fix the 404 templates [74b6c4e5](https://github.com/gohugoio/hugo/commit/74b6c4e5ff5ee16f0e6b352a26c1e58b90a25dc6) [@bep](https://github.com/bep) [#6795](https://github.com/gohugoio/hugo/issues/6795) -* Fix 404 with base template regression [8df5d76e](https://github.com/gohugoio/hugo/commit/8df5d76e708238563185bac84809b34a4d395734) [@bep](https://github.com/bep) [#6795](https://github.com/gohugoio/hugo/issues/6795) -* Fix baseof with regular define regression [f441f675](https://github.com/gohugoio/hugo/commit/f441f675126ef1123d9f94429872dd683b40e011) [@bep](https://github.com/bep) [#6790](https://github.com/gohugoio/hugo/issues/6790) - - - - - diff --git a/testscripts/commands/commands_errors.txt b/testscripts/commands/commands_errors.txt new file mode 100644 index 000000000..54448361d --- /dev/null +++ b/testscripts/commands/commands_errors.txt @@ -0,0 +1,12 @@ +# Testing various error situations. + +! hugo mods +stderr 'unknown command "mods" for "hugo"' +stderr 'Did you mean this\?' + +! hugo mod clea +stderr 'Did you mean this\?' +stderr 'clean' + +! hugo mod foo +stderr 'unknown command "foo" for "hugo mod"' \ No newline at end of file diff --git a/testscripts/commands/completion.txt b/testscripts/commands/completion.txt new file mode 100644 index 000000000..04d79e3a1 --- /dev/null +++ b/testscripts/commands/completion.txt @@ -0,0 +1,4 @@ +# Test the completion commands. + +hugo completion -h +stdout 'Generate the autocompletion script for hugo for the specified shell.' \ No newline at end of file diff --git a/testscripts/commands/config.txt b/testscripts/commands/config.txt new file mode 100644 index 000000000..46386eb92 --- /dev/null +++ b/testscripts/commands/config.txt @@ -0,0 +1,21 @@ +# Test the config command. + +hugo config -h +stdout 'Display site configuration' + + +hugo config +stdout 'baseurl = .https://example.com/' +hugo config --format json +stdout '\"baseurl\": \"https://example.com/\",' + +hugo config mounts -h +stdout 'Print the configured file mounts' + +hugo config mounts +stdout '\"source\": \"content\",' + +# Test files +-- hugo.toml -- +baseURL="https://example.com/" +title="My New Hugo Site" diff --git a/testscripts/commands/config__cachedir.txt b/testscripts/commands/config__cachedir.txt new file mode 100644 index 000000000..aecb40b6c --- /dev/null +++ b/testscripts/commands/config__cachedir.txt @@ -0,0 +1,18 @@ + +[windows] skip + +env HUGO_CACHEDIR= +hugo config + +[darwin] stdout 'home/Library/Caches/hugo_cache' +[linux] stdout 'xdgcachehome/hugo_cache' + +# Repeat it to make sure it handles an existing hugo_cache dir. +hugo config + +[darwin] stdout 'home/Library/Caches/hugo_cache' +[linux] stdout 'xdgcachehome/hugo_cache' + +-- hugo.toml -- +baseURL="https://example.com/" +title="My New Hugo Site" diff --git a/testscripts/commands/convert.txt b/testscripts/commands/convert.txt new file mode 100644 index 000000000..811aeecc8 --- /dev/null +++ b/testscripts/commands/convert.txt @@ -0,0 +1,42 @@ +# Test the convert commands. + +hugo convert -h +stdout 'Convert front matter to another format' +hugo convert toJSON -h +stdout 'to use JSON for the front matter' +hugo convert toTOML -h +stdout 'to use TOML for the front matter' +hugo convert toYAML -h +stdout 'to use YAML for the front matter' + +hugo convert toJSON -o myjsoncontent +stdout 'processing 3 content files' +grep '^{' myjsoncontent/content/mytoml.md +grep '^{' myjsoncontent/content/myjson.md +grep '^{' myjsoncontent/content/myyaml.md +hugo convert toYAML -o myyamlcontent +stdout 'processing 3 content files' +hugo convert toTOML -o mytomlcontent +stdout 'processing 3 content files' + + + + + +-- hugo.toml -- +baseURL = "http://example.org/" +-- content/mytoml.md -- ++++ +title = "TOML" ++++ +TOML content +-- content/myjson.md -- +{ + "title": "JSON" +} +JSON content +-- content/myyaml.md -- +--- +title: YAML +--- +YAML content diff --git a/testscripts/commands/deprecate.txt b/testscripts/commands/deprecate.txt new file mode 100644 index 000000000..8791c3a78 --- /dev/null +++ b/testscripts/commands/deprecate.txt @@ -0,0 +1,25 @@ + +# Test deprecation logging. +hugo -e info --logLevel info +stderr 'INFO deprecated: item was deprecated in Hugo' + +hugo -e warn --logLevel warn +stderr 'WARN deprecated: item was deprecated in Hugo' + +! hugo -e error --logLevel warn +stderr 'ERROR deprecated: item was deprecated in Hugo' + +-- hugo.toml -- +baseURL = "https://example.com/" +disableKinds = ["taxonomy", "term"] +-- layouts/index.html -- +Deprecate: +{{ if eq hugo.Environment "info" }} + {{ debug.TestDeprecationInfo "item" "alternative" }} +{{ end }} +{{ if eq hugo.Environment "warn" }} + {{ debug.TestDeprecationWarn "item" "alternative" }} +{{ end }} +{{ if eq hugo.Environment "error" }} + {{ debug.TestDeprecationErr "item" "alternative" }} +{{ end }} \ No newline at end of file diff --git a/testscripts/commands/env.txt b/testscripts/commands/env.txt new file mode 100644 index 000000000..742e05ffc --- /dev/null +++ b/testscripts/commands/env.txt @@ -0,0 +1,5 @@ +# Test the hugo env command. + +hugo env +stdout 'GOARCH' +! stderr . \ No newline at end of file diff --git a/testscripts/commands/gen.txt b/testscripts/commands/gen.txt new file mode 100644 index 000000000..e83e9982f --- /dev/null +++ b/testscripts/commands/gen.txt @@ -0,0 +1,22 @@ +# Test the gen commands. + +hugo gen -h +stdout 'Generate documentation for your project using Hugo''s documentation engine, including syntax highlighting for various programming languages\.' +hugo gen doc --dir clidocs + +hugo gen man -h +stdout 'up-to-date man pages' +hugo gen man --dir manpages + +hugo gen chromastyles -h +stdout 'Generate CSS stylesheet for the Chroma code highlighter' +hugo gen chromastyles --style monokai +stdout 'Generated using: hugo gen chromastyles --style monokai' +! hugo gen chromastyles --style __invalid_style__ +stderr 'invalid style: __invalid_style__' + +# Issue 13475 +hugo gen chromastyles --style monokai +stdout '{ }' +hugo gen chromastyles --omitEmpty --style monokai +! stdout '\{ \}' diff --git a/testscripts/commands/hugo.txt b/testscripts/commands/hugo.txt new file mode 100644 index 000000000..bf0f5cf0d --- /dev/null +++ b/testscripts/commands/hugo.txt @@ -0,0 +1,20 @@ +# Test the hugo command. + +hugo +stdout 'Pages.*|1' +stdout 'Total in' +checkfile public/index.html +checkfile public/p1/index.html +grep 'IsServer: false;IsProduction: true' public/index.html + +-- hugo.toml -- +baseURL = "http://example.org/" +disableKinds = ["RSS", "sitemap", "robotsTXT", "404", "taxonomy", "term"] +-- layouts/index.html -- +Home|IsServer: {{ hugo.IsServer }};IsProduction: {{ hugo.IsProduction }}| +-- layouts/_default/single.html -- +Title: {{ .Title }} +-- content/p1.md -- +--- +title: "P1" +--- diff --git a/testscripts/commands/hugo__configdir.txt b/testscripts/commands/hugo__configdir.txt new file mode 100644 index 000000000..148523e9f --- /dev/null +++ b/testscripts/commands/hugo__configdir.txt @@ -0,0 +1,7 @@ + +hugo +! stderr . + +-- config/_default/hugo.toml -- +baseURL = "https://example.com/" +disableKinds = ["RSS", "page", "sitemap", "robotsTXT", "404", "taxonomy", "term", "home"] \ No newline at end of file diff --git a/testscripts/commands/hugo__errors.txt b/testscripts/commands/hugo__errors.txt new file mode 100644 index 000000000..975d11616 --- /dev/null +++ b/testscripts/commands/hugo__errors.txt @@ -0,0 +1,18 @@ +# Testing error output. + +# The hugo mod get command handles flags a little special, but the -h flag should print the help. +hugo mod get -h +stdout 'Resolves dependencies in your current Hugo project' + +# Invalid flag. Should print an error message to stderr and the help to stdout. +! hugo --asdf +stderr 'unknown flag: --asdf' +stdout 'hugo is the main command' + +# This should fail the build, print an error message to stderr, but no help output. +! hugo +! stdout 'hugo is the main command' +stderr 'failed to load config' + +-- hugo.toml -- +invalid: toml diff --git a/testscripts/commands/hugo__flags.txt b/testscripts/commands/hugo__flags.txt new file mode 100644 index 000000000..ad4591322 --- /dev/null +++ b/testscripts/commands/hugo__flags.txt @@ -0,0 +1,33 @@ +# Test the hugo command. + +hugo --baseURL http://example.com/ --minify --destination ${WORK}/newpublic --clock 2021-11-06T22:30:00.00+09:00 -e staging --config ${WORK}/myconfig --configDir ${WORK}/myconfigdir -s mysource +stdout 'Pages.*|1' +stdout 'Total in' +grep 'Home: http://example.com/, Time: 2021-11-06' newpublic/index.html +grep 'Environment: staging, foo: bar, bar: baz' newpublic/index.html +# Verify that it's minified. +grep 'Home' newpublic/index.html + +hugo --quiet +! stdout . + +-- hugo.toml -- +title = "Hugo Test" +-- myconfig.toml -- +baseURL = "http://example.org/" +disableKinds = ["RSS", "sitemap", "robotsTXT", "404", "taxonomy", "term"] +[params] +foo = "bar" +-- myconfigdir/_default/params.toml -- +bar = "baz" +-- mysource/layouts/index.html -- + +Home: {{ .Permalink }}, Time: {{ now }} + +Environment: {{ hugo.Environment }}, foo: {{ .Site.Params.foo }}, bar: {{ .Site.Params.bar }} +-- mysource/layouts/_default/single.html -- +Title: {{ .Title }} +-- mysource/content/p1.md -- +--- +title: "P1" +--- diff --git a/testscripts/commands/hugo__noconfig.txt b/testscripts/commands/hugo__noconfig.txt new file mode 100644 index 000000000..21fbdb9a0 --- /dev/null +++ b/testscripts/commands/hugo__noconfig.txt @@ -0,0 +1,8 @@ + +mkdir mysite +cd mysite +! hugo + +stderr 'Unable to locate config file or config directory' +ls . +stdout 'Empty dir' \ No newline at end of file diff --git a/testscripts/commands/hugo__path-warnings-postprocess.txt b/testscripts/commands/hugo__path-warnings-postprocess.txt new file mode 100644 index 000000000..0677da084 --- /dev/null +++ b/testscripts/commands/hugo__path-warnings-postprocess.txt @@ -0,0 +1,20 @@ +hugo --printPathWarnings + +! stdout 'Duplicate' + +-- hugo.toml -- +-- assets/css/styles.css -- +body { + background-color: #000; +} +-- content/p1.md -- +-- content/p2.md -- +-- content/p3.md -- +-- layouts/index.html -- +Home. +-- layouts/_default/single.html -- +{{ $css := resources.Get "css/styles.css" }} +{{ $css := $css | minify | fingerprint | resources.PostProcess }} +CSS: {{ $css.RelPermalink }} +{{ .Title }} + diff --git a/testscripts/commands/hugo__path-warnings.txt b/testscripts/commands/hugo__path-warnings.txt new file mode 100644 index 000000000..8eccb6567 --- /dev/null +++ b/testscripts/commands/hugo__path-warnings.txt @@ -0,0 +1,26 @@ +hugo --printPathWarnings + +stderr 'Duplicate' + +-- hugo.toml -- +-- assets/css/styles.css -- +body { + background-color: #000; +} +-- content/p1.md -- +--- +url: /p1/ +--- +-- content/p2.md -- +--- +url: /p1/ +--- +-- content/p3.md -- +--- +url: /p1/ +--- +-- layouts/index.html -- +Home. +-- layouts/_default/single.html -- +Single. + diff --git a/testscripts/commands/hugo__path-warnings_issue13164.txt b/testscripts/commands/hugo__path-warnings_issue13164.txt new file mode 100644 index 000000000..1342c287a --- /dev/null +++ b/testscripts/commands/hugo__path-warnings_issue13164.txt @@ -0,0 +1,15 @@ +hugo --printPathWarnings + +! stderr 'Duplicate target paths' + +-- hugo.toml -- +disableKinds = ['page','rss','section','sitemap','taxonomy','term'] +-- assets/foo.txt -- +foo +-- layouts/index.html -- +A: {{ (resources.Get "foo.txt").RelPermalink }} +B: {{ (resources.GetMatch "foo.txt").RelPermalink }} +C: {{ (index (resources.Match "foo.txt") 0).RelPermalink }} +D: {{ (index (resources.ByType "text") 0).RelPermalink }} +-- layouts/unused/single.html -- +{{ .Title }} diff --git a/testscripts/commands/hugo__processingstats.txt b/testscripts/commands/hugo__processingstats.txt new file mode 100644 index 000000000..3d30b8155 --- /dev/null +++ b/testscripts/commands/hugo__processingstats.txt @@ -0,0 +1,49 @@ +cp $SOURCE/resources/testdata/pix.gif content/en/bundle1/pix.gif +cp $SOURCE/resources/testdata/pix.gif content/en/bundle2/pix.gif +cp $SOURCE/resources/testdata/pix.gif content/fr/bundle1/pix.gif +mkdir static/images +cp $SOURCE/resources/testdata/pix.gif static/images/p1.gif +cp $SOURCE/resources/testdata/pix.gif static/images/p2.gif +cp $SOURCE/resources/testdata/pix.gif static/images/p3.gif +cp $SOURCE/resources/testdata/pix.gif static/images/p4.gif + + +hugo + +stdout 'Pages.*3.*2' +stdout 'Processed images.*2.*1' +stdout 'Static files.*4 |' + +ls public/images +stdout 'p1.gif' +stdout 'p2.gif' +stdout 'p3.gif' +stdout 'p4.gif' + +-- content/en/bundle1/index.md -- +-- content/en/bundle2/index.md -- +-- content/fr/bundle1/index.md -- +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "RSS", "sitemap", "robotsTXT", "404"] +defaultLanguage = "en" +defaultLanguageInSubdir = true +baseURL = "https://example.com/" +[languages] + [languages.en] + languageName = "English" + weight = 1 + title = "English Title" + contentDir = "content/en" + [languages.fr] + languageName = "French" + weight = 2 + title = "French Title" + contentDir = "content/fr" +-- layouts/index.html -- +Home. +-- layouts/_default/single.html -- +Single. +{{ range .Resources }} +{{ $img := .Resize "3x" }} +Resized: {{ $img.RelPermalink }} +{{ end }} diff --git a/testscripts/commands/hugo__processingstats2.txt b/testscripts/commands/hugo__processingstats2.txt new file mode 100644 index 000000000..2f8226faa --- /dev/null +++ b/testscripts/commands/hugo__processingstats2.txt @@ -0,0 +1,16 @@ +cp $SOURCE/resources/testdata/pix.gif content/posts/post-1/p1.gif +cp $SOURCE/resources/testdata/pix.gif content/posts/post-1/p2.gif + +hugo + +stdout 'Pages.*/| 10\s' +stdout 'Non-page files.*/| 2\s' + +-- content/posts/post-1/index.md -- +-- hugo.toml -- +baseURL = "https://example.com/" +-- layouts/_default/list.html -- +List. +-- layouts/_default/single.html -- +Single. + diff --git a/testscripts/commands/hugo__publishdir_in_config.txt b/testscripts/commands/hugo__publishdir_in_config.txt new file mode 100644 index 000000000..e57d4ad2f --- /dev/null +++ b/testscripts/commands/hugo__publishdir_in_config.txt @@ -0,0 +1,12 @@ +# Test the hugo command. + +hugo + +grep 'Home' newpublic/index.html + +-- hugo.toml -- +baseURL = "http://example.org/" +disableKinds = ["RSS", "sitemap", "robotsTXT", "404", "taxonomy", "term"] +publishDir = "newpublic" +-- layouts/index.html -- +Home. diff --git a/testscripts/commands/hugo__static_composite.txt b/testscripts/commands/hugo__static_composite.txt new file mode 100644 index 000000000..bf73f6abb --- /dev/null +++ b/testscripts/commands/hugo__static_composite.txt @@ -0,0 +1,27 @@ +hugo +ls public/files +checkfile public/files/f1.txt +checkfile public/files/f2.txt +checkfile public/f3.txt + +-- hugo.toml -- +disableKinds = ["taxonomy", "term"] +[module] +[[module.mounts]] +source = "myfiles/f1.txt" +target = "static/files/f1.txt" +[[module.mounts]] +source = "f3.txt" +target = "static/f3.txt" +[[module.mounts]] +source = "static" +target = "static" +-- static/files/f2.txt -- +f2 +-- myfiles/f1.txt -- +f1 +-- f3.txt -- +f3 +-- layouts/home.html -- +Home. + diff --git a/testscripts/commands/hugo__watch.txt b/testscripts/commands/hugo__watch.txt new file mode 100644 index 000000000..aa5201c56 --- /dev/null +++ b/testscripts/commands/hugo__watch.txt @@ -0,0 +1,29 @@ +# Test the hugo command. + +# See https://github.com/rogpeppe/go-internal/issues/228 +[windows] skip + +hugo -w & + +sleep 3 +grep 'P1start' public/p1/index.html + +replace content/p1.md 'P1start' 'P1end' +sleep 2 +grep 'P1end' public/p1/index.html +! grep 'livereload' public/p1/index.html + +stop + +-- hugo.toml -- +baseURL = "http://example.org/" +disableKinds = ["RSS", "sitemap", "robotsTXT", "404", "taxonomy", "term"] +-- layouts/index.html -- +Home. +-- layouts/_default/single.html -- +Title: {{ .Title }}| {{ .Content }} +-- content/p1.md -- +--- +title: "P1" +--- +P1start \ No newline at end of file diff --git a/testscripts/commands/hugo_build.txt b/testscripts/commands/hugo_build.txt new file mode 100644 index 000000000..0bcbcba7a --- /dev/null +++ b/testscripts/commands/hugo_build.txt @@ -0,0 +1,20 @@ +# Test the hugo build command (alias for hugo) + +hugo build +stdout 'Pages.*|1' +stdout 'Total in' +checkfile public/index.html +checkfile public/p1/index.html +grep 'IsServer: false;IsProduction: true' public/index.html + +-- hugo.toml -- +baseURL = "http://example.org/" +disableKinds = ["RSS", "sitemap", "robotsTXT", "404", "taxonomy", "term"] +-- layouts/index.html -- +Home|IsServer: {{ hugo.IsServer }};IsProduction: {{ hugo.IsProduction }}| +-- layouts/_default/single.html -- +Title: {{ .Title }} +-- content/p1.md -- +--- +title: "P1" +--- diff --git a/testscripts/commands/hugo_configdev_env.txt b/testscripts/commands/hugo_configdev_env.txt new file mode 100644 index 000000000..758f4fc96 --- /dev/null +++ b/testscripts/commands/hugo_configdev_env.txt @@ -0,0 +1,19 @@ +# Test the hugo command. +env HUGO_ENV=development + +hugo +grep 'myparam: dev§' public/index.html + +-- hugo.toml -- +baseURL = "http://example.org/" +disableKinds = ["RSS", "sitemap", "robotsTXT", "404", "taxonomy", "term"] +-- layouts/index.html -- +myparam: {{ site.Params.myparam }}§ +-- layouts/_default/single.html -- +Title: {{ .Title }} +-- config/development/params.toml -- +myparam = "dev" +-- content/p1.md -- +--- +title: "P1" +--- diff --git a/testscripts/commands/hugo_configdev_environment.txt b/testscripts/commands/hugo_configdev_environment.txt new file mode 100644 index 000000000..037148178 --- /dev/null +++ b/testscripts/commands/hugo_configdev_environment.txt @@ -0,0 +1,22 @@ +# Test the hugo command. +env HUGO_ENVIRONMENT=development + +hugo +grep 'myparam: dev§' public/index.html + +hugo -e production +grep 'myparam: §' public/index.html + +-- hugo.toml -- +baseURL = "http://example.org/" +disableKinds = ["RSS", "sitemap", "robotsTXT", "404", "taxonomy", "term"] +-- layouts/index.html -- +myparam: {{ site.Params.myparam }}§ +-- layouts/_default/single.html -- +Title: {{ .Title }} +-- config/development/params.toml -- +myparam = "dev" +-- content/p1.md -- +--- +title: "P1" +--- diff --git a/testscripts/commands/hugo_configprod.txt b/testscripts/commands/hugo_configprod.txt new file mode 100644 index 000000000..ac046b205 --- /dev/null +++ b/testscripts/commands/hugo_configprod.txt @@ -0,0 +1,18 @@ +# Test the hugo command. + +hugo +grep 'myparam: §' public/index.html + +-- hugo.toml -- +baseURL = "http://example.org/" +disableKinds = ["RSS", "sitemap", "robotsTXT", "404", "taxonomy", "term"] +-- layouts/index.html -- +myparam: {{ site.Params.myparam }}§ +-- layouts/_default/single.html -- +Title: {{ .Title }} +-- config/development/params.toml -- +myparam = "dev" +-- content/p1.md -- +--- +title: "P1" +--- diff --git a/testscripts/commands/hugo_printpathwarnings.txt b/testscripts/commands/hugo_printpathwarnings.txt new file mode 100644 index 000000000..51eb46d91 --- /dev/null +++ b/testscripts/commands/hugo_printpathwarnings.txt @@ -0,0 +1,17 @@ +hugo --printPathWarnings + +stderr 'Duplicate target paths: .index.html \(2\)' + +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "RSS", "sitemap", "robotsTXT", "404", "section"] +baseURL = "https://example.org/" +-- layouts/_default/single.html -- +Single. +-- layouts/index.html -- +Home. +-- content/p1.md -- +--- +title: "P1" +url: "/" +--- + diff --git a/testscripts/commands/hugo_printunusedtemplates.txt b/testscripts/commands/hugo_printunusedtemplates.txt new file mode 100644 index 000000000..a7a5d87c3 --- /dev/null +++ b/testscripts/commands/hugo_printunusedtemplates.txt @@ -0,0 +1,11 @@ +hugo --printUnusedTemplates + +stderr 'Template /list.html is unused' + +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "RSS", "sitemap", "robotsTXT", "404", "section", "page"] +baseURL = "https://example.org/" +-- layouts/index.html -- +Home. +-- layouts/_default/list.html -- +{{ errorf "unused template: %s" .Kind }} diff --git a/testscripts/commands/import_jekyll.txt b/testscripts/commands/import_jekyll.txt new file mode 100644 index 000000000..953349acf --- /dev/null +++ b/testscripts/commands/import_jekyll.txt @@ -0,0 +1,19 @@ +# Test the import + import jekyll command. + +hugo import -h +stdout 'Import a site from another system' + +hugo import jekyll -h +stdout 'hugo import from Jekyll\.' + +hugo import jekyll myjekyllsite myhugosite +checkfilecount 1 myhugosite/content/post +grep 'example\.org' myhugosite/hugo.yaml + +# A simple Jekyll site. +-- myjekyllsite/_posts/2012-01-18-hello-world.markdown -- +--- +layout: post +title: "Hello World" +--- +Hello world! diff --git a/testscripts/commands/list.txt b/testscripts/commands/list.txt new file mode 100644 index 000000000..8cbf93266 --- /dev/null +++ b/testscripts/commands/list.txt @@ -0,0 +1,65 @@ +# Test the hugo list commands. + +hugo list drafts +! stderr . +stdout 'path,slug,title,date,expiryDate,publishDate,draft,permalink' +stdout 'content/draft.md,draft,The Draft,2019-01-01T00:00:00Z,2032-01-01T00:00:00Z,2018-01-01T00:00:00Z,true,https://example.org/draft/' +stdout 'draftexpired.md' +stdout 'draftfuture.md' +! stdout '/expired.md' + +hugo list future +stdout 'path,slug,title,date,expiryDate,publishDate,draft,permalink' +stdout 'future.md' +stdout 'draftfuture.md' +! stdout 'expired.md' + +hugo list expired +stdout 'path,slug,title,date,expiryDate,publishDate,draft,permalink' +stdout 'expired.md' +stdout 'draftexpired.md' +! stdout 'future.md' + +hugo list all +stdout 'path,slug,title,date,expiryDate,publishDate,draft,permalink' +stdout 'future.md' +stdout 'draft.md' +stdout 'expired.md' +stdout 'draftexpired.md' +stdout 'draftfuture.md' + +hugo list expired --clock 2000-01-01T00:00:00Z +! stdout 'expired.md' + +-- hugo.toml -- +baseURL = "https://example.org/" +disableKinds = ["taxonomy", "term"] +-- content/draft.md -- +--- +title: "The Draft" +slug: "draft" +draft: true +date: 2019-01-01 +expiryDate: 2032-01-01 +publishDate: 2018-01-01 +--- +-- content/expired.md -- +--- +date: 2018-01-01 +expiryDate: 2019-01-01 +--- +-- content/future.md -- +--- +date: 2030-01-01 +--- +-- content/draftfuture.md -- +--- +date: 2030-01-01 +draft: true +--- +-- content/draftexpired.md -- +--- +date: 2018-01-01 +expiryDate: 2019-01-01 +draft: true +--- \ No newline at end of file diff --git a/testscripts/commands/mod.txt b/testscripts/commands/mod.txt new file mode 100644 index 000000000..2fa17dbbe --- /dev/null +++ b/testscripts/commands/mod.txt @@ -0,0 +1,49 @@ +# Test the hugo mod commands. + +dostounix golden/vendor.txt +dostounix golden/go.mod.testsubmod + +hugo mod graph +stdout 'empty-hugo' +hugo mod verify +! stderr . +hugo mod get -u +! stderr . +hugo mod get -u ./... +! stderr . +hugo mod vendor +! stderr . +cmp _vendor/modules.txt golden/vendor.txt +hugo mod clean +! stderr . +stdout 'hugo: removed 1 dirs in module cache for \"github.com/bep/empty-hugo-module\"' +hugo mod clean --all +# Currently this is 299 on MacOS and 301 on Linux. +stdout 'Deleted (2|3)\d{2} files from module cache\.' +cd submod +hugo mod init testsubmod +cmpenv go.mod $WORK/golden/go.mod.testsubmod +hugo mod get -h +stdout 'hugo mod get \[flags\] \[args\]' +hugo mod get --help +stdout 'hugo mod get \[flags\] \[args\]' +-- hugo.toml -- +title = "Hugo Modules Test" +[module] +[[module.imports]] +path="github.com/bep/empty-hugo-module" +[[module.imports.mounts]] +source="README.md" +target="content/_index.md" +-- go.mod -- +go 1.19 + +module github.com/gohugoio/testmod +-- submod/hugo.toml -- +title = "Hugo Sub Module" +-- golden/vendor.txt -- +# github.com/bep/empty-hugo-module v1.0.0 +-- golden/go.mod.testsubmod -- +module testsubmod + +go ${GOVERSION} diff --git a/testscripts/commands/mod__disable.txt b/testscripts/commands/mod__disable.txt new file mode 100644 index 000000000..f2d65dd0c --- /dev/null +++ b/testscripts/commands/mod__disable.txt @@ -0,0 +1,15 @@ +hugo mod graph +stdout 'withhugotoml.*commonmod' + +-- hugo.toml -- +title = "Hugo Modules Test" +[module] +[[module.imports]] +path="github.com/gohugoio/hugo-mod-integrationtests/withconfigtoml" +disable = true +[[module.imports]] +path="github.com/gohugoio/hugo-mod-integrationtests/withhugotoml" +-- go.mod -- +module foo +go 1.19 + diff --git a/testscripts/commands/mod__themesdir.txt b/testscripts/commands/mod__themesdir.txt new file mode 100644 index 000000000..ec76d0c76 --- /dev/null +++ b/testscripts/commands/mod__themesdir.txt @@ -0,0 +1,7 @@ +hugo --theme mytheme mod graph +stdout 'project mytheme' + +-- hugo.toml -- +title = "Hugo Module" +-- themes/mytheme/hugo.toml -- +title = "My Theme" diff --git a/testscripts/commands/mod_get.txt b/testscripts/commands/mod_get.txt new file mode 100644 index 000000000..d11d3b817 --- /dev/null +++ b/testscripts/commands/mod_get.txt @@ -0,0 +1,15 @@ +hugo mod get +stderr 'withhugotoml.*v1.1.0' + +-- hugo.toml -- +title = "Hugo Modules Test" +[module] +[[module.imports]] +path="github.com/gohugoio/hugo-mod-integrationtests/withconfigtoml" +disable = true +[[module.imports]] +path="github.com/gohugoio/hugo-mod-integrationtests/withhugotoml" +-- go.mod -- +module foo +go 1.20 + diff --git a/testscripts/commands/mod_get_u.txt b/testscripts/commands/mod_get_u.txt new file mode 100644 index 000000000..f3efc0c1b --- /dev/null +++ b/testscripts/commands/mod_get_u.txt @@ -0,0 +1,22 @@ +hugo mod get -u +hugo mod graph +stdout 'commonmod@v1.0.1.*commonmod2@v1.0.2' + +-- hugo.toml -- +title = "Hugo Modules Update Test" +theme = ["my-theme"] +[module] +[[module.imports]] +path="github.com/gohugoio/hugo-mod-integrationtests/withconfigtoml" +disable = true +[[module.imports]] +path="github.com/gohugoio/hugo-mod-integrationtests/withhugotoml" +-- go.mod -- +module foo +go 1.20 +require ( + github.com/gohugoio/hugo-mod-integrationtests/withhugotoml v1.1.0 // indirect + github.com/gohugoio/hugo-mod-integrationtests/commonmod v0.0.0-20230823103305-919cefe8a425 // indirect +) +-- themes/my-theme/dummy.txt -- +MY THEME diff --git a/testscripts/commands/mod_init.txt b/testscripts/commands/mod_init.txt new file mode 100644 index 000000000..c09e71a23 --- /dev/null +++ b/testscripts/commands/mod_init.txt @@ -0,0 +1,15 @@ +# Test the hugo init command. +dostounix golden/go.mod.testsubmod + +hugo mod init testsubmod +cmpenv go.mod $WORK/golden/go.mod.testsubmod + +-- hugo.toml -- +title = "Hugo Modules Test" +[module] +[[module.imports]] +path="github.com/bep/empty-hugo-module" +-- golden/go.mod.testsubmod -- +module testsubmod + +go ${GOVERSION} \ No newline at end of file diff --git a/testscripts/commands/mod_npm.txt b/testscripts/commands/mod_npm.txt new file mode 100644 index 000000000..3d8903e6a --- /dev/null +++ b/testscripts/commands/mod_npm.txt @@ -0,0 +1,45 @@ +# Test mod npm. + +dostounix golden/package.json + + +hugo mod npm pack +cmp package.json golden/package.json + +-- hugo.toml -- +baseURL = "https://example.org/" +[module] +[[module.imports]] +path="github.com/gohugoio/hugoTestModule2" + + +-- golden/package.json -- +{ + "comments": { + "dependencies": { + "react-dom": "github.com/gohugoio/hugoTestModule2" + }, + "devDependencies": { + "@babel/cli": "github.com/gohugoio/hugoTestModule2", + "@babel/core": "github.com/gohugoio/hugoTestModule2", + "@babel/preset-env": "github.com/gohugoio/hugoTestModule2", + "postcss-cli": "github.com/gohugoio/hugoTestModule2", + "tailwindcss": "github.com/gohugoio/hugoTestModule2" + } + }, + "dependencies": { + "react-dom": "^16.13.1" + }, + "devDependencies": { + "@babel/cli": "7.8.4", + "@babel/core": "7.9.0", + "@babel/preset-env": "7.9.5", + "postcss-cli": "7.1.0", + "tailwindcss": "1.2.0" + }, + "name": "script-mod_npm", + "version": "0.1.0" +} +-- go.mod -- +module github.com/gohugoio/hugoTestModule +go 1.20 diff --git a/testscripts/commands/mod_npm_withexisting.txt b/testscripts/commands/mod_npm_withexisting.txt new file mode 100644 index 000000000..9f72eefdc --- /dev/null +++ b/testscripts/commands/mod_npm_withexisting.txt @@ -0,0 +1,61 @@ +# Test mod npm. + +# See https://github.com/gohugoio/hugo/issues/13465 +[windows] skip + +dostounix golden/package.json + +hugo mod npm pack +cmp package.json golden/package.json + +-- hugo.toml -- +baseURL = "https://example.org/" +[module] +[[module.imports]] +path="github.com/gohugoio/hugoTestModule2" +-- package.json -- +{ + "comments": { + "foo": { + "a": "b" + } + }, + "devDependencies": { + "tailwindcss": "2.2.0" + }, + "name": "mypackage", + "version": "1.1.0" +} +-- golden/package.json -- +{ + "comments": { + "dependencies": { + "react-dom": "github.com/gohugoio/hugoTestModule2" + }, + "devDependencies": { + "@babel/cli": "github.com/gohugoio/hugoTestModule2", + "@babel/core": "github.com/gohugoio/hugoTestModule2", + "@babel/preset-env": "github.com/gohugoio/hugoTestModule2", + "postcss-cli": "github.com/gohugoio/hugoTestModule2", + "tailwindcss": "project" + }, + "foo": { + "a": "b" + } + }, + "dependencies": { + "react-dom": "^16.13.1" + }, + "devDependencies": { + "@babel/cli": "7.8.4", + "@babel/core": "7.9.0", + "@babel/preset-env": "7.9.5", + "postcss-cli": "7.1.0", + "tailwindcss": "2.2.0" + }, + "name": "mypackage", + "version": "1.1.0" +} +-- go.mod -- +module github.com/gohugoio/hugoTestModule +go 1.20 diff --git a/testscripts/commands/mod_tidy.txt b/testscripts/commands/mod_tidy.txt new file mode 100644 index 000000000..6e8d37f64 --- /dev/null +++ b/testscripts/commands/mod_tidy.txt @@ -0,0 +1,21 @@ +# Test hugo mod tidy. + +dostounix golden/go.mod.cleaned + +hugo mod tidy + +cmp go.mod golden/go.mod.cleaned + +-- hugo.toml -- +title = "Hugo Modules Test" +-- go.mod -- +go 1.19 + +require github.com/bep/empty-hugo-module v1.0.0 + +module github.com/gohugoio/testmod +-- golden/go.mod.cleaned -- +go 1.19 + + +module github.com/gohugoio/testmod diff --git a/testscripts/commands/mod_vendor.txt b/testscripts/commands/mod_vendor.txt new file mode 100644 index 000000000..20862c14e --- /dev/null +++ b/testscripts/commands/mod_vendor.txt @@ -0,0 +1,31 @@ +dostounix golden/vendor.txt + +hugo mod vendor +cmp _vendor/modules.txt golden/vendor.txt +ls _vendor/github.com/gohugoio/hugo-mod-integrationtests/withconfigtoml +stdout 'config.toml' +ls _vendor/github.com/gohugoio/hugo-mod-integrationtests/withhugotoml +stdout 'hugo.toml' + + + +-- hugo.toml -- +title = "Hugo Modules Test" +[module] +[[module.imports]] +path="github.com/gohugoio/hugo-mod-integrationtests/withconfigtoml" +[[module.imports]] +path="github.com/gohugoio/hugo-mod-integrationtests/withhugotoml" +-- go.mod -- +go 1.19 + +module github.com/gohugoio/testmod + +require ( + github.com/gohugoio/hugo-mod-integrationtests/withconfigtoml v1.0.0 + github.com/gohugoio/hugo-mod-integrationtests/withhugotoml v1.0.0 +) + +-- golden/vendor.txt -- +# github.com/gohugoio/hugo-mod-integrationtests/withconfigtoml v1.0.0 +# github.com/gohugoio/hugo-mod-integrationtests/withhugotoml v1.0.0 diff --git a/testscripts/commands/new.txt b/testscripts/commands/new.txt new file mode 100644 index 000000000..f8d7c1ec1 --- /dev/null +++ b/testscripts/commands/new.txt @@ -0,0 +1,114 @@ +# Test the new command. + +hugo new site -h +stdout 'Create a new site at the specified path.' +hugo new site my-yaml-site --format yml +checkfile my-yaml-site/hugo.yml +hugo new site mysite -f +stdout 'Congratulations! Your new Hugo site was created in' +cd mysite +checkfile archetypes/default.md +checkfile hugo.toml +exists assets +exists content +exists data +exists i18n +exists layouts +exists static +exists themes +! exists resources + +hugo new theme -h +stdout 'Create a new theme with the specified name in the ./themes directory.' +hugo new theme mytheme --format yml +stdout 'Creating new theme' +! exists resources +cd themes +cd mytheme +checkfile archetypes/default.md +checkfile assets/css/main.css +checkfile assets/js/main.js +checkfile content/_index.md +checkfile content/posts/_index.md +checkfile content/posts/post-1.md +checkfile content/posts/post-2.md +checkfile content/posts/post-3/bryce-canyon.jpg +checkfile content/posts/post-3/index.md +checkfile layouts/baseof.html +checkfile layouts/home.html +checkfile layouts/section.html +checkfile layouts/page.html +checkfile layouts/taxonomy.html +checkfile layouts/term.html +checkfile layouts/_partials/footer.html +checkfile layouts/_partials/head.html +checkfile layouts/_partials/head/css.html +checkfile layouts/_partials/head/js.html +checkfile layouts/_partials/header.html +checkfile layouts/_partials/menu.html +checkfile layouts/_partials/terms.html +checkfile static/favicon.ico +checkfile hugo.yml +exists data +exists i18n + +cd $WORK/mysite + +hugo new -h +stdout 'Create a new content file.' +hugo new posts/my-first-post.md +checkfile content/posts/my-first-post.md + +cd .. +cd myexistingsite +hugo new post/foo.md -t mytheme +grep 'Dummy content' content/post/foo.md + +cd $WORK + +# In the three archetype format tests below, skip Windows testing to avoid +# newline differences when comparing to golden. + +hugo new site json-site --format json +[!windows] cmp json-site/archetypes/default.md archetype-golden-json.md + +hugo new site toml-site --format toml +[!windows] cmp toml-site/archetypes/default.md archetype-golden-toml.md + +hugo new site yaml-site --format yaml +[!windows] cmp yaml-site/archetypes/default.md archetype-golden-yaml.md + +-- myexistingsite/hugo.toml -- +theme = "mytheme" +-- myexistingsite/content/p1.md -- +--- +title: "P1" +--- +-- myexistingsite/themes/mytheme/hugo.toml -- +-- myexistingsite/themes/mytheme/archetypes/post.md -- +--- +title: "{{ replace .Name "-" " " | title }}" +date: {{ .Date }} +draft: true +--- + +Dummy content. + +-- archetype-golden-json.md -- +{ + "date": "{{ .Date }}", + "draft": true, + "title": "{{ replace .File.ContentBaseName \"-\" \" \" | title }}" +} +-- archetype-golden-toml.md -- ++++ +date = '{{ .Date }}' +draft = true +title = '{{ replace .File.ContentBaseName "-" " " | title }}' ++++ +-- archetype-golden-yaml.md -- +--- +date: '{{ .Date }}' +draft: true +title: '{{ replace .File.ContentBaseName "-" " " | title }}' +--- diff --git a/testscripts/commands/new_content.txt b/testscripts/commands/new_content.txt new file mode 100644 index 000000000..217058353 --- /dev/null +++ b/testscripts/commands/new_content.txt @@ -0,0 +1,7 @@ +hugo new site myblog +cd myblog +hugo new content --kind post post/first-post.md +! exists resources +grep 'draft = true' content/post/first-post.md + + diff --git a/testscripts/commands/new_content_archetypedir.txt b/testscripts/commands/new_content_archetypedir.txt new file mode 100644 index 000000000..ccd85c999 --- /dev/null +++ b/testscripts/commands/new_content_archetypedir.txt @@ -0,0 +1,40 @@ +mkdir content +hugo new content --kind mybundle post/first-post +grep 'First Post' content/post/first-post/index.md +grep 'Site Lang: en' content/post/first-post/index.md +grep 'Site Lang: no' content/post/first-post/index.no.md +grep 'A text file.' content/post/first-post/file.txt + +-- hugo.toml -- +baseURL = "http://example.org/" +[languages] +[languages.en] +languageName = "English" +weight = 1 +[languages.no] +languageName = "Norsk" +weight = 2 + +-- archetypes/mybundle/index.md -- +--- +title: "{{ replace .Name "-" " " | title }}" +date: {{ .Date }} +draft: true +--- + +Site Lang: {{ site.Language.Lang }}. +-- archetypes/mybundle/index.no.md -- +--- +title: "{{ replace .Name "-" " " | title }}" +date: {{ .Date }} +draft: true +--- + +Site Lang: {{ site.Language.Lang }}. + +-- archetypes/mybundle/file.txt -- +A text file. + + + + diff --git a/testscripts/commands/noop.txt b/testscripts/commands/noop.txt new file mode 100644 index 000000000..e69de29bb diff --git a/testscripts/commands/server.txt b/testscripts/commands/server.txt new file mode 100644 index 000000000..7f6afd8fd --- /dev/null +++ b/testscripts/commands/server.txt @@ -0,0 +1,31 @@ +# Test the hugo server command. + +# We run these tests in parallel so let Hugo decide which port to use. +hugo server --renderToMemory --gc & + +waitServer + +httpget $HUGOTEST_BASEURL_0 'Title: Hugo Server Test' $HUGOTEST_BASEURL_0 'ServerPort: \d{4,5}' 'myenv: thedevelopment' 'livereload\.js' 'Env: development' 'IsServer: true' +httpget ${HUGOTEST_BASEURL_0}doesnotexist 'custom 404' +httpget ${HUGOTEST_BASEURL_0}livereload.js 'function' + +# By default, the server renders to memory. +! exists public/index.html + +stopServer +! stderr . + +-- hugo.toml -- +title = "Hugo Server Test" +baseURL = "https://example.org/" +disableKinds = ["taxonomy", "term", "sitemap"] +-- config/production/params.toml -- +myenv = "theproduction" +-- config/development/params.toml -- +myenv = "thedevelopment" +-- layouts/index.html -- + +Title: {{ .Title }}|BaseURL: {{ site.BaseURL }}|ServerPort: {{ site.ServerPort }}|myenv: {{ .Site.Params.myenv }}|Env: {{ hugo.Environment }}|IsServer: {{ hugo.IsServer }}| + +-- layouts/404.html -- +custom 404 diff --git a/testscripts/commands/server__edit_config.txt b/testscripts/commands/server__edit_config.txt new file mode 100644 index 000000000..3997ca895 --- /dev/null +++ b/testscripts/commands/server__edit_config.txt @@ -0,0 +1,43 @@ +# Test the hugo server command when editing the config file. + +# We run these tests in parallel so let Hugo decide which port to use. +hugo server --renderToMemory & + +waitServer + +httpget $HUGOTEST_BASEURL_0 'Title: Hugo Server Test' $HUGOTEST_BASEURL_0 + +mv edits/title.toml hugo.toml + +httpget $HUGOTEST_BASEURL_0 'Title: Hugo New Server Test' $HUGOTEST_BASEURL_0 + +mv edits/addlanguage.toml hugo.toml + +httpget $HUGOTEST_BASEURL_0 'Title: Hugo New Server Test' $HUGOTEST_BASEURL_0 +httpget ${HUGOTEST_BASEURL_0}nn/ 'Hugo Nynorsk Server Test' ${HUGOTEST_BASEURL_0}nn/ + +stopServer +! stderr . + +-- hugo.toml -- +title = "Hugo Server Test" +baseURL = "https://example.org/" +-- edits/title.toml -- +title = "Hugo New Server Test" +baseURL = "https://example.org/" +-- edits/addlanguage.toml -- +title = "Hugo New Server Test" +baseURL = "https://example.org/" +[languages] +[languages.en] +languageName = "English" +weight = 1 +[languages.nn] +languageName = "Nynorsk" +title = "Hugo Nynorsk Server Test" +weight = 2 + +-- layouts/index.html -- +Title: {{ .Title }}|BaseURL: {{ .Permalink }}| + + diff --git a/testscripts/commands/server__edit_content.txt b/testscripts/commands/server__edit_content.txt new file mode 100644 index 000000000..0aca2e892 --- /dev/null +++ b/testscripts/commands/server__edit_content.txt @@ -0,0 +1,55 @@ +# Test the hugo server command when editing content. + +# We run these tests in parallel so let Hugo decide which port to use. +# Render to disk so we can check the /public dir. +hugo server & + +waitServer + +httpget ${HUGOTEST_BASEURL_0}p1/ 'Title: P1' $HUGOTEST_BASEURL_0 + +ls public/p2 +cp stdout lsp2_1.txt +ls public/staticfiles +stdout 'static\.txt' +cp stdout lsstaticfiles_1.txt + +replace $WORK/content/p1/index.md 'P1' 'P1 New' + +httpget ${HUGOTEST_BASEURL_0}p1/ 'Title: P1 New' $HUGOTEST_BASEURL_0 + +ls public/p2 +cp stdout lsp2_2.txt +cmp lsp2_1.txt lsp2_2.txt +ls public/staticfiles +cp stdout lsstaticfiles_2.txt +cmp lsstaticfiles_1.txt lsstaticfiles_2.txt + +stopServer +! stderr . + +-- hugo.toml -- +title = "Hugo Server Test" +baseURL = "https://example.org/" +disableKinds = ["taxonomy", "term", "sitemap"] +-- layouts/index.html -- +Title: {{ .Title }}|BaseURL: {{ site.BaseURL }}| +-- layouts/_default/single.html -- +Title: {{ .Title }}|BaseURL: {{ site.BaseURL }}| +-- content/_index.md -- +--- +title: Hugo Home +--- +-- content/p1/index.md -- +--- +title: P1 +--- +-- content/p2/index.md -- +--- +title: P2 +--- +-- static/staticfiles/static.txt -- +static + + + diff --git a/testscripts/commands/server__error_recovery_edit_config.txt b/testscripts/commands/server__error_recovery_edit_config.txt new file mode 100644 index 000000000..664d99272 --- /dev/null +++ b/testscripts/commands/server__error_recovery_edit_config.txt @@ -0,0 +1,42 @@ +# Test the hugo server command when adding an error to a config file +# and then fixing it. + +hugo server & + +waitServer + +httpget ${HUGOTEST_BASEURL_0}p1/ 'Title: P1' + +replace $WORK/hugo.toml 'title =' 'titlefoo' +httpget ${HUGOTEST_BASEURL_0}p1/ 'failed' + +replace $WORK/hugo.toml 'titlefoo' 'title =' +httpget ${HUGOTEST_BASEURL_0}p1/ 'Title: P1' + +stopServer + +-- hugo.toml -- +title = "Hugo Server Test" +baseURL = "https://example.org/" +disableKinds = ["taxonomy", "term", "sitemap"] +-- layouts/index.html -- +Title: {{ .Title }}|BaseURL: {{ site.BaseURL }}| +-- layouts/_default/single.html -- +Title: {{ .Title }}|BaseURL: {{ site.BaseURL }}| +-- content/_index.md -- +--- +title: Hugo Home +--- +-- content/p1/index.md -- +--- +title: P1 +--- +-- content/p2/index.md -- +--- +title: P2 +--- +-- static/staticfiles/static.txt -- +static + + + diff --git a/testscripts/commands/server__error_recovery_edit_content.txt b/testscripts/commands/server__error_recovery_edit_content.txt new file mode 100644 index 000000000..f5ea7e94b --- /dev/null +++ b/testscripts/commands/server__error_recovery_edit_content.txt @@ -0,0 +1,42 @@ +# Test the hugo server command when adding a front matter error to a content file +# and then fixing it. + +hugo server & + +waitServer + +httpget ${HUGOTEST_BASEURL_0}p1/ 'Title: P1' + +replace $WORK/content/p1/index.md 'title:' 'titlecolon' +httpget ${HUGOTEST_BASEURL_0}p1/ 'failed' + +replace $WORK/content/p1/index.md 'titlecolon' 'title:' +httpget ${HUGOTEST_BASEURL_0}p1/ 'Title: P1' + +stopServer + +-- hugo.toml -- +title = "Hugo Server Test" +baseURL = "https://example.org/" +disableKinds = ["taxonomy", "term", "sitemap"] +-- layouts/index.html -- +Title: {{ .Title }}|BaseURL: {{ site.BaseURL }}| +-- layouts/_default/single.html -- +Title: {{ .Title }}|BaseURL: {{ site.BaseURL }}| +-- content/_index.md -- +--- +title: Hugo Home +--- +-- content/p1/index.md -- +--- +title: P1 +--- +-- content/p2/index.md -- +--- +title: P2 +--- +-- static/staticfiles/static.txt -- +static + + + diff --git a/testscripts/commands/server__multihost.txt b/testscripts/commands/server__multihost.txt new file mode 100644 index 000000000..888886370 --- /dev/null +++ b/testscripts/commands/server__multihost.txt @@ -0,0 +1,32 @@ +# Test the hugo server command. + +# We run these tests in parallel so let Hugo decide which port to use. +hugo server --renderToMemory & + +waitServer + +httpget $HUGOTEST_BASEURL_0 'Title: Hugo Server Test' $HUGOTEST_BASEURL_0 +httpget $HUGOTEST_BASEURL_1 'Title: Hugo Serveur Test' $HUGOTEST_BASEURL_1 + +stopServer +! stderr . + +-- hugo.toml -- +title = "Hugo Server Test" +baseURL = "https://example.org/" +disableKinds = ["taxonomy", "term", "sitemap"] +[languages] +[languages.en] +baseURL = "https://en.example.org/" +languageName = "English" +title = "Hugo Server Test" +weight = 1 +[languages.fr] +baseURL = "https://fr.example.org/" +title = "Hugo Serveur Test" +languageName = "Français" +weight = 2 +-- layouts/index.html -- +Title: {{ .Title }}|BaseURL: {{ site.BaseURL }}| + + diff --git a/testscripts/commands/server__watch_hugo_stats.txt b/testscripts/commands/server__watch_hugo_stats.txt new file mode 100644 index 000000000..da4ffc19f --- /dev/null +++ b/testscripts/commands/server__watch_hugo_stats.txt @@ -0,0 +1,18 @@ +hugo server --renderToMemory & + +waitServer +stopServer +! stderr . + +exists hugo_stats.json + +-- hugo.toml -- +title = "Hugo Server Test" +baseURL = "https://example.org/" +disableKinds = ["taxonomy", "term", "sitemap"] +[module] +[[module.mounts]] +source = "hugo_stats.json" +target = "assets/watching/hugo_stats.json" +-- layouts/index.html -- +Home diff --git a/testscripts/commands/server__watch_moduleconfig.txt b/testscripts/commands/server__watch_moduleconfig.txt new file mode 100644 index 000000000..bd84a1449 --- /dev/null +++ b/testscripts/commands/server__watch_moduleconfig.txt @@ -0,0 +1,19 @@ +hugo server --renderToMemory --disableLiveReload & + +waitServer +stopServer +wait +! stderr . +stdout 'Watching for config changes in.*mytheme' + + +-- hugo.toml -- +title = "Hugo Server Test" +baseURL = "https://example.org/" +disableKinds = ["section", "page", "taxonomy", "term", "RSS", "sitemap", "robotsTXT", "404"] +theme = "mytheme" +-- layouts/index.html -- +foo: {{ .Site.Params.foo }} +-- themes/mytheme/hugo.toml -- +[params] + foo = "bar" diff --git a/testscripts/commands/server_disablelivereload.txt b/testscripts/commands/server_disablelivereload.txt new file mode 100644 index 000000000..f3f163c83 --- /dev/null +++ b/testscripts/commands/server_disablelivereload.txt @@ -0,0 +1,20 @@ +hugo server --disableLiveReload & + +waitServer + +! grep 'livereload' public/index.html + +stopServer +! stderr . + +-- hugo.toml -- +baseURL = "http://example.org/" +disableKinds = ["RSS", "sitemap", "robotsTXT", "404", "taxonomy", "term"] +-- layouts/index.html -- + + + + +Home. + + \ No newline at end of file diff --git a/testscripts/commands/server_disablelivereload__config.txt b/testscripts/commands/server_disablelivereload__config.txt new file mode 100644 index 000000000..a71cde12b --- /dev/null +++ b/testscripts/commands/server_disablelivereload__config.txt @@ -0,0 +1,21 @@ +hugo server & + +waitServer + +! grep 'livereload' public/index.html + +stopServer +! stderr . + +-- hugo.toml -- +baseURL = "http://example.org/" +disableKinds = ["RSS", "sitemap", "robotsTXT", "404", "taxonomy", "term"] +disableLiveReload = true +-- layouts/index.html -- + + + + +Home. + + \ No newline at end of file diff --git a/testscripts/commands/server_render_static_to_disk.txt b/testscripts/commands/server_render_static_to_disk.txt new file mode 100644 index 000000000..98d6c0de8 --- /dev/null +++ b/testscripts/commands/server_render_static_to_disk.txt @@ -0,0 +1,26 @@ +# Test the hugo server command. + +# We run these tests in parallel so let Hugo decide which port to use. +hugo server --renderToMemory --renderStaticToDisk & + +waitServer + +httpget $HUGOTEST_BASEURL_0 'Title: Hugo Server Test' $HUGOTEST_BASEURL_0 +httpget ${HUGOTEST_BASEURL_0}mystatic.txt 'This is a static file.' + +! exists public/index.html +exists public/mystatic.txt + +stopServer +! stderr . + +-- hugo.toml -- +title = "Hugo Server Test" +baseURL = "https://example.org/" +disableKinds = ["taxonomy", "term", "sitemap"] +-- static/mystatic.txt -- +This is a static file. +-- layouts/index.html -- +Title: {{ .Title }}|BaseURL: {{ site.BaseURL }}| + + diff --git a/testscripts/commands/server_render_to_memory.txt b/testscripts/commands/server_render_to_memory.txt new file mode 100644 index 000000000..afff92126 --- /dev/null +++ b/testscripts/commands/server_render_to_memory.txt @@ -0,0 +1,26 @@ +# Test the hugo server command. + +# We run these tests in parallel so let Hugo decide which port to use. +# Deliberately using the alias 'serve' here. +hugo serve --renderToMemory & + +waitServer + +httpget $HUGOTEST_BASEURL_0 'Title: Hugo Server Test' $HUGOTEST_BASEURL_0 + +! exists public/index.html +! exists public/mystatic.txt + +stopServer +! stderr . + +-- hugo.toml -- +title = "Hugo Server Test" +baseURL = "https://example.org/" +disableKinds = ["taxonomy", "term", "sitemap"] +-- static/mystatic.txt -- +This is a static file. +-- layouts/index.html -- +Title: {{ .Title }}|BaseURL: {{ site.BaseURL }}| + + diff --git a/testscripts/commands/version.txt b/testscripts/commands/version.txt new file mode 100644 index 000000000..25fbbc85f --- /dev/null +++ b/testscripts/commands/version.txt @@ -0,0 +1,7 @@ +# Test the hugo version command. + +hugo -h +stdout 'hugo is the main command, used to build your Hugo site' + +hugo version +stdout 'hugo v.* BuildDate=unknown' diff --git a/testscripts/commands/warnf_stderr.txt b/testscripts/commands/warnf_stderr.txt new file mode 100644 index 000000000..f899253c5 --- /dev/null +++ b/testscripts/commands/warnf_stderr.txt @@ -0,0 +1,13 @@ +# Issue #13074 + +hugo +stderr 'warning' +! stdout 'warning' + +-- hugo.toml -- +baseURL = "http://example.org/" +disableKinds = ["RSS", "page", "sitemap", "robotsTXT", "404", "taxonomy", "term"] +-- layouts/index.html -- +Home +{{ warnf "This is a warning" }} + diff --git a/testscripts/unfinished/noop.txt b/testscripts/unfinished/noop.txt new file mode 100644 index 000000000..e69de29bb diff --git a/testscripts/withdeploy-off/deploy_off.txt b/testscripts/withdeploy-off/deploy_off.txt new file mode 100644 index 000000000..5e6c65d27 --- /dev/null +++ b/testscripts/withdeploy-off/deploy_off.txt @@ -0,0 +1,3 @@ +! hugo deploy --force +# Issue 13012 +stderr 'deploy not supported in this version of Hugo' \ No newline at end of file diff --git a/testscripts/withdeploy/deploy.txt b/testscripts/withdeploy/deploy.txt new file mode 100644 index 000000000..2586f8b8f --- /dev/null +++ b/testscripts/withdeploy/deploy.txt @@ -0,0 +1,24 @@ +# Test the deploy command. + +hugo deploy -h +stdout 'Deploy your site to a cloud provider' +mkdir mybucket +hugo deploy --target mydeployment --invalidateCDN=false +grep 'hello' mybucket/index.html +replace public/index.html 'hello' 'changed' +hugo deploy --target mydeployment --dryRun +stdout 'Would upload: index.html' +stdout 'Would invalidate CloudFront CDN with ID foobar' +-- hugo.toml -- +disableKinds = ["RSS", "sitemap", "robotsTXT", "404", "taxonomy", "term"] +baseURL = "https://example.org/" +[deployment] +[[deployment.targets]] +name = "myfirst" +url="gs://asdfasdf" +[[deployment.targets]] +name = "mydeployment" +url="file://./mybucket" +cloudFrontDistributionID = "foobar" +-- public/index.html -- +hello diff --git a/tpl/cast/cast.go b/tpl/cast/cast.go index c864b5e32..1999ddc0a 100644 --- a/tpl/cast/cast.go +++ b/tpl/cast/cast.go @@ -26,27 +26,26 @@ func New() *Namespace { } // Namespace provides template functions for the "cast" namespace. -type Namespace struct { -} +type Namespace struct{} -// ToInt converts the given value to an int. -func (ns *Namespace) ToInt(v interface{}) (int, error) { +// ToInt converts v to an int. +func (ns *Namespace) ToInt(v any) (int, error) { v = convertTemplateToString(v) return _cast.ToIntE(v) } -// ToString converts the given value to a string. -func (ns *Namespace) ToString(v interface{}) (string, error) { +// ToString converts v to a string. +func (ns *Namespace) ToString(v any) (string, error) { return _cast.ToStringE(v) } -// ToFloat converts the given value to a float. -func (ns *Namespace) ToFloat(v interface{}) (float64, error) { +// ToFloat converts v to a float. +func (ns *Namespace) ToFloat(v any) (float64, error) { v = convertTemplateToString(v) return _cast.ToFloat64E(v) } -func convertTemplateToString(v interface{}) interface{} { +func convertTemplateToString(v any) any { switch vv := v.(type) { case template.HTML: v = string(vv) diff --git a/tpl/cast/cast_test.go b/tpl/cast/cast_test.go index d3f8d9733..a8fdc662b 100644 --- a/tpl/cast/cast_test.go +++ b/tpl/cast/cast_test.go @@ -15,10 +15,11 @@ package cast import ( "html/template" - "testing" + "github.com/bep/imagemeta" qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/htesting/hqt" ) func TestToInt(t *testing.T) { @@ -28,8 +29,8 @@ func TestToInt(t *testing.T) { ns := New() for i, test := range []struct { - v interface{} - expect interface{} + v any + expect any }{ {"1", 1}, {template.HTML("2"), 2}, @@ -60,8 +61,8 @@ func TestToString(t *testing.T) { ns := New() for i, test := range []struct { - v interface{} - expect interface{} + v any + expect any }{ {1, "1"}, {template.HTML("2"), "2"}, @@ -86,10 +87,11 @@ func TestToFloat(t *testing.T) { t.Parallel() c := qt.New(t) ns := New() + oneThird, _ := imagemeta.NewRat[uint32](1, 3) for i, test := range []struct { - v interface{} - expect interface{} + v any + expect any }{ {"1", 1.0}, {template.HTML("2"), 2.0}, @@ -102,6 +104,7 @@ func TestToFloat(t *testing.T) { {"0", 0.0}, {float64(2.12), 2.12}, {int64(123), 123.0}, + {oneThird, 0.3333333333333333}, {2, 2.0}, {t, false}, } { @@ -115,6 +118,6 @@ func TestToFloat(t *testing.T) { } c.Assert(err, qt.IsNil, errMsg) - c.Assert(result, qt.Equals, test.expect, errMsg) + c.Assert(result, hqt.IsSameFloat64, test.expect, errMsg) } } diff --git a/tpl/cast/docshelper.go b/tpl/cast/docshelper.go index 1ee614b10..981c51551 100644 --- a/tpl/cast/docshelper.go +++ b/tpl/cast/docshelper.go @@ -14,24 +14,23 @@ package cast import ( - "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/config/testconfig" "github.com/gohugoio/hugo/deps" "github.com/gohugoio/hugo/docshelper" "github.com/gohugoio/hugo/resources/page" "github.com/gohugoio/hugo/tpl/internal" - "github.com/spf13/viper" ) // This file provides documentation support and is randomly put into this package. func init() { - docsProvider := func() map[string]interface{} { - docs := make(map[string]interface{}) - d := &deps.Deps{ - Cfg: viper.New(), - Log: loggers.NewErrorLogger(), - BuildStartListeners: &deps.Listeners{}, - Site: page.NewDummyHugoSite(newTestConfig()), + docsProvider := func() docshelper.DocProvider { + d := &deps.Deps{Conf: testconfig.GetTestConfig(nil, nil)} + if err := d.Init(); err != nil { + panic(err) } + conf := testconfig.GetTestConfig(nil, newTestConfig()) + d.Site = page.NewDummyHugoSite(conf) var namespaces internal.TemplateFuncsNamespaces @@ -41,15 +40,14 @@ func init() { } - docs["funcs"] = namespaces - return docs + return docshelper.DocProvider{"tpl": map[string]any{"funcs": namespaces}} } - docshelper.AddDocProvider("tpl", docsProvider) + docshelper.AddDocProviderFunc(docsProvider) } -func newTestConfig() *viper.Viper { - v := viper.New() +func newTestConfig() config.Provider { + v := config.New() v.Set("contentDir", "content") return v } diff --git a/tpl/cast/init.go b/tpl/cast/init.go index 3aee6f036..84211a00b 100644 --- a/tpl/cast/init.go +++ b/tpl/cast/init.go @@ -14,6 +14,8 @@ package cast import ( + "context" + "github.com/gohugoio/hugo/deps" "github.com/gohugoio/hugo/tpl/internal" ) @@ -26,7 +28,7 @@ func init() { ns := &internal.TemplateFuncsNamespace{ Name: name, - Context: func(args ...interface{}) interface{} { return ctx }, + Context: func(cctx context.Context, args ...any) (any, error) { return ctx, nil }, } ns.AddMethodMapping(ctx.ToInt, @@ -51,7 +53,6 @@ func init() { ) return ns - } internal.AddTemplateFuncsNamespace(f) diff --git a/tpl/cast/init_test.go b/tpl/cast/init_test.go deleted file mode 100644 index 73d9d5adc..000000000 --- a/tpl/cast/init_test.go +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright 2017 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 cast - -import ( - "testing" - - "github.com/gohugoio/hugo/htesting/hqt" - - qt "github.com/frankban/quicktest" - "github.com/gohugoio/hugo/deps" - "github.com/gohugoio/hugo/tpl/internal" -) - -func TestInit(t *testing.T) { - c := qt.New(t) - var found bool - var ns *internal.TemplateFuncsNamespace - - for _, nsf := range internal.TemplateFuncsNamespaceRegistry { - ns = nsf(&deps.Deps{}) - if ns.Name == name { - found = true - break - } - } - - c.Assert(found, qt.Equals, true) - c.Assert(ns.Context(), hqt.IsSameType, &Namespace{}) - -} diff --git a/tpl/collections/append.go b/tpl/collections/append.go index 297328dc8..0f262b6bc 100644 --- a/tpl/collections/append.go +++ b/tpl/collections/append.go @@ -19,13 +19,16 @@ import ( "github.com/gohugoio/hugo/common/collections" ) -// Append appends the arguments up to the last one to the slice in the last argument. +// Append appends args up to the last one to the slice in the last argument. // This construct allows template constructs like this: -// {{ $pages = $pages | append $p2 $p1 }} +// +// {{ $pages = $pages | append $p2 $p1 }} +// // Note that with 2 arguments where both are slices of the same type, // the first slice will be appended to the second: -// {{ $pages = $pages | append .Site.RegularPages }} -func (ns *Namespace) Append(args ...interface{}) (interface{}, error) { +// +// {{ $pages = $pages | append .Site.RegularPages }} +func (ns *Namespace) Append(args ...any) (any, error) { if len(args) < 2 { return nil, errors.New("need at least 2 arguments to append") } @@ -34,5 +37,4 @@ func (ns *Namespace) Append(args ...interface{}) (interface{}, error) { from := args[:len(args)-1] return collections.Append(to, from...) - } diff --git a/tpl/collections/append_test.go b/tpl/collections/append_test.go index a254601b4..78cdcdd84 100644 --- a/tpl/collections/append_test.go +++ b/tpl/collections/append_test.go @@ -18,31 +18,31 @@ import ( "testing" qt "github.com/frankban/quicktest" - "github.com/gohugoio/hugo/deps" ) // Also see tests in common/collection. func TestAppend(t *testing.T) { t.Parallel() c := qt.New(t) - - ns := New(&deps.Deps{}) + ns := newNs() for i, test := range []struct { - start interface{} - addend []interface{} - expected interface{} + start any + addend []any + expected any }{ - {[]string{"a", "b"}, []interface{}{"c"}, []string{"a", "b", "c"}}, - {[]string{"a", "b"}, []interface{}{"c", "d", "e"}, []string{"a", "b", "c", "d", "e"}}, - {[]string{"a", "b"}, []interface{}{[]string{"c", "d", "e"}}, []string{"a", "b", "c", "d", "e"}}, + {[]string{"a", "b"}, []any{"c"}, []string{"a", "b", "c"}}, + {[]string{"a", "b"}, []any{"c", "d", "e"}, []string{"a", "b", "c", "d", "e"}}, + {[]string{"a", "b"}, []any{[]string{"c", "d", "e"}}, []string{"a", "b", "c", "d", "e"}}, // Errors - {"", []interface{}{[]string{"a", "b"}}, false}, - {[]string{"a", "b"}, []interface{}{}, false}, + {"", []any{[]string{"a", "b"}}, false}, + {[]string{"a", "b"}, []any{}, false}, // No string concatenation. - {"ab", - []interface{}{"c"}, - false}, + { + "ab", + []any{"c"}, + false, + }, } { errMsg := qt.Commentf("[%d]", i) @@ -62,5 +62,4 @@ func TestAppend(t *testing.T) { t.Fatalf("%s got\n%T: %v\nexpected\n%T: %v", errMsg, result, result, test.expected, test.expected) } } - } diff --git a/tpl/collections/apply.go b/tpl/collections/apply.go index 55d29d3a9..caa51f149 100644 --- a/tpl/collections/apply.go +++ b/tpl/collections/apply.go @@ -14,45 +14,43 @@ package collections import ( + "context" "errors" "fmt" "reflect" "strings" - "github.com/gohugoio/hugo/tpl" + "github.com/gohugoio/hugo/common/hreflect" ) -// Apply takes a map, array, or slice and returns a new slice with the function fname applied over it. -func (ns *Namespace) Apply(seq interface{}, fname string, args ...interface{}) (interface{}, error) { - if seq == nil { - return make([]interface{}, 0), nil +// Apply takes an array or slice c and returns a new slice with the function fname applied over it. +func (ns *Namespace) Apply(ctx context.Context, c any, fname string, args ...any) (any, error) { + if c == nil { + return make([]any, 0), nil } if fname == "apply" { return nil, errors.New("can't apply myself (no turtles allowed)") } - seqv := reflect.ValueOf(seq) + seqv := reflect.ValueOf(c) seqv, isNil := indirect(seqv) if isNil { return nil, errors.New("can't iterate over a nil value") } - fnv, found := ns.lookupFunc(fname) + fnv, found := ns.lookupFunc(ctx, fname) if !found { return nil, errors.New("can't find function " + fname) } - // fnv := reflect.ValueOf(fn) - switch seqv.Kind() { case reflect.Array, reflect.Slice: - r := make([]interface{}, seqv.Len()) - for i := 0; i < seqv.Len(); i++ { + r := make([]any, seqv.Len()) + for i := range seqv.Len() { vv := seqv.Index(i) - vvv, err := applyFnToThis(fnv, vv, args...) - + vvv, err := applyFnToThis(ctx, fnv, vv, args...) if err != nil { return nil, err } @@ -62,11 +60,16 @@ func (ns *Namespace) Apply(seq interface{}, fname string, args ...interface{}) ( return r, nil default: - return nil, fmt.Errorf("can't apply over %v", seq) + return nil, fmt.Errorf("can't apply over %v", c) } } -func applyFnToThis(fn, this reflect.Value, args ...interface{}) (reflect.Value, error) { +func applyFnToThis(ctx context.Context, fn, this reflect.Value, args ...any) (reflect.Value, error) { + num := fn.Type().NumIn() + if num > 0 && hreflect.IsContextType(fn.Type().In(0)) { + args = append([]any{ctx}, args...) + } + n := make([]reflect.Value, len(args)) for i, arg := range args { if arg == "." { @@ -76,8 +79,6 @@ func applyFnToThis(fn, this reflect.Value, args ...interface{}) (reflect.Value, } } - num := fn.Type().NumIn() - if fn.Type().IsVariadic() { num-- } @@ -89,7 +90,7 @@ func applyFnToThis(fn, this reflect.Value, args ...interface{}) (reflect.Value, return reflect.ValueOf(nil), errors.New("Too many arguments") }*/ - for i := 0; i < num; i++ { + for i := range num { // AssignableTo reports whether xt is assignable to type targ. if xt, targ := n[i].Type(), fn.Type().In(i); !xt.AssignableTo(targ) { return reflect.ValueOf(nil), errors.New("called apply using " + xt.String() + " as type " + targ.String()) @@ -104,23 +105,31 @@ func applyFnToThis(fn, this reflect.Value, args ...interface{}) (reflect.Value, return reflect.ValueOf(nil), res[1].Interface().(error) } -func (ns *Namespace) lookupFunc(fname string) (reflect.Value, bool) { - if !strings.ContainsRune(fname, '.') { - templ := ns.deps.Tmpl().(tpl.TemplateFuncGetter) - return templ.GetFunc(fname) +func (ns *Namespace) lookupFunc(ctx context.Context, fname string) (reflect.Value, bool) { + namespace, methodName, ok := strings.Cut(fname, ".") + if !ok { + return ns.deps.GetTemplateStore().GetFunc(fname) } - ss := strings.SplitN(fname, ".", 2) - - // namespace - nv, found := ns.lookupFunc(ss[0]) + // Namespace + nv, found := ns.lookupFunc(ctx, namespace) if !found { return reflect.Value{}, false } + fn, ok := nv.Interface().(func(context.Context, ...any) (any, error)) + if !ok { + return reflect.Value{}, false + } + v, err := fn(ctx) + if err != nil { + panic(err) + } + nv = reflect.ValueOf(v) + // method - m := nv.MethodByName(ss[1]) - // if reflect.DeepEqual(m, reflect.Value{}) { + m := hreflect.GetMethodByName(nv, methodName) + if m.Kind() == reflect.Invalid { return reflect.Value{}, false } diff --git a/tpl/collections/apply_test.go b/tpl/collections/apply_test.go deleted file mode 100644 index 0d06f52e8..000000000 --- a/tpl/collections/apply_test.go +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright 2019 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 collections - -import ( - "io" - "reflect" - "testing" - - "fmt" - - qt "github.com/frankban/quicktest" - "github.com/gohugoio/hugo/deps" - "github.com/gohugoio/hugo/output" - "github.com/gohugoio/hugo/tpl" -) - -type templateFinder int - -func (templateFinder) Lookup(name string) (tpl.Template, bool) { - return nil, false -} - -func (templateFinder) HasTemplate(name string) bool { - return false -} - -func (templateFinder) LookupVariant(name string, variants tpl.TemplateVariants) (tpl.Template, bool, bool) { - return nil, false, false -} - -func (templateFinder) LookupLayout(d output.LayoutDescriptor, f output.Format) (tpl.Template, bool, error) { - return nil, false, nil -} - -func (templateFinder) Execute(t tpl.Template, wr io.Writer, data interface{}) error { - return nil -} - -func (templateFinder) GetFunc(name string) (reflect.Value, bool) { - if name == "dobedobedo" { - return reflect.Value{}, false - } - - return reflect.ValueOf(fmt.Sprint), true - -} - -func TestApply(t *testing.T) { - t.Parallel() - c := qt.New(t) - d := &deps.Deps{} - d.SetTmpl(new(templateFinder)) - ns := New(d) - - strings := []interface{}{"a\n", "b\n"} - - result, err := ns.Apply(strings, "print", "a", "b", "c") - c.Assert(err, qt.IsNil) - c.Assert(result, qt.DeepEquals, []interface{}{"abc", "abc"}) - - _, err = ns.Apply(strings, "apply", ".") - c.Assert(err, qt.Not(qt.IsNil)) - - var nilErr *error - _, err = ns.Apply(nilErr, "chomp", ".") - c.Assert(err, qt.Not(qt.IsNil)) - - _, err = ns.Apply(strings, "dobedobedo", ".") - c.Assert(err, qt.Not(qt.IsNil)) - - _, err = ns.Apply(strings, "foo.Chomp", "c\n") - if err == nil { - t.Errorf("apply with unknown func should fail") - } - -} diff --git a/tpl/collections/collections.go b/tpl/collections/collections.go index 5b9d4a700..0653a453a 100644 --- a/tpl/collections/collections.go +++ b/tpl/collections/collections.go @@ -16,11 +16,10 @@ package collections import ( + "context" + "errors" "fmt" - "html/template" - "math/rand" - "net/url" "reflect" "strings" "time" @@ -29,66 +28,72 @@ import ( "github.com/gohugoio/hugo/common/maps" "github.com/gohugoio/hugo/common/types" "github.com/gohugoio/hugo/deps" - "github.com/gohugoio/hugo/helpers" - "github.com/pkg/errors" + "github.com/gohugoio/hugo/langs" + "github.com/gohugoio/hugo/tpl/compare" "github.com/spf13/cast" ) -func init() { - rand.Seed(time.Now().UTC().UnixNano()) -} - // New returns a new instance of the collections-namespaced template functions. func New(deps *deps.Deps) *Namespace { + language := deps.Conf.Language() + if language == nil { + panic("language must be set") + } + loc := langs.GetLocation(language) + return &Namespace{ - deps: deps, + loc: loc, + sortComp: compare.New(loc, true), + deps: deps, } } // Namespace provides template functions for the "collections" namespace. type Namespace struct { - deps *deps.Deps + loc *time.Location + sortComp *compare.Namespace + deps *deps.Deps } -// After returns all the items after the first N in a rangeable list. -func (ns *Namespace) After(index interface{}, seq interface{}) (interface{}, error) { - if index == nil || seq == nil { +// After returns all the items after the first n items in list l. +func (ns *Namespace) After(n any, l any) (any, error) { + if n == nil || l == nil { return nil, errors.New("both limit and seq must be provided") } - indexv, err := cast.ToIntE(index) + nv, err := cast.ToIntE(n) if err != nil { return nil, err } - if indexv < 0 { - return nil, errors.New("sequence bounds out of range [" + cast.ToString(indexv) + ":]") + if nv < 0 { + return nil, errors.New("sequence bounds out of range [" + cast.ToString(nv) + ":]") } - seqv := reflect.ValueOf(seq) - seqv, isNil := indirect(seqv) + lv := reflect.ValueOf(l) + lv, isNil := indirect(lv) if isNil { return nil, errors.New("can't iterate over a nil value") } - switch seqv.Kind() { + switch lv.Kind() { case reflect.Array, reflect.Slice, reflect.String: // okay default: - return nil, errors.New("can't iterate over " + reflect.ValueOf(seq).Type().String()) + return nil, errors.New("can't iterate over " + reflect.ValueOf(l).Type().String()) } - if indexv >= seqv.Len() { - return seqv.Slice(0, 0).Interface(), nil + if nv >= lv.Len() { + return lv.Slice(0, 0).Interface(), nil } - return seqv.Slice(indexv, seqv.Len()).Interface(), nil + return lv.Slice(nv, lv.Len()).Interface(), nil } -// Delimit takes a given sequence and returns a delimited HTML string. +// Delimit takes a given list l and returns a string delimited by sep. // If last is passed to the function, it will be used as the final delimiter. -func (ns *Namespace) Delimit(seq, delimiter interface{}, last ...interface{}) (template.HTML, error) { - d, err := cast.ToStringE(delimiter) +func (ns *Namespace) Delimit(ctx context.Context, l, sep any, last ...any) (string, error) { + d, err := cast.ToStringE(sep) if err != nil { return "", err } @@ -104,32 +109,32 @@ func (ns *Namespace) Delimit(seq, delimiter interface{}, last ...interface{}) (t } } - seqv := reflect.ValueOf(seq) - seqv, isNil := indirect(seqv) + lv := reflect.ValueOf(l) + lv, isNil := indirect(lv) if isNil { return "", errors.New("can't iterate over a nil value") } var str string - switch seqv.Kind() { + switch lv.Kind() { case reflect.Map: - sortSeq, err := ns.Sort(seq) + sortSeq, err := ns.Sort(ctx, l) if err != nil { return "", err } - seqv = reflect.ValueOf(sortSeq) + lv = reflect.ValueOf(sortSeq) fallthrough case reflect.Array, reflect.Slice, reflect.String: - for i := 0; i < seqv.Len(); i++ { - val := seqv.Index(i).Interface() + for i := range lv.Len() { + val := lv.Index(i).Interface() valStr, err := cast.ToStringE(val) if err != nil { continue } switch { - case i == seqv.Len()-2 && dLast != nil: + case i == lv.Len()-2 && dLast != nil: str += valStr + *dLast - case i == seqv.Len()-1: + case i == lv.Len()-1: str += valStr default: str += valStr + d @@ -137,22 +142,21 @@ func (ns *Namespace) Delimit(seq, delimiter interface{}, last ...interface{}) (t } default: - return "", fmt.Errorf("can't iterate over %v", seq) + return "", fmt.Errorf("can't iterate over %T", l) } - return template.HTML(str), nil + return str, nil } -// Dictionary creates a map[string]interface{} from the given parameters by -// walking the parameters and treating them as key-value pairs. The number -// of parameters must be even. +// Dictionary creates a new map from the given parameters by +// treating values as key-value pairs. The number of values must be even. // The keys can be string slices, which will create the needed nested structure. -func (ns *Namespace) Dictionary(values ...interface{}) (map[string]interface{}, error) { +func (ns *Namespace) Dictionary(values ...any) (map[string]any, error) { if len(values)%2 != 0 { return nil, errors.New("invalid dictionary call") } - root := make(map[string]interface{}) + root := make(map[string]any) for i := 0; i < len(values); i += 2 { dict := root @@ -161,14 +165,14 @@ func (ns *Namespace) Dictionary(values ...interface{}) (map[string]interface{}, case string: key = v case []string: - for i := 0; i < len(v)-1; i++ { + for i := range len(v) - 1 { key = v[i] - var m map[string]interface{} + var m map[string]any v, found := dict[key] if found { - m = v.(map[string]interface{}) + m = v.(map[string]any) } else { - m = make(map[string]interface{}) + m = make(map[string]any) dict[key] = m } dict = m @@ -183,53 +187,9 @@ func (ns *Namespace) Dictionary(values ...interface{}) (map[string]interface{}, return root, nil } -// EchoParam returns a given value if it is set; otherwise, it returns an -// empty string. -func (ns *Namespace) EchoParam(a, key interface{}) interface{} { - av, isNil := indirect(reflect.ValueOf(a)) - if isNil { - return "" - } - - var avv reflect.Value - switch av.Kind() { - case reflect.Array, reflect.Slice: - index, ok := key.(int) - if ok && av.Len() > index { - avv = av.Index(index) - } - case reflect.Map: - kv := reflect.ValueOf(key) - if kv.Type().AssignableTo(av.Type().Key()) { - avv = av.MapIndex(kv) - } - } - - avv, isNil = indirect(avv) - - if isNil { - return "" - } - - if avv.IsValid() { - switch avv.Kind() { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - return avv.Int() - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return avv.Uint() - case reflect.Float32, reflect.Float64: - return avv.Float() - case reflect.String: - return avv.String() - } - } - - return "" -} - -// First returns the first N items in a rangeable list. -func (ns *Namespace) First(limit interface{}, seq interface{}) (interface{}, error) { - if limit == nil || seq == nil { +// First returns the first limit items in list l. +func (ns *Namespace) First(limit any, l any) (any, error) { + if limit == nil || l == nil { return nil, errors.New("both limit and seq must be provided") } @@ -242,28 +202,28 @@ func (ns *Namespace) First(limit interface{}, seq interface{}) (interface{}, err return nil, errors.New("sequence length must be non-negative") } - seqv := reflect.ValueOf(seq) - seqv, isNil := indirect(seqv) + lv := reflect.ValueOf(l) + lv, isNil := indirect(lv) if isNil { return nil, errors.New("can't iterate over a nil value") } - switch seqv.Kind() { + switch lv.Kind() { case reflect.Array, reflect.Slice, reflect.String: // okay default: - return nil, errors.New("can't iterate over " + reflect.ValueOf(seq).Type().String()) + return nil, errors.New("can't iterate over " + reflect.ValueOf(l).Type().String()) } - if limitv > seqv.Len() { - limitv = seqv.Len() + if limitv > lv.Len() { + limitv = lv.Len() } - return seqv.Slice(0, limitv).Interface(), nil + return lv.Slice(0, limitv).Interface(), nil } -// In returns whether v is in the set l. l may be an array or slice. -func (ns *Namespace) In(l interface{}, v interface{}) (bool, error) { +// In returns whether v is in the list l. l may be an array or slice. +func (ns *Namespace) In(l any, v any) (bool, error) { if l == nil || v == nil { return false, nil } @@ -271,18 +231,13 @@ func (ns *Namespace) In(l interface{}, v interface{}) (bool, error) { lv := reflect.ValueOf(l) vv := reflect.ValueOf(v) - if !vv.Type().Comparable() { - return false, errors.Errorf("value to check must be comparable: %T", v) - } - - // Normalize numeric types to float64 etc. vvk := normalize(vv) switch lv.Kind() { case reflect.Array, reflect.Slice: - for i := 0; i < lv.Len(); i++ { + for i := range lv.Len() { lvv, isNil := indirectInterface(lv.Index(i)) - if isNil || !lvv.Type().Comparable() { + if isNil { continue } @@ -292,19 +247,24 @@ func (ns *Namespace) In(l interface{}, v interface{}) (bool, error) { return true, nil } } - case reflect.String: - if vv.Type() == lv.Type() && strings.Contains(lv.String(), vv.String()) { - return true, nil - } } - return false, nil + ss, err := cast.ToStringE(l) + if err != nil { + return false, nil + } + + su, err := cast.ToStringE(v) + if err != nil { + return false, nil + } + return strings.Contains(ss, su), nil } // Intersect returns the common elements in the given sets, l1 and l2. l1 and // l2 must be of the same type and may be either arrays or slices. -func (ns *Namespace) Intersect(l1, l2 interface{}) (interface{}, error) { +func (ns *Namespace) Intersect(l1, l2 any) (any, error) { if l1 == nil || l2 == nil { - return make([]interface{}, 0), nil + return make([]any, 0), nil } var ins *intersector @@ -314,19 +274,19 @@ func (ns *Namespace) Intersect(l1, l2 interface{}) (interface{}, error) { switch l1v.Kind() { case reflect.Array, reflect.Slice: - ins = &intersector{r: reflect.MakeSlice(l1v.Type(), 0, 0), seen: make(map[interface{}]bool)} + ins = &intersector{r: reflect.MakeSlice(l1v.Type(), 0, 0), seen: make(map[any]bool)} switch l2v.Kind() { case reflect.Array, reflect.Slice: - for i := 0; i < l1v.Len(); i++ { + for i := range l1v.Len() { l1vv := l1v.Index(i) if !l1vv.Type().Comparable() { - return make([]interface{}, 0), errors.New("intersect does not support slices or arrays of uncomparable types") + return make([]any, 0), errors.New("intersect does not support slices or arrays of uncomparable types") } - for j := 0; j < l2v.Len(); j++ { + for j := range l2v.Len() { l2vv := l2v.Index(j) if !l2vv.Type().Comparable() { - return make([]interface{}, 0), errors.New("intersect does not support slices or arrays of uncomparable types") + return make([]any, 0), errors.New("intersect does not support slices or arrays of uncomparable types") } ins.handleValuePair(l1vv, l2vv) @@ -341,9 +301,9 @@ func (ns *Namespace) Intersect(l1, l2 interface{}) (interface{}, error) { } } -// Group groups a set of elements by the given key. +// Group groups a set of items by the given key. // This is currently only supported for Pages. -func (ns *Namespace) Group(key interface{}, items interface{}) (interface{}, error) { +func (ns *Namespace) Group(key any, items any) (any, error) { if key == nil { return nil, errors.New("nil is not a valid key to group by") } @@ -361,10 +321,10 @@ func (ns *Namespace) Group(key interface{}, items interface{}) (interface{}, err return nil, fmt.Errorf("grouping not supported for type %T %T", items, in) } -// IsSet returns whether a given array, channel, slice, or map has a key +// IsSet returns whether a given array, channel, slice, or map in c has the given key // defined. -func (ns *Namespace) IsSet(a interface{}, key interface{}) (bool, error) { - av := reflect.ValueOf(a) +func (ns *Namespace) IsSet(c any, key any) (bool, error) { + av := reflect.ValueOf(c) kv := reflect.ValueOf(key) switch av.Kind() { @@ -381,15 +341,15 @@ func (ns *Namespace) IsSet(a interface{}, key interface{}) (bool, error) { return av.MapIndex(kv).IsValid(), nil } default: - helpers.DistinctFeedbackLog.Printf("WARNING: calling IsSet with unsupported type %q (%T) will always return false.\n", av.Kind(), a) + ns.deps.Log.Warnf("calling IsSet with unsupported type %q (%T) will always return false.\n", av.Kind(), c) } return false, nil } -// Last returns the last N items in a rangeable list. -func (ns *Namespace) Last(limit interface{}, seq interface{}) (interface{}, error) { - if limit == nil || seq == nil { +// Last returns the last limit items in the list l. +func (ns *Namespace) Last(limit any, l any) (any, error) { + if limit == nil || l == nil { return nil, errors.New("both limit and seq must be provided") } @@ -402,7 +362,7 @@ func (ns *Namespace) Last(limit interface{}, seq interface{}) (interface{}, erro return nil, errors.New("sequence length must be non-negative") } - seqv := reflect.ValueOf(seq) + seqv := reflect.ValueOf(l) seqv, isNil := indirect(seqv) if isNil { return nil, errors.New("can't iterate over a nil value") @@ -412,7 +372,7 @@ func (ns *Namespace) Last(limit interface{}, seq interface{}) (interface{}, erro case reflect.Array, reflect.Slice, reflect.String: // okay default: - return nil, errors.New("can't iterate over " + reflect.ValueOf(seq).Type().String()) + return nil, errors.New("can't iterate over " + reflect.ValueOf(l).Type().String()) } if limitv > seqv.Len() { @@ -422,27 +382,12 @@ func (ns *Namespace) Last(limit interface{}, seq interface{}) (interface{}, erro return seqv.Slice(seqv.Len()-limitv, seqv.Len()).Interface(), nil } -// Querify encodes the given parameters in URL-encoded form ("bar=baz&foo=quux") sorted by key. -func (ns *Namespace) Querify(params ...interface{}) (string, error) { - qs := url.Values{} - vals, err := ns.Dictionary(params...) - if err != nil { - return "", errors.New("querify keys must be strings") - } - - for name, value := range vals { - qs.Add(name, fmt.Sprintf("%v", value)) - } - - return qs.Encode(), nil -} - -// Reverse creates a copy of slice and reverses it. -func (ns *Namespace) Reverse(slice interface{}) (interface{}, error) { - if slice == nil { +// Reverse creates a copy of the list l and reverses it. +func (ns *Namespace) Reverse(l any) (any, error) { + if l == nil { return nil, nil } - v := reflect.ValueOf(slice) + v := reflect.ValueOf(l) switch v.Kind() { case reflect.Slice: @@ -460,15 +405,16 @@ func (ns *Namespace) Reverse(slice interface{}) (interface{}, error) { return sliceCopy.Interface(), nil } -// Seq creates a sequence of integers. It's named and used as GNU's seq. +// Seq creates a sequence of integers from args. It's named and used as GNU's seq. // // Examples: -// 3 => 1, 2, 3 -// 1 2 4 => 1, 3 -// -3 => -1, -2, -3 -// 1 4 => 1, 2, 3, 4 -// 1 -2 => 1, 0, -1, -2 -func (ns *Namespace) Seq(args ...interface{}) ([]int, error) { +// +// 3 => 1, 2, 3 +// 1 2 4 => 1, 3 +// -3 => -1, -2, -3 +// 1 4 => 1, 2, 3, 4 +// 1 -2 => 1, 0, -1, -2 +func (ns *Namespace) Seq(args ...any) ([]int, error) { if len(args) < 1 || len(args) > 3 { return nil, errors.New("invalid number of arguments to Seq") } @@ -478,9 +424,9 @@ func (ns *Namespace) Seq(args ...interface{}) ([]int, error) { return nil, errors.New("invalid arguments to Seq") } - var inc = 1 + inc := 1 var last int - var first = intArgs[0] + first := intArgs[0] if len(intArgs) == 1 { last = first @@ -535,38 +481,38 @@ func (ns *Namespace) Seq(args ...interface{}) ([]int, error) { return seq, nil } -// Shuffle returns the given rangeable list in a randomised order. -func (ns *Namespace) Shuffle(seq interface{}) (interface{}, error) { - if seq == nil { +// Shuffle returns list l in a randomized order. +func (ns *Namespace) Shuffle(l any) (any, error) { + if l == nil { return nil, errors.New("both count and seq must be provided") } - seqv := reflect.ValueOf(seq) - seqv, isNil := indirect(seqv) + lv := reflect.ValueOf(l) + lv, isNil := indirect(lv) if isNil { return nil, errors.New("can't iterate over a nil value") } - switch seqv.Kind() { + switch lv.Kind() { case reflect.Array, reflect.Slice, reflect.String: // okay default: - return nil, errors.New("can't iterate over " + reflect.ValueOf(seq).Type().String()) + return nil, errors.New("can't iterate over " + reflect.ValueOf(l).Type().String()) } - shuffled := reflect.MakeSlice(reflect.TypeOf(seq), seqv.Len(), seqv.Len()) + shuffled := reflect.MakeSlice(reflect.TypeOf(l), lv.Len(), lv.Len()) - randomIndices := rand.Perm(seqv.Len()) + randomIndices := rand.Perm(lv.Len()) for index, value := range randomIndices { - shuffled.Index(value).Set(seqv.Index(index)) + shuffled.Index(value).Set(lv.Index(index)) } return shuffled.Interface(), nil } // Slice returns a slice of all passed arguments. -func (ns *Namespace) Slice(args ...interface{}) interface{} { +func (ns *Namespace) Slice(args ...any) any { if len(args) == 0 { return args } @@ -576,15 +522,14 @@ func (ns *Namespace) Slice(args ...interface{}) interface{} { type intersector struct { r reflect.Value - seen map[interface{}]bool + seen map[any]bool } func (i *intersector) appendIfNotSeen(v reflect.Value) { - - vi := v.Interface() - if !i.seen[vi] { + k := normalize(v) + if !i.seen[k] { i.r = reflect.Append(i.r, v) - i.seen[vi] = true + i.seen[k] = true } } @@ -602,7 +547,7 @@ func (i *intersector) handleValuePair(l1vv, l2vv reflect.Value) { i.appendIfNotSeen(l1vv) } case kind == reflect.Ptr, kind == reflect.Struct: - if l1vv.Interface() == l2vv.Interface() { + if types.Unwrapv(l1vv.Interface()) == types.Unwrapv(l2vv.Interface()) { i.appendIfNotSeen(l1vv) } case kind == reflect.Interface: @@ -614,9 +559,9 @@ func (i *intersector) handleValuePair(l1vv, l2vv reflect.Value) { // l2 must be of the same type and may be either arrays or slices. // If l1 and l2 aren't of the same type then l1 will be returned. // If either l1 or l2 is nil then the non-nil list will be returned. -func (ns *Namespace) Union(l1, l2 interface{}) (interface{}, error) { +func (ns *Namespace) Union(l1, l2 any) (any, error) { if l1 == nil && l2 == nil { - return []interface{}{}, nil + return []any{}, nil } else if l1 == nil && l2 != nil { return l2, nil } else if l1 != nil && l2 == nil { @@ -632,7 +577,7 @@ func (ns *Namespace) Union(l1, l2 interface{}) (interface{}, error) { case reflect.Array, reflect.Slice: switch l2v.Kind() { case reflect.Array, reflect.Slice: - ins = &intersector{r: reflect.MakeSlice(l1v.Type(), 0, 0), seen: make(map[interface{}]bool)} + ins = &intersector{r: reflect.MakeSlice(l1v.Type(), 0, 0), seen: make(map[any]bool)} if l1v.Type() != l2v.Type() && l1v.Type().Elem().Kind() != reflect.Interface && @@ -645,11 +590,11 @@ func (ns *Namespace) Union(l1, l2 interface{}) (interface{}, error) { isNil bool ) - for i := 0; i < l1v.Len(); i++ { + for i := range l1v.Len() { l1vv, isNil = indirectInterface(l1v.Index(i)) if !l1vv.Type().Comparable() { - return []interface{}{}, errors.New("union does not support slices or arrays of uncomparable types") + return []any{}, errors.New("union does not support slices or arrays of uncomparable types") } if !isNil { @@ -665,7 +610,7 @@ func (ns *Namespace) Union(l1, l2 interface{}) (interface{}, error) { } } - for j := 0; j < l2v.Len(); j++ { + for j := range l2v.Len() { l2vv := l2v.Index(j) switch kind := l1vv.Kind(); { @@ -695,32 +640,32 @@ func (ns *Namespace) Union(l1, l2 interface{}) (interface{}, error) { } } -// Uniq takes in a slice or array and returns a slice with subsequent -// duplicate elements removed. -func (ns *Namespace) Uniq(seq interface{}) (interface{}, error) { - if seq == nil { - return make([]interface{}, 0), nil +// Uniq returns a new list with duplicate elements in the list l removed. +func (ns *Namespace) Uniq(l any) (any, error) { + if l == nil { + return make([]any, 0), nil } - v := reflect.ValueOf(seq) + v := reflect.ValueOf(l) var slice reflect.Value switch v.Kind() { case reflect.Slice: slice = reflect.MakeSlice(v.Type(), 0, 0) + case reflect.Array: slice = reflect.MakeSlice(reflect.SliceOf(v.Type().Elem()), 0, 0) default: - return nil, errors.Errorf("type %T not supported", seq) + return nil, fmt.Errorf("type %T not supported", l) } - seen := make(map[interface{}]bool) - for i := 0; i < v.Len(); i++ { + seen := make(map[any]bool) + + for i := range v.Len() { ev, _ := indirectInterface(v.Index(i)) - if !ev.Type().Comparable() { - return nil, errors.New("elements must be comparable") - } + key := normalize(ev) + if _, found := seen[key]; !found { slice = reflect.Append(slice, ev) seen[key] = true @@ -728,12 +673,11 @@ func (ns *Namespace) Uniq(seq interface{}) (interface{}, error) { } return slice.Interface(), nil - } // KeyVals creates a key and values wrapper. -func (ns *Namespace) KeyVals(key interface{}, vals ...interface{}) (types.KeyValues, error) { - return types.KeyValues{Key: key, Values: vals}, nil +func (ns *Namespace) KeyVals(key any, values ...any) (types.KeyValues, error) { + return types.KeyValues{Key: key, Values: values}, nil } // NewScratch creates a new Scratch which can be used to store values in a diff --git a/tpl/collections/collections_integration_test.go b/tpl/collections/collections_integration_test.go new file mode 100644 index 000000000..b60aaea87 --- /dev/null +++ b/tpl/collections/collections_integration_test.go @@ -0,0 +1,300 @@ +// Copyright 2024 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 collections_test + +import ( + "testing" + + "github.com/gohugoio/hugo/hugolib" +) + +// Issue 9585 +func TestApplyWithContext(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +baseURL = 'http://example.com/' +-- layouts/index.html -- +{{ apply (seq 3) "partial" "foo.html"}} +-- layouts/partials/foo.html -- +{{ return "foo"}} + ` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", ` + [foo foo foo] +`) +} + +// Issue 9865 +func TestSortStable(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +-- layouts/index.html -- +{{ $values := slice (dict "a" 1 "b" 2) (dict "a" 3 "b" 1) (dict "a" 2 "b" 0) (dict "a" 1 "b" 0) (dict "a" 3 "b" 1) (dict "a" 2 "b" 2) (dict "a" 2 "b" 1) (dict "a" 0 "b" 3) (dict "a" 3 "b" 3) (dict "a" 0 "b" 0) (dict "a" 0 "b" 0) (dict "a" 2 "b" 0) (dict "a" 1 "b" 2) (dict "a" 1 "b" 1) (dict "a" 3 "b" 0) (dict "a" 2 "b" 0) (dict "a" 3 "b" 0) (dict "a" 3 "b" 0) (dict "a" 3 "b" 0) (dict "a" 3 "b" 1) }} +Asc: {{ sort (sort $values "b" "asc") "a" "asc" }} +Desc: {{ sort (sort $values "b" "desc") "a" "desc" }} + + ` + + for range 4 { + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + }, + ).Build() + + b.AssertFileContent("public/index.html", ` +Asc: [map[a:0 b:0] map[a:0 b:0] map[a:0 b:3] map[a:1 b:0] map[a:1 b:1] map[a:1 b:2] map[a:1 b:2] map[a:2 b:0] map[a:2 b:0] map[a:2 b:0] map[a:2 b:1] map[a:2 b:2] map[a:3 b:0] map[a:3 b:0] map[a:3 b:0] map[a:3 b:0] map[a:3 b:1] map[a:3 b:1] map[a:3 b:1] map[a:3 b:3]] +Desc: [map[a:3 b:3] map[a:3 b:1] map[a:3 b:1] map[a:3 b:1] map[a:3 b:0] map[a:3 b:0] map[a:3 b:0] map[a:3 b:0] map[a:2 b:2] map[a:2 b:1] map[a:2 b:0] map[a:2 b:0] map[a:2 b:0] map[a:1 b:2] map[a:1 b:2] map[a:1 b:1] map[a:1 b:0] map[a:0 b:3] map[a:0 b:0] map[a:0 b:0]] +`) + + } +} + +// Issue #11004. +func TestAppendSliceToASliceOfSlices(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +-- layouts/index.html -- +{{ $obj := slice (slice "a") }} +{{ $obj = $obj | append (slice "b") }} +{{ $obj = $obj | append (slice "c") }} + +{{ $obj }} + + ` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", "[[a] [b] [c]]") +} + +func TestAppendNilToSlice(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +-- layouts/index.html -- +{{ $obj := (slice "a") }} +{{ $obj = $obj | append nil }} + +{{ $obj }} + + + ` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", "[a <nil>]") +} + +func TestAppendNilsToSliceWithNils(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +-- layouts/index.html -- +{{ $obj := (slice "a" nil "c") }} +{{ $obj = $obj | append nil }} + +{{ $obj }} + + + ` + + for range 4 { + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + }, + ).Build() + + b.AssertFileContent("public/index.html", "[a <nil> c <nil>]") + + } +} + +// Issue 11234. +func TestWhereWithWordCount(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +baseURL = 'http://example.com/' +-- layouts/index.html -- +Home: {{ range where site.RegularPages "WordCount" "gt" 50 }}{{ .Title }}|{{ end }} +-- layouts/shortcodes/lorem.html -- +{{ "ipsum " | strings.Repeat (.Get 0 | int) }} + +-- content/p1.md -- +--- +title: "p1" +--- +{{< lorem 100 >}} +-- content/p2.md -- +--- +title: "p2" +--- +{{< lorem 20 >}} +-- content/p3.md -- +--- +title: "p3" +--- +{{< lorem 60 >}} + ` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", ` +Home: p1|p3| +`) +} + +// Issue #11279 +func TestWhereLikeOperator(t *testing.T) { + t.Parallel() + files := ` +-- content/p1.md -- +--- +title: P1 +foo: ab +--- +-- content/p2.md -- +--- +title: P2 +foo: abc +--- +-- content/p3.md -- +--- +title: P3 +foo: bc +--- +-- layouts/index.html -- +
      + {{- range where site.RegularPages "Params.foo" "like" "^ab" -}} +
    • {{ .Title }}
    • + {{- end -}} +
    + ` + b := hugolib.Test(t, files) + b.AssertFileContent("public/index.html", "
    • P1
    • P2
    ") +} + +func TestTermEntriesCollectionsIssue12254(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +capitalizeListTitles = false +disableKinds = ['rss','sitemap'] +-- content/p1.md -- +--- +title: p1 +categories: [cat-a] +tags: ['tag-b','tag-a','tag-c'] +--- +-- content/p2.md -- +--- +title: p2 +categories: [cat-a] +tags: ['tag-b','tag-a'] +--- +-- content/p3.md -- +--- +title: p3 +categories: [cat-a] +tags: ['tag-b'] +--- +-- layouts/_default/term.html -- +{{ $list1 := .Pages }} +{{ range $i, $e := site.Taxonomies.tags.ByCount }} +{{ $list2 := .Pages }} +{{ $i }}: List1: {{ len $list1 }}| +{{ $i }}: List2: {{ len $list2 }}| +{{ $i }}: Intersect: {{ intersect $.Pages .Pages | len }}| +{{ $i }}: Union: {{ union $.Pages .Pages | len }}| +{{ $i }}: SymDiff: {{ symdiff $.Pages .Pages | len }}| +{{ $i }}: Uniq: {{ append $.Pages .Pages | uniq | len }}| +{{ end }} + + +` + b := hugolib.Test(t, files) + + b.AssertFileContent("public/categories/cat-a/index.html", + "0: List1: 3|\n0: List2: 3|\n0: Intersect: 3|\n0: Union: 3|\n0: SymDiff: 0|\n0: Uniq: 3|\n\n\n1: List1: 3|", + "1: List2: 2|\n1: Intersect: 2|\n1: Union: 3|\n1: SymDiff: 1|\n1: Uniq: 3|\n\n\n2: List1: 3|\n2: List2: 1|", + "2: Intersect: 1|\n2: Union: 3|\n2: SymDiff: 2|\n2: Uniq: 3|", + ) +} + +// Issue #13181 +func TestUnionResourcesMatch(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +disableKinds = ['rss','sitemap', 'taxonomy', 'term', 'page'] +-- layouts/index.html -- +{{ $a := resources.Match "*a*" }} +{{ $b := resources.Match "*b*" }} +{{ $union := $a | union $b }} +{{ range $i, $e := $union }} +{{ $i }}: {{ .Name }} +{{ end }}$ +-- assets/a1.html -- +
    file1
    +-- assets/a2.html -- +
    file2
    +-- assets/a3_b1.html -- +
    file3
    +-- assets/b2.html -- +
    file4
    +` + + b := hugolib.Test(t, files) + + b.AssertFileContentExact("public/index.html", "0: /a3_b1.html\n\n1: /b2.html\n\n2: /a1.html\n\n3: /a2.html\n$") +} + +// Issue 13621. +func TestWhereNotInEmptySlice(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +-- layouts/home.html -- +{{- $pages := where site.RegularPages "Kind" "not in" (slice) -}} +Len: {{ $pages | len }}| +-- layouts/all.html -- +All|{{ .Title }}| +-- content/p1.md -- + +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", "Len: 1|") +} diff --git a/tpl/collections/collections_test.go b/tpl/collections/collections_test.go index c98f4a527..fe7f2144d 100644 --- a/tpl/collections/collections_test.go +++ b/tpl/collections/collections_test.go @@ -14,26 +14,19 @@ package collections import ( + "context" "errors" "fmt" "html/template" - "math/rand" "reflect" "testing" "time" "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/config/testconfig" qt "github.com/frankban/quicktest" - "github.com/gohugoio/hugo/common/loggers" - "github.com/gohugoio/hugo/config" - "github.com/gohugoio/hugo/deps" - "github.com/gohugoio/hugo/helpers" - "github.com/gohugoio/hugo/hugofs" - "github.com/gohugoio/hugo/langs" - "github.com/spf13/afero" - "github.com/spf13/viper" ) type tstNoStringer struct{} @@ -42,12 +35,12 @@ func TestAfter(t *testing.T) { t.Parallel() c := qt.New(t) - ns := New(&deps.Deps{}) + ns := newNs() for i, test := range []struct { - index interface{} - seq interface{} - expect interface{} + index any + seq any + expect any }{ {int(2), []string{"a", "b", "c", "d"}, []string{"c", "d"}}, {int32(3), []string{"a", "b"}, []string{}}, @@ -78,20 +71,18 @@ func TestAfter(t *testing.T) { } } -type tstGrouper struct { -} +type tstGrouper struct{} type tstGroupers []*tstGrouper -func (g tstGrouper) Group(key interface{}, items interface{}) (interface{}, error) { +func (g tstGrouper) Group(key any, items any) (any, error) { ilen := reflect.ValueOf(items).Len() return fmt.Sprintf("%v(%d)", key, ilen), nil } -type tstGrouper2 struct { -} +type tstGrouper2 struct{} -func (g *tstGrouper2) Group(key interface{}, items interface{}) (interface{}, error) { +func (g *tstGrouper2) Group(key any, items any) (any, error) { ilen := reflect.ValueOf(items).Len() return fmt.Sprintf("%v(%d)", key, ilen), nil } @@ -99,12 +90,12 @@ func (g *tstGrouper2) Group(key interface{}, items interface{}) (interface{}, er func TestGroup(t *testing.T) { t.Parallel() c := qt.New(t) - ns := New(&deps.Deps{}) + ns := newNs() for i, test := range []struct { - key interface{} - items interface{} - expect interface{} + key any + items any + expect any }{ {"a", []*tstGrouper{{}, {}}, "a(2)"}, {"b", tstGroupers{&tstGrouper{}, &tstGrouper{}}, "b(2)"}, @@ -135,13 +126,13 @@ func TestDelimit(t *testing.T) { t.Parallel() c := qt.New(t) - ns := New(&deps.Deps{}) + ns := newNs() for i, test := range []struct { - seq interface{} - delimiter interface{} - last interface{} - expect template.HTML + seq any + delimiter any + last any + expect string }{ {[]string{"class1", "class2", "class3"}, " ", nil, "class1 class2 class3"}, {[]int{1, 2, 3, 4, 5}, ",", nil, "1,2,3,4,5"}, @@ -170,13 +161,13 @@ func TestDelimit(t *testing.T) { } { errMsg := qt.Commentf("[%d] %v", i, test) - var result template.HTML + var result string var err error if test.last == nil { - result, err = ns.Delimit(test.seq, test.delimiter) + result, err = ns.Delimit(context.Background(), test.seq, test.delimiter) } else { - result, err = ns.Delimit(test.seq, test.delimiter, test.last) + result, err = ns.Delimit(context.Background(), test.seq, test.delimiter, test.last) } c.Assert(err, qt.IsNil, errMsg) @@ -187,20 +178,22 @@ func TestDelimit(t *testing.T) { func TestDictionary(t *testing.T) { c := qt.New(t) - ns := New(&deps.Deps{}) + ns := newNs() for i, test := range []struct { - values []interface{} - expect interface{} + values []any + expect any }{ - {[]interface{}{"a", "b"}, map[string]interface{}{"a": "b"}}, - {[]interface{}{[]string{"a", "b"}, "c"}, map[string]interface{}{"a": map[string]interface{}{"b": "c"}}}, - {[]interface{}{[]string{"a", "b"}, "c", []string{"a", "b2"}, "c2", "b", "c"}, - map[string]interface{}{"a": map[string]interface{}{"b": "c", "b2": "c2"}, "b": "c"}}, - {[]interface{}{"a", 12, "b", []int{4}}, map[string]interface{}{"a": 12, "b": []int{4}}}, + {[]any{"a", "b"}, map[string]any{"a": "b"}}, + {[]any{[]string{"a", "b"}, "c"}, map[string]any{"a": map[string]any{"b": "c"}}}, + { + []any{[]string{"a", "b"}, "c", []string{"a", "b2"}, "c2", "b", "c"}, + map[string]any{"a": map[string]any{"b": "c", "b2": "c2"}, "b": "c"}, + }, + {[]any{"a", 12, "b", []int{4}}, map[string]any{"a": 12, "b": []int{4}}}, // errors - {[]interface{}{5, "b"}, false}, - {[]interface{}{"a", "b", "c"}, false}, + {[]any{5, "b"}, false}, + {[]any{"a", "b", "c"}, false}, } { i := i test := test @@ -224,7 +217,7 @@ func TestDictionary(t *testing.T) { func TestReverse(t *testing.T) { t.Parallel() c := qt.New(t) - ns := New(&deps.Deps{}) + ns := newNs() s := []string{"a", "b", "c"} reversed, err := ns.Reverse(s) @@ -237,51 +230,18 @@ func TestReverse(t *testing.T) { c.Assert(reversed, qt.IsNil) _, err = ns.Reverse(43) c.Assert(err, qt.Not(qt.IsNil)) - -} - -func TestEchoParam(t *testing.T) { - t.Parallel() - c := qt.New(t) - - ns := New(&deps.Deps{}) - - for i, test := range []struct { - a interface{} - key interface{} - expect interface{} - }{ - {[]int{1, 2, 3}, 1, int64(2)}, - {[]uint{1, 2, 3}, 1, uint64(2)}, - {[]float64{1.1, 2.2, 3.3}, 1, float64(2.2)}, - {[]string{"foo", "bar", "baz"}, 1, "bar"}, - {[]TstX{{A: "a", B: "b"}, {A: "c", B: "d"}, {A: "e", B: "f"}}, 1, ""}, - {map[string]int{"foo": 1, "bar": 2, "baz": 3}, "bar", int64(2)}, - {map[string]uint{"foo": 1, "bar": 2, "baz": 3}, "bar", uint64(2)}, - {map[string]float64{"foo": 1.1, "bar": 2.2, "baz": 3.3}, "bar", float64(2.2)}, - {map[string]string{"foo": "FOO", "bar": "BAR", "baz": "BAZ"}, "bar", "BAR"}, - {map[string]TstX{"foo": {A: "a", B: "b"}, "bar": {A: "c", B: "d"}, "baz": {A: "e", B: "f"}}, "bar", ""}, - {map[string]interface{}{"foo": nil}, "foo", ""}, - {(*[]string)(nil), "bar", ""}, - } { - errMsg := qt.Commentf("[%d] %v", i, test) - - result := ns.EchoParam(test.a, test.key) - - c.Assert(result, qt.Equals, test.expect, errMsg) - } } func TestFirst(t *testing.T) { t.Parallel() c := qt.New(t) - ns := New(&deps.Deps{}) + ns := newNs() for i, test := range []struct { - limit interface{} - seq interface{} - expect interface{} + limit any + seq any + expect any }{ {int(2), []string{"a", "b", "c"}, []string{"a", "b"}}, {int32(3), []string{"a", "b"}, []string{"a", "b"}}, @@ -313,24 +273,23 @@ func TestFirst(t *testing.T) { func TestIn(t *testing.T) { t.Parallel() c := qt.New(t) - - ns := New(&deps.Deps{}) + ns := newNs() for i, test := range []struct { - l1 interface{} - l2 interface{} + l1 any + l2 any expect bool }{ {[]string{"a", "b", "c"}, "b", true}, - {[]interface{}{"a", "b", "c"}, "b", true}, - {[]interface{}{"a", "b", "c"}, "d", false}, + {[]any{"a", "b", "c"}, "b", true}, + {[]any{"a", "b", "c"}, "d", false}, {[]string{"a", "b", "c"}, "d", false}, {[]string{"a", "12", "c"}, 12, false}, {[]string{"a", "b", "c"}, nil, false}, {[]int{1, 2, 4}, 2, true}, - {[]interface{}{1, 2, 4}, 2, true}, - {[]interface{}{1, 2, 4}, nil, false}, - {[]interface{}{nil}, nil, false}, + {[]any{1, 2, 4}, 2, true}, + {[]any{1, 2, 4}, nil, false}, + {[]any{nil}, nil, false}, {[]int{1, 2, 4}, 3, false}, {[]float64{1.23, 2.45, 4.67}, 1.23, true}, {[]float64{1.234567, 2.45, 4.67}, 1.234568, false}, @@ -345,6 +304,12 @@ func TestIn(t *testing.T) { // Structs {pagesVals{p3v, p2v, p3v, p2v}, p2v, true}, {pagesVals{p3v, p2v, p3v, p2v}, p4v, false}, + // template.HTML + {template.HTML("this substring should be found"), "substring", true}, + {template.HTML("this substring should not be found"), "subseastring", false}, + // Uncomparable, use hashstructure + {[]string{"a", "b"}, []string{"a", "b"}, false}, + {[][]string{{"a", "b"}}, []string{"a", "b"}, true}, } { errMsg := qt.Commentf("[%d] %v", i, test) @@ -353,10 +318,6 @@ func TestIn(t *testing.T) { c.Assert(err, qt.IsNil) c.Assert(result, qt.Equals, test.expect, errMsg) } - - // Slices are not comparable - _, err := ns.In([]string{"a", "b"}, []string{"a", "b"}) - c.Assert(err, qt.Not(qt.IsNil)) } type testPage struct { @@ -367,8 +328,10 @@ func (p testPage) String() string { return "p-" + p.Title } -type pagesPtr []*testPage -type pagesVals []testPage +type ( + pagesPtr []*testPage + pagesVals []testPage +) var ( p1 = &testPage{"A"} @@ -386,19 +349,19 @@ func TestIntersect(t *testing.T) { t.Parallel() c := qt.New(t) - ns := New(&deps.Deps{}) + ns := newNs() for i, test := range []struct { - l1, l2 interface{} - expect interface{} + l1, l2 any + expect any }{ {[]string{"a", "b", "c", "c"}, []string{"a", "b", "b"}, []string{"a", "b"}}, {[]string{"a", "b"}, []string{"a", "b", "c"}, []string{"a", "b"}}, {[]string{"a", "b", "c"}, []string{"d", "e"}, []string{}}, {[]string{}, []string{}, []string{}}, - {[]string{"a", "b"}, nil, []interface{}{}}, - {nil, []string{"a", "b"}, []interface{}{}}, - {nil, nil, []interface{}{}}, + {[]string{"a", "b"}, nil, []any{}}, + {nil, []string{"a", "b"}, []any{}}, + {nil, nil, []any{}}, {[]string{"1", "2"}, []int{1, 2}, []string{}}, {[]int{1, 2}, []string{"1", "2"}, []int{}}, {[]int{1, 2, 4}, []int{2, 4}, []int{2, 4}}, @@ -407,45 +370,45 @@ func TestIntersect(t *testing.T) { {[]float64{2.2, 4.4}, []float64{1.1, 2.2, 4.4}, []float64{2.2, 4.4}}, // []interface{} ∩ []interface{} - {[]interface{}{"a", "b", "c"}, []interface{}{"a", "b", "b"}, []interface{}{"a", "b"}}, - {[]interface{}{1, 2, 3}, []interface{}{1, 2, 2}, []interface{}{1, 2}}, - {[]interface{}{int8(1), int8(2), int8(3)}, []interface{}{int8(1), int8(2), int8(2)}, []interface{}{int8(1), int8(2)}}, - {[]interface{}{int16(1), int16(2), int16(3)}, []interface{}{int16(1), int16(2), int16(2)}, []interface{}{int16(1), int16(2)}}, - {[]interface{}{int32(1), int32(2), int32(3)}, []interface{}{int32(1), int32(2), int32(2)}, []interface{}{int32(1), int32(2)}}, - {[]interface{}{int64(1), int64(2), int64(3)}, []interface{}{int64(1), int64(2), int64(2)}, []interface{}{int64(1), int64(2)}}, - {[]interface{}{float32(1), float32(2), float32(3)}, []interface{}{float32(1), float32(2), float32(2)}, []interface{}{float32(1), float32(2)}}, - {[]interface{}{float64(1), float64(2), float64(3)}, []interface{}{float64(1), float64(2), float64(2)}, []interface{}{float64(1), float64(2)}}, + {[]any{"a", "b", "c"}, []any{"a", "b", "b"}, []any{"a", "b"}}, + {[]any{1, 2, 3}, []any{1, 2, 2}, []any{1, 2}}, + {[]any{int8(1), int8(2), int8(3)}, []any{int8(1), int8(2), int8(2)}, []any{int8(1), int8(2)}}, + {[]any{int16(1), int16(2), int16(3)}, []any{int16(1), int16(2), int16(2)}, []any{int16(1), int16(2)}}, + {[]any{int32(1), int32(2), int32(3)}, []any{int32(1), int32(2), int32(2)}, []any{int32(1), int32(2)}}, + {[]any{int64(1), int64(2), int64(3)}, []any{int64(1), int64(2), int64(2)}, []any{int64(1), int64(2)}}, + {[]any{float32(1), float32(2), float32(3)}, []any{float32(1), float32(2), float32(2)}, []any{float32(1), float32(2)}}, + {[]any{float64(1), float64(2), float64(3)}, []any{float64(1), float64(2), float64(2)}, []any{float64(1), float64(2)}}, // []interface{} ∩ []T - {[]interface{}{"a", "b", "c"}, []string{"a", "b", "b"}, []interface{}{"a", "b"}}, - {[]interface{}{1, 2, 3}, []int{1, 2, 2}, []interface{}{1, 2}}, - {[]interface{}{int8(1), int8(2), int8(3)}, []int8{1, 2, 2}, []interface{}{int8(1), int8(2)}}, - {[]interface{}{int16(1), int16(2), int16(3)}, []int16{1, 2, 2}, []interface{}{int16(1), int16(2)}}, - {[]interface{}{int32(1), int32(2), int32(3)}, []int32{1, 2, 2}, []interface{}{int32(1), int32(2)}}, - {[]interface{}{int64(1), int64(2), int64(3)}, []int64{1, 2, 2}, []interface{}{int64(1), int64(2)}}, - {[]interface{}{uint(1), uint(2), uint(3)}, []uint{1, 2, 2}, []interface{}{uint(1), uint(2)}}, - {[]interface{}{float32(1), float32(2), float32(3)}, []float32{1, 2, 2}, []interface{}{float32(1), float32(2)}}, - {[]interface{}{float64(1), float64(2), float64(3)}, []float64{1, 2, 2}, []interface{}{float64(1), float64(2)}}, + {[]any{"a", "b", "c"}, []string{"a", "b", "b"}, []any{"a", "b"}}, + {[]any{1, 2, 3}, []int{1, 2, 2}, []any{1, 2}}, + {[]any{int8(1), int8(2), int8(3)}, []int8{1, 2, 2}, []any{int8(1), int8(2)}}, + {[]any{int16(1), int16(2), int16(3)}, []int16{1, 2, 2}, []any{int16(1), int16(2)}}, + {[]any{int32(1), int32(2), int32(3)}, []int32{1, 2, 2}, []any{int32(1), int32(2)}}, + {[]any{int64(1), int64(2), int64(3)}, []int64{1, 2, 2}, []any{int64(1), int64(2)}}, + {[]any{uint(1), uint(2), uint(3)}, []uint{1, 2, 2}, []any{uint(1), uint(2)}}, + {[]any{float32(1), float32(2), float32(3)}, []float32{1, 2, 2}, []any{float32(1), float32(2)}}, + {[]any{float64(1), float64(2), float64(3)}, []float64{1, 2, 2}, []any{float64(1), float64(2)}}, // []T ∩ []interface{} - {[]string{"a", "b", "c"}, []interface{}{"a", "b", "b"}, []string{"a", "b"}}, - {[]int{1, 2, 3}, []interface{}{1, 2, 2}, []int{1, 2}}, - {[]int8{1, 2, 3}, []interface{}{int8(1), int8(2), int8(2)}, []int8{1, 2}}, - {[]int16{1, 2, 3}, []interface{}{int16(1), int16(2), int16(2)}, []int16{1, 2}}, - {[]int32{1, 2, 3}, []interface{}{int32(1), int32(2), int32(2)}, []int32{1, 2}}, - {[]int64{1, 2, 3}, []interface{}{int64(1), int64(2), int64(2)}, []int64{1, 2}}, - {[]float32{1, 2, 3}, []interface{}{float32(1), float32(2), float32(2)}, []float32{1, 2}}, - {[]float64{1, 2, 3}, []interface{}{float64(1), float64(2), float64(2)}, []float64{1, 2}}, + {[]string{"a", "b", "c"}, []any{"a", "b", "b"}, []string{"a", "b"}}, + {[]int{1, 2, 3}, []any{1, 2, 2}, []int{1, 2}}, + {[]int8{1, 2, 3}, []any{int8(1), int8(2), int8(2)}, []int8{1, 2}}, + {[]int16{1, 2, 3}, []any{int16(1), int16(2), int16(2)}, []int16{1, 2}}, + {[]int32{1, 2, 3}, []any{int32(1), int32(2), int32(2)}, []int32{1, 2}}, + {[]int64{1, 2, 3}, []any{int64(1), int64(2), int64(2)}, []int64{1, 2}}, + {[]float32{1, 2, 3}, []any{float32(1), float32(2), float32(2)}, []float32{1, 2}}, + {[]float64{1, 2, 3}, []any{float64(1), float64(2), float64(2)}, []float64{1, 2}}, // Structs {pagesPtr{p1, p4, p2, p3}, pagesPtr{p4, p2, p2}, pagesPtr{p4, p2}}, {pagesVals{p1v, p4v, p2v, p3v}, pagesVals{p1v, p3v, p3v}, pagesVals{p1v, p3v}}, - {[]interface{}{p1, p4, p2, p3}, []interface{}{p4, p2, p2}, []interface{}{p4, p2}}, - {[]interface{}{p1v, p4v, p2v, p3v}, []interface{}{p1v, p3v, p3v}, []interface{}{p1v, p3v}}, + {[]any{p1, p4, p2, p3}, []any{p4, p2, p2}, []any{p4, p2}}, + {[]any{p1v, p4v, p2v, p3v}, []any{p1v, p3v, p3v}, []any{p1v, p3v}}, {pagesPtr{p1, p4, p2, p3}, pagesPtr{}, pagesPtr{}}, {pagesVals{}, pagesVals{p1v, p3v, p3v}, pagesVals{}}, - {[]interface{}{p1, p4, p2, p3}, []interface{}{}, []interface{}{}}, - {[]interface{}{}, []interface{}{p1v, p3v, p3v}, []interface{}{}}, + {[]any{p1, p4, p2, p3}, []any{}, []any{}}, + {[]any{}, []any{p1v, p3v, p3v}, []any{}}, // errors {"not array or slice", []string{"a"}, false}, @@ -476,26 +439,26 @@ func TestIntersect(t *testing.T) { func TestIsSet(t *testing.T) { t.Parallel() c := qt.New(t) - ns := newTestNs() + ns := newNs() for i, test := range []struct { - a interface{} - key interface{} + a any + key any expect bool isErr bool }{ - {[]interface{}{1, 2, 3, 5}, 2, true, false}, - {[]interface{}{1, 2, 3, 5}, "2", true, false}, - {[]interface{}{1, 2, 3, 5}, 2.0, true, false}, + {[]any{1, 2, 3, 5}, 2, true, false}, + {[]any{1, 2, 3, 5}, "2", true, false}, + {[]any{1, 2, 3, 5}, 2.0, true, false}, - {[]interface{}{1, 2, 3, 5}, 22, false, false}, + {[]any{1, 2, 3, 5}, 22, false, false}, - {map[string]interface{}{"a": 1, "b": 2}, "b", true, false}, - {map[string]interface{}{"a": 1, "b": 2}, "bc", false, false}, + {map[string]any{"a": 1, "b": 2}, "b", true, false}, + {map[string]any{"a": 1, "b": 2}, "bc", false, false}, {time.Now(), "Day", false, false}, {nil, "nil", false, false}, - {[]interface{}{1, 2, 3, 5}, TstX{}, false, true}, + {[]any{1, 2, 3, 5}, TstX{}, false, true}, } { errMsg := qt.Commentf("[%d] %v", i, test) @@ -513,12 +476,12 @@ func TestLast(t *testing.T) { t.Parallel() c := qt.New(t) - ns := New(&deps.Deps{}) + ns := newNs() for i, test := range []struct { - limit interface{} - seq interface{} - expect interface{} + limit any + seq any + expect any }{ {int(2), []string{"a", "b", "c"}, []string{"b", "c"}}, {int32(3), []string{"a", "b"}, []string{"a", "b"}}, @@ -549,63 +512,34 @@ func TestLast(t *testing.T) { } } -func TestQuerify(t *testing.T) { - t.Parallel() - c := qt.New(t) - ns := New(&deps.Deps{}) - - for i, test := range []struct { - params []interface{} - expect interface{} - }{ - {[]interface{}{"a", "b"}, "a=b"}, - {[]interface{}{"a", "b", "c", "d", "f", " &"}, `a=b&c=d&f=+%26`}, - // errors - {[]interface{}{5, "b"}, false}, - {[]interface{}{"a", "b", "c"}, false}, - } { - errMsg := qt.Commentf("[%d] %v", i, test.params) - - result, err := ns.Querify(test.params...) - - if b, ok := test.expect.(bool); ok && !b { - c.Assert(err, qt.Not(qt.IsNil), errMsg) - continue - } - - c.Assert(err, qt.IsNil, errMsg) - c.Assert(result, qt.Equals, test.expect, errMsg) - } -} - func TestSeq(t *testing.T) { t.Parallel() c := qt.New(t) - ns := New(&deps.Deps{}) + ns := newNs() for i, test := range []struct { - args []interface{} - expect interface{} + args []any + expect any }{ - {[]interface{}{-2, 5}, []int{-2, -1, 0, 1, 2, 3, 4, 5}}, - {[]interface{}{1, 2, 4}, []int{1, 3}}, - {[]interface{}{1}, []int{1}}, - {[]interface{}{3}, []int{1, 2, 3}}, - {[]interface{}{3.2}, []int{1, 2, 3}}, - {[]interface{}{0}, []int{}}, - {[]interface{}{-1}, []int{-1}}, - {[]interface{}{-3}, []int{-1, -2, -3}}, - {[]interface{}{3, -2}, []int{3, 2, 1, 0, -1, -2}}, - {[]interface{}{6, -2, 2}, []int{6, 4, 2}}, + {[]any{-2, 5}, []int{-2, -1, 0, 1, 2, 3, 4, 5}}, + {[]any{1, 2, 4}, []int{1, 3}}, + {[]any{1}, []int{1}}, + {[]any{3}, []int{1, 2, 3}}, + {[]any{3.2}, []int{1, 2, 3}}, + {[]any{0}, []int{}}, + {[]any{-1}, []int{-1}}, + {[]any{-3}, []int{-1, -2, -3}}, + {[]any{3, -2}, []int{3, 2, 1, 0, -1, -2}}, + {[]any{6, -2, 2}, []int{6, 4, 2}}, // errors - {[]interface{}{1, 0, 2}, false}, - {[]interface{}{1, -1, 2}, false}, - {[]interface{}{2, 1, 1}, false}, - {[]interface{}{2, 1, 1, 1}, false}, - {[]interface{}{2001}, false}, - {[]interface{}{}, false}, - {[]interface{}{0, -1000000}, false}, - {[]interface{}{tstNoStringer{}}, false}, + {[]any{1, 0, 2}, false}, + {[]any{1, -1, 2}, false}, + {[]any{2, 1, 1}, false}, + {[]any{2, 1, 1, 1}, false}, + {[]any{2001}, false}, + {[]any{}, false}, + {[]any{0, -1000000}, false}, + {[]any{tstNoStringer{}}, false}, {nil, false}, } { errMsg := qt.Commentf("[%d] %v", i, test) @@ -625,10 +559,10 @@ func TestSeq(t *testing.T) { func TestShuffle(t *testing.T) { t.Parallel() c := qt.New(t) - ns := New(&deps.Deps{}) + ns := newNs() for i, test := range []struct { - seq interface{} + seq any success bool }{ {[]string{"a", "b", "c", "d"}, true}, @@ -665,13 +599,12 @@ func TestShuffle(t *testing.T) { func TestShuffleRandomising(t *testing.T) { t.Parallel() c := qt.New(t) - ns := New(&deps.Deps{}) + ns := newNs() // Note that this test can fail with false negative result if the shuffle // of the sequence happens to be the same as the original sequence. However - // the propability of the event is 10^-158 which is negligible. + // the probability of the event is 10^-158 which is negligible. seqLen := 100 - rand.Seed(time.Now().UTC().UnixNano()) for _, test := range []struct { seq []int @@ -696,17 +629,17 @@ func TestShuffleRandomising(t *testing.T) { func TestSlice(t *testing.T) { t.Parallel() c := qt.New(t) - ns := New(&deps.Deps{}) + ns := newNs() for i, test := range []struct { - args []interface{} - expected interface{} + args []any + expected any }{ - {[]interface{}{"a", "b"}, []string{"a", "b"}}, - {[]interface{}{}, []interface{}{}}, - {[]interface{}{nil}, []interface{}{nil}}, - {[]interface{}{5, "b"}, []interface{}{5, "b"}}, - {[]interface{}{tstNoStringer{}}, []tstNoStringer{{}}}, + {[]any{"a", "b"}, []string{"a", "b"}}, + {[]any{}, []any{}}, + {[]any{nil}, []any{nil}}, + {[]any{5, "b"}, []any{5, "b"}}, + {[]any{tstNoStringer{}}, []tstNoStringer{{}}}, } { errMsg := qt.Commentf("[%d] %v", i, test.args) @@ -714,22 +647,21 @@ func TestSlice(t *testing.T) { c.Assert(result, qt.DeepEquals, test.expected, errMsg) } - } func TestUnion(t *testing.T) { t.Parallel() c := qt.New(t) - ns := New(&deps.Deps{}) + ns := newNs() for i, test := range []struct { - l1 interface{} - l2 interface{} - expect interface{} + l1 any + l2 any + expect any isErr bool }{ - {nil, nil, []interface{}{}, false}, + {nil, nil, []any{}, false}, {nil, []string{"a", "b"}, []string{"a", "b"}, false}, {[]string{"a", "b"}, nil, []string{"a", "b"}, false}, @@ -748,36 +680,36 @@ func TestUnion(t *testing.T) { {[]int{2, 4}, []int{1, 2, 4}, []int{2, 4, 1}, false}, {[]int{1, 2, 4}, []int{3, 6}, []int{1, 2, 4, 3, 6}, false}, {[]float64{2.2, 4.4}, []float64{1.1, 2.2, 4.4}, []float64{2.2, 4.4, 1.1}, false}, - {[]interface{}{"a", "b", "c", "c"}, []interface{}{"a", "b", "b"}, []interface{}{"a", "b", "c"}, false}, + {[]any{"a", "b", "c", "c"}, []any{"a", "b", "b"}, []any{"a", "b", "c"}, false}, // []T ∪ []interface{} - {[]string{"1", "2"}, []interface{}{"9"}, []string{"1", "2", "9"}, false}, - {[]int{2, 4}, []interface{}{1, 2, 4}, []int{2, 4, 1}, false}, - {[]int8{2, 4}, []interface{}{int8(1), int8(2), int8(4)}, []int8{2, 4, 1}, false}, - {[]int8{2, 4}, []interface{}{1, 2, 4}, []int8{2, 4, 1}, false}, - {[]int16{2, 4}, []interface{}{1, 2, 4}, []int16{2, 4, 1}, false}, - {[]int32{2, 4}, []interface{}{1, 2, 4}, []int32{2, 4, 1}, false}, - {[]int64{2, 4}, []interface{}{1, 2, 4}, []int64{2, 4, 1}, false}, + {[]string{"1", "2"}, []any{"9"}, []string{"1", "2", "9"}, false}, + {[]int{2, 4}, []any{1, 2, 4}, []int{2, 4, 1}, false}, + {[]int8{2, 4}, []any{int8(1), int8(2), int8(4)}, []int8{2, 4, 1}, false}, + {[]int8{2, 4}, []any{1, 2, 4}, []int8{2, 4, 1}, false}, + {[]int16{2, 4}, []any{1, 2, 4}, []int16{2, 4, 1}, false}, + {[]int32{2, 4}, []any{1, 2, 4}, []int32{2, 4, 1}, false}, + {[]int64{2, 4}, []any{1, 2, 4}, []int64{2, 4, 1}, false}, - {[]float64{2.2, 4.4}, []interface{}{1.1, 2.2, 4.4}, []float64{2.2, 4.4, 1.1}, false}, - {[]float32{2.2, 4.4}, []interface{}{1.1, 2.2, 4.4}, []float32{2.2, 4.4, 1.1}, false}, + {[]float64{2.2, 4.4}, []any{1.1, 2.2, 4.4}, []float64{2.2, 4.4, 1.1}, false}, + {[]float32{2.2, 4.4}, []any{1.1, 2.2, 4.4}, []float32{2.2, 4.4, 1.1}, false}, // []interface{} ∪ []T - {[]interface{}{"a", "b", "c", "c"}, []string{"a", "b", "d"}, []interface{}{"a", "b", "c", "d"}, false}, - {[]interface{}{}, []string{}, []interface{}{}, false}, - {[]interface{}{1, 2}, []int{2, 3}, []interface{}{1, 2, 3}, false}, - {[]interface{}{1, 2}, []int8{2, 3}, []interface{}{1, 2, 3}, false}, // 28 - {[]interface{}{uint(1), uint(2)}, []uint{2, 3}, []interface{}{uint(1), uint(2), uint(3)}, false}, - {[]interface{}{1.1, 2.2}, []float64{2.2, 3.3}, []interface{}{1.1, 2.2, 3.3}, false}, + {[]any{"a", "b", "c", "c"}, []string{"a", "b", "d"}, []any{"a", "b", "c", "d"}, false}, + {[]any{}, []string{}, []any{}, false}, + {[]any{1, 2}, []int{2, 3}, []any{1, 2, 3}, false}, + {[]any{1, 2}, []int8{2, 3}, []any{1, 2, 3}, false}, // 28 + {[]any{uint(1), uint(2)}, []uint{2, 3}, []any{uint(1), uint(2), uint(3)}, false}, + {[]any{1.1, 2.2}, []float64{2.2, 3.3}, []any{1.1, 2.2, 3.3}, false}, // Structs {pagesPtr{p1, p4}, pagesPtr{p4, p2, p2}, pagesPtr{p1, p4, p2}, false}, {pagesVals{p1v}, pagesVals{p3v, p3v}, pagesVals{p1v, p3v}, false}, - {[]interface{}{p1, p4}, []interface{}{p4, p2, p2}, []interface{}{p1, p4, p2}, false}, - {[]interface{}{p1v}, []interface{}{p3v, p3v}, []interface{}{p1v, p3v}, false}, + {[]any{p1, p4}, []any{p4, p2, p2}, []any{p1, p4, p2}, false}, + {[]any{p1v}, []any{p3v, p3v}, []any{p1v, p3v}, false}, // #3686 - {[]interface{}{p1v}, []interface{}{}, []interface{}{p1v}, false}, - {[]interface{}{}, []interface{}{p1v}, []interface{}{p1v}, false}, + {[]any{p1v}, []any{}, []any{p1v}, false}, + {[]any{}, []any{p1v}, []any{p1v}, false}, {pagesPtr{p1}, pagesPtr{}, pagesPtr{p1}, false}, {pagesVals{p1v}, pagesVals{}, pagesVals{p1v}, false}, {pagesPtr{}, pagesPtr{p1}, pagesPtr{p1}, false}, @@ -810,10 +742,10 @@ func TestUnion(t *testing.T) { func TestUniq(t *testing.T) { t.Parallel() c := qt.New(t) - ns := New(&deps.Deps{}) + ns := newNs() for i, test := range []struct { - l interface{} - expect interface{} + l any + expect any isErr bool }{ {[]string{"a", "b", "c"}, []string{"a", "b", "c"}, false}, @@ -825,16 +757,21 @@ func TestUniq(t *testing.T) { {[]int{1, 2, 2, 3}, []int{1, 2, 3}, false}, {[]int{1, 2, 3, 2}, []int{1, 2, 3}, false}, {[4]int{1, 2, 3, 2}, []int{1, 2, 3}, false}, - {nil, make([]interface{}, 0), false}, + {nil, make([]any, 0), false}, // Pointers {pagesPtr{p1, p2, p3, p2}, pagesPtr{p1, p2, p3}, false}, {pagesPtr{}, pagesPtr{}, false}, // Structs {pagesVals{p3v, p2v, p3v, p2v}, pagesVals{p3v, p2v}, false}, + // not Comparable(), use hashstructure + {[]map[string]int{ + {"K1": 1}, {"K2": 2}, {"K1": 1}, {"K2": 1}, + }, []map[string]int{ + {"K1": 1}, {"K2": 2}, {"K2": 1}, + }, false}, + // should fail - // uncomparable types - {[]map[string]int{{"K1": 1}}, []map[string]int{{"K2": 2}, {"K2": 2}}, true}, {1, 1, true}, {"foo", "fo", true}, } { @@ -863,6 +800,7 @@ func (x TstX) TstRv2() string { return "r" + x.B } +//lint:ignore U1000 reflect test func (x TstX) unexportedMethod() string { return x.unexported } @@ -891,7 +829,7 @@ func (x TstX) String() string { type TstX struct { A, B string - unexported string + unexported string //lint:ignore U1000 reflect test } type TstParams struct { @@ -900,7 +838,6 @@ type TstParams struct { func (x TstParams) Params() maps.Params { return x.params - } type TstXIHolder struct { @@ -912,14 +849,14 @@ type TstXI interface { TstRv2() string } -func ToTstXIs(slice interface{}) []TstXI { +func ToTstXIs(slice any) []TstXI { s := reflect.ValueOf(slice) if s.Kind() != reflect.Slice { return nil } tis := make([]TstXI, s.Len()) - for i := 0; i < s.Len(); i++ { + for i := range s.Len() { tsti, ok := s.Index(i).Interface().(TstXI) if !ok { return nil @@ -930,23 +867,6 @@ func ToTstXIs(slice interface{}) []TstXI { return tis } -func newDeps(cfg config.Provider) *deps.Deps { - l := langs.NewLanguage("en", cfg) - l.Set("i18nDir", "i18n") - cs, err := helpers.NewContentSpec(l, loggers.NewErrorLogger(), afero.NewMemMapFs()) - if err != nil { - panic(err) - } - return &deps.Deps{ - Cfg: cfg, - Fs: hugofs.NewMem(l), - ContentSpec: cs, - Log: loggers.NewErrorLogger(), - } -} - -func newTestNs() *Namespace { - v := viper.New() - v.Set("contentDir", "content") - return New(newDeps(v)) +func newNs() *Namespace { + return New(testconfig.GetTestDeps(nil, nil)) } diff --git a/tpl/collections/complement.go b/tpl/collections/complement.go index a5633f8b4..606d77dde 100644 --- a/tpl/collections/complement.go +++ b/tpl/collections/complement.go @@ -19,19 +19,21 @@ import ( "reflect" ) -// Complement gives the elements in the last element of seqs that are not in +// Complement gives the elements in the last element of ls that are not in // any of the others. -// All elements of seqs must be slices or arrays of comparable types. +// +// All elements of ls must be slices or arrays of comparable types. // // The reasoning behind this rather clumsy API is so we can do this in the templates: -// {{ $c := .Pages | complement $last4 }} -func (ns *Namespace) Complement(seqs ...interface{}) (interface{}, error) { - if len(seqs) < 2 { +// +// {{ $c := .Pages | complement $last4 }} +func (ns *Namespace) Complement(ls ...any) (any, error) { + if len(ls) < 2 { return nil, errors.New("complement needs at least two arguments") } - universe := seqs[len(seqs)-1] - as := seqs[:len(seqs)-1] + universe := ls[len(ls)-1] + as := ls[:len(ls)-1] aset, err := collectIdentities(as...) if err != nil { @@ -42,11 +44,8 @@ func (ns *Namespace) Complement(seqs ...interface{}) (interface{}, error) { switch v.Kind() { case reflect.Array, reflect.Slice: sl := reflect.MakeSlice(v.Type(), 0, 0) - for i := 0; i < v.Len(); i++ { + for i := range v.Len() { ev, _ := indirectInterface(v.Index(i)) - if !ev.Type().Comparable() { - return nil, errors.New("elements in complement must be comparable") - } if _, found := aset[normalize(ev)]; !found { sl = reflect.Append(sl, ev) } diff --git a/tpl/collections/complement_test.go b/tpl/collections/complement_test.go index abe572b6e..761a2451c 100644 --- a/tpl/collections/complement_test.go +++ b/tpl/collections/complement_test.go @@ -17,8 +17,6 @@ import ( "reflect" "testing" - "github.com/gohugoio/hugo/deps" - qt "github.com/frankban/quicktest" ) @@ -34,7 +32,7 @@ func TestComplement(t *testing.T) { c := qt.New(t) - ns := New(&deps.Deps{}) + ns := newNs() s1 := []TstX{{A: "a"}, {A: "b"}, {A: "d"}, {A: "e"}} s2 := []TstX{{A: "b"}, {A: "e"}} @@ -48,24 +46,28 @@ func TestComplement(t *testing.T) { sp2_2 := StructWithSlicePointers{xb, xe} for i, test := range []struct { - s interface{} - t []interface{} - expected interface{} + s any + t []any + expected any }{ - {[]string{"a", "b", "c"}, []interface{}{[]string{"c", "d"}}, []string{"a", "b"}}, - {[]string{"a", "b", "c"}, []interface{}{[]string{"c", "d"}, []string{"a", "b"}}, []string{}}, - {[]interface{}{"a", "b", nil}, []interface{}{[]string{"a", "d"}}, []interface{}{"b", nil}}, - {[]int{1, 2, 3, 4, 5}, []interface{}{[]int{1, 3}, []string{"a", "b"}, []int{1, 2}}, []int{4, 5}}, - {[]int{1, 2, 3, 4, 5}, []interface{}{[]int64{1, 3}}, []int{2, 4, 5}}, - {s1, []interface{}{s2}, []TstX{{A: "a"}, {A: "d"}}}, - {sp1, []interface{}{sp2}, []*StructWithSlice{xa, xd}}, - {sp1_2, []interface{}{sp2_2}, StructWithSlicePointers{xa, xd}}, + {[]string{"a", "b", "c"}, []any{[]string{"c", "d"}}, []string{"a", "b"}}, + {[]string{"a", "b", "c"}, []any{[]string{"c", "d"}, []string{"a", "b"}}, []string{}}, + {[]any{"a", "b", nil}, []any{[]string{"a", "d"}}, []any{"b", nil}}, + {[]int{1, 2, 3, 4, 5}, []any{[]int{1, 3}, []string{"a", "b"}, []int{1, 2}}, []int{4, 5}}, + {[]int{1, 2, 3, 4, 5}, []any{[]int64{1, 3}}, []int{2, 4, 5}}, + {s1, []any{s2}, []TstX{{A: "a"}, {A: "d"}}}, + {sp1, []any{sp2}, []*StructWithSlice{xa, xd}}, + {sp1_2, []any{sp2_2}, StructWithSlicePointers{xa, xd}}, // Errors - {[]string{"a", "b", "c"}, []interface{}{"error"}, false}, - {"error", []interface{}{[]string{"c", "d"}, []string{"a", "b"}}, false}, - {[]string{"a", "b", "c"}, []interface{}{[][]string{{"c", "d"}}}, false}, - {[]interface{}{[][]string{{"c", "d"}}}, []interface{}{[]string{"c", "d"}, []string{"a", "b"}}, false}, + {[]string{"a", "b", "c"}, []any{"error"}, false}, + {"error", []any{[]string{"c", "d"}, []string{"a", "b"}}, false}, + {[]string{"a", "b", "c"}, []any{[][]string{{"c", "d"}}}, false}, + { + []any{[][]string{{"c", "d"}}}, + []any{[]string{"c", "d"}, []string{"a", "b"}}, + []any{[][]string{{"c", "d"}}}, + }, } { errMsg := qt.Commentf("[%d]", i) @@ -90,5 +92,4 @@ func TestComplement(t *testing.T) { c.Assert(err, qt.Not(qt.IsNil)) _, err = ns.Complement([]string{"a", "b"}) c.Assert(err, qt.Not(qt.IsNil)) - } diff --git a/tpl/collections/index.go b/tpl/collections/index.go index cd1d1577b..a319ea298 100644 --- a/tpl/collections/index.go +++ b/tpl/collections/index.go @@ -27,42 +27,53 @@ import ( // arguments. Thus "index x 1 2 3" is, in Go syntax, x[1][2][3]. Each // indexed item must be a map, slice, or array. // -// Copied from Go stdlib src/text/template/funcs.go. +// Adapted from Go stdlib src/text/template/funcs.go. // -// We deviate from the stdlib due to https://github.com/golang/go/issues/14751. -// -// TODO(moorereason): merge upstream changes. -func (ns *Namespace) Index(item interface{}, args ...interface{}) (interface{}, error) { +// We deviate from the stdlib mostly because of https://github.com/golang/go/issues/14751. +func (ns *Namespace) Index(item any, args ...any) (any, error) { + v, err := ns.doIndex(item, args...) + if err != nil { + return nil, fmt.Errorf("index of type %T with args %v failed: %s", item, args, err) + } + return v, nil +} + +func (ns *Namespace) doIndex(item any, args ...any) (any, error) { + // TODO(moorereason): merge upstream changes. v := reflect.ValueOf(item) if !v.IsValid() { - return nil, errors.New("index of untyped nil") + // See issue 10489 + // This used to be an error. + return nil, nil } - lowerm, ok := item.(maps.Params) - if ok { - return lowerm.Get(cast.ToStringSlice(args)...), nil - } - - var indices []interface{} + var indices []any if len(args) == 1 { v := reflect.ValueOf(args[0]) if v.Kind() == reflect.Slice { - for i := 0; i < v.Len(); i++ { + for i := range v.Len() { indices = append(indices, v.Index(i).Interface()) } + } else { + indices = append(indices, args[0]) } + } else { + indices = args } - if indices == nil { - indices = args + lowerm, ok := item.(maps.Params) + if ok { + return lowerm.GetNested(cast.ToStringSlice(indices)...), nil } for _, i := range indices { index := reflect.ValueOf(i) var isNil bool if v, isNil = indirect(v); isNil { - return nil, errors.New("index of nil pointer") + // See issue 10489 + // This used to be an error. + return nil, nil } switch v.Kind() { case reflect.Array, reflect.Slice, reflect.String: diff --git a/tpl/collections/index_test.go b/tpl/collections/index_test.go index 0c380d8d5..0c5a58756 100644 --- a/tpl/collections/index_test.go +++ b/tpl/collections/index_test.go @@ -20,40 +20,50 @@ import ( "github.com/gohugoio/hugo/common/maps" qt "github.com/frankban/quicktest" - "github.com/gohugoio/hugo/deps" ) func TestIndex(t *testing.T) { t.Parallel() c := qt.New(t) - ns := New(&deps.Deps{}) + ns := newNs() + + var ( + emptyInterface any + nilPointer *int + ) for i, test := range []struct { - item interface{} - indices []interface{} - expect interface{} + item any + indices []any + expect any isErr bool }{ - {[]int{0, 1}, []interface{}{0}, 0, false}, - {[]int{0, 1}, []interface{}{9}, nil, false}, // index out of range + {[]int{0, 1}, []any{0}, 0, false}, + {[]int{0, 1}, []any{9}, nil, false}, // index out of range {[]uint{0, 1}, nil, []uint{0, 1}, false}, - {[][]int{{1, 2}, {3, 4}}, []interface{}{0, 0}, 1, false}, - {map[int]int{1: 10, 2: 20}, []interface{}{1}, 10, false}, - {map[int]int{1: 10, 2: 20}, []interface{}{0}, 0, false}, - {map[string]map[string]string{"a": {"b": "c"}}, []interface{}{"a", "b"}, "c", false}, - {[]map[string]map[string]string{{"a": {"b": "c"}}}, []interface{}{0, "a", "b"}, "c", false}, - {map[string]map[string]interface{}{"a": {"b": []string{"c", "d"}}}, []interface{}{"a", "b", 1}, "d", false}, - {map[string]map[string]string{"a": {"b": "c"}}, []interface{}{[]string{"a", "b"}}, "c", false}, - {maps.Params{"a": "av"}, []interface{}{"A"}, "av", false}, - {maps.Params{"a": map[string]interface{}{"b": "bv"}}, []interface{}{"A", "B"}, "bv", false}, + {[][]int{{1, 2}, {3, 4}}, []any{0, 0}, 1, false}, + {map[int]int{1: 10, 2: 20}, []any{1}, 10, false}, + {map[int]int{1: 10, 2: 20}, []any{0}, 0, false}, + {map[string]map[string]string{"a": {"b": "c"}}, []any{"a", "b"}, "c", false}, + {[]map[string]map[string]string{{"a": {"b": "c"}}}, []any{0, "a", "b"}, "c", false}, + {map[string]map[string]any{"a": {"b": []string{"c", "d"}}}, []any{"a", "b", 1}, "d", false}, + {maps.Params{"a": "av"}, []any{"A"}, "av", false}, + {maps.Params{"a": map[string]any{"b": "bv"}}, []any{"A", "B"}, "bv", false}, + + // These used to be errors. + // See issue 10489. + {nil, nil, nil, false}, + {nil, []any{0}, nil, false}, + {emptyInterface, []any{0}, nil, false}, + {nilPointer, []any{0}, nil, false}, + // errors - {nil, nil, nil, true}, - {[]int{0, 1}, []interface{}{"1"}, nil, true}, - {[]int{0, 1}, []interface{}{nil}, nil, true}, - {tstNoStringer{}, []interface{}{0}, nil, true}, + {[]int{0, 1}, []any{"1"}, nil, true}, + {[]int{0, 1}, []any{nil}, nil, true}, + {tstNoStringer{}, []any{0}, nil, true}, } { - c.Run(fmt.Sprint(i), func(c *qt.C) { + c.Run(fmt.Sprintf("vararg %d", i), func(c *qt.C) { errMsg := qt.Commentf("[%d] %v", i, test) result, err := ns.Index(test.item, test.indices...) @@ -65,5 +75,18 @@ func TestIndex(t *testing.T) { c.Assert(err, qt.IsNil, errMsg) c.Assert(result, qt.DeepEquals, test.expect, errMsg) }) + + c.Run(fmt.Sprintf("slice %d", i), func(c *qt.C) { + errMsg := qt.Commentf("[%d] %v", i, test) + + result, err := ns.Index(test.item, test.indices) + + if test.isErr { + c.Assert(err, qt.Not(qt.IsNil), errMsg) + return + } + c.Assert(err, qt.IsNil, errMsg) + c.Assert(result, qt.DeepEquals, test.expect, errMsg) + }) } } diff --git a/tpl/collections/init.go b/tpl/collections/init.go index 17ca16543..f89651326 100644 --- a/tpl/collections/init.go +++ b/tpl/collections/init.go @@ -14,6 +14,8 @@ package collections import ( + "context" + "github.com/gohugoio/hugo/deps" "github.com/gohugoio/hugo/tpl/internal" ) @@ -26,7 +28,7 @@ func init() { ns := &internal.TemplateFuncsNamespace{ Name: name, - Context: func(args ...interface{}) interface{} { return ctx }, + Context: func(cctx context.Context, args ...any) (any, error) { return ctx, nil }, } ns.AddMethodMapping(ctx.After, @@ -42,7 +44,7 @@ func init() { ns.AddMethodMapping(ctx.Complement, []string{"complement"}, [][2]string{ - {`{{ slice "a" "b" "c" "d" "e" "f" | complement (slice "b" "c") (slice "d" "e") }}`, `[a f]`}, + {`{{ slice "a" "b" "c" "d" "e" "f" | complement (slice "b" "c") (slice "d" "e") }}`, `[a f]`}, }, ) @@ -65,13 +67,6 @@ func init() { [][2]string{}, ) - ns.AddMethodMapping(ctx.EchoParam, - []string{"echoParam"}, - [][2]string{ - {`{{ echoParam .Params "langCode" }}`, `en`}, - }, - ) - ns.AddMethodMapping(ctx.First, []string{"first"}, [][2]string{}, @@ -116,10 +111,16 @@ func init() { [][2]string{ { `{{ (querify "foo" 1 "bar" 2 "baz" "with spaces" "qux" "this&that=those") | safeHTML }}`, - `bar=2&baz=with+spaces&foo=1&qux=this%26that%3Dthose`}, + `bar=2&baz=with+spaces&foo=1&qux=this%26that%3Dthose`, + }, { `Search`, - `Search`}, + `Search`, + }, + { + `{{ slice "foo" 1 "bar" 2 | querify | safeHTML }}`, + `bar=2&foo=1`, + }, }, ) @@ -186,15 +187,22 @@ func init() { ns.AddMethodMapping(ctx.Merge, []string{"merge"}, [][2]string{ - {`{{ dict "title" "Hugo Rocks!" | collections.Merge (dict "title" "Default Title" "description" "Yes, Hugo Rocks!") | sort }}`, - `[Yes, Hugo Rocks! Hugo Rocks!]`}, - {`{{ merge (dict "title" "Default Title" "description" "Yes, Hugo Rocks!") (dict "title" "Hugo Rocks!") | sort }}`, - `[Yes, Hugo Rocks! Hugo Rocks!]`}, + { + `{{ dict "title" "Hugo Rocks!" | collections.Merge (dict "title" "Default Title" "description" "Yes, Hugo Rocks!") | sort }}`, + `[Yes, Hugo Rocks! Hugo Rocks!]`, + }, + { + `{{ merge (dict "title" "Default Title" "description" "Yes, Hugo Rocks!") (dict "title" "Hugo Rocks!") | sort }}`, + `[Yes, Hugo Rocks! Hugo Rocks!]`, + }, + { + `{{ merge (dict "title" "Default Title" "description" "Yes, Hugo Rocks!") (dict "title" "Hugo Rocks!") (dict "extra" "For reals!") | sort }}`, + `[Yes, Hugo Rocks! For reals! Hugo Rocks!]`, + }, }, ) return ns - } internal.AddTemplateFuncsNamespace(f) diff --git a/tpl/collections/init_test.go b/tpl/collections/init_test.go deleted file mode 100644 index 3a3b2070f..000000000 --- a/tpl/collections/init_test.go +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright 2017 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 collections - -import ( - "testing" - - "github.com/gohugoio/hugo/htesting/hqt" - - qt "github.com/frankban/quicktest" - "github.com/gohugoio/hugo/deps" - "github.com/gohugoio/hugo/tpl/internal" -) - -func TestInit(t *testing.T) { - c := qt.New(t) - var found bool - var ns *internal.TemplateFuncsNamespace - - for _, nsf := range internal.TemplateFuncsNamespaceRegistry { - ns = nsf(&deps.Deps{}) - if ns.Name == name { - found = true - break - } - } - - c.Assert(found, qt.Equals, true) - c.Assert(ns.Context(), hqt.IsSameType, &Namespace{}) -} diff --git a/tpl/collections/merge.go b/tpl/collections/merge.go index c65e9dd90..b9696b502 100644 --- a/tpl/collections/merge.go +++ b/tpl/collections/merge.go @@ -14,24 +14,43 @@ package collections import ( + "errors" + "fmt" "reflect" "strings" - "github.com/gohugoio/hugo/common/maps" - "github.com/gohugoio/hugo/common/hreflect" - - "github.com/pkg/errors" + "github.com/gohugoio/hugo/common/maps" ) -// Merge creates a copy of dst and merges src into it. -// Currently only maps supported. Key handling is case insensitive. -func (ns *Namespace) Merge(src, dst interface{}) (interface{}, error) { +// Merge creates a copy of the final parameter in params and merges the preceding +// parameters into it in reverse order. +// +// Currently only maps are supported. Key handling is case insensitive. +func (ns *Namespace) Merge(params ...any) (any, error) { + if len(params) < 2 { + return nil, errors.New("merge requires at least two parameters") + } + var err error + result := params[len(params)-1] + + for i := len(params) - 2; i >= 0; i-- { + result, err = ns.merge(params[i], result) + if err != nil { + return nil, err + } + } + + return result, nil +} + +// merge creates a copy of dst and merges src into it. +func (ns *Namespace) merge(src, dst any) (any, error) { vdst, vsrc := reflect.ValueOf(dst), reflect.ValueOf(src) if vdst.Kind() != reflect.Map { - return nil, errors.Errorf("destination must be a map, got %T", dst) + return nil, fmt.Errorf("destination must be a map, got %T", dst) } if !hreflect.IsTruthfulValue(vsrc) { @@ -39,11 +58,11 @@ func (ns *Namespace) Merge(src, dst interface{}) (interface{}, error) { } if vsrc.Kind() != reflect.Map { - return nil, errors.Errorf("source must be a map, got %T", src) + return nil, fmt.Errorf("source must be a map, got %T", src) } if vsrc.Type().Key() != vdst.Type().Key() { - return nil, errors.Errorf("incompatible map types, got %T to %T", src, dst) + return nil, fmt.Errorf("incompatible map types, got %T to %T", src, dst) } return mergeMap(vdst, vsrc).Interface(), nil @@ -56,49 +75,67 @@ func caseInsensitiveLookup(m, k reflect.Value) (reflect.Value, bool) { return v, hreflect.IsTruthfulValue(v) } - for _, key := range m.MapKeys() { - if strings.EqualFold(k.String(), key.String()) { - return m.MapIndex(key), true - } + k2 := reflect.New(m.Type().Key()).Elem() + iter := m.MapRange() + for iter.Next() { + k2.SetIterKey(iter) + if strings.EqualFold(k.String(), k2.String()) { + return iter.Value(), true + } } return reflect.Value{}, false } func mergeMap(dst, src reflect.Value) reflect.Value { - out := reflect.MakeMap(dst.Type()) // If the destination is Params, we must lower case all keys. _, lowerCase := dst.Interface().(maps.Params) + k := reflect.New(dst.Type().Key()).Elem() + v := reflect.New(dst.Type().Elem()).Elem() + // Copy the destination map. - for _, key := range dst.MapKeys() { - v := dst.MapIndex(key) - out.SetMapIndex(key, v) + iter := dst.MapRange() + for iter.Next() { + k.SetIterKey(iter) + v.SetIterValue(iter) + out.SetMapIndex(k, v) } // Add all keys in src not already in destination. // Maps of the same type will be merged. - for _, key := range src.MapKeys() { - sv := src.MapIndex(key) - dv, found := caseInsensitiveLookup(dst, key) + k = reflect.New(src.Type().Key()).Elem() + sv := reflect.New(src.Type().Elem()).Elem() + + iter = src.MapRange() + for iter.Next() { + sv.SetIterValue(iter) + k.SetIterKey(iter) + + dv, found := caseInsensitiveLookup(dst, k) if found { // If both are the same map key type, merge. dve := dv.Elem() if dve.Kind() == reflect.Map { sve := sv.Elem() + if sve.Kind() != reflect.Map { + continue + } + if dve.Type().Key() == sve.Type().Key() { - out.SetMapIndex(key, mergeMap(dve, sve)) + out.SetMapIndex(k, mergeMap(dve, sve)) } } } else { - if lowerCase && key.Kind() == reflect.String { - key = reflect.ValueOf(strings.ToLower(key.String())) + kk := k + if lowerCase && k.Kind() == reflect.String { + kk = reflect.ValueOf(strings.ToLower(k.String())) } - out.SetMapIndex(key, sv) + out.SetMapIndex(kk, sv) } } diff --git a/tpl/collections/merge_test.go b/tpl/collections/merge_test.go index c18664e25..a8ef0afea 100644 --- a/tpl/collections/merge_test.go +++ b/tpl/collections/merge_test.go @@ -15,85 +15,130 @@ package collections import ( "bytes" - "fmt" "reflect" - "runtime" - "strings" "testing" "github.com/gohugoio/hugo/common/maps" - "github.com/gohugoio/hugo/parser" - "github.com/gohugoio/hugo/parser/metadecoders" qt "github.com/frankban/quicktest" - "github.com/gohugoio/hugo/deps" ) func TestMerge(t *testing.T) { + ns := newNs() - ns := New(&deps.Deps{}) - - simpleMap := map[string]interface{}{"a": 1, "b": 2} + simpleMap := map[string]any{"a": 1, "b": 2} for i, test := range []struct { name string - dst interface{} - src interface{} - expect interface{} + params []any + expect any isErr bool }{ { "basic", - map[string]interface{}{"a": 1, "b": 2}, - map[string]interface{}{"a": 42, "c": 3}, - map[string]interface{}{"a": 1, "b": 2, "c": 3}, false}, + []any{ + map[string]any{"a": 42, "c": 3}, + map[string]any{"a": 1, "b": 2}, + }, + map[string]any{"a": 1, "b": 2, "c": 3}, + false, + }, + { + "multi", + []any{ + map[string]any{"a": 42, "c": 3, "e": 11}, + map[string]any{"a": 1, "b": 2}, + map[string]any{"a": 9, "c": 4, "d": 7}, + }, + map[string]any{"a": 9, "b": 2, "c": 4, "d": 7, "e": 11}, + false, + }, { "basic case insensitive", - map[string]interface{}{"a": 1, "b": 2}, - map[string]interface{}{"A": 42, "c": 3}, - map[string]interface{}{"a": 1, "b": 2, "c": 3}, false}, + []any{ + map[string]any{"A": 42, "c": 3}, + map[string]any{"a": 1, "b": 2}, + }, + map[string]any{"a": 1, "b": 2, "c": 3}, + false, + }, { "nested", - map[string]interface{}{"a": 1, "b": map[string]interface{}{"d": 1, "e": 2}}, - map[string]interface{}{"a": 42, "c": 3, "b": map[string]interface{}{"d": 55, "e": 66, "f": 3}}, - map[string]interface{}{"a": 1, "b": map[string]interface{}{"d": 1, "e": 2, "f": 3}, "c": 3}, false}, + []any{ + map[string]any{"a": 42, "c": 3, "b": map[string]any{"d": 55, "e": 66, "f": 3}}, + map[string]any{"a": 1, "b": map[string]any{"d": 1, "e": 2}}, + }, + map[string]any{"a": 1, "b": map[string]any{"d": 1, "e": 2, "f": 3}, "c": 3}, + false, + }, { // https://github.com/gohugoio/hugo/issues/6633 "params dst", - maps.Params{"a": 1, "b": 2}, - map[string]interface{}{"a": 42, "c": 3}, - maps.Params{"a": int(1), "b": int(2), "c": int(3)}, false}, + []any{ + map[string]any{"a": 42, "c": 3}, + maps.Params{"a": 1, "b": 2}, + }, + maps.Params{"a": int(1), "b": int(2), "c": int(3)}, + false, + }, { "params dst, upper case src", - maps.Params{"a": 1, "b": 2}, - map[string]interface{}{"a": 42, "C": 3}, - maps.Params{"a": int(1), "b": int(2), "c": int(3)}, false}, + []any{ + map[string]any{"a": 42, "C": 3}, + maps.Params{"a": 1, "b": 2}, + }, + maps.Params{"a": int(1), "b": int(2), "c": int(3)}, + false, + }, { "params src", - map[string]interface{}{"a": 1, "c": 2}, - maps.Params{"a": 42, "c": 3}, - map[string]interface{}{"a": int(1), "c": int(2)}, false}, + []any{ + maps.Params{"a": 42, "c": 3}, + map[string]any{"a": 1, "c": 2}, + }, + map[string]any{"a": int(1), "c": int(2)}, + false, + }, { "params src, upper case dst", - map[string]interface{}{"a": 1, "C": 2}, - maps.Params{"a": 42, "c": 3}, - map[string]interface{}{"a": int(1), "C": int(2)}, false}, + []any{ + maps.Params{"a": 42, "c": 3}, + map[string]any{"a": 1, "C": 2}, + }, + map[string]any{"a": int(1), "C": int(2)}, + false, + }, { "nested, params dst", - maps.Params{"a": 1, "b": maps.Params{"d": 1, "e": 2}}, - map[string]interface{}{"a": 42, "c": 3, "b": map[string]interface{}{"d": 55, "e": 66, "f": 3}}, - maps.Params{"a": 1, "b": maps.Params{"d": 1, "e": 2, "f": 3}, "c": 3}, false}, - {"src nil", simpleMap, nil, simpleMap, false}, + []any{ + map[string]any{"a": 42, "c": 3, "b": map[string]any{"d": 55, "e": 66, "f": 3}}, + maps.Params{"a": 1, "b": maps.Params{"d": 1, "e": 2}}, + }, + maps.Params{"a": 1, "b": maps.Params{"d": 1, "e": 2, "f": 3}, "c": 3}, + false, + }, + { + // https://github.com/gohugoio/hugo/issues/7899 + "matching keys with non-map src value", + []any{ + map[string]any{"k": "v"}, + map[string]any{"k": map[string]any{"k2": "v2"}}, + }, + map[string]any{"k": map[string]any{"k2": "v2"}}, + false, + }, + {"src nil", []any{nil, simpleMap}, simpleMap, false}, // Error cases. - {"dst not a map", "not a map", nil, nil, true}, - {"src not a map", simpleMap, "not a map", nil, true}, - {"different map types", simpleMap, map[int]interface{}{32: "a"}, nil, true}, - {"all nil", nil, nil, nil, true}, + {"dst not a map", []any{nil, "not a map"}, nil, true}, + {"src not a map", []any{"not a map", simpleMap}, nil, true}, + {"different map types", []any{map[int]any{32: "a"}, simpleMap}, nil, true}, + {"all nil", []any{nil, nil}, nil, true}, } { test := test + i := i t.Run(test.name, func(t *testing.T) { t.Parallel() @@ -101,9 +146,7 @@ func TestMerge(t *testing.T) { c := qt.New(t) - srcStr, dstStr := fmt.Sprint(test.src), fmt.Sprint(test.dst) - - result, err := ns.Merge(test.src, test.dst) + result, err := ns.Merge(test.params...) if test.isErr { c.Assert(err, qt.Not(qt.IsNil), errMsg) @@ -112,21 +155,25 @@ func TestMerge(t *testing.T) { c.Assert(err, qt.IsNil) c.Assert(result, qt.DeepEquals, test.expect, errMsg) - - // map sort in fmt was fixed in go 1.12. - if !strings.HasPrefix(runtime.Version(), "go1.11") { - // Verify that the original maps are preserved. - c.Assert(fmt.Sprint(test.src), qt.Equals, srcStr) - c.Assert(fmt.Sprint(test.dst), qt.Equals, dstStr) - } - }) } } +func BenchmarkMerge(b *testing.B) { + ns := newNs() + + for i := 0; i < b.N; i++ { + ns.Merge( + map[string]any{"a": 42, "c": 3, "e": 11}, + map[string]any{"a": 1, "b": 2}, + map[string]any{"a": 9, "c": 4, "d": 7}, + ) + } +} + func TestMergeDataFormats(t *testing.T) { c := qt.New(t) - ns := New(&deps.Deps{}) + ns := newNs() toml1 := ` V1 = "v1_1" @@ -170,22 +217,22 @@ V22 = "v22_2" c.Assert( merged, qt.DeepEquals, - map[string]interface{}{ + map[string]any{ "V1": "v1_1", "V2": "v2_2", - "V2s": map[string]interface{}{"V21": "v21_1", "V22": "v22_2"}}) + "V2s": map[string]any{"V21": "v21_1", "V22": "v22_2"}, + }) } - } func TestCaseInsensitiveMapLookup(t *testing.T) { c := qt.New(t) - m1 := reflect.ValueOf(map[string]interface{}{ + m1 := reflect.ValueOf(map[string]any{ "a": 1, "B": 2, }) - m2 := reflect.ValueOf(map[int]interface{}{ + m2 := reflect.ValueOf(map[int]any{ 1: 1, 2: 2, }) diff --git a/tpl/collections/querify.go b/tpl/collections/querify.go new file mode 100644 index 000000000..19e6d8afe --- /dev/null +++ b/tpl/collections/querify.go @@ -0,0 +1,125 @@ +// Copyright 2024 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 collections + +import ( + "errors" + "net/url" + + "github.com/gohugoio/hugo/common/maps" + "github.com/spf13/cast" +) + +var ( + errWrongArgStructure = errors.New("expected a map, a slice with an even number of elements, or an even number of scalar values, and each key must be a string") + errKeyIsEmptyString = errors.New("one of the keys is an empty string") +) + +// Querify returns a URL query string composed of the given key-value pairs, +// encoded and sorted by key. +func (ns *Namespace) Querify(params ...any) (string, error) { + if len(params) == 0 { + return "", nil + } + + if len(params) == 1 { + switch v := params[0].(type) { + case map[string]any: // created with collections.Dictionary + return mapToQueryString(v) + case maps.Params: // site configuration or page parameters + return mapToQueryString(v) + case []string: + return stringSliceToQueryString(v) + case []any: + s, err := interfaceSliceToStringSlice(v) + if err != nil { + return "", err + } + return stringSliceToQueryString(s) + default: + return "", errWrongArgStructure + } + } + + if len(params)%2 != 0 { + return "", errWrongArgStructure + } + + s, err := interfaceSliceToStringSlice(params) + if err != nil { + return "", err + } + return stringSliceToQueryString(s) +} + +// mapToQueryString returns a URL query string derived from the given string +// map, encoded and sorted by key. The function returns an error if it cannot +// convert an element value to a string. +func mapToQueryString[T map[string]any | maps.Params](m T) (string, error) { + if len(m) == 0 { + return "", nil + } + + qs := url.Values{} + for k, v := range m { + if len(k) == 0 { + return "", errKeyIsEmptyString + } + vs, err := cast.ToStringE(v) + if err != nil { + return "", err + } + qs.Add(k, vs) + } + return qs.Encode(), nil +} + +// sliceToQueryString returns a URL query string derived from the given slice +// of strings, encoded and sorted by key. The function returns an error if +// there are an odd number of elements. +func stringSliceToQueryString(s []string) (string, error) { + if len(s) == 0 { + return "", nil + } + if len(s)%2 != 0 { + return "", errWrongArgStructure + } + + qs := url.Values{} + for i := 0; i < len(s); i += 2 { + if len(s[i]) == 0 { + return "", errKeyIsEmptyString + } + qs.Add(s[i], s[i+1]) + } + return qs.Encode(), nil +} + +// interfaceSliceToStringSlice converts a slice of interfaces to a slice of +// strings, returning an error if it cannot convert an element to a string. +func interfaceSliceToStringSlice(s []any) ([]string, error) { + if len(s) == 0 { + return []string{}, nil + } + + ss := make([]string, 0, len(s)) + for _, v := range s { + vs, err := cast.ToStringE(v) + if err != nil { + return []string{}, err + } + ss = append(ss, vs) + } + return ss, nil +} diff --git a/tpl/collections/querify_test.go b/tpl/collections/querify_test.go new file mode 100644 index 000000000..17556e4cb --- /dev/null +++ b/tpl/collections/querify_test.go @@ -0,0 +1,121 @@ +// Copyright 2024 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 collections + +import ( + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/common/maps" +) + +func TestQuerify(t *testing.T) { + t.Parallel() + c := qt.New(t) + ns := newNs() + + for _, test := range []struct { + name string + params []any + expect any + }{ + // map + {"01", []any{maps.Params{"a": "foo", "b": "bar"}}, `a=foo&b=bar`}, + {"02", []any{maps.Params{"a": 6, "b": 7}}, `a=6&b=7`}, + {"03", []any{maps.Params{"a": "foo", "b": 7}}, `a=foo&b=7`}, + {"04", []any{map[string]any{"a": "foo", "b": "bar"}}, `a=foo&b=bar`}, + {"05", []any{map[string]any{"a": 6, "b": 7}}, `a=6&b=7`}, + {"06", []any{map[string]any{"a": "foo", "b": 7}}, `a=foo&b=7`}, + // slice + {"07", []any{[]string{"a", "foo", "b", "bar"}}, `a=foo&b=bar`}, + {"08", []any{[]any{"a", 6, "b", 7}}, `a=6&b=7`}, + {"09", []any{[]any{"a", "foo", "b", 7}}, `a=foo&b=7`}, + // sequence of scalar values + {"10", []any{"a", "foo", "b", "bar"}, `a=foo&b=bar`}, + {"11", []any{"a", 6, "b", 7}, `a=6&b=7`}, + {"12", []any{"a", "foo", "b", 7}, `a=foo&b=7`}, + // empty map + {"13", []any{map[string]any{}}, ``}, + // empty slice + {"14", []any{[]string{}}, ``}, + {"15", []any{[]any{}}, ``}, + // no arguments + {"16", []any{}, ``}, + // errors: zero key length + {"17", []any{maps.Params{"": "foo"}}, false}, + {"18", []any{map[string]any{"": "foo"}}, false}, + {"19", []any{[]string{"", "foo"}}, false}, + {"20", []any{[]any{"", 6}}, false}, + {"21", []any{"", "foo"}, false}, + // errors: odd number of values + {"22", []any{[]string{"a", "foo", "b"}}, false}, + {"23", []any{[]any{"a", 6, "b"}}, false}, + {"24", []any{"a", "foo", "b"}, false}, + // errors: value cannot be cast to string + {"25", []any{map[string]any{"a": "foo", "b": tstNoStringer{}}}, false}, + {"26", []any{[]any{"a", "foo", "b", tstNoStringer{}}}, false}, + {"27", []any{"a", "foo", "b", tstNoStringer{}}, false}, + } { + errMsg := qt.Commentf("[%s] %v", test.name, test.params) + + result, err := ns.Querify(test.params...) + + if b, ok := test.expect.(bool); ok && !b { + c.Assert(err, qt.Not(qt.IsNil), errMsg) + continue + } + + c.Assert(err, qt.IsNil, errMsg) + c.Assert(result, qt.Equals, test.expect, errMsg) + } +} + +func BenchmarkQuerify(b *testing.B) { + ns := newNs() + params := []any{"a", "b", "c", "d", "f", " &"} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := ns.Querify(params...) + if err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkQuerifySlice(b *testing.B) { + ns := newNs() + params := []string{"a", "b", "c", "d", "f", " &"} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := ns.Querify(params) + if err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkQuerifyMap(b *testing.B) { + ns := newNs() + params := map[string]any{"a": "b", "c": "d", "f": " &"} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := ns.Querify(params) + if err != nil { + b.Fatal(err) + } + } +} diff --git a/tpl/collections/reflect_helpers.go b/tpl/collections/reflect_helpers.go index 69425fcb0..05816a009 100644 --- a/tpl/collections/reflect_helpers.go +++ b/tpl/collections/reflect_helpers.go @@ -14,17 +14,18 @@ package collections import ( + "errors" "fmt" "reflect" - "time" - "github.com/pkg/errors" + "github.com/gohugoio/hugo/common/hashing" + "github.com/gohugoio/hugo/common/types" + "github.com/gohugoio/hugo/resources/resource" ) var ( zero reflect.Value - errorType = reflect.TypeOf((*error)(nil)).Elem() - timeType = reflect.TypeOf((*time.Time)(nil)).Elem() + errorType = reflect.TypeFor[error]() ) func numberToFloat(v reflect.Value) (float64, error) { @@ -42,11 +43,14 @@ func numberToFloat(v reflect.Value) (float64, error) { } } -// normalizes different numeric types to make them comparable. -func normalize(v reflect.Value) interface{} { +// normalizes different numeric types if isNumber +// or get the hash values if not Comparable (such as map or struct) +// to make them comparable +func normalize(v reflect.Value) any { k := v.Kind() - switch { + case !v.Type().Comparable(): + return hashing.HashUint64(v.Interface()) case isNumber(k): f, err := numberToFloat(v) if err == nil { @@ -54,18 +58,23 @@ func normalize(v reflect.Value) interface{} { } } - return v.Interface() + vv := types.Unwrapv(v.Interface()) + if ip, ok := vv.(resource.TransientIdentifier); ok { + return ip.TransientKey() + } + + return vv } // collects identities from the slices in seqs into a set. Numeric values are normalized, // pointers unwrapped. -func collectIdentities(seqs ...interface{}) (map[interface{}]bool, error) { - seen := make(map[interface{}]bool) +func collectIdentities(seqs ...any) (map[any]bool, error) { + seen := make(map[any]bool) for _, seq := range seqs { v := reflect.ValueOf(seq) switch v.Kind() { case reflect.Array, reflect.Slice: - for i := 0; i < v.Len(); i++ { + for i := range v.Len() { ev, _ := indirectInterface(v.Index(i)) if !ev.Type().Comparable() { @@ -95,7 +104,7 @@ func convertValue(v reflect.Value, to reflect.Type) (reflect.Value, error) { case isNumber(kind): return convertNumber(v, kind) default: - return reflect.Value{}, errors.Errorf("%s is not assignable to %s", v.Type(), to) + return reflect.Value{}, fmt.Errorf("%s is not assignable to %s", v.Type(), to) } } @@ -149,7 +158,6 @@ func convertNumber(v reflect.Value, to reflect.Kind) (reflect.Value, error) { case reflect.Uint64: n = reflect.ValueOf(uint64(i)) } - } if !n.IsValid() { @@ -157,10 +165,9 @@ func convertNumber(v reflect.Value, to reflect.Kind) (reflect.Value, error) { } return n, nil - } -func newSliceElement(items interface{}) interface{} { +func newSliceElement(items any) any { tp := reflect.TypeOf(items) if tp == nil { return nil diff --git a/tpl/collections/sort.go b/tpl/collections/sort.go index 7ca764e9b..0c09f6af4 100644 --- a/tpl/collections/sort.go +++ b/tpl/collections/sort.go @@ -14,29 +14,31 @@ package collections import ( + "context" "errors" "reflect" "sort" "strings" "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/langs" "github.com/gohugoio/hugo/tpl/compare" "github.com/spf13/cast" ) -var sortComp = compare.New(true) - -// Sort returns a sorted sequence. -func (ns *Namespace) Sort(seq interface{}, args ...interface{}) (interface{}, error) { - if seq == nil { +// Sort returns a sorted copy of the list l. +func (ns *Namespace) Sort(ctx context.Context, l any, args ...any) (any, error) { + if l == nil { return nil, errors.New("sequence must be provided") } - seqv, isNil := indirect(reflect.ValueOf(seq)) + seqv, isNil := indirect(reflect.ValueOf(l)) if isNil { return nil, errors.New("can't iterate over a nil value") } + ctxv := reflect.ValueOf(ctx) + var sliceType reflect.Type switch seqv.Kind() { case reflect.Array, reflect.Slice: @@ -44,11 +46,13 @@ func (ns *Namespace) Sort(seq interface{}, args ...interface{}) (interface{}, er case reflect.Map: sliceType = reflect.SliceOf(seqv.Type().Elem()) default: - return nil, errors.New("can't sort " + reflect.ValueOf(seq).Type().String()) + return nil, errors.New("can't sort " + reflect.ValueOf(l).Type().String()) } + collator := langs.GetCollator1(ns.deps.Conf.Language()) + // Create a list of pairs that will be used to do the sort - p := pairList{SortAsc: true, SliceType: sliceType} + p := pairList{Collator: collator, sortComp: ns.sortComp, SortAsc: true, SliceType: sliceType} p.Pairs = make([]pair, seqv.Len()) var sortByField string @@ -69,7 +73,7 @@ func (ns *Namespace) Sort(seq interface{}, args ...interface{}) (interface{}, er switch seqv.Kind() { case reflect.Array, reflect.Slice: - for i := 0; i < seqv.Len(); i++ { + for i := range seqv.Len() { p.Pairs[i].Value = seqv.Index(i) if sortByField == "" || sortByField == "value" { p.Pairs[i].Key = p.Pairs[i].Value @@ -77,7 +81,7 @@ func (ns *Namespace) Sort(seq interface{}, args ...interface{}) (interface{}, er v := p.Pairs[i].Value var err error for i, elemName := range path { - v, err = evaluateSubElem(v, elemName) + v, err = evaluateSubElem(ctxv, v, elemName) if err != nil { return nil, err } @@ -86,7 +90,7 @@ func (ns *Namespace) Sort(seq interface{}, args ...interface{}) (interface{}, er } // Special handling of lower cased maps. if params, ok := v.Interface().(maps.Params); ok { - v = reflect.ValueOf(params.Get(path[i+1:]...)) + v = reflect.ValueOf(params.GetNested(path[i+1:]...)) break } } @@ -95,19 +99,22 @@ func (ns *Namespace) Sort(seq interface{}, args ...interface{}) (interface{}, er } case reflect.Map: - keys := seqv.MapKeys() - for i := 0; i < seqv.Len(); i++ { - p.Pairs[i].Value = seqv.MapIndex(keys[i]) + iter := seqv.MapRange() + i := 0 + for iter.Next() { + key := iter.Key() + value := iter.Value() + p.Pairs[i].Value = value if sortByField == "" { - p.Pairs[i].Key = keys[i] + p.Pairs[i].Key = key } else if sortByField == "value" { p.Pairs[i].Key = p.Pairs[i].Value } else { v := p.Pairs[i].Value var err error - for i, elemName := range path { - v, err = evaluateSubElem(v, elemName) + for j, elemName := range path { + v, err = evaluateSubElem(ctxv, v, elemName) if err != nil { return nil, err } @@ -116,14 +123,19 @@ func (ns *Namespace) Sort(seq interface{}, args ...interface{}) (interface{}, er } // Special handling of lower cased maps. if params, ok := v.Interface().(maps.Params); ok { - v = reflect.ValueOf(params.Get(path[i+1:]...)) + v = reflect.ValueOf(params.GetNested(path[j+1:]...)) break } } p.Pairs[i].Key = v } + i++ } } + + collator.Lock() + defer collator.Unlock() + return p.sort(), nil } @@ -137,6 +149,8 @@ type pair struct { // A slice of pairs that implements sort.Interface to sort by Value. type pairList struct { + Collator *langs.Collator + sortComp *compare.Namespace Pairs []pair SortAsc bool SliceType reflect.Type @@ -151,27 +165,27 @@ func (p pairList) Less(i, j int) bool { if iv.IsValid() { if jv.IsValid() { // can only call Interface() on valid reflect Values - return sortComp.Lt(iv.Interface(), jv.Interface()) + return p.sortComp.LtCollate(p.Collator, iv.Interface(), jv.Interface()) } // if j is invalid, test i against i's zero value - return sortComp.Lt(iv.Interface(), reflect.Zero(iv.Type())) + return p.sortComp.LtCollate(p.Collator, iv.Interface(), reflect.Zero(iv.Type())) } if jv.IsValid() { // if i is invalid, test j against j's zero value - return sortComp.Lt(reflect.Zero(jv.Type()), jv.Interface()) + return p.sortComp.LtCollate(p.Collator, reflect.Zero(jv.Type()), jv.Interface()) } return false } // sorts a pairList and returns a slice of sorted values -func (p pairList) sort() interface{} { +func (p pairList) sort() any { if p.SortAsc { - sort.Sort(p) + sort.Stable(p) } else { - sort.Sort(sort.Reverse(p)) + sort.Stable(sort.Reverse(p)) } sorted := reflect.MakeSlice(p.SliceType, len(p.Pairs), len(p.Pairs)) for i, v := range p.Pairs { diff --git a/tpl/collections/sort_test.go b/tpl/collections/sort_test.go index 2bf6e85fe..cc4581921 100644 --- a/tpl/collections/sort_test.go +++ b/tpl/collections/sort_test.go @@ -14,13 +14,12 @@ package collections import ( + "context" "fmt" "reflect" "testing" "github.com/gohugoio/hugo/common/maps" - - "github.com/gohugoio/hugo/deps" ) type stringsSlice []string @@ -28,7 +27,7 @@ type stringsSlice []string func TestSort(t *testing.T) { t.Parallel() - ns := New(&deps.Deps{}) + ns := newNs() type ts struct { MyInt int @@ -40,10 +39,10 @@ func TestSort(t *testing.T) { } for i, test := range []struct { - seq interface{} - sortByField interface{} + seq any + sortByField any sortAsc string - expect interface{} + expect any }{ {[]string{"class1", "class2", "class3"}, nil, "asc", []string{"class1", "class2", "class3"}}, {[]string{"class3", "class1", "class2"}, nil, "asc", []string{"class1", "class2", "class3"}}, @@ -53,7 +52,7 @@ func TestSort(t *testing.T) { {[]int{1, 2, 3, 4, 5}, nil, "asc", []int{1, 2, 3, 4, 5}}, {[]int{5, 4, 3, 1, 2}, nil, "asc", []int{1, 2, 3, 4, 5}}, - // test sort key parameter is focibly set empty + // test sort key parameter is forcibly set empty {[]string{"class3", "class1", "class2"}, map[int]string{1: "a"}, "asc", []string{"class1", "class2", "class3"}}, // test map sorting by keys {map[string]int{"1": 10, "2": 20, "3": 30, "4": 40, "5": 50}, nil, "asc", []int{10, 20, 30, 40, 50}}, @@ -205,19 +204,22 @@ func TestSort(t *testing.T) { }, // interface slice with missing elements { - []interface{}{ - map[interface{}]interface{}{"Title": "Foo", "Weight": 10}, - map[interface{}]interface{}{"Title": "Bar"}, - map[interface{}]interface{}{"Title": "Zap", "Weight": 5}, + []any{ + map[any]any{"Title": "Foo", "Weight": 10}, + map[any]any{"Title": "Bar"}, + map[any]any{"Title": "Zap", "Weight": 5}, }, "Weight", "asc", - []interface{}{ - map[interface{}]interface{}{"Title": "Bar"}, - map[interface{}]interface{}{"Title": "Zap", "Weight": 5}, - map[interface{}]interface{}{"Title": "Foo", "Weight": 10}, + []any{ + map[any]any{"Title": "Bar"}, + map[any]any{"Title": "Zap", "Weight": 5}, + map[any]any{"Title": "Foo", "Weight": 10}, }, }, + // test boolean values + {[]bool{false, true, false}, "value", "asc", []bool{false, false, true}}, + {[]bool{false, true, false}, "value", "desc", []bool{true, false, false}}, // test error cases {(*[]TstX)(nil), nil, "asc", false}, {TstX{A: "a", B: "b"}, nil, "asc", false}, @@ -235,14 +237,13 @@ func TestSort(t *testing.T) { }, {nil, nil, "asc", false}, } { - t.Run(fmt.Sprintf("test%d", i), func(t *testing.T) { - var result interface{} + var result any var err error if test.sortByField == nil { - result, err = ns.Sort(test.seq) + result, err = ns.Sort(context.Background(), test.seq) } else { - result, err = ns.Sort(test.seq, test.sortByField, test.sortAsc) + result, err = ns.Sort(context.Background(), test.seq, test.sortByField, test.sortAsc) } if b, ok := test.expect.(bool); ok && !b { @@ -258,6 +259,13 @@ func TestSort(t *testing.T) { } } }) - + } +} + +func BenchmarkSortMap(b *testing.B) { + ns := newNs() + m := map[string]int{"1": 10, "2": 20, "3": 30, "4": 40, "5": 50} + for i := 0; i < b.N; i++ { + ns.Sort(context.Background(), m) } } diff --git a/tpl/collections/symdiff.go b/tpl/collections/symdiff.go index 1c58257e4..4b9dc6e42 100644 --- a/tpl/collections/symdiff.go +++ b/tpl/collections/symdiff.go @@ -16,13 +16,11 @@ package collections import ( "fmt" "reflect" - - "github.com/pkg/errors" ) // SymDiff returns the symmetric difference of s1 and s2. // Arguments must be either a slice or an array of comparable types. -func (ns *Namespace) SymDiff(s2, s1 interface{}) (interface{}, error) { +func (ns *Namespace) SymDiff(s2, s1 any) (any, error) { ids1, err := collectIdentities(s1) if err != nil { return nil, err @@ -35,7 +33,7 @@ func (ns *Namespace) SymDiff(s2, s1 interface{}) (interface{}, error) { var slice reflect.Value var sliceElemType reflect.Type - for i, s := range []interface{}{s1, s2} { + for i, s := range []any{s1, s2} { v := reflect.ValueOf(s) switch v.Kind() { @@ -46,17 +44,15 @@ func (ns *Namespace) SymDiff(s2, s1 interface{}) (interface{}, error) { slice = reflect.MakeSlice(sliceType, 0, 0) } - for i := 0; i < v.Len(); i++ { + for i := range v.Len() { ev, _ := indirectInterface(v.Index(i)) - if !ev.Type().Comparable() { - return nil, errors.New("symdiff: elements must be comparable") - } key := normalize(ev) + // Append if the key is not in their intersection. if ids1[key] != ids2[key] { v, err := convertValue(ev, sliceElemType) if err != nil { - return nil, errors.WithMessage(err, "symdiff: failed to convert value") + return nil, fmt.Errorf("symdiff: failed to convert value: %w", err) } slice = reflect.Append(slice, v) } @@ -67,5 +63,4 @@ func (ns *Namespace) SymDiff(s2, s1 interface{}) (interface{}, error) { } return slice.Interface(), nil - } diff --git a/tpl/collections/symdiff_test.go b/tpl/collections/symdiff_test.go index ac40fda55..548f91b6c 100644 --- a/tpl/collections/symdiff_test.go +++ b/tpl/collections/symdiff_test.go @@ -17,8 +17,6 @@ import ( "reflect" "testing" - "github.com/gohugoio/hugo/deps" - qt "github.com/frankban/quicktest" ) @@ -27,7 +25,7 @@ func TestSymDiff(t *testing.T) { c := qt.New(t) - ns := New(&deps.Deps{}) + ns := newNs() s1 := []TstX{{A: "a"}, {A: "b"}} s2 := []TstX{{A: "a"}, {A: "e"}} @@ -38,13 +36,13 @@ func TestSymDiff(t *testing.T) { sp2 := []*StructWithSlice{xb, xe} for i, test := range []struct { - s1 interface{} - s2 interface{} - expected interface{} + s1 any + s2 any + expected any }{ {[]string{"a", "x", "b", "c"}, []string{"a", "b", "y", "c"}, []string{"x", "y"}}, {[]string{"a", "b", "c"}, []string{"a", "b", "c"}, []string{}}, - {[]interface{}{"a", "b", nil}, []interface{}{"a"}, []interface{}{"b", nil}}, + {[]any{"a", "b", nil}, []any{"a"}, []any{"b", nil}}, {[]int{1, 2, 3}, []int{3, 4}, []int{1, 2, 4}}, {[]int{1, 2, 3}, []int64{3, 4}, []int{1, 2, 4}}, {s1, s2, []TstX{{A: "b"}, {A: "e"}}}, @@ -75,5 +73,4 @@ func TestSymDiff(t *testing.T) { c.Assert(err, qt.Not(qt.IsNil)) _, err = ns.Complement([]string{"a", "b"}) c.Assert(err, qt.Not(qt.IsNil)) - } diff --git a/tpl/collections/where.go b/tpl/collections/where.go index cada675f3..ee49d0bbb 100644 --- a/tpl/collections/where.go +++ b/tpl/collections/where.go @@ -14,19 +14,22 @@ package collections import ( + "context" "errors" "fmt" "reflect" "strings" + "github.com/gohugoio/hugo/common/hreflect" + "github.com/gohugoio/hugo/common/hstrings" "github.com/gohugoio/hugo/common/maps" ) -// Where returns a filtered subset of a given data type. -func (ns *Namespace) Where(seq, key interface{}, args ...interface{}) (interface{}, error) { - seqv, isNil := indirect(reflect.ValueOf(seq)) +// Where returns a filtered subset of collection c. +func (ns *Namespace) Where(ctx context.Context, c, key any, args ...any) (any, error) { + seqv, isNil := indirect(reflect.ValueOf(c)) if isNil { - return nil, errors.New("can't iterate over a nil value of type " + reflect.ValueOf(seq).Type().String()) + return nil, errors.New("can't iterate over a nil value of type " + reflect.ValueOf(c).Type().String()) } mv, op, err := parseWhereArgs(args...) @@ -34,6 +37,8 @@ func (ns *Namespace) Where(seq, key interface{}, args ...interface{}) (interface return nil, err } + ctxv := reflect.ValueOf(ctx) + var path []string kv := reflect.ValueOf(key) if kv.Kind() == reflect.String { @@ -42,11 +47,11 @@ func (ns *Namespace) Where(seq, key interface{}, args ...interface{}) (interface switch seqv.Kind() { case reflect.Array, reflect.Slice: - return ns.checkWhereArray(seqv, kv, mv, path, op) + return ns.checkWhereArray(ctxv, seqv, kv, mv, path, op) case reflect.Map: - return ns.checkWhereMap(seqv, kv, mv, path, op) + return ns.checkWhereMap(ctxv, seqv, kv, mv, path, op) default: - return nil, fmt.Errorf("can't iterate over %v", seq) + return nil, fmt.Errorf("can't iterate over %T", c) } } @@ -83,11 +88,12 @@ func (ns *Namespace) checkCondition(v, mv reflect.Value, op string) (bool, error var ivp, imvp *int64 var fvp, fmvp *float64 var svp, smvp *string - var slv, slmv interface{} + var slv, slmv any var ima []int64 var fma []float64 var sma []string - if mv.Type() == v.Type() { + + if mv.Kind() == v.Kind() { switch v.Kind() { case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: iv := v.Int() @@ -105,11 +111,10 @@ func (ns *Namespace) checkCondition(v, mv reflect.Value, op string) (bool, error fmv := mv.Float() fmvp = &fmv case reflect.Struct: - switch v.Type() { - case timeType: - iv := toTimeUnix(v) + if hreflect.IsTime(v.Type()) { + iv := ns.toTimeUnix(v) ivp = &iv - imv := toTimeUnix(mv) + imv := ns.toTimeUnix(mv) imvp = &imv } case reflect.Array, reflect.Slice: @@ -133,6 +138,9 @@ func (ns *Namespace) checkCondition(v, mv reflect.Value, op string) (bool, error } if mv.Len() == 0 { + if op == "not in" { + return true, nil + } return false, nil } @@ -143,7 +151,7 @@ func (ns *Namespace) checkCondition(v, mv reflect.Value, op string) (bool, error case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: iv := v.Int() ivp = &iv - for i := 0; i < mv.Len(); i++ { + for i := range mv.Len() { if anInt, err := toInt(mv.Index(i)); err == nil { ima = append(ima, anInt) } @@ -151,7 +159,7 @@ func (ns *Namespace) checkCondition(v, mv reflect.Value, op string) (bool, error case reflect.String: sv := v.String() svp = &sv - for i := 0; i < mv.Len(); i++ { + for i := range mv.Len() { if aString, err := toString(mv.Index(i)); err == nil { sma = append(sma, aString) } @@ -159,18 +167,17 @@ func (ns *Namespace) checkCondition(v, mv reflect.Value, op string) (bool, error case reflect.Float64: fv := v.Float() fvp = &fv - for i := 0; i < mv.Len(); i++ { + for i := range mv.Len() { if aFloat, err := toFloat(mv.Index(i)); err == nil { fma = append(fma, aFloat) } } case reflect.Struct: - switch v.Type() { - case timeType: - iv := toTimeUnix(v) + if hreflect.IsTime(v.Type()) { + iv := ns.toTimeUnix(v) ivp = &iv - for i := 0; i < mv.Len(); i++ { - ima = append(ima, toTimeUnix(mv.Index(i))) + for i := range mv.Len() { + ima = append(ima, ns.toTimeUnix(mv.Index(i))) } } case reflect.Array, reflect.Slice: @@ -269,13 +276,24 @@ func (ns *Namespace) checkCondition(v, mv reflect.Value, op string) (bool, error return false, nil } return false, errors.New("invalid intersect values") + case "like": + if svp != nil && smvp != nil { + re, err := hstrings.GetOrCompileRegexp(*smvp) + if err != nil { + return false, err + } + if re.MatchString(*svp) { + return true, nil + } + return false, nil + } default: return false, errors.New("no such operator") } return false, nil } -func evaluateSubElem(obj reflect.Value, elemName string) (reflect.Value, error) { +func evaluateSubElem(ctx, obj reflect.Value, elemName string) (reflect.Value, error) { if !obj.IsValid() { return zero, errors.New("can't evaluate an invalid value") } @@ -299,13 +317,22 @@ func evaluateSubElem(obj reflect.Value, elemName string) (reflect.Value, error) objPtr = objPtr.Addr() } - mt, ok := objPtr.Type().MethodByName(elemName) - if ok { + index := hreflect.GetMethodIndexByName(objPtr.Type(), elemName) + if index != -1 { + var args []reflect.Value + mt := objPtr.Type().Method(index) + num := mt.Type.NumIn() + maxNumIn := 1 + if num > 1 && hreflect.IsContextType(mt.Type.In(1)) { + args = []reflect.Value{ctx} + maxNumIn = 2 + } + switch { case mt.PkgPath != "": return zero, fmt.Errorf("%s is an unexported method of type %s", elemName, typ) - case mt.Type.NumIn() > 1: - return zero, fmt.Errorf("%s is a method of type %s but requires more than 1 parameter", elemName, typ) + case mt.Type.NumIn() > maxNumIn: + return zero, fmt.Errorf("%s is a method of type %s but requires more than %d parameter", elemName, typ, maxNumIn) case mt.Type.NumOut() == 0: return zero, fmt.Errorf("%s is a method of type %s but returns no output", elemName, typ) case mt.Type.NumOut() > 2: @@ -315,7 +342,7 @@ func evaluateSubElem(obj reflect.Value, elemName string) (reflect.Value, error) case mt.Type.NumOut() == 2 && !mt.Type.Out(1).Implements(errorType): return zero, fmt.Errorf("%s is a method of type %s returning two values but the second value is not an error type", elemName, typ) } - res := objPtr.Method(mt.Index).Call([]reflect.Value{}) + res := objPtr.Method(mt.Index).Call(args) if len(res) == 2 && !res[1].IsNil() { return zero, fmt.Errorf("error at calling a method %s of type %s: %s", elemName, typ, res[1].Interface().(error)) } @@ -350,7 +377,7 @@ func evaluateSubElem(obj reflect.Value, elemName string) (reflect.Value, error) // parseWhereArgs parses the end arguments to the where function. Return a // match value and an operator, if one is defined. -func parseWhereArgs(args ...interface{}) (mv reflect.Value, op string, err error) { +func parseWhereArgs(args ...any) (mv reflect.Value, op string, err error) { switch len(args) { case 1: mv = reflect.ValueOf(args[0]) @@ -370,24 +397,32 @@ func parseWhereArgs(args ...interface{}) (mv reflect.Value, op string, err error // checkWhereArray handles the where-matching logic when the seqv value is an // Array or Slice. -func (ns *Namespace) checkWhereArray(seqv, kv, mv reflect.Value, path []string, op string) (interface{}, error) { +func (ns *Namespace) checkWhereArray(ctxv, seqv, kv, mv reflect.Value, path []string, op string) (any, error) { rv := reflect.MakeSlice(seqv.Type(), 0, 0) - for i := 0; i < seqv.Len(); i++ { + for i := range seqv.Len() { var vvv reflect.Value rvv := seqv.Index(i) if kv.Kind() == reflect.String { if params, ok := rvv.Interface().(maps.Params); ok { - vvv = reflect.ValueOf(params.Get(path...)) + vvv = reflect.ValueOf(params.GetNested(path...)) } else { vvv = rvv - for _, elemName := range path { + for i, elemName := range path { var err error - vvv, err = evaluateSubElem(vvv, elemName) + vvv, err = evaluateSubElem(ctxv, vvv, elemName) if err != nil { continue } + + if i < len(path)-1 && vvv.IsValid() { + if params, ok := vvv.Interface().(maps.Params); ok { + // The current path element is the map itself, .Params. + vvv = reflect.ValueOf(params.GetNested(path[i+1:]...)) + break + } + } } } } else { @@ -407,14 +442,17 @@ func (ns *Namespace) checkWhereArray(seqv, kv, mv reflect.Value, path []string, } // checkWhereMap handles the where-matching logic when the seqv value is a Map. -func (ns *Namespace) checkWhereMap(seqv, kv, mv reflect.Value, path []string, op string) (interface{}, error) { +func (ns *Namespace) checkWhereMap(ctxv, seqv, kv, mv reflect.Value, path []string, op string) (any, error) { rv := reflect.MakeMap(seqv.Type()) - keys := seqv.MapKeys() - for _, k := range keys { - elemv := seqv.MapIndex(k) + k := reflect.New(seqv.Type().Key()).Elem() + elemv := reflect.New(seqv.Type().Elem()).Elem() + iter := seqv.MapRange() + for iter.Next() { + k.SetIterKey(iter) + elemv.SetIterValue(iter) switch elemv.Kind() { case reflect.Array, reflect.Slice: - r, err := ns.checkWhereArray(elemv, kv, mv, path, op) + r, err := ns.checkWhereArray(ctxv, elemv, kv, mv, path, op) if err != nil { return nil, err } @@ -433,7 +471,7 @@ func (ns *Namespace) checkWhereMap(seqv, kv, mv reflect.Value, path []string, op switch elemvv.Kind() { case reflect.Array, reflect.Slice: - r, err := ns.checkWhereArray(elemvv, kv, mv, path, op) + r, err := ns.checkWhereArray(ctxv, elemvv, kv, mv, path, op) if err != nil { return nil, err } @@ -496,12 +534,10 @@ func toString(v reflect.Value) (string, error) { return "", errors.New("unable to convert value to string") } -func toTimeUnix(v reflect.Value) int64 { - if v.Kind() == reflect.Interface { - return toTimeUnix(v.Elem()) - } - if v.Type() != timeType { +func (ns *Namespace) toTimeUnix(v reflect.Value) int64 { + t, ok := hreflect.AsTime(v, ns.loc) + if !ok { panic("coding error: argument must be time.Time type reflect Value") } - return v.MethodByName("Unix").Call([]reflect.Value{})[0].Int() + return t.Unix() } diff --git a/tpl/collections/where_test.go b/tpl/collections/where_test.go index d6a1dd141..ecf748f93 100644 --- a/tpl/collections/where_test.go +++ b/tpl/collections/where_test.go @@ -14,21 +14,22 @@ package collections import ( + "context" "fmt" + "html/template" + "math/rand" "reflect" "strings" "testing" "time" "github.com/gohugoio/hugo/common/maps" - - "github.com/gohugoio/hugo/deps" ) func TestWhere(t *testing.T) { t.Parallel() - ns := New(&deps.Deps{}) + ns := newNs() type Mid struct { Tst TstX @@ -42,11 +43,11 @@ func TestWhere(t *testing.T) { d6 := d5.Add(1 * time.Hour) type testt struct { - seq interface{} - key interface{} + seq any + key any op string - match interface{} - expect interface{} + match any + expect any } createTestVariants := func(test testt) []testt { @@ -62,7 +63,6 @@ func TestWhere(t *testing.T) { } return testVariants - } for i, test := range []testt{ @@ -147,6 +147,17 @@ func TestWhere(t *testing.T) { key: "b", match: 2.0, op: ">=", expect: []map[string]float64{{"a": 1, "b": 2}, {"a": 3, "b": 3}}, }, + // Issue #8353 + // String type mismatch. + { + seq: []map[string]any{ + {"a": "1", "b": "2"}, {"a": "3", "b": template.HTML("4")}, {"a": "5", "x": "4"}, + }, + key: "b", match: "4", + expect: []map[string]any{ + {"a": "3", "b": template.HTML("4")}, + }, + }, { seq: []TstX{ {A: "a", B: "b"}, {A: "c", B: "d"}, {A: "e", B: "f"}, @@ -165,32 +176,50 @@ func TestWhere(t *testing.T) { {1: "a", 2: "m"}, }, }, + // Case insensitive maps.Params + // Slice of structs + { + seq: []TstParams{{params: maps.Params{"i": 0, "color": "indigo"}}, {params: maps.Params{"i": 1, "color": "blue"}}, {params: maps.Params{"i": 2, "color": "green"}}, {params: maps.Params{"i": 3, "color": "blue"}}}, + key: ".Params.COLOR", match: "blue", + expect: []TstParams{{params: maps.Params{"i": 1, "color": "blue"}}, {params: maps.Params{"i": 3, "color": "blue"}}}, + }, + { + seq: []TstParams{{params: maps.Params{"nested": map[string]any{"color": "indigo"}}}, {params: maps.Params{"nested": map[string]any{"color": "blue"}}}}, + key: ".Params.NEsTED.COLOR", match: "blue", + expect: []TstParams{{params: maps.Params{"nested": map[string]any{"color": "blue"}}}}, + }, + { + seq: []TstParams{{params: maps.Params{"i": 0, "color": "indigo"}}, {params: maps.Params{"i": 1, "color": "blue"}}, {params: maps.Params{"i": 2, "color": "green"}}, {params: maps.Params{"i": 3, "color": "blue"}}}, + key: ".Params", match: "blue", + expect: []TstParams{}, + }, + // Slice of maps { seq: []maps.Params{ {"a": "a1", "b": "b1"}, {"a": "a2", "b": "b2"}, }, key: "B", match: "b2", expect: []maps.Params{ - maps.Params{"a": "a2", "b": "b2"}, + {"a": "a2", "b": "b2"}, }, }, { seq: []maps.Params{ - maps.Params{ - "a": map[string]interface{}{ + { + "a": map[string]any{ "b": "b1", }, }, - maps.Params{ - "a": map[string]interface{}{ + { + "a": map[string]any{ "b": "b2", }, }, }, key: "A.B", match: "b2", expect: []maps.Params{ - maps.Params{ - "a": map[string]interface{}{ + { + "a": map[string]any{ "b": "b2", }, }, @@ -569,38 +598,38 @@ func TestWhere(t *testing.T) { expect: false, }, { - seq: map[string]interface{}{ - "foo": []interface{}{map[interface{}]interface{}{"a": 1, "b": 2}}, - "bar": []interface{}{map[interface{}]interface{}{"a": 3, "b": 4}}, - "zap": []interface{}{map[interface{}]interface{}{"a": 5, "b": 6}}, + seq: map[string]any{ + "foo": []any{map[any]any{"a": 1, "b": 2}}, + "bar": []any{map[any]any{"a": 3, "b": 4}}, + "zap": []any{map[any]any{"a": 5, "b": 6}}, }, key: "b", op: "in", match: ns.Slice(3, 4, 5), - expect: map[string]interface{}{ - "bar": []interface{}{map[interface{}]interface{}{"a": 3, "b": 4}}, + expect: map[string]any{ + "bar": []any{map[any]any{"a": 3, "b": 4}}, }, }, { - seq: map[string]interface{}{ - "foo": []interface{}{map[interface{}]interface{}{"a": 1, "b": 2}}, - "bar": []interface{}{map[interface{}]interface{}{"a": 3, "b": 4}}, - "zap": []interface{}{map[interface{}]interface{}{"a": 5, "b": 6}}, + seq: map[string]any{ + "foo": []any{map[any]any{"a": 1, "b": 2}}, + "bar": []any{map[any]any{"a": 3, "b": 4}}, + "zap": []any{map[any]any{"a": 5, "b": 6}}, }, key: "b", op: ">", match: 3, - expect: map[string]interface{}{ - "bar": []interface{}{map[interface{}]interface{}{"a": 3, "b": 4}}, - "zap": []interface{}{map[interface{}]interface{}{"a": 5, "b": 6}}, + expect: map[string]any{ + "bar": []any{map[any]any{"a": 3, "b": 4}}, + "zap": []any{map[any]any{"a": 5, "b": 6}}, }, }, { - seq: map[string]interface{}{ - "foo": []interface{}{maps.Params{"a": 1, "b": 2}}, - "bar": []interface{}{maps.Params{"a": 3, "b": 4}}, - "zap": []interface{}{maps.Params{"a": 5, "b": 6}}, + seq: map[string]any{ + "foo": []any{maps.Params{"a": 1, "b": 2}}, + "bar": []any{maps.Params{"a": 3, "b": 4}}, + "zap": []any{maps.Params{"a": 5, "b": 6}}, }, key: "B", op: ">", match: 3, - expect: map[string]interface{}{ - "bar": []interface{}{maps.Params{"a": 3, "b": 4}}, - "zap": []interface{}{maps.Params{"a": 5, "b": 6}}, + expect: map[string]any{ + "bar": []any{maps.Params{"a": 3, "b": 4}}, + "zap": []any{maps.Params{"a": 5, "b": 6}}, }, }, } { @@ -610,13 +639,13 @@ func TestWhere(t *testing.T) { name := fmt.Sprintf("%d/%d %T %s %s", i, j, test.seq, test.op, test.key) name = strings.ReplaceAll(name, "[]", "slice-of-") t.Run(name, func(t *testing.T) { - var results interface{} + var results any var err error if len(test.op) > 0 { - results, err = ns.Where(test.seq, test.key, test.op, test.match) + results, err = ns.Where(context.Background(), test.seq, test.key, test.op, test.match) } else { - results, err = ns.Where(test.seq, test.key, test.match) + results, err = ns.Where(context.Background(), test.seq, test.key, test.match) } if b, ok := test.expect.(bool); ok && !b { if err == nil { @@ -635,17 +664,17 @@ func TestWhere(t *testing.T) { } var err error - _, err = ns.Where(map[string]int{"a": 1, "b": 2}, "a", []byte("="), 1) + _, err = ns.Where(context.Background(), map[string]int{"a": 1, "b": 2}, "a", []byte("="), 1) if err == nil { t.Errorf("Where called with none string op value didn't return an expected error") } - _, err = ns.Where(map[string]int{"a": 1, "b": 2}, "a", []byte("="), 1, 2) + _, err = ns.Where(context.Background(), map[string]int{"a": 1, "b": 2}, "a", []byte("="), 1, 2) if err == nil { t.Errorf("Where called with more than two variable arguments didn't return an expected error") } - _, err = ns.Where(map[string]int{"a": 1, "b": 2}, "a") + _, err = ns.Where(context.Background(), map[string]int{"a": 1, "b": 2}, "a") if err == nil { t.Errorf("Where called with no variable arguments didn't return an expected error") } @@ -654,7 +683,7 @@ func TestWhere(t *testing.T) { func TestCheckCondition(t *testing.T) { t.Parallel() - ns := New(&deps.Deps{}) + ns := newNs() type expect struct { result bool @@ -732,6 +761,7 @@ func TestCheckCondition(t *testing.T) { expect{true, false}, }, {reflect.ValueOf(123), reflect.ValueOf([]int{45, 678}), "not in", expect{true, false}}, + {reflect.ValueOf(123), reflect.ValueOf([]int{}), "not in", expect{true, false}}, {reflect.ValueOf("foo"), reflect.ValueOf([]string{"bar", "baz"}), "not in", expect{true, false}}, { reflect.ValueOf(time.Date(2015, time.May, 26, 19, 18, 56, 12345, time.UTC)), @@ -759,10 +789,10 @@ func TestCheckCondition(t *testing.T) { {reflect.ValueOf(123), reflect.ValueOf(123), "op", expect{false, true}}, // Issue #3718 - {reflect.ValueOf([]interface{}{"a"}), reflect.ValueOf([]string{"a", "b"}), "intersect", expect{true, false}}, - {reflect.ValueOf([]string{"a"}), reflect.ValueOf([]interface{}{"a", "b"}), "intersect", expect{true, false}}, - {reflect.ValueOf([]interface{}{1, 2}), reflect.ValueOf([]int{1}), "intersect", expect{true, false}}, - {reflect.ValueOf([]int{1}), reflect.ValueOf([]interface{}{1, 2}), "intersect", expect{true, false}}, + {reflect.ValueOf([]any{"a"}), reflect.ValueOf([]string{"a", "b"}), "intersect", expect{true, false}}, + {reflect.ValueOf([]string{"a"}), reflect.ValueOf([]any{"a", "b"}), "intersect", expect{true, false}}, + {reflect.ValueOf([]any{1, 2}), reflect.ValueOf([]int{1}), "intersect", expect{true, false}}, + {reflect.ValueOf([]int{1}), reflect.ValueOf([]any{1, 2}), "intersect", expect{true, false}}, } { result, err := ns.checkCondition(test.value, test.match, test.op) if test.expect.isError { @@ -793,7 +823,7 @@ func TestEvaluateSubElem(t *testing.T) { for i, test := range []struct { value reflect.Value key string - expect interface{} + expect any }{ {reflect.ValueOf(tstx), "A", "foo"}, {reflect.ValueOf(&tstx), "TstRp", "rfoo"}, @@ -815,7 +845,7 @@ func TestEvaluateSubElem(t *testing.T) { {reflect.ValueOf(map[int]string{1: "foo", 2: "bar"}), "1", false}, {reflect.ValueOf([]string{"foo", "bar"}), "1", false}, } { - result, err := evaluateSubElem(test.value, test.key) + result, err := evaluateSubElem(reflect.ValueOf(context.Background()), test.value, test.key) if b, ok := test.expect.(bool); ok && !b { if err == nil { t.Errorf("[%d] evaluateSubElem didn't return an expected error", i) @@ -831,3 +861,61 @@ func TestEvaluateSubElem(t *testing.T) { } } } + +func BenchmarkWhereOps(b *testing.B) { + ns := newNs() + var seq []map[string]string + ctx := context.Background() + for range 500 { + seq = append(seq, map[string]string{"foo": "bar"}) + } + for range 500 { + seq = append(seq, map[string]string{"foo": "baz"}) + } + // Shuffle the sequence. + for i := range seq { + j := rand.Intn(i + 1) + seq[i], seq[j] = seq[j], seq[i] + } + // results, err = ns.Where(context.Background(), test.seq, test.key, test.op, test.match) + runOps := func(b *testing.B, op, match string) { + _, err := ns.Where(ctx, seq, "foo", op, match) + if err != nil { + b.Fatal(err) + } + } + + b.Run("eq", func(b *testing.B) { + for i := 0; i < b.N; i++ { + runOps(b, "eq", "bar") + } + }) + + b.Run("ne", func(b *testing.B) { + for i := 0; i < b.N; i++ { + runOps(b, "ne", "baz") + } + }) + + b.Run("like", func(b *testing.B) { + for i := 0; i < b.N; i++ { + runOps(b, "like", "^bar") + } + }) +} + +func BenchmarkWhereMap(b *testing.B) { + ns := newNs() + seq := map[string]string{} + + for i := range 1000 { + seq[fmt.Sprintf("key%d", i)] = "value" + } + + for i := 0; i < b.N; i++ { + _, err := ns.Where(context.Background(), seq, "key", "eq", "value") + if err != nil { + b.Fatal(err) + } + } +} diff --git a/tpl/compare/compare.go b/tpl/compare/compare.go index e005aff06..d32f3df95 100644 --- a/tpl/compare/compare.go +++ b/tpl/compare/compare.go @@ -16,45 +16,50 @@ package compare import ( "fmt" + "math" "reflect" "strconv" "time" "github.com/gohugoio/hugo/compare" + "github.com/gohugoio/hugo/langs" + "github.com/gohugoio/hugo/common/hreflect" + "github.com/gohugoio/hugo/common/htime" "github.com/gohugoio/hugo/common/types" ) // New returns a new instance of the compare-namespaced template functions. -func New(caseInsensitive bool) *Namespace { - return &Namespace{caseInsensitive: caseInsensitive} +func New(loc *time.Location, caseInsensitive bool) *Namespace { + return &Namespace{loc: loc, caseInsensitive: caseInsensitive} } // Namespace provides template functions for the "compare" namespace. type Namespace struct { + loc *time.Location // Enable to do case insensitive string compares. caseInsensitive bool } -// Default checks whether a given value is set and returns a default value if it +// Default checks whether a givenv is set and returns the default value defaultv if it // is not. "Set" in this context means non-zero for numeric types and times; // non-zero length for strings, arrays, slices, and maps; // any boolean or struct value; or non-nil for any other types. -func (*Namespace) Default(dflt interface{}, given ...interface{}) (interface{}, error) { +func (*Namespace) Default(defaultv any, givenv ...any) (any, error) { // given is variadic because the following construct will not pass a piped // argument when the key is missing: {{ index . "key" | default "foo" }} - // The Go template will complain that we got 1 argument when we expectd 2. + // The Go template will complain that we got 1 argument when we expected 2. - if len(given) == 0 { - return dflt, nil + if len(givenv) == 0 { + return defaultv, nil } - if len(given) != 1 { - return nil, fmt.Errorf("wrong number of args for default: want 2 got %d", len(given)+1) + if len(givenv) != 1 { + return nil, fmt.Errorf("wrong number of args for default: want 2 got %d", len(givenv)+1) } - g := reflect.ValueOf(given[0]) + g := reflect.ValueOf(givenv[0]) if !g.IsValid() { - return dflt, nil + return defaultv, nil } set := false @@ -73,7 +78,7 @@ func (*Namespace) Default(dflt interface{}, given ...interface{}) (interface{}, case reflect.Complex64, reflect.Complex128: set = g.Complex() != 0 case reflect.Struct: - switch actual := given[0].(type) { + switch actual := givenv[0].(type) { case time.Time: set = !actual.IsZero() default: @@ -84,25 +89,27 @@ func (*Namespace) Default(dflt interface{}, given ...interface{}) (interface{}, } if set { - return given[0], nil + return givenv[0], nil } - return dflt, nil + return defaultv, nil } // Eq returns the boolean truth of arg1 == arg2 || arg1 == arg3 || arg1 == arg4. -func (n *Namespace) Eq(first interface{}, others ...interface{}) bool { +func (n *Namespace) Eq(first any, others ...any) bool { if n.caseInsensitive { panic("caseInsensitive not implemented for Eq") } - if len(others) == 0 { - panic("missing arguments for comparison") - } - - normalize := func(v interface{}) interface{} { + n.checkComparisonArgCount(1, others...) + normalize := func(v any) any { if types.IsNil(v) { return nil } + + if at, ok := v.(htime.AsTimeProvider); ok { + return at.AsTime(n.loc) + } + vv := reflect.ValueOf(v) switch vv.Kind() { case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: @@ -110,7 +117,14 @@ func (n *Namespace) Eq(first interface{}, others ...interface{}) bool { case reflect.Float32, reflect.Float64: return vv.Float() case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return vv.Uint() + i := vv.Uint() + // If it can fit in an int, convert it. + if i <= math.MaxInt64 { + return int64(i) + } + return i + case reflect.String: + return vv.String() default: return v } @@ -142,7 +156,8 @@ func (n *Namespace) Eq(first interface{}, others ...interface{}) bool { } // Ne returns the boolean truth of arg1 != arg2 && arg1 != arg3 && arg1 != arg4. -func (n *Namespace) Ne(first interface{}, others ...interface{}) bool { +func (n *Namespace) Ne(first any, others ...any) bool { + n.checkComparisonArgCount(1, others...) for _, other := range others { if n.Eq(first, other) { return false @@ -152,7 +167,8 @@ func (n *Namespace) Ne(first interface{}, others ...interface{}) bool { } // Ge returns the boolean truth of arg1 >= arg2 && arg1 >= arg3 && arg1 >= arg4. -func (n *Namespace) Ge(first interface{}, others ...interface{}) bool { +func (n *Namespace) Ge(first any, others ...any) bool { + n.checkComparisonArgCount(1, others...) for _, other := range others { left, right := n.compareGet(first, other) if !(left >= right) { @@ -163,7 +179,8 @@ func (n *Namespace) Ge(first interface{}, others ...interface{}) bool { } // Gt returns the boolean truth of arg1 > arg2 && arg1 > arg3 && arg1 > arg4. -func (n *Namespace) Gt(first interface{}, others ...interface{}) bool { +func (n *Namespace) Gt(first any, others ...any) bool { + n.checkComparisonArgCount(1, others...) for _, other := range others { left, right := n.compareGet(first, other) if !(left > right) { @@ -174,7 +191,8 @@ func (n *Namespace) Gt(first interface{}, others ...interface{}) bool { } // Le returns the boolean truth of arg1 <= arg2 && arg1 <= arg3 && arg1 <= arg4. -func (n *Namespace) Le(first interface{}, others ...interface{}) bool { +func (n *Namespace) Le(first any, others ...any) bool { + n.checkComparisonArgCount(1, others...) for _, other := range others { left, right := n.compareGet(first, other) if !(left <= right) { @@ -184,10 +202,13 @@ func (n *Namespace) Le(first interface{}, others ...interface{}) bool { return true } -// Lt returns the boolean truth of arg1 < arg2 && arg1 < arg3 && arg1 < arg4. -func (n *Namespace) Lt(first interface{}, others ...interface{}) bool { +// LtCollate returns the boolean truth of arg1 < arg2 && arg1 < arg3 && arg1 < arg4. +// The provided collator will be used for string comparisons. +// This is for internal use. +func (n *Namespace) LtCollate(collator *langs.Collator, first any, others ...any) bool { + n.checkComparisonArgCount(1, others...) for _, other := range others { - left, right := n.compareGet(first, other) + left, right := n.compareGetWithCollator(collator, first, other) if !(left < right) { return false } @@ -195,16 +216,43 @@ func (n *Namespace) Lt(first interface{}, others ...interface{}) bool { return true } -// Conditional can be used as a ternary operator. -// It returns a if condition, else b. -func (n *Namespace) Conditional(condition bool, a, b interface{}) interface{} { - if condition { - return a - } - return b +// Lt returns the boolean truth of arg1 < arg2 && arg1 < arg3 && arg1 < arg4. +func (n *Namespace) Lt(first any, others ...any) bool { + return n.LtCollate(nil, first, others...) } -func (ns *Namespace) compareGet(a interface{}, b interface{}) (float64, float64) { +func (n *Namespace) checkComparisonArgCount(min int, others ...any) bool { + if len(others) < min { + panic("missing arguments for comparison") + } + return true +} + +// Conditional can be used as a ternary operator. +// +// It returns v1 if cond is true, else v2. +func (n *Namespace) Conditional(cond any, v1, v2 any) any { + if hreflect.IsTruthful(cond) { + return v1 + } + return v2 +} + +func (ns *Namespace) compareGet(a any, b any) (float64, float64) { + return ns.compareGetWithCollator(nil, a, b) +} + +func (ns *Namespace) compareTwoUints(a uint64, b uint64) (float64, float64) { + if a < b { + return 1, 0 + } else if a == b { + return 0, 0 + } else { + return 0, 1 + } +} + +func (ns *Namespace) compareGetWithCollator(collator *langs.Collator, a any, b any) (float64, float64) { if ac, ok := a.(compare.Comparer); ok { c := ac.Compare(b) if c < 0 { @@ -230,53 +278,85 @@ func (ns *Namespace) compareGet(a interface{}, b interface{}) (float64, float64) var left, right float64 var leftStr, rightStr *string av := reflect.ValueOf(a) + bv := reflect.ValueOf(b) switch av.Kind() { case reflect.Array, reflect.Chan, reflect.Map, reflect.Slice: left = float64(av.Len()) case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if hreflect.IsUint(bv.Kind()) { + return ns.compareTwoUints(uint64(av.Int()), bv.Uint()) + } left = float64(av.Int()) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32: + left = float64(av.Uint()) + case reflect.Uint64: + if hreflect.IsUint(bv.Kind()) { + return ns.compareTwoUints(av.Uint(), bv.Uint()) + } case reflect.Float32, reflect.Float64: left = av.Float() case reflect.String: var err error left, err = strconv.ParseFloat(av.String(), 64) - if err != nil { + // Check if float is a special floating value and cast value as string. + if math.IsInf(left, 0) || math.IsNaN(left) || err != nil { str := av.String() leftStr = &str } case reflect.Struct: - switch av.Type() { - case timeType: - left = float64(toTimeUnix(av)) + if hreflect.IsTime(av.Type()) { + left = float64(ns.toTimeUnix(av)) + } + case reflect.Bool: + left = 0 + if av.Bool() { + left = 1 } } - bv := reflect.ValueOf(b) - switch bv.Kind() { case reflect.Array, reflect.Chan, reflect.Map, reflect.Slice: right = float64(bv.Len()) case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if hreflect.IsUint(av.Kind()) { + return ns.compareTwoUints(av.Uint(), uint64(bv.Int())) + } right = float64(bv.Int()) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32: + right = float64(bv.Uint()) + case reflect.Uint64: + if hreflect.IsUint(av.Kind()) { + return ns.compareTwoUints(av.Uint(), bv.Uint()) + } case reflect.Float32, reflect.Float64: right = bv.Float() case reflect.String: var err error right, err = strconv.ParseFloat(bv.String(), 64) - if err != nil { + // Check if float is a special floating value and cast value as string. + if math.IsInf(right, 0) || math.IsNaN(right) || err != nil { str := bv.String() rightStr = &str } case reflect.Struct: - switch bv.Type() { - case timeType: - right = float64(toTimeUnix(bv)) + if hreflect.IsTime(bv.Type()) { + right = float64(ns.toTimeUnix(bv)) + } + case reflect.Bool: + right = 0 + if bv.Bool() { + right = 1 } } - if ns.caseInsensitive && leftStr != nil && rightStr != nil { - c := compare.Strings(*leftStr, *rightStr) + if (ns.caseInsensitive || collator != nil) && leftStr != nil && rightStr != nil { + var c int + if collator != nil { + c = collator.CompareStrings(*leftStr, *rightStr) + } else { + c = compare.Strings(*leftStr, *rightStr) + } if c < 0 { return 0, 1 } else if c > 0 { @@ -299,14 +379,10 @@ func (ns *Namespace) compareGet(a interface{}, b interface{}) (float64, float64) return left, right } -var timeType = reflect.TypeOf((*time.Time)(nil)).Elem() - -func toTimeUnix(v reflect.Value) int64 { - if v.Kind() == reflect.Interface { - return toTimeUnix(v.Elem()) - } - if v.Type() != timeType { +func (ns *Namespace) toTimeUnix(v reflect.Value) int64 { + t, ok := hreflect.AsTime(v, ns.loc) + if !ok { panic("coding error: argument must be time.Time type reflect Value") } - return v.MethodByName("Unix").Call([]reflect.Value{})[0].Int() + return t.Unix() } diff --git a/tpl/compare/compare_test.go b/tpl/compare/compare_test.go index 3eb793d30..0ebebef4b 100644 --- a/tpl/compare/compare_test.go +++ b/tpl/compare/compare_test.go @@ -14,16 +14,16 @@ package compare import ( + "math" "path" "reflect" "runtime" "testing" "time" - "github.com/gohugoio/hugo/htesting/hqt" - qt "github.com/frankban/quicktest" "github.com/gohugoio/hugo/common/hugo" + "github.com/gohugoio/hugo/htesting/hqt" "github.com/spf13/cast" ) @@ -44,10 +44,12 @@ var testT = &T{ NonEmptyInterfaceTypedNil: (*T)(nil), } -type tstEqerType1 string -type tstEqerType2 string +type ( + tstEqerType1 string + tstEqerType2 string +) -func (t tstEqerType2) Eq(other interface{}) bool { +func (t tstEqerType2) Eq(other any) bool { return cast.ToString(t) == cast.ToString(other) } @@ -55,7 +57,7 @@ func (t tstEqerType2) String() string { return string(t) } -func (t tstEqerType1) Eq(other interface{}) bool { +func (t tstEqerType1) Eq(other any) bool { return cast.ToString(t) == cast.ToString(other) } @@ -63,6 +65,8 @@ func (t tstEqerType1) String() string { return string(t) } +type stringType string + type tstCompareType int const ( @@ -84,12 +88,12 @@ func TestDefaultFunc(t *testing.T) { then := time.Now() now := time.Now() - ns := New(false) + ns := New(time.UTC, false) for i, test := range []struct { - dflt interface{} - given interface{} - expect interface{} + dflt any + given any + expect any }{ {true, false, false}, {"5", 0, "5"}, @@ -143,35 +147,35 @@ func TestDefaultFunc(t *testing.T) { func TestCompare(t *testing.T) { t.Parallel() - n := New(false) + n := New(time.UTC, false) - twoEq := func(a, b interface{}) bool { + twoEq := func(a, b any) bool { return n.Eq(a, b) } - twoGt := func(a, b interface{}) bool { + twoGt := func(a, b any) bool { return n.Gt(a, b) } - twoLt := func(a, b interface{}) bool { + twoLt := func(a, b any) bool { return n.Lt(a, b) } - twoGe := func(a, b interface{}) bool { + twoGe := func(a, b any) bool { return n.Ge(a, b) } - twoLe := func(a, b interface{}) bool { + twoLe := func(a, b any) bool { return n.Le(a, b) } - twoNe := func(a, b interface{}) bool { + twoNe := func(a, b any) bool { return n.Ne(a, b) } for _, test := range []struct { tstCompareType - funcUnderTest func(a, b interface{}) bool + funcUnderTest func(a, b any) bool }{ {tstGt, twoGt}, {tstLt, twoLt}, @@ -184,10 +188,10 @@ func TestCompare(t *testing.T) { } } -func doTestCompare(t *testing.T, tp tstCompareType, funcUnderTest func(a, b interface{}) bool) { +func doTestCompare(t *testing.T, tp tstCompareType, funcUnderTest func(a, b any) bool) { for i, test := range []struct { - left interface{} - right interface{} + left any + right any expectIndicator int }{ {5, 8, -1}, @@ -195,6 +199,16 @@ func doTestCompare(t *testing.T, tp tstCompareType, funcUnderTest func(a, b inte {5, 5, 0}, {int(5), int64(5), 0}, {int32(5), int(5), 0}, + {int16(4), 4, 0}, + {uint8(4), 4, 0}, + {uint16(4), 4, 0}, + {uint16(4), 4, 0}, + {uint32(4), uint16(4), 0}, + {uint32(4), uint16(3), 1}, + {uint64(4), 4, 0}, + {4, uint64(4), 0}, + {uint64(math.MaxUint32), uint32(math.MaxUint32), 0}, + {uint64(math.MaxUint16), int(math.MaxUint16), 0}, {int16(4), int(5), -1}, {uint(15), uint64(15), 0}, {-2, 1, -1}, @@ -213,6 +227,8 @@ func doTestCompare(t *testing.T, tp tstCompareType, funcUnderTest func(a, b inte {"a", "a", 0}, {"a", "b", -1}, {"b", "a", 1}, + {"infinity", "infinity", 0}, + {"nan", "nan", 0}, {tstEqerType1("a"), tstEqerType1("a"), 0}, {tstEqerType1("a"), tstEqerType2("a"), 0}, {tstEqerType2("a"), tstEqerType1("a"), 0}, @@ -265,19 +281,19 @@ func TestEqualExtend(t *testing.T) { t.Parallel() c := qt.New(t) - ns := New(false) + ns := New(time.UTC, false) for _, test := range []struct { - first interface{} - others []interface{} + first any + others []any expect bool }{ - {1, []interface{}{1, 2}, true}, - {1, []interface{}{2, 1}, true}, - {1, []interface{}{2, 3}, false}, - {tstEqerType1("a"), []interface{}{tstEqerType1("a"), tstEqerType1("b")}, true}, - {tstEqerType1("a"), []interface{}{tstEqerType1("b"), tstEqerType1("a")}, true}, - {tstEqerType1("a"), []interface{}{tstEqerType1("b"), tstEqerType1("c")}, false}, + {1, []any{1, 2}, true}, + {1, []any{2, 1}, true}, + {1, []any{2, 3}, false}, + {tstEqerType1("a"), []any{tstEqerType1("a"), tstEqerType1("b")}, true}, + {tstEqerType1("a"), []any{tstEqerType1("b"), tstEqerType1("a")}, true}, + {tstEqerType1("a"), []any{tstEqerType1("b"), tstEqerType1("c")}, false}, } { result := ns.Eq(test.first, test.others...) @@ -290,16 +306,16 @@ func TestNotEqualExtend(t *testing.T) { t.Parallel() c := qt.New(t) - ns := New(false) + ns := New(time.UTC, false) for _, test := range []struct { - first interface{} - others []interface{} + first any + others []any expect bool }{ - {1, []interface{}{2, 3}, true}, - {1, []interface{}{2, 1}, false}, - {1, []interface{}{1, 2}, false}, + {1, []any{2, 3}, true}, + {1, []any{2, 1}, false}, + {1, []any{1, 2}, false}, } { result := ns.Ne(test.first, test.others...) c.Assert(result, qt.Equals, test.expect) @@ -310,17 +326,17 @@ func TestGreaterEqualExtend(t *testing.T) { t.Parallel() c := qt.New(t) - ns := New(false) + ns := New(time.UTC, false) for _, test := range []struct { - first interface{} - others []interface{} + first any + others []any expect bool }{ - {5, []interface{}{2, 3}, true}, - {5, []interface{}{5, 5}, true}, - {3, []interface{}{4, 2}, false}, - {3, []interface{}{2, 4}, false}, + {5, []any{2, 3}, true}, + {5, []any{5, 5}, true}, + {3, []any{4, 2}, false}, + {3, []any{2, 4}, false}, } { result := ns.Ge(test.first, test.others...) c.Assert(result, qt.Equals, test.expect) @@ -331,16 +347,16 @@ func TestGreaterThanExtend(t *testing.T) { t.Parallel() c := qt.New(t) - ns := New(false) + ns := New(time.UTC, false) for _, test := range []struct { - first interface{} - others []interface{} + first any + others []any expect bool }{ - {5, []interface{}{2, 3}, true}, - {5, []interface{}{5, 4}, false}, - {3, []interface{}{4, 2}, false}, + {5, []any{2, 3}, true}, + {5, []any{5, 4}, false}, + {3, []any{4, 2}, false}, } { result := ns.Gt(test.first, test.others...) c.Assert(result, qt.Equals, test.expect) @@ -351,17 +367,17 @@ func TestLessEqualExtend(t *testing.T) { t.Parallel() c := qt.New(t) - ns := New(false) + ns := New(time.UTC, false) for _, test := range []struct { - first interface{} - others []interface{} + first any + others []any expect bool }{ - {1, []interface{}{2, 3}, true}, - {1, []interface{}{1, 2}, true}, - {2, []interface{}{1, 2}, false}, - {3, []interface{}{2, 4}, false}, + {1, []any{2, 3}, true}, + {1, []any{1, 2}, true}, + {2, []any{1, 2}, false}, + {3, []any{2, 4}, false}, } { result := ns.Le(test.first, test.others...) c.Assert(result, qt.Equals, test.expect) @@ -372,17 +388,17 @@ func TestLessThanExtend(t *testing.T) { t.Parallel() c := qt.New(t) - ns := New(false) + ns := New(time.UTC, false) for _, test := range []struct { - first interface{} - others []interface{} + first any + others []any expect bool }{ - {1, []interface{}{2, 3}, true}, - {1, []interface{}{1, 2}, false}, - {2, []interface{}{1, 2}, false}, - {3, []interface{}{2, 4}, false}, + {1, []any{2, 3}, true}, + {1, []any{1, 2}, false}, + {2, []any{1, 2}, false}, + {3, []any{2, 4}, false}, } { result := ns.Lt(test.first, test.others...) c.Assert(result, qt.Equals, test.expect) @@ -391,7 +407,15 @@ func TestLessThanExtend(t *testing.T) { func TestCase(t *testing.T) { c := qt.New(t) - n := New(true) + n := New(time.UTC, false) + + c.Assert(n.Eq("az", "az"), qt.Equals, true) + c.Assert(n.Eq("az", stringType("az")), qt.Equals, true) +} + +func TestStringType(t *testing.T) { + c := qt.New(t) + n := New(time.UTC, true) c.Assert(n.Lt("az", "Za"), qt.Equals, true) c.Assert(n.Gt("ab", "Ab"), qt.Equals, true) @@ -399,11 +423,12 @@ func TestCase(t *testing.T) { func TestTimeUnix(t *testing.T) { t.Parallel() + n := New(time.UTC, false) var sec int64 = 1234567890 tv := reflect.ValueOf(time.Unix(sec, 0)) i := 1 - res := toTimeUnix(tv) + res := n.toTimeUnix(tv) if sec != res { t.Errorf("[%d] timeUnix got %v but expected %v", i, res, sec) } @@ -416,15 +441,55 @@ func TestTimeUnix(t *testing.T) { } }() iv := reflect.ValueOf(sec) - toTimeUnix(iv) + n.toTimeUnix(iv) }(t) } func TestConditional(t *testing.T) { + t.Parallel() c := qt.New(t) - n := New(false) - a, b := "a", "b" + ns := New(time.UTC, false) - c.Assert(n.Conditional(true, a, b), qt.Equals, a) - c.Assert(n.Conditional(false, a, b), qt.Equals, b) + type args struct { + cond any + v1 any + v2 any + } + tests := []struct { + name string + args args + want any + }{ + {"a", args{cond: true, v1: "true", v2: "false"}, "true"}, + {"b", args{cond: false, v1: "true", v2: "false"}, "false"}, + {"c", args{cond: 1, v1: "true", v2: "false"}, "true"}, + {"d", args{cond: 0, v1: "true", v2: "false"}, "false"}, + {"e", args{cond: "foo", v1: "true", v2: "false"}, "true"}, + {"f", args{cond: "", v1: "true", v2: "false"}, "false"}, + {"g", args{cond: []int{6, 7}, v1: "true", v2: "false"}, "true"}, + {"h", args{cond: []int{}, v1: "true", v2: "false"}, "false"}, + {"i", args{cond: nil, v1: "true", v2: "false"}, "false"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c.Assert(ns.Conditional(tt.args.cond, tt.args.v1, tt.args.v2), qt.Equals, tt.want) + }) + } +} + +// Issue 9462 +func TestComparisonArgCount(t *testing.T) { + t.Parallel() + c := qt.New(t) + + ns := New(time.UTC, false) + + panicMsg := "missing arguments for comparison" + + c.Assert(func() { ns.Eq(1) }, qt.PanicMatches, panicMsg) + c.Assert(func() { ns.Ge(1) }, qt.PanicMatches, panicMsg) + c.Assert(func() { ns.Gt(1) }, qt.PanicMatches, panicMsg) + c.Assert(func() { ns.Le(1) }, qt.PanicMatches, panicMsg) + c.Assert(func() { ns.Lt(1) }, qt.PanicMatches, panicMsg) + c.Assert(func() { ns.Ne(1) }, qt.PanicMatches, panicMsg) } diff --git a/tpl/compare/init.go b/tpl/compare/init.go index 3b9dc6856..f70b19254 100644 --- a/tpl/compare/init.go +++ b/tpl/compare/init.go @@ -14,7 +14,10 @@ package compare import ( + "context" + "github.com/gohugoio/hugo/deps" + "github.com/gohugoio/hugo/langs" "github.com/gohugoio/hugo/tpl/internal" ) @@ -22,11 +25,16 @@ const name = "compare" func init() { f := func(d *deps.Deps) *internal.TemplateFuncsNamespace { - ctx := New(false) + language := d.Conf.Language() + if language == nil { + panic("language must be set") + } + + ctx := New(langs.GetLocation(language), false) ns := &internal.TemplateFuncsNamespace{ Name: name, - Context: func(args ...interface{}) interface{} { return ctx }, + Context: func(cctx context.Context, args ...any) (any, error) { return ctx, nil }, } ns.AddMethodMapping(ctx.Default, @@ -40,14 +48,14 @@ func init() { ns.AddMethodMapping(ctx.Eq, []string{"eq"}, [][2]string{ - {`{{ if eq .Section "blog" }}current{{ end }}`, `current`}, + {`{{ if eq .Section "blog" }}current-section{{ end }}`, `current-section`}, }, ) ns.AddMethodMapping(ctx.Ge, []string{"ge"}, [][2]string{ - {`{{ if ge .Hugo.Version "0.36" }}Reasonable new Hugo version!{{ end }}`, `Reasonable new Hugo version!`}, + {`{{ if ge hugo.Version "0.80" }}Reasonable new Hugo version!{{ end }}`, `Reasonable new Hugo version!`}, }, ) @@ -79,7 +87,6 @@ func init() { ) return ns - } internal.AddTemplateFuncsNamespace(f) diff --git a/tpl/compare/init_test.go b/tpl/compare/init_test.go deleted file mode 100644 index 29a525f93..000000000 --- a/tpl/compare/init_test.go +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2017 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 compare - -import ( - "testing" - - qt "github.com/frankban/quicktest" - "github.com/gohugoio/hugo/deps" - "github.com/gohugoio/hugo/htesting/hqt" - "github.com/gohugoio/hugo/tpl/internal" -) - -func TestInit(t *testing.T) { - c := qt.New(t) - var found bool - var ns *internal.TemplateFuncsNamespace - - for _, nsf := range internal.TemplateFuncsNamespaceRegistry { - ns = nsf(&deps.Deps{}) - if ns.Name == name { - found = true - break - } - } - - c.Assert(found, qt.Equals, true) - c.Assert(ns.Context(), hqt.IsSameType, &Namespace{}) -} diff --git a/tpl/crypto/crypto.go b/tpl/crypto/crypto.go index 5771c98b5..677f59139 100644 --- a/tpl/crypto/crypto.go +++ b/tpl/crypto/crypto.go @@ -15,11 +15,17 @@ package crypto import ( + "crypto/hmac" "crypto/md5" "crypto/sha1" "crypto/sha256" + "crypto/sha512" "encoding/hex" + "fmt" + "hash" + "hash/fnv" + "github.com/gohugoio/hugo/common/hugo" "github.com/spf13/cast" ) @@ -31,9 +37,9 @@ func New() *Namespace { // Namespace provides template functions for the "crypto" namespace. type Namespace struct{} -// MD5 hashes the given input and returns its MD5 checksum. -func (ns *Namespace) MD5(in interface{}) (string, error) { - conv, err := cast.ToStringE(in) +// MD5 hashes the v and returns its MD5 checksum. +func (ns *Namespace) MD5(v any) (string, error) { + conv, err := cast.ToStringE(v) if err != nil { return "", err } @@ -42,9 +48,9 @@ func (ns *Namespace) MD5(in interface{}) (string, error) { return hex.EncodeToString(hash[:]), nil } -// SHA1 hashes the given input and returns its SHA1 checksum. -func (ns *Namespace) SHA1(in interface{}) (string, error) { - conv, err := cast.ToStringE(in) +// SHA1 hashes v and returns its SHA1 checksum. +func (ns *Namespace) SHA1(v any) (string, error) { + conv, err := cast.ToStringE(v) if err != nil { return "", err } @@ -53,9 +59,9 @@ func (ns *Namespace) SHA1(in interface{}) (string, error) { return hex.EncodeToString(hash[:]), nil } -// SHA256 hashes the given input and returns its SHA256 checksum. -func (ns *Namespace) SHA256(in interface{}) (string, error) { - conv, err := cast.ToStringE(in) +// SHA256 hashes v and returns its SHA256 checksum. +func (ns *Namespace) SHA256(v any) (string, error) { + conv, err := cast.ToStringE(v) if err != nil { return "", err } @@ -63,3 +69,71 @@ func (ns *Namespace) SHA256(in interface{}) (string, error) { hash := sha256.Sum256([]byte(conv)) return hex.EncodeToString(hash[:]), nil } + +// FNV32a hashes v using fnv32a algorithm. +// {"newIn": "0.98.0" } +func (ns *Namespace) FNV32a(v any) (int, error) { + hugo.Deprecate("crypto.FNV32a", "Use hash.FNV32a.", "v0.129.0") + conv, err := cast.ToStringE(v) + if err != nil { + return 0, err + } + algorithm := fnv.New32a() + algorithm.Write([]byte(conv)) + return int(algorithm.Sum32()), nil +} + +// HMAC returns a cryptographic hash that uses a key to sign a message. +func (ns *Namespace) HMAC(h any, k any, m any, e ...any) (string, error) { + ha, err := cast.ToStringE(h) + if err != nil { + return "", err + } + + var hash func() hash.Hash + switch ha { + case "md5": + hash = md5.New + case "sha1": + hash = sha1.New + case "sha256": + hash = sha256.New + case "sha512": + hash = sha512.New + default: + return "", fmt.Errorf("hmac: %s is not a supported hash function", ha) + } + + msg, err := cast.ToStringE(m) + if err != nil { + return "", err + } + + key, err := cast.ToStringE(k) + if err != nil { + return "", err + } + + mac := hmac.New(hash, []byte(key)) + _, err = mac.Write([]byte(msg)) + if err != nil { + return "", err + } + + encoding := "hex" + if len(e) > 0 && e[0] != nil { + encoding, err = cast.ToStringE(e[0]) + if err != nil { + return "", err + } + } + + switch encoding { + case "binary": + return string(mac.Sum(nil)[:]), nil + case "hex": + return hex.EncodeToString(mac.Sum(nil)[:]), nil + default: + return "", fmt.Errorf("%q is not a supported encoding method", encoding) + } +} diff --git a/tpl/crypto/crypto_test.go b/tpl/crypto/crypto_test.go index 209ef9f0a..b6b2a6915 100644 --- a/tpl/crypto/crypto_test.go +++ b/tpl/crypto/crypto_test.go @@ -26,8 +26,8 @@ func TestMD5(t *testing.T) { ns := New() for i, test := range []struct { - in interface{} - expect interface{} + in any + expect any }{ {"Hello world, gophers!", "b3029f756f98f79e7f1b7f1d1f0dd53b"}, {"Lorem ipsum dolor", "06ce65ac476fc656bea3fca5d02cfd81"}, @@ -53,8 +53,8 @@ func TestSHA1(t *testing.T) { ns := New() for i, test := range []struct { - in interface{} - expect interface{} + in any + expect any }{ {"Hello world, gophers!", "c8b5b0e33d408246e30f53e32b8f7627a7a649d4"}, {"Lorem ipsum dolor", "45f75b844be4d17b3394c6701768daf39419c99b"}, @@ -80,8 +80,8 @@ func TestSHA256(t *testing.T) { ns := New() for i, test := range []struct { - in interface{} - expect interface{} + in any + expect any }{ {"Hello world, gophers!", "6ec43b78da9669f50e4e422575c54bf87536954ccd58280219c393f2ce352b46"}, {"Lorem ipsum dolor", "9b3e1beb7053e0f900a674dd1c99aca3355e1275e1b03d3cb1bc977f5154e196"}, @@ -100,3 +100,39 @@ func TestSHA256(t *testing.T) { c.Assert(result, qt.Equals, test.expect, errMsg) } } + +func TestHMAC(t *testing.T) { + t.Parallel() + c := qt.New(t) + ns := New() + + for i, test := range []struct { + hash any + key any + msg any + encoding any + expect any + }{ + {"md5", "Secret key", "Hello world, gophers!", nil, "36eb69b6bf2de96b6856fdee8bf89754"}, + {"sha1", "Secret key", "Hello world, gophers!", nil, "84a76647de6cd47ac6ae4258e3753f711172ce68"}, + {"sha256", "Secret key", "Hello world, gophers!", nil, "b6d11b6c53830b9d87036272ca9fe9d19306b8f9d8aa07b15da27d89e6e34f40"}, + {"sha512", "Secret key", "Hello world, gophers!", nil, "dc3e586cd936865e2abc4c12665e9cc568b2dad714df3c9037cbea159d036cfc4209da9e3fcd30887ff441056941966899f6fb7eec9646ff9ddb592595a8eb7f"}, + {"md5", "Secret key", "Hello world, gophers!", "hex", "36eb69b6bf2de96b6856fdee8bf89754"}, + {"md5", "Secret key", "Hello world, gophers!", "binary", "6\xebi\xb6\xbf-\xe9khV\xfd\xee\x8b\xf8\x97T"}, + {"md5", "Secret key", "Hello world, gophers!", "foo", false}, + {"md5", "Secret key", "Hello world, gophers!", "", false}, + {"", t, "", nil, false}, + } { + errMsg := qt.Commentf("[%d] %v, %v, %v, %v", i, test.hash, test.key, test.msg, test.encoding) + + result, err := ns.HMAC(test.hash, test.key, test.msg, test.encoding) + + if b, ok := test.expect.(bool); ok && !b { + c.Assert(err, qt.Not(qt.IsNil), errMsg) + continue + } + + c.Assert(err, qt.IsNil, errMsg) + c.Assert(result, qt.Equals, test.expect, errMsg) + } +} diff --git a/tpl/crypto/init.go b/tpl/crypto/init.go index db6a5f92c..0527fba06 100644 --- a/tpl/crypto/init.go +++ b/tpl/crypto/init.go @@ -14,6 +14,8 @@ package crypto import ( + "context" + "github.com/gohugoio/hugo/deps" "github.com/gohugoio/hugo/tpl/internal" ) @@ -26,7 +28,7 @@ func init() { ns := &internal.TemplateFuncsNamespace{ Name: name, - Context: func(args ...interface{}) interface{} { return ctx }, + Context: func(cctx context.Context, args ...any) (any, error) { return ctx, nil }, } ns.AddMethodMapping(ctx.MD5, @@ -51,8 +53,14 @@ func init() { }, ) - return ns + ns.AddMethodMapping(ctx.HMAC, + []string{"hmac"}, + [][2]string{ + {`{{ hmac "sha256" "Secret key" "Hello world, gophers!" }}`, `b6d11b6c53830b9d87036272ca9fe9d19306b8f9d8aa07b15da27d89e6e34f40`}, + }, + ) + return ns } internal.AddTemplateFuncsNamespace(f) diff --git a/tpl/crypto/init_test.go b/tpl/crypto/init_test.go deleted file mode 100644 index 120e1e4e7..000000000 --- a/tpl/crypto/init_test.go +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2017 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 crypto - -import ( - "testing" - - qt "github.com/frankban/quicktest" - "github.com/gohugoio/hugo/deps" - "github.com/gohugoio/hugo/htesting/hqt" - "github.com/gohugoio/hugo/tpl/internal" -) - -func TestInit(t *testing.T) { - c := qt.New(t) - var found bool - var ns *internal.TemplateFuncsNamespace - - for _, nsf := range internal.TemplateFuncsNamespaceRegistry { - ns = nsf(&deps.Deps{}) - if ns.Name == name { - found = true - break - } - } - - c.Assert(found, qt.Equals, true) - c.Assert(ns.Context(), hqt.IsSameType, &Namespace{}) -} diff --git a/tpl/css/css.go b/tpl/css/css.go new file mode 100644 index 000000000..199deda69 --- /dev/null +++ b/tpl/css/css.go @@ -0,0 +1,192 @@ +package css + +import ( + "context" + "errors" + "fmt" + "sync" + + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/common/paths" + "github.com/gohugoio/hugo/common/types/css" + "github.com/gohugoio/hugo/deps" + "github.com/gohugoio/hugo/resources" + "github.com/gohugoio/hugo/resources/resource" + "github.com/gohugoio/hugo/resources/resource_transformers/babel" + "github.com/gohugoio/hugo/resources/resource_transformers/cssjs" + "github.com/gohugoio/hugo/resources/resource_transformers/tocss/dartsass" + "github.com/gohugoio/hugo/resources/resource_transformers/tocss/sass" + "github.com/gohugoio/hugo/resources/resource_transformers/tocss/scss" + "github.com/gohugoio/hugo/tpl/internal" + "github.com/gohugoio/hugo/tpl/internal/resourcehelpers" + "github.com/spf13/cast" +) + +const name = "css" + +// Namespace provides template functions for the "css" namespace. +type Namespace struct { + d *deps.Deps + scssClientLibSass *scss.Client + postcssClient *cssjs.PostCSSClient + tailwindcssClient *cssjs.TailwindCSSClient + babelClient *babel.Client + + // The Dart Client requires a os/exec process, so only + // create it if we really need it. + // This is mostly to avoid creating one per site build test. + scssClientDartSassInit sync.Once + scssClientDartSass *dartsass.Client +} + +// Quoted returns a string that needs to be quoted in CSS. +func (ns *Namespace) Quoted(v any) css.QuotedString { + s := cast.ToString(v) + return css.QuotedString(s) +} + +// Unquoted returns a string that does not need to be quoted in CSS. +func (ns *Namespace) Unquoted(v any) css.UnquotedString { + s := cast.ToString(v) + return css.UnquotedString(s) +} + +// PostCSS processes the given Resource with PostCSS. +func (ns *Namespace) PostCSS(args ...any) (resource.Resource, error) { + if len(args) > 2 { + return nil, errors.New("must not provide more arguments than resource object and options") + } + + r, m, err := resourcehelpers.ResolveArgs(args) + if err != nil { + return nil, err + } + + return ns.postcssClient.Process(r, m) +} + +// TailwindCSS processes the given Resource with tailwindcss. +func (ns *Namespace) TailwindCSS(args ...any) (resource.Resource, error) { + if len(args) > 2 { + return nil, errors.New("must not provide more arguments than resource object and options") + } + + r, m, err := resourcehelpers.ResolveArgs(args) + if err != nil { + return nil, err + } + + return ns.tailwindcssClient.Process(r, m) +} + +// Sass processes the given Resource with SASS. +func (ns *Namespace) Sass(args ...any) (resource.Resource, error) { + if len(args) > 2 { + return nil, errors.New("must not provide more arguments than resource object and options") + } + + var ( + r resources.ResourceTransformer + m map[string]any + targetPath string + err error + ok bool + transpiler = sass.TranspilerLibSass + ) + + r, targetPath, ok = resourcehelpers.ResolveIfFirstArgIsString(args) + + if !ok { + r, m, err = resourcehelpers.ResolveArgs(args) + if err != nil { + return nil, err + } + } + + if m != nil { + if t, _, found := maps.LookupEqualFold(m, "transpiler"); found { + switch t { + case sass.TranspilerDart, sass.TranspilerLibSass: + transpiler = cast.ToString(t) + default: + return nil, fmt.Errorf("unsupported transpiler %q; valid values are %q or %q", t, sass.TranspilerLibSass, sass.TranspilerDart) + } + } + } + + if transpiler == sass.TranspilerLibSass { + var options scss.Options + if targetPath != "" { + options.TargetPath = paths.ToSlashTrimLeading(targetPath) + } else if m != nil { + options, err = scss.DecodeOptions(m) + if err != nil { + return nil, err + } + } + + return ns.scssClientLibSass.ToCSS(r, options) + } + + if m == nil { + m = make(map[string]any) + } + if targetPath != "" { + m["targetPath"] = targetPath + } + + client, err := ns.getscssClientDartSass() + if err != nil { + return nil, err + } + + return client.ToCSS(r, m) +} + +func init() { + f := func(d *deps.Deps) *internal.TemplateFuncsNamespace { + scssClient, err := scss.New(d.BaseFs.Assets, d.ResourceSpec) + if err != nil { + panic(err) + } + ctx := &Namespace{ + d: d, + scssClientLibSass: scssClient, + postcssClient: cssjs.NewPostCSSClient(d.ResourceSpec), + tailwindcssClient: cssjs.NewTailwindCSSClient(d.ResourceSpec), + babelClient: babel.New(d.ResourceSpec), + } + + ns := &internal.TemplateFuncsNamespace{ + Name: name, + Context: func(cctx context.Context, args ...any) (any, error) { return ctx, nil }, + } + + ns.AddMethodMapping(ctx.Sass, + []string{"toCSS"}, + [][2]string{}, + ) + + ns.AddMethodMapping(ctx.PostCSS, + []string{"postCSS"}, + [][2]string{}, + ) + + return ns + } + + internal.AddTemplateFuncsNamespace(f) +} + +func (ns *Namespace) getscssClientDartSass() (*dartsass.Client, error) { + var err error + ns.scssClientDartSassInit.Do(func() { + ns.scssClientDartSass, err = dartsass.New(ns.d.BaseFs.Assets, ns.d.ResourceSpec) + if err != nil { + return + } + ns.d.BuildClosers.Add(ns.scssClientDartSass) + }) + + return ns.scssClientDartSass, err +} diff --git a/tpl/data/data.go b/tpl/data/data.go index f64ba0127..ca1796826 100644 --- a/tpl/data/data.go +++ b/tpl/data/data.go @@ -20,23 +20,31 @@ import ( "encoding/csv" "encoding/json" "errors" + "fmt" "net/http" "strings" + "github.com/gohugoio/hugo/cache/filecache" + "github.com/gohugoio/hugo/common/hugo" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/config/security" + + "github.com/gohugoio/hugo/common/types" + + "github.com/gohugoio/hugo/common/constants" + "github.com/spf13/cast" - "github.com/gohugoio/hugo/cache/filecache" "github.com/gohugoio/hugo/deps" - _errors "github.com/pkg/errors" + "slices" ) // New returns a new instance of the data-namespaced template functions. func New(deps *deps.Deps) *Namespace { - return &Namespace{ deps: deps, - cacheGetCSV: deps.FileCaches.GetCSVCache(), - cacheGetJSON: deps.FileCaches.GetJSONCache(), + cacheGetCSV: deps.ResourceSpec.FileCaches.GetCSVCache(), + cacheGetJSON: deps.ResourceSpec.FileCaches.GetJSONCache(), client: http.DefaultClient, } } @@ -51,22 +59,20 @@ type Namespace struct { client *http.Client } -// GetCSV expects a data separator and one or n-parts of a URL to a resource which +// GetCSV expects the separator sep and one or n-parts of a URL to a resource which // can either be a local or a remote one. // The data separator can be a comma, semi-colon, pipe, etc, but only one character. // If you provide multiple parts for the URL they will be joined together to the final URL. // GetCSV returns nil or a slice slice to use in a short code. -func (ns *Namespace) GetCSV(sep string, urlParts ...interface{}) (d [][]string, err error) { - url := joinURL(urlParts) +func (ns *Namespace) GetCSV(sep string, args ...any) (d [][]string, err error) { + hugo.Deprecate("data.GetCSV", "use resources.Get or resources.GetRemote with transform.Unmarshal.", "v0.123.0") + + url, headers := toURLAndHeaders(args) cache := ns.cacheGetCSV unmarshal := func(b []byte) (bool, error) { - if !bytes.Contains(b, []byte(sep)) { - return false, _errors.Errorf("cannot find separator %s in CSV for %s", sep, url) - } - if d, err = parseCSV(b, sep); err != nil { - err = _errors.Wrapf(err, "failed to parse CSV file %s", url) + err = fmt.Errorf("failed to parse CSV file %s: %w", url, err) return true, err } @@ -77,32 +83,38 @@ func (ns *Namespace) GetCSV(sep string, urlParts ...interface{}) (d [][]string, var req *http.Request req, err = http.NewRequest("GET", url, nil) if err != nil { - return nil, _errors.Wrapf(err, "failed to create request for getCSV for resource %s", url) + return nil, fmt.Errorf("failed to create request for getCSV for resource %s: %w", url, err) } - req.Header.Add("Accept", "text/csv") - req.Header.Add("Accept", "text/plain") + // Add custom user headers. + addUserProvidedHeaders(headers, req) + addDefaultHeaders(req, "text/csv", "text/plain") err = ns.getResource(cache, unmarshal, req) if err != nil { - ns.deps.Log.ERROR.Printf("Failed to get CSV resource %q: %s", url, err) + if security.IsAccessDenied(err) { + return nil, err + } + ns.deps.Log.Erroridf(constants.ErrRemoteGetCSV, "Failed to get CSV resource %q: %s", url, err) return nil, nil } return } -// GetJSON expects one or n-parts of a URL to a resource which can either be a local or a remote one. +// GetJSON expects one or n-parts of a URL in args to a resource which can either be a local or a remote one. // If you provide multiple parts they will be joined together to the final URL. // GetJSON returns nil or parsed JSON to use in a short code. -func (ns *Namespace) GetJSON(urlParts ...interface{}) (interface{}, error) { - var v interface{} - url := joinURL(urlParts) +func (ns *Namespace) GetJSON(args ...any) (any, error) { + hugo.Deprecate("data.GetJSON", "use resources.Get or resources.GetRemote with transform.Unmarshal.", "v0.123.0") + + var v any + url, headers := toURLAndHeaders(args) cache := ns.cacheGetJSON req, err := http.NewRequest("GET", url, nil) if err != nil { - return nil, _errors.Wrapf(err, "Failed to create request for getJSON resource %s", url) + return nil, fmt.Errorf("failed to create request for getJSON resource %s: %w", url, err) } unmarshal := func(b []byte) (bool, error) { @@ -113,19 +125,74 @@ func (ns *Namespace) GetJSON(urlParts ...interface{}) (interface{}, error) { return false, nil } - req.Header.Add("Accept", "application/json") + addUserProvidedHeaders(headers, req) + addDefaultHeaders(req, "application/json") err = ns.getResource(cache, unmarshal, req) if err != nil { - ns.deps.Log.ERROR.Printf("Failed to get JSON resource %q: %s", url, err) + if security.IsAccessDenied(err) { + return nil, err + } + ns.deps.Log.Erroridf(constants.ErrRemoteGetJSON, "Failed to get JSON resource %q: %s", url, err) return nil, nil } return v, nil } -func joinURL(urlParts []interface{}) string { - return strings.Join(cast.ToStringSlice(urlParts), "") +func addDefaultHeaders(req *http.Request, accepts ...string) { + for _, accept := range accepts { + if !hasHeaderValue(req.Header, "Accept", accept) { + req.Header.Add("Accept", accept) + } + } + if !hasHeaderKey(req.Header, "User-Agent") { + req.Header.Add("User-Agent", "Hugo Static Site Generator") + } +} + +func addUserProvidedHeaders(headers map[string]any, req *http.Request) { + if headers == nil { + return + } + for key, val := range headers { + vals := types.ToStringSlicePreserveString(val) + for _, s := range vals { + req.Header.Add(key, s) + } + } +} + +func hasHeaderValue(m http.Header, key, value string) bool { + var s []string + var ok bool + + if s, ok = m[key]; !ok { + return false + } + + return slices.Contains(s, value) +} + +func hasHeaderKey(m http.Header, key string) bool { + _, ok := m[key] + return ok +} + +func toURLAndHeaders(urlParts []any) (string, map[string]any) { + if len(urlParts) == 0 { + return "", nil + } + + // The last argument may be a map. + headers, err := maps.ToStringMapE(urlParts[len(urlParts)-1]) + if err == nil { + urlParts = urlParts[:len(urlParts)-1] + } else { + headers = nil + } + + return strings.Join(cast.ToStringSlice(urlParts), ""), headers } // parseCSV parses bytes of CSV data into a slice slice string or an error diff --git a/tpl/data/data_test.go b/tpl/data/data_test.go index 8bd4edc98..7c49d8076 100644 --- a/tpl/data/data_test.go +++ b/tpl/data/data_test.go @@ -14,12 +14,17 @@ package data import ( + "bytes" + "html/template" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" + "github.com/bep/logg" + "github.com/gohugoio/hugo/common/maps" + qt "github.com/frankban/quicktest" ) @@ -31,7 +36,7 @@ func TestGetCSV(t *testing.T) { sep string url string content string - expect interface{} + expect any }{ // Remotes { @@ -46,12 +51,6 @@ func TestGetCSV(t *testing.T) { "gomeetup,city\nyes,Sydney\nyes,San Francisco\nyes,Stockholm,EXTRA\n", false, }, - { - ",", - `http://error.no.sep/`, - "gomeetup;city\nyes;Sydney\nyes;San Francisco\nyes;Stockholm\n", - false, - }, { ",", `http://nofound/404`, @@ -73,52 +72,52 @@ func TestGetCSV(t *testing.T) { false, }, } { - msg := qt.Commentf("Test %d", i) + c.Run(test.url, func(c *qt.C) { + msg := qt.Commentf("Test %d", i) - ns := newTestNs() + ns := newTestNs() - // Setup HTTP test server - var srv *httptest.Server - srv, ns.client = getTestServer(func(w http.ResponseWriter, r *http.Request) { - if !haveHeader(r.Header, "Accept", "text/csv") && !haveHeader(r.Header, "Accept", "text/plain") { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + // Setup HTTP test server + var srv *httptest.Server + srv, ns.client = getTestServer(func(w http.ResponseWriter, r *http.Request) { + if !hasHeaderValue(r.Header, "Accept", "text/csv") && !hasHeaderValue(r.Header, "Accept", "text/plain") { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + if r.URL.Path == "/404" { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + + w.Header().Add("Content-type", "text/csv") + + w.Write([]byte(test.content)) + }) + defer func() { srv.Close() }() + + // Setup local test file for schema-less URLs + if !strings.Contains(test.url, ":") && !strings.HasPrefix(test.url, "fail/") { + f, err := ns.deps.Fs.Source.Create(filepath.Join(ns.deps.Conf.BaseConfig().WorkingDir, test.url)) + c.Assert(err, qt.IsNil, msg) + f.WriteString(test.content) + f.Close() + } + + // Get on with it + got, err := ns.GetCSV(test.sep, test.url) + + if _, ok := test.expect.(bool); ok { + c.Assert(int(ns.deps.Log.LoggCount(logg.LevelError)), qt.Equals, 1) + c.Assert(got, qt.IsNil) return } - if r.URL.Path == "/404" { - http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) - return - } - - w.Header().Add("Content-type", "text/csv") - - w.Write([]byte(test.content)) - }) - defer func() { srv.Close() }() - - // Setup local test file for schema-less URLs - if !strings.Contains(test.url, ":") && !strings.HasPrefix(test.url, "fail/") { - f, err := ns.deps.Fs.Source.Create(filepath.Join(ns.deps.Cfg.GetString("workingDir"), test.url)) c.Assert(err, qt.IsNil, msg) - f.WriteString(test.content) - f.Close() - } - - // Get on with it - got, err := ns.GetCSV(test.sep, test.url) - - if _, ok := test.expect.(bool); ok { - c.Assert(int(ns.deps.Log.ErrorCounter.Count()), qt.Equals, 1) - //c.Assert(err, msg, qt.Not(qt.IsNil)) - c.Assert(got, qt.IsNil) - continue - } - - c.Assert(err, qt.IsNil, msg) - c.Assert(int(ns.deps.Log.ErrorCounter.Count()), qt.Equals, 0) - c.Assert(got, qt.Not(qt.IsNil), msg) - c.Assert(got, qt.DeepEquals, test.expect, msg) - + c.Assert(int(ns.deps.Log.LoggCount(logg.LevelError)), qt.Equals, 0) + c.Assert(got, qt.Not(qt.IsNil), msg) + c.Assert(got, qt.DeepEquals, test.expect, msg) + }) } } @@ -129,12 +128,12 @@ func TestGetJSON(t *testing.T) { for i, test := range []struct { url string content string - expect interface{} + expect any }{ { `http://success/`, `{"gomeetup":["Sydney","San Francisco","Stockholm"]}`, - map[string]interface{}{"gomeetup": []interface{}{"Sydney", "San Francisco", "Stockholm"}}, + map[string]any{"gomeetup": []any{"Sydney", "San Francisco", "Stockholm"}}, }, { `http://malformed/`, @@ -150,64 +149,159 @@ func TestGetJSON(t *testing.T) { { "pass/semi", `{"gomeetup":["Sydney","San Francisco","Stockholm"]}`, - map[string]interface{}{"gomeetup": []interface{}{"Sydney", "San Francisco", "Stockholm"}}, + map[string]any{"gomeetup": []any{"Sydney", "San Francisco", "Stockholm"}}, }, { "fail/no-file", "", false, }, + { + `pass/üńīçøðê-url.json`, + `{"gomeetup":["Sydney","San Francisco","Stockholm"]}`, + map[string]any{"gomeetup": []any{"Sydney", "San Francisco", "Stockholm"}}, + }, } { + c.Run(test.url, func(c *qt.C) { + msg := qt.Commentf("Test %d", i) + ns := newTestNs() - msg := qt.Commentf("Test %d", i) - ns := newTestNs() + // Setup HTTP test server + var srv *httptest.Server + srv, ns.client = getTestServer(func(w http.ResponseWriter, r *http.Request) { + if !hasHeaderValue(r.Header, "Accept", "application/json") { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } - // Setup HTTP test server - var srv *httptest.Server - srv, ns.client = getTestServer(func(w http.ResponseWriter, r *http.Request) { - if !haveHeader(r.Header, "Accept", "application/json") { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + if r.URL.Path == "/404" { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + + w.Header().Add("Content-type", "application/json") + + w.Write([]byte(test.content)) + }) + defer func() { srv.Close() }() + + // Setup local test file for schema-less URLs + if !strings.Contains(test.url, ":") && !strings.HasPrefix(test.url, "fail/") { + f, err := ns.deps.Fs.Source.Create(filepath.Join(ns.deps.Conf.BaseConfig().WorkingDir, test.url)) + c.Assert(err, qt.IsNil, msg) + f.WriteString(test.content) + f.Close() + } + + // Get on with it + got, _ := ns.GetJSON(test.url) + + if _, ok := test.expect.(bool); ok { + c.Assert(int(ns.deps.Log.LoggCount(logg.LevelError)), qt.Equals, 1) return } - if r.URL.Path == "/404" { - http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) - return - } - - w.Header().Add("Content-type", "application/json") - - w.Write([]byte(test.content)) + c.Assert(int(ns.deps.Log.LoggCount(logg.LevelError)), qt.Equals, 0, msg) + c.Assert(got, qt.Not(qt.IsNil), msg) + c.Assert(got, qt.DeepEquals, test.expect) }) - defer func() { srv.Close() }() - - // Setup local test file for schema-less URLs - if !strings.Contains(test.url, ":") && !strings.HasPrefix(test.url, "fail/") { - f, err := ns.deps.Fs.Source.Create(filepath.Join(ns.deps.Cfg.GetString("workingDir"), test.url)) - c.Assert(err, qt.IsNil, msg) - f.WriteString(test.content) - f.Close() - } - - // Get on with it - got, _ := ns.GetJSON(test.url) - - if _, ok := test.expect.(bool); ok { - c.Assert(int(ns.deps.Log.ErrorCounter.Count()), qt.Equals, 1) - //c.Assert(err, msg, qt.Not(qt.IsNil)) - continue - } - - c.Assert(int(ns.deps.Log.ErrorCounter.Count()), qt.Equals, 0, msg) - c.Assert(got, qt.Not(qt.IsNil), msg) - c.Assert(got, qt.DeepEquals, test.expect) } } -func TestJoinURL(t *testing.T) { +func TestHeaders(t *testing.T) { t.Parallel() c := qt.New(t) - c.Assert(joinURL([]interface{}{"https://foo?id=", 32}), qt.Equals, "https://foo?id=32") + + for _, test := range []struct { + name string + headers any + assert func(c *qt.C, headers string) + }{ + { + `Misc header variants`, + map[string]any{ + "Accept-Charset": "utf-8", + "Max-forwards": "10", + "X-Int": 32, + "X-Templ": template.HTML("a"), + "X-Multiple": []string{"a", "b"}, + "X-MultipleInt": []int{3, 4}, + }, + func(c *qt.C, headers string) { + c.Assert(headers, qt.Contains, "Accept-Charset: utf-8") + c.Assert(headers, qt.Contains, "Max-Forwards: 10") + c.Assert(headers, qt.Contains, "X-Int: 32") + c.Assert(headers, qt.Contains, "X-Templ: a") + c.Assert(headers, qt.Contains, "X-Multiple: a") + c.Assert(headers, qt.Contains, "X-Multiple: b") + c.Assert(headers, qt.Contains, "X-Multipleint: 3") + c.Assert(headers, qt.Contains, "X-Multipleint: 4") + c.Assert(headers, qt.Contains, "User-Agent: Hugo Static Site Generator") + }, + }, + { + `Params`, + maps.Params{ + "Accept-Charset": "utf-8", + }, + func(c *qt.C, headers string) { + c.Assert(headers, qt.Contains, "Accept-Charset: utf-8") + }, + }, + { + `Override User-Agent`, + map[string]any{ + "User-Agent": "007", + }, + func(c *qt.C, headers string) { + c.Assert(headers, qt.Contains, "User-Agent: 007") + }, + }, + } { + c.Run(test.name, func(c *qt.C) { + ns := newTestNs() + + // Setup HTTP test server + var srv *httptest.Server + var headers bytes.Buffer + srv, ns.client = getTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.URL.String(), qt.Equals, "http://gohugo.io/api?foo") + w.Write([]byte("{}")) + r.Header.Write(&headers) + }) + defer func() { srv.Close() }() + + testFunc := func(fn func(args ...any) error) { + defer headers.Reset() + err := fn("http://example.org/api", "?foo", test.headers) + + c.Assert(err, qt.IsNil) + c.Assert(int(ns.deps.Log.LoggCount(logg.LevelError)), qt.Equals, 0) + test.assert(c, headers.String()) + } + + testFunc(func(args ...any) error { + _, err := ns.GetJSON(args...) + return err + }) + testFunc(func(args ...any) error { + _, err := ns.GetCSV(",", args...) + return err + }) + }) + } +} + +func TestToURLAndHeaders(t *testing.T) { + t.Parallel() + c := qt.New(t) + url, headers := toURLAndHeaders([]any{"https://foo?id=", 32}) + c.Assert(url, qt.Equals, "https://foo?id=32") + c.Assert(headers, qt.IsNil) + + url, headers = toURLAndHeaders([]any{"https://foo?id=", 32, map[string]any{"a": "b"}}) + c.Assert(url, qt.Equals, "https://foo?id=32") + c.Assert(headers, qt.DeepEquals, map[string]any{"a": "b"}) } func TestParseCSV(t *testing.T) { @@ -244,19 +338,3 @@ func TestParseCSV(t *testing.T) { c.Assert(act, qt.Equals, test.exp, msg) } } - -func haveHeader(m http.Header, key, needle string) bool { - var s []string - var ok bool - - if s, ok = m[key]; !ok { - return false - } - - for _, v := range s { - if v == needle { - return true - } - } - return false -} diff --git a/tpl/data/init.go b/tpl/data/init.go index 3bdc02786..507e0d43e 100644 --- a/tpl/data/init.go +++ b/tpl/data/init.go @@ -14,6 +14,8 @@ package data import ( + "context" + "github.com/gohugoio/hugo/deps" "github.com/gohugoio/hugo/tpl/internal" ) @@ -26,7 +28,7 @@ func init() { ns := &internal.TemplateFuncsNamespace{ Name: name, - Context: func(args ...interface{}) interface{} { return ctx }, + Context: func(cctx context.Context, args ...any) (any, error) { return ctx, nil }, } ns.AddMethodMapping(ctx.GetCSV, diff --git a/tpl/data/init_test.go b/tpl/data/init_test.go deleted file mode 100644 index fedce8e5c..000000000 --- a/tpl/data/init_test.go +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright 2017 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 data - -import ( - "testing" - - qt "github.com/frankban/quicktest" - "github.com/gohugoio/hugo/htesting/hqt" - "github.com/gohugoio/hugo/langs" - "github.com/gohugoio/hugo/tpl/internal" - "github.com/spf13/viper" -) - -func TestInit(t *testing.T) { - c := qt.New(t) - var found bool - var ns *internal.TemplateFuncsNamespace - - v := viper.New() - v.Set("contentDir", "content") - langs.LoadLanguageSettings(v, nil) - - for _, nsf := range internal.TemplateFuncsNamespaceRegistry { - ns = nsf(newDeps(v)) - if ns.Name == name { - found = true - break - } - } - - c.Assert(found, qt.Equals, true) - c.Assert(ns.Context(), hqt.IsSameType, &Namespace{}) -} diff --git a/tpl/data/resources.go b/tpl/data/resources.go index 7de440ca6..9e06c0cce 100644 --- a/tpl/data/resources.go +++ b/tpl/data/resources.go @@ -14,17 +14,16 @@ package data import ( - "io/ioutil" + "bytes" + "fmt" + "io" "net/http" + "net/url" "path/filepath" "time" - "github.com/pkg/errors" - "github.com/gohugoio/hugo/cache/filecache" - - "github.com/gohugoio/hugo/config" - "github.com/gohugoio/hugo/helpers" + "github.com/gohugoio/hugo/common/hashing" "github.com/spf13/afero" ) @@ -36,7 +35,16 @@ var ( // getRemote loads the content of a remote file. This method is thread safe. func (ns *Namespace) getRemote(cache *filecache.Cache, unmarshal func([]byte) (bool, error), req *http.Request) error { url := req.URL.String() - id := helpers.MD5String(url) + if err := ns.deps.ExecHelper.Sec().CheckAllowedHTTPURL(url); err != nil { + return err + } + if err := ns.deps.ExecHelper.Sec().CheckAllowedHTTPMethod("GET"); err != nil { + return err + } + + var headers bytes.Buffer + req.Header.Write(&headers) + id := hashing.MD5FromStringHexEncoded(url + headers.String()) var handled bool var retry bool @@ -44,25 +52,24 @@ func (ns *Namespace) getRemote(cache *filecache.Cache, unmarshal func([]byte) (b var err error handled = true for i := 0; i <= resRetries; i++ { - ns.deps.Log.INFO.Printf("Downloading: %s ...", url) + ns.deps.Log.Infof("Downloading: %s ...", url) var res *http.Response res, err = ns.client.Do(req) if err != nil { return nil, err } - if isHTTPError(res) { - return nil, errors.Errorf("Failed to retrieve remote file: %s", http.StatusText(res.StatusCode)) - } - var b []byte - b, err = ioutil.ReadAll(res.Body) - + b, err = io.ReadAll(res.Body) if err != nil { return nil, err } res.Body.Close() + if isHTTPError(res) { + return nil, fmt.Errorf("failed to retrieve remote file: %s, body: %q", http.StatusText(res.StatusCode), b) + } + retry, err = unmarshal(b) if err == nil { @@ -74,13 +81,12 @@ func (ns *Namespace) getRemote(cache *filecache.Cache, unmarshal func([]byte) (b return nil, err } - ns.deps.Log.INFO.Printf("Cannot read remote resource %s: %s", url, err) - ns.deps.Log.INFO.Printf("Retry #%d for %s and sleeping for %s", i+1, url, resSleep) + ns.deps.Log.Infof("Cannot read remote resource %s: %s", url, err) + ns.deps.Log.Infof("Retry #%d for %s and sleeping for %s", i+1, url, resSleep) time.Sleep(resSleep) } return nil, err - }) if !handled { @@ -92,14 +98,9 @@ func (ns *Namespace) getRemote(cache *filecache.Cache, unmarshal func([]byte) (b } // getLocal loads the content of a local file -func getLocal(url string, fs afero.Fs, cfg config.Provider) ([]byte, error) { - filename := filepath.Join(cfg.GetString("workingDir"), url) - if e, err := helpers.Exists(filename, fs); !e { - return nil, err - } - +func getLocal(workingDir, url string, fs afero.Fs) ([]byte, error) { + filename := filepath.Join(workingDir, url) return afero.ReadFile(fs, filename) - } // getResource loads the content of a local or remote file and returns its content and the @@ -107,7 +108,11 @@ func getLocal(url string, fs afero.Fs, cfg config.Provider) ([]byte, error) { func (ns *Namespace) getResource(cache *filecache.Cache, unmarshal func(b []byte) (bool, error), req *http.Request) error { switch req.URL.Scheme { case "": - b, err := getLocal(req.URL.String(), ns.deps.Fs.Source, ns.deps.Cfg) + url, err := url.QueryUnescape(req.URL.String()) + if err != nil { + return err + } + b, err := getLocal(ns.deps.Conf.BaseConfig().WorkingDir, url, ns.deps.Fs.Source) if err != nil { return err } diff --git a/tpl/data/resources_test.go b/tpl/data/resources_test.go index 11a9a8fc4..d49e74d4c 100644 --- a/tpl/data/resources_test.go +++ b/tpl/data/resources_test.go @@ -18,29 +18,32 @@ import ( "net/http" "net/http/httptest" "net/url" + "path/filepath" "sync" "testing" "time" - "github.com/gohugoio/hugo/modules" + "github.com/gohugoio/hugo/cache/filecache" + "github.com/gohugoio/hugo/common/loggers" + + "github.com/gohugoio/hugo/config/testconfig" "github.com/gohugoio/hugo/helpers" qt "github.com/frankban/quicktest" - "github.com/gohugoio/hugo/cache/filecache" - "github.com/gohugoio/hugo/common/loggers" "github.com/gohugoio/hugo/config" "github.com/gohugoio/hugo/deps" "github.com/gohugoio/hugo/hugofs" - "github.com/gohugoio/hugo/langs" "github.com/spf13/afero" - "github.com/spf13/viper" ) func TestScpGetLocal(t *testing.T) { t.Parallel() - v := viper.New() - fs := hugofs.NewMem(v) + v := config.New() + workingDir := "/my/working/dir" + v.Set("workingDir", workingDir) + v.Set("publishDir", "public") + fs := hugofs.NewFromOld(afero.NewMemMapFs(), v) ps := helpers.FilePathSeparator tests := []struct { @@ -56,12 +59,12 @@ func TestScpGetLocal(t *testing.T) { for _, test := range tests { r := bytes.NewReader(test.content) - err := helpers.WriteToDisk(test.path, r, fs.Source) + err := helpers.WriteToDisk(filepath.Join(workingDir, test.path), r, fs.Source) if err != nil { t.Error(err) } - c, err := getLocal(test.path, fs.Source, v) + c, err := getLocal(workingDir, test.path, fs.Source) if err != nil { t.Errorf("Error getting resource content: %s", err) } @@ -69,7 +72,6 @@ func TestScpGetLocal(t *testing.T) { t.Errorf("\nExpected: %s\nActual: %s\n", string(test.content), string(c)) } } - } func getTestServer(handler func(w http.ResponseWriter, r *http.Request)) (*httptest.Server, *http.Client) { @@ -145,20 +147,19 @@ func TestScpGetRemoteParallel(t *testing.T) { c.Assert(err, qt.IsNil) for _, ignoreCache := range []bool{false} { - cfg := viper.New() + cfg := config.New() cfg.Set("ignoreCache", ignoreCache) - cfg.Set("contentDir", "content") ns := New(newDeps(cfg)) ns.client = cl var wg sync.WaitGroup - for i := 0; i < 1; i++ { + for i := range 1 { wg.Add(1) go func(gor int) { defer wg.Done() - for j := 0; j < 10; j++ { + for range 10 { var cb []byte f := func(b []byte) (bool, error) { cb = b @@ -181,50 +182,21 @@ func TestScpGetRemoteParallel(t *testing.T) { } func newDeps(cfg config.Provider) *deps.Deps { - cfg.Set("resourceDir", "resources") - cfg.Set("dataDir", "resources") - cfg.Set("i18nDir", "i18n") - cfg.Set("assetDir", "assets") - cfg.Set("layoutDir", "layouts") - cfg.Set("archetypeDir", "archetypes") + conf := testconfig.GetTestConfig(nil, cfg) + logger := loggers.NewDefault() + fs := hugofs.NewFrom(afero.NewMemMapFs(), conf.BaseConfig()) - langs.LoadLanguageSettings(cfg, nil) - mod, err := modules.CreateProjectModule(cfg) - if err != nil { + d := &deps.Deps{ + Fs: fs, + Log: logger, + Conf: conf, + } + if err := d.Init(); err != nil { panic(err) } - cfg.Set("allModules", modules.Modules{mod}) - - cs, err := helpers.NewContentSpec(cfg, loggers.NewErrorLogger(), afero.NewMemMapFs()) - if err != nil { - panic(err) - } - - fs := hugofs.NewMem(cfg) - logger := loggers.NewErrorLogger() - - p, err := helpers.NewPathSpec(fs, cfg, nil) - if err != nil { - panic(err) - } - - fileCaches, err := filecache.NewCaches(p) - if err != nil { - panic(err) - } - - return &deps.Deps{ - Cfg: cfg, - Fs: fs, - FileCaches: fileCaches, - ContentSpec: cs, - Log: logger, - DistinctErrorLog: helpers.NewDistinctLogger(logger.ERROR), - } + return d } func newTestNs() *Namespace { - v := viper.New() - v.Set("contentDir", "content") - return New(newDeps(v)) + return New(newDeps(config.New())) } diff --git a/tpl/debug/debug.go b/tpl/debug/debug.go new file mode 100644 index 000000000..3909cbfa2 --- /dev/null +++ b/tpl/debug/debug.go @@ -0,0 +1,185 @@ +// Copyright 2020 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 debug provides template functions to help debugging templates. +package debug + +import ( + "encoding/json" + "sort" + "sync" + "time" + + "github.com/bep/logg" + "github.com/spf13/cast" + "github.com/yuin/goldmark/util" + + "github.com/gohugoio/hugo/common/hugo" + "github.com/gohugoio/hugo/deps" +) + +// New returns a new instance of the debug-namespaced template functions. +func New(d *deps.Deps) *Namespace { + ns := &Namespace{} + if d.Log.Level() <= logg.LevelInfo { + ns.timers = make(map[string][]*timer) + } + + if ns.timers == nil { + return ns + } + + l := d.Log.InfoCommand("timer") + + d.BuildEndListeners.Add(func(...any) bool { + type data struct { + Name string + Count int + Average time.Duration + Median time.Duration + Duration time.Duration + } + + var timersSorted []data + + for k, v := range ns.timers { + var total time.Duration + var median time.Duration + sort.Slice(v, func(i, j int) bool { + return v[i].elapsed < v[j].elapsed + }) + if len(v) > 0 { + median = v[len(v)/2].elapsed + } + for _, t := range v { + // Stop any running timers. + t.Stop() + total += t.elapsed + + } + average := total / time.Duration(len(v)) + timersSorted = append(timersSorted, data{k, len(v), average, median, total}) + } + + sort.Slice(timersSorted, func(i, j int) bool { + // Sort it so the slowest gets printed last. + return timersSorted[i].Duration < timersSorted[j].Duration + }) + + for _, t := range timersSorted { + l.WithField("name", t.Name).WithField("count", t.Count). + WithField("duration", t.Duration). + WithField("average", t.Average). + WithField("median", t.Median).Logf("") + } + + ns.timers = make(map[string][]*timer) + + return false + }) + + return ns +} + +// Namespace provides template functions for the "debug" namespace. +type Namespace struct { + timersMu sync.Mutex + timers map[string][]*timer +} + +// Dump returns a object dump of val as a string. +// Note that not every value passed to Dump will print so nicely, but +// we'll improve on that. +// +// We recommend using the "go" Chroma lexer to format the output +// nicely. +// +// Also note that the output from Dump may change from Hugo version to the next, +// so don't depend on a specific output. +func (ns *Namespace) Dump(val any) string { + b, err := json.MarshalIndent(val, "", " ") + if err != nil { + return "" + } + return string(b) +} + +// VisualizeSpaces returns a string with spaces replaced by a visible string. +func (ns *Namespace) VisualizeSpaces(val any) string { + s := cast.ToString(val) + return string(util.VisualizeSpaces([]byte(s))) +} + +func (ns *Namespace) Timer(name string) Timer { + if ns.timers == nil { + return nopTimer + } + ns.timersMu.Lock() + defer ns.timersMu.Unlock() + t := &timer{start: time.Now()} + ns.timers[name] = append(ns.timers[name], t) + return t +} + +var nopTimer = nopTimerImpl{} + +type nopTimerImpl struct{} + +func (nopTimerImpl) Stop() string { + return "" +} + +// Timer is a timer that can be stopped. +type Timer interface { + // Stop stops the timer and returns an empty string. + // Stop can be called multiple times, but only the first call will stop the timer. + // If Stop is not called, the timer will be stopped when the build ends. + Stop() string +} + +type timer struct { + start time.Time + elapsed time.Duration + stopOnce sync.Once +} + +func (t *timer) Stop() string { + t.stopOnce.Do(func() { + t.elapsed = time.Since(t.start) + }) + // This is used in templates, we need to return something. + return "" +} + +// Internal template func, used in tests only. +func (ns *Namespace) TestDeprecationInfo(item, alternative string) string { + v := hugo.CurrentVersion + hugo.Deprecate(item, alternative, v.String()) + return "" +} + +// Internal template func, used in tests only. +func (ns *Namespace) TestDeprecationWarn(item, alternative string) string { + v := hugo.CurrentVersion + v.Minor -= 3 + hugo.Deprecate(item, alternative, v.String()) + return "" +} + +// Internal template func, used in tests only. +func (ns *Namespace) TestDeprecationErr(item, alternative string) string { + v := hugo.CurrentVersion + v.Minor -= 15 + hugo.Deprecate(item, alternative, v.String()) + return "" +} diff --git a/tpl/debug/debug_integration_test.go b/tpl/debug/debug_integration_test.go new file mode 100644 index 000000000..52f0a427c --- /dev/null +++ b/tpl/debug/debug_integration_test.go @@ -0,0 +1,75 @@ +// Copyright 2024 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 requiredF 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 debug_test + +import ( + "testing" + + "github.com/bep/logg" + "github.com/gohugoio/hugo/hugolib" +) + +func TestTimer(t *testing.T) { + files := ` +-- hugo.toml -- +baseURL = "https://example.org/" +disableKinds = ["taxonomy", "term"] +-- layouts/index.html -- +{{ range seq 5 }} +{{ $t := debug.Timer "foo" }} +{{ seq 1 1000 }} +{{ $t.Stop }} +{{ end }} + +` + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + LogLevel: logg.LevelInfo, + }, + ).Build() + + b.AssertLogContains("timer: name foo count 5 duration") +} + +func TestDebugDumpPage(t *testing.T) { + files := ` +-- hugo.toml -- +baseURL = "https://example.org/" +disableLiveReload = true +[taxonomies] +tag = "tags" +-- content/_index.md -- +--- +title: "The Index" +date: 2012-03-15 +--- +-- content/p1.md -- +--- +title: "The First" +tags: ["a", "b"] +--- +-- layouts/_default/list.html -- +Dump: {{ debug.Dump . | safeHTML }} +Dump Site: {{ debug.Dump site }} +Dum site.Taxonomies: {{ debug.Dump site.Taxonomies | safeHTML }} +-- layouts/_default/single.html -- +Dump: {{ debug.Dump . | safeHTML }} + + +` + b := hugolib.TestRunning(t, files) + b.AssertFileContent("public/index.html", "Dump: {\n \"Date\": \"2012-03-15T00:00:00Z\"") +} diff --git a/tpl/debug/init.go b/tpl/debug/init.go new file mode 100644 index 000000000..1232a4166 --- /dev/null +++ b/tpl/debug/init.go @@ -0,0 +1,47 @@ +// Copyright 2020 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 debug + +import ( + "context" + + "github.com/gohugoio/hugo/deps" + "github.com/gohugoio/hugo/tpl/internal" +) + +const name = "debug" + +func init() { + f := func(d *deps.Deps) *internal.TemplateFuncsNamespace { + ctx := New(d) + + ns := &internal.TemplateFuncsNamespace{ + Name: name, + Context: func(cctx context.Context, args ...any) (any, error) { return ctx, nil }, + } + + ns.AddMethodMapping(ctx.Dump, + nil, + [][2]string{ + {`{{ $m := newScratch }} +{{ $m.Set "Hugo" "Rocks!" }} +{{ $m.Values | debug.Dump | safeHTML }}`, "{\n \"Hugo\": \"Rocks!\"\n}"}, + }, + ) + + return ns + } + + internal.AddTemplateFuncsNamespace(f) +} diff --git a/tpl/diagrams/diagrams.go b/tpl/diagrams/diagrams.go new file mode 100644 index 000000000..6a58bcfe4 --- /dev/null +++ b/tpl/diagrams/diagrams.go @@ -0,0 +1,33 @@ +// Copyright 2024 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 diagrams + +import ( + "html/template" +) + +type SVGDiagram interface { + // Wrapped returns the diagram as an SVG, including the container. + Wrapped() template.HTML + + // Inner returns the inner markup of the SVG. + // This allows for the container to be created manually. + Inner() template.HTML + + // Width returns the width of the SVG. + Width() int + + // Height returns the height of the SVG. + Height() int +} diff --git a/tpl/diagrams/goat.go b/tpl/diagrams/goat.go new file mode 100644 index 000000000..fe156f1e8 --- /dev/null +++ b/tpl/diagrams/goat.go @@ -0,0 +1,68 @@ +// Copyright 2024 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 diagrams + +import ( + "bytes" + "html/template" + "io" + "strings" + + "github.com/bep/goat" + "github.com/gohugoio/hugo/deps" + "github.com/spf13/cast" +) + +type goatDiagram struct { + d goat.SVG +} + +func (d goatDiagram) Inner() template.HTML { + return template.HTML(d.d.Body) +} + +func (d goatDiagram) Wrapped() template.HTML { + return template.HTML(d.d.String()) +} + +func (d goatDiagram) Width() int { + return d.d.Width +} + +func (d goatDiagram) Height() int { + return d.d.Height +} + +// Namespace provides template functions for the diagrams namespace. +type Namespace struct { + d *deps.Deps +} + +// Goat creates a new SVG diagram from input v. +func (d *Namespace) Goat(v any) SVGDiagram { + var r io.Reader + + switch vv := v.(type) { + case io.Reader: + r = vv + case []byte: + r = bytes.NewReader(vv) + default: + r = strings.NewReader(cast.ToString(v)) + } + + return goatDiagram{ + d: goat.BuildSVG(r), + } +} diff --git a/tpl/diagrams/init.go b/tpl/diagrams/init.go new file mode 100644 index 000000000..0cbec7e1b --- /dev/null +++ b/tpl/diagrams/init.go @@ -0,0 +1,41 @@ +// Copyright 2024 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 diagrams provides template functions for generating diagrams. +package diagrams + +import ( + "context" + + "github.com/gohugoio/hugo/deps" + "github.com/gohugoio/hugo/tpl/internal" +) + +const name = "diagrams" + +func init() { + f := func(d *deps.Deps) *internal.TemplateFuncsNamespace { + ctx := &Namespace{ + d: d, + } + + ns := &internal.TemplateFuncsNamespace{ + Name: name, + Context: func(cctx context.Context, args ...any) (any, error) { return ctx, nil }, + } + + return ns + } + + internal.AddTemplateFuncsNamespace(f) +} diff --git a/tpl/encoding/encoding.go b/tpl/encoding/encoding.go index 9045acd1c..f9102967a 100644 --- a/tpl/encoding/encoding.go +++ b/tpl/encoding/encoding.go @@ -1,4 +1,4 @@ -// Copyright 2017 The Hugo Authors. All rights reserved. +// Copyright 2020 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. @@ -17,8 +17,13 @@ package encoding import ( "encoding/base64" "encoding/json" + "errors" "html/template" + bp "github.com/gohugoio/hugo/bufferpool" + + "github.com/gohugoio/hugo/common/maps" + "github.com/mitchellh/mapstructure" "github.com/spf13/cast" ) @@ -31,7 +36,7 @@ func New() *Namespace { type Namespace struct{} // Base64Decode returns the base64 decoding of the given content. -func (ns *Namespace) Base64Decode(content interface{}) (string, error) { +func (ns *Namespace) Base64Decode(content any) (string, error) { conv, err := cast.ToStringE(content) if err != nil { return "", err @@ -42,7 +47,7 @@ func (ns *Namespace) Base64Decode(content interface{}) (string, error) { } // Base64Encode returns the base64 encoding of the given content. -func (ns *Namespace) Base64Encode(content interface{}) (string, error) { +func (ns *Namespace) Base64Encode(content any) (string, error) { conv, err := cast.ToStringE(content) if err != nil { return "", err @@ -51,12 +56,61 @@ func (ns *Namespace) Base64Encode(content interface{}) (string, error) { return base64.StdEncoding.EncodeToString([]byte(conv)), nil } -// Jsonify encodes a given object to JSON. -func (ns *Namespace) Jsonify(v interface{}) (template.HTML, error) { - b, err := json.Marshal(v) +// Jsonify encodes a given object to JSON. To pretty print the JSON, pass a map +// or dictionary of options as the first value in args. Supported options are +// "prefix" and "indent". Each JSON element in the output will begin on a new +// line beginning with prefix followed by one or more copies of indent according +// to the indentation nesting. +func (ns *Namespace) Jsonify(args ...any) (template.HTML, error) { + var ( + b []byte + err error + obj any + opts jsonifyOpts + ) + + switch len(args) { + case 0: + return "", nil + case 1: + obj = args[0] + case 2: + var m map[string]any + m, err = maps.ToStringMapE(args[0]) + if err != nil { + break + } + if err = mapstructure.WeakDecode(m, &opts); err != nil { + break + } + obj = args[1] + default: + err = errors.New("too many arguments to jsonify") + } + if err != nil { return "", err } + buff := bp.GetBuffer() + defer bp.PutBuffer(buff) + e := json.NewEncoder(buff) + e.SetEscapeHTML(!opts.NoHTMLEscape) + e.SetIndent(opts.Prefix, opts.Indent) + if err = e.Encode(obj); err != nil { + return "", err + } + b = buff.Bytes() + // See https://github.com/golang/go/issues/37083 + // Hugo changed from MarshalIndent/Marshal. To make the output + // the same, we need to trim the trailing newline. + b = b[:len(b)-1] + return template.HTML(b), nil } + +type jsonifyOpts struct { + Prefix string + Indent string + NoHTMLEscape bool +} diff --git a/tpl/encoding/encoding_test.go b/tpl/encoding/encoding_test.go index 2c1804dad..8e6e2da48 100644 --- a/tpl/encoding/encoding_test.go +++ b/tpl/encoding/encoding_test.go @@ -1,4 +1,4 @@ -// Copyright 2017 The Hugo Authors. All rights reserved. +// Copyright 2020 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. @@ -30,8 +30,8 @@ func TestBase64Decode(t *testing.T) { ns := New() for _, test := range []struct { - v interface{} - expect interface{} + v any + expect any }{ {"YWJjMTIzIT8kKiYoKSctPUB+", "abc123!?$*&()'-=@~"}, // errors @@ -57,8 +57,8 @@ func TestBase64Encode(t *testing.T) { ns := New() for _, test := range []struct { - v interface{} - expect interface{} + v any + expect any }{ {"YWJjMTIzIT8kKiYoKSctPUB+", "WVdKak1USXpJVDhrS2lZb0tTY3RQVUIr"}, // errors @@ -82,25 +82,40 @@ func TestJsonify(t *testing.T) { c := qt.New(t) ns := New() - for _, test := range []struct { - v interface{} - expect interface{} + for i, test := range []struct { + opts any + v any + expect any }{ - {[]string{"a", "b"}, template.HTML(`["a","b"]`)}, - {tstNoStringer{}, template.HTML("{}")}, - {nil, template.HTML("null")}, + {nil, []string{"a", "b"}, template.HTML(`["a","b"]`)}, + {map[string]string{"indent": ""}, []string{"a", "b"}, template.HTML("[\n\"a\",\n\"b\"\n]")}, + {map[string]string{"prefix": "

    "}, []string{"a", "b"}, template.HTML("[\n

    \"a\",\n

    \"b\"\n

    ]")}, + {map[string]string{"prefix": "

    ", "indent": ""}, []string{"a", "b"}, template.HTML("[\n

    \"a\",\n

    \"b\"\n

    ]")}, + {map[string]string{"indent": ""}, []string{"a", "b"}, template.HTML("[\n\"a\",\n\"b\"\n]")}, + {map[string]any{"noHTMLEscape": false}, []string{"", ""}, template.HTML("[\"\\u003ca\\u003e\",\"\\u003cb\\u003e\"]")}, + {map[string]any{"noHTMLEscape": true}, []string{"", ""}, template.HTML("[\"\",\"\"]")}, + {nil, tstNoStringer{}, template.HTML("{}")}, + {nil, nil, template.HTML("null")}, // errors - {math.NaN(), false}, + {nil, math.NaN(), false}, + {tstNoStringer{}, []string{"a", "b"}, false}, } { + args := []any{} - result, err := ns.Jsonify(test.v) + if test.opts != nil { + args = append(args, test.opts) + } + + args = append(args, test.v) + + result, err := ns.Jsonify(args...) if b, ok := test.expect.(bool); ok && !b { - c.Assert(err, qt.Not(qt.IsNil)) + c.Assert(err, qt.Not(qt.IsNil), qt.Commentf("#%d", i)) continue } c.Assert(err, qt.IsNil) - c.Assert(result, qt.Equals, test.expect) + c.Assert(result, qt.Equals, test.expect, qt.Commentf("#%d", i)) } } diff --git a/tpl/encoding/init.go b/tpl/encoding/init.go index bad1804de..1c3322d6e 100644 --- a/tpl/encoding/init.go +++ b/tpl/encoding/init.go @@ -1,4 +1,4 @@ -// Copyright 2017 The Hugo Authors. All rights reserved. +// Copyright 2020 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,8 @@ package encoding import ( + "context" + "github.com/gohugoio/hugo/deps" "github.com/gohugoio/hugo/tpl/internal" ) @@ -26,7 +28,7 @@ func init() { ns := &internal.TemplateFuncsNamespace{ Name: name, - Context: func(args ...interface{}) interface{} { return ctx }, + Context: func(cctx context.Context, args ...any) (any, error) { return ctx, nil }, } ns.AddMethodMapping(ctx.Base64Decode, @@ -48,11 +50,11 @@ func init() { []string{"jsonify"}, [][2]string{ {`{{ (slice "A" "B" "C") | jsonify }}`, `["A","B","C"]`}, + {`{{ (slice "A" "B" "C") | jsonify (dict "indent" " ") }}`, "[\n \"A\",\n \"B\",\n \"C\"\n]"}, }, ) return ns - } internal.AddTemplateFuncsNamespace(f) diff --git a/tpl/encoding/init_test.go b/tpl/encoding/init_test.go deleted file mode 100644 index 5fd71eb32..000000000 --- a/tpl/encoding/init_test.go +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2017 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 encoding - -import ( - "testing" - - qt "github.com/frankban/quicktest" - "github.com/gohugoio/hugo/deps" - "github.com/gohugoio/hugo/htesting/hqt" - "github.com/gohugoio/hugo/tpl/internal" -) - -func TestInit(t *testing.T) { - c := qt.New(t) - var found bool - var ns *internal.TemplateFuncsNamespace - - for _, nsf := range internal.TemplateFuncsNamespaceRegistry { - ns = nsf(&deps.Deps{}) - if ns.Name == name { - found = true - break - } - } - - c.Assert(found, qt.Equals, true) - c.Assert(ns.Context(), hqt.IsSameType, &Namespace{}) -} diff --git a/tpl/fmt/fmt.go b/tpl/fmt/fmt.go index aa6b8c1a6..264cb1435 100644 --- a/tpl/fmt/fmt.go +++ b/tpl/fmt/fmt.go @@ -16,51 +16,102 @@ package fmt import ( _fmt "fmt" + "sort" + "github.com/bep/logg" + "github.com/gohugoio/hugo/common/loggers" "github.com/gohugoio/hugo/deps" - "github.com/gohugoio/hugo/helpers" + "github.com/spf13/cast" ) // New returns a new instance of the fmt-namespaced template functions. func New(d *deps.Deps) *Namespace { - return &Namespace{ - errorLogger: helpers.NewDistinctLogger(d.Log.ERROR), - warnLogger: helpers.NewDistinctLogger(d.Log.WARN), + ns := &Namespace{ + logger: d.Log, } + + d.BuildStartListeners.Add(func(...any) bool { + ns.logger.Reset() + return false + }) + + return ns } // Namespace provides template functions for the "fmt" namespace. type Namespace struct { - errorLogger *helpers.DistinctLogger - warnLogger *helpers.DistinctLogger + logger loggers.Logger } -// Print returns string representation of the passed arguments. -func (ns *Namespace) Print(a ...interface{}) string { - return _fmt.Sprint(a...) +// Print returns a string representation of args. +func (ns *Namespace) Print(args ...any) string { + return _fmt.Sprint(args...) } -// Printf returns a formatted string representation of the passed arguments. -func (ns *Namespace) Printf(format string, a ...interface{}) string { - return _fmt.Sprintf(format, a...) - +// Printf returns string representation of args formatted with the layout in format. +func (ns *Namespace) Printf(format string, args ...any) string { + return _fmt.Sprintf(format, args...) } -// Println returns string representation of the passed arguments ending with a newline. -func (ns *Namespace) Println(a ...interface{}) string { - return _fmt.Sprintln(a...) +// Println returns string representation of args ending with a newline. +func (ns *Namespace) Println(args ...any) string { + return _fmt.Sprintln(args...) } -// Errorf formats according to a format specifier and logs an ERROR. +// Errorf formats args according to a format specifier and logs an ERROR. // It returns an empty string. -func (ns *Namespace) Errorf(format string, a ...interface{}) string { - ns.errorLogger.Printf(format, a...) +func (ns *Namespace) Errorf(format string, args ...any) string { + ns.logger.Errorf(format, args...) return "" } -// Warnf formats according to a format specifier and logs a WARNING. +// Erroridf formats args according to a format specifier and logs an ERROR and +// an information text that the error with the given id can be suppressed in config. // It returns an empty string. -func (ns *Namespace) Warnf(format string, a ...interface{}) string { - ns.warnLogger.Printf(format, a...) +func (ns *Namespace) Erroridf(id, format string, args ...any) string { + ns.logger.Erroridf(id, format, args...) + return "" +} + +// Warnf formats args according to a format specifier and logs a WARNING. +// It returns an empty string. +func (ns *Namespace) Warnf(format string, args ...any) string { + ns.logger.Warnf(format, args...) + return "" +} + +// Warnidf formats args according to a format specifier and logs an WARNING and +// an information text that the warning with the given id can be suppressed in config. +// It returns an empty string. +func (ns *Namespace) Warnidf(id, format string, args ...any) string { + ns.logger.Warnidf(id, format, args...) + return "" +} + +// Warnmf is experimental and subject to change at any time. +func (ns *Namespace) Warnmf(m any, format string, args ...any) string { + return ns.logmf(ns.logger.Warn(), m, format, args...) +} + +// Errormf is experimental and subject to change at any time. +func (ns *Namespace) Errormf(m any, format string, args ...any) string { + return ns.logmf(ns.logger.Error(), m, format, args...) +} + +func (ns *Namespace) logmf(l logg.LevelLogger, m any, format string, args ...any) string { + mm := cast.ToStringMap(m) + fields := make(logg.Fields, len(mm)) + i := 0 + for k, v := range mm { + fields[i] = logg.Field{Name: k, Value: v} + i++ + } + // Sort the fields to make the output deterministic. + sort.Slice(fields, func(i, j int) bool { + return fields[i].Name < fields[j].Name + }) + + l.WithFields(fields).Logf(format, args...) + return "" } diff --git a/tpl/fmt/fmt_integration_test.go b/tpl/fmt/fmt_integration_test.go new file mode 100644 index 000000000..87a89943c --- /dev/null +++ b/tpl/fmt/fmt_integration_test.go @@ -0,0 +1,65 @@ +// Copyright 2024 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 fmt_test + +import ( + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/hugolib" +) + +// Issue #11506 +func TestErroridf(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['page','rss','section','sitemap','taxonomy','term'] +ignoreErrors = ['error-b','error-C'] +-- layouts/index.html -- +{{ erroridf "error-a" "%s" "a"}} +{{ erroridf "error-b" "%s" "b"}} +{{ erroridf "error-C" "%s" "C"}} +{{ erroridf "error-c" "%s" "c"}} + {{ erroridf "error-d" "%s" "D"}} + ` + + b, err := hugolib.TestE(t, files) + + b.Assert(err, qt.IsNotNil) + b.AssertLogMatches(`ERROR a\nYou can suppress this error by adding the following to your site configuration:\nignoreLogs = \['error-a'\]`) + b.AssertLogMatches(`ERROR D`) + b.AssertLogMatches(`! ERROR C`) + b.AssertLogMatches(`! ERROR c`) +} + +func TestWarnidf(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['page','rss','section','sitemap','taxonomy','term'] +ignoreLogs = ['warning-b', 'WarniNg-C'] +-- layouts/index.html -- +{{ warnidf "warning-a" "%s" "a"}} +{{ warnidf "warning-b" "%s" "b"}} +{{ warnidf "warNing-C" "%s" "c"}} + ` + + b := hugolib.Test(t, files, hugolib.TestOptWarn()) + b.AssertLogContains("WARN a", "You can suppress this warning", "ignoreLogs", "['warning-a']") + b.AssertLogContains("! ['warning-b']") + b.AssertLogContains("! ['warning-c']") +} diff --git a/tpl/fmt/init.go b/tpl/fmt/init.go index 6a2c9a856..701bd3b6a 100644 --- a/tpl/fmt/init.go +++ b/tpl/fmt/init.go @@ -14,6 +14,8 @@ package fmt import ( + "context" + "github.com/gohugoio/hugo/deps" "github.com/gohugoio/hugo/tpl/internal" ) @@ -26,7 +28,7 @@ func init() { ns := &internal.TemplateFuncsNamespace{ Name: name, - Context: func(args ...interface{}) interface{} { return ctx }, + Context: func(cctx context.Context, args ...any) (any, error) { return ctx, nil }, } ns.AddMethodMapping(ctx.Print, @@ -57,6 +59,20 @@ func init() { }, ) + ns.AddMethodMapping(ctx.Erroridf, + []string{"erroridf"}, + [][2]string{ + {`{{ erroridf "my-err-id" "%s." "failed" }}`, ``}, + }, + ) + + ns.AddMethodMapping(ctx.Warnidf, + []string{"warnidf"}, + [][2]string{ + {`{{ warnidf "my-warn-id" "%s." "warning" }}`, ``}, + }, + ) + ns.AddMethodMapping(ctx.Warnf, []string{"warnf"}, [][2]string{ diff --git a/tpl/fmt/init_test.go b/tpl/fmt/init_test.go deleted file mode 100644 index edc1dbb5e..000000000 --- a/tpl/fmt/init_test.go +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright 2017 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 fmt - -import ( - "testing" - - "github.com/gohugoio/hugo/htesting/hqt" - - qt "github.com/frankban/quicktest" - "github.com/gohugoio/hugo/common/loggers" - "github.com/gohugoio/hugo/deps" - "github.com/gohugoio/hugo/tpl/internal" -) - -func TestInit(t *testing.T) { - c := qt.New(t) - var found bool - var ns *internal.TemplateFuncsNamespace - - for _, nsf := range internal.TemplateFuncsNamespaceRegistry { - ns = nsf(&deps.Deps{Log: loggers.NewErrorLogger()}) - if ns.Name == name { - found = true - break - } - } - - c.Assert(found, qt.Equals, true) - c.Assert(ns.Context(), hqt.IsSameType, &Namespace{}) -} diff --git a/tpl/hash/hash.go b/tpl/hash/hash.go new file mode 100644 index 000000000..00df4e3cd --- /dev/null +++ b/tpl/hash/hash.go @@ -0,0 +1,85 @@ +// Copyright 2024 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 hash provides non-cryptographic hash functions for template use. +package hash + +import ( + "context" + "hash/fnv" + + "github.com/gohugoio/hugo/common/hashing" + "github.com/gohugoio/hugo/deps" + "github.com/gohugoio/hugo/tpl/internal" + "github.com/spf13/cast" +) + +// New returns a new instance of the hash-namespaced template functions. +func New() *Namespace { + return &Namespace{} +} + +// Namespace provides template functions for the "hash" namespace. +type Namespace struct{} + +// FNV32a hashes v using fnv32a algorithm. +func (ns *Namespace) FNV32a(v any) (int, error) { + conv, err := cast.ToStringE(v) + if err != nil { + return 0, err + } + algorithm := fnv.New32a() + algorithm.Write([]byte(conv)) + return int(algorithm.Sum32()), nil +} + +// XxHash returns the xxHash of the input string. +func (ns *Namespace) XxHash(v any) (string, error) { + conv, err := cast.ToStringE(v) + if err != nil { + return "", err + } + + return hashing.XxHashFromStringHexEncoded(conv), nil +} + +const name = "hash" + +func init() { + f := func(d *deps.Deps) *internal.TemplateFuncsNamespace { + ctx := New() + + ns := &internal.TemplateFuncsNamespace{ + Name: name, + Context: func(cctx context.Context, args ...any) (any, error) { return ctx, nil }, + } + + ns.AddMethodMapping(ctx.XxHash, + []string{"xxhash"}, + [][2]string{ + {`{{ hash.XxHash "The quick brown fox jumps over the lazy dog" }}`, `0b242d361fda71bc`}, + }, + ) + + ns.AddMethodMapping(ctx.FNV32a, + nil, + [][2]string{ + {`{{ hash.FNV32a "Hugo Rocks!!" }}`, `1515779328`}, + }, + ) + + return ns + } + + internal.AddTemplateFuncsNamespace(f) +} diff --git a/tpl/hash/hash_test.go b/tpl/hash/hash_test.go new file mode 100644 index 000000000..ff5d59a9a --- /dev/null +++ b/tpl/hash/hash_test.go @@ -0,0 +1,84 @@ +// Copyright 2024 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 hash provides non-cryptographic hash functions for template use. +package hash + +import ( + "crypto/md5" + "encoding/hex" + "fmt" + "strings" + "testing" + + qt "github.com/frankban/quicktest" + "github.com/spf13/cast" +) + +func TestXxHash(t *testing.T) { + t.Parallel() + c := qt.New(t) + + ns := New() + + h, err := ns.XxHash("The quick brown fox jumps over the lazy dog") + c.Assert(err, qt.IsNil) + // Facit: https://asecuritysite.com/encryption/xxhash?val=The%20quick%20brown%20fox%20jumps%20over%20the%20lazy%20dog + c.Assert(h, qt.Equals, "0b242d361fda71bc") +} + +func BenchmarkXxHash(b *testing.B) { + const inputSmall = "The quick brown fox jumps over the lazy dog" + inputLarge := strings.Repeat(inputSmall, 100) + + runBench := func(name, input string, b *testing.B, fn func(v any)) { + b.Run(fmt.Sprintf("%s_%d", name, len(input)), func(b *testing.B) { + for i := 0; i < b.N; i++ { + fn(input) + } + }) + } + + ns := New() + fnXxHash := func(v any) { + _, err := ns.XxHash(v) + if err != nil { + panic(err) + } + } + + fnFNv32a := func(v any) { + _, err := ns.FNV32a(v) + if err != nil { + panic(err) + } + } + + // Copied from the crypto tpl/crypto package, + // just to have something to compare the above with. + fnMD5 := func(v any) { + conv, err := cast.ToStringE(v) + if err != nil { + panic(err) + } + + hash := md5.Sum([]byte(conv)) + _ = hex.EncodeToString(hash[:]) + } + + for _, input := range []string{inputSmall, inputLarge} { + runBench("xxHash", input, b, fnXxHash) + runBench("mdb5", input, b, fnMD5) + runBench("fnv32a", input, b, fnFNv32a) + } +} diff --git a/tpl/hugo/init.go b/tpl/hugo/init.go index 1556b759c..32a279343 100644 --- a/tpl/hugo/init.go +++ b/tpl/hugo/init.go @@ -15,6 +15,8 @@ package hugo import ( + "context" + "github.com/gohugoio/hugo/deps" "github.com/gohugoio/hugo/tpl/internal" ) @@ -23,18 +25,19 @@ const name = "hugo" func init() { f := func(d *deps.Deps) *internal.TemplateFuncsNamespace { - + if d.Site == nil { + panic("no site in deps") + } h := d.Site.Hugo() ns := &internal.TemplateFuncsNamespace{ Name: name, - Context: func(args ...interface{}) interface{} { return h }, + Context: func(cctx context.Context, args ...any) (any, error) { return h, nil }, } // We just add the Hugo struct as the namespace here. No method mappings. return ns - } internal.AddTemplateFuncsNamespace(f) diff --git a/tpl/hugo/init_test.go b/tpl/hugo/init_test.go deleted file mode 100644 index c94a883fd..000000000 --- a/tpl/hugo/init_test.go +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright 2017 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 hugo - -import ( - "testing" - - "github.com/gohugoio/hugo/htesting/hqt" - - qt "github.com/frankban/quicktest" - "github.com/gohugoio/hugo/deps" - "github.com/gohugoio/hugo/resources/page" - "github.com/gohugoio/hugo/tpl/internal" - "github.com/spf13/viper" -) - -func TestInit(t *testing.T) { - c := qt.New(t) - var found bool - var ns *internal.TemplateFuncsNamespace - v := viper.New() - v.Set("contentDir", "content") - s := page.NewDummyHugoSite(v) - - for _, nsf := range internal.TemplateFuncsNamespaceRegistry { - ns = nsf(&deps.Deps{Site: s}) - if ns.Name == name { - found = true - break - } - } - - c.Assert(found, qt.Equals, true) - c.Assert(ns.Context(), hqt.IsSameType, s.Hugo()) -} diff --git a/tpl/images/images.go b/tpl/images/images.go index 1d12aea72..6296a7214 100644 --- a/tpl/images/images.go +++ b/tpl/images/images.go @@ -15,13 +15,19 @@ package images import ( + "errors" + "fmt" "image" + "path" "sync" - "github.com/pkg/errors" - + "github.com/bep/overlayfs" + "github.com/gohugoio/hugo/common/hashing" + "github.com/gohugoio/hugo/common/hugio" "github.com/gohugoio/hugo/resources/images" - "github.com/gohugoio/hugo/resources/resource" + "github.com/gohugoio/hugo/resources/resource_factories/create" + "github.com/mitchellh/mapstructure" + "rsc.io/qr" // Importing image codecs for image.DecodeConfig _ "image/gif" @@ -32,30 +38,46 @@ import ( _ "golang.org/x/image/webp" "github.com/gohugoio/hugo/deps" + "github.com/spf13/afero" "github.com/spf13/cast" ) // New returns a new instance of the images-namespaced template functions. -func New(deps *deps.Deps) *Namespace { +func New(d *deps.Deps) *Namespace { + var readFileFs afero.Fs + + // The docshelper script does not have or need all the dependencies set up. + if d.PathSpec != nil { + readFileFs = overlayfs.New(overlayfs.Options{ + Fss: []afero.Fs{ + d.PathSpec.BaseFs.Work, + d.PathSpec.BaseFs.Content.Fs, + }, + }) + } + return &Namespace{ - Filters: &images.Filters{}, - cache: map[string]image.Config{}, - deps: deps, + readFileFs: readFileFs, + Filters: &images.Filters{}, + cache: map[string]image.Config{}, + deps: d, + createClient: create.New(d.ResourceSpec), } } // Namespace provides template functions for the "images" namespace. type Namespace struct { *images.Filters - cacheMu sync.RWMutex - cache map[string]image.Config - - deps *deps.Deps + readFileFs afero.Fs + cacheMu sync.RWMutex + cache map[string]image.Config + deps *deps.Deps + createClient *create.Client } // Config returns the image.Config for the specified path relative to the // working directory. -func (ns *Namespace) Config(path interface{}) (image.Config, error) { +func (ns *Namespace) Config(path any) (image.Config, error) { filename, err := cast.ToStringE(path) if err != nil { return image.Config{}, err @@ -74,7 +96,7 @@ func (ns *Namespace) Config(path interface{}) (image.Config, error) { return config, nil } - f, err := ns.deps.Fs.WorkingDir.Open(filename) + f, err := ns.readFileFs.Open(filename) if err != nil { return image.Config{}, err } @@ -92,13 +114,98 @@ func (ns *Namespace) Config(path interface{}) (image.Config, error) { return config, nil } -func (ns *Namespace) Filter(args ...interface{}) (resource.Image, error) { +// Filter applies the given filters to the image given as the last element in args. +func (ns *Namespace) Filter(args ...any) (images.ImageResource, error) { if len(args) < 2 { return nil, errors.New("must provide an image and one or more filters") } - img := args[len(args)-1].(resource.Image) + img := args[len(args)-1].(images.ImageResource) filtersv := args[:len(args)-1] return img.Filter(filtersv...) } + +var qrErrorCorrectionLevels = map[string]qr.Level{ + "low": qr.L, + "medium": qr.M, + "quartile": qr.Q, + "high": qr.H, +} + +// QR encodes the given text into a QR code using the specified options, +// returning an image resource. +func (ns *Namespace) QR(args ...any) (images.ImageResource, error) { + const ( + qrDefaultErrorCorrectionLevel = "medium" + qrDefaultScale = 4 + ) + + opts := struct { + Level string // error correction level; one of low, medium, quartile, or high + Scale int // number of image pixels per QR code module + TargetDir string // target directory relative to publishDir + }{ + Level: qrDefaultErrorCorrectionLevel, + Scale: qrDefaultScale, + } + + if len(args) == 0 || len(args) > 2 { + return nil, errors.New("requires 1 or 2 arguments") + } + + text, err := cast.ToStringE(args[0]) + if err != nil { + return nil, err + } + + if text == "" { + return nil, errors.New("cannot encode an empty string") + } + + if len(args) == 2 { + err := mapstructure.WeakDecode(args[1], &opts) + if err != nil { + return nil, err + } + } + + level, ok := qrErrorCorrectionLevels[opts.Level] + if !ok { + return nil, errors.New("error correction level must be one of low, medium, quartile, or high") + } + + if opts.Scale < 2 { + return nil, errors.New("scale must be an integer greater than or equal to 2") + } + + targetPath := path.Join(opts.TargetDir, fmt.Sprintf("qr_%s.png", hashing.HashStringHex(text, opts))) + + r, err := ns.createClient.FromOpts( + create.Options{ + TargetPath: targetPath, + TargetPathHasHash: true, + CreateContent: func() (func() (hugio.ReadSeekCloser, error), error) { + code, err := qr.Encode(text, level) + if err != nil { + return nil, err + } + code.Scale = opts.Scale + png := code.PNG() + return func() (hugio.ReadSeekCloser, error) { + return hugio.NewReadSeekerNoOpCloserFromBytes(png), nil + }, nil + }, + }, + ) + if err != nil { + return nil, err + } + + ir, ok := r.(images.ImageResource) + if !ok { + panic("bug: resource is not an image resource") + } + + return ir, nil +} diff --git a/tpl/images/images_integration_test.go b/tpl/images/images_integration_test.go new file mode 100644 index 000000000..226165070 --- /dev/null +++ b/tpl/images/images_integration_test.go @@ -0,0 +1,144 @@ +// Copyright 2024 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 images_test + +import ( + "strings" + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/hugolib" + "github.com/gohugoio/hugo/resources/images/imagetesting" +) + +func TestImageConfigFromModule(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = 'http://example.com/' +theme = ["mytheme"] +-- static/images/pixel1.png -- +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg== +-- themes/mytheme/static/images/pixel2.png -- +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg== +-- layouts/index.html -- +{{ $path := "static/images/pixel1.png" }} +fileExists OK: {{ fileExists $path }}| +imageConfig OK: {{ (imageConfig $path).Width }}| +{{ $path2 := "static/images/pixel2.png" }} +fileExists2 OK: {{ fileExists $path2 }}| +imageConfig2 OK: {{ (imageConfig $path2).Width }}| + + ` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", ` +fileExists OK: true| +imageConfig OK: 1| +fileExists2 OK: true| +imageConfig2 OK: 1| +`) +} + +func TestQR(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['page','rss','section','sitemap','taxonomy','term'] +-- layouts/index.html -- +{{- $text := "https://gohugo.io" }} +{{- $optionMaps := slice + (dict) + (dict "level" "medium") + (dict "level" "medium" "scale" 4) + (dict "level" "low" "scale" 2) + (dict "level" "medium" "scale" 3) + (dict "level" "quartile" "scale" 5) + (dict "level" "high" "scale" 6) + (dict "level" "high" "scale" 6 "targetDir" "foo/bar") +}} +{{- range $k, $opts := $optionMaps }} + {{- with images.QR $text $opts }} + + {{- end }} +{{- end }} +` + + b := hugolib.Test(t, files) + b.AssertFileContent("public/index.html", + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ) + + files = strings.ReplaceAll(files, "low", "foo") + + b, err := hugolib.TestE(t, files) + b.Assert(err.Error(), qt.Contains, "error correction level must be one of low, medium, quartile, or high") + + files = strings.ReplaceAll(files, "foo", "low") + files = strings.ReplaceAll(files, "https://gohugo.io", "") + + b, err = hugolib.TestE(t, files) + b.Assert(err.Error(), qt.Contains, "cannot encode an empty string") +} + +func TestImagesGoldenFuncs(t *testing.T) { + t.Parallel() + + if imagetesting.SkipGoldenTests { + t.Skip("Skip golden test on this architecture") + } + + // Will be used as the base folder for generated images. + name := "funcs" + + files := ` +-- hugo.toml -- +-- assets/sunset.jpg -- +sourcefilename: ../../resources/testdata/sunset.jpg + +-- layouts/index.html -- +Home. + +{{ template "copy" (dict "name" "qr-default.png" "img" (images.QR "https://gohugo.io")) }} +{{ template "copy" (dict "name" "qr-level-high_scale-6.png" "img" (images.QR "https://gohugo.io" (dict "level" "high" "scale" 6))) }} + +{{ define "copy"}} +{{ if lt (len (path.Ext .name)) 4 }} + {{ errorf "No extension in %q" .name }} +{{ end }} +{{ $img := .img }} +{{ $name := printf "images/%s" .name }} +{{ with $img | resources.Copy $name }} +{{ .Publish }} +{{ end }} +{{ end }} +` + + opts := imagetesting.DefaultGoldenOpts + opts.T = t + opts.Name = name + opts.Files = files + + imagetesting.RunGolden(opts) +} diff --git a/tpl/images/images_test.go b/tpl/images/images_test.go index b1b1e1cfd..4f656d1f9 100644 --- a/tpl/images/images_test.go +++ b/tpl/images/images_test.go @@ -22,19 +22,18 @@ import ( "testing" qt "github.com/frankban/quicktest" - "github.com/gohugoio/hugo/deps" - "github.com/gohugoio/hugo/hugofs" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/config/testconfig" "github.com/spf13/afero" "github.com/spf13/cast" - "github.com/spf13/viper" ) type tstNoStringer struct{} var configTests = []struct { - path interface{} + path any input []byte - expect interface{} + expect any }{ { path: "a.png", @@ -82,10 +81,13 @@ func TestNSConfig(t *testing.T) { t.Parallel() c := qt.New(t) - v := viper.New() + afs := afero.NewMemMapFs() + v := config.New() v.Set("workingDir", "/a/b") + d := testconfig.GetTestDeps(afs, v) + bcfg := d.Conf - ns := New(&deps.Deps{Fs: hugofs.NewMem(v)}) + ns := New(d) for _, test := range configTests { @@ -99,7 +101,7 @@ func TestNSConfig(t *testing.T) { // cast path to string for afero.WriteFile sp, err := cast.ToStringE(test.path) c.Assert(err, qt.IsNil) - afero.WriteFile(ns.deps.Fs.Source, filepath.Join(v.GetString("workingDir"), sp), test.input, 0755) + afero.WriteFile(ns.deps.Fs.Source, filepath.Join(bcfg.WorkingDir(), sp), test.input, 0o755) result, err := ns.Config(test.path) diff --git a/tpl/images/init.go b/tpl/images/init.go index 299c76846..a350d5b9d 100644 --- a/tpl/images/init.go +++ b/tpl/images/init.go @@ -14,6 +14,8 @@ package images import ( + "context" + "github.com/gohugoio/hugo/deps" "github.com/gohugoio/hugo/tpl/internal" ) @@ -26,7 +28,7 @@ func init() { ns := &internal.TemplateFuncsNamespace{ Name: name, - Context: func(args ...interface{}) interface{} { return ctx }, + Context: func(cctx context.Context, args ...any) (any, error) { return ctx, nil }, } ns.AddMethodMapping(ctx.Config, @@ -35,7 +37,6 @@ func init() { ) return ns - } internal.AddTemplateFuncsNamespace(f) diff --git a/tpl/images/init_test.go b/tpl/images/init_test.go deleted file mode 100644 index d6dc26fe7..000000000 --- a/tpl/images/init_test.go +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2017 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 images - -import ( - "testing" - - qt "github.com/frankban/quicktest" - "github.com/gohugoio/hugo/deps" - "github.com/gohugoio/hugo/htesting/hqt" - "github.com/gohugoio/hugo/tpl/internal" -) - -func TestInit(t *testing.T) { - c := qt.New(t) - var found bool - var ns *internal.TemplateFuncsNamespace - - for _, nsf := range internal.TemplateFuncsNamespaceRegistry { - ns = nsf(&deps.Deps{}) - if ns.Name == name { - found = true - break - } - } - - c.Assert(found, qt.Equals, true) - c.Assert(ns.Context(), hqt.IsSameType, &Namespace{}) -} diff --git a/tpl/images/testdata/images_golden/funcs/qr-default.png b/tpl/images/testdata/images_golden/funcs/qr-default.png new file mode 100644 index 000000000..6c7ab919e Binary files /dev/null and b/tpl/images/testdata/images_golden/funcs/qr-default.png differ diff --git a/tpl/images/testdata/images_golden/funcs/qr-level-high_scale-6.png b/tpl/images/testdata/images_golden/funcs/qr-level-high_scale-6.png new file mode 100644 index 000000000..17e00f1a1 Binary files /dev/null and b/tpl/images/testdata/images_golden/funcs/qr-level-high_scale-6.png differ diff --git a/tpl/inflect/inflect.go b/tpl/inflect/inflect.go index 187f360d6..ef12e1bc0 100644 --- a/tpl/inflect/inflect.go +++ b/tpl/inflect/inflect.go @@ -16,8 +16,9 @@ package inflect import ( "strconv" + "strings" - _inflect "github.com/markbates/inflect" + _inflect "github.com/gobuffalo/flect" "github.com/spf13/cast" ) @@ -29,16 +30,12 @@ func New() *Namespace { // Namespace provides template functions for the "inflect" namespace. type Namespace struct{} -// Humanize returns the humanized form of a single parameter. +// Humanize returns the humanized form of v. // -// If the parameter is either an integer or a string containing an integer +// If v is either an integer or a string containing an integer // value, the behavior is to add the appropriate ordinal. -// -// Example: "my-first-post" -> "My first post" -// Example: "103" -> "103rd" -// Example: 52 -> "52nd" -func (ns *Namespace) Humanize(in interface{}) (string, error) { - word, err := cast.ToStringE(in) +func (ns *Namespace) Humanize(v any) (string, error) { + word, err := cast.ToStringE(v) if err != nil { return "", err } @@ -47,18 +44,19 @@ func (ns *Namespace) Humanize(in interface{}) (string, error) { return "", nil } - _, ok := in.(int) // original param was literal int value + _, ok := v.(int) // original param was literal int value _, err = strconv.Atoi(word) // original param was string containing an int value if ok || err == nil { return _inflect.Ordinalize(word), nil } - return _inflect.Humanize(word), nil + str := _inflect.Humanize(word) + return _inflect.Humanize(strings.ToLower(str)), nil } -// Pluralize returns the plural form of a single word. -func (ns *Namespace) Pluralize(in interface{}) (string, error) { - word, err := cast.ToStringE(in) +// Pluralize returns the plural form of the single word in v. +func (ns *Namespace) Pluralize(v any) (string, error) { + word, err := cast.ToStringE(v) if err != nil { return "", err } @@ -66,9 +64,9 @@ func (ns *Namespace) Pluralize(in interface{}) (string, error) { return _inflect.Pluralize(word), nil } -// Singularize returns the singular form of a single word. -func (ns *Namespace) Singularize(in interface{}) (string, error) { - word, err := cast.ToStringE(in) +// Singularize returns the singular form of a single word in v. +func (ns *Namespace) Singularize(v any) (string, error) { + word, err := cast.ToStringE(v) if err != nil { return "", err } diff --git a/tpl/inflect/inflect_test.go b/tpl/inflect/inflect_test.go index 609d4a470..083e7da4e 100644 --- a/tpl/inflect/inflect_test.go +++ b/tpl/inflect/inflect_test.go @@ -13,9 +13,9 @@ func TestInflect(t *testing.T) { ns := New() for _, test := range []struct { - fn func(i interface{}) (string, error) - in interface{} - expect interface{} + fn func(i any) (string, error) + in any + expect any }{ {ns.Humanize, "MyCamel", "My camel"}, {ns.Humanize, "óbito", "Óbito"}, @@ -26,6 +26,8 @@ func TestInflect(t *testing.T) { {ns.Humanize, int64(92), "92nd"}, {ns.Humanize, "5.5", "5.5"}, {ns.Humanize, t, false}, + {ns.Humanize, "this is a TEST", "This is a test"}, + {ns.Humanize, "my-first-Post", "My first post"}, {ns.Pluralize, "cat", "cats"}, {ns.Pluralize, "", ""}, {ns.Pluralize, t, false}, diff --git a/tpl/inflect/init.go b/tpl/inflect/init.go index 3f258356b..736e9fbd6 100644 --- a/tpl/inflect/init.go +++ b/tpl/inflect/init.go @@ -14,6 +14,8 @@ package inflect import ( + "context" + "github.com/gohugoio/hugo/deps" "github.com/gohugoio/hugo/tpl/internal" ) @@ -26,7 +28,7 @@ func init() { ns := &internal.TemplateFuncsNamespace{ Name: name, - Context: func(args ...interface{}) interface{} { return ctx }, + Context: func(cctx context.Context, args ...any) (any, error) { return ctx, nil }, } ns.AddMethodMapping(ctx.Humanize, @@ -54,7 +56,6 @@ func init() { ) return ns - } internal.AddTemplateFuncsNamespace(f) diff --git a/tpl/inflect/init_test.go b/tpl/inflect/init_test.go deleted file mode 100644 index 322813b5f..000000000 --- a/tpl/inflect/init_test.go +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright 2017 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 inflect - -import ( - "testing" - - "github.com/gohugoio/hugo/htesting/hqt" - - qt "github.com/frankban/quicktest" - "github.com/gohugoio/hugo/deps" - "github.com/gohugoio/hugo/tpl/internal" -) - -func TestInit(t *testing.T) { - c := qt.New(t) - var found bool - var ns *internal.TemplateFuncsNamespace - - for _, nsf := range internal.TemplateFuncsNamespaceRegistry { - ns = nsf(&deps.Deps{}) - if ns.Name == name { - found = true - break - } - } - - c.Assert(found, qt.Equals, true) - c.Assert(ns.Context(), hqt.IsSameType, &Namespace{}) -} diff --git a/tpl/internal/go_templates/cfg/cfg.go b/tpl/internal/go_templates/cfg/cfg.go new file mode 100644 index 000000000..932976972 --- /dev/null +++ b/tpl/internal/go_templates/cfg/cfg.go @@ -0,0 +1,74 @@ +// Copyright 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package cfg holds configuration shared by the Go command and internal/testenv. +// Definitions that don't need to be exposed outside of cmd/go should be in +// cmd/go/internal/cfg instead of this package. +package cfg + +// KnownEnv is a list of environment variables that affect the operation +// of the Go command. +const KnownEnv = ` + AR + CC + CGO_CFLAGS + CGO_CFLAGS_ALLOW + CGO_CFLAGS_DISALLOW + CGO_CPPFLAGS + CGO_CPPFLAGS_ALLOW + CGO_CPPFLAGS_DISALLOW + CGO_CXXFLAGS + CGO_CXXFLAGS_ALLOW + CGO_CXXFLAGS_DISALLOW + CGO_ENABLED + CGO_FFLAGS + CGO_FFLAGS_ALLOW + CGO_FFLAGS_DISALLOW + CGO_LDFLAGS + CGO_LDFLAGS_ALLOW + CGO_LDFLAGS_DISALLOW + CXX + FC + GCCGO + GO111MODULE + GO386 + GOAMD64 + GOARCH + GOARM + GOARM64 + GOAUTH + GOBIN + GOCACHE + GOCACHEPROG + GOENV + GOEXE + GOEXPERIMENT + GOFIPS140 + GOFLAGS + GOGCCFLAGS + GOHOSTARCH + GOHOSTOS + GOINSECURE + GOMIPS + GOMIPS64 + GOMODCACHE + GONOPROXY + GONOSUMDB + GOOS + GOPATH + GOPPC64 + GOPRIVATE + GOPROXY + GORISCV64 + GOROOT + GOSUMDB + GOTMPDIR + GOTOOLCHAIN + GOTOOLDIR + GOVCS + GOWASM + GOWORK + GO_EXTLINK_ENABLED + PKG_CONFIG +` diff --git a/tpl/internal/go_templates/fmtsort/sort.go b/tpl/internal/go_templates/fmtsort/sort.go index 70a305a3a..f51cdc708 100644 --- a/tpl/internal/go_templates/fmtsort/sort.go +++ b/tpl/internal/go_templates/fmtsort/sort.go @@ -9,25 +9,23 @@ package fmtsort import ( + "cmp" "reflect" - "sort" + "slices" ) // Note: Throughout this package we avoid calling reflect.Value.Interface as // it is not always legal to do so and it's easier to avoid the issue than to face it. -// SortedMap represents a map's keys and values. The keys and values are -// aligned in index order: Value[i] is the value in the map corresponding to Key[i]. -type SortedMap struct { - Key []reflect.Value - Value []reflect.Value -} +// SortedMap is a slice of KeyValue pairs that simplifies sorting +// and iterating over map entries. +// +// Each KeyValue pair contains a map key and its corresponding value. +type SortedMap []KeyValue -func (o *SortedMap) Len() int { return len(o.Key) } -func (o *SortedMap) Less(i, j int) bool { return compare(o.Key[i], o.Key[j]) < 0 } -func (o *SortedMap) Swap(i, j int) { - o.Key[i], o.Key[j] = o.Key[j], o.Key[i] - o.Value[i], o.Value[j] = o.Value[j], o.Value[i] +// KeyValue holds a single key and value pair found in a map. +type KeyValue struct { + Key, Value reflect.Value } // Sort accepts a map and returns a SortedMap that has the same keys and @@ -36,35 +34,34 @@ func (o *SortedMap) Swap(i, j int) { // // The ordering rules are more general than with Go's < operator: // -// - when applicable, nil compares low -// - ints, floats, and strings order by < -// - NaN compares less than non-NaN floats -// - bool compares false before true -// - complex compares real, then imag -// - pointers compare by machine address -// - channel values compare by machine address -// - structs compare each field in turn -// - arrays compare each element in turn. -// Otherwise identical arrays compare by length. -// - interface values compare first by reflect.Type describing the concrete type -// and then by concrete value as described in the previous rules. -// -func Sort(mapValue reflect.Value) *SortedMap { +// - when applicable, nil compares low +// - ints, floats, and strings order by < +// - NaN compares less than non-NaN floats +// - bool compares false before true +// - complex compares real, then imag +// - pointers compare by machine address +// - channel values compare by machine address +// - structs compare each field in turn +// - arrays compare each element in turn. +// Otherwise identical arrays compare by length. +// - interface values compare first by reflect.Type describing the concrete type +// and then by concrete value as described in the previous rules. +func Sort(mapValue reflect.Value) SortedMap { if mapValue.Type().Kind() != reflect.Map { return nil } - key := make([]reflect.Value, mapValue.Len()) - value := make([]reflect.Value, len(key)) + // Note: this code is arranged to not panic even in the presence + // of a concurrent map update. The runtime is responsible for + // yelling loudly if that happens. See issue 33275. + n := mapValue.Len() + sorted := make(SortedMap, 0, n) iter := mapValue.MapRange() - for i := 0; iter.Next(); i++ { - key[i] = iter.Key() - value[i] = iter.Value() + for iter.Next() { + sorted = append(sorted, KeyValue{iter.Key(), iter.Value()}) } - sorted := &SortedMap{ - Key: key, - Value: value, - } - sort.Stable(sorted) + slices.SortStableFunc(sorted, func(a, b KeyValue) int { + return compare(a.Key, b.Key) + }) return sorted } @@ -79,43 +76,19 @@ func compare(aVal, bVal reflect.Value) int { } switch aVal.Kind() { case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - a, b := aVal.Int(), bVal.Int() - switch { - case a < b: - return -1 - case a > b: - return 1 - default: - return 0 - } + return cmp.Compare(aVal.Int(), bVal.Int()) case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: - a, b := aVal.Uint(), bVal.Uint() - switch { - case a < b: - return -1 - case a > b: - return 1 - default: - return 0 - } + return cmp.Compare(aVal.Uint(), bVal.Uint()) case reflect.String: - a, b := aVal.String(), bVal.String() - switch { - case a < b: - return -1 - case a > b: - return 1 - default: - return 0 - } + return cmp.Compare(aVal.String(), bVal.String()) case reflect.Float32, reflect.Float64: - return floatCompare(aVal.Float(), bVal.Float()) + return cmp.Compare(aVal.Float(), bVal.Float()) case reflect.Complex64, reflect.Complex128: a, b := aVal.Complex(), bVal.Complex() - if c := floatCompare(real(a), real(b)); c != 0 { + if c := cmp.Compare(real(a), real(b)); c != 0 { return c } - return floatCompare(imag(a), imag(b)) + return cmp.Compare(imag(a), imag(b)) case reflect.Bool: a, b := aVal.Bool(), bVal.Bool() switch { @@ -126,29 +99,13 @@ func compare(aVal, bVal reflect.Value) int { default: return -1 } - case reflect.Ptr: - a, b := aVal.Pointer(), bVal.Pointer() - switch { - case a < b: - return -1 - case a > b: - return 1 - default: - return 0 - } + case reflect.Pointer, reflect.UnsafePointer: + return cmp.Compare(aVal.Pointer(), bVal.Pointer()) case reflect.Chan: if c, ok := nilCompare(aVal, bVal); ok { return c } - ap, bp := aVal.Pointer(), bVal.Pointer() - switch { - case ap < bp: - return -1 - case ap > bp: - return 1 - default: - return 0 - } + return cmp.Compare(aVal.Pointer(), bVal.Pointer()) case reflect.Struct: for i := 0; i < aVal.NumField(); i++ { if c := compare(aVal.Field(i), bVal.Field(i)); c != 0 { @@ -195,22 +152,3 @@ func nilCompare(aVal, bVal reflect.Value) (int, bool) { } return 0, false } - -// floatCompare compares two floating-point values. NaNs compare low. -func floatCompare(a, b float64) int { - switch { - case isNaN(a): - return -1 // No good answer if b is a NaN so don't bother checking. - case isNaN(b): - return 1 - case a < b: - return -1 - case a > b: - return 1 - } - return 0 -} - -func isNaN(a float64) bool { - return a != a -} diff --git a/tpl/internal/go_templates/fmtsort/sort_test.go b/tpl/internal/go_templates/fmtsort/sort_test.go index 601ec9d25..0986dbb6d 100644 --- a/tpl/internal/go_templates/fmtsort/sort_test.go +++ b/tpl/internal/go_templates/fmtsort/sort_test.go @@ -5,12 +5,16 @@ package fmtsort_test import ( + "cmp" "fmt" "github.com/gohugoio/hugo/tpl/internal/go_templates/fmtsort" "math" "reflect" + "runtime" + "slices" "strings" "testing" + "unsafe" ) var compareTests = [][]reflect.Value{ @@ -32,15 +36,16 @@ var compareTests = [][]reflect.Value{ ct(reflect.TypeOf(complex128(0+1i)), -1-1i, -1+0i, -1+1i, 0-1i, 0+0i, 0+1i, 1-1i, 1+0i, 1+1i), ct(reflect.TypeOf(false), false, true), ct(reflect.TypeOf(&ints[0]), &ints[0], &ints[1], &ints[2]), + ct(reflect.TypeOf(unsafe.Pointer(&ints[0])), unsafe.Pointer(&ints[0]), unsafe.Pointer(&ints[1]), unsafe.Pointer(&ints[2])), ct(reflect.TypeOf(chans[0]), chans[0], chans[1], chans[2]), ct(reflect.TypeOf(toy{}), toy{0, 1}, toy{0, 2}, toy{1, -1}, toy{1, 1}), ct(reflect.TypeOf([2]int{}), [2]int{1, 1}, [2]int{1, 2}, [2]int{2, 0}), - ct(reflect.TypeOf(interface{}(interface{}(0))), iFace, 1, 2, 3), + ct(reflect.TypeOf(any(0)), iFace, 1, 2, 3), } -var iFace interface{} +var iFace any -func ct(typ reflect.Type, args ...interface{}) []reflect.Value { +func ct(typ reflect.Type, args ...any) []reflect.Value { value := make([]reflect.Value, len(args)) for i, v := range args { x := reflect.ValueOf(v) @@ -63,10 +68,6 @@ func TestCompare(t *testing.T) { switch { case i == j: expect = 0 - // NaNs are tricky. - if typ := v0.Type(); (typ.Kind() == reflect.Float32 || typ.Kind() == reflect.Float64) && math.IsNaN(v0.Float()) { - expect = -1 - } case i < j: expect = -1 case i > j: @@ -81,8 +82,8 @@ func TestCompare(t *testing.T) { } type sortTest struct { - data interface{} // Always a map. - print string // Printed result using our custom printer. + data any // Always a map. + print string // Printed result using our custom printer. } var sortTests = []sortTest{ @@ -119,7 +120,11 @@ var sortTests = []sortTest{ "PTR0:0 PTR1:1 PTR2:2", }, { - map[toy]string{toy{7, 2}: "72", toy{7, 1}: "71", toy{3, 4}: "34"}, + unsafePointerMap(), + "UNSAFEPTR0:0 UNSAFEPTR1:1 UNSAFEPTR2:2", + }, + { + map[toy]string{{7, 2}: "72", {7, 1}: "71", {3, 4}: "34"}, "{3 4}:34 {7 1}:71 {7 2}:72", }, { @@ -128,19 +133,19 @@ var sortTests = []sortTest{ }, } -func sprint(data interface{}) string { +func sprint(data any) string { om := fmtsort.Sort(reflect.ValueOf(data)) if om == nil { return "nil" } b := new(strings.Builder) - for i, key := range om.Key { + for i, m := range om { if i > 0 { b.WriteRune(' ') } - b.WriteString(sprintKey(key)) + b.WriteString(sprintKey(m.Key)) b.WriteRune(':') - b.WriteString(fmt.Sprint(om.Value[i])) + fmt.Fprint(b, m.Value) } return b.String() } @@ -159,6 +164,14 @@ func sprintKey(key reflect.Value) string { } } return "PTR???" + case "unsafe.Pointer": + ptr := key.Interface().(unsafe.Pointer) + for i := range ints { + if ptr == unsafe.Pointer(&ints[i]) { + return fmt.Sprintf("UNSAFEPTR%d", i) + } + } + return "UNSAFEPTR???" case "chan int": c := key.Interface().(chan int) for i := range chans { @@ -174,9 +187,22 @@ func sprintKey(key reflect.Value) string { var ( ints [3]int - chans = [3]chan int{make(chan int), make(chan int), make(chan int)} + chans = makeChans() + pin runtime.Pinner ) +func makeChans() []chan int { + cs := []chan int{make(chan int), make(chan int), make(chan int)} + // Order channels by address. See issue #49431. + for i := range cs { + pin.Pin(reflect.ValueOf(cs[i]).UnsafePointer()) + } + slices.SortFunc(cs, func(a, b chan int) int { + return cmp.Compare(reflect.ValueOf(a).Pointer(), reflect.ValueOf(b).Pointer()) + }) + return cs +} + func pointerMap() map[*int]string { m := make(map[*int]string) for i := 2; i >= 0; i-- { @@ -185,6 +211,14 @@ func pointerMap() map[*int]string { return m } +func unsafePointerMap() map[unsafe.Pointer]string { + m := make(map[unsafe.Pointer]string) + for i := 2; i >= 0; i-- { + m[unsafe.Pointer(&ints[i])] = fmt.Sprint(i) + } + return m +} + func chanMap() map[chan int]string { m := make(map[chan int]string) for i := 2; i >= 0; i-- { @@ -211,7 +245,7 @@ func TestInterface(t *testing.T) { // A map containing multiple concrete types should be sorted by type, // then value. However, the relative ordering of types is unspecified, // so test this by checking the presence of sorted subgroups. - m := map[interface{}]string{ + m := map[any]string{ [2]int{1, 0}: "", [2]int{0, 1}: "", true: "", diff --git a/tpl/internal/go_templates/htmltemplate/attr.go b/tpl/internal/go_templates/htmltemplate/attr.go index 22922e603..6c52211fe 100644 --- a/tpl/internal/go_templates/htmltemplate/attr.go +++ b/tpl/internal/go_templates/htmltemplate/attr.go @@ -143,12 +143,12 @@ func attrType(name string) contentType { // widely applied. // Treat data-action as URL below. name = name[5:] - } else if colon := strings.IndexRune(name, ':'); colon != -1 { - if name[:colon] == "xmlns" { + } else if prefix, short, ok := strings.Cut(name, ":"); ok { + if prefix == "xmlns" { return contentTypeURL } // Treat svg:href and xlink:href as href below. - name = name[colon+1:] + name = short } if t, ok := attrTypeMap[name]; ok { return t diff --git a/tpl/internal/go_templates/htmltemplate/attr_string.go b/tpl/internal/go_templates/htmltemplate/attr_string.go index babe70c08..51c3f2620 100644 --- a/tpl/internal/go_templates/htmltemplate/attr_string.go +++ b/tpl/internal/go_templates/htmltemplate/attr_string.go @@ -4,6 +4,18 @@ package template import "strconv" +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[attrNone-0] + _ = x[attrScript-1] + _ = x[attrScriptType-2] + _ = x[attrStyle-3] + _ = x[attrURL-4] + _ = x[attrSrcset-5] +} + const _attr_name = "attrNoneattrScriptattrScriptTypeattrStyleattrURLattrSrcset" var _attr_index = [...]uint8{0, 8, 18, 32, 41, 48, 58} diff --git a/tpl/internal/go_templates/htmltemplate/clone_test.go b/tpl/internal/go_templates/htmltemplate/clone_test.go index 2035e7101..7db335b5b 100644 --- a/tpl/internal/go_templates/htmltemplate/clone_test.go +++ b/tpl/internal/go_templates/htmltemplate/clone_test.go @@ -2,15 +2,15 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. +//go:build go1.13 && !windows // +build go1.13,!windows package template import ( - "bytes" "errors" "fmt" - "io/ioutil" + "io" "strings" "sync" "testing" @@ -18,14 +18,14 @@ import ( "github.com/gohugoio/hugo/tpl/internal/go_templates/texttemplate/parse" ) -func TestAddParseTree(t *testing.T) { +func TestAddParseTreeHTML(t *testing.T) { root := Must(New("root").Parse(`{{define "a"}} {{.}} {{template "b"}} {{.}} ">{{end}}`)) tree, err := parse.Parse("t", `{{define "b"}}0") if err != nil { t.Fatal(err) @@ -42,7 +42,7 @@ func TestClone(t *testing.T) { // In the t2 template, it will be in a JavaScript context. // In the t3 template, it will be in a CSS context. const tmpl = `{{define "a"}}{{template "lhs"}}{{.}}{{template "rhs"}}{{end}}` - b := new(bytes.Buffer) + b := new(strings.Builder) // Create an incomplete template t0. t0 := Must(New("t0").Parse(tmpl)) @@ -174,7 +174,7 @@ func TestCloneThenParse(t *testing.T) { t.Error("adding a template to a clone added it to the original") } // double check that the embedded template isn't available in the original - err := t0.ExecuteTemplate(ioutil.Discard, "a", nil) + err := t0.ExecuteTemplate(io.Discard, "a", nil) if err == nil { t.Error("expected 'no such template' error") } @@ -188,13 +188,13 @@ func TestFuncMapWorksAfterClone(t *testing.T) { // get the expected error output (no clone) uncloned := Must(New("").Funcs(funcs).Parse("{{customFunc}}")) - wantErr := uncloned.Execute(ioutil.Discard, nil) + wantErr := uncloned.Execute(io.Discard, nil) // toClone must be the same as uncloned. It has to be recreated from scratch, // since cloning cannot occur after execution. toClone := Must(New("").Funcs(funcs).Parse("{{customFunc}}")) cloned := Must(toClone.Clone()) - gotErr := cloned.Execute(ioutil.Discard, nil) + gotErr := cloned.Execute(io.Discard, nil) if wantErr.Error() != gotErr.Error() { t.Errorf("clone error message mismatch want %q got %q", wantErr, gotErr) @@ -216,7 +216,7 @@ func TestTemplateCloneExecuteRace(t *testing.T) { go func() { defer wg.Done() for i := 0; i < 100; i++ { - if err := tmpl.Execute(ioutil.Discard, "data"); err != nil { + if err := tmpl.Execute(io.Discard, "data"); err != nil { panic(err) } } @@ -240,7 +240,7 @@ func TestCloneGrowth(t *testing.T) { tmpl = Must(tmpl.Clone()) Must(tmpl.Parse(`{{define "B"}}Text{{end}}`)) for i := 0; i < 10; i++ { - tmpl.Execute(ioutil.Discard, nil) + tmpl.Execute(io.Discard, nil) } if len(tmpl.DefinedTemplates()) > 200 { t.Fatalf("too many templates: %v", len(tmpl.DefinedTemplates())) @@ -260,7 +260,7 @@ func TestCloneRedefinedName(t *testing.T) { for i := 0; i < 2; i++ { t2 := Must(t1.Clone()) t2 = Must(t2.New(fmt.Sprintf("%d", i)).Parse(page)) - err := t2.Execute(ioutil.Discard, nil) + err := t2.Execute(io.Discard, nil) if err != nil { t.Fatal(err) } diff --git a/tpl/internal/go_templates/htmltemplate/content.go b/tpl/internal/go_templates/htmltemplate/content.go index bc32dc813..d19b1ec12 100644 --- a/tpl/internal/go_templates/htmltemplate/content.go +++ b/tpl/internal/go_templates/htmltemplate/content.go @@ -29,35 +29,35 @@ const ( // indirect returns the value, after dereferencing as many times // as necessary to reach the base type (or nil). -func indirect(a interface{}) interface{} { +func doIndirect(a any) any { if a == nil { return nil } - if t := reflect.TypeOf(a); t.Kind() != reflect.Ptr { + if t := reflect.TypeOf(a); t.Kind() != reflect.Pointer { // Avoid creating a reflect.Value if it's not a pointer. return a } v := reflect.ValueOf(a) - for v.Kind() == reflect.Ptr && !v.IsNil() { + for v.Kind() == reflect.Pointer && !v.IsNil() { v = v.Elem() } return v.Interface() } var ( - errorType = reflect.TypeOf((*error)(nil)).Elem() - fmtStringerType = reflect.TypeOf((*fmt.Stringer)(nil)).Elem() + errorType = reflect.TypeFor[error]() + fmtStringerType = reflect.TypeFor[fmt.Stringer]() ) // indirectToStringerOrError returns the value, after dereferencing as many times // as necessary to reach the base type (or nil) or an implementation of fmt.Stringer -// or error, -func indirectToStringerOrError(a interface{}) interface{} { +// or error. +func indirectToStringerOrError(a any) any { if a == nil { return nil } v := reflect.ValueOf(a) - for !v.Type().Implements(fmtStringerType) && !v.Type().Implements(errorType) && v.Kind() == reflect.Ptr && !v.IsNil() { + for !v.Type().Implements(fmtStringerType) && !v.Type().Implements(errorType) && v.Kind() == reflect.Pointer && !v.IsNil() { v = v.Elem() } return v.Interface() @@ -65,7 +65,7 @@ func indirectToStringerOrError(a interface{}) interface{} { // stringify converts its arguments to a string and the type of the content. // All pointers are dereferenced, as in the text/template package. -func stringify(args ...interface{}) (string, contentType) { +func stringify(args ...any) (string, contentType) { if len(args) == 1 { switch s := indirect(args[0]).(type) { case string: diff --git a/tpl/internal/go_templates/htmltemplate/content_test.go b/tpl/internal/go_templates/htmltemplate/content_test.go index 2a1abfbfb..fac4774cc 100644 --- a/tpl/internal/go_templates/htmltemplate/content_test.go +++ b/tpl/internal/go_templates/htmltemplate/content_test.go @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. +//go:build go1.13 && !windows // +build go1.13,!windows package template @@ -15,13 +16,13 @@ import ( ) func TestTypedContent(t *testing.T) { - data := []interface{}{ + data := []any{ ` "foo%" O'Reilly &bar;`, htmltemplate.CSS(`a[href =~ "//example.com"]#foo`), htmltemplate.HTML(`Hello, World &tc!`), htmltemplate.HTMLAttr(` dir="ltr"`), htmltemplate.JS(`c && alert("Hello, World!");`), - htmltemplate.JSStr(`Hello, World & O'Reilly\x21`), + htmltemplate.JSStr(`Hello, World & O'Reilly\u0021`), htmltemplate.URL(`greeting=H%69,&addressee=(World)`), htmltemplate.Srcset(`greeting=H%69,&addressee=(World) 2x, https://golang.org/favicon.ico 500.5w`), htmltemplate.URL(`,foo/,`), @@ -73,7 +74,7 @@ func TestTypedContent(t *testing.T) { `Hello, World &tc!`, ` dir="ltr"`, `c && alert("Hello, World!");`, - `Hello, World & O'Reilly\x21`, + `Hello, World & O'Reilly\u0021`, `greeting=H%69,&addressee=(World)`, `greeting=H%69,&addressee=(World) 2x, https://golang.org/favicon.ico 500.5w`, `,foo/,`, @@ -103,7 +104,7 @@ func TestTypedContent(t *testing.T) { `Hello, World &tc!`, ` dir="ltr"`, `c && alert("Hello, World!");`, - `Hello, World & O'Reilly\x21`, + `Hello, World & O'Reilly\u0021`, `greeting=H%69,&addressee=(World)`, `greeting=H%69,&addressee=(World) 2x, https://golang.org/favicon.ico 500.5w`, `,foo/,`, @@ -118,7 +119,7 @@ func TestTypedContent(t *testing.T) { `Hello, World &tc!`, ` dir="ltr"`, `c && alert("Hello, World!");`, - `Hello, World & O'Reilly\x21`, + `Hello, World & O'Reilly\u0021`, `greeting=H%69,&addressee=(World)`, `greeting=H%69,&addressee=(World) 2x, https://golang.org/favicon.ico 500.5w`, `,foo/,`, @@ -133,7 +134,7 @@ func TestTypedContent(t *testing.T) { `Hello, <b>World</b> &tc!`, ` dir="ltr"`, `c && alert("Hello, World!");`, - `Hello, World & O'Reilly\x21`, + `Hello, World & O'Reilly\u0021`, `greeting=H%69,&addressee=(World)`, `greeting=H%69,&addressee=(World) 2x, https://golang.org/favicon.ico 500.5w`, `,foo/,`, @@ -149,7 +150,7 @@ func TestTypedContent(t *testing.T) { // Not escaped. `c && alert("Hello, World!");`, // Escape sequence not over-escaped. - `"Hello, World & O'Reilly\x21"`, + `"Hello, World & O'Reilly\u0021"`, `"greeting=H%69,\u0026addressee=(World)"`, `"greeting=H%69,\u0026addressee=(World) 2x, https://golang.org/favicon.ico 500.5w"`, `",foo/,"`, @@ -165,7 +166,7 @@ func TestTypedContent(t *testing.T) { // Not JS escaped but HTML escaped. `c && alert("Hello, World!");`, // Escape sequence not over-escaped. - `"Hello, World & O'Reilly\x21"`, + `"Hello, World & O'Reilly\u0021"`, `"greeting=H%69,\u0026addressee=(World)"`, `"greeting=H%69,\u0026addressee=(World) 2x, https://golang.org/favicon.ico 500.5w"`, `",foo/,"`, @@ -174,30 +175,30 @@ func TestTypedContent(t *testing.T) { { ``, []string{ - `\x3cb\x3e \x22foo%\x22 O\x27Reilly \x26bar;`, - `a[href =~ \x22\/\/example.com\x22]#foo`, - `Hello, \x3cb\x3eWorld\x3c\/b\x3e \x26amp;tc!`, - ` dir=\x22ltr\x22`, - `c \x26\x26 alert(\x22Hello, World!\x22);`, + `\u003cb\u003e \u0022foo%\u0022 O\u0027Reilly \u0026bar;`, + `a[href =~ \u0022\/\/example.com\u0022]#foo`, + `Hello, \u003cb\u003eWorld\u003c\/b\u003e \u0026amp;tc!`, + ` dir=\u0022ltr\u0022`, + `c \u0026\u0026 alert(\u0022Hello, World!\u0022);`, // Escape sequence not over-escaped. - `Hello, World \x26 O\x27Reilly\x21`, - `greeting=H%69,\x26addressee=(World)`, - `greeting=H%69,\x26addressee=(World) 2x, https:\/\/golang.org\/favicon.ico 500.5w`, + `Hello, World \u0026 O\u0027Reilly\u0021`, + `greeting=H%69,\u0026addressee=(World)`, + `greeting=H%69,\u0026addressee=(World) 2x, https:\/\/golang.org\/favicon.ico 500.5w`, `,foo\/,`, }, }, { ``, []string{ - `\x3cb\x3e \x22foo%\x22 O\x27Reilly \x26bar;`, - `a[href =~ \x22\/\/example.com\x22]#foo`, - `Hello, \x3cb\x3eWorld\x3c\/b\x3e \x26amp;tc!`, - ` dir=\x22ltr\x22`, - `c \x26\x26 alert(\x22Hello, World!\x22);`, + `\u003cb\u003e \u0022foo%\u0022 O\u0027Reilly \u0026bar;`, + `a[href =~ \u0022\/\/example.com\u0022]#foo`, + `Hello, \u003cb\u003eWorld\u003c\/b\u003e \u0026amp;tc!`, + ` dir=\u0022ltr\u0022`, + `c \u0026\u0026 alert(\u0022Hello, World!\u0022);`, // Escape sequence not over-escaped. - `Hello, World \x26 O\x27Reilly\x21`, - `greeting=H%69,\x26addressee=(World)`, - `greeting=H%69,\x26addressee=(World) 2x, https:\/\/golang.org\/favicon.ico 500.5w`, + `Hello, World \u0026 O\u0027Reilly\u0021`, + `greeting=H%69,\u0026addressee=(World)`, + `greeting=H%69,\u0026addressee=(World) 2x, https:\/\/golang.org\/favicon.ico 500.5w`, `,foo\/,`, }, }, @@ -211,7 +212,7 @@ func TestTypedContent(t *testing.T) { // Not escaped. `c && alert("Hello, World!");`, // Escape sequence not over-escaped. - `"Hello, World & O'Reilly\x21"`, + `"Hello, World & O'Reilly\u0021"`, `"greeting=H%69,\u0026addressee=(World)"`, `"greeting=H%69,\u0026addressee=(World) 2x, https://golang.org/favicon.ico 500.5w"`, `",foo/,"`, @@ -227,7 +228,7 @@ func TestTypedContent(t *testing.T) { `Hello, World &tc!`, ` dir="ltr"`, `c && alert("Hello, World!");`, - `Hello, World & O'Reilly\x21`, + `Hello, World & O'Reilly\u0021`, `greeting=H%69,&addressee=(World)`, `greeting=H%69,&addressee=(World) 2x, https://golang.org/favicon.ico 500.5w`, `,foo/,`, @@ -236,15 +237,15 @@ func TestTypedContent(t *testing.T) { { `