diff --git a/.circleci/config.yml b/.circleci/config.yml index 7a248bb4a..06e643bdd 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,50 +1,115 @@ +parameters: + +# v2: 11m. defaults: &defaults - working_directory: /go/src/github.com/gohugoio + resource_class: large docker: - - image: bepsays/ci-goreleaser:0.34.2-10 - + - 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: | - go get -d github.com/magefile/mage/... - git clone git@github.com:gohugoio/hugoDocs.git - cd hugo - mage vendor - mage check - - 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: /go/src/github.com/gohugoio + - &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/.dockerignore b/.dockerignore new file mode 100644 index 000000000..a183f6fcf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +*.md +*.log +*.txt +.git +.github +.circleci +docs +examples +Dockerfile diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..6994810cf --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# Text files have auto line endings +* text=auto + +# Go source files always have LF line endings +*.go text eol=lf + +# SVG files should not be modified +*.svg -text diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..fa2791492 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,23 @@ +--- +name: 'Bug report' +labels: 'Bug, NeedsTriage' +assignees: '' +about: Create a report to help us improve +--- + + + + + +### What version of Hugo are you using (`hugo version`)? + +
+$ hugo version
+
+
+ +### Does this issue reproduce with the latest release? 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 new file mode 100644 index 000000000..c114b3d7f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,11 @@ +--- +name: Proposal +about: Propose a new feature for Hugo +title: '' +labels: 'Proposal, NeedsTriage' +assignees: '' + +--- + + + \ No newline at end of file diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md new file mode 100644 index 000000000..cc9de09ff --- /dev/null +++ b/.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/.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/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 08e830c87..ddad69611 100644 --- a/.gitignore +++ b/.gitignore @@ -1,19 +1,6 @@ -hugo -docs/public* -/.idea -hugo.exe -*.test -*.prof -nohup.out -cover.out -*.swp -*.swo -.DS_Store -*~ -vendor/*/ -*.bench -*.debug -coverage*.out -GoBuilds -dist +*.test +imports.* +dist/ +public/ +.DS_Store \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 352e9e3ff..000000000 --- a/.travis.yml +++ /dev/null @@ -1,25 +0,0 @@ -language: go -sudo: false -dist: trusty -go: - - 1.9.4 - - "1.10" - - tip -os: - - linux - - osx -matrix: - allow_failures: - - go: tip - fast_finish: true -install: - - go get github.com/magefile/mage - - mage -v vendor -script: - - mage -v hugoRace - - mage -v check - - ./hugo -s docs/ - - ./hugo --renderToMemory -s docs/ -before_install: - - gem install asciidoctor - - type asciidoctor diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9933e55a1..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, @@ -9,7 +11,7 @@ The Hugo community and maintainers are [very active](https://github.com/gohugoio *Note that this repository only contains the actual source code of Hugo. For **only** documentation-related pull requests / issues please refer to the [hugoDocs](https://github.com/gohugoio/hugoDocs) repository.* -*Pull requests that contain changes on the code base **and** related documentation, e.g. for a new feature, shall remain a single, atomic one.* +*Changes to the codebase **and** related documentation, e.g. for a new feature, should still use a single pull request.* ## Table of Contents @@ -18,11 +20,8 @@ The Hugo community and maintainers are [very active](https://github.com/gohugoio * [Submitting Patches](#submitting-patches) * [Code Contribution Guidelines](#code-contribution-guidelines) * [Git Commit Message Guidelines](#git-commit-message-guidelines) - * [Vendored Dependencies](#vendored-dependencies) * [Fetching the Sources From GitHub](#fetching-the-sources-from-github) - * [Using Git Remotes](#using-git-remotes) - * [Build Hugo with Your Changes](#build-hugo-with-your-changes) - * [Updating the Hugo Sources](#updating-the-hugo-sources) + * [Building Hugo with Your Changes](#building-hugo-with-your-changes) ## Asking Support Questions @@ -32,39 +31,42 @@ 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](https://github.com/gohugoio/hugo/issues) 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`) and your operating system. +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`) and your operating system. + +- [Hugo Issues · gohugoio/hugo](https://github.com/gohugoio/hugo/issues) +- [Hugo Documentation Issues · gohugoio/hugoDocs](https://github.com/gohugoio/hugoDocs/issues) +- [Hugo Website Theme Issues · gohugoio/hugoThemesSite](https://github.com/gohugoio/hugoThemesSite/issues) ## Code Contribution -Hugo has become a fully featured static site generator, so any new functionality must meet these criterias: +Hugo has become a fully featured static site generator, so any new functionality must: -* It must be useful to many. -* It must fit natural into _what Hugo does best._ -* It must strive not to break existing sites. -* It must close ur 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.). +* be useful to many. +* fit naturally into _what Hugo does best._ +* strive not to break existing sites. +* close or update an open [Hugo issue](https://github.com/gohugoio/hugo/issues) -So, to avoid doing unneeded work, it is recommended to open up a discussion on the [Hugo Forum](https://discourse.gohugo.io/) to get some acceptance that this is a good idea. Also, if this is a complex feature, create a small design proposal on the [Hugo issue tracker](https://github.com/gohugoio/hugo/issues) before you start coding. +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.). +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 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.** - - ## 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. -Because 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. +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. ### Code Contribution Guidelines +Because 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. + To make the contribution process as seamless as possible, we ask for the following: * Go ahead and fork the project and make your changes. We encourage pull requests to allow for review and discussion of code changes. @@ -74,24 +76,28 @@ To make the contribution process as seamless as possible, we ask for the followi * Run `go fmt`. * Add documentation if you are adding new features or changing functionality. The docs site lives in `/docs`. * Squash your commits into a single commit. `git rebase -i`. It’s okay to force update your pull request with `git push -f`. - * Ensure that `mage check` succeeds. [Travis CI](https://travis-ci.org/gohugoio/hugo) (Linux and macOS) and [AppVeyor](https://ci.appveyor.com/project/gohugoio/hugo/branch/master) (Windows) will fail the build if `mage check` fails. + * Ensure that `mage check` succeeds. [Travis CI](https://travis-ci.org/gohugoio/hugo) (Windows, Linux and macOS) will fail the build if `mage check` fails. * Follow the **Git Commit Message Guidelines** below. ### 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 packagename (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 consider to 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 @@ -107,30 +113,23 @@ Fixes #1949 ### Fetching the Sources From GitHub -Due to the way Go handles package imports, the best approach for working on a -Hugo fork is to use Git Remotes. Here's a simple walk-through for getting -started: +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: -1. Get the Hugo source: +```bash +mkdir $HOME/src +cd $HOME/src +git clone https://github.com/gohugoio/hugo.git +cd hugo +go install +``` - ```bash - go get -u -v -d github.com/gohugoio/hugo - ``` +For some convenient build and test targets, you also will want to install Mage: -1. Install Mage: +```bash +go install github.com/magefile/mage +``` - ```bash - go get github.com/magefile/mage - ``` - -1. Change to the Hugo source directory and fetch the dependencies: - - ```bash - cd $HOME/go/src/github.com/gohugoio/hugo - mage vendor - ``` - - Note that Hugo uses [Go Dep](https://github.com/golang/dep) to vendor dependencies, rather than a a simple `go get`. We don't commit the vendored packages themselves to the Hugo git repository. The call to `mage vendor` takes care of all this for you. +Now, to make a change to Hugo's source: 1. Create a new branch for your changes (the branch name is arbitrary): @@ -149,7 +148,7 @@ started: 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: @@ -168,7 +167,7 @@ Hugo uses [mage](https://github.com/magefile/mage) to sync vendor dependencies, cd $HOME/go/src/github.com/gohugoio/hugo ``` -To build Hugo: +To build Hugo: ```bash mage hugo @@ -193,14 +192,8 @@ To list all available commands along with descriptions: mage -l ``` -### Updating the Hugo Sources - -If you want to stay in sync with the Hugo repository, you can easily pull down -the source changes, but you'll need to keep the vendored packages up-to-date as -well. +**Note:** From Hugo 0.43 we have added a build tag, `extended` that adds **SCSS support**. This needs a C compiler installed to build. You can enable this when building by: ```bash -git pull -mage vendor -``` - +HUGO_BUILD_TAGS=extended mage install +```` diff --git a/Dockerfile b/Dockerfile old mode 100644 new mode 100755 index 14834dcc2..a0e34353f --- a/Dockerfile +++ b/Dockerfile @@ -1,23 +1,99 @@ -FROM golang:1.9.0-alpine3.6 AS build +# GitHub: https://github.com/gohugoio +# Twitter: https://twitter.com/gohugoio +# Website: https://gohugo.io/ -RUN apk add --no-cache --virtual git musl-dev -RUN go get github.com/golang/dep/cmd/dep +ARG GO_VERSION="1.24" +ARG ALPINE_VERSION="3.22" +ARG DART_SASS_VERSION="1.79.3" + +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 + + +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 -ADD . /go/src/github.com/gohugoio/hugo/ -RUN dep ensure -RUN go install -ldflags '-s -w' -FROM alpine:3.6 -RUN \ - adduser -h /site -s /sbin/nologin -u 1000 -D hugo && \ - apk add --no-cache \ - dumb-init -COPY --from=build /go/bin/hugo /bin/hugo -USER hugo -WORKDIR /site -VOLUME /site -EXPOSE 1313 +# 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 <>_ - -### 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. diff --git a/README.md b/README.md index 7353bd464..9befa9c9d 100644 --- a/README.md +++ b/README.md @@ -1,113 +1,282 @@ -![Hugo](https://raw.githubusercontent.com/gohugoio/hugoDocs/master/static/img/hugo-logo.png) +[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) | -[Developer Chat (no support)](https://gitter.im/gohugoio/hugo) | -[Documentation](https://gohugo.io/overview/introduction/) | -[Installation Guide](https://gohugo.io/overview/installing/) | -[Contribution Guide](CONTRIBUTING.md) | -[Twitter](http://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=Linux+and+macOS+build "Linux and macOS Build Status")](https://travis-ci.org/gohugoio/hugo) -[![Windows Build Status](https://ci.appveyor.com/api/projects/status/a5mr220vsd091kua?svg=true&label=Windows+build "Windows Build Status")](https://ci.appveyor.com/project/bep/hugo/branch/master) -[![Dev chat at https://gitter.im/gohugoio/hugo](https://img.shields.io/badge/gitter-developer_chat-46bc99.svg)](https://gitter.im/spf13/hugo?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) +[![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, 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 DragonFly BSD, OpenBSD, 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][].** +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/overview/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. -Add Hugo and its package dependencies to your go `src` directory. +## Installation - go get -v github.com/gohugoio/hugo +Install Hugo from a [prebuilt binary], package manager, or package repository. Please see the installation instructions for your operating system: -Once the `get` completes, you should find your new `hugo` (or `hugo.exe`) executable sitting inside `$GOPATH/bin/`. +- [macOS] +- [Linux] +- [Windows] +- [DragonFly BSD, FreeBSD, NetBSD, and OpenBSD] -To update Hugo’s dependencies, use `go get` with the `-u` option. +## Build from source - go get -u -v github.com/gohugoio/hugo - -## The Hugo Documentation +Prerequisites to build Hugo from source: -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: +- 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 -```bash -git clone git@github.com:gohugoio/hugo.git +Build the standard edition: + +```text +go install github.com/gohugoio/hugo@latest ``` -## Contributing to Hugo + +Build the extended edition: + +```text +CGO_ENABLED=1 go install -tags extended github.com/gohugoio/hugo@latest +``` + +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. +## Dependencies -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. +Hugo stands on the shoulders of great open source libraries. Run `hugo env --logLevel info` to display a list of dependencies. -### Asking Support Questions +
+See current dependencies -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. - -Because 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/ +```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/appveyor.yml b/appveyor.yml deleted file mode 100644 index 0ed9e959f..000000000 --- a/appveyor.yml +++ /dev/null @@ -1,18 +0,0 @@ -init: - - set PATH=%PATH%;C:\MinGW\bin;%GOPATH%\bin - - go version - - go env - -# clones and cd's to path -clone_folder: C:\GOPATH\src\github.com\gohugoio\hugo - -install: - - gem install asciidoctor - - pip install docutils - - go get github.com/magefile/mage - -build_script: - - mage vendor hugoRace - - mage -v check - - hugo -s docs/ - - hugo --renderToMemory -s docs/ 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/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/bufferpool/bufpool_test.go b/bufferpool/bufpool_test.go index cfa247f62..023724b97 100644 --- a/bufferpool/bufpool_test.go +++ b/bufferpool/bufpool_test.go @@ -14,14 +14,18 @@ package bufferpool import ( - "github.com/stretchr/testify/assert" "testing" + + qt "github.com/frankban/quicktest" ) func TestBufferPool(t *testing.T) { + c := qt.New(t) + buff := GetBuffer() buff.WriteString("do be do be do") - assert.Equal(t, "do be do be do", buff.String()) + c.Assert(buff.String(), qt.Equals, "do be do be do") PutBuffer(buff) - assert.Equal(t, 0, buff.Len()) + + c.Assert(buff.Len(), qt.Equals, 0) } 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 new file mode 100644 index 000000000..01c466ca6 --- /dev/null +++ b/cache/filecache/filecache.go @@ -0,0 +1,496 @@ +// 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 + +import ( + "bytes" + "errors" + "io" + "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" + + "github.com/BurntSushi/locker" + "github.com/spf13/afero" +) + +// ErrFatal can be used to signal an unrecoverable error. +var ErrFatal = errors.New("fatal filecache error") + +const ( + FilecacheRootDirname = "filecache" +) + +// Cache caches a set of files in a directory. This is usually a file on +// disk, but since this is backed by an Afero file system, it can be anything. +type Cache struct { + Fs afero.Fs + + // Max age for items in this cache. Negative duration means forever, + // 0 is effectively turning this cache off. + maxAge time.Duration + + // When set, we just remove this entire root directory on expiration. + pruneAllRootDir string + + nlocker *lockTracker + + initOnce sync.Once + initErr error +} + +type lockTracker struct { + seenMu sync.RWMutex + seen map[string]struct{} + + *locker.Locker +} + +// Lock tracks the ids in use. We use this information to do garbage collection +// after a Hugo build. +func (l *lockTracker) Lock(id string) { + l.seenMu.RLock() + if _, seen := l.seen[id]; !seen { + l.seenMu.RUnlock() + l.seenMu.Lock() + l.seen[id] = struct{}{} + l.seenMu.Unlock() + } else { + l.seenMu.RUnlock() + } + + l.Locker.Lock(id) +} + +// ItemInfo contains info about a cached file. +type ItemInfo struct { + // This is the file's name relative to the cache's filesystem. + Name string +} + +// NewCache creates a new file cache with the given filesystem and max age. +func NewCache(fs afero.Fs, maxAge time.Duration, pruneAllRootDir string) *Cache { + return &Cache{ + Fs: fs, + nlocker: &lockTracker{Locker: locker.NewLocker(), seen: make(map[string]struct{})}, + maxAge: maxAge, + pruneAllRootDir: pruneAllRootDir, + } +} + +// lockedFile is a file with a lock that is released on Close. +type lockedFile struct { + afero.File + unlock func() +} + +func (l *lockedFile) Close() error { + defer l.unlock() + 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) + + info := ItemInfo{Name: id} + + f, err := helpers.OpenFileForWriting(c.Fs, id) + if err != nil { + c.nlocker.Unlock(id) + return info, nil, err + } + + return info, &lockedFile{ + File: f, + unlock: func() { c.nlocker.Unlock(id) }, + }, nil +} + +// ReadOrCreate tries to lookup the file in cache. +// If found, it is passed to read and then closed. +// If not found a new file is created and passed to create, which should close +// 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) { + if err := c.init(); err != nil { + return ItemInfo{}, err + } + + id = cleanID(id) + + c.nlocker.Lock(id) + defer c.nlocker.Unlock(id) + + info = ItemInfo{Name: id} + + if r := c.getOrRemove(id); r != nil { + err = read(info, r) + defer r.Close() + if err == nil || err == ErrFatal { + // See https://github.com/gohugoio/hugo/issues/6401 + // To recover from file corruption we handle read errors + // as the cache item was not found. + // Any file permission issue will also fail in the next step. + return + } + } + + f, err := helpers.OpenFileForWriting(c.Fs, id) + if err != nil { + return + } + + 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) + defer c.nlocker.Unlock(id) + + info := ItemInfo{Name: id} + + if r := c.getOrRemove(id); r != nil { + return info, r, nil + } + + var ( + r io.ReadCloser + err error + ) + + r, err = create() + if err != nil { + return info, nil, err + } + + if c.maxAge == 0 { + // No caching. + return info, hugio.ToReadCloser(r), nil + } + + var buff bytes.Buffer + return info, + hugio.ToReadCloser(&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) + defer c.nlocker.Unlock(id) + + info := ItemInfo{Name: id} + + if r := c.getOrRemove(id); r != nil { + defer r.Close() + b, err := io.ReadAll(r) + return info, b, err + } + + var ( + b []byte + err error + ) + + b, err = create() + if err != nil { + return info, nil, err + } + + if c.maxAge == 0 { + return info, b, nil + } + + if err := c.writeReader(id, bytes.NewReader(b)); err != nil { + return info, nil, err + } + + return info, b, nil +} + +// 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) + defer c.nlocker.Unlock(id) + + info := ItemInfo{Name: id} + + if r := c.getOrRemove(id); r != nil { + defer r.Close() + b, err := io.ReadAll(r) + return info, b, err + } + + return info, nil, nil +} + +// 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) + defer c.nlocker.Unlock(id) + + info := ItemInfo{Name: id} + + r := c.getOrRemove(id) + + return info, r, nil +} + +// getOrRemove gets the file with the given id. If it's expired, it will +// be removed. +func (c *Cache) getOrRemove(id string) hugio.ReadSeekCloser { + if c.maxAge == 0 { + // No caching. + return nil + } + + if removed, err := c.removeIfExpired(id); err != nil || removed { + return nil + } + + f, err := c.Fs.Open(id) + if err != nil { + return nil + } + + 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 { + 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, _ := io.ReadAll(f) + return string(b) +} + +// Caches is a named set of caches. +type Caches map[string]*Cache + +// Get gets a named cache, nil if none found. +func (f Caches) Get(name string) *Cache { + return f[strings.ToLower(name)] +} + +// NewCaches creates a new set of file caches from the given +// configuration. +func NewCaches(p *helpers.PathSpec) (Caches, error) { + 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 { + cfs = p.BaseFs.ResourcesCache + } else { + cfs = fs + } + + if cfs == nil { + panic("nil fs") + } + + baseDir := v.DirCompiled + + bfs := hugofs.NewBasePathFs(cfs, baseDir) + + var pruneAllRootDir string + if k == CacheKeyModules { + pruneAllRootDir = "pkg" + } + + m[k] = NewCache(bfs, v.MaxAge, pruneAllRootDir) + } + + return m, nil +} + +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 new file mode 100644 index 000000000..a71ddb474 --- /dev/null +++ b/cache/filecache/filecache_config.go @@ -0,0 +1,247 @@ +// 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 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/mitchellh/mapstructure" + "github.com/spf13/afero" +) + +const ( + resourcesGenDir = ":resourceDir/_gen" + cacheDirProject = ":cacheDir/:project" +) + +var defaultCacheConfig = FileCacheConfig{ + MaxAge: -1, // Never expire + Dir: cacheDirProject, +} + +const ( + CacheKeyGetJSON = "getjson" + CacheKeyGetCSV = "getcsv" + CacheKeyImages = "images" + CacheKeyAssets = "assets" + CacheKeyModules = "modules" + CacheKeyGetResource = "getresource" + CacheKeyMisc = "misc" +) + +type Configs map[string]FileCacheConfig + +// For internal use. +func (c Configs) CacheDirModules() string { + return c[CacheKeyModules].DirCompiled +} + +var defaultCacheConfigs = Configs{ + CacheKeyModules: { + MaxAge: -1, + Dir: ":cacheDir/modules", + }, + CacheKeyGetJSON: defaultCacheConfig, + CacheKeyGetCSV: defaultCacheConfig, + CacheKeyImages: { + MaxAge: -1, + Dir: resourcesGenDir, + }, + CacheKeyAssets: { + MaxAge: -1, + Dir: resourcesGenDir, + }, + CacheKeyGetResource: { + MaxAge: -1, // Never expire + Dir: cacheDirProject, + }, + CacheKeyMisc: { + MaxAge: -1, + Dir: cacheDirProject, + }, +} + +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. + // 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 + DirCompiled string `json:"-"` + + // Will resources/_gen will get its own composite filesystem that + // also checks any theme. + IsResourceDir bool `json:"-"` +} + +// GetJSONCache gets the file cache for getJSON. +func (f Caches) GetJSONCache() *Cache { + return f[CacheKeyGetJSON] +} + +// GetCSVCache gets the file cache for getCSV. +func (f Caches) GetCSVCache() *Cache { + return f[CacheKeyGetCSV] +} + +// ImageCache gets the file cache for processed images. +func (f Caches) ImageCache() *Cache { + return f[CacheKeyImages] +} + +// ModulesCache gets the file cache for Hugo Modules. +func (f Caches) ModulesCache() *Cache { + return f[CacheKeyModules] +} + +// AssetsCache gets the file cache for assets (processed resources, SCSS etc.). +func (f Caches) AssetsCache() *Cache { + return f[CacheKeyAssets] +} + +// 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 + for k, v := range defaultCacheConfigs { + c[k] = v + valid[k] = true + } + + _, isOsFs := fs.(*afero.OsFs) + + for k, v := range m { + if _, ok := v.(maps.Params); !ok { + continue + } + cc := defaultCacheConfig + + dc := &mapstructure.DecoderConfig{ + Result: &cc, + DecodeHook: mapstructure.StringToTimeDurationHookFunc(), + WeaklyTypedInput: true, + } + + decoder, err := mapstructure.NewDecoder(dc) + if err != nil { + return c, err + } + + if err := decoder.Decode(v); err != nil { + return nil, fmt.Errorf("failed to decode filecache config: %w", err) + } + + if cc.Dir == "" { + return c, errors.New("must provide cache Dir") + } + + name := strings.ToLower(k) + if !valid[name] { + return nil, fmt.Errorf("%q is not a valid cache name", name) + } + + c[name] = cc + } + + for k, v := range c { + dir := filepath.ToSlash(filepath.Clean(v.Dir)) + hadSlash := strings.HasPrefix(dir, "/") + parts := strings.Split(dir, "/") + + for i, part := range parts { + if strings.HasPrefix(part, ":") { + resolved, isResource, err := resolveDirPlaceholder(fs, bcfg, part) + if err != nil { + return c, err + } + if isResource { + v.IsResourceDir = true + } + parts[i] = resolved + } + } + + dir = path.Join(parts...) + if hadSlash { + dir = "/" + dir + } + v.DirCompiled = filepath.Clean(filepath.FromSlash(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.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.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.DirCompiled = filepath.Join(v.DirCompiled, FilecacheRootDirname, k) + } else { + v.DirCompiled = filepath.Join(v.DirCompiled, k) + } + + c[k] = v + } + + return c, nil +} + +// Resolves :resourceDir => /myproject/resources etc., :cacheDir => ... +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": + return bcfg.CacheDir, false, nil + case ":project": + return filepath.Base(bcfg.WorkingDir), false, nil + } + + 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 new file mode 100644 index 000000000..c6d346dfc --- /dev/null +++ b/cache/filecache/filecache_config_test.go @@ -0,0 +1,146 @@ +// 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 filecache_test + +import ( + "path/filepath" + "runtime" + "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" +) + +func TestDecodeConfig(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 = "/path/to/c1" +[caches.getCSV] +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 := 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.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.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) { + t.Parallel() + + c := qt.New(t) + + configStr := ` +resourceDir = "myresources" +contentDir = "content" +dataDir = "data" +i18nDir = "i18n" +layoutDir = "layouts" +assetDir = "assets" +archeTypedir = "archetypes" + +ignoreCache = true +[caches] +[caches.getJSON] +maxAge = 1234 +dir = "/path/to/c1" +[caches.getCSV] +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 := 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 := config.New() + + if runtime.GOOS == "windows" { + cfg.Set("resourceDir", "c:\\cache\\resources") + cfg.Set("cacheDir", "c:\\cache\\thecache") + + } else { + cfg.Set("resourceDir", "/cache/resources") + cfg.Set("cacheDir", "/cache/thecache") + } + cfg.Set("workingDir", filepath.FromSlash("/my/cool/hugoproject")) + + 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 new file mode 100644 index 000000000..6f224cef4 --- /dev/null +++ b/cache/filecache/filecache_pruner.go @@ -0,0 +1,137 @@ +// 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 filecache + +import ( + "fmt" + "io" + "os" + + "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/hugofs" + + "github.com/spf13/afero" +) + +// Prune removes expired and unused items from this cache. +// The last one requires a full build so the cache usage can be tracked. +// Note that we operate directly on the filesystem here, so this is not +// thread safe. +func (c Caches) Prune() (int, error) { + counter := 0 + for k, cache := range c { + count, err := cache.Prune(false) + + counter += count + + if err != nil { + if herrors.IsNotExist(err) { + continue + } + return counter, fmt.Errorf("failed to prune cache %q: %w", k, err) + } + + } + + return counter, nil +} + +// Prune removes expired and unused items from this cache. +// If force is set, everything will be removed not considering expiry time. +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 + + err := afero.Walk(c.Fs, "", func(name string, info os.FileInfo, err error) error { + if info == nil { + return nil + } + + name = cleanID(name) + + if info.IsDir() { + f, err := c.Fs.Open(name) + if err != nil { + // This cache dir may not exist. + return nil + } + _, err = f.Readdirnames(1) + f.Close() + if err == io.EOF { + // Empty dir. + if name == "." { + // e.g. /_gen/images -- keep it even if empty. + err = nil + } else { + err = c.Fs.Remove(name) + } + } + + if err != nil && !herrors.IsNotExist(err) { + return err + } + + return nil + } + + shouldRemove := force || c.isExpired(info.ModTime()) + + if !shouldRemove && len(c.nlocker.seen) > 0 { + // Remove it if it's not been touched/used in the last build. + _, seen := c.nlocker.seen[name] + shouldRemove = !seen + } + + if shouldRemove { + err := c.Fs.Remove(name) + if err == nil { + counter++ + } + + if err != nil && !herrors.IsNotExist(err) { + return err + } + + } + + return nil + }) + + return counter, err +} + +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 herrors.IsNotExist(err) { + return 0, nil + } + return 0, err + } + + if !force && !c.isExpired(info.ModTime()) { + return 0, nil + } + + return hugofs.MakeReadableAndRemoveAllModulePkgDir(c.Fs, c.pruneAllRootDir) +} diff --git a/cache/filecache/filecache_pruner_test.go b/cache/filecache/filecache_pruner_test.go new file mode 100644 index 000000000..b49ba7645 --- /dev/null +++ b/cache/filecache/filecache_pruner_test.go @@ -0,0 +1,111 @@ +// 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 filecache_test + +import ( + "fmt" + "testing" + "time" + + "github.com/gohugoio/hugo/cache/filecache" + "github.com/spf13/afero" + + qt "github.com/frankban/quicktest" +) + +func TestPrune(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 = "200ms" +dir = "/cache/c" +[caches.getcsv] +maxAge = "200ms" +dir = "/cache/d" +[caches.assets] +maxAge = "200ms" +dir = ":resourceDir/_gen" +[caches.images] +maxAge = "200ms" +dir = ":resourceDir/_gen" +` + + 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 := filecache.NewCaches(p) + c.Assert(err, qt.IsNil) + cache := caches[name] + for i := range 10 { + id := fmt.Sprintf("i%d", i) + cache.GetOrCreateBytes(id, func() ([]byte, error) { + return []byte("abc"), nil + }) + if i == 4 { + // This will expire the first 5 + time.Sleep(201 * time.Millisecond) + } + } + + count, err := caches.Prune() + c.Assert(err, qt.IsNil) + c.Assert(count, qt.Equals, 5, msg) + + for i := range 10 { + id := fmt.Sprintf("i%d", i) + v := cache.GetString(id) + if i < 5 { + c.Assert(v, qt.Equals, "") + } else { + c.Assert(v, qt.Equals, "abc") + } + } + + caches, err = filecache.NewCaches(p) + c.Assert(err, qt.IsNil) + cache = caches[name] + // Touch one and then prune. + cache.GetOrCreateBytes("i5", func() ([]byte, error) { + return []byte("abc"), nil + }) + + count, err = caches.Prune() + c.Assert(err, qt.IsNil) + c.Assert(count, qt.Equals, 4) + + // Now only the i5 should be left. + for i := range 10 { + id := fmt.Sprintf("i%d", i) + v := cache.GetString(id) + if i != 5 { + c.Assert(v, qt.Equals, "") + } else { + c.Assert(v, qt.Equals, "abc") + } + } + + } +} diff --git a/cache/filecache/filecache_test.go b/cache/filecache/filecache_test.go new file mode 100644 index 000000000..a30aaa50b --- /dev/null +++ b/cache/filecache/filecache_test.go @@ -0,0 +1,276 @@ +// 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 ( + "errors" + "fmt" + "io" + "strings" + "sync" + "testing" + "time" + + "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" + "github.com/spf13/afero" + + qt "github.com/frankban/quicktest" +) + +func TestFileCache(t *testing.T) { + t.Parallel() + c := qt.New(t) + + tempWorkingDir := t.TempDir() + tempCacheDir := t.TempDir() + + osfs := afero.NewOsFs() + + for _, test := range []struct { + cacheDir string + workingDir string + }{ + // Run with same dirs twice to make sure that works. + {tempCacheDir, tempWorkingDir}, + {tempCacheDir, tempWorkingDir}, + } { + + configStr := ` +workingDir = "WORKING_DIR" +resourceDir = "resources" +cacheDir = "CACHEDIR" +contentDir = "content" +dataDir = "data" +i18nDir = "i18n" +layoutDir = "layouts" +assetDir = "assets" +archeTypedir = "archetypes" + +[caches] +[caches.getJSON] +maxAge = "10h" +dir = ":cacheDir/c" + +` + + winPathSep := "\\\\" + + replacer := strings.NewReplacer("CACHEDIR", test.cacheDir, "WORKING_DIR", test.workingDir) + + configStr = replacer.Replace(configStr) + configStr = strings.Replace(configStr, "\\", winPathSep, -1) + + p := newPathsSpec(t, osfs, configStr) + + caches, err := filecache.NewCaches(p) + c.Assert(err, qt.IsNil) + + cache := caches.Get("GetJSON") + c.Assert(cache, qt.Not(qt.IsNil)) + + cache = caches.Get("Images") + c.Assert(cache, qt.Not(qt.IsNil)) + + rf := func(s string) func() (io.ReadCloser, error) { + return func() (io.ReadCloser, error) { + return struct { + io.ReadSeeker + io.Closer + }{ + strings.NewReader(s), + io.NopCloser(nil), + }, nil + } + } + + bf := func() ([]byte, error) { + return []byte("bcd"), nil + } + + 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, _ := io.ReadAll(r) + r.Close() + c.Assert(string(b), qt.Equals, "abc") + + info, b, err = ca.GetOrCreateBytes("b", bf) + c.Assert(err, qt.IsNil) + c.Assert(r, qt.Not(qt.IsNil)) + c.Assert(info.Name, qt.Equals, "b") + c.Assert(string(b), qt.Equals, "bcd") + + _, b, err = ca.GetOrCreateBytes("a", bf) + c.Assert(err, qt.IsNil) + c.Assert(string(b), qt.Equals, "abc") + + _, r, err = ca.GetOrCreate("a", rf("bcd")) + c.Assert(err, qt.IsNil) + b, _ = io.ReadAll(r) + r.Close() + c.Assert(string(b), qt.Equals, "abc") + } + } + + c.Assert(caches.Get("getJSON"), qt.Not(qt.IsNil)) + + info, w, err := caches.ImageCache().WriteCloser("mykey") + c.Assert(err, qt.IsNil) + 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!") + + 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, _ := io.ReadAll(r) + r.Close() + c.Assert(string(b), qt.Equals, "Hugo is great!") + + info, b, err = caches.ImageCache().GetBytes("mykey") + c.Assert(err, qt.IsNil) + c.Assert(info.Name, qt.Equals, "mykey") + c.Assert(string(b), qt.Equals, "Hugo is great!") + + } +} + +func TestFileCacheConcurrent(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 = "1s" +dir = "/cache/c" + +` + + p := newPathsSpec(t, afero.NewMemMapFs(), configStr) + + caches, err := filecache.NewCaches(p) + c.Assert(err, qt.IsNil) + + const cacheName = "getjson" + + filenameData := func(i int) (string, string) { + data := fmt.Sprintf("data: %d", i) + filename := fmt.Sprintf("file%d", i) + return filename, data + } + + var wg sync.WaitGroup + + for i := range 50 { + wg.Add(1) + go func(i int) { + defer wg.Done() + for range 20 { + ca := caches.Get(cacheName) + c.Assert(ca, qt.Not(qt.IsNil)) + filename, data := filenameData(i) + _, r, err := ca.GetOrCreate(filename, func() (io.ReadCloser, error) { + return hugio.ToReadCloser(strings.NewReader(data)), nil + }) + c.Assert(err, qt.IsNil) + b, _ := io.ReadAll(r) + r.Close() + c.Assert(string(b), qt.Equals, data) + // Trigger some expiration. + time.Sleep(50 * time.Millisecond) + } + }(i) + + } + wg.Wait() +} + +func TestFileCacheReadOrCreateErrorInRead(t *testing.T) { + t.Parallel() + c := qt.New(t) + + var result string + + 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 filecache.ErrFatal + } + return errors.New("fail") + } + + b, _ := io.ReadAll(r) + result = string(b) + + return nil + } + } + + 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)) + return err + } + } + + cache := filecache.NewCache(afero.NewMemMapFs(), 100*time.Hour, "") + + const id = "a32" + + _, err := cache.ReadOrCreate(id, rf(0), bf("v1")) + c.Assert(err, qt.IsNil) + c.Assert(result, qt.Equals, "v1") + _, err = cache.ReadOrCreate(id, rf(0), bf("v2")) + c.Assert(err, qt.IsNil) + c.Assert(result, qt.Equals, "v1") + _, err = cache.ReadOrCreate(id, rf(1), bf("v3")) + c.Assert(err, qt.IsNil) + c.Assert(result, qt.Equals, "v3") + _, err = cache.ReadOrCreate(id, rf(2), bf("v3")) + 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) + 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/partitioned_lazy_cache.go b/cache/partitioned_lazy_cache.go deleted file mode 100644 index 9baf0377d..000000000 --- a/cache/partitioned_lazy_cache.go +++ /dev/null @@ -1,80 +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) -} - -type lazyPartition struct { - initSync sync.Once - cache map[string]interface{} - load func() (map[string]interface{}, error) -} - -func (l *lazyPartition) init() error { - var err error - l.initSync.Do(func() { - var c map[string]interface{} - c, err = l.load() - l.cache = c - }) - - return err -} - -// PartitionedLazyCache is a lazily loaded cache paritioned by a supplied string key. -type PartitionedLazyCache struct { - partitions map[string]*lazyPartition -} - -// NewPartitionedLazyCache creates a new NewPartitionedLazyCache with the supplied -// partitions. -func NewPartitionedLazyCache(partitions ...Partition) *PartitionedLazyCache { - lazyPartitions := make(map[string]*lazyPartition, len(partitions)) - for _, partition := range partitions { - lazyPartitions[partition.Key] = &lazyPartition{load: 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 - } - - if err := p.init(); err != nil { - return nil, err - } - - if v, found := p.cache[key]; 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 ba8b6a454..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" - - "github.com/stretchr/testify/require" -) - -func TestNewPartitionedLazyCache(t *testing.T) { - t.Parallel() - - assert := require.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") - assert.NoError(err) - assert.Equal("p1v1", v) - - v, err = cache.Get("p1", "p2_1") - assert.NoError(err) - assert.Nil(v) - - v, err = cache.Get("p1", "p1_nil") - assert.NoError(err) - assert.Nil(v) - - v, err = cache.Get("p2", "p2_3") - assert.NoError(err) - assert.Equal("p2v3", v) - - v, err = cache.Get("doesnotexist", "p1_1") - assert.NoError(err) - assert.Nil(v) - - v, err = cache.Get("p1", "doesnotexist") - assert.NoError(err) - assert.Nil(v) - - errorP := Partition{ - Key: "p3", - Load: func() (map[string]interface{}, error) { - return nil, errors.New("Failed") - }, - } - - cache = NewPartitionedLazyCache(errorP) - - v, err = cache.Get("p1", "doesnotexist") - assert.NoError(err) - assert.Nil(v) - - _, err = cache.Get("p3", "doesnotexist") - assert.Error(err) - -} - -func TestConcurrentPartitionedLazyCache(t *testing.T) { - t.Parallel() - - assert := require.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") - assert.NoError(err) - assert.Equal("p1v1", v) - } - }() - } - 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 new file mode 100644 index 000000000..08ac97b00 --- /dev/null +++ b/codegen/methods.go @@ -0,0 +1,540 @@ +// Copyright 2019 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 codegen contains helpers for code generation. +package codegen + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path" + "path/filepath" + "reflect" + "regexp" + "slices" + "sort" + "strings" + "sync" +) + +// Make room for insertions +const weightWidth = 1000 + +// NewInspector creates a new Inspector given a source root. +func NewInspector(root string) *Inspector { + return &Inspector{ProjectRootDir: root} +} + +// Inspector provides methods to help code generation. It uses a combination +// of reflection and source code AST to do the heavy lifting. +type Inspector struct { + ProjectRootDir string + + init sync.Once + + // Determines method order. Go's reflect sorts lexicographically, so + // we must parse the source to preserve this order. + methodWeight map[string]map[string]int +} + +// MethodsFromTypes create a method set from the include slice, excluding any +// method in exclude. +func (c *Inspector) MethodsFromTypes(include []reflect.Type, exclude []reflect.Type) Methods { + c.parseSource() + + var methods Methods + + excludes := make(map[string]bool) + + if len(exclude) > 0 { + for _, m := range c.MethodsFromTypes(exclude, nil) { + excludes[m.Name] = true + } + } + + // There may be overlapping interfaces in types. Do a simple check for now. + seen := make(map[string]bool) + + nameAndPackage := func(t reflect.Type) (string, string) { + var name, pkg string + + isPointer := t.Kind() == reflect.Ptr + + if isPointer { + t = t.Elem() + } + + pkgPrefix := "" + if pkgPath := t.PkgPath(); pkgPath != "" { + pkgPath = strings.TrimSuffix(pkgPath, "/") + _, shortPath := path.Split(pkgPath) + pkgPrefix = shortPath + "." + pkg = pkgPath + } + + name = t.Name() + if name == "" { + // interface{} + name = t.String() + } + + if isPointer { + pkgPrefix = "*" + pkgPrefix + } + + name = pkgPrefix + name + + return name, pkg + } + + for _, t := range include { + for i := range t.NumMethod() { + + m := t.Method(i) + if excludes[m.Name] || seen[m.Name] { + continue + } + + seen[m.Name] = true + + if m.PkgPath != "" { + // Not exported + continue + } + + numIn := m.Type.NumIn() + + ownerName, _ := nameAndPackage(t) + + method := Method{Owner: t, OwnerName: ownerName, Name: m.Name} + + for i := range numIn { + in := m.Type.In(i) + + name, pkg := nameAndPackage(in) + + if pkg != "" { + method.Imports = append(method.Imports, pkg) + } + + method.In = append(method.In, name) + } + + numOut := m.Type.NumOut() + + if numOut > 0 { + for i := range numOut { + out := m.Type.Out(i) + name, pkg := nameAndPackage(out) + + if pkg != "" { + method.Imports = append(method.Imports, pkg) + } + + method.Out = append(method.Out, name) + } + } + + methods = append(methods, method) + } + } + + sort.SliceStable(methods, func(i, j int) bool { + mi, mj := methods[i], methods[j] + + wi := c.methodWeight[mi.OwnerName][mi.Name] + wj := c.methodWeight[mj.OwnerName][mj.Name] + + if wi == wj { + return mi.Name < mj.Name + } + + 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") + } + + c.methodWeight = make(map[string]map[string]int) + dirExcludes := regexp.MustCompile("docs|examples") + fileExcludes := regexp.MustCompile("autogen") + var filenames []string + + filepath.Walk(c.ProjectRootDir, func(path string, info os.FileInfo, err error) error { + if info.IsDir() { + if dirExcludes.MatchString(info.Name()) { + return filepath.SkipDir + } + } + + if !strings.HasSuffix(path, ".go") || fileExcludes.MatchString(path) { + return nil + } + + filenames = append(filenames, path) + + return nil + }) + + for _, filename := range filenames { + + pkg := c.packageFromPath(filename) + + fset := token.NewFileSet() + node, err := parser.ParseFile(fset, filename, nil, parser.ParseComments) + if err != nil { + panic(err) + } + + ast.Inspect(node, func(n ast.Node) bool { + switch t := n.(type) { + case *ast.TypeSpec: + if t.Name.IsExported() { + switch it := t.Type.(type) { + case *ast.InterfaceType: + iface := pkg + "." + t.Name.Name + methodNames := collectMethodsRecursive(pkg, it.Methods.List) + weights := make(map[string]int) + weight := weightWidth + for _, name := range methodNames { + weights[name] = weight + weight += weightWidth + } + c.methodWeight[iface] = weights + } + } + } + return true + }) + + } + + // Complement + for _, v1 := range c.methodWeight { + for k2, w := range v1 { + if v, found := c.methodWeight[k2]; found { + for k3, v3 := range v { + v1[k3] = (v3 / weightWidth) + w + } + } + } + } + }) +} + +func (c *Inspector) packageFromPath(p string) string { + p = filepath.ToSlash(p) + base := path.Base(p) + if !strings.Contains(base, ".") { + return base + } + return path.Base(strings.TrimSuffix(p, base)) +} + +// Method holds enough information about it to recreate it. +type Method struct { + // The interface we extracted this method from. + Owner reflect.Type + + // String version of the above, on the form PACKAGE.NAME, e.g. + // page.Page + OwnerName string + + // Method name. + Name string + + // Imports needed to satisfy the method signature. + Imports []string + + // Argument types, including any package prefix, e.g. string, int, interface{}, + // net.Url + In []string + + // Return types. + Out []string +} + +// Declaration creates a method declaration (without any body) for the given receiver. +func (m Method) Declaration(receiver string) string { + return fmt.Sprintf("func (%s %s) %s%s %s", receiverShort(receiver), receiver, m.Name, m.inStr(), m.outStr()) +} + +// DeclarationNamed creates a method declaration (without any body) for the given receiver +// with named return values. +func (m Method) DeclarationNamed(receiver string) string { + return fmt.Sprintf("func (%s %s) %s%s %s", receiverShort(receiver), receiver, m.Name, m.inStr(), m.outStrNamed()) +} + +// Delegate creates a delegate call string. +func (m Method) Delegate(receiver, delegate string) string { + ret := "" + if len(m.Out) > 0 { + ret = "return " + } + return fmt.Sprintf("%s%s.%s.%s%s", ret, receiverShort(receiver), delegate, m.Name, m.inOutStr()) +} + +func (m Method) String() string { + return m.Name + m.inStr() + " " + m.outStr() + "\n" +} + +func (m Method) inOutStr() string { + if len(m.In) == 0 { + return "()" + } + + args := make([]string, len(m.In)) + for i := range args { + args[i] = fmt.Sprintf("arg%d", i) + } + return "(" + strings.Join(args, ", ") + ")" +} + +func (m Method) inStr() string { + if len(m.In) == 0 { + return "()" + } + + args := make([]string, len(m.In)) + for i := range args { + args[i] = fmt.Sprintf("arg%d %s", i, m.In[i]) + } + return "(" + strings.Join(args, ", ") + ")" +} + +func (m Method) outStr() string { + if len(m.Out) == 0 { + return "" + } + if len(m.Out) == 1 { + return m.Out[0] + } + + return "(" + strings.Join(m.Out, ", ") + ")" +} + +func (m Method) outStrNamed() string { + if len(m.Out) == 0 { + return "" + } + + outs := make([]string, len(m.Out)) + for i := range outs { + outs[i] = fmt.Sprintf("o%d %s", i, m.Out[i]) + } + + return "(" + strings.Join(outs, ", ") + ")" +} + +// Methods represents a list of methods for one or more interfaces. +// The order matches the defined order in their source file(s). +type Methods []Method + +// Imports returns a sorted list of package imports needed to satisfy the +// signatures of all methods. +func (m Methods) Imports() []string { + var pkgImports []string + for _, method := range m { + pkgImports = append(pkgImports, method.Imports...) + } + if len(pkgImports) > 0 { + pkgImports = uniqueNonEmptyStrings(pkgImports) + sort.Strings(pkgImports) + } + return pkgImports +} + +// ToMarshalJSON creates a MarshalJSON method for these methods. Any method name +// 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 + + r := receiverShort(receiver) + what := firstToUpper(trimAsterisk(receiver)) + pgkName := path.Base(pkgPath) + + fmt.Fprintf(&sb, "func Marshal%sToJSON(%s %s) ([]byte, error) {\n", what, r, receiver) + + var methods Methods + excludeRes := make([]*regexp.Regexp, len(excludes)) + + for i, exclude := range excludes { + excludeRes[i] = regexp.MustCompile(exclude) + } + + for _, method := range m { + // Exclude methods with arguments and incompatible return values + if len(method.In) > 0 || len(method.Out) == 0 || len(method.Out) > 2 { + continue + } + + if len(method.Out) == 2 { + if method.Out[1] != "error" { + continue + } + } + + for _, re := range excludeRes { + if re.MatchString(method.Name) { + continue + } + } + + methods = append(methods, method) + } + + for _, method := range methods { + varn := varName(method.Name) + if len(method.Out) == 1 { + fmt.Fprintf(&sb, "\t%s := %s.%s()\n", varn, r, method.Name) + } else { + fmt.Fprintf(&sb, "\t%s, err := %s.%s()\n", varn, r, method.Name) + fmt.Fprint(&sb, "\tif err != nil {\n\t\treturn nil, err\n\t}\n") + } + } + + fmt.Fprint(&sb, "\n\ts := struct {\n") + + for _, method := range methods { + fmt.Fprintf(&sb, "\t\t%s %s\n", method.Name, typeName(method.Out[0], pgkName)) + } + + fmt.Fprint(&sb, "\n\t}{\n") + + for _, method := range methods { + varn := varName(method.Name) + fmt.Fprintf(&sb, "\t\t%s: %s,\n", method.Name, varn) + } + + fmt.Fprint(&sb, "\n\t}\n\n") + fmt.Fprint(&sb, "\treturn json.Marshal(&s)\n}") + + pkgImports := append(methods.Imports(), "encoding/json") + + if pkgPath != "" { + // Exclude self + for i, pkgImp := range pkgImports { + if pkgImp == pkgPath { + pkgImports = slices.Delete(pkgImports, i, i+1) + } + } + } + + return sb.String(), pkgImports +} + +func collectMethodsRecursive(pkg string, f []*ast.Field) []string { + var methodNames []string + for _, m := range f { + if m.Names != nil { + methodNames = append(methodNames, m.Names[0].Name) + continue + } + + if ident, ok := m.Type.(*ast.Ident); ok && ident.Obj != nil { + 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. + name := packageName(m.Type) + if !strings.Contains(name, ".") { + // Assume current package + name = pkg + "." + name + } + methodNames = append(methodNames, name) + } + } + + return methodNames +} + +func firstToLower(name string) string { + return strings.ToLower(name[:1]) + name[1:] +} + +func firstToUpper(name string) string { + return strings.ToUpper(name[:1]) + name[1:] +} + +func packageName(e ast.Expr) string { + switch tp := e.(type) { + case *ast.Ident: + return tp.Name + case *ast.SelectorExpr: + return fmt.Sprintf("%s.%s", packageName(tp.X), packageName(tp.Sel)) + } + return "" +} + +func receiverShort(receiver string) string { + return strings.ToLower(trimAsterisk(receiver))[:1] +} + +func trimAsterisk(name string) string { + return strings.TrimPrefix(name, "*") +} + +func typeName(name, pkg string) string { + return strings.TrimPrefix(name, pkg+".") +} + +func uniqueNonEmptyStrings(s []string) []string { + var unique []string + set := map[string]any{} + for _, val := range s { + if val == "" { + continue + } + if _, ok := set[val]; !ok { + unique = append(unique, val) + set[val] = val + } + } + return unique +} + +func varName(name string) string { + name = firstToLower(name) + + // Adjust some reserved keywords, see https://golang.org/ref/spec#Keywords + switch name { + case "type": + name = "typ" + case "package": + name = "pkg" + // Not reserved, but syntax highlighters has it as a keyword. + case "len": + name = "length" + } + + return name +} diff --git a/codegen/methods2_test.go b/codegen/methods2_test.go new file mode 100644 index 000000000..bd36b5e80 --- /dev/null +++ b/codegen/methods2_test.go @@ -0,0 +1,20 @@ +// 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 codegen + +type IEmbed interface { + MethodEmbed3(s string) string + MethodEmbed1() string + MethodEmbed2() +} diff --git a/codegen/methods_test.go b/codegen/methods_test.go new file mode 100644 index 000000000..0aff43d0e --- /dev/null +++ b/codegen/methods_test.go @@ -0,0 +1,96 @@ +// 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 codegen + +import ( + "fmt" + "net" + "os" + "reflect" + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/common/herrors" +) + +func TestMethods(t *testing.T) { + var ( + zeroIE = reflect.TypeOf((*IEmbed)(nil)).Elem() + zeroIEOnly = reflect.TypeOf((*IEOnly)(nil)).Elem() + zeroI = reflect.TypeOf((*I)(nil)).Elem() + ) + + dir, _ := os.Getwd() + insp := NewInspector(dir) + + t.Run("MethodsFromTypes", func(t *testing.T) { + c := qt.New(t) + + methods := insp.MethodsFromTypes([]reflect.Type{zeroI}, nil) + + methodsStr := fmt.Sprint(methods) + + c.Assert(methodsStr, qt.Contains, "Method1(arg0 herrors.ErrorContext)") + c.Assert(methodsStr, qt.Contains, "Method7() interface {}") + c.Assert(methodsStr, qt.Contains, "Method0() string\n Method4() string") + c.Assert(methodsStr, qt.Contains, "MethodEmbed3(arg0 string) string\n MethodEmbed1() string") + + c.Assert(methods.Imports(), qt.Contains, "github.com/gohugoio/hugo/common/herrors") + }) + + t.Run("EmbedOnly", func(t *testing.T) { + c := qt.New(t) + + methods := insp.MethodsFromTypes([]reflect.Type{zeroIEOnly}, nil) + + methodsStr := fmt.Sprint(methods) + + c.Assert(methodsStr, qt.Contains, "MethodEmbed3(arg0 string) string") + }) + + t.Run("ToMarshalJSON", func(t *testing.T) { + c := qt.New(t) + + m, pkg := insp.MethodsFromTypes( + []reflect.Type{zeroI}, + []reflect.Type{zeroIE}).ToMarshalJSON("*page", "page") + + c.Assert(m, qt.Contains, "method6 := p.Method6()") + c.Assert(m, qt.Contains, "Method0: method0,") + c.Assert(m, qt.Contains, "return json.Marshal(&s)") + + c.Assert(pkg, qt.Contains, "github.com/gohugoio/hugo/common/herrors") + c.Assert(pkg, qt.Contains, "encoding/json") + + fmt.Println(pkg) + }) +} + +type I interface { + IEmbed + Method0() string + Method4() string + Method1(myerr herrors.ErrorContext) + Method3(myint int, mystring string) + Method5() (string, error) + Method6() *net.IP + Method7() any + Method8() herrors.ErrorContext + method2() + method9() os.FileInfo +} + +type IEOnly interface { + IEmbed +} diff --git a/commands/benchmark.go b/commands/benchmark.go deleted file mode 100644 index a66aee0b5..000000000 --- a/commands/benchmark.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 commands - -import ( - "os" - "runtime" - "runtime/pprof" - "time" - - "github.com/spf13/cobra" - jww "github.com/spf13/jwalterweatherman" -) - -var ( - benchmarkTimes int - cpuProfileFile string - memProfileFile string -) - -var benchmarkCmd = &cobra.Command{ - Use: "benchmark", - Short: "Benchmark Hugo by building a site a number of times.", - Long: `Hugo can build a site many times over and analyze the running process -creating a benchmark.`, -} - -func init() { - initHugoBuilderFlags(benchmarkCmd) - initBenchmarkBuildingFlags(benchmarkCmd) - - benchmarkCmd.Flags().StringVar(&cpuProfileFile, "cpuprofile", "", "path/filename for the CPU profile file") - benchmarkCmd.Flags().StringVar(&memProfileFile, "memprofile", "", "path/filename for the memory profile file") - benchmarkCmd.Flags().IntVarP(&benchmarkTimes, "count", "n", 13, "number of times to build the site") - - benchmarkCmd.RunE = benchmark -} - -func benchmark(cmd *cobra.Command, args []string) error { - cfgInit := func(c *commandeer) error { - c.Set("renderToMemory", renderToMemory) - return nil - } - c, err := InitializeConfig(false, cfgInit, benchmarkCmd) - if err != nil { - return err - } - - var memProf *os.File - if memProfileFile != "" { - memProf, err = os.Create(memProfileFile) - if err != nil { - return err - } - } - - var cpuProf *os.File - if cpuProfileFile != "" { - cpuProf, err = os.Create(cpuProfileFile) - if err != nil { - return err - } - } - - var memStats runtime.MemStats - runtime.ReadMemStats(&memStats) - memAllocated := memStats.TotalAlloc - mallocs := memStats.Mallocs - if cpuProf != nil { - pprof.StartCPUProfile(cpuProf) - } - - t := time.Now() - for i := 0; i < benchmarkTimes; i++ { - if err = c.resetAndBuildSites(); err != nil { - return err - } - } - totalTime := time.Since(t) - - if memProf != nil { - pprof.WriteHeapProfile(memProf) - memProf.Close() - } - if cpuProf != nil { - pprof.StopCPUProfile() - cpuProf.Close() - } - - runtime.ReadMemStats(&memStats) - totalMemAllocated := memStats.TotalAlloc - memAllocated - totalMallocs := memStats.Mallocs - mallocs - - jww.FEEDBACK.Println() - jww.FEEDBACK.Printf("Average time per operation: %vms\n", int(1000*totalTime.Seconds()/float64(benchmarkTimes))) - jww.FEEDBACK.Printf("Average memory allocated per operation: %vkB\n", totalMemAllocated/uint64(benchmarkTimes)/1024) - jww.FEEDBACK.Printf("Average allocations per operation: %v\n", totalMallocs/uint64(benchmarkTimes)) - - return nil -} diff --git a/commands/check.go b/commands/check.go deleted file mode 100644 index e5dbc1ffa..000000000 --- a/commands/check.go +++ /dev/null @@ -1,23 +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" -) - -var checkCmd = &cobra.Command{ - Use: "check", - Short: "Contains some verification checks", -} diff --git a/commands/commandeer.go b/commands/commandeer.go index f7ac93efa..bf9655637 100644 --- a/commands/commandeer.go +++ b/commands/commandeer.go @@ -1,4 +1,4 @@ -// Copyright 2017 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,247 +14,666 @@ package commands import ( + "context" + "errors" + "fmt" + "io" + "log" "os" + "os/signal" "path/filepath" + "runtime" + "strings" "sync" + "sync/atomic" + "syscall" "time" - "github.com/spf13/cobra" + "go.uber.org/automaxprocs/maxprocs" - "github.com/gohugoio/hugo/utils" + "github.com/bep/clocks" + "github.com/bep/lazycache" + "github.com/bep/logg" + "github.com/bep/overlayfs" + "github.com/bep/simplecobra" - "github.com/spf13/afero" - - "github.com/gohugoio/hugo/hugolib" - - "github.com/bep/debounce" + "github.com/gohugoio/hugo/common/hstrings" + "github.com/gohugoio/hugo/common/htime" + "github.com/gohugoio/hugo/common/loggers" + "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" - src "github.com/gohugoio/hugo/source" + "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 commandeer struct { - *deps.DepsCfg +var errHelp = errors.New("help requested") - subCmdVs []*cobra.Command - - pathSpec *helpers.PathSpec - visitedURLs *types.EvictingStringQueue - - staticDirsConfig []*src.Dirs - - // We watch these for changes. - configFiles []string - - doWithCommandeer func(c *commandeer) error - - // We can do this only once. - fsCreate sync.Once - - // Used in cases where we get flooded with events in server mode. - debounce func(f func()) - - serverPorts []int - languages helpers.Languages - - configured bool -} - -func (c *commandeer) Set(key string, value interface{}) { - if c.configured { - panic("commandeer cannot be changed") - } - c.Cfg.Set(key, value) -} - -// PathSpec lazily creates a new PathSpec, as all the paths must -// be configured before it is created. -func (c *commandeer) PathSpec() *helpers.PathSpec { - c.configured = true - return c.pathSpec -} - -func (c *commandeer) initFs(fs *hugofs.Fs) error { - c.DepsCfg.Fs = fs - ps, err := helpers.NewPathSpec(fs, c.Cfg) +// 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 } - c.pathSpec = ps - - dirsConfig, err := c.createStaticDirsConfig() - if err != nil { - return err + args = mapLegacyArgs(args) + cd, err := x.Execute(context.Background(), args) + if cd != nil { + if closer, ok := cd.Root.Command.(types.Closer); ok { + closer.Close() + } } - c.staticDirsConfig = dirsConfig + if err != nil { + if err == errHelp { + cd.CobraCommand.Help() + fmt.Println() + return nil + } + if simplecobra.IsCommandError(err) { + // Print the help, but also return the error to fail the command. + cd.CobraCommand.Help() + fmt.Println() + } + } + return err +} + +type commonConfig struct { + mu *sync.Mutex + configs *allconfig.Configs + cfg config.Provider + fs *hugofs.Fs +} + +type configKey struct { + counter int32 + ignoreModulesDoesNotExists bool +} + +// This is the root command. +type rootCommand struct { + Printf func(format string, v ...any) + Println func(a ...any) + StdOut io.Writer + StdErr io.Writer + + logger loggers.Logger + + // The main cache busting key for the caches below. + configVersionID atomic.Int32 + + // 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] + + // 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 } -func newCommandeer(running bool, 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) +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 } - c := &commandeer{ - doWithCommandeer: doWithCommandeer, - subCmdVs: append([]*cobra.Command{hugoCmdV}, subCmdVs...), - visitedURLs: types.NewEvictingStringQueue(10), - debounce: rebuildDebouncer, - } - - return c, c.loadConfig(running) + return h, nil } -func (c *commandeer) loadConfig(running bool) error { +func (r *rootCommand) Commands() []simplecobra.Commander { + return r.commands +} - if c.DepsCfg == nil { - c.DepsCfg = &deps.DepsCfg{} - } - - cfg := c.DepsCfg - c.configured = false - cfg.Running = running - - var dir string - if source != "" { - dir, _ = filepath.Abs(source) - } else { - dir, _ = os.Getwd() - } - - var sourceFs afero.Fs = hugofs.Os - if c.DepsCfg.Fs != nil { - sourceFs = c.DepsCfg.Fs.Source - } - - config, configFiles, err := hugolib.LoadConfig(hugolib.ConfigSourceDescriptor{Fs: sourceFs, Path: source, WorkingDir: dir, Filename: cfgFile}) - if err != nil { - return err - } - - c.Cfg = config - c.configFiles = configFiles - - for _, cmdV := range c.subCmdVs { - c.initializeFlags(cmdV) - } - - if l, ok := c.Cfg.Get("languagesSorted").(helpers.Languages); ok { - c.languages = l - } - - if baseURL != "" { - config.Set("baseURL", baseURL) - } - - if c.doWithCommandeer != nil { - err = c.doWithCommandeer(c) - } - - if err != nil { - return err - } - - if len(disableKinds) > 0 { - c.Set("disableKinds", disableKinds) - } - - logger, err := createLogger(cfg.Cfg) - if err != nil { - return err - } - - cfg.Logger = logger - - config.Set("logI18nWarnings", logI18nWarnings) - - if theme != "" { - config.Set("theme", theme) - } - - if themesDir != "" { - config.Set("themesDir", themesDir) - } - - if destination != "" { - config.Set("publishDir", destination) - } - - config.Set("workingDir", dir) - - if contentDir != "" { - config.Set("contentDir", contentDir) - } - - if layoutDir != "" { - config.Set("layoutDir", layoutDir) - } - - if cacheDir != "" { - config.Set("cacheDir", cacheDir) - } - - createMemFs := config.GetBool("renderToMemory") - - if createMemFs { - // Rendering to memoryFS, publish to Root regardless of publishDir. - config.Set("publishDir", "/") - } - - c.fsCreate.Do(func() { - fs := hugofs.NewFrom(sourceFs, config) - - // Hugo writes the output to memory instead of the disk. - if createMemFs { - fs.Destination = new(afero.MemMapFs) +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 { + return nil, err } - err = c.initFs(fs) + 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 = config.GetString("cacheDir") - if cacheDir != "" { - if helpers.FilePathSeparator != cacheDir[len(cacheDir)-1:] { - cacheDir = cacheDir + helpers.FilePathSeparator - } - isDir, err := helpers.DirExists(cacheDir, sourceFs) - utils.CheckErr(cfg.Logger, err) - if !isDir { - mkdir(cacheDir) - } - config.Set("cacheDir", cacheDir) - } else { - config.Set("cacheDir", helpers.GetTempDir("hugo_cache", sourceFs)) + if !r.buildWatch { + // Done. + return nil } - cfg.Logger.INFO.Println("Using config file:", config.ConfigFileUsed()) - - themeDir := c.PathSpec().GetThemeDir() - if themeDir != "" { - if _, err := sourceFs.Stat(themeDir); os.IsNotExist(err) { - return newSystemError("Unable to find theme Directory:", themeDir) - } + watchDirs, err := b.getDirList() + if err != nil { + return err } - themeVersionMismatch, minVersion := c.isThemeVsHugoVersionMismatch(sourceFs) + watchGroups := helpers.ExtractAndGroupRootPaths(watchDirs) - if themeVersionMismatch { - cfg.Logger.ERROR.Printf("Current theme does not support Hugo version %s. Minimum version required is %s\n", - helpers.CurrentHugoVersion.ReleaseVersion(), minVersion) + 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 new file mode 100644 index 000000000..10ab106e2 --- /dev/null +++ b/commands/commands.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 commands + +import ( + "context" + + "github.com/bep/simplecobra" +) + +// 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) +} + +func newHugoBuildCmd() simplecobra.Commander { + return &hugoBuildCommand{} +} + +// hugoBuildCommand just delegates to the rootCommand. +type hugoBuildCommand struct { + rootCmd *rootCommand +} + +func (c *hugoBuildCommand) Commands() []simplecobra.Commander { + return nil +} + +func (c *hugoBuildCommand) Name() string { + return "build" +} + +func (c *hugoBuildCommand) Init(cd *simplecobra.Commandeer) error { + c.rootCmd = cd.Root.Command.(*rootCommand) + return c.rootCmd.initRootCommand("build", cd) +} + +func (c *hugoBuildCommand) PreRun(cd, runner *simplecobra.Commandeer) error { + return c.rootCmd.PreRun(cd, runner) +} + +func (c *hugoBuildCommand) Run(ctx context.Context, cd *simplecobra.Commandeer, args []string) error { + return c.rootCmd.Run(ctx, cd, args) +} diff --git a/commands/config.go b/commands/config.go new file mode 100644 index 000000000..7d166b9b8 --- /dev/null +++ b/commands/config.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 commands + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "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/spf13/cobra" +) + +// newConfigCommand creates a new config command and its subcommands. +func newConfigCommand() *configCommand { + return &configCommand{ + commands: []simplecobra.Commander{ + &configMountsCommand{}, + }, + } +} + +type configCommand struct { + r *rootCommand + + format string + lang string + printZero bool + + commands []simplecobra.Commander +} + +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] + } + + var buf bytes.Buffer + dec := json.NewEncoder(&buf) + dec.SetIndent("", " ") + dec.SetEscapeHTML(false) + + 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 + } + 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 +} + +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 +} + +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"` +} + +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, 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"` + 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(), + 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 f63f8522f..ebf81cfb3 100644 --- a/commands/convert.go +++ b/commands/convert.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,170 +14,216 @@ package commands import ( + "bytes" + "context" "fmt" + "path/filepath" + "strings" "time" - src "github.com/gohugoio/hugo/source" - + "github.com/bep/simplecobra" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/helpers" + "github.com/gohugoio/hugo/hugofs" "github.com/gohugoio/hugo/hugolib" - - "path/filepath" - "github.com/gohugoio/hugo/parser" - "github.com/spf13/cast" + "github.com/gohugoio/hugo/parser/metadecoders" + "github.com/gohugoio/hugo/parser/pageparser" + "github.com/gohugoio/hugo/resources/page" "github.com/spf13/cobra" ) -var outputDir string -var unsafe bool - -var convertCmd = &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, -} - -var toJSONCmd = &cobra.Command{ - Use: "toJSON", - Short: "Convert front matter to JSON", - Long: `toJSON converts all front matter in the content directory +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.`, - RunE: func(cmd *cobra.Command, args []string) error { - return convertContents(rune([]byte(parser.JSONLead)[0])) - }, -} - -var toTOMLCmd = &cobra.Command{ - Use: "toTOML", - Short: "Convert front matter to TOML", - Long: `toTOML converts all front matter in the content directory + 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.`, - RunE: func(cmd *cobra.Command, args []string) error { - return convertContents(rune([]byte(parser.TOMLLead)[0])) - }, -} - -var toYAMLCmd = &cobra.Command{ - Use: "toYAML", - Short: "Convert front matter to YAML", - Long: `toYAML converts all front matter in the content directory + 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.`, - RunE: func(cmd *cobra.Command, args []string) error { - return convertContents(rune([]byte(parser.YAMLLead)[0])) - }, + 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 } -func init() { - convertCmd.AddCommand(toJSONCmd) - convertCmd.AddCommand(toTOMLCmd) - convertCmd.AddCommand(toYAMLCmd) - convertCmd.PersistentFlags().StringVarP(&outputDir, "output", "o", "", "filesystem path to write files to") - convertCmd.PersistentFlags().StringVarP(&source, "source", "s", "", "filesystem path to read files relative from") - convertCmd.PersistentFlags().BoolVar(&unsafe, "unsafe", false, "enable less safe operations, please backup first") - convertCmd.PersistentFlags().SetAnnotation("source", cobra.BashCompSubdirsInDir, []string{}) +type convertCommand struct { + // Flags. + outputDir string + unsafe bool + + // Deps. + r *rootCommand + h *hugolib.HugoSites + + // Commands. + commands []simplecobra.Commander } -func convertContents(mark rune) error { - if outputDir == "" && !unsafe { - return newUserError("Unsafe operation not allowed, use --unsafe or set a different output path") - } +func (c *convertCommand) Commands() []simplecobra.Commander { + return c.commands +} - c, err := InitializeConfig(false, nil) - if err != nil { - return err - } +func (c *convertCommand) Name() string { + return "convert" +} - 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 := convertAndSavePage(p, site, mark); err != nil { - return err - } - } +func (c *convertCommand) Run(ctx context.Context, cd *simplecobra.Commandeer, args []string) error { return nil } -func convertAndSavePage(p *hugolib.Page, site *hugolib.Site, mark rune) 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 := convertAndSavePage(r.(*hugolib.Page), site, mark); err != nil { + for _, r := range p.Resources().ByType("page") { + if err := c.convertAndSavePage(r.(page.Page), site, targetFormat); err != nil { return err } } - if p.Filename() == "" { + if p.File() == nil { // No content file. return nil } - site.Log.INFO.Println("Attempting to convert", p.LogicalName()) - newPage, err := site.NewPage(p.LogicalName()) - if err != nil { - return err - } + errMsg := fmt.Errorf("error processing file %q", p.File().Path()) - f, _ := p.File.(src.ReadableFile) - file, err := f.Open() + 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("Error reading file:", p.Path()) + site.Log.Errorln(errMsg) file.Close() return nil } - psr, err := parser.ReadFrom(file) + pf, err := pageparser.ParseFrontMatterAndContent(file) if err != nil { - site.Log.ERROR.Println("Error processing file:", p.Path()) + site.Log.Errorln(errMsg) file.Close() return err } file.Close() - metadata, err := psr.Metadata() + // 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 { + switch vv := v.(type) { + case time.Time: + pf.FrontMatter[k] = vv.Format(time.RFC3339) + } + } + } + + var newContent bytes.Buffer + err = parser.InterfaceToFrontMatter(pf.FrontMatter, targetFormat, &newContent) if err != nil { - site.Log.ERROR.Println("Error processing file:", p.Path()) + site.Log.Errorln(errMsg) return err } - // better handling of dates in formats that don't have support for them - if mark == parser.FormatToLeadRune("json") || mark == parser.FormatToLeadRune("yaml") || mark == parser.FormatToLeadRune("toml") { - newMetadata := cast.ToStringMap(metadata) - for k, v := range newMetadata { - switch vv := v.(type) { - case time.Time: - newMetadata[k] = vv.Format(time.RFC3339) - } - } - metadata = newMetadata + newContent.Write(pf.Content) + + newFilename := p.File().Filename() + + if c.outputDir != "" { + contentDir := strings.TrimSuffix(newFilename, p.File().Path()) + contentDir = filepath.Base(contentDir) + + newFilename = filepath.Join(c.outputDir, contentDir, p.File().Path()) } - newPage.SetSourceContent(psr.Content()) - if err = newPage.SetSourceMetaData(metadata, mark); err != nil { - site.Log.ERROR.Printf("Failed to set source metadata for file %q: %s. For more info see For more info see https://github.com/gohugoio/hugo/issues/2458", newPage.FullFilePath(), err) - return nil - } - - newFilename := p.Filename() - if outputDir != "" { - newFilename = filepath.Join(outputDir, p.Dir(), newPage.LogicalName()) - } - - if err = newPage.SaveSourceAs(newFilename); err != nil { - return fmt.Errorf("Failed to save file %q: %s", newFilename, err) + fs := hugofs.Os + if err := helpers.WriteToDisk(newFilename, &newContent, fs); err != nil { + return fmt.Errorf("failed to save file %q:: %w", newFilename, err) } return nil } + +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") + } + + if err := c.h.Build(hugolib.BuildCfg{SkipRender: true}); err != nil { + return err + } + + site := c.h.Sites[0] + + var pagesBackedByFile page.Pages + for _, p := range site.AllPages() { + if p.File() == nil { + continue + } + pagesBackedByFile = append(pagesBackedByFile, p) + } + + site.Log.Println("processing", len(pagesBackedByFile), "content files") + for _, p := range site.AllPages() { + if err := c.convertAndSavePage(p, site, format); err != nil { + return err + } + } + return nil +} diff --git a/commands/deploy.go b/commands/deploy.go new file mode 100644 index 000000000..3e9d3df20 --- /dev/null +++ b/commands/deploy.go @@ -0,0 +1,51 @@ +// 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 commands + +import ( + "context" + + "github.com/gohugoio/hugo/deploy" + + "github.com/bep/simplecobra" + "github.com/spf13/cobra" +) + +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. +`, + 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(h.Configs.GetFirstLanguageConfig(), h.Log, h.PathSpec.PublishFs) + if err != nil { + return err + } + return deployer.Deploy(ctx) + }, + withc: func(cmd *cobra.Command, r *rootCommand) { + applyDeployFlags(cmd, r) + }, + } +} 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 54c98d527..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,22 +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 envCmd = &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()) - return nil - }, + 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 62a84b0d0..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,10 +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 genCmd = &cobra.Command{ - Use: "gen", - Short: "A collection of several useful generators.", +func newGenCommand() *genCommand { + var ( + // Flags. + gendocdir string + genmandir string + + // 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(), + }, + } +} + +type genCommand struct { + rootCmd *rootCommand + + 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 c2004ab22..000000000 --- a/commands/genautocomplete.go +++ /dev/null @@ -1,70 +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 autocompleteTarget string - -// bash for now (zsh and others will come) -var autocompleteType string - -var genautocompleteCmd = &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 autocompleteType != "bash" { - return newUserError("Only Bash is supported for now") - } - - err := cmd.Root().GenBashCompletionFile(autocompleteTarget) - - if err != nil { - return err - } - - jww.FEEDBACK.Println("Bash completion file for Hugo saved to", autocompleteTarget) - - return nil - }, -} - -func init() { - genautocompleteCmd.PersistentFlags().StringVarP(&autocompleteTarget, "completionfile", "", "/etc/bash_completion.d/hugo.sh", "autocompletion file") - genautocompleteCmd.PersistentFlags().StringVarP(&autocompleteType, "type", "", "bash", "autocompletion type (currently only bash supported)") - - // For bash-completion - genautocompleteCmd.PersistentFlags().SetAnnotation("completionfile", cobra.BashCompFilenameExt, []string{}) -} diff --git a/commands/genchromastyles.go b/commands/genchromastyles.go deleted file mode 100644 index 66a2b50a6..000000000 --- a/commands/genchromastyles.go +++ /dev/null @@ -1,70 +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" -) - -type genChromaStyles struct { - style string - highlightStyle string - linesStyle string - cmd *cobra.Command -} - -// TODO(bep) highlight -func createGenChromaStyles() *genChromaStyles { - g := &genChromaStyles{ - cmd: &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()) - formatter.WriteCSS(os.Stdout, style) - return nil -} diff --git a/commands/gendoc.go b/commands/gendoc.go deleted file mode 100644 index c4840050b..000000000 --- a/commands/gendoc.go +++ /dev/null @@ -1,86 +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" -) - -const gendocFrontmatterTemplate = `--- -date: %s -title: "%s" -slug: %s -url: %s ---- -` - -var gendocdir string -var gendocCmd = &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(gendocdir, helpers.FilePathSeparator) { - gendocdir += helpers.FilePathSeparator - } - if found, _ := helpers.Exists(gendocdir, hugofs.Os); !found { - jww.FEEDBACK.Println("Directory", gendocdir, "does not exist, creating...") - if err := hugofs.Os.MkdirAll(gendocdir, 0777); err != nil { - return err - } - } - now := time.Now().Format(time.RFC3339) - 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", gendocdir, "...") - doc.GenMarkdownTreeCustom(cmd.Root(), gendocdir, prepender, linkHandler) - jww.FEEDBACK.Println("Done.") - - return nil - }, -} - -func init() { - gendocCmd.PersistentFlags().StringVar(&gendocdir, "dir", "/tmp/hugodoc/", "the directory to write the doc.") - - // For bash-completion - gendocCmd.PersistentFlags().SetAnnotation("dir", cobra.BashCompSubdirsInDir, []string{}) -} diff --git a/commands/gendocshelper.go b/commands/gendocshelper.go deleted file mode 100644 index ca781242e..000000000 --- a/commands/gendocshelper.go +++ /dev/null @@ -1,70 +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" -) - -type genDocsHelper struct { - target string - cmd *cobra.Command -} - -func createGenDocsHelper() *genDocsHelper { - g := &genDocsHelper{ - cmd: &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 004e669e7..000000000 --- a/commands/genman.go +++ /dev/null @@ -1,66 +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/helpers" - "github.com/gohugoio/hugo/hugofs" - "github.com/spf13/cobra" - "github.com/spf13/cobra/doc" - jww "github.com/spf13/jwalterweatherman" -) - -var genmandir string -var genmanCmd = &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", helpers.CurrentHugoVersion), - } - if !strings.HasSuffix(genmandir, helpers.FilePathSeparator) { - genmandir += helpers.FilePathSeparator - } - if found, _ := helpers.Exists(genmandir, hugofs.Os); !found { - jww.FEEDBACK.Println("Directory", genmandir, "does not exist, creating...") - if err := hugofs.Os.MkdirAll(genmandir, 0777); err != nil { - return err - } - } - cmd.Root().DisableAutoGenTag = true - - jww.FEEDBACK.Println("Generating Hugo man pages in", genmandir, "...") - doc.GenManTree(cmd.Root(), header, genmandir) - - jww.FEEDBACK.Println("Done.") - - return nil - }, -} - -func init() { - genmanCmd.PersistentFlags().StringVar(&genmandir, "dir", "man/", "the directory to write the man pages.") - - // For bash-completion - genmanCmd.PersistentFlags().SetAnnotation("dir", cobra.BashCompSubdirsInDir, []string{}) -} diff --git a/commands/helpers.go b/commands/helpers.go new file mode 100644 index 000000000..a13bdebc2 --- /dev/null +++ b/commands/helpers.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 commands + +import ( + "errors" + "fmt" + "log" + "os" + "path/filepath" + "strings" + + "github.com/bep/simplecobra" + "github.com/gohugoio/hugo/config" + "github.com/spf13/pflag" +) + +const ( + ansiEsc = "\u001B" + clearLine = "\r\033[K" + hideCursor = ansiEsc + "[?25l" + showCursor = ansiEsc + "[?25h" +) + +func newUserError(a ...any) *simplecobra.CommandError { + return &simplecobra.CommandError{Err: errors.New(fmt.Sprint(a...))} +} + +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())) + } + + } +} + +func flagsToCfg(cd *simplecobra.Commandeer, cfg config.Provider) config.Provider { + return flagsToCfgWithAdditionalConfigBase(cd, cfg, "") +} + +func flagsToCfgWithAdditionalConfigBase(cd *simplecobra.Commandeer, cfg config.Provider, additionalConfigBase string) config.Provider { + if cfg == nil { + cfg = config.New() + } + + // 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 d0894a11a..000000000 --- a/commands/hugo.go +++ /dev/null @@ -1,1143 +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 defines and implements command-line commands and flags -// used by Hugo. Commands and flags are implemented using Cobra. -package commands - -import ( - "fmt" - "io/ioutil" - "os/signal" - "sort" - "sync/atomic" - "syscall" - - "golang.org/x/sync/errgroup" - - "log" - "os" - "path/filepath" - "runtime" - "strings" - "time" - - src "github.com/gohugoio/hugo/source" - - "github.com/gohugoio/hugo/config" - - "github.com/gohugoio/hugo/parser" - flag "github.com/spf13/pflag" - - "regexp" - - "github.com/fsnotify/fsnotify" - "github.com/gohugoio/hugo/helpers" - "github.com/gohugoio/hugo/hugolib" - "github.com/gohugoio/hugo/livereload" - "github.com/gohugoio/hugo/utils" - "github.com/gohugoio/hugo/watcher" - "github.com/spf13/afero" - "github.com/spf13/cobra" - "github.com/spf13/fsync" - jww "github.com/spf13/jwalterweatherman" - "github.com/spf13/nitro" -) - -// Hugo represents the Hugo sites to build. This variable is exported as it -// is used by at least one external library (the Hugo caddy plugin). We should -// provide a cleaner external API, but until then, this is it. -var Hugo *hugolib.HugoSites - -const ( - ansiEsc = "\u001B" - clearLine = "\r\033[K" - hideCursor = ansiEsc + "[?25l" - showCursor = ansiEsc + "[?25h" -) - -// Reset resets Hugo ready for a new full build. This is mainly only useful -// for benchmark testing etc. via the CLI commands. -func Reset() error { - Hugo = nil - return nil -} - -// commandError is an error used to signal different error situations in command handling. -type commandError struct { - s string - userError bool -} - -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 - } - - return userErrorRegexp.MatchString(err.Error()) -} - -// HugoCmd is Hugo's root command. -// Every other command attached to HugoCmd is a child command to it. -var HugoCmd = &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 { - - cfgInit := func(c *commandeer) error { - if buildWatch { - c.Set("disableLiveReload", true) - } - c.Set("renderToMemory", renderToMemory) - return nil - } - - c, err := InitializeConfig(buildWatch, cfgInit) - if err != nil { - return err - } - - return c.build() - }, -} - -var hugoCmdV *cobra.Command - -// Flags that are to be added to commands. -var ( - buildWatch bool - logging bool - renderToMemory bool // for benchmark testing - verbose bool - verboseLog bool - debug bool - quiet bool -) - -var ( - gc bool - baseURL string - cacheDir string - contentDir string - layoutDir string - cfgFile string - destination string - logFile string - theme string - themesDir string - source string - logI18nWarnings bool - disableKinds []string -) - -// Execute adds all child commands to the root command HugoCmd and sets flags appropriately. -func Execute() { - HugoCmd.SetGlobalNormalizationFunc(helpers.NormalizeHugoFlags) - - HugoCmd.SilenceUsage = true - - AddCommands() - - if c, err := HugoCmd.ExecuteC(); err != nil { - if isUserError(err) { - c.Println("") - c.Println(c.UsageString()) - } - - os.Exit(-1) - } -} - -// AddCommands adds child commands to the root command HugoCmd. -func AddCommands() { - HugoCmd.AddCommand(serverCmd) - HugoCmd.AddCommand(versionCmd) - HugoCmd.AddCommand(envCmd) - HugoCmd.AddCommand(configCmd) - HugoCmd.AddCommand(checkCmd) - HugoCmd.AddCommand(benchmarkCmd) - HugoCmd.AddCommand(convertCmd) - HugoCmd.AddCommand(newCmd) - HugoCmd.AddCommand(listCmd) - HugoCmd.AddCommand(importCmd) - - HugoCmd.AddCommand(genCmd) - genCmd.AddCommand(genautocompleteCmd) - genCmd.AddCommand(gendocCmd) - genCmd.AddCommand(genmanCmd) - genCmd.AddCommand(createGenDocsHelper().cmd) - genCmd.AddCommand(createGenChromaStyles().cmd) - -} - -// initHugoBuilderFlags initializes all common flags, typically used by the -// core build commands, namely hugo itself, server, check and benchmark. -func initHugoBuilderFlags(cmd *cobra.Command) { - initHugoBuildCommonFlags(cmd) -} - -func initRootPersistentFlags() { - HugoCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is path/config.yaml|json|toml)") - HugoCmd.PersistentFlags().BoolVar(&quiet, "quiet", false, "build in quiet mode") - - // Set bash-completion - validConfigFilenames := []string{"json", "js", "yaml", "yml", "toml", "tml"} - _ = HugoCmd.PersistentFlags().SetAnnotation("config", cobra.BashCompFilenameExt, validConfigFilenames) -} - -// initHugoBuildCommonFlags initialize common flags related to the Hugo build. -// Called by initHugoBuilderFlags. -func initHugoBuildCommonFlags(cmd *cobra.Command) { - 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().StringVarP(&source, "source", "s", "", "filesystem path to read files relative from") - cmd.Flags().StringVarP(&contentDir, "contentDir", "c", "", "filesystem path to content directory") - cmd.Flags().StringVarP(&layoutDir, "layoutDir", "l", "", "filesystem path to layout directory") - cmd.Flags().StringVarP(&cacheDir, "cacheDir", "", "", "filesystem path to cache directory. Defaults: $TMPDIR/hugo_cache/") - cmd.Flags().BoolP("ignoreCache", "", false, "ignores the cache directory") - cmd.Flags().StringVarP(&destination, "destination", "d", "", "filesystem path to write files to") - cmd.Flags().StringVarP(&theme, "theme", "t", "", "theme to use (located in /themes/THEMENAME/)") - cmd.Flags().StringVarP(&themesDir, "themesDir", "", "", "filesystem path to themes directory") - cmd.Flags().Bool("uglyURLs", false, "(deprecated) if true, use /filename.html instead of /filename/") - cmd.Flags().Bool("canonifyURLs", false, "(deprecated) if true, all relative URLs will be canonicalized using baseURL") - cmd.Flags().StringVarP(&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(&gc, "gc", false, "enable to run some cleanup tasks (remove unused cache files) after the build") - - cmd.Flags().BoolVar(&nitro.AnalysisOn, "stepAnalysis", false, "display memory and timing of different steps of the program") - 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().Bool("pluralizeListTitles", true, "(deprecated) pluralize titles in lists using inflect") - cmd.Flags().Bool("preserveTaxonomyNames", false, `(deprecated) preserve taxonomy names as written ("Gérard Depardieu" vs "gerard-depardieu")`) - 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().BoolVarP(&logI18nWarnings, "i18n-warnings", "", false, "print missing translations") - - cmd.Flags().StringSliceVar(&disableKinds, "disableKinds", []string{}, "disable different kind of pages (home, RSS 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 initBenchmarkBuildingFlags(cmd *cobra.Command) { - cmd.Flags().BoolVar(&renderToMemory, "renderToMemory", false, "render to memory (only useful for benchmark testing)") -} - -// init initializes flags. -func init() { - HugoCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output") - HugoCmd.PersistentFlags().BoolVarP(&debug, "debug", "", false, "debug output") - HugoCmd.PersistentFlags().BoolVar(&logging, "log", false, "enable Logging") - HugoCmd.PersistentFlags().StringVar(&logFile, "logFile", "", "log File path (if set, logging enabled automatically)") - HugoCmd.PersistentFlags().BoolVar(&verboseLog, "verboseLog", false, "verbose logging") - - initRootPersistentFlags() - initHugoBuilderFlags(HugoCmd) - initBenchmarkBuildingFlags(HugoCmd) - - HugoCmd.Flags().BoolVarP(&buildWatch, "watch", "w", false, "watch filesystem for changes and recreate as needed") - hugoCmdV = HugoCmd - - // Set bash-completion - _ = HugoCmd.PersistentFlags().SetAnnotation("logFile", cobra.BashCompFilenameExt, []string{}) -} - -// InitializeConfig initializes a config file with sensible default configuration flags. -func InitializeConfig(running bool, doWithCommandeer func(c *commandeer) error, subCmdVs ...*cobra.Command) (*commandeer, error) { - - c, err := newCommandeer(running, doWithCommandeer, subCmdVs...) - if err != nil { - return nil, err - } - - return c, nil - -} - -func createLogger(cfg config.Provider) (*jww.Notepad, error) { - var ( - logHandle = ioutil.Discard - logThreshold = jww.LevelWarn - logFile = cfg.GetString("logFile") - outHandle = os.Stdout - stdoutThreshold = jww.LevelError - ) - - if verboseLog || logging || (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 !quiet && cfg.GetBool("verbose") { - stdoutThreshold = jww.LevelInfo - } - - if cfg.GetBool("debug") { - stdoutThreshold = jww.LevelDebug - } - - if verboseLog { - logThreshold = jww.LevelInfo - if cfg.GetBool("debug") { - logThreshold = jww.LevelDebug - } - } - - // The global logger is used in some few cases. - jww.SetLogOutput(logHandle) - jww.SetLogThreshold(logThreshold) - jww.SetStdoutThreshold(stdoutThreshold) - helpers.InitLoggers() - - return jww.NewNotepad(stdoutThreshold, logThreshold, outHandle, logHandle, "", log.Ldate|log.Ltime), nil -} - -func (c *commandeer) initializeFlags(cmd *cobra.Command) { - persFlagKeys := []string{"debug", "verbose", "logFile"} - flagKeys := []string{ - "cleanDestinationDir", - "buildDrafts", - "buildFuture", - "buildExpired", - "uglyURLs", - "canonifyURLs", - "enableRobotsTXT", - "enableGitInfo", - "pluralizeListTitles", - "preserveTaxonomyNames", - "ignoreCache", - "forceSyncStatic", - "noTimes", - "noChmod", - "templateMetrics", - "templateMetricsHints", - } - - for _, key := range persFlagKeys { - c.setValueFromFlag(cmd.PersistentFlags(), key) - } - for _, key := range flagKeys { - c.setValueFromFlag(cmd.Flags(), key) - } - -} - -var deprecatedFlags = map[string]bool{ - strings.ToLower("uglyURLs"): true, - strings.ToLower("pluralizeListTitles"): true, - strings.ToLower("preserveTaxonomyNames"): true, - strings.ToLower("canonifyURLs"): true, -} - -func (c *commandeer) setValueFromFlag(flags *flag.FlagSet, key string) { - if flags.Changed(key) { - if _, deprecated := deprecatedFlags[strings.ToLower(key)]; deprecated { - msg := fmt.Sprintf(`Set "%s = true" in your config.toml. -If you need to set this configuration value from the command line, set it via an OS environment variable: "HUGO_%s=true hugo"`, key, strings.ToUpper(key)) - // Remove in Hugo 0.38 - helpers.Deprecated("hugo", "--"+key+" flag", msg, true) - } - f := flags.Lookup(key) - c.Set(key, f.Value.String()) - } -} - -func (c *commandeer) fullBuild() error { - var ( - g errgroup.Group - langCount map[string]uint64 - ) - - if !quiet { - fmt.Print(hideCursor + "Building sites … ") - defer func() { - fmt.Print(showCursor + clearLine) - }() - } - - copyStaticFunc := func() error { - cnt, err := c.copyStatic() - if err != nil { - return fmt.Errorf("Error copying static files: %s", err) - } - langCount = cnt - return nil - } - buildSitesFunc := func() error { - if err := c.buildSites(); err != nil { - return fmt.Errorf("Error building site: %s", 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. - 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 Hugo.Sites { - s.ProcessingStats.Static = langCount[s.Language.Lang] - } - - if gc { - count, err := Hugo.GC() - if err != nil { - return err - } - for _, s := range Hugo.Sites { - // We have no way of knowing what site the garbage belonged to. - s.ProcessingStats.Cleaned = uint64(count) - } - } - - return nil - -} - -func (c *commandeer) build() error { - defer c.timeTrack(time.Now(), "Total") - - if err := c.fullBuild(); err != nil { - return err - } - - // TODO(bep) Feedback? - if !quiet { - fmt.Println() - Hugo.PrintProcessingStats(os.Stdout) - fmt.Println() - } - - if buildWatch { - watchDirs, err := c.getDirList() - if err != nil { - return err - } - c.Logger.FEEDBACK.Println("Watching for changes in", c.PathSpec().AbsPathify(c.Cfg.GetString("contentDir"))) - c.Logger.FEEDBACK.Println("Press Ctrl+C to stop") - watcher, err := c.newWatcher(watchDirs...) - utils.CheckErr(c.Logger, err) - defer watcher.Close() - - var sigs = make(chan os.Signal) - signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) - - <-sigs - } - - return nil -} - -func (c *commandeer) serverBuild() error { - defer c.timeTrack(time.Now(), "Total") - - if err := c.fullBuild(); err != nil { - return err - } - - // TODO(bep) Feedback? - if !quiet { - fmt.Println() - Hugo.PrintProcessingStats(os.Stdout) - fmt.Println() - } - - return nil -} - -func (c *commandeer) copyStatic() (map[string]uint64, error) { - return c.doWithPublishDirs(c.copyStaticTo) -} - -func (c *commandeer) createStaticDirsConfig() ([]*src.Dirs, error) { - var dirsConfig []*src.Dirs - - if !c.languages.IsMultihost() { - dirs, err := src.NewDirs(c.Fs, c.Cfg, c.DepsCfg.Logger) - if err != nil { - return nil, err - } - dirsConfig = append(dirsConfig, dirs) - } else { - for _, l := range c.languages { - dirs, err := src.NewDirs(c.Fs, l, c.DepsCfg.Logger) - if err != nil { - return nil, err - } - dirsConfig = append(dirsConfig, dirs) - } - } - - return dirsConfig, nil - -} - -func (c *commandeer) doWithPublishDirs(f func(dirs *src.Dirs, publishDir string) (uint64, error)) (map[string]uint64, error) { - - langCount := make(map[string]uint64) - - for _, dirs := range c.staticDirsConfig { - - cnt, err := f(dirs, c.pathSpec.PublishDir) - if err != nil { - return langCount, err - } - - if dirs.Language == nil { - // Not multihost - for _, l := range c.languages { - langCount[l.Lang] = cnt - } - } else { - langCount[dirs.Language.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 (c *commandeer) copyStaticTo(dirs *src.Dirs, publishDir string) (uint64, error) { - - // If root, remove the second '/' - if publishDir == "//" { - publishDir = helpers.FilePathSeparator - } - - if dirs.Language != nil { - // Multihost setup. - publishDir = filepath.Join(publishDir, dirs.Language.Lang) - } - - staticSourceFs, err := dirs.CreateStaticFs() - if err != nil { - return 0, err - } - - if staticSourceFs == nil { - c.Logger.WARN.Println("No static directories found to sync") - return 0, nil - } - - fs := &countingStatFs{Fs: staticSourceFs} - - syncer := fsync.NewSyncer() - syncer.NoTimes = c.Cfg.GetBool("noTimes") - syncer.NoChmod = c.Cfg.GetBool("noChmod") - 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) timeTrack(start time.Time, name string) { - if quiet { - return - } - 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 a []string - - // To handle nested symlinked content dirs - var seen = make(map[string]bool) - var nested []string - - dataDir := c.PathSpec().AbsPathify(c.Cfg.GetString("dataDir")) - i18nDir := c.PathSpec().AbsPathify(c.Cfg.GetString("i18nDir")) - staticSyncer, err := newStaticSyncer(c) - if err != nil { - return nil, err - } - - layoutDir := c.PathSpec().GetLayoutDirPath() - staticDirs := staticSyncer.d.AbsStaticDirs - - newWalker := func(allowSymbolicDirs bool) func(path string, fi os.FileInfo, err error) error { - return func(path string, fi os.FileInfo, err error) error { - if err != nil { - if path == dataDir && os.IsNotExist(err) { - c.Logger.WARN.Println("Skip dataDir:", err) - return nil - } - - if path == i18nDir && os.IsNotExist(err) { - c.Logger.WARN.Println("Skip i18nDir:", err) - return nil - } - - if path == layoutDir && os.IsNotExist(err) { - c.Logger.WARN.Println("Skip layoutDir:", err) - return nil - } - - if os.IsNotExist(err) { - for _, staticDir := range staticDirs { - if path == staticDir && os.IsNotExist(err) { - c.Logger.WARN.Println("Skip staticDir:", err) - } - } - // Ignore. - return nil - } - - c.Logger.ERROR.Println("Walker: ", err) - return nil - } - - // Skip .git directories. - // Related to https://github.com/gohugoio/hugo/issues/3468. - if fi.Name() == ".git" { - return nil - } - - if fi.Mode()&os.ModeSymlink == os.ModeSymlink { - link, err := filepath.EvalSymlinks(path) - if err != nil { - c.Logger.ERROR.Printf("Cannot read symbolic link '%s', error was: %s", path, err) - return nil - } - linkfi, err := helpers.LstatIfPossible(c.Fs.Source, link) - if err != nil { - c.Logger.ERROR.Printf("Cannot stat %q: %s", link, err) - return nil - } - if !allowSymbolicDirs && !linkfi.Mode().IsRegular() { - c.Logger.ERROR.Printf("Symbolic links for directories not supported, skipping %q", path) - return nil - } - - if allowSymbolicDirs && linkfi.IsDir() { - // afero.Walk will not walk symbolic links, so wee need to do it. - if !seen[path] { - seen[path] = true - nested = append(nested, path) - } - return nil - } - - fi = linkfi - } - - if fi.IsDir() { - if fi.Name() == ".git" || - fi.Name() == "node_modules" || fi.Name() == "bower_components" { - return filepath.SkipDir - } - a = append(a, path) - } - return nil - } - } - - symLinkWalker := newWalker(true) - regularWalker := newWalker(false) - - // SymbolicWalk will log anny ERRORs - _ = helpers.SymbolicWalk(c.Fs.Source, dataDir, regularWalker) - _ = helpers.SymbolicWalk(c.Fs.Source, i18nDir, regularWalker) - _ = helpers.SymbolicWalk(c.Fs.Source, layoutDir, regularWalker) - - for _, contentDir := range c.PathSpec().ContentDirs() { - _ = helpers.SymbolicWalk(c.Fs.Source, contentDir.Value, symLinkWalker) - } - - for _, staticDir := range staticDirs { - _ = helpers.SymbolicWalk(c.Fs.Source, staticDir, regularWalker) - } - - if c.PathSpec().ThemeSet() { - themesDir := c.PathSpec().GetThemeDir() - _ = helpers.SymbolicWalk(c.Fs.Source, filepath.Join(themesDir, "layouts"), regularWalker) - _ = helpers.SymbolicWalk(c.Fs.Source, filepath.Join(themesDir, "i18n"), regularWalker) - _ = helpers.SymbolicWalk(c.Fs.Source, filepath.Join(themesDir, "data"), regularWalker) - } - - if len(nested) > 0 { - for { - - toWalk := nested - nested = nested[:0] - - for _, d := range toWalk { - _ = helpers.SymbolicWalk(c.Fs.Source, d, symLinkWalker) - } - - if len(nested) == 0 { - break - } - } - } - - a = helpers.UniqueStrings(a) - sort.Strings(a) - - return a, nil -} - -func (c *commandeer) recreateAndBuildSites(watching bool) (err error) { - defer c.timeTrack(time.Now(), "Total") - if err := c.initSites(); err != nil { - return err - } - if !quiet { - c.Logger.FEEDBACK.Println("Started building sites ...") - } - return Hugo.Build(hugolib.BuildCfg{CreateSitesFromConfig: true}) -} - -func (c *commandeer) resetAndBuildSites() (err error) { - if err = c.initSites(); err != nil { - return - } - if !quiet { - c.Logger.FEEDBACK.Println("Started building sites ...") - } - return Hugo.Build(hugolib.BuildCfg{ResetState: true}) -} - -func (c *commandeer) initSites() error { - if Hugo != nil { - Hugo.Cfg = c.Cfg - Hugo.Log.ResetLogCounters() - return nil - } - h, err := hugolib.NewHugoSites(*c.DepsCfg) - - if err != nil { - return err - } - Hugo = h - - return nil -} - -func (c *commandeer) buildSites() (err error) { - if err := c.initSites(); err != nil { - return err - } - return Hugo.Build(hugolib.BuildCfg{}) -} - -func (c *commandeer) rebuildSites(events []fsnotify.Event) error { - defer c.timeTrack(time.Now(), "Total") - - if err := c.initSites(); err != nil { - return err - } - visited := c.visitedURLs.PeekAllSet() - doLiveReload := !buildWatch && !c.Cfg.GetBool("disableLiveReload") - if doLiveReload && !c.Cfg.GetBool("disableFastRender") { - - // Make sure we always render the home pages - for _, l := range c.languages { - langPath := c.PathSpec().GetLangSubDir(l.Lang) - if langPath != "" { - langPath = langPath + "/" - } - home := c.pathSpec.PrependBasePath("/" + langPath) - visited[home] = true - } - - } - return Hugo.Build(hugolib.BuildCfg{RecentlyVisited: visited}, events...) -} - -func (c *commandeer) fullRebuild() { - if err := c.loadConfig(true); err != nil { - jww.ERROR.Println("Failed to reload config:", err) - } else if err := c.recreateAndBuildSites(true); err != nil { - jww.ERROR.Println(err) - } else if !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) - - for _, configFile := range c.configFiles { - c.Logger.FEEDBACK.Println("Watching for config changes in", configFile) - watcher.Add(configFile) - configSet[configFile] = true - } - - go func() { - for { - select { - case evs := <-watcher.Events: - 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(c.fullRebuild) - continue - } - - 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 { - if configSet[ev.Name] { - if ev.Op&fsnotify.Chmod == fsnotify.Chmod { - continue - } - // Config file changed. Need full rebuild. - c.fullRebuild() - break - } - - // Check the most specific first, i.e. files. - contentMapped := 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 = 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 - } - // 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 os.FileInfo, 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.Logger.FEEDBACK.Println("\nStatic file changes detected") - const layout = "2006-01-02 15:04:05.000 -0700" - c.Logger.FEEDBACK.Println(time.Now().Format(layout)) - - if c.Cfg.GetBool("forceSyncStatic") { - c.Logger.FEEDBACK.Printf("Syncing all static files\n") - _, err := c.copyStatic() - if err != nil { - utils.StopOnErr(c.Logger, err, "Error copying static files to publish dir") - } - } else { - if err := staticSyncer.syncsStaticEvents(staticEvents); err != nil { - c.Logger.ERROR.Println(err) - continue - } - } - - if !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) > 0 { - for _, ev := range staticEvents { - path := staticSyncer.d.MakeStaticPathRelative(ev.Name) - livereload.RefreshPath(path) - } - - } else { - livereload.ForceRefresh() - } - } - } - - if len(dynamicEvents) > 0 { - doLiveReload := !buildWatch && !c.Cfg.GetBool("disableLiveReload") - onePageName := pickOneWriteOrCreatePath(dynamicEvents) - - c.Logger.FEEDBACK.Println("\nChange detected, rebuilding site") - const layout = "2006-01-02 15:04:05.000 -0700" - c.Logger.FEEDBACK.Println(time.Now().Format(layout)) - - if err := c.rebuildSites(dynamicEvents); err != nil { - c.Logger.ERROR.Println("Failed to rebuild site:", err) - } - - if doLiveReload { - navigate := c.Cfg.GetBool("navigateToChanged") - // We have fetched the same page above, but it may have - // changed. - var p *hugolib.Page - - if navigate { - if onePageName != "" { - p = Hugo.GetContentPage(onePageName) - } - - } - - if p != nil { - livereload.NavigateToPathForPort(p.RelPermalink(), p.Site.ServerPort()) - } else { - livereload.ForceRefresh() - } - } - } - case err := <-watcher.Errors: - if err != nil { - c.Logger.ERROR.Println(err) - } - } - } - }() - - return watcher, nil -} - -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 -} - -// isThemeVsHugoVersionMismatch returns whether the current Hugo version is -// less than the theme's min_version. -func (c *commandeer) isThemeVsHugoVersionMismatch(fs afero.Fs) (mismatch bool, requiredMinVersion string) { - if !c.PathSpec().ThemeSet() { - return - } - - themeDir := c.PathSpec().GetThemeDir() - - path := filepath.Join(themeDir, "theme.toml") - - exists, err := helpers.Exists(path, fs) - - if err != nil || !exists { - return - } - - b, err := afero.ReadFile(fs, path) - - tomlMeta, err := parser.HandleTOMLMetaData(b) - - if err != nil { - return - } - - if minVersion, ok := tomlMeta["min_version"]; ok { - return helpers.CompareVersion(minVersion) > 0, fmt.Sprint(minVersion) - } - - return -} diff --git a/commands/hugo_windows.go b/commands/hugo_windows.go index 7342f21a0..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 cmd.exe and run Hugo from there. - - Visit http://gohugo.io/ for more information.` + You need to open PowerShell 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 8c9c4d093..000000000 --- a/commands/import_jekyll.go +++ /dev/null @@ -1,605 +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 ( - "bytes" - "errors" - "io" - "io/ioutil" - "os" - "path/filepath" - "regexp" - "strconv" - "strings" - "time" - - "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/cast" - "github.com/spf13/cobra" - jww "github.com/spf13/jwalterweatherman" -) - -func init() { - importCmd.AddCommand(importJekyllCmd) -} - -var importCmd = &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, -} - -var 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: importFromJekyll, -} - -func init() { - importJekyllCmd.Flags().Bool("force", false, "allow import into non-empty target directory") -} - -func 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("Target path should not be inside the Jekyll root, aborting.") - } - - forceImport, _ := cmd.Flags().GetBool("force") - - fs := afero.NewOsFs() - jekyllPostDirs, hasAnyPost := getJekyllDirInfo(fs, jekyllRoot) - if !hasAnyPost { - return errors.New("Your Jekyll root contains neither posts nor drafts, aborting.") - } - - site, err := createSiteFromJekyll(jekyllRoot, targetDir, jekyllPostDirs, forceImport) - - if err != nil { - return newUserError(err) - } - - jww.FEEDBACK.Println("Importing...") - - fileCount := 0 - callback := func(path string, fi os.FileInfo, 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(site, 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 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 := retrieveJekyllPostDir(fs, subDir); isPostDir { - postDirs[entry.Name()] = hasAnyPostInDir - if hasAnyPostInDir { - hasAnyPost = true - } - } - } - } - } - return postDirs, hasAnyPost -} - -func 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 := retrieveJekyllPostDir(fs, subDir); isPostDir { - return isPostDir, hasAnyPost - } - } - } - } - - return false, true -} - -func createSiteFromJekyll(jekyllRoot, targetDir string, jekyllPostDirs map[string]bool, force bool) (*hugolib.Site, error) { - s, err := hugolib.NewSiteDefaultLang() - if err != nil { - return nil, err - } - - fs := s.Fs.Source - if exists, _ := helpers.Exists(targetDir, fs); exists { - if isDir, _ := helpers.IsDir(targetDir, fs); !isDir { - return nil, errors.New("Target path \"" + targetDir + "\" already exists but not a directory") - } - - isEmpty, _ := helpers.IsEmpty(targetDir, fs) - - if !isEmpty && !force { - return nil, errors.New("Target path \"" + targetDir + "\" already exists and is not empty") - } - } - - jekyllConfig := loadJekyllConfig(fs, jekyllRoot) - - mkdir(targetDir, "layouts") - mkdir(targetDir, "content") - mkdir(targetDir, "archetypes") - mkdir(targetDir, "static") - mkdir(targetDir, "data") - mkdir(targetDir, "themes") - - createConfigFromJekyll(fs, targetDir, "yaml", jekyllConfig) - - copyJekyllFilesAndFolders(jekyllRoot, filepath.Join(targetDir, "static"), jekyllPostDirs) - - return s, nil -} - -func 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 := parser.HandleYAMLMetaData(b) - - if err != nil { - return nil - } - - return c -} - -func createConfigFromJekyll(fs afero.Fs, inpath string, kind string, 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, - } - kind = parser.FormatSanitize(kind) - - var buf bytes.Buffer - err = parser.InterfaceToConfig(in, parser.FormatToLeadRune(kind), &buf) - if err != nil { - return err - } - - return helpers.WriteToDisk(filepath.Join(inpath, "config."+kind), &buf, fs) -} - -func copyFile(source string, dest string) error { - sf, err := os.Open(source) - if err != nil { - return err - } - defer sf.Close() - df, err := os.Create(dest) - if err != nil { - return err - } - defer df.Close() - _, err = io.Copy(df, sf) - if err == nil { - si, err := os.Stat(source) - if err != nil { - err = os.Chmod(dest, si.Mode()) - - if err != nil { - return err - } - } - - } - return nil -} - -func copyDir(source string, dest string) error { - fi, err := os.Stat(source) - if err != nil { - return err - } - if !fi.IsDir() { - return errors.New(source + " is not a directory") - } - err = os.MkdirAll(dest, fi.Mode()) - if err != nil { - return err - } - entries, err := ioutil.ReadDir(source) - for _, entry := range entries { - sfp := filepath.Join(source, entry.Name()) - dfp := filepath.Join(dest, entry.Name()) - if entry.IsDir() { - err = copyDir(sfp, dfp) - if err != nil { - jww.ERROR.Println(err) - } - } else { - err = copyFile(sfp, dfp) - if err != nil { - jww.ERROR.Println(err) - } - } - - } - return nil -} - -func copyJekyllFilesAndFolders(jekyllRoot, dest string, jekyllPostDirs map[string]bool) (err error) { - fi, err := os.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) - 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 = copyDir(sfp, dfp) - 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 = copyFile(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(s *hugolib.Site, 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 - } - - psr, err := parser.ReadFrom(bytes.NewReader(contentBytes)) - if err != nil { - jww.ERROR.Println("Parse file error:", path) - return err - } - - metadata, err := psr.Metadata() - if err != nil { - jww.ERROR.Println("Processing file error:", path) - return err - } - - newmetadata, err := convertJekyllMetaData(metadata, postName, postDate, draft) - if err != nil { - jww.ERROR.Println("Convert metadata error:", path) - return err - } - - jww.TRACE.Println(newmetadata) - content := convertJekyllContent(newmetadata, string(psr.Content())) - - page, err := s.NewPage(filename) - if err != nil { - jww.ERROR.Println("New page error", filename) - return err - } - - page.SetSourceContent([]byte(content)) - page.SetSourceMetaData(newmetadata, parser.FormatToLeadRune("yaml")) - page.SaveSourceAs(targetFile) - - jww.TRACE.Println("Target file:", targetFile) - - return nil -} - -func convertJekyllMetaData(m interface{}, postName string, postDate time.Time, draft bool) (interface{}, error) { - metadata, err := cast.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 { - metadata, _ := cast.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*highlight\s*(.*?)\s*%}`), "{{< highlight $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}, - } - - for _, replace := range replaceListFunc { - content = replace.re.ReplaceAllStringFunc(content, replace.replace) - } - - return content -} - -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 cb22e9cd7..000000000 --- a/commands/import_jekyll_test.go +++ /dev/null @@ -1,126 +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" - "github.com/stretchr/testify/assert" - "testing" - "time" -) - -func TestParseJekyllFilename(t *testing.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) - assert.Equal(t, err, nil) - assert.Equal(t, expectResult[i].postDate.Format("2006-01-02"), postDate.Format("2006-01-02")) - assert.Equal(t, expectResult[i].postName, postName) - } -} - -func TestConvertJekyllMetadata(t *testing.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) - assert.Equal(t, nil, err) - jsonResult, err := json.Marshal(result) - assert.Equal(t, nil, err) - assert.Equal(t, data.expect, string(jsonResult)) - } -} - -func TestConvertJekyllContent(t *testing.T) { - testDataList := []struct { - metadata interface{} - content string - expect string - }{ - {map[interface{}]interface{}{}, - `Test content\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`, `Test 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 >}}"}, - - // 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\" >}}"}, - } - - for _, data := range testDataList { - result := convertJekyllContent(data.metadata, data.content) - assert.Equal(t, data.expect, result) - } -} diff --git a/commands/limit_darwin.go b/commands/limit_darwin.go deleted file mode 100644 index 9246f4497..000000000 --- a/commands/limit_darwin.go +++ /dev/null @@ -1,85 +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. - -// 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 ( - "syscall" - - "github.com/spf13/cobra" - jww "github.com/spf13/jwalterweatherman" -) - -func init() { - checkCmd.AddCommand(limit) -} - -var limit = &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) - - jww.FEEDBACK.Println("Attempting to increase limit") - rLimit.Max = 999999 - rLimit.Cur = 999999 - 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 - }, -} - -func tweakLimit() { - var rLimit syscall.Rlimit - err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rLimit) - if err != nil { - jww.ERROR.Println("Unable to obtain rLimit", err) - } - if rLimit.Cur < rLimit.Max { - rLimit.Max = 64000 - rLimit.Cur = 64000 - err = syscall.Setrlimit(syscall.RLIMIT_NOFILE, &rLimit) - if err != nil { - jww.WARN.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 c757f174e..000000000 --- a/commands/limit_others.go +++ /dev/null @@ -1,32 +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. - -// +build !darwin -// 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 - -func tweakLimit() { - // nothing to do -} diff --git a/commands/list.go b/commands/list.go index b391f204e..42f3408ba 100644 --- a/commands/list.go +++ b/commands/list.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,136 +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" ) -func init() { - listCmd.AddCommand(listDraftsCmd) - listCmd.AddCommand(listFutureCmd) - listCmd.AddCommand(listExpiredCmd) - listCmd.PersistentFlags().StringVarP(&source, "source", "s", "", "filesystem path to read files relative from") - listCmd.PersistentFlags().SetAnnotation("source", cobra.BashCompSubdirsInDir, []string{}) -} - -var listCmd = &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, -} - -var listDraftsCmd = &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 { - cfgInit := func(c *commandeer) error { - c.Set("buildDrafts", true) - return nil +// 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(), } - c, err := InitializeConfig(false, cfgInit) + } + + 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 } - sites, err := hugolib.NewHugoSites(*c.DepsCfg) + writer := csv.NewWriter(r.StdOut) + defer writer.Flush() - if err != nil { - return newSystemError("Error creating sites", err) - } + writer.Write([]string{ + "path", + "slug", + "title", + "date", + "expiryDate", + "publishDate", + "draft", + "permalink", + "kind", + "section", + }) - if err := sites.Build(hugolib.BuildCfg{SkipRender: true}); err != nil { - return newSystemError("Error Processing Source Content", err) - } - - for _, p := range sites.Pages() { - if p.IsDraft() { - jww.FEEDBACK.Println(filepath.Join(p.File.Dir(), p.File.LogicalName())) + 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 + } - }, + 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 + }, + }, + }, + } } -var listFutureCmd = &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 { - cfgInit := func(c *commandeer) error { - c.Set("buildFuture", true) - return nil - } - c, err := InitializeConfig(false, cfgInit) - if err != nil { - return err - } - - sites, err := hugolib.NewHugoSites(*c.DepsCfg) - - if err != nil { - return newSystemError("Error creating sites", err) - } - - if err := sites.Build(hugolib.BuildCfg{SkipRender: true}); err != nil { - return newSystemError("Error Processing Source Content", err) - } - - for _, p := range sites.Pages() { - if p.IsFuture() { - jww.FEEDBACK.Println(filepath.Join(p.File.Dir(), p.File.LogicalName())) - } - - } - - return nil - - }, +type listCommand struct { + commands []simplecobra.Commander } -var listExpiredCmd = &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 { - cfgInit := func(c *commandeer) error { - c.Set("buildExpired", true) - return nil - } - c, err := InitializeConfig(false, cfgInit) - if err != nil { - return err - } - - sites, err := hugolib.NewHugoSites(*c.DepsCfg) - - if err != nil { - return newSystemError("Error creating sites", err) - } - - if err := sites.Build(hugolib.BuildCfg{SkipRender: true}); err != nil { - return newSystemError("Error Processing Source Content", err) - } - - for _, p := range sites.Pages() { - if p.IsExpired() { - jww.FEEDBACK.Println(filepath.Join(p.File.Dir(), p.File.LogicalName())) - } - - } - - return nil - - }, +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_config.go b/commands/list_config.go deleted file mode 100644 index 031bff73f..000000000 --- a/commands/list_config.go +++ /dev/null @@ -1,66 +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.Print the version number of Hug - -package commands - -import ( - "reflect" - "sort" - - "github.com/spf13/cobra" - jww "github.com/spf13/jwalterweatherman" - "github.com/spf13/viper" -) - -var configCmd = &cobra.Command{ - Use: "config", - Short: "Print the site configuration", - Long: `Print the site configuration, both default and custom settings.`, -} - -func init() { - configCmd.RunE = printConfig -} - -func printConfig(cmd *cobra.Command, args []string) error { - cfg, err := InitializeConfig(false, nil, configCmd) - - if err != nil { - return err - } - - allSettings := cfg.Cfg.(*viper.Viper).AllSettings() - - var separator string - if allSettings["metadataformat"] == "toml" { - separator = " = " - } else { - separator = ": " - } - - var keys []string - for k := range allSettings { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - kv := reflect.ValueOf(allSettings[k]) - if kv.Kind() == reflect.String { - jww.FEEDBACK.Printf("%s%s\"%+v\"\n", k, separator, allSettings[k]) - } else { - jww.FEEDBACK.Printf("%s%s%+v\n", k, separator, allSettings[k]) - } - } - - return nil -} diff --git a/commands/mod.go b/commands/mod.go new file mode 100644 index 000000000..58155f9be --- /dev/null +++ b/commands/mod.go @@ -0,0 +1,344 @@ +// 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" + "os" + "path/filepath" + + "github.com/bep/simplecobra" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/modules/npm" + "github.com/spf13/cobra" +) + +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 --ignoreVendorPaths flag provided), +Go Modules, or a folder inside the themes directory, in that order. + +See https://gohugo.io/hugo-modules/ for more information. + +` + +// buildConfigCommands creates a new config command and its subcommands. +func newModCommands() *modCommands { + var ( + clean bool + pattern string + all bool + ) + + 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. + +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. + +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) + }, + }, + }, + } + + 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 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. +` + 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" || args[0] == "--help") { + return errHelp + } + + 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, + }, + } +} + +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 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 1bdd21949..81e1c65a4 100644 --- a/commands/new.go +++ b/commands/new.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. @@ -15,386 +15,213 @@ package commands import ( "bytes" - "errors" - "fmt" - "os" + "context" "path/filepath" "strings" - "time" + "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/hugofs" - "github.com/gohugoio/hugo/hugolib" - "github.com/gohugoio/hugo/parser" - "github.com/spf13/afero" + "github.com/gohugoio/hugo/create/skeletons" "github.com/spf13/cobra" - jww "github.com/spf13/jwalterweatherman" - "github.com/spf13/viper" ) -var ( - configFormat string - contentEditor string - contentType string -) +func newNewCommand() *newCommand { + var ( + force bool + contentType string + format string + ) -func init() { - newSiteCmd.Flags().StringVarP(&configFormat, "format", "f", "toml", "config & frontmatter format") - newSiteCmd.Flags().Bool("force", false, "init inside non-empty directory") - newCmd.Flags().StringVarP(&contentType, "kind", "k", "", "content type to create") - newCmd.PersistentFlags().StringVarP(&source, "source", "s", "", "filesystem path to read files relative from") - newCmd.PersistentFlags().SetAnnotation("source", cobra.BashCompSubdirsInDir, []string{}) - newCmd.Flags().StringVar(&contentEditor, "editor", "", "edit new content with this editor, if provided") - - newCmd.AddCommand(newSiteCmd) - newCmd.AddCommand(newThemeCmd) - -} - -var newCmd = &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`" + `. -If archetypes are provided in your theme or site, they will be used.`, +If archetypes are provided in your theme or site, they will be used. - RunE: NewContent, +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)) + }, + }, + }, + } + + return c } -var newSiteCmd = &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: NewSite, +type newCommand struct { + rootCmd *rootCommand + + commands []simplecobra.Commander } -var newThemeCmd = &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: NewTheme, +func (c *newCommand) Commands() []simplecobra.Commander { + return c.commands } -// NewContent adds new content to a Hugo site. -func NewContent(cmd *cobra.Command, args []string) error { - cfgInit := func(c *commandeer) error { - if cmd.Flags().Changed("editor") { - c.Set("newContentEditor", contentEditor) - } - return nil - } - - c, err := InitializeConfig(false, 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(createPath) - - if contentType != "" { - kind = contentType - } - - cfg := c.DepsCfg - - ps, err := helpers.NewPathSpec(cfg.Fs, cfg.Cfg) - if err != nil { - return err - } - - // If a site isn't in use in the archetype template, we can skip the build. - siteFactory := func(filename string, siteUsed bool) (*hugolib.Site, error) { - if !siteUsed { - return hugolib.NewSite(*cfg) - } - var s *hugolib.Site - if err := c.initSites(); err != nil { - return nil, err - } - - if err := Hugo.Build(hugolib.BuildCfg{SkipRender: true}); err != nil { - return nil, err - } - - s = Hugo.Sites[0] - - if len(Hugo.Sites) > 1 { - // Find the best match. - for _, ss := range Hugo.Sites { - if strings.Contains(createPath, "."+ss.Language.Lang) { - s = ss - break - } - } - } - return s, nil - } - - return create.NewContent(ps, siteFactory, kind, createPath) +func (c *newCommand) Name() string { + return "new" } -func 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") - - case !isEmpty && force: - all := append(dirs, filepath.Join(basepath, "config."+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 fmt.Errorf("Failed to create dir: %s", err) - } - } - - createConfig(fs, basepath, configFormat) - - // Create a defaul 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()) - +func (c *newCommand) Run(ctx context.Context, cd *simplecobra.Commandeer, args []string) error { return nil } -func nextStepsText() string { +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 and you're ready to go: + nextStepsText.WriteString(`Just a few more steps... -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 `) +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(`". -3. Start the built-in live server via "hugo server". +5. Start the embedded web server with the command "hugo server --buildDrafts". -Visit https://gohugo.io/ for quickstart guide and full documentation.`) +See documentation at https://gohugo.io/.`) return nextStepsText.String() } - -// NewSite creates a new Hugo site and initializes a structured Hugo directory. -func 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 doNewSite(hugofs.NewDefault(viper.New()), createpath, forceNew) -} - -// NewTheme creates a new Hugo theme. -func NewTheme(cmd *cobra.Command, args []string) error { - c, err := InitializeConfig(false, nil) - - if err != nil { - return err - } - - if len(args) < 1 { - return newUserError("theme name needs to be provided") - } - - createpath := c.PathSpec().AbsPathify(filepath.Join(c.Cfg.GetString("themesDir"), args[0])) - jww.INFO.Println("creating theme at", createpath) - - cfg := c.DepsCfg - - if x, _ := helpers.Exists(createpath, cfg.Fs.Source); x { - return newUserError(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") - - 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.md"), bytes.NewReader(by), cfg.Fs.Source) - if err != nil { - return err - } - - createThemeMD(cfg.Fs, createpath) - - return nil -} - -func mkdir(x ...string) { - p := filepath.Join(x...) - - err := os.MkdirAll(p, 0777) // before umask - if err != nil { - jww.FATAL.Fatalln(err) - } -} - -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 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.md" -description = "" -homepage = "http://example.com/" -tags = [] -features = [] -min_version = "0.38.1" - -[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 -} - -func newContentPathSection(path string) (string, string) { - // Forward slashes is used in all examples. Convert if needed. - // Issue #1133 - createpath := filepath.FromSlash(path) - 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 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", - } - kind = parser.FormatSanitize(kind) - - var buf bytes.Buffer - err = parser.InterfaceToConfig(in, parser.FormatToLeadRune(kind), &buf) - if err != nil { - return err - } - - return helpers.WriteToDisk(filepath.Join(inpath, "config."+kind), &buf, fs.Source) -} diff --git a/commands/new_test.go b/commands/new_test.go deleted file mode 100644 index c9adb83d4..000000000 --- a/commands/new_test.go +++ /dev/null @@ -1,122 +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 ( - "path/filepath" - "testing" - - "github.com/gohugoio/hugo/hugofs" - "github.com/spf13/viper" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// Issue #1133 -func TestNewContentPathSectionWithForwardSlashes(t *testing.T) { - p, s := newContentPathSection("/post/new.md") - assert.Equal(t, filepath.FromSlash("/post/new.md"), p) - assert.Equal(t, "post", s) -} - -func checkNewSiteInited(fs *hugofs.Fs, basepath string, t *testing.T) { - - 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 := fs.Source.Stat(path) - require.NoError(t, err) - } -} - -func TestDoNewSite(t *testing.T) { - basepath := filepath.Join("base", "blog") - _, fs := newTestCfg() - - require.NoError(t, doNewSite(fs, basepath, false)) - - checkNewSiteInited(fs, basepath, t) -} - -func TestDoNewSite_noerror_base_exists_but_empty(t *testing.T) { - basepath := filepath.Join("base", "blog") - _, fs := newTestCfg() - - require.NoError(t, fs.Source.MkdirAll(basepath, 777)) - - require.NoError(t, doNewSite(fs, basepath, false)) -} - -func TestDoNewSite_error_base_exists(t *testing.T) { - basepath := filepath.Join("base", "blog") - _, fs := newTestCfg() - - require.NoError(t, fs.Source.MkdirAll(basepath, 777)) - _, err := fs.Source.Create(filepath.Join(basepath, "foo")) - require.NoError(t, err) - // Since the directory already exists and isn't empty, expect an error - require.Error(t, doNewSite(fs, basepath, false)) - -} - -func TestDoNewSite_force_empty_dir(t *testing.T) { - basepath := filepath.Join("base", "blog") - _, fs := newTestCfg() - - require.NoError(t, fs.Source.MkdirAll(basepath, 777)) - - require.NoError(t, doNewSite(fs, basepath, true)) - - checkNewSiteInited(fs, basepath, t) -} - -func TestDoNewSite_error_force_dir_inside_exists(t *testing.T) { - basepath := filepath.Join("base", "blog") - _, fs := newTestCfg() - - contentPath := filepath.Join(basepath, "content") - - require.NoError(t, fs.Source.MkdirAll(contentPath, 777)) - require.Error(t, doNewSite(fs, basepath, true)) -} - -func TestDoNewSite_error_force_config_inside_exists(t *testing.T) { - basepath := filepath.Join("base", "blog") - _, fs := newTestCfg() - - configPath := filepath.Join(basepath, "config.toml") - require.NoError(t, fs.Source.MkdirAll(basepath, 777)) - _, err := fs.Source.Create(configPath) - require.NoError(t, err) - - require.Error(t, doNewSite(fs, basepath, true)) -} - -func newTestCfg() (*viper.Viper, *hugofs.Fs) { - - v := viper.New() - fs := hugofs.NewMem(v) - - v.SetFs(fs.Source) - - return v, fs - -} diff --git a/commands/release.go b/commands/release.go index 8ccf8bcc2..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,50 +14,40 @@ package commands import ( - "errors" + "context" + "github.com/bep/simplecobra" "github.com/gohugoio/hugo/releaser" "github.com/spf13/cobra" ) -func init() { - HugoCmd.AddCommand(createReleaser().cmd) -} +// 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() *releaseCommandeer { - // 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 (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/server.go b/commands/server.go index 278ba7f37..c8895b9a1 100644 --- a/commands/server.go +++ b/commands/server.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,71 +14,417 @@ 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" - "runtime" + "regexp" + "sort" "strconv" "strings" "sync" + "sync/atomic" "syscall" "time" - "github.com/gohugoio/hugo/livereload" + "github.com/bep/mclib" + "github.com/pkg/browser" + "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" ) var ( - disableLiveReload bool - navigateToChanged bool - renderToDisk bool - serverAppend bool - serverInterface string - serverPort int - liveReloadPort int - serverWatch bool - noHTTPCache bool - - disableFastRender bool + logDuplicateTemplateExecuteRe = regexp.MustCompile(`: template: .*?:\d+:\d+: executing ".*?"`) + logDuplicateTemplateParseRe = regexp.MustCompile(`: template: .*?:\d+:\d*`) ) -var serverCmd = &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. +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:", "", +) -'hugo server' will avoid writing the rendered and served content to disk, -preferring to store it in memory. +const ( + configChangeConfig = "config file" + configChangeGoMod = "go.mod file" + configChangeGoWork = "go work file" +) -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: server, +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 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 (c *serverCommand) Commands() []simplecobra.Commander { + return c.commands +} + +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 +} + +// 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) + }) + + 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") + } + + 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) + } + + 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 { @@ -87,163 +433,68 @@ 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 } -func init() { - initHugoBuilderFlags(serverCmd) +type serverCommand struct { + r *rootCommand - serverCmd.Flags().IntVarP(&serverPort, "port", "p", 1313, "port on which the server will listen") - serverCmd.Flags().IntVar(&liveReloadPort, "liveReloadPort", -1, "port for live reloading (i.e. 443 in HTTPS proxy situations)") - serverCmd.Flags().StringVarP(&serverInterface, "bind", "", "127.0.0.1", "interface to which the server will bind") - serverCmd.Flags().BoolVarP(&serverWatch, "watch", "w", true, "watch filesystem for changes and recreate as needed") - serverCmd.Flags().BoolVar(&noHTTPCache, "noHTTPCache", false, "prevent HTTP caching") - serverCmd.Flags().BoolVarP(&serverAppend, "appendPort", "", true, "append port to baseURL") - serverCmd.Flags().BoolVar(&disableLiveReload, "disableLiveReload", false, "watch without enabling live browser reload on rebuild") - serverCmd.Flags().BoolVar(&navigateToChanged, "navigateToChanged", false, "navigate to changed content file on live browser reload") - serverCmd.Flags().BoolVar(&renderToDisk, "renderToDisk", false, "render to Destination path (default is render to memory & serve from there)") - serverCmd.Flags().BoolVar(&disableFastRender, "disableFastRender", false, "enables full re-renders on changes") + commands []simplecobra.Commander - serverCmd.Flags().String("memstats", "", "log memory usage to this file") - serverCmd.Flags().String("meminterval", "100ms", "interval to poll memory usage (requires --memstats), valid time units are \"ns\", \"us\" (or \"µs\"), \"ms\", \"s\", \"m\", \"h\".") + *hugoBuilder - serverCmd.RunE = server + 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 } -var serverPorts []int +func (c *serverCommand) Name() string { + return "server" +} -func server(cmd *cobra.Command, args []string) error { - // If a Destination is provided via flag write to disk - if destination != "" { - renderToDisk = true +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", !renderToDisk) - if cmd.Flags().Changed("navigateToChanged") { - c.Set("navigateToChanged", navigateToChanged) - } - if cmd.Flags().Changed("disableLiveReload") { - c.Set("disableLiveReload", disableLiveReload) - } - if cmd.Flags().Changed("disableFastRender") { - c.Set("disableFastRender", disableFastRender) - } - if serverWatch { - c.Set("watch", true) - } - - var err error - - // We can only do this once. - serverCfgInit.Do(func() { - serverPorts = make([]int, 1) - - if c.languages.IsMultihost() { - if !serverAppend { - err = newSystemError("--appendPort=false not supported when in multihost mode") - } - serverPorts = make([]int, len(c.languages)) - } - - currentServerPort := serverPort - - for i := 0; i < len(serverPorts); i++ { - l, err := net.Listen("tcp", net.JoinHostPort(serverInterface, strconv.Itoa(currentServerPort))) - if err == nil { - l.Close() - serverPorts[i] = currentServerPort - } else { - if i == 0 && serverCmd.Flags().Changed("port") { - // port set explicitly by user -- he/she probably meant it! - err = newSystemErrorF("Server startup failed: %s", err) - } - jww.ERROR.Println("port", 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", serverPort) - if liveReloadPort != -1 { - c.Set("liveReloadPort", 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 := fixURL(language, 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.ERROR.Println("memstats error:", err) - } - - c, err := InitializeConfig(true, cfgInit, serverCmd) - if err != nil { - return err - } - - if err := c.serverBuild(); err != nil { - return err - } - - for _, s := range Hugo.Sites { - s.RegisterMediaTypes() - } - // Watch runs its own server as part of the routine - if serverWatch { + if c.serverWatch { watchDirs, err := c.getDirList() if err != nil { return err } - baseWatchDir := c.Cfg.GetString("workingDir") - relWatchDirs := make([]string, len(watchDirs)) - for i, dir := range watchDirs { - relWatchDirs[i], _ = helpers.GetRelativePath(dir, baseWatchDir) + watchGroups := helpers.ExtractAndGroupRootPaths(watchDirs) + + for _, group := range watchGroups { + c.r.Printf("Watching for changes in %s\n", group) } - - rootWatchDirs := strings.Join(helpers.UniqueStrings(helpers.ExtractRootPaths(relWatchDirs)), ",") - - jww.FEEDBACK.Printf("Watching for changes in %s%s{%s}\n", baseWatchDir, helpers.FilePathSeparator, rootWatchDirs) - watcher, err := c.newWatcher(watchDirs...) - + watcher, err := c.newWatcher(c.r.poll, watchDirs...) if err != nil { return err } @@ -252,185 +503,333 @@ func server(cmd *cobra.Command, args []string) error { } - return c.serve() - -} - -type fileServer struct { - baseURLs []string - roots []string - c *commandeer -} - -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.PathSpec().AbsPathify(publishDir) - - if i == 0 { - if renderToDisk { - jww.FEEDBACK.Println("Serving pages from " + absPublishDir) - } else { - jww.FEEDBACK.Println("Serving pages from memory") - } - } - - httpFs := afero.NewHttpFs(f.c.Fs.Destination) - fs := filesOnlyFs{httpFs.Dir(absPublishDir)} - - doLiveReload := !buildWatch && !f.c.Cfg.GetBool("disableLiveReload") - fastRenderMode := doLiveReload && !f.c.Cfg.GetBool("disableFastRender") - - if i == 0 && 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) + err := func() error { + defer c.r.timeTrack(time.Now(), "Built") + return c.build() + }() if err != nil { - return nil, "", "", fmt.Errorf("Invalid baseURL: %s", err) + return err } - decorate := func(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if noHTTPCache { - w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") - w.Header().Set("Pragma", "no-cache") - } - - if fastRenderMode { - p := r.RequestURI - if strings.HasSuffix(p, "/") || strings.HasSuffix(p, "html") || strings.HasSuffix(p, "htm") { - 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(serverInterface, strconv.Itoa(port)) - - return mu, u.String(), endpoint, nil + return c.serve() } -func (c *commandeer) serve() error { +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. - isMultiHost := Hugo.IsMultihost() +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 ( - baseURLs []string - roots []string - ) +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"} - if isMultiHost { - for _, s := range Hugo.Sites { - baseURLs = append(baseURLs, s.BaseURL.String()) - roots = append(roots, s.Language.Lang) - } - } else { - s := Hugo.Sites[0] - baseURLs = []string{s.BaseURL.String()} - roots = []string{""} - } + 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") - srv := &fileServer{ - baseURLs: baseURLs, - roots: roots, - c: c, - } - - doLiveReload := !c.Cfg.GetBool("disableLiveReload") - - if doLiveReload { - livereload.Initialize() - } - - var sigs = make(chan os.Signal) - signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) - - for i := range baseURLs { - mu, serverURL, endpoint, err := srv.createEndpoint(i) - - 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, serverInterface) - go func() { - err = http.ListenAndServe(endpoint, mu) - if err != nil { - jww.ERROR.Printf("Error: %s\n", err.Error()) - os.Exit(1) - } - }() - } - - jww.FEEDBACK.Println("Press Ctrl+C to stop") - - <-sigs + 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() + + } + + 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 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 serverAppend { + if c.serverAppend { if strings.Contains(u.Host, ":") { u.Host, _, err = net.SplitHostPort(u.Host) if err != nil { - return "", fmt.Errorf("Failed to split baseURL hostpost: %s", err) + return "", fmt.Errorf("failed to split baseURL hostport: %w", err) } } u.Host += fmt.Sprintf(":%d", port) @@ -439,37 +838,420 @@ func fixURL(cfg config.Provider, s string, port int) (string, error) { return u.String(), nil } -func memStats() error { - memstats := serverCmd.Flags().Lookup("memstats").Value.String() - if memstats != "" { - interval, err := time.ParseDuration(serverCmd.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_test.go b/commands/server_test.go deleted file mode 100644 index ce6dc078b..000000000 --- a/commands/server_test.go +++ /dev/null @@ -1,58 +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 ( - "testing" - - "github.com/spf13/viper" -) - -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 i, test := range tests { - v := viper.New() - baseURL = test.CLIBaseURL - v.Set("baseURL", test.CfgBaseURL) - serverAppend = test.AppendPort - serverPort = test.Port - result, err := fixURL(v, baseURL, serverPort) - if err != nil { - t.Errorf("Test #%d %s: unexpected error %s", i, test.TestName, err) - } - if result != test.Result { - t.Errorf("Test #%d %s: expected %q, got %q", i, test.TestName, test.Result, result) - } - } -} diff --git a/commands/static_syncer.go b/commands/static_syncer.go deleted file mode 100644 index a04904f95..000000000 --- a/commands/static_syncer.go +++ /dev/null @@ -1,141 +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/fsnotify/fsnotify" - "github.com/gohugoio/hugo/helpers" - src "github.com/gohugoio/hugo/source" - "github.com/spf13/fsync" -) - -type staticSyncer struct { - c *commandeer - d *src.Dirs -} - -func newStaticSyncer(c *commandeer) (*staticSyncer, error) { - dirs, err := src.NewDirs(c.Fs, c.Cfg, c.DepsCfg.Logger) - if err != nil { - return nil, err - } - - return &staticSyncer{c: c, d: dirs}, nil -} - -func (s *staticSyncer) isStatic(path string) bool { - return s.d.IsStatic(path) -} - -func (s *staticSyncer) syncsStaticEvents(staticEvents []fsnotify.Event) error { - c := s.c - - syncFn := func(dirs *src.Dirs, publishDir string) (uint64, error) { - staticSourceFs, err := dirs.CreateStaticFs() - if err != nil { - return 0, err - } - - if dirs.Language != nil { - // Multihost setup - publishDir = filepath.Join(publishDir, dirs.Language.Lang) - } - - if staticSourceFs == nil { - c.Logger.WARN.Println("No static directories found to sync") - return 0, nil - } - - syncer := fsync.NewSyncer() - syncer.NoTimes = c.Cfg.GetBool("noTimes") - syncer.NoChmod = c.Cfg.GetBool("noChmod") - syncer.SrcFs = staticSourceFs - 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 - - // If we are here we already know the event took place in a static dir - relPath := dirs.MakeStaticPathRelative(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 := staticSourceFs.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 4f3810c78..000000000 --- a/commands/version.go +++ /dev/null @@ -1,42 +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 ( - "runtime" - "strings" - - "github.com/gohugoio/hugo/helpers" - "github.com/gohugoio/hugo/hugolib" - "github.com/spf13/cobra" - jww "github.com/spf13/jwalterweatherman" -) - -var versionCmd = &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() { - if hugolib.CommitHash == "" { - jww.FEEDBACK.Printf("Hugo Static Site Generator v%s %s/%s BuildDate: %s\n", helpers.CurrentHugoVersion, runtime.GOOS, runtime.GOARCH, hugolib.BuildDate) - } else { - jww.FEEDBACK.Printf("Hugo Static Site Generator v%s-%s %s/%s BuildDate: %s\n", helpers.CurrentHugoVersion, strings.ToUpper(hugolib.CommitHash), runtime.GOOS, runtime.GOARCH, hugolib.BuildDate) - } -} diff --git a/common/collections/append.go b/common/collections/append.go new file mode 100644 index 000000000..db9db8bf3 --- /dev/null +++ b/common/collections/append.go @@ -0,0 +1,152 @@ +// 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 ( + "fmt" + "reflect" +) + +// 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 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 + } + + // 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) + } + + } + } + } + + if toIsNil { + return Slice(from...), nil + } + + for _, f := range from { + fv := reflect.ValueOf(f) + if !fv.IsValid() || !fv.Type().AssignableTo(tot) { + // Fall back to a []interface{} slice. + tov, _ := indirect(reflect.ValueOf(to)) + return appendToInterfaceSlice(tov, from...) + } + tov = reflect.Append(tov, fv) + } + + return tov.Interface(), nil +} + +func appendToInterfaceSliceFromValues(slice1, slice2 reflect.Value) ([]any, error) { + var tos []any + + for _, slice := range []reflect.Value{slice1, slice2} { + if !slice.IsValid() { + tos = append(tos, nil) + continue + } + for i := range slice.Len() { + tos = append(tos, slice.Index(i).Interface()) + } + } + + return tos, nil +} + +func appendToInterfaceSlice(tov reflect.Value, from ...any) ([]any, error) { + var tos []any + + for i := range tov.Len() { + tos = append(tos, tov.Index(i).Interface()) + } + + tos = append(tos, from...) + + return tos, nil +} + +// indirect is borrowed from the Go stdlib: 'text/template/exec.go' +// TODO(bep) consolidate +func indirect(v reflect.Value) (rv reflect.Value, isNil bool) { + for ; v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface; v = v.Elem() { + if v.IsNil() { + return v, true + } + if v.Kind() == reflect.Interface && v.NumMethod() > 0 { + break + } + } + return v, false +} diff --git a/common/collections/append_test.go b/common/collections/append_test.go new file mode 100644 index 000000000..62d9015ce --- /dev/null +++ b/common/collections/append_test.go @@ -0,0 +1,213 @@ +// 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 collections + +import ( + "html/template" + "reflect" + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestAppend(t *testing.T) { + t.Parallel() + c := qt.New(t) + + for i, test := range []struct { + start any + addend []any + expected any + }{ + {[]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"}}, + []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 + {"", []any{[]string{"a", "b"}}, false}, + // No string concatenation. + { + "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...) + + if b, ok := test.expected.(bool); ok && !b { + + c.Assert(err, qt.Not(qt.IsNil)) + continue + } + + c.Assert(err, qt.IsNil) + 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 new file mode 100644 index 000000000..0b46abee9 --- /dev/null +++ b/common/collections/collections.go @@ -0,0 +1,21 @@ +// 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 collections contains common Hugo functionality related to collection +// handling. +package collections + +// Grouper defines a very generic way to group items by a given key. +type Grouper interface { + 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 new file mode 100644 index 000000000..731f489f9 --- /dev/null +++ b/common/collections/slice.go @@ -0,0 +1,95 @@ +// 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 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 any) (any, error) +} + +// Slice returns a slice of all passed arguments. +func Slice(args ...any) any { + if len(args) == 0 { + return args + } + + first := args[0] + firstType := reflect.TypeOf(first) + + if firstType == nil { + return args + } + + if g, ok := first.(Slicer); ok { + v, err := g.Slice(args) + if err == nil { + return v + } + + // If Slice fails, the items are not of the same type and + // []interface{} is the best we can do. + return args + } + + if len(args) > 1 { + // This can be a mix of types. + for i := 1; i < len(args); i++ { + if firstType != reflect.TypeOf(args[i]) { + // []interface{} is the best we can do + return args + } + } + } + + slice := reflect.MakeSlice(reflect.SliceOf(firstType), len(args), len(args)) + for i, arg := range args { + slice.Index(i).Set(reflect.ValueOf(arg)) + } + 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 new file mode 100644 index 000000000..4008a5e6c --- /dev/null +++ b/common/collections/slice_test.go @@ -0,0 +1,172 @@ +// 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 ( + "errors" + "testing" + + qt "github.com/frankban/quicktest" +) + +var ( + _ Slicer = (*tstSlicer)(nil) + _ Slicer = (*tstSlicerIn1)(nil) + _ Slicer = (*tstSlicerIn2)(nil) + _ testSlicerInterface = (*tstSlicerIn1)(nil) + _ testSlicerInterface = (*tstSlicerIn1)(nil) +) + +type testSlicerInterface interface { + Name() string +} + +type testSlicerInterfaces []testSlicerInterface + +type tstSlicerIn1 struct { + TheName string +} + +type tstSlicerIn2 struct { + TheName string +} + +type tstSlicer struct { + TheName string +} + +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) { + case testSlicerInterface: + result[i] = vv + default: + return nil, errors.New("invalid type") + } + } + return result, nil +} + +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) { + case testSlicerInterface: + result[i] = vv + default: + return nil, errors.New("invalid type") + } + } + return result, nil +} + +func (p *tstSlicerIn1) Name() string { + return p.TheName +} + +func (p *tstSlicerIn2) Name() string { + return p.TheName +} + +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) { + case *tstSlicer: + result[i] = vv + default: + return nil, errors.New("invalid type") + } + } + return result, nil +} + +type tstSlicers []*tstSlicer + +func TestSlice(t *testing.T) { + t.Parallel() + c := qt.New(t) + + for i, test := range []struct { + args []any + expected any + }{ + {[]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) + + result := Slice(test.args...) + + 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 new file mode 100644 index 000000000..acaebb4bc --- /dev/null +++ b/common/herrors/error_locator.go @@ -0,0 +1,170 @@ +// 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 contains common Hugo errors and error related utilities. +package herrors + +import ( + "io" + "path/filepath" + "strings" + + "github.com/gohugoio/hugo/common/text" +) + +// LineMatcher contains the elements used to match an error to a line +type LineMatcher struct { + Position text.Position + Error error + + LineNumber int + Offset int + Line string +} + +// LineMatcherFn is used to match a line with an error. +// 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) int { + if m.Position.LineNumber == m.LineNumber { + // We found the line, but don't know the column. + return 0 + } + return -1 +} + +// 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 + + // The position of the error in the Lines above. 0 based. + LinesPos int + + // 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 +} + +func chromaLexerFromType(fileType string) string { + switch fileType { + case "html", "htm": + return "go-html-template" + } + return fileType +} + +func extNoDelimiter(filename string) string { + return strings.TrimPrefix(filepath.Ext(filename), ".") +} + +func chromaLexerFromFilename(filename string) string { + if strings.Contains(filename, "layouts") { + return "go-html-template" + } + + ext := extNoDelimiter(filename) + return chromaLexerFromType(ext) +} + +func locateErrorInString(src string, matcher LineMatcherFn) *ErrorContext { + return locateError(strings.NewReader(src), &fileError{}, matcher) +} + +func locateError(r io.Reader, le FileError, matches LineMatcherFn) *ErrorContext { + if le == nil { + panic("must provide an error") + } + + ectx := &ErrorContext{LinesPos: -1, Position: text.Position{Offset: -1}} + + b, err := io.ReadAll(r) + if err != nil { + return ectx + } + + lines := strings.Split(string(b), "\n") + + lineNo := 0 + posBytes := 0 + + for li, line := range lines { + lineNo = li + 1 + m := LineMatcher{ + Position: le.Position(), + Error: le, + LineNumber: lineNo, + Offset: posBytes, + Line: line, + } + v := matches(m) + if ectx.LinesPos == -1 && v != -1 { + ectx.Position.LineNumber = lineNo + ectx.Position.ColumnNumber = v + break + } + + posBytes += len(line) + } + + if ectx.Position.LineNumber > 0 { + low := max(ectx.Position.LineNumber-3, 0) + + if ectx.Position.LineNumber > 2 { + ectx.LinesPos = 2 + } else { + ectx.LinesPos = ectx.Position.LineNumber - 1 + } + + high := min(ectx.Position.LineNumber+2, len(lines)) + + ectx.Lines = lines[low:high] + + } + + return ectx +} diff --git a/common/herrors/error_locator_test.go b/common/herrors/error_locator_test.go new file mode 100644 index 000000000..62f15213d --- /dev/null +++ b/common/herrors/error_locator_test.go @@ -0,0 +1,152 @@ +// 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 contains common Hugo errors and error related utilities. +package herrors + +import ( + "strings" + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestErrorLocator(t *testing.T) { + c := qt.New(t) + + lineMatcher := func(m LineMatcher) int { + if strings.Contains(m.Line, "THEONE") { + return 1 + } + return -1 + } + + lines := `LINE 1 +LINE 2 +LINE 3 +LINE 4 +This is THEONE +LINE 6 +LINE 7 +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"}) + + c.Assert(pos.LineNumber, qt.Equals, 5) + c.Assert(location.LinesPos, qt.Equals, 2) + + 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) + 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 = 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 = locate(`L1 +This THEONE +`, lineMatcher) + c.Assert(location.Lines, qt.DeepEquals, []string{"L1", "This THEONE", ""}) + c.Assert(location.LinesPos, qt.Equals, 1) + + location = locate(`L1 +L2 +This THEONE +`, lineMatcher) + c.Assert(location.Lines, qt.DeepEquals, []string{"L1", "L2", "This THEONE", ""}) + c.Assert(location.LinesPos, qt.Equals, 2) + + location = locateErrorInString("NO MATCH", lineMatcher) + 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) int { + if m.LineNumber == 6 { + return 1 + } + return -1 + } + + location = locateErrorInString(`A +B +C +D +E +F +G +H +I +J`, lineMatcher) + pos = location.Position + + c.Assert(location.Lines, qt.DeepEquals, []string{"D", "E", "F", "G", "H"}) + c.Assert(pos.LineNumber, qt.Equals, 6) + c.Assert(location.LinesPos, qt.Equals, 2) + + // Test match EOF + lineMatcher = func(m LineMatcher) int { + if m.LineNumber == 4 { + return 1 + } + return -1 + } + + location = locateErrorInString(`A +B +C +`, lineMatcher) + + pos = location.Position + + c.Assert(location.Lines, qt.DeepEquals, []string{"B", "C", ""}) + c.Assert(pos.LineNumber, qt.Equals, 4) + c.Assert(location.LinesPos, qt.Equals, 2) + + offsetMatcher := func(m LineMatcher) int { + if m.Offset == 1 { + return 1 + } + return -1 + } + + location = locateErrorInString(`A +B +C +D +E`, offsetMatcher) + + 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 new file mode 100644 index 000000000..c7ee90dd0 --- /dev/null +++ b/common/herrors/errors.go @@ -0,0 +1,187 @@ +// 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 contains common Hugo errors and error related utilities. +package herrors + +import ( + "errors" + "fmt" + "io" + "os" + "regexp" + "runtime" + "runtime/debug" + "strings" + "time" +) + +// PrintStackTrace prints the current stacktrace to w. +func PrintStackTrace(w io.Writer) { + buf := make([]byte, 1<<16) + runtime.Stack(buf, true) + 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 ...any) { + if r := recover(); r != nil { + fmt.Println("ERR:", r) + args = append(args, "stacktrace from panic: \n"+string(debug.Stack()), "\n") + fmt.Println(args...) + } +} + +// 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 = &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 new file mode 100644 index 000000000..38b198656 --- /dev/null +++ b/common/herrors/file_error.go @@ -0,0 +1,430 @@ +// 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 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 +// 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/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, +// execute a template etc. +type FileError interface { + error + + // ErrorContext holds some context information about the error. + ErrorContext() *ErrorContext + + text.Positioner + + // 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 +} + +// 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 + 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) Error() string { + return fmt.Sprintf("%s: %s", e.position, e.causeString()) +} + +func (e *fileError) causeString() string { + if e.cause == nil { + return "" + } + 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 (e *fileError) Unwrap() error { + return e.cause +} + +// 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 { + for err != nil { + switch v := err.(type) { + case FileError: + return v + default: + err = errors.Unwrap(err) + } + } + return nil +} + +// 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) + } + 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) { + switch v := e.(type) { + case *json.UnmarshalTypeError: + return int(v.Offset), "json" + case *json.SyntaxError: + return int(v.Offset), "json" + default: + 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 new file mode 100644 index 000000000..7aca08405 --- /dev/null +++ b/common/herrors/file_error_test.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 herrors + +import ( + "errors" + "fmt" + "strings" + "testing" + + "github.com/gohugoio/hugo/common/text" + + qt "github.com/frankban/quicktest" +) + +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) + + for i, test := range []struct { + in error + offset int + lineNumber int + columnNumber int + }{ + {errors.New("no line number for you"), 0, 1, 1}, + {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(`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 := NewFileErrorFromName(test.in, "test.txt") + + errMsg := qt.Commentf("[%d][%T]", i, got) + + pos := got.Position() + c.Assert(pos.LineNumber, qt.Equals, test.lineNumber, errMsg) + c.Assert(pos.ColumnNumber, qt.Equals, test.columnNumber, errMsg) + 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 new file mode 100644 index 000000000..f70a2691f --- /dev/null +++ b/common/herrors/line_number_extractors.go @@ -0,0 +1,63 @@ +// 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 herrors + +import ( + "regexp" + "strconv" +) + +var lineNumberExtractors = []lineNumberExtractor{ + // Template/shortcode parse errors + newLineNumberErrHandlerFromRegexp(`:(\d+):(\d*):`), + newLineNumberErrHandlerFromRegexp(`:(\d+):`), + + // YAML parse errors + newLineNumberErrHandlerFromRegexp(`line (\d+):`), + + // i18n bundle errors + newLineNumberErrHandlerFromRegexp(`\((\d+),\s(\d*)`), +} + +type lineNumberExtractor func(e error) (int, int) + +func newLineNumberErrHandlerFromRegexp(expression string) lineNumberExtractor { + re := regexp.MustCompile(expression) + return extractLineNo(re) +} + +func extractLineNo(re *regexp.Regexp) lineNumberExtractor { + return func(e error) (int, int) { + if e == nil { + panic("no error") + } + col := 1 + s := e.Error() + m := re.FindStringSubmatch(s) + if len(m) >= 2 { + lno, _ := strconv.Atoi(m[1]) + if len(m) > 2 { + col, _ = strconv.Atoi(m[2]) + } + + if col <= 0 { + col = 1 + } + + return lno, 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 + case reflect.Bool: + truth = val.Bool() + case reflect.Complex64, reflect.Complex128: + truth = val.Complex() != 0 + case reflect.Chan, reflect.Func, reflect.Ptr, reflect.Interface: + truth = !val.IsNil() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + truth = val.Int() != 0 + case reflect.Float32, reflect.Float64: + truth = val.Float() != 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + truth = val.Uint() != 0 + case reflect.Struct: + truth = true // Struct values are always true. + default: + return + } + + return +} + +type methodKey struct { + typ reflect.Type + name string +} + +var methodCache sync.Map + +// GetMethodByName is the same as reflect.Value.MethodByName, but it caches the +// type lookup. +func GetMethodByName(v reflect.Value, name string) reflect.Value { + index := GetMethodIndexByName(v.Type(), name) + + if index == -1 { + return reflect.Value{} + } + + return v.Method(index) +} + +// GetMethodIndexByName returns the index of the method with the given name, or +// -1 if no such method exists. +func GetMethodIndexByName(tp reflect.Type, name string) int { + k := methodKey{tp, name} + v, found := methodCache.Load(k) + if found { + return v.(int) + } + m, ok := tp.MethodByName(name) + index := m.Index + if !ok { + index = -1 + } + methodCache.Store(k, index) + + if !ok { + return -1 + } + + return m.Index +} + +var ( + timeType = reflect.TypeOf((*time.Time)(nil)).Elem() + asTimeProviderType = reflect.TypeOf((*htime.AsTimeProvider)(nil)).Elem() +) + +// IsTime returns whether tp is a time.Time type or if it can be converted into one +// in ToTime. +func IsTime(tp reflect.Type) bool { + if tp == timeType { + return true + } + + if tp.Implements(asTimeProviderType) { + return true + } + return false +} + +// IsValid returns whether v is not nil and a valid value. +func IsValid(v reflect.Value) bool { + if !v.IsValid() { + return false + } + + switch v.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice: + return !v.IsNil() + } + + return true +} + +// AsTime returns v as a time.Time if possible. +// The given location is only used if the value implements AsTimeProvider (e.g. go-toml local). +// A zero Time and false is returned if this isn't possible. +// Note that this function does not accept string dates. +func AsTime(v reflect.Value, loc *time.Location) (time.Time, bool) { + if v.Kind() == reflect.Interface { + return AsTime(v.Elem(), loc) + } + + if v.Type() == timeType { + return v.Interface().(time.Time), true + } + + if v.Type().Implements(asTimeProviderType) { + return v.Interface().(htime.AsTimeProvider).AsTime(loc), true + } + + return time.Time{}, false +} + +// ToSliceAny converts the given value to a slice of any if possible. +func ToSliceAny(v any) ([]any, bool) { + if v == nil { + return nil, false + } + switch vv := v.(type) { + case []any: + return vv, true + default: + vvv := reflect.ValueOf(v) + if vvv.Kind() == reflect.Slice { + out := make([]any, vvv.Len()) + for i := range vvv.Len() { + out[i] = vvv.Index(i).Interface() + } + return out, true + } + } + return nil, false +} + +func CallMethodByName(cxt context.Context, name string, v reflect.Value) []reflect.Value { + fn := v.MethodByName(name) + var args []reflect.Value + tp := fn.Type() + if tp.NumIn() > 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 { + return v + } + if v.IsNil() { + return 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 new file mode 100644 index 000000000..cbcad0f22 --- /dev/null +++ b/common/hreflect/helpers_test.go @@ -0,0 +1,150 @@ +// 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 hreflect + +import ( + "context" + "reflect" + "testing" + "time" + + qt "github.com/frankban/quicktest" +) + +func TestIsTruthful(t *testing.T) { + c := qt.New(t) + + c.Assert(IsTruthful(true), qt.Equals, true) + c.Assert(IsTruthful(false), qt.Equals, false) + c.Assert(IsTruthful(time.Now()), qt.Equals, true) + 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") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + if !IsTruthfulValue(v) { + b.Fatal("not truthful") + } + } +} + +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 new file mode 100644 index 000000000..31d679dfc --- /dev/null +++ b/common/hugio/copy.go @@ -0,0 +1,93 @@ +// 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 hugio + +import ( + "fmt" + "io" + iofs "io/fs" + "path/filepath" + + "github.com/spf13/afero" +) + +// CopyFile copies a file. +func CopyFile(fs afero.Fs, from, to string) error { + sf, err := fs.Open(from) + if err != nil { + return err + } + defer sf.Close() + df, err := fs.Create(to) + if err != nil { + return err + } + defer df.Close() + _, err = io.Copy(df, sf) + 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 := fs.Stat(from) + if err != nil { + return err + } + + if !fi.IsDir() { + return fmt.Errorf("%q is not a directory", from) + } + + err = fs.MkdirAll(to, 0o777) // before umask + if err != nil { + return err + } + + 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()) + if entry.IsDir() { + if shouldCopy != nil && !shouldCopy(fromFilename) { + continue + } + if err := CopyDir(fs, fromFilename, toFilename, shouldCopy); err != nil { + return err + } + } else { + if err := CopyFile(fs, fromFilename, toFilename); err != nil { + return err + } + } + + } + + return nil +} 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 new file mode 100644 index 000000000..c4304c84e --- /dev/null +++ b/common/hugio/readers.go @@ -0,0 +1,106 @@ +// 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 hugio + +import ( + "bytes" + "io" + "strings" +) + +// ReadSeeker wraps io.Reader and io.Seeker. +type ReadSeeker interface { + io.Reader + io.Seeker +} + +// ReadSeekCloser is implemented by afero.File. We use this as the common type for +// content in Resource objects, even for strings. +type ReadSeekCloser interface { + ReadSeeker + io.Closer +} + +// 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 readSeekerNopCloser) Close() error { + return nil +} + +// NewReadSeekerNoOpCloser creates a new ReadSeekerNoOpCloser with the given ReadSeeker. +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) 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 new file mode 100644 index 000000000..6f439cc8b --- /dev/null +++ b/common/hugio/writers.go @@ -0,0 +1,113 @@ +// 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 hugio + +import ( + "io" +) + +// 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 +} + +func (m multiWriteCloser) Close() error { + var err error + for _, c := range m.closers { + if closeErr := c.Close(); closeErr != nil { + err = closeErr + } + } + return err +} + +// NewMultiWriteCloser creates a new io.WriteCloser that duplicates its writes to all the +// provided writers. +func NewMultiWriteCloser(writeClosers ...io.WriteCloser) io.WriteCloser { + writers := make([]io.Writer, len(writeClosers)) + for i, w := range writeClosers { + writers[i] = w + } + return multiWriteCloser{Writer: io.MultiWriter(writers...), closers: writeClosers} +} + +// ToWriteCloser creates an io.WriteCloser from the given io.Writer. +// If it's not already, one will be created with a Close method that does nothing. +func ToWriteCloser(w io.Writer) io.WriteCloser { + if rw, ok := w.(io.WriteCloser); ok { + return rw + } + + return struct { + io.Writer + io.Closer + }{ + w, + io.NopCloser(nil), + } +} + +// ToReadCloser creates an io.ReadCloser from the given io.Reader. +// If it's not already, one will be created with a Close method that does nothing. +func ToReadCloser(r io.Reader) io.ReadCloser { + if rc, ok := r.(io.ReadCloser); ok { + return rc + } + + return struct { + io.Reader + io.Closer + }{ + r, + 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 new file mode 100644 index 000000000..764a86a97 --- /dev/null +++ b/common/hugo/hugo.go @@ -0,0 +1,467 @@ +// 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 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 ( + EnvironmentDevelopment = "development" + EnvironmentProduction = "production" +) + +var ( + // buildDate allows vendor-specified build date when .git/ is unavailable. + buildDate string + // vendorInfo contains vendor notes about the current build. + vendorInfo string +) + +var _ maps.StoreProvider = (*HugoInfo)(nil) + +// HugoInfo contains information about the current Hugo environment +type HugoInfo struct { + CommitHash string + BuildDate string + + // The build environment. + // Defaults are "production" (hugo) and "development" (hugo server). + // 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 HugoInfo) Version() VersionString { + return CurrentVersion.Version() +} + +// Generator a Hugo meta generator HTML tag. +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(conf ConfigProvider, deps []*Dependency) HugoInfo { + if conf.Environment() == "" { + panic("environment not set") + } + 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: 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 new file mode 100644 index 000000000..f938073da --- /dev/null +++ b/common/hugo/hugo_test.go @@ -0,0 +1,111 @@ +// 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 hugo + +import ( + "context" + "fmt" + "testing" + + "github.com/bep/logg" + qt "github.com/frankban/quicktest" +) + +func TestHugoInfo(t *testing.T) { + c := qt.New(t) + + 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.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 new file mode 100644 index 000000000..ab01e2647 --- /dev/null +++ b/common/hugo/vars_extended.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. + +//go:build extended + +package hugo + +var IsExtended = true diff --git a/common/hugo/vars_regular.go b/common/hugo/vars_regular.go new file mode 100644 index 000000000..a78aeb0b6 --- /dev/null +++ b/common/hugo/vars_regular.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. + +//go:build !extended + +package hugo + +var IsExtended = false 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 new file mode 100644 index 000000000..cf5988840 --- /dev/null +++ b/common/hugo/version.go @@ -0,0 +1,305 @@ +// 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 hugo + +import ( + "fmt" + "io" + "math" + "runtime" + "strconv" + "strings" + + "github.com/gohugoio/hugo/compare" + "github.com/spf13/cast" +) + +// Version represents the Hugo build version. +type Version struct { + Major int + + Minor int + + // Increment this for bug releases + PatchLevel int + + // HugoVersionSuffix is the suffix used in the Hugo version string. + // It will be blank for release versions. + Suffix string +} + +var ( + _ compare.Eqer = (*VersionString)(nil) + _ compare.Comparer = (*VersionString)(nil) +) + +func (v Version) String() string { + return version(v.Major, v.Minor, v.PatchLevel, v.Suffix) +} + +// Version returns the Hugo version. +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 + +func (h VersionString) String() string { + return string(h) +} + +// Compare implements the compare.Comparer interface. +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 any) bool { + s, err := cast.ToStringE(other) + if err != nil { + return false + } + return s == h.String() +} + +var versionSuffixes = []string{"-test", "-DEV"} + +// ParseVersion parses a version string. +func ParseVersion(s string) (Version, error) { + var vv Version + for _, suffix := range versionSuffixes { + if strings.HasSuffix(s, suffix) { + vv.Suffix = suffix + s = strings.TrimSuffix(s, suffix) + } + } + + vv.Major, vv.Minor, vv.PatchLevel = parseVersion(s) + + return vv, nil +} + +// MustParseVersion parses a version string +// and panics if any error occurs. +func MustParseVersion(s string) Version { + vv, err := ParseVersion(s) + if err != nil { + panic(err) + } + return vv +} + +// ReleaseVersion represents the release version. +func (v Version) ReleaseVersion() Version { + v.Suffix = "" + return v +} + +// Next returns the next Hugo release version. +func (v Version) Next() Version { + return Version{Major: v.Major, Minor: v.Minor + 1} +} + +// Prev returns the previous Hugo release version. +func (v Version) Prev() Version { + 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 { + 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" + + version := "v" + CurrentVersion.String() + + bi := getBuildInfo() + if bi == nil { + return version + } + if bi.Revision != "" { + version += "-" + bi.Revision + } + if IsExtended { + version += "+extended" + } + if IsWithdeploy { + version += "+withdeploy" + } + + osArch := bi.GoOS + "/" + bi.GoArch + + date := bi.RevisionTime + if date == "" { + // Accept vendor-specified build date if .git/ is unavailable. + date = buildDate + } + if date == "" { + date = "unknown" + } + + versionString := fmt.Sprintf("%s %s %s BuildDate=%s", + program, version, osArch, date) + + if vendorInfo != "" { + versionString += " VendorInfo=" + vendorInfo + } + + return versionString +} + +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("%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 any) int { + return compareVersions(CurrentVersion, version) +} + +func compareVersions(inVersion Version, in any) int { + var c int + switch d := in.(type) { + case float64: + c = compareFloatWithVersion(d, inVersion) + case float32: + c = compareFloatWithVersion(float64(d), inVersion) + case int: + c = compareFloatWithVersion(float64(d), inVersion) + case int32: + c = compareFloatWithVersion(float64(d), inVersion) + case int64: + 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 { + return -1 + } + + v, err := ParseVersion(s) + if err != nil { + return -1 + } + return inVersion.Compare(v) + + } + + return c +} + +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]) + } + + return major, minor, patch +} + +// 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 v1maj > v2.Major { + return 1 + } + + if v1maj < v2.Major { + return -1 + } + + if v1min > v2.Minor { + return 1 + } + + return -1 +} + +func GoMinorVersion() int { + return goMinorVersion(runtime.Version()) +} + +func goMinorVersion(version string) int { + if strings.HasPrefix(version, "devel") { + return 9999 // magic + } + 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 new file mode 100644 index 000000000..ba367ceb5 --- /dev/null +++ b/common/hugo/version_current.go @@ -0,0 +1,23 @@ +// 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 hugo + +// CurrentVersion represents the current build version. +// This should be the only one. +var CurrentVersion = Version{ + Major: 0, + Minor: 148, + PatchLevel: 0, + Suffix: "-DEV", +} diff --git a/common/hugo/version_test.go b/common/hugo/version_test.go new file mode 100644 index 000000000..33e50ebf5 --- /dev/null +++ b/common/hugo/version_test.go @@ -0,0 +1,88 @@ +// 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 hugo + +import ( + "testing" + + qt "github.com/frankban/quicktest" +) + +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") + + v := Version{Minor: 21, Suffix: "-DEV"} + + c.Assert(v.ReleaseVersion().String(), qt.Equals, "0.21") + c.Assert(v.String(), qt.Equals, "0.21-DEV") + c.Assert(v.Next().String(), qt.Equals, "0.22") + nextVersionString := v.Next().Version() + c.Assert(nextVersionString.String(), qt.Equals, "0.22") + c.Assert(nextVersionString.Eq("0.22"), qt.Equals, true) + c.Assert(nextVersionString.Eq("0.21"), qt.Equals, false) + c.Assert(nextVersionString.Eq(nextVersionString), qt.Equals, true) + c.Assert(v.NextPatchLevel(3).String(), qt.Equals, "0.20.3") + + // We started to use full semver versions even for main + // releases in v0.54.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{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(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) { + c := qt.New(t) + + c.Assert(MustParseVersion("0.25").String(), qt.Equals, "0.25") + c.Assert(MustParseVersion("0.25.2").String(), qt.Equals, "0.25.2") + c.Assert(MustParseVersion("0.25-test").String(), qt.Equals, "0.25-test") + c.Assert(MustParseVersion("0.25-DEV").String(), qt.Equals, "0.25-DEV") +} + +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/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 new file mode 100644 index 000000000..f9171ebf2 --- /dev/null +++ b/common/maps/maps.go @@ -0,0 +1,236 @@ +// 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 maps + +import ( + "fmt" + "strings" + + "github.com/gohugoio/hugo/common/types" + + "github.com/gobwas/glob" + "github.com/spf13/cast" +) + +// ToStringMapE converts in to map[string]interface{}. +func ToStringMapE(in any) (map[string]any, error) { + switch vv := in.(type) { + case Params: + 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) + } +} + +// 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 +} + +// KeyRenamer supports renaming of keys in a map. +type KeyRenamer struct { + renames []keyRename +} + +// NewKeyRenamer creates a new KeyRenamer given a list of pattern and new key +// value pairs. +func NewKeyRenamer(patternKeys ...string) (KeyRenamer, error) { + var renames []keyRename + for i := 0; i < len(patternKeys); i += 2 { + g, err := glob.Compile(strings.ToLower(patternKeys[i]), '/') + if err != nil { + return KeyRenamer{}, err + } + renames = append(renames, keyRename{pattern: g, newKey: patternKeys[i+1]}) + } + + return KeyRenamer{renames: renames}, nil +} + +func (r KeyRenamer) getNewKey(keyPath string) string { + for _, matcher := range r.renames { + if matcher.pattern.Match(keyPath) { + return matcher.newKey + } + } + + return "" +} + +// Rename renames the keys in the given map according +// to the patterns in the current KeyRenamer. +func (r KeyRenamer) Rename(m map[string]any) { + r.renamePath("", m) +} + +func (KeyRenamer) keyPath(k1, k2 string) string { + k1, k2 = strings.ToLower(k1), strings.ToLower(k2) + if k1 == "" { + return k2 + } + return k1 + "/" + k2 +} + +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, 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_test.go b/common/maps/maps_test.go new file mode 100644 index 000000000..40c8ac824 --- /dev/null +++ b/common/maps/maps_test.go @@ -0,0 +1,201 @@ +// 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 maps + +import ( + "fmt" + "reflect" + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestPrepareParams(t *testing.T) { + tests := []struct { + input Params + expected Params + }{ + { + map[string]any{ + "abC": 32, + }, + Params{ + "abc": 32, + }, + }, + { + map[string]any{ + "abC": 32, + "deF": map[any]any{ + 23: "A value", + 24: map[string]any{ + "AbCDe": "A value", + "eFgHi": "Another value", + }, + }, + "gHi": map[string]any{ + "J": 25, + }, + "jKl": map[string]string{ + "M": "26", + }, + }, + Params{ + "abc": 32, + "def": Params{ + "23": "A value", + "24": Params{ + "abcde": "A value", + "efghi": "Another value", + }, + }, + "ghi": Params{ + "j": 25, + }, + "jkl": Params{ + "m": "26", + }, + }, + }, + } + + for i, test := range tests { + t.Run(fmt.Sprint(i), func(t *testing.T) { + // 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]any{ + "a": 32, + "ren1": "m1", + "ren2": "m1_2", + "sub": map[string]any{ + "subsub": map[string]any{ + "REN1": "m2", + "ren2": "m2_2", + }, + }, + "no": map[string]any{ + "ren1": "m2", + "ren2": "m2_2", + }, + } + + expected := map[string]any{ + "a": 32, + "new1": "m1", + "new2": "m1_2", + "sub": map[string]any{ + "subsub": map[string]any{ + "new1": "m2", + "ren2": "m2_2", + }, + }, + "no": map[string]any{ + "ren1": "m2", + "ren2": "m2_2", + }, + } + + renamer, err := NewKeyRenamer( + "{ren1,sub/*/ren1}", "new1", + "{Ren2,sub/ren2}", "new2", + ) + c.Assert(err, qt.IsNil) + + renamer.Rename(m) + + 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 new file mode 100644 index 000000000..819f796e4 --- /dev/null +++ b/common/maps/params.go @@ -0,0 +1,384 @@ +// 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 ( + "fmt" + "strings" + + "github.com/spf13/cast" +) + +// Params is a map where all keys are lower case. +type Params map[string]any + +// 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. +// Make all of these methods internal somehow. +func (p Params) GetNested(indices ...string) any { + v, _, _ := getNested(p, indices) + return v +} + +// 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 + } + + 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 { + return v, first, m + } + + switch m2 := v.(type) { + case Params: + return getNested(m2, indices[1:]) + case map[string]any: + return getNested(m2, indices[1:]) + default: + return nil, "", nil + } +} + +// GetNestedParam gets the first match of the keyStr in the candidates given. +// 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) (any, error) { + keyStr = strings.ToLower(keyStr) + + // Try exact match first + for _, m := range candidates { + if v, ok := m[keyStr]; ok { + return v, nil + } + } + + keySegments := strings.Split(keyStr, separator) + for _, m := range candidates { + if v := m.GetNested(keySegments...); v != nil { + return v, nil + } + } + + return nil, nil +} + +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 + } + + first := lookupFn(keySegments[0]) + if first == nil { + return nil, "", nil, nil + } + + if len(keySegments) == 1 { + return first, keySegments[0], nil, nil + } + + switch m := first.(type) { + case map[string]any: + v, key, owner := getNested(m, keySegments[1:]) + return v, key, owner, nil + case Params: + v, key, owner := getNested(m, keySegments[1:]) + return v, key, owner, nil + } + + 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 new file mode 100644 index 000000000..892c77175 --- /dev/null +++ b/common/maps/params_test.go @@ -0,0 +1,169 @@ +// 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 ( + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestGetNestedParam(t *testing.T) { + m := map[string]any{ + "string": "value", + "first": 1, + "with_underscore": 2, + "nested": map[string]any{ + "color": "blue", + "nestednested": map[string]any{ + "color": "green", + }, + }, + } + + c := qt.New(t) + + must := func(keyStr, separator string, candidates ...Params) any { + v, err := GetNestedParam(keyStr, separator, candidates...) + c.Assert(err, qt.IsNil) + return v + } + + c.Assert(must("first", "_", m), qt.Equals, 1) + c.Assert(must("First", "_", m), qt.Equals, 1) + c.Assert(must("with_underscore", "_", m), qt.Equals, 2) + 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 new file mode 100644 index 000000000..cf5231783 --- /dev/null +++ b/common/maps/scratch.go @@ -0,0 +1,160 @@ +// 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 ( + "reflect" + "sort" + "sync" + + "github.com/gohugoio/hugo/common/collections" + "github.com/gohugoio/hugo/common/math" +) + +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]any + mu sync.RWMutex +} + +// 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 any) (string, error) { + var newVal any + c.mu.RLock() + existingAddend, found := c.values[key] + c.mu.RUnlock() + if found { + var err error + + addendV := reflect.TypeOf(existingAddend) + + if addendV.Kind() == reflect.Slice || addendV.Kind() == reflect.Array { + newVal, err = collections.Append(existingAddend, newAddend) + if err != nil { + return "", err + } + } else { + newVal, err = math.DoArithmetic(existingAddend, newAddend, '+') + if err != nil { + return "", err + } + } + } else { + newVal = newAddend + } + c.mu.Lock() + c.values[key] = newVal + c.mu.Unlock() + return "", nil // have to return something to make it work with the Go templates +} + +// 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 any) string { + c.mu.Lock() + c.values[key] = value + c.mu.Unlock() + return "" +} + +// Delete deletes the given key. +func (c *Scratch) Delete(key string) string { + c.mu.Lock() + delete(c.values, key) + c.mu.Unlock() + return "" +} + +// Get returns a value previously set by Add or Set. +func (c *Scratch) Get(key string) any { + c.mu.RLock() + val := c.values[key] + c.mu.RUnlock() + + 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 any) string { + c.mu.Lock() + _, found := c.values[key] + if !found { + c.values[key] = make(map[string]any) + } + + 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) any { + c.mu.RLock() + + if c.values[key] == nil { + c.mu.RUnlock() + return nil + } + + unsortedMap := c.values[key].(map[string]any) + c.mu.RUnlock() + var keys []string + for mapKey := range unsortedMap { + keys = append(keys, mapKey) + } + + sort.Strings(keys) + + sortedArray := make([]any, len(unsortedMap)) + for i, mapKey := range keys { + sortedArray[i] = unsortedMap[mapKey] + } + + return sortedArray +} + +// NewScratch returns a new instance of Scratch. +func NewScratch() *Scratch { + return &Scratch{values: make(map[string]any)} +} diff --git a/common/maps/scratch_test.go b/common/maps/scratch_test.go new file mode 100644 index 000000000..f07169e61 --- /dev/null +++ b/common/maps/scratch_test.go @@ -0,0 +1,221 @@ +// 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 maps + +import ( + "reflect" + "sync" + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestScratchAdd(t *testing.T) { + t.Parallel() + c := qt.New(t) + + scratch := NewScratch() + scratch.Add("int1", 10) + scratch.Add("int1", 20) + scratch.Add("int2", 20) + + c.Assert(scratch.Get("int1"), qt.Equals, int64(30)) + c.Assert(scratch.Get("int2"), qt.Equals, 20) + + scratch.Add("float1", float64(10.5)) + scratch.Add("float1", float64(20.1)) + + c.Assert(scratch.Get("float1"), qt.Equals, float64(30.6)) + + scratch.Add("string1", "Hello ") + scratch.Add("string1", "big ") + scratch.Add("string1", "World!") + + c.Assert(scratch.Get("string1"), qt.Equals, "Hello big World!") + + 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) { + t.Parallel() + c := qt.New(t) + + scratch := NewScratch() + + _, err := scratch.Add("intSlice", []int{1, 2}) + c.Assert(err, qt.IsNil) + _, err = scratch.Add("intSlice", 3) + c.Assert(err, qt.IsNil) + + sl := scratch.Get("intSlice") + expected := []int{1, 2, 3} + + if !reflect.DeepEqual(expected, sl) { + t.Errorf("Slice difference, go %q expected %q", sl, expected) + } + _, err = scratch.Add("intSlice", []int{4, 5}) + + c.Assert(err, qt.IsNil) + + sl = scratch.Get("intSlice") + expected = []int{1, 2, 3, 4, 5} + + if !reflect.DeepEqual(expected, sl) { + t.Errorf("Slice difference, go %q expected %q", sl, expected) + } +} + +// https://github.com/gohugoio/hugo/issues/5275 +func TestScratchAddTypedSliceToInterfaceSlice(t *testing.T) { + t.Parallel() + c := qt.New(t) + + scratch := NewScratch() + 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 +func TestScratchAddDifferentTypedSliceToInterfaceSlice(t *testing.T) { + t.Parallel() + c := qt.New(t) + + scratch := NewScratch() + scratch.Set("slice", []string{"foo"}) + + _, err := scratch.Add("slice", []int{1, 2}) + c.Assert(err, qt.IsNil) + c.Assert(scratch.Get("slice"), qt.DeepEquals, []any{"foo", 1, 2}) +} + +func TestScratchSet(t *testing.T) { + t.Parallel() + c := qt.New(t) + + scratch := NewScratch() + scratch.Set("key", "val") + c.Assert(scratch.Get("key"), qt.Equals, "val") +} + +func TestScratchDelete(t *testing.T) { + t.Parallel() + c := qt.New(t) + + scratch := NewScratch() + scratch.Set("key", "val") + scratch.Delete("key") + scratch.Add("key", "Lucy Parsons") + c.Assert(scratch.Get("key"), qt.Equals, "Lucy Parsons") +} + +// Issue #2005 +func TestScratchInParallel(t *testing.T) { + var wg sync.WaitGroup + scratch := NewScratch() + + key := "counter" + scratch.Set(key, int64(1)) + for i := 1; i <= 10; i++ { + wg.Add(1) + go func(j int) { + for k := range 10 { + newVal := int64(k + j) + + _, err := scratch.Add(key, newVal) + if err != nil { + t.Errorf("Got err %s", err) + } + + scratch.Set(key, newVal) + + val := scratch.Get(key) + + if counter, ok := val.(int64); ok { + if counter < 1 { + t.Errorf("Got %d", counter) + } + } else { + t.Errorf("Got %T", val) + } + } + wg.Done() + }(i) + } + wg.Wait() +} + +func TestScratchGet(t *testing.T) { + t.Parallel() + scratch := NewScratch() + nothing := scratch.Get("nothing") + if nothing != nil { + t.Errorf("Should not return anything, but got %v", nothing) + } +} + +func TestScratchSetInMap(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.SetInMap("key", "abc", "Abc (updated)") + scratch.SetInMap("key", "def", "Def") + 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) { + t.Parallel() + scratch := NewScratch() + nothing := scratch.GetSortedMapValues("nothing") + if nothing != nil { + t.Errorf("Should not return anything, but got %v", nothing) + } +} + +func BenchmarkScratchGet(b *testing.B) { + scratch := NewScratch() + scratch.Add("A", 1) + b.ResetTimer() + for i := 0; i < b.N; i++ { + scratch.Get("A") + } +} diff --git a/common/math/math.go b/common/math/math.go new file mode 100644 index 000000000..f88fbcd9c --- /dev/null +++ b/common/math/math.go @@ -0,0 +1,133 @@ +// 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 math + +import ( + "errors" + "reflect" +) + +// DoArithmetic performs arithmetic operations (+,-,*,/) using reflection to +// determine the type of the two terms. +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 + 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) + } else { + isInt = true + bi = int64(bu) // may overflow + } + 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: + bf = float64(bv.Int()) // may overflow + case reflect.Float32, reflect.Float64: + bf = bv.Float() + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + bf = float64(bv.Uint()) // may overflow + default: + return nil, errors.New("can't apply the operator to the values") + } + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + au = av.Uint() + switch bv.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + bi = bv.Int() + if bi >= 0 { + isUint = true + bu = uint64(bi) + } else { + isInt = true + ai = int64(au) // may overflow + } + case reflect.Float32, reflect.Float64: + isFloat = true + af = float64(au) // may overflow + 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") + } + case reflect.String: + as := av.String() + if bv.Kind() == reflect.String && op == '+' { + bs := bv.String() + return as + bs, nil + } + return nil, errors.New("can't apply the operator to the values") + default: + return nil, errors.New("can't apply the operator to the values") + } + + switch op { + case '+': + if isInt { + return ai + bi, nil + } else if isFloat { + return af + bf, nil + } + return au + bu, nil + case '-': + if isInt { + return ai - bi, nil + } else if isFloat { + return af - bf, nil + } + return au - bu, nil + case '*': + if isInt { + return ai * bi, nil + } else if isFloat { + return af * bf, nil + } + return au * bu, nil + case '/': + if isInt && bi != 0 { + return ai / bi, nil + } else if isFloat && bf != 0 { + return af / bf, nil + } else if isUint && bu != 0 { + return au / bu, nil + } + return nil, errors.New("can't divide the value by 0") + default: + return nil, errors.New("there is no such an operation") + } +} diff --git a/common/math/math_test.go b/common/math/math_test.go new file mode 100644 index 000000000..d75d30a69 --- /dev/null +++ b/common/math/math_test.go @@ -0,0 +1,111 @@ +// 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 math + +import ( + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestDoArithmetic(t *testing.T) { + t.Parallel() + c := qt.New(t) + + for _, test := range []struct { + a any + b any + op rune + 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)}, + {3, 2.0, '+', float64(5)}, + {3, 2.0, '-', float64(1)}, + {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)}, + {uint(3), -2, '+', int64(1)}, + {uint(3), -2, '-', int64(5)}, + {uint(3), -2, '*', int64(-6)}, + {uint(3), -2, '/', int64(-1)}, + {-3, uint(2), '+', int64(-1)}, + {-3, uint(2), '-', int64(-5)}, + {-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)}, + {"foo", "bar", '+', "foobar"}, + {3, 0, '/', false}, + {3.0, 0, '/', false}, + {3, 0.0, '/', false}, + {uint(3), uint(0), '/', false}, + {3, uint(0), '/', false}, + {-3, uint(0), '/', false}, + {uint(3), 0, '/', false}, + {3.0, uint(0), '/', false}, + {uint(3), 0.0, '/', false}, + {3, "foo", '+', false}, + {3.0, "foo", '+', false}, + {uint(3), "foo", '+', false}, + {"foo", 3, '+', false}, + {"foo", "bar", '-', false}, + {3, 2, '%', false}, + } { + result, err := DoArithmetic(test.a, test.b, test.op) + + if b, ok := test.expect.(bool); ok && !b { + c.Assert(err, qt.Not(qt.IsNil)) + continue + } + + c.Assert(err, qt.IsNil) + c.Assert(test.expect, qt.Equals, result) + } +} diff --git a/common/para/para.go b/common/para/para.go new file mode 100644 index 000000000..c323a3073 --- /dev/null +++ b/common/para/para.go @@ -0,0 +1,73 @@ +// 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 para implements parallel execution helpers. +package para + +import ( + "context" + + "golang.org/x/sync/errgroup" +) + +// Workers configures a task executor with the most number of tasks to be executed in parallel. +type Workers struct { + sem chan struct{} +} + +// Runner wraps the lifecycle methods of a new task set. +// +// 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 { + Run(func() error) + Wait() error +} + +type errGroupRunner struct { + *errgroup.Group + w *Workers + ctx context.Context +} + +func (g *errGroupRunner) Run(fn func() error) { + select { + case g.w.sem <- struct{}{}: + case <-g.ctx.Done(): + return + } + + g.Go(func() error { + err := fn() + <-g.w.sem + return err + }) +} + +// New creates a new Workers with the given number of workers. +func New(numWorkers int) *Workers { + return &Workers{ + sem: make(chan struct{}, numWorkers), + } +} + +// Start starts a new Runner. +func (w *Workers) Start(ctx context.Context) (Runner, context.Context) { + g, ctx := errgroup.WithContext(ctx) + return &errGroupRunner{ + Group: g, + ctx: ctx, + w: w, + }, ctx +} diff --git a/common/para/para_test.go b/common/para/para_test.go new file mode 100644 index 000000000..cf24a4e37 --- /dev/null +++ b/common/para/para_test.go @@ -0,0 +1,96 @@ +// 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 para + +import ( + "context" + "runtime" + "sort" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/gohugoio/hugo/htesting" + + qt "github.com/frankban/quicktest" +) + +func TestPara(t *testing.T) { + if runtime.NumCPU() < 4 { + t.Skipf("skip para test, CPU count is %d", runtime.NumCPU()) + } + + // TODO(bep) + if htesting.IsCI() { + t.Skip("skip para test when running on CI") + } + + c := qt.New(t) + + c.Run("Order", func(c *qt.C) { + n := 500 + ints := make([]int, n) + for i := range n { + ints[i] = i + } + + p := New(4) + r, _ := p.Start(context.Background()) + + var result []int + var mu sync.Mutex + for i := range n { + i := i + r.Run(func() error { + mu.Lock() + defer mu.Unlock() + result = append(result, i) + return nil + }) + } + + c.Assert(r.Wait(), qt.IsNil) + c.Assert(result, qt.HasLen, len(ints)) + 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) { + const n = 100 + + p := New(5) + r, _ := p.Start(context.Background()) + + start := time.Now() + + var counter int64 + + for range n { + r.Run(func() error { + atomic.AddInt64(&counter, 1) + time.Sleep(1 * time.Millisecond) + return nil + }) + } + + c.Assert(r.Wait(), qt.IsNil) + c.Assert(counter, qt.Equals, int64(n)) + + 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..1cae710e8 --- /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 typ == "" || 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 new file mode 100644 index 000000000..fef6efce8 --- /dev/null +++ b/common/terminal/colors.go @@ -0,0 +1,74 @@ +// 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 terminal contains helper for the terminal, such as coloring output. +package terminal + +import ( + "fmt" + "os" + "strings" + + isatty "github.com/mattn/go-isatty" +) + +const ( + errorColor = "\033[1;31m%s\033[0m" + warningColor = "\033[0;33m%s\033[0m" + 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 { + fd := f.Fd() + return os.Getenv("TERM") != "dumb" && (isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd)) +} + +// Notice colorizes the string in a noticeable color. +func Notice(s string) string { + return colorize(s, noticeColor) +} + +// Error colorizes the string in a colour that grabs attention. +func Error(s string) string { + return colorize(s, errorColor) +} + +// Warning colorizes the string in a colour that warns. +func Warning(s string) string { + return colorize(s, warningColor) +} + +// colorize s in color. +func colorize(s, color string) string { + s = fmt.Sprintf(color, doublePercent(s)) + return singlePercent(s) +} + +func doublePercent(str string) string { + return strings.Replace(str, "%", "%%", -1) +} + +func singlePercent(str string) string { + return strings.Replace(str, "%%", "%", -1) +} diff --git a/common/text/position.go b/common/text/position.go new file mode 100644 index 000000000..eb9de5624 --- /dev/null +++ b/common/text/position.go @@ -0,0 +1,100 @@ +// 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 text + +import ( + "fmt" + "os" + "strings" + + "github.com/gohugoio/hugo/common/terminal" +) + +// 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 +} + +// Position holds a source position in a text file or stream. +type Position struct { + Filename string // filename, if any + Offset int // byte offset, starting at 0. It's set to -1 if not provided. + LineNumber int // line number, starting at 1 + ColumnNumber int // column number, starting at 1 (character count per line) +} + +func (pos Position) String() string { + if pos.Filename == "" { + pos.Filename = "" + } + return positionStringFormatfunc(pos) +} + +// IsValid returns true if line number is > 0. +func (pos Position) IsValid() bool { + return pos.LineNumber > 0 +} + +var positionStringFormatfunc func(p Position) string + +func createPositionStringFormatter(formatStr string) func(p Position) string { + if formatStr == "" { + formatStr = "\":file::line::col\"" + } + + identifiers := []string{":file", ":line", ":col"} + var identifiersFound []string + + for i := range formatStr { + for _, id := range identifiers { + if strings.HasPrefix(formatStr[i:], id) { + identifiersFound = append(identifiersFound, id) + } + } + } + + replacer := strings.NewReplacer(":file", "%s", ":line", "%d", ":col", "%d") + format := replacer.Replace(formatStr) + + f := func(pos Position) string { + args := make([]any, len(identifiersFound)) + for i, id := range identifiersFound { + switch id { + case ":file": + args[i] = pos.Filename + case ":line": + args[i] = pos.LineNumber + case ":col": + args[i] = pos.ColumnNumber + } + } + + msg := fmt.Sprintf(format, args...) + + if terminal.PrintANSIColors(os.Stdout) { + return terminal.Notice(msg) + } + + return msg + } + + return f +} + +func init() { + positionStringFormatfunc = createPositionStringFormatter(os.Getenv("HUGO_FILE_LOG_FORMAT")) +} diff --git a/common/text/position_test.go b/common/text/position_test.go new file mode 100644 index 000000000..a1f43c5d4 --- /dev/null +++ b/common/text/position_test.go @@ -0,0 +1,32 @@ +// 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 text + +import ( + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestPositionStringFormatter(t *testing.T) { + c := qt.New(t) + + pos := Position{Filename: "/my/file.txt", LineNumber: 12, ColumnNumber: 13, Offset: 14} + + c.Assert(createPositionStringFormatter(":file|:col|:line")(pos), qt.Equals, "/my/file.txt|13|12") + c.Assert(createPositionStringFormatter(":col|:file|:line")(pos), qt.Equals, "13|/my/file.txt|12") + 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 new file mode 100644 index 000000000..de093af0d --- /dev/null +++ b/common/text/transform.go @@ -0,0 +1,78 @@ +// 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 text + +import ( + "strings" + "sync" + "unicode" + + "golang.org/x/text/runes" + "golang.org/x/text/transform" + "golang.org/x/text/unicode/norm" +) + +var accentTransformerPool = &sync.Pool{ + New: func() any { + return transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC) + }, +} + +// RemoveAccents removes all accents from b. +func RemoveAccents(b []byte) []byte { + t := accentTransformerPool.Get().(transform.Transformer) + b, _, _ = transform.Bytes(t, b) + t.Reset() + accentTransformerPool.Put(t) + return b +} + +// RemoveAccentsString removes all accents from s. +func RemoveAccentsString(s string) string { + t := accentTransformerPool.Get().(transform.Transformer) + s, _, _ = transform.String(t, s) + t.Reset() + 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 new file mode 100644 index 000000000..74bb37783 --- /dev/null +++ b/common/text/transform_test.go @@ -0,0 +1,72 @@ +// 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 text + +import ( + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestRemoveAccents(t *testing.T) { + c := qt.New(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 152dc4c41..a335be3b2 100644 --- a/common/types/evictingqueue.go +++ b/common/types/evictingqueue.go @@ -15,50 +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 *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() @@ -66,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 { @@ -78,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 a33f1a344..b93243f3c 100644 --- a/common/types/evictingqueue_test.go +++ b/common/types/evictingqueue_test.go @@ -17,42 +17,45 @@ import ( "sync" "testing" - "github.com/stretchr/testify/require" + qt "github.com/frankban/quicktest" ) func TestEvictingStringQueue(t *testing.T) { - assert := require.New(t) + c := qt.New(t) - queue := NewEvictingStringQueue(3) + queue := NewEvictingQueue[string](3) - assert.Equal("", queue.Peek()) + c.Assert(queue.Peek(), qt.Equals, "") queue.Add("a") queue.Add("b") queue.Add("a") - assert.Equal("b", queue.Peek()) + c.Assert(queue.Peek(), qt.Equals, "b") queue.Add("b") - assert.Equal("b", queue.Peek()) + c.Assert(queue.Peek(), qt.Equals, "b") queue.Add("a") queue.Add("b") - assert.Equal([]string{"b", "a"}, queue.PeekAll()) - assert.Equal("b", queue.Peek()) + c.Assert(queue.Contains("a"), qt.Equals, true) + c.Assert(queue.Contains("foo"), qt.Equals, false) + + c.Assert(queue.PeekAll(), qt.DeepEquals, []string{"b", "a"}) + c.Assert(queue.Peek(), qt.Equals, "b") queue.Add("c") queue.Add("d") // Overflowed, a should now be removed. - assert.Equal([]string{"d", "c", "b"}, queue.PeekAll()) - assert.Len(queue.PeekAllSet(), 3) - assert.True(queue.PeekAllSet()["c"]) + c.Assert(queue.PeekAll(), qt.DeepEquals, []string{"d", "c", "b"}) + c.Assert(len(queue.PeekAllSet()), qt.Equals, 3) + c.Assert(queue.PeekAllSet()["c"], qt.Equals, true) } 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 a5805d07a..7e94c1eea 100644 --- a/common/types/types.go +++ b/common/types/types.go @@ -1,4 +1,4 @@ -// Copyright 2017-present The Hugo Authors. All rights reserved. +// 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. @@ -16,10 +16,34 @@ package types import ( "fmt" + "reflect" + "sync/atomic" "github.com/spf13/cast" ) +// RLocker represents the read locks in sync.RWMutex. +type RLocker interface { + RLock() + 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 @@ -28,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. @@ -41,10 +65,81 @@ func (k KeyValues) String() string { return fmt.Sprintf("%v: %v", k.Key, k.Values) } +// 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} } + +// Zeroer, as implemented by time.Time, will be used by the truth template +// funcs in Hugo (if, with, not, and, or). +type Zeroer interface { + IsZero() bool +} + +// IsNil reports whether v is nil. +func IsNil(v any) bool { + if v == nil { + return true + } + + value := reflect.ValueOf(v) + switch value.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice: + return value.IsNil() + } + + return false +} + +// DevMarker is a marker interface for types that should only be used during +// development. +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 7cec8c0c0..795733047 100644 --- a/common/types/types_test.go +++ b/common/types/types_test.go @@ -16,14 +16,36 @@ package types import ( "testing" - "github.com/stretchr/testify/require" + qt "github.com/frankban/quicktest" ) func TestKeyValues(t *testing.T) { - assert := require.New(t) + c := qt.New(t) kv := NewKeyValuesStrings("key", "a1", "a2") - assert.Equal("key", kv.KeyString()) - assert.Equal([]interface{}{"a1", "a2"}, kv.Values) + c.Assert(kv.KeyString(), qt.Equals, "key") + 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 new file mode 100644 index 000000000..e5804a279 --- /dev/null +++ b/common/urls/ref.go @@ -0,0 +1,22 @@ +// 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 urls + +// RefLinker is implemented by those who support reference linking. +// args must contain a path, but can also point to the target +// language or output format. +type RefLinker interface { + 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 19a5deaa2..fd15bd087 100644 --- a/compare/compare.go +++ b/compare/compare.go @@ -1,4 +1,4 @@ -// Copyright 2017-present The Hugo Authors. All rights reserved. +// 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. @@ -17,7 +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 { + // For internal use. + ProbablyEq(other any) bool } // Comparer can be used to compare two values. @@ -25,5 +34,34 @@ type Eqer 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.go b/compare/compare_strings.go new file mode 100644 index 000000000..1fd954081 --- /dev/null +++ b/compare/compare_strings.go @@ -0,0 +1,113 @@ +// 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 compare + +import ( + "strings" + "unicode" + "unicode/utf8" +) + +// Strings returns an integer comparing two strings lexicographically. +func Strings(s, t string) int { + c := compareFold(s, t) + + if c == 0 { + // "B" and "b" would be the same so we need a tiebreaker. + return strings.Compare(s, t) + } + + return c +} + +// This function is derived from strings.EqualFold in Go's stdlib. +// https://github.com/golang/go/blob/ad4a58e31501bce5de2aad90a620eaecdc1eecb8/src/strings/strings.go#L893 +func compareFold(s, t string) int { + for s != "" && t != "" { + var sr, tr rune + if s[0] < utf8.RuneSelf { + sr, s = rune(s[0]), s[1:] + } else { + r, size := utf8.DecodeRuneInString(s) + sr, s = r, s[size:] + } + if t[0] < utf8.RuneSelf { + tr, t = rune(t[0]), t[1:] + } else { + r, size := utf8.DecodeRuneInString(t) + tr, t = r, t[size:] + } + + if tr == sr { + continue + } + + c := 1 + if tr < sr { + tr, sr = sr, tr + c = -c + } + + // ASCII only. + if tr < utf8.RuneSelf { + if sr >= 'A' && sr <= 'Z' { + if tr <= 'Z' { + // Same case. + return -c + } + + diff := tr - (sr + 'a' - 'A') + + if diff == 0 { + continue + } + + if diff < 0 { + return c + } + + if diff > 0 { + return -c + } + } + } + + // Unicode. + r := unicode.SimpleFold(sr) + for r != sr && r < tr { + r = unicode.SimpleFold(r) + } + + if r == tr { + continue + } + + return -c + } + + if s == "" && t == "" { + return 0 + } + + if s == "" { + return -1 + } + + return 1 +} + +// LessStrings returns whether s is less than t lexicographically. +func LessStrings(s, t string) bool { + return Strings(s, t) < 0 +} diff --git a/compare/compare_strings_test.go b/compare/compare_strings_test.go new file mode 100644 index 000000000..1a5bb0b1a --- /dev/null +++ b/compare/compare_strings_test.go @@ -0,0 +1,82 @@ +// 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 compare + +import ( + "sort" + "strings" + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestCompare(t *testing.T) { + c := qt.New(t) + for _, test := range []struct { + a string + b string + }{ + {"a", "a"}, + {"A", "a"}, + {"Ab", "Ac"}, + {"az", "Za"}, + {"C", "D"}, + {"B", "a"}, + {"C", ""}, + {"", ""}, + {"αβδC", "ΑΒΔD"}, + {"αβδC", "ΑΒΔ"}, + {"αβδ", "ΑΒΔD"}, + {"αβδ", "ΑΒΔ"}, + {"β", "δ"}, + {"好", strings.ToLower("好")}, + } { + + expect := strings.Compare(strings.ToLower(test.a), strings.ToLower(test.b)) + got := compareFold(test.a, test.b) + + c.Assert(got, qt.Equals, expect) + + } +} + +func TestLexicographicSort(t *testing.T) { + c := qt.New(t) + + s := []string{"b", "Bz", "ba", "A", "Ba", "ba"} + + sort.Slice(s, func(i, j int) bool { + return LessStrings(s[i], s[j]) + }) + + 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 new file mode 100644 index 000000000..dd103f27b --- /dev/null +++ b/config/configLoader.go @@ -0,0 +1,231 @@ +// 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 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" +) + +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 + } +} + +// IsValidConfigFilename returns whether filename is one of the supported +// config formats in Hugo. +func IsValidConfigFilename(filename string) bool { + ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), ".")) + 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) { + m, err := readConfig(metadecoders.FormatFromString(configType), []byte(config)) + if err != nil { + return nil, err + } + 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 { + 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) + } + 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]any, error) { + return loadConfigFromFile(fs, filename) +} + +func readConfig(format metadecoders.Format, data []byte) (map[string]any, error) { + m, err := metadecoders.Default.UnmarshalToMap(data, format) + if err != nil { + return nil, err + } + + RenameKeys(m) + + return m, nil +} + +func loadConfigFromFile(fs afero.Fs, filename string) (map[string]any, error) { + m, err := metadecoders.Default.UnmarshalFileToMap(fs, filename) + if err != nil { + return nil, err + } + RenameKeys(m) + 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() { + var err error + keyAliases, err = maps.NewKeyRenamer( + // Before 0.53 we used singular for "menu". + "{menu,languages/*/menu}", "menus", + ) + + if err != nil { + panic(err) + } +} + +// RenameKeys renames config keys in m recursively according to a global Hugo +// alias definition. +func RenameKeys(m map[string]any) { + keyAliases.Rename(m) +} diff --git a/config/configLoader_test.go b/config/configLoader_test.go new file mode 100644 index 000000000..546031334 --- /dev/null +++ b/config/configLoader_test.go @@ -0,0 +1,34 @@ +// 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 ( + "strings" + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestIsValidConfigFileName(t *testing.T) { + c := qt.New(t) + + for _, ext := range ValidConfigFileExtensions { + filename := "config." + ext + c.Assert(IsValidConfigFilename(filename), qt.Equals, true) + c.Assert(IsValidConfigFilename(strings.ToUpper(filename)), qt.Equals, true) + } + + c.Assert(IsValidConfigFilename(""), qt.Equals, false) + c.Assert(IsValidConfigFilename("config.toml.swp"), qt.Equals, false) +} diff --git a/config/configProvider.go b/config/configProvider.go index 471ce9a1d..c21342dce 100644 --- a/config/configProvider.go +++ b/config/configProvider.go @@ -1,4 +1,4 @@ -// Copyright 2017-present The Hugo Authors. All rights reserved. +// 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. @@ -13,15 +13,100 @@ package config +import ( + "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 } + +// GetStringSlicePreserveString returns a string slice from the given config and key. +// It differs from the GetStringSlice method in that if the config value is a string, +// we do not attempt to split it into fields. +func GetStringSlicePreserveString(cfg Provider, key string) []string { + sd := cfg.Get(key) + return types.ToStringSlicePreserveString(sd) +} diff --git a/config/configProvider_test.go b/config/configProvider_test.go new file mode 100644 index 000000000..0afba1e58 --- /dev/null +++ b/config/configProvider_test.go @@ -0,0 +1,35 @@ +// 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 config + +import ( + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestGetStringSlicePreserveString(t *testing.T) { + c := qt.New(t) + cfg := New() + + s := "This is a string" + sSlice := []string{"This", "is", "a", "slice"} + + cfg.Set("s1", s) + cfg.Set("s2", sSlice) + + c.Assert(GetStringSlicePreserveString(cfg, "s1"), qt.DeepEquals, []string{s}) + c.Assert(GetStringSlicePreserveString(cfg, "s2"), qt.DeepEquals, sSlice) + c.Assert(GetStringSlicePreserveString(cfg, "s3"), qt.IsNil) +} 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 new file mode 100644 index 000000000..4dcd63653 --- /dev/null +++ b/config/env.go @@ -0,0 +1,93 @@ +// 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 ( + "os" + "runtime" + "strconv" + "strings" + + "github.com/pbnjay/memory" +) + +const ( + gigabyte = 1 << 30 +) + +// GetNumWorkerMultiplier returns the base value used to calculate the number +// of workers to use for Hugo's parallel execution. +// It returns the value in HUGO_NUMWORKERMULTIPLIER OS env variable if set to a +// positive integer, else the number of logical CPUs. +func GetNumWorkerMultiplier() int { + if gmp := os.Getenv("HUGO_NUMWORKERMULTIPLIER"); gmp != "" { + if p, err := strconv.Atoi(gmp); err == nil && p > 0 { + return p + } + } + 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 { + setEnvVar(oldVars, keyValues[i], keyValues[i+1]) + } +} + +func SplitEnvVar(v string) (string, string) { + name, value, _ := strings.Cut(v, "=") + return name, value +} + +func setEnvVar(vars *[]string, key, value string) { + for i := range *vars { + if strings.HasPrefix((*vars)[i], key+"=") { + (*vars)[i] = key + "=" + value + return + } + } + // New var. + *vars = append(*vars, key+"="+value) +} diff --git a/config/env_test.go b/config/env_test.go new file mode 100644 index 000000000..3c402b9ef --- /dev/null +++ b/config/env_test.go @@ -0,0 +1,32 @@ +// 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 ( + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestSetEnvVars(t *testing.T) { + t.Parallel() + c := qt.New(t) + vars := []string{"FOO=bar", "HUGO=cool", "BAR=foo"} + SetEnvVars(&vars, "HUGO", "rocking!", "NEW", "bar") + c.Assert(vars, qt.DeepEquals, []string{"FOO=bar", "HUGO=rocking!", "BAR=foo", "NEW=bar"}) + + key, val := SplitEnvVar("HUGO=rocks") + c.Assert(key, qt.Equals, "HUGO") + c.Assert(val, qt.Equals, "rocks") +} 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 new file mode 100644 index 000000000..900f73540 --- /dev/null +++ b/config/privacy/privacyConfig.go @@ -0,0 +1,124 @@ +// 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 privacy + +import ( + "github.com/gohugoio/hugo/config" + "github.com/mitchellh/mapstructure" +) + +const privacyConfigKey = "privacy" + +// Service is the common values for a service in a policy definition. +type Service struct { + Disable bool +} + +// Config is a privacy configuration for all the relevant services in Hugo. +type Config struct { + Disqus Disqus + GoogleAnalytics GoogleAnalytics + Instagram Instagram + 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. +type Disqus struct { + Service `mapstructure:",squash"` +} + +// GoogleAnalytics holds the privacy configuration settings related to the Google Analytics template. +type GoogleAnalytics struct { + Service `mapstructure:",squash"` + + // Enabling this will make the GA templates respect the + // "Do Not Track" HTTP header. See https://www.paulfurley.com/google-analytics-dnt/. + RespectDoNotTrack bool +} + +// Instagram holds the privacy configuration settings related to the Instagram shortcode. +type Instagram struct { + Service `mapstructure:",squash"` + + // If simple mode is enabled, a static and no-JS version of the Instagram + // image card will be built. + Simple bool +} + +// Twitter holds the privacy configuration settings related to the Twitter shortcode. +// Deprecated in favor of X in v0.141.0. +type Twitter struct { + Service `mapstructure:",squash"` + + // When set to true, the Tweet and its embedded page on your site are not used + // for purposes that include personalized suggestions and personalized ads. + EnableDNT bool + + // If simple mode is enabled, a static and no-JS version of the Tweet will be built. + Simple bool +} + +// Vimeo holds the privacy configuration settings related to the Vimeo shortcode. +type Vimeo struct { + Service `mapstructure:",squash"` + + // 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 settings related to the YouTube shortcode. +type YouTube struct { + Service `mapstructure:",squash"` + + // When you turn on privacy-enhanced mode, + // YouTube won’t store information about visitors on your website + // unless the user plays the embedded video. + 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) { + return + } + + m := cfg.GetStringMap(privacyConfigKey) + + err = mapstructure.WeakDecode(m, &pc) + + return +} diff --git a/config/privacy/privacyConfig_test.go b/config/privacy/privacyConfig_test.go new file mode 100644 index 000000000..1dd20215b --- /dev/null +++ b/config/privacy/privacyConfig_test.go @@ -0,0 +1,98 @@ +// 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 privacy + +import ( + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/config" +) + +func TestDecodeConfigFromTOML(t *testing.T) { + c := qt.New(t) + + tomlConfig := ` + +someOtherValue = "foo" + +[privacy] +[privacy.disqus] +disable = true +[privacy.googleAnalytics] +disable = true +respectDoNotTrack = true +[privacy.instagram] +disable = true +simple = true +[privacy.x] +disable = true +enableDNT = true +simple = true +[privacy.vimeo] +disable = true +enableDNT = true +simple = true +[privacy.youtube] +disable = true +privacyEnhanced = true +simple = true +` + 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)) + + got := []bool{ + pc.Disqus.Disable, pc.GoogleAnalytics.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) { + c := qt.New(t) + + tomlConfig := ` + +someOtherValue = "foo" + +[Privacy] +[Privacy.YouTube] +PrivacyENhanced = true +` + 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.YouTube.PrivacyEnhanced, qt.Equals, true) +} + +func TestDecodeConfigDefault(t *testing.T) { + c := qt.New(t) + + 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 new file mode 100644 index 000000000..f9d5e1a6e --- /dev/null +++ b/config/services/servicesConfig.go @@ -0,0 +1,110 @@ +// 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 services + +import ( + "github.com/gohugoio/hugo/config" + "github.com/mitchellh/mapstructure" +) + +const ( + servicesConfigKey = "services" + + disqusShortnameKey = "disqusshortname" + googleAnalyticsKey = "googleanalytics" + rssLimitKey = "rssLimit" +) + +// Config is a privacy configuration for all the relevant services in Hugo. +type Config struct { + Disqus Disqus + GoogleAnalytics GoogleAnalytics + Instagram Instagram + Twitter Twitter // deprecated in favor of X in v0.141.0 + X X + RSS RSS +} + +// Disqus holds the functional configuration settings related to the Disqus template. +type Disqus struct { + // A Shortname is the unique identifier assigned to a Disqus site. + Shortname string +} + +// GoogleAnalytics holds the functional configuration settings related to the Google Analytics template. +type GoogleAnalytics struct { + // The GA tracking ID. + ID string +} + +// Instagram holds the functional configuration settings related to the Instagram shortcodes. +type Instagram struct { + // The Simple variant of the Instagram is decorated with Bootstrap 4 card classes. + // 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 + // to disable the inline CSS provided by Hugo. + DisableInlineCSS bool +} + +// X holds the functional configuration settings related to the X shortcodes. +type X struct { + // The Simple variant of X is decorated with a basic set of inline styles. + // 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. + Limit int +} + +// DecodeConfig creates a services Config from a given Hugo configuration. +func DecodeConfig(cfg config.Provider) (c Config, err error) { + m := cfg.GetStringMap(servicesConfigKey) + + err = mapstructure.WeakDecode(m, &c) + + // Keep backwards compatibility. + if c.GoogleAnalytics.ID == "" { + // Try the global config + c.GoogleAnalytics.ID = cfg.GetString(googleAnalyticsKey) + } + if c.Disqus.Shortname == "" { + c.Disqus.Shortname = cfg.GetString(disqusShortnameKey) + } + + 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 new file mode 100644 index 000000000..952a7fe1c --- /dev/null +++ b/config/services/servicesConfig_test.go @@ -0,0 +1,69 @@ +// 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 services + +import ( + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/config" +) + +func TestDecodeConfigFromTOML(t *testing.T) { + c := qt.New(t) + + tomlConfig := ` + +someOtherValue = "foo" + +[services] +[services.disqus] +shortname = "DS" +[services.googleAnalytics] +id = "ga_id" +[services.instagram] +disableInlineCSS = true +[services.twitter] +disableInlineCSS = true +[services.x] +disableInlineCSS = true +` + cfg, err := config.FromConfigString(tomlConfig, "toml") + c.Assert(err, qt.IsNil) + + config, err := DecodeConfig(cfg) + c.Assert(err, qt.IsNil) + c.Assert(config, qt.Not(qt.IsNil)) + + c.Assert(config.Disqus.Shortname, qt.Equals, "DS") + c.Assert(config.GoogleAnalytics.ID, qt.Equals, "ga_id") + + c.Assert(config.Instagram.DisableInlineCSS, qt.Equals, true) +} + +// Support old root-level GA settings etc. +func TestUseSettingsFromRootIfSet(t *testing.T) { + c := qt.New(t) + + cfg := config.New() + cfg.Set("disqusShortname", "root_short") + cfg.Set("googleAnalytics", "ga_root") + + config, err := DecodeConfig(cfg) + c.Assert(err, qt.IsNil) + c.Assert(config, qt.Not(qt.IsNil)) + + c.Assert(config.Disqus.Shortname, qt.Equals, "root_short") + c.Assert(config.GoogleAnalytics.ID, qt.Equals, "ga_root") +} 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 29fe47394..a4661c1ba 100644 --- a/create/content.go +++ b/create/content.go @@ -1,4 +1,4 @@ -// Copyright 2016 The Hugo Authors. All rights reserved. +// 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. @@ -16,129 +16,387 @@ package create import ( "bytes" + "errors" + "fmt" + "io" "os" - "os/exec" "path/filepath" + "strings" + + "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" - jww "github.com/spf13/jwalterweatherman" + "github.com/spf13/afero" ) -// NewContent creates a new content file in the content directory based upon the -// given kind, which is used to lookup an archetype. -func NewContent( - ps *helpers.PathSpec, - siteFactory func(filename string, siteUsed bool) (*hugolib.Site, error), kind, targetPath string) error { - ext := helpers.Ext(targetPath) +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 := findArchetype(ps, kind, ext) +// 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 + cf := hugolib.NewContentFactory(h) - if archetypeFilename != "" { - f, err := ps.Fs.Source.Open(archetypeFilename) + if kind == "" { + var err error + kind, err = cf.SectionFromFilename(targetPath) if err != nil { return err } - defer f.Close() + } - if helpers.ReaderContains(f, []byte(".Site")) { - siteUsed = true + 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, + } + + 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() } - s, err := siteFactory(targetPath, siteUsed) + filename, err := withBuildLock() if err != nil { return err } - var content []byte - - content, err = executeArcheTypeAsTemplate(s, kind, targetPath, archetypeFilename) - if err != nil { - return err - } - - // The site may have multiple content dirs, and we currently do not know which contentDir the - // user wants to create this content in. We should improve on this, but we start by testing if the - // provided path points to an existing dir. If so, use it as is. - var contentPath string - var exists bool - targetDir := filepath.Dir(targetPath) - - if targetDir != "" && targetDir != "." { - exists, _ = helpers.Exists(targetDir, ps.Fs.Source) - } - - if exists { - contentPath = targetPath - } else { - contentPath = s.PathSpec.AbsPathify(filepath.Join(s.Cfg.GetString("contentDir"), targetPath)) - } - - 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 } -// FindArchetype takes a given kind/archetype of content and returns an output -// path for that archetype. If no archetype is found, an empty string is -// returned. -func findArchetype(ps *helpers.PathSpec, kind, ext string) (outpath string) { - search := []string{ps.AbsPathify(ps.Cfg.GetString("archetypeDir"))} +type contentBuilder struct { + archeTypeFs afero.Fs + sourceFs afero.Fs - if ps.Cfg.GetString("theme") != "" { - themeDir := filepath.Join(ps.AbsPathify(ps.Cfg.GetString("themesDir")+"/"+ps.Cfg.GetString("theme")), "/archetypes/") - if _, err := ps.Fs.Source.Stat(themeDir); os.IsNotExist(err) { - jww.ERROR.Printf("Unable to find archetypes directory for theme %q at %q", ps.Cfg.GetString("theme"), themeDir) - } else { - search = append(search, themeDir) - } - } + ps *helpers.PathSpec + h *hugolib.HugoSites + cf hugolib.ContentFactory - for _, x := range search { - // If the new content isn't in a subdirectory, kind == "". - // Therefore it should be excluded otherwise `is a directory` - // error will occur. github.com/gohugoio/hugo/issues/411 - var pathsToCheck = []string{"default"} - - if ext != "" { - if kind != "" { - pathsToCheck = append([]string{kind + ext, "default" + ext}, pathsToCheck...) - } else { - pathsToCheck = append([]string{"default" + ext}, pathsToCheck...) - } - } - - for _, p := range pathsToCheck { - curpath := filepath.Join(x, p) - jww.DEBUG.Println("checking", curpath, "for archetypes") - if exists, _ := helpers.Exists(curpath, ps.Fs.Source); exists { - jww.INFO.Println("curpath: " + curpath) - return curpath - } - } - } - - return "" + // Builder state + archetypeFi hugofs.FileMetaInfo + targetPath string + kind string + isDir bool + dirMap archetypeMap + force bool +} + +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() + + in, err := meta.Open() + if err != nil { + return fmt.Errorf("failed to open non-content file: %w", err) + } + targetFilename := filepath.Join(baseDir, b.targetPath, strings.TrimPrefix(fi.Meta().Filename, b.archetypeFi.Meta().Filename)) + targetDir := filepath.Dir(targetFilename) + + 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 := b.sourceFs.Create(targetFilename) + if err != nil { + return err + } + + _, err = io.Copy(out, in) + if err != nil { + return err + } + + in.Close() + out.Close() + } + + 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 + // These are just copied to destination. + otherFiles []hugofs.FileMetaInfo + // If the templates needs a fully built site. This can potentially be + // expensive, so only do when needed. + siteUsed bool } diff --git a/create/content_template_handler.go b/create/content_template_handler.go deleted file mode 100644 index e9e7cb62b..000000000 --- a/create/content_template_handler.go +++ /dev/null @@ -1,147 +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/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.Site - - // 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, kind, targetPath, archetypeFilename string) ([]byte, error) { - - var ( - archetypeContent []byte - archetypeTemplate []byte - err error - ) - - ps, err := helpers.NewPathSpec(s.Deps.Fs, s.Deps.Cfg) - sp := source.NewSourceSpec(ps, ps.Fs.Source) - if err != nil { - return nil, err - } - f := sp.NewFileInfo("", targetPath, false, nil) - - 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, - } - - if archetypeFilename == "" { - // TODO(bep) archetype revive the issue about wrong tpl funcs arg order - archetypeTemplate = []byte(ArchetypeTemplateTemplate) - } else { - archetypeTemplate, err = afero.ReadFile(s.Fs.Source, archetypeFilename) - if err != nil { - return nil, fmt.Errorf("Failed to read archetype file %q: %s", archetypeFilename, 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.TemplateHandler) - templateName := "_text/" + helpers.Filename(archetypeFilename) - if err := templateHandler.AddTemplate(templateName, string(archetypeTemplate)); err != nil { - return nil, fmt.Errorf("Failed to parse archetype file %q: %s", archetypeFilename, err) - } - - templ := templateHandler.Lookup(templateName) - - var buff bytes.Buffer - if err := templ.Execute(&buff, data); err != nil { - return nil, fmt.Errorf("Failed to process archetype file %q: %s", archetypeFilename, err) - } - - archetypeContent = []byte(archetypeShortcodeReplacementsPost.Replace(buff.String())) - - return archetypeContent, nil - -} diff --git a/create/content_test.go b/create/content_test.go index 62d5ed1da..429edfc26 100644 --- a/create/content_test.go +++ b/create/content_test.go @@ -14,85 +14,150 @@ 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" - "github.com/stretchr/testify/require" ) -func TestNewContent(t *testing.T) { - v := viper.New() - initViper(v) - +// 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 - {"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 } - for _, c := range cases { - cfg, fs := newTestCfg() - ps, err := helpers.NewPathSpec(fs, cfg) - require.NoError(t, err) - h, err := hugolib.NewHugoSites(deps.DepsCfg{Cfg: cfg, Fs: fs}) - require.NoError(t, err) - require.NoError(t, initFs(fs)) + c := qt.New(t) - siteFactory := func(filename string, siteUsed bool) (*hugolib.Site, error) { - return h.Sites[0], nil - } + for i, cas := range cases { + cas := cas - require.NoError(t, create.NewContent(ps, siteFactory, c.kind, c.path)) + c.Run(cas.name, func(c *qt.C) { + c.Parallel() - fname := filepath.Join("content", filepath.FromSlash(c.path)) - content := readFileFromFs(t, fs.Source, fname) - for i, v := range c.expected { - found := strings.Contains(content, v) - if !found { - t.Fatalf("[%d] %q missing from output:\n%q", i, v, content) + mm := afero.NewMemMapFs() + c.Assert(initFs(mm), qt.IsNil) + cfg, fs := newTestCfg(c, mm) + conf := testconfig.GetTestConfigs(fs.Source, cfg) + h, err := hugolib.NewHugoSites(deps.DepsCfg{Configs: conf, Fs: fs}) + c.Assert(err, qt.IsNil) + err = create.NewContent(h, cas.kind, cas.path, false) + + 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(c, fs.Source, fname) + + for _, v := range cas.expected.([]string) { + found := strings.Contains(content, v) + if !found { + c.Fatalf("[%d] %q missing from output:\n%q", i, v, content) + } + } + }) + } } -func initViper(v *viper.Viper) { - v.Set("metaDataFormat", "toml") - v.Set("archetypeDir", "archetypes") - v.Set("contentDir", "content") - v.Set("themesDir", "themes") - v.Set("layoutDir", "layouts") - v.Set("i18nDir", "i18n") - v.Set("theme", "sample") +func TestNewContentFromDirSiteFunction(t *testing.T) { + mm := afero.NewMemMapFs() + c := qt.New(t) + + archetypeDir := filepath.Join("archetypes", "my-bundle") + 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 RegularPages: {{ len site.RegularPages }} + +` + + 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) + + 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", false), qt.IsNil) + cContains(c, readFileFromFs(t, fs.Source, filepath.Join("content", "post/my-post/index.md")), `site RegularPages: 10`) + + // 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`) + + // 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 *hugofs.Fs) error { - perm := os.FileMode(0755) +func initFs(fs afero.Fs) error { + perm := os.FileMode(0o755) var err error // create directories @@ -102,13 +167,22 @@ func initFs(fs *hugofs.Fs) error { filepath.Join("themes", "sample", "archetypes"), } for _, dir := range dirs { - err = fs.Source.Mkdir(dir, perm) - if err != nil { + err = fs.Mkdir(dir, perm) + if err != nil && !os.IsExist(err) { return err } } - // 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 @@ -121,16 +195,49 @@ func initFs(fs *hugofs.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"), content: "+++\ndate =\"\"\ntitle = \"Empty Date Arch title\"\ntest = \"test1\"\n+++\n", }, + { + path: filepath.Join("archetypes", "lang.md"), + content: `Site Lang: {{ site.Language.Lang }}|Name: {{ replace .Name "-" " " | title }}|i18n: {{ T "hugo" }}`, + }, // #3623x { path: filepath.Join("archetypes", "shortcodes.md"), @@ -150,7 +257,7 @@ Some text. `, }, } { - f, err := fs.Source.Create(v.path) + f, err := fs.Create(v.path) if err != nil { return err } @@ -165,8 +272,15 @@ Some text. return nil } +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) if err != nil { @@ -184,15 +298,48 @@ func readFileFromFs(t *testing.T, fs afero.Fs, filename string) string { return string(b) } -func newTestCfg() (*viper.Viper, *hugofs.Fs) { +func newTestCfg(c *qt.C, mm afero.Fs) (config.Provider, *hugofs.Fs) { + cfg := ` - v := viper.New() - fs := hugofs.NewMem(v) +theme = "mytheme" +[languages] +[languages.en] +weight = 1 +languageName = "English" +[languages.nn] +weight = 2 +languageName = "Nynorsk" - v.SetFs(fs.Source) +[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() + } - initViper(v) + mm.MkdirAll(filepath.FromSlash("content_nn"), 0o777) - return v, fs + mm.MkdirAll(filepath.FromSlash("themes/mytheme"), 0o777) + c.Assert(afero.WriteFile(mm, filepath.Join("i18n", "en.toml"), []byte(`[hugo] +other = "Hugo Rocks!"`), 0o755), qt.IsNil) + c.Assert(afero.WriteFile(mm, filepath.Join("i18n", "nn.toml"), []byte(`[hugo] +other = "Hugo Rokkar!"`), 0o755), qt.IsNil) + + c.Assert(afero.WriteFile(mm, "config.toml", []byte(cfg), 0o755), qt.IsNil) + + res, err := allconfig.LoadConfig(allconfig.ConfigSourceDescriptor{Fs: mm, Filename: "config.toml"}) + c.Assert(err, qt.IsNil) + + return res.LoadingInfo.Cfg, hugofs.NewFrom(mm, res.LoadingInfo.BaseConfig) } diff --git a/docs/.gitmodules b/create/skeletons/site/assets/.gitkeep similarity index 100% rename from docs/.gitmodules rename to create/skeletons/site/assets/.gitkeep diff --git a/docs/themes/gohugoioTheme/layouts/partials/head-additions.html b/create/skeletons/site/content/.gitkeep similarity index 100% rename from docs/themes/gohugoioTheme/layouts/partials/head-additions.html rename to create/skeletons/site/content/.gitkeep diff --git a/docs/themes/gohugoioTheme/layouts/partials/svg/exclamation.svg b/create/skeletons/site/data/.gitkeep similarity index 100% rename from docs/themes/gohugoioTheme/layouts/partials/svg/exclamation.svg rename to create/skeletons/site/data/.gitkeep diff --git a/docs/themes/gohugoioTheme/src/js/filesaver.js b/create/skeletons/site/i18n/.gitkeep similarity index 100% rename from docs/themes/gohugoioTheme/src/js/filesaver.js rename to create/skeletons/site/i18n/.gitkeep 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 new file mode 100644 index 000000000..3202a73ea --- /dev/null +++ b/deploy/cloudfront.go @@ -0,0 +1,72 @@ +// 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. + +//go:build withdeploy + +package deploy + +import ( + "context" + "net/url" + "time" + + "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. +// 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(target.CloudFrontDistributionID), + InvalidationBatch: &types.InvalidationBatch{ + CallerReference: aws.String(time.Now().Format("20060102150405")), + Paths: &types.Paths{ + Items: []string{"/*"}, + Quantity: aws.Int32(1), + }, + }, + } + _, err = cf.CreateInvalidation(ctx, req) + return err +} diff --git a/deploy/deploy.go b/deploy/deploy.go new file mode 100644 index 000000000..57e1f41a2 --- /dev/null +++ b/deploy/deploy.go @@ -0,0 +1,763 @@ +// 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. + +//go:build withdeploy + +package deploy + +import ( + "bytes" + "compress/gzip" + "context" + "crypto/md5" + "encoding/hex" + "errors" + "fmt" + "io" + "mime" + "os" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + "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/gohugoio/hugo/deploy/deployconfig" + "github.com/gohugoio/hugo/media" + "github.com/spf13/afero" + "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. +type Deployer struct { + localFs afero.Fs + bucket *blob.Bucket + + 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 +} + +type deploySummary struct { + NumLocal, NumRemote, NumUploads, NumDeletes int +} + +const metaMD5Hash = "md5chksum" // the meta key to store md5hash in + +// 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 *deployconfig.Target + if targetName == "" { + // Default to the first target. + tgt = dcfg.Targets[0] + } else { + for _, t := range dcfg.Targets { + if t.Name == targetName { + tgt = t + } + } + if tgt == nil { + return nil, fmt.Errorf("deployment target %q not found", targetName) + } + } + + return &Deployer{ + localFs: localFs, + target: tgt, + quiet: cfg.BuildExpired(), + mediaTypes: mediaTypes, + cfg: dcfg, + }, nil +} + +func (d *Deployer) openBucket(ctx context.Context) (*blob.Bucket, error) { + if d.bucket != nil { + return d.bucket, nil + } + 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. + 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 + } + d.logger.Infof("Found %d local files.\n", len(local)) + d.summary.NumLocal = len(local) + + // Load remote files from the target. + remote, err := d.walkRemote(ctx, bucket, include, exclude) + if err != nil { + return err + } + 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 := 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 { + d.logger.Println("No changes required.") + } + return nil + } + if !d.quiet { + d.logger.Println(summarizeChanges(uploads, deletes)) + } + + // Ask for confirmation before proceeding. + if d.cfg.Confirm && !d.cfg.DryRun { + fmt.Printf("Continue? (Y/n) ") + var confirm string + if _, err := fmt.Scanln(&confirm); err != nil { + return err + } + if confirm != "" && confirm[0] != 'y' && confirm[0] != 'Y' { + return errors.New("aborted") + } + } + + // 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.cfg.Ordering, uploads) + + nParallel := d.cfg.Workers + var errs []error + var errMu sync.Mutex // protects errs + + for _, uploads := range uploadGroups { + // Short-circuit for an empty group. + if len(uploads) == 0 { + continue + } + + // Within the group, apply uploads in parallel. + sem := make(chan struct{}, nParallel) + for _, upload := range uploads { + if d.cfg.DryRun { + if !d.quiet { + d.logger.Printf("[DRY RUN] Would upload: %v\n", upload) + } + continue + } + + sem <- struct{}{} + go func(upload *fileToUpload) { + if err := d.doSingleUpload(ctx, bucket, upload); err != nil { + errMu.Lock() + defer errMu.Unlock() + errs = append(errs, err) + } + <-sem + }(upload) + } + // Wait for all uploads in the group to finish. + for n := nParallel; n > 0; n-- { + sem <- struct{}{} + } + } + + 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.cfg.DryRun { + if !d.quiet { + d.logger.Printf("[DRY RUN] Would delete %s\n", del) + } + continue + } + sem <- struct{}{} + go func(del string) { + d.logger.Infof("Deleting %s...\n", del) + if err := bucket.Delete(ctx, del); err != nil { + 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) + } + // Wait for all deletes to finish. + for n := nParallel; n > 0; n-- { + sem <- struct{}{} + } + } + + if len(errs) > 0 { + if !d.quiet { + d.logger.Printf("Encountered %d errors.\n", len(errs)) + } + return errs[0] + } + if !d.quiet { + d.logger.Println("Success!") + } + + if d.cfg.InvalidateCDN { + if d.target.CloudFrontDistributionID != "" { + 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 != "" { + 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 + } + } + } + d.logger.Println("Success!") + } + return nil +} + +// summarizeChanges creates a text description of the proposed changes. +func summarizeChanges(uploads []*fileToUpload, deletes []string) string { + uploadSize := int64(0) + for _, u := range uploads { + uploadSize += u.Local.UploadSize + } + return fmt.Sprintf("Identified %d file(s) to upload, totaling %s, and %d file(s) to delete.", len(uploads), humanize.Bytes(uint64(uploadSize)), len(deletes)) +} + +// doSingleUpload executes a single file 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 { + return err + } + r, err := upload.Local.Reader() + if err != nil { + return err + } + defer r.Close() + _, err = io.Copy(w, r) + if err != nil { + return err + } + if err := w.Close(); err != nil { + return err + } + return nil +} + +// localFile represents a local file from the source. Use newLocalFile to +// construct one. +type localFile struct { + // NativePath is the native path to the file (using file.Separator). + NativePath string + // SlashPath is NativePath converted to use /. + SlashPath string + // UploadSize is the size of the content to be uploaded. It may not + // be the same as the local file size if the content will be + // gzipped before upload. + UploadSize int64 + + 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 *deployconfig.Matcher, mt media.Types) (*localFile, error) { + f, err := fs.Open(nativePath) + if err != nil { + return nil, err + } + defer f.Close() + lf := &localFile{ + NativePath: nativePath, + 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 + // in gzipped. The UploadSize is the size of the gzipped content. + gz := gzip.NewWriter(&lf.gzipped) + if _, err := io.Copy(gz, f); err != nil { + return nil, err + } + if err := gz.Close(); err != nil { + return nil, err + } + lf.UploadSize = int64(lf.gzipped.Len()) + } else { + // Raw content. Just get the UploadSize. + info, err := f.Stat() + if err != nil { + return nil, err + } + lf.UploadSize = info.Size() + } + return lf, nil +} + +// Reader returns an io.ReadCloser for reading the content to be uploaded. +// The caller must call Close on the returned ReaderCloser. +// The reader content may not be the same as the local file content due to +// gzipping. +func (lf *localFile) Reader() (io.ReadCloser, error) { + if lf.matcher != nil && lf.matcher.Gzip { + // 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 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. + return lf.fs.Open(lf.NativePath) +} + +// CacheControl returns the Cache-Control header to use for lf, based on the +// first matching matcher (if any). +func (lf *localFile) CacheControl() string { + if lf.matcher == nil { + return "" + } + return lf.matcher.CacheControl +} + +// ContentEncoding returns the Content-Encoding header to use for lf, based +// on the matcher's Content-Encoding and Gzip fields. +func (lf *localFile) ContentEncoding() string { + if lf.matcher == nil { + return "" + } + if lf.matcher.Gzip { + return "gzip" + } + return lf.matcher.ContentEncoding +} + +// ContentType returns the Content-Type header to use for lf. +// It first checks if there's a Content-Type header configured via a matching +// matcher; if not, it tries to generate one based on the filename extension. +// If this fails, the Content-Type will be the empty string. In this case, Go +// Cloud will automatically try to infer a Content-Type based on the file +// content. +func (lf *localFile) ContentType() string { + if lf.matcher != nil && lf.matcher.ContentType != "" { + return lf.matcher.ContentType + } + + 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 +// matching matcher. +func (lf *localFile) Force() bool { + return lf.matcher != nil && lf.matcher.Force +} + +// MD5 returns an MD5 hash of the content to be uploaded. +func (lf *localFile) MD5() []byte { + if len(lf.md5) > 0 { + return lf.md5 + } + h := md5.New() + r, err := lf.Reader() + if err != nil { + return nil + } + defer r.Close() + if _, err := io.Copy(h, r); err != nil { + return nil + } + lf.md5 = h.Sum(nil) + 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 (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 + } + if info.IsDir() { + // Skip hidden directories. + if path != "" && strings.HasPrefix(info.Name(), ".") { + // Except for specific hidden directories + if !knownHiddenDirectory(info.Name()) { + return filepath.SkipDir + } + } + return nil + } + + // .DS_Store is an internal MacOS attribute file; skip it. + if info.Name() == ".DS_Store" { + return nil + } + + // 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) + } + + // 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 (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 { + obj, err := iter.Next(ctx) + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + // 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 + // via a multi-part upload. + // 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 { + var attrMD5 []byte + attrs, err := bucket.Attributes(ctx, obj.Key) + if err == nil { + md5String, exists := attrs.Metadata[metaMD5Hash] + if exists { + attrMD5, _ = hex.DecodeString(md5String) + } + } + 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 + } + return retval, nil +} + +// uploadReason is an enum of reasons why a file must be uploaded. +type uploadReason string + +const ( + reasonUnknown uploadReason = "unknown" + reasonNotFound uploadReason = "not found at target" + reasonForce uploadReason = "--force" + reasonSize uploadReason = "size differs" + reasonMD5Differs uploadReason = "md5 differs" + reasonMD5Missing uploadReason = "remote md5 missing" +) + +// fileToUpload represents a single local file that should be uploaded to +// the target. +type fileToUpload struct { + Local *localFile + Reason uploadReason +} + +func (u *fileToUpload) String() string { + details := []string{humanize.Bytes(uint64(u.Local.UploadSize))} + if s := u.Local.CacheControl(); s != "" { + details = append(details, fmt.Sprintf("Cache-Control: %q", s)) + } + if s := u.Local.ContentEncoding(); s != "" { + details = append(details, fmt.Sprintf("Content-Encoding: %q", s)) + } + if s := u.Local.ContentType(); s != "" { + details = append(details, fmt.Sprintf("Content-Type: %q", s)) + } + return fmt.Sprintf("%s (%s): %v", u.Local.SlashPath, strings.Join(details, ", "), u.Reason) +} + +// 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 (d *Deployer) findDiffs(localFiles map[string]*localFile, remoteFiles map[string]*blob.ListObject, force bool) ([]*fileToUpload, []string) { + var uploads []*fileToUpload + var deletes []string + + found := map[string]bool{} + for path, lf := range localFiles { + upload := false + reason := reasonUnknown + + if remoteFile, ok := remoteFiles[path]; ok { + // The file exists in remote. Let's see if we need to upload it anyway. + + // TODO: We don't register a diff if the metadata (e.g., Content-Type + // header) has changed. This would be difficult/expensive to detect; some + // providers return metadata along with their "List" result, but others + // (notably AWS S3) do not, so gocloud.dev's blob.Bucket doesn't expose + // it in the list result. It would require a separate request per blob + // to fetch. At least for now, we work around this by documenting it and + // providing a "force" flag (to re-upload everything) and a "force" bool + // per matcher (to re-upload all files in a matcher whose headers may have + // changed). + // Idea: extract a sample set of 1 file per extension + 1 file per matcher + // and check those files? + if force { + upload = true + reason = reasonForce + } else if lf.Force() { + upload = true + reason = reasonForce + } else if lf.UploadSize != remoteFile.Size { + upload = true + reason = reasonSize + } else if len(remoteFile.MD5) == 0 { + // This shouldn't happen unless the remote didn't give us an MD5 hash + // from List, AND we failed to compute one by reading the remote file. + // Default to considering the files different. + upload = true + reason = reasonMD5Missing + } else if !bytes.Equal(lf.MD5(), remoteFile.MD5) { + upload = true + reason = reasonMD5Differs + } + found[path] = true + } else { + // The file doesn't exist in remote. + upload = true + reason = reasonNotFound + } + if upload { + d.logger.Debugf("%s needs to be uploaded: %v\n", path, reason) + uploads = append(uploads, &fileToUpload{lf, reason}) + } else { + d.logger.Debugf("%s exists at target and does not need to be uploaded", path) + } + } + + // Remote files that weren't found locally should be deleted. + for path := range remoteFiles { + if !found[path] { + deletes = append(deletes, path) + } + } + return uploads, deletes +} + +// applyOrdering returns an ordered slice of slices of uploads. +// +// The returned slice will have length len(ordering)+1. +// +// The subslice at index i, for i = 0 ... len(ordering)-1, will have all of the +// uploads whose Local.SlashPath matched the regex at ordering[i] (but not any +// previous ordering regex). +// The subslice at index len(ordering) will have the remaining uploads that +// didn't match any ordering regex. +// +// 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 }) + + retval := make([][]*fileToUpload, len(ordering)+1) + for _, u := range uploads { + matched := false + for i, re := range ordering { + if re.MatchString(u.Local.SlashPath) { + retval[i] = append(retval[i], u) + matched = true + break + } + } + if !matched { + retval[len(ordering)] = append(retval[len(ordering)], u) + } + } + return retval +} diff --git a/deploy/deploy_azure.go b/deploy/deploy_azure.go new file mode 100644 index 000000000..b1ce7358c --- /dev/null +++ b/deploy/deploy_azure.go @@ -0,0 +1,21 @@ +// 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. + +//go:build !solaris && withdeploy + +package deploy + +import ( + _ "gocloud.dev/blob" + _ "gocloud.dev/blob/azureblob" // import +) diff --git a/deploy/deploy_test.go b/deploy/deploy_test.go new file mode 100644 index 000000000..bdc8299a0 --- /dev/null +++ b/deploy/deploy_test.go @@ -0,0 +1,1102 @@ +// 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. + +//go:build withdeploy + +package deploy + +import ( + "bytes" + "compress/gzip" + "context" + "crypto/md5" + "fmt" + "io" + "os" + "path" + "path/filepath" + "regexp" + "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" + "gocloud.dev/blob" + "gocloud.dev/blob/fileblob" + "gocloud.dev/blob/memblob" +) + +func TestFindDiffs(t *testing.T) { + hash1 := []byte("hash 1") + hash2 := []byte("hash 2") + makeLocal := func(path string, size int64, hash []byte) *localFile { + return &localFile{NativePath: path, SlashPath: filepath.ToSlash(path), UploadSize: size, md5: hash} + } + makeRemote := func(path string, size int64, hash []byte) *blob.ListObject { + return &blob.ListObject{Key: path, Size: size, MD5: hash} + } + + tests := []struct { + Description string + Local []*localFile + Remote []*blob.ListObject + Force bool + WantUpdates []*fileToUpload + WantDeletes []string + }{ + { + Description: "empty -> no diffs", + }, + { + Description: "local == remote -> no diffs", + Local: []*localFile{ + makeLocal("aaa", 1, hash1), + makeLocal("bbb", 2, hash1), + makeLocal("ccc", 3, hash2), + }, + Remote: []*blob.ListObject{ + makeRemote("aaa", 1, hash1), + makeRemote("bbb", 2, hash1), + makeRemote("ccc", 3, hash2), + }, + }, + { + Description: "local w/ separators == remote -> no diffs", + Local: []*localFile{ + makeLocal(filepath.Join("aaa", "aaa"), 1, hash1), + makeLocal(filepath.Join("bbb", "bbb"), 2, hash1), + makeLocal(filepath.Join("ccc", "ccc"), 3, hash2), + }, + Remote: []*blob.ListObject{ + makeRemote("aaa/aaa", 1, hash1), + makeRemote("bbb/bbb", 2, hash1), + makeRemote("ccc/ccc", 3, hash2), + }, + }, + { + Description: "local == remote with force flag true -> diffs", + Local: []*localFile{ + makeLocal("aaa", 1, hash1), + makeLocal("bbb", 2, hash1), + makeLocal("ccc", 3, hash2), + }, + Remote: []*blob.ListObject{ + makeRemote("aaa", 1, hash1), + makeRemote("bbb", 2, hash1), + makeRemote("ccc", 3, hash2), + }, + Force: true, + WantUpdates: []*fileToUpload{ + {makeLocal("aaa", 1, nil), reasonForce}, + {makeLocal("bbb", 2, nil), reasonForce}, + {makeLocal("ccc", 3, nil), reasonForce}, + }, + }, + { + Description: "local == remote with route.Force true -> diffs", + Local: []*localFile{ + {NativePath: "aaa", SlashPath: "aaa", UploadSize: 1, matcher: &deployconfig.Matcher{Force: true}, md5: hash1}, + makeLocal("bbb", 2, hash1), + }, + Remote: []*blob.ListObject{ + makeRemote("aaa", 1, hash1), + makeRemote("bbb", 2, hash1), + }, + WantUpdates: []*fileToUpload{ + {makeLocal("aaa", 1, nil), reasonForce}, + }, + }, + { + Description: "extra local file -> upload", + Local: []*localFile{ + makeLocal("aaa", 1, hash1), + makeLocal("bbb", 2, hash2), + }, + Remote: []*blob.ListObject{ + makeRemote("aaa", 1, hash1), + }, + WantUpdates: []*fileToUpload{ + {makeLocal("bbb", 2, nil), reasonNotFound}, + }, + }, + { + Description: "extra remote file -> delete", + Local: []*localFile{ + makeLocal("aaa", 1, hash1), + }, + Remote: []*blob.ListObject{ + makeRemote("aaa", 1, hash1), + makeRemote("bbb", 2, hash2), + }, + WantDeletes: []string{"bbb"}, + }, + { + Description: "diffs in size or md5 -> upload", + Local: []*localFile{ + makeLocal("aaa", 1, hash1), + makeLocal("bbb", 2, hash1), + makeLocal("ccc", 1, hash2), + }, + Remote: []*blob.ListObject{ + makeRemote("aaa", 1, nil), + makeRemote("bbb", 1, hash1), + makeRemote("ccc", 1, hash1), + }, + WantUpdates: []*fileToUpload{ + {makeLocal("aaa", 1, nil), reasonMD5Missing}, + {makeLocal("bbb", 2, nil), reasonSize}, + {makeLocal("ccc", 1, nil), reasonMD5Differs}, + }, + }, + { + Description: "mix of updates and deletes", + Local: []*localFile{ + makeLocal("same", 1, hash1), + makeLocal("updated", 2, hash1), + makeLocal("updated2", 1, hash2), + makeLocal("new", 1, hash1), + makeLocal("new2", 2, hash2), + }, + Remote: []*blob.ListObject{ + makeRemote("same", 1, hash1), + makeRemote("updated", 1, hash1), + makeRemote("updated2", 1, hash1), + makeRemote("stale", 1, hash1), + makeRemote("stale2", 1, hash1), + }, + WantUpdates: []*fileToUpload{ + {makeLocal("new", 1, nil), reasonNotFound}, + {makeLocal("new2", 2, nil), reasonNotFound}, + {makeLocal("updated", 2, nil), reasonSize}, + {makeLocal("updated2", 1, nil), reasonMD5Differs}, + }, + WantDeletes: []string{"stale", "stale2"}, + }, + } + + for _, tc := range tests { + t.Run(tc.Description, func(t *testing.T) { + local := map[string]*localFile{} + for _, l := range tc.Local { + local[l.SlashPath] = l + } + remote := map[string]*blob.ListObject{} + for _, r := range tc.Remote { + remote[r.Key] = r + } + 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 != "" { + t.Errorf("updates differ:\n%s", diff) + } + if diff := cmp.Diff(gotDeletes, tc.WantDeletes); diff != "" { + t.Errorf("deletes differ:\n%s", diff) + } + }) + } +} + +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!" + ) + contentBytes := []byte(content) + contentLen := int64(len(contentBytes)) + contentMD5 := md5.Sum(contentBytes) + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + if _, err := gz.Write(contentBytes); err != nil { + t.Fatal(err) + } + gz.Close() + gzBytes := buf.Bytes() + gzLen := int64(len(gzBytes)) + gzMD5 := md5.Sum(gzBytes) + + tests := []struct { + Description string + Path string + Matcher *deployconfig.Matcher + MediaTypesConfig map[string]any + WantContent []byte + WantSize int64 + WantMD5 []byte + WantContentType string // empty string is always OK, since content type detection is OS-specific + WantCacheControl string + WantContentEncoding string + }{ + { + Description: "file with no suffix", + Path: "foo", + WantContent: contentBytes, + WantSize: contentLen, + WantMD5: contentMD5[:], + }, + { + Description: "file with .txt suffix", + Path: "foo.txt", + WantContent: contentBytes, + WantSize: contentLen, + WantMD5: contentMD5[:], + }, + { + Description: "CacheControl from matcher", + Path: "foo.txt", + Matcher: &deployconfig.Matcher{CacheControl: "max-age=630720000"}, + WantContent: contentBytes, + WantSize: contentLen, + WantMD5: contentMD5[:], + WantCacheControl: "max-age=630720000", + }, + { + Description: "ContentEncoding from matcher", + Path: "foo.txt", + Matcher: &deployconfig.Matcher{ContentEncoding: "foobar"}, + WantContent: contentBytes, + WantSize: contentLen, + WantMD5: contentMD5[:], + WantContentEncoding: "foobar", + }, + { + Description: "ContentType from matcher", + Path: "foo.txt", + Matcher: &deployconfig.Matcher{ContentType: "foo/bar"}, + WantContent: contentBytes, + WantSize: contentLen, + WantMD5: contentMD5[:], + WantContentType: "foo/bar", + }, + { + Description: "gzipped content", + Path: "foo.txt", + 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 { + t.Run(tc.Description, func(t *testing.T) { + fs := new(afero.MemMapFs) + if err := afero.WriteFile(fs, tc.Path, []byte(content), os.ModePerm); err != nil { + t.Fatal(err) + } + 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) + } + if got := lf.UploadSize; got != tc.WantSize { + t.Errorf("got size %d want %d", got, tc.WantSize) + } + if got := lf.MD5(); !bytes.Equal(got, tc.WantMD5) { + t.Errorf("got MD5 %x want %x", got, tc.WantMD5) + } + if got := lf.CacheControl(); got != tc.WantCacheControl { + t.Errorf("got CacheControl %q want %q", got, tc.WantCacheControl) + } + if got := lf.ContentEncoding(); got != tc.WantContentEncoding { + t.Errorf("got ContentEncoding %q want %q", got, tc.WantContentEncoding) + } + if tc.WantContentType != "" { + if got := lf.ContentType(); got != tc.WantContentType { + t.Errorf("got ContentType %q want %q", got, tc.WantContentType) + } + } + // Verify the reader last to ensure the previous operations don't + // interfere with it. + r, err := lf.Reader() + if err != nil { + t.Fatal(err) + } + gotContent, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(gotContent, tc.WantContent) { + t.Errorf("got content %q want %q", string(gotContent), string(tc.WantContent)) + } + r.Close() + // Verify we can read again. + r, err = lf.Reader() + if err != nil { + t.Fatal(err) + } + gotContent, err = io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + r.Close() + if !bytes.Equal(gotContent, tc.WantContent) { + t.Errorf("got content %q want %q", string(gotContent), string(tc.WantContent)) + } + }) + } +} + +func TestOrdering(t *testing.T) { + tests := []struct { + Description string + Uploads []string + Ordering []*regexp.Regexp + Want [][]string + }{ + { + Description: "empty", + Want: [][]string{nil}, + }, + { + Description: "no ordering", + Uploads: []string{"c", "b", "a", "d"}, + Want: [][]string{{"a", "b", "c", "d"}}, + }, + { + Description: "one ordering", + Uploads: []string{"db", "c", "b", "a", "da"}, + Ordering: []*regexp.Regexp{regexp.MustCompile("^d")}, + Want: [][]string{{"da", "db"}, {"a", "b", "c"}}, + }, + { + Description: "two orderings", + Uploads: []string{"db", "c", "b", "a", "da"}, + Ordering: []*regexp.Regexp{ + regexp.MustCompile("^d"), + regexp.MustCompile("^b"), + }, + Want: [][]string{{"da", "db"}, {"b"}, {"a", "c"}}, + }, + } + + for _, tc := range tests { + t.Run(tc.Description, func(t *testing.T) { + uploads := make([]*fileToUpload, len(tc.Uploads)) + for i, u := range tc.Uploads { + uploads[i] = &fileToUpload{Local: &localFile{SlashPath: u}} + } + gotUploads := applyOrdering(tc.Ordering, uploads) + var got [][]string + for _, subslice := range gotUploads { + var gotsubslice []string + for _, u := range subslice { + gotsubslice = append(gotsubslice, u.Local.SlashPath) + } + got = append(got, gotsubslice) + } + if diff := cmp.Diff(got, tc.Want); diff != "" { + t.Error(diff) + } + }) + } +} + +type fileData struct { + Name string // name of the file + Contents string // contents of the file +} + +// initLocalFs initializes fs with some test files. +func initLocalFs(ctx context.Context, fs afero.Fs) ([]*fileData, error) { + // The initial local filesystem. + local := []*fileData{ + {"aaa", "aaa"}, + {"bbb", "bbb"}, + {"subdir/aaa", "subdir-aaa"}, + {"subdir/nested/aaa", "subdir-nested-aaa"}, + {"subdir2/bbb", "subdir2-bbb"}, + } + if err := writeFiles(fs, local); err != nil { + return nil, err + } + return local, nil +} + +// fsTest represents an (afero.FS, Go CDK blob.Bucket) against which end-to-end +// tests can be run. +type fsTest struct { + name string + fs afero.Fs + bucket *blob.Bucket +} + +// initFsTests initializes a pair of tests for end-to-end test: +// 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(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 := hugofs.NewBasePathFs(afero.NewOsFs(), tmpfsdir) + filebucket, err := fileblob.OpenBucket(tmpbucketdir, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { filebucket.Close() }) + + tests := []*fsTest{ + {"mem", memfs, membucket}, + {"file", filefs, filebucket}, + } + return tests +} + +// TestEndToEndSync verifies that basic adds, updates, and deletes are working +// correctly. +func TestEndToEndSync(t *testing.T) { + ctx := context.Background() + tests := initFsTests(t) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + local, err := initLocalFs(ctx, test.fs) + if err != nil { + t.Fatal(err) + } + deployer := &Deployer{ + localFs: test.fs, + bucket: test.bucket, + mediaTypes: media.DefaultTypes, + cfg: deployconfig.DeployConfig{Workers: 2, MaxDeletes: -1}, + } + + // Initial deployment should sync remote with local. + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("initial deploy: failed: %v", err) + } + wantSummary := deploySummary{NumLocal: 5, NumRemote: 0, NumUploads: 5, NumDeletes: 0} + if !cmp.Equal(deployer.summary, wantSummary) { + t.Errorf("initial deploy: got %v, want %v", deployer.summary, wantSummary) + } + if diff, err := verifyRemote(ctx, deployer.bucket, local); err != nil { + t.Errorf("initial deploy: failed to verify remote: %v", err) + } else if diff != "" { + t.Errorf("initial deploy: remote snapshot doesn't match expected:\n%v", diff) + } + + // A repeat deployment shouldn't change anything. + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("no-op deploy: %v", err) + } + wantSummary = deploySummary{NumLocal: 5, NumRemote: 5, NumUploads: 0, NumDeletes: 0} + if !cmp.Equal(deployer.summary, wantSummary) { + t.Errorf("no-op deploy: got %v, want %v", deployer.summary, wantSummary) + } + + // Make some changes to the local filesystem: + // 1. Modify file [0]. + // 2. Delete file [1]. + // 3. Add a new file (sorted last). + updatefd := local[0] + updatefd.Contents = "new contents" + deletefd := local[1] + local = append(local[:1], local[2:]...) // removing deleted [1] + newfd := &fileData{"zzz", "zzz"} + local = append(local, newfd) + if err := writeFiles(test.fs, []*fileData{updatefd, newfd}); err != nil { + t.Fatal(err) + } + if err := test.fs.Remove(deletefd.Name); err != nil { + t.Fatal(err) + } + + // A deployment should apply those 3 changes. + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("deploy after changes: failed: %v", err) + } + wantSummary = deploySummary{NumLocal: 5, NumRemote: 5, NumUploads: 2, NumDeletes: 1} + if !cmp.Equal(deployer.summary, wantSummary) { + t.Errorf("deploy after changes: got %v, want %v", deployer.summary, wantSummary) + } + if diff, err := verifyRemote(ctx, deployer.bucket, local); err != nil { + t.Errorf("deploy after changes: failed to verify remote: %v", err) + } else if diff != "" { + t.Errorf("deploy after changes: remote snapshot doesn't match expected:\n%v", diff) + } + + // Again, a repeat deployment shouldn't change anything. + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("no-op deploy: %v", err) + } + wantSummary = deploySummary{NumLocal: 5, NumRemote: 5, NumUploads: 0, NumDeletes: 0} + if !cmp.Equal(deployer.summary, wantSummary) { + t.Errorf("no-op deploy: got %v, want %v", deployer.summary, wantSummary) + } + }) + } +} + +// TestMaxDeletes verifies that the "maxDeletes" flag is working correctly. +func TestMaxDeletes(t *testing.T) { + ctx := context.Background() + tests := initFsTests(t) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + local, err := initLocalFs(ctx, test.fs) + if err != nil { + t.Fatal(err) + } + deployer := &Deployer{ + localFs: test.fs, + bucket: test.bucket, + mediaTypes: media.DefaultTypes, + cfg: deployconfig.DeployConfig{Workers: 2, MaxDeletes: -1}, + } + + // Sync remote with local. + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("initial deploy: failed: %v", err) + } + wantSummary := deploySummary{NumLocal: 5, NumRemote: 0, NumUploads: 5, NumDeletes: 0} + if !cmp.Equal(deployer.summary, wantSummary) { + t.Errorf("initial deploy: got %v, want %v", deployer.summary, wantSummary) + } + + // Delete two files, [1] and [2]. + if err := test.fs.Remove(local[1].Name); err != nil { + t.Fatal(err) + } + if err := test.fs.Remove(local[2].Name); err != nil { + t.Fatal(err) + } + + // A deployment with maxDeletes=0 shouldn't change anything. + deployer.cfg.MaxDeletes = 0 + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("deploy failed: %v", err) + } + wantSummary = deploySummary{NumLocal: 3, NumRemote: 5, NumUploads: 0, NumDeletes: 0} + if !cmp.Equal(deployer.summary, wantSummary) { + t.Errorf("deploy: got %v, want %v", deployer.summary, wantSummary) + } + + // A deployment with maxDeletes=1 shouldn't change anything either. + deployer.cfg.MaxDeletes = 1 + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("deploy failed: %v", err) + } + wantSummary = deploySummary{NumLocal: 3, NumRemote: 5, NumUploads: 0, NumDeletes: 0} + if !cmp.Equal(deployer.summary, wantSummary) { + t.Errorf("deploy: got %v, want %v", deployer.summary, wantSummary) + } + + // A deployment with maxDeletes=2 should make the changes. + deployer.cfg.MaxDeletes = 2 + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("deploy failed: %v", err) + } + wantSummary = deploySummary{NumLocal: 3, NumRemote: 5, NumUploads: 0, NumDeletes: 2} + if !cmp.Equal(deployer.summary, wantSummary) { + t.Errorf("deploy: got %v, want %v", deployer.summary, wantSummary) + } + + // Delete two more files, [0] and [3]. + if err := test.fs.Remove(local[0].Name); err != nil { + t.Fatal(err) + } + if err := test.fs.Remove(local[3].Name); err != nil { + t.Fatal(err) + } + + // A deployment with maxDeletes=-1 should make the changes. + deployer.cfg.MaxDeletes = -1 + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("deploy failed: %v", err) + } + wantSummary = deploySummary{NumLocal: 1, NumRemote: 3, NumUploads: 0, NumDeletes: 2} + if !cmp.Equal(deployer.summary, wantSummary) { + t.Errorf("deploy: got %v, want %v", deployer.summary, wantSummary) + } + }) + } +} + +// 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 := initFsTests(t) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + local, err := initLocalFs(ctx, test.fs) + if err != nil { + t.Fatal(err) + } + deployer := &Deployer{ + 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. + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("initial deploy: failed: %v", err) + } + wantSummary := deploySummary{NumLocal: 5, NumRemote: 0, NumUploads: 5, NumDeletes: 0} + if !cmp.Equal(deployer.summary, wantSummary) { + t.Errorf("initial deploy: got %v, want %v", deployer.summary, wantSummary) + } + + // A repeat deployment shouldn't change anything. + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("no-op deploy: %v", err) + } + wantSummary = deploySummary{NumLocal: 5, NumRemote: 5, NumUploads: 0, NumDeletes: 0} + if !cmp.Equal(deployer.summary, wantSummary) { + t.Errorf("no-op deploy: got %v, want %v", deployer.summary, wantSummary) + } + + // Make an update to the local filesystem, on [1]. + updatefd := local[1] + updatefd.Contents = "new contents" + if err := writeFiles(test.fs, []*fileData{updatefd}); err != nil { + t.Fatal(err) + } + + // A deployment should apply the changes. + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("deploy after changes: failed: %v", err) + } + wantSummary = deploySummary{NumLocal: 5, NumRemote: 5, NumUploads: 1, NumDeletes: 0} + if !cmp.Equal(deployer.summary, wantSummary) { + t.Errorf("deploy after changes: got %v, want %v", deployer.summary, wantSummary) + } + }) + } +} + +// TestMatching verifies that matchers match correctly, and that the Force +// attribute for matcher works. +func TestMatching(t *testing.T) { + ctx := context.Background() + tests := initFsTests(t) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := initLocalFs(ctx, test.fs) + if err != nil { + t.Fatal(err) + } + deployer := &Deployer{ + 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. + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("initial deploy: failed: %v", err) + } + wantSummary := deploySummary{NumLocal: 5, NumRemote: 0, NumUploads: 5, NumDeletes: 0} + if !cmp.Equal(deployer.summary, wantSummary) { + t.Errorf("initial deploy: got %v, want %v", deployer.summary, wantSummary) + } + + // A repeat deployment should upload a single file, the one that matched the Force matcher. + // Note that matching happens based on the ToSlash form, so this matches + // even on Windows. + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("no-op deploy with single force matcher: %v", err) + } + wantSummary = deploySummary{NumLocal: 5, NumRemote: 5, NumUploads: 1, NumDeletes: 0} + if !cmp.Equal(deployer.summary, wantSummary) { + t.Errorf("no-op deploy with single force matcher: got %v, want %v", deployer.summary, wantSummary) + } + + // Repeat with a matcher that should now match 3 files. + 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) + } + wantSummary = deploySummary{NumLocal: 5, NumRemote: 5, NumUploads: 3, NumDeletes: 0} + if !cmp.Equal(deployer.summary, wantSummary) { + t.Errorf("no-op deploy with triple force matcher: got %v, want %v", deployer.summary, wantSummary) + } + }) + } +} + +// writeFiles writes the files in fds to fd. +func writeFiles(fs afero.Fs, fds []*fileData) error { + for _, fd := range fds { + dir := path.Dir(fd.Name) + if dir != "." { + err := fs.MkdirAll(dir, os.ModePerm) + if err != nil { + return err + } + } + f, err := fs.Create(fd.Name) + if err != nil { + return err + } + defer f.Close() + _, err = f.WriteString(fd.Contents) + if err != nil { + return err + } + } + return nil +} + +// verifyRemote that the current contents of bucket matches local. +// It returns an empty string if the contents matched, and a non-empty string +// capturing the diff if they didn't. +func verifyRemote(ctx context.Context, bucket *blob.Bucket, local []*fileData) (string, error) { + var cur []*fileData + iter := bucket.List(nil) + for { + obj, err := iter.Next(ctx) + if err == io.EOF { + break + } + if err != nil { + return "", err + } + contents, err := bucket.ReadAll(ctx, obj.Key) + if err != nil { + return "", err + } + cur = append(cur, &fileData{obj.Key, string(contents)}) + } + if cmp.Equal(cur, local) { + return "", nil + } + diff := "got: \n" + for _, f := range cur { + diff += fmt.Sprintf(" %s: %s\n", f.Name, f.Contents) + } + diff += "want: \n" + for _, f := range local { + diff += fmt.Sprintf(" %s: %s\n", f.Name, f.Contents) + } + 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 new file mode 100644 index 000000000..5b302e95b --- /dev/null +++ b/deploy/google.go @@ -0,0 +1,39 @@ +// 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. + +//go:build withdeploy + +package deploy + +import ( + "context" + "fmt" + "strings" + + "google.golang.org/api/compute/v1" +) + +// Invalidate all of the content in a Google Cloud CDN distribution. +func InvalidateGoogleCloudCDN(ctx context.Context, origin string) error { + parts := strings.Split(origin, "/") + if len(parts) != 2 { + return fmt.Errorf("origin must be /") + } + service, err := compute.NewService(ctx) + if err != nil { + return err + } + rule := &compute.CacheInvalidationRule{Path: "/*"} + _, err = service.UrlMaps.InvalidateCache(parts[0], parts[1], rule).Context(ctx).Do() + return err +} diff --git a/deps/deps.go b/deps/deps.go index fd9635444..d0d6d95fc 100644 --- a/deps/deps.go +++ b/deps/deps.go @@ -1,18 +1,41 @@ package deps import ( - "io/ioutil" - "log" + "context" + "fmt" + "io" "os" + "path/filepath" + "sort" + "strings" + "sync" + "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/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. @@ -20,10 +43,9 @@ import ( // at a given time, i.e. one per Site built. type Deps struct { // The logger to use. - Log *jww.Notepad `json:"-"` + Log loggers.Logger `json:"-"` - // The templates to use. This will usually implement the full tpl.TemplateHandler. - Tmpl tpl.TemplateFinder `json:"-"` + ExecHelper *hexec.Exec // The file systems to use. Fs *hugofs.Fs `json:"-"` @@ -37,171 +59,440 @@ type Deps struct { // The SourceSpec to use SourceSpec *source.SourceSpec `json:"-"` + // The Resource Spec to use + ResourceSpec *resources.Spec + // The configuration to use - Cfg config.Provider `json:"-"` + Conf config.AllProvider `json:"-"` + + // The memory cache to use. + MemCache *dynacache.Cache // The translation func to use - Translate func(translationID string, args ...interface{}) string `json:"-"` + Translate func(ctx context.Context, translationID string, templateData any) string `json:"-"` - Language *helpers.Language + // The site building. + Site page.Site - // All the output formats available for the current site. - OutputFormatsConfig output.Formats + TemplateStore *tplimpl.TemplateStore - templateProvider ResourceProvider - WithTemplate func(templ tpl.TemplateHandler) error `json:"-"` + // Used in tests + OverloadedTemplateFuncs map[string]any - translationProvider ResourceProvider + TranslationProvider ResourceProvider Metrics metrics.Provider + + // BuildStartListeners will be notified before a build starts. + BuildStartListeners *Listeners[any] + + // 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 } -// ResourceProvider is used to create and refresh, and clone resources needed. -type ResourceProvider interface { - Update(deps *Deps) error - Clone(deps *Deps) 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 -// TemplateHandler returns the used tpl.TemplateFinder as tpl.TemplateHandler. -func (d *Deps) TemplateHandler() tpl.TemplateHandler { - return d.Tmpl.(tpl.TemplateHandler) -} - -// 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 err + if err := d.Init(); err != nil { + return nil, err } - if err := d.templateProvider.Update(d); err != nil { - return err + return &d, nil +} + +func (d *Deps) GetTemplateStore() *tplimpl.TemplateStore { + return d.TemplateStore +} + +func (d *Deps) Init() error { + if d.Conf == nil { + panic("conf is nil") } - if th, ok := d.Tmpl.(tpl.TemplateHandler); ok { - th.PrintErrors() + 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 } -// 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 +// 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) + }, + ), ) - if cfg.TemplateProvider == nil { - panic("Must have a TemplateProvider") - } - - if cfg.TranslationProvider == nil { - panic("Must have a TranslationProvider") - } - - if cfg.Language == nil { - panic("Must have a Language") - } - - if logger == nil { - logger = jww.NewNotepad(jww.LevelError, jww.LevelError, os.Stdout, ioutil.Discard, "", log.Ldate|log.Ltime) - } - - if fs == nil { - // Default to the production file system. - fs = hugofs.NewDefault(cfg.Language) - } - - ps, err := helpers.NewPathSpec(fs, cfg.Language) - - if err != nil { - return nil, err - } - - contentSpec, err := helpers.NewContentSpec(cfg.Language) - if err != nil { - return nil, err - } - - sp := source.NewSourceSpec(ps, fs.Source) - - d := &Deps{ - Fs: fs, - Log: logger, - templateProvider: cfg.TemplateProvider, - translationProvider: cfg.TranslationProvider, - WithTemplate: cfg.WithTemplate, - PathSpec: ps, - ContentSpec: contentSpec, - SourceSpec: sp, - Cfg: cfg.Language, - Language: cfg.Language, - } - - if cfg.Cfg.GetBool("templateMetrics") { - d.Metrics = metrics.NewProvider(cfg.Cfg.GetBool("templateMetricsHints")) - } - - return d, nil + return filename, nil } -// ForLanguage creates a copy of the Deps with the language dependent -// parts switched out. -func (d Deps) ForLanguage(l *helpers.Language) (*Deps, error) { - var err error +type globalErrHandler struct { + logger loggers.Logger - d.PathSpec, err = helpers.NewPathSpec(d.Fs, l) - if err != nil { - return nil, err + // 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 } + e.logger.Errorln(err) +} - d.ContentSpec, err = helpers.NewContentSpec(l) - if err != nil { - return nil, 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) } +} - d.Cfg = l - d.Language = l +// Listeners represents an event listener. +type Listeners[T any] struct { + sync.Mutex - if err := d.translationProvider.Clone(&d); err != nil { - return nil, err + // A list of funcs to be notified about an event. + // 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[T]) Add(f func(...T) bool) { + if b == nil { + return } + b.Lock() + defer b.Unlock() + b.listeners = append(b.listeners, f) +} - if err := d.templateProvider.Clone(&d); err != nil { - return nil, err +// Notify executes all listener functions. +func (b *Listeners[T]) Notify(vs ...T) { + b.Lock() + defer b.Unlock() + temp := b.listeners[:0] + for _, notify := range b.listeners { + if !notify(vs...) { + temp = append(temp, notify) + } } + b.listeners = temp +} - return &d, nil +// ResourceProvider is used to create and refresh, and clone resources needed. +type ResourceProvider interface { + NewResource(dst *Deps) error + CloneResource(dst, src *Deps) error +} +func (d *Deps) Close() error { + if d.isClosed { + return nil + } + d.isClosed = true + + if d.MemCache != nil { + d.MemCache.Stop() + } + if d.WasmDispatchers != nil { + d.WasmDispatchers.Close() + } + 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 *jww.Notepad + // 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 *helpers.Language + // The Site in use + Site page.Site - // The configuration to use. - Cfg config.Provider + Configs *allconfig.Configs // Template handling. TemplateProvider ResourceProvider - WithTemplate func(templ tpl.TemplateHandler) error // 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 +} + +// 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 +} + +// 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 new file mode 100644 index 000000000..e92ed2327 --- /dev/null +++ b/deps/deps_test.go @@ -0,0 +1,31 @@ +// 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 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 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/.editorconfig b/docs/.editorconfig new file mode 100644 index 000000000..dd2a0096f --- /dev/null +++ b/docs/.editorconfig @@ -0,0 +1,20 @@ +# https://editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +trim_trailing_whitespace = true + +[*.go] +indent_size = 8 +indent_style = tab + +[*.js] +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false 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 new file mode 100644 index 000000000..1e72eb329 --- /dev/null +++ b/docs/.github/stale.yml @@ -0,0 +1,22 @@ +# 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 + - UndocumentedFeature +# 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 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/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 190dfb1ab..5208c5c3a 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -1,4 +1,12 @@ -/.idea -/public -nohup.out .DS_Store +.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 5f47e8a1e..58d0e748c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,45 +1,27 @@ -# Hugo Docs +Hugo -Documentation site for [Hugo](https://github.com/gohugoio/hugo), the very fast and flexible static site generator built with love in GoLang. +A fast and flexible static site generator built with love by [bep], [spf13], and [friends] in [Go]. -## Contributing +--- -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. +[![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/) -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. +This is the repository for the [Hugo](https://github.com/gohugoio/hugo) documentation site. -*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.* +Please see the [contributing] section for guidelines, examples, and process. -Spelling fixes are most welcomed, and if you want to contribute longer sections to the documentation, it would be great if you had these in mind when writing: +[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 -* 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 examples, try to find short snippets that teaches people about the concept. If the example is also useful as-is (copy and paste), then great, but 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 an easy to understand and [simple English](https://simple.wikipedia.org/wiki/Basic_English) is good. +# Install -## 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 is 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/archetypes/default.md b/docs/archetypes/default.md index f30f01f74..58a60edc4 100644 --- a/docs/archetypes/default.md +++ b/docs/archetypes/default.md @@ -1,13 +1,6 @@ --- -linktitle: "" -description: "" -godocref: "" -publishdate: "" -lastmod: "" +title: {{ replace .File.ContentBaseName "-" " " | strings.FirstUpper }} +description: categories: [] -tags: [] -weight: 00 -slug: "" -aliases: [] -toc: false ---- \ No newline at end of file +keywords: [] +--- diff --git a/docs/archetypes/functions.md b/docs/archetypes/functions.md index 0a5dd344f..de2d72060 100644 --- a/docs/archetypes/functions.md +++ b/docs/archetypes/functions.md @@ -1,17 +1,11 @@ --- -linktitle: "" -description: "" -godocref: "" -publishdate: "" -lastmod: "" -categories: [functions] -tags: [] -ns: "" -signature: [] -workson: [] -hugoversion: "" -aliases: [] -relatedfuncs: [] -toc: false -deprecated: false ---- \ No newline at end of file +title: {{ replace .File.ContentBaseName "-" " " | title }} +description: +categories: [] +keywords: [] +params: + functions_and_methods: + aliases: [] + returnType: + signatures: [] +--- diff --git a/docs/archetypes/glossary.md b/docs/archetypes/glossary.md new file mode 100644 index 000000000..1eeb4ef4b --- /dev/null +++ b/docs/archetypes/glossary.md @@ -0,0 +1,13 @@ +--- +title: {{ replace .File.ContentBaseName "-" " " }} +params: + reference: +--- + + diff --git a/docs/archetypes/methods.md b/docs/archetypes/methods.md new file mode 100644 index 000000000..944fe527c --- /dev/null +++ b/docs/archetypes/methods.md @@ -0,0 +1,10 @@ +--- +title: {{ replace .File.ContentBaseName "-" " " | title }} +description: +categories: [] +keywords: [] +params: + functions_and_methods: + returnType: + signatures: [] +--- diff --git a/docs/archetypes/news.md b/docs/archetypes/news.md new file mode 100644 index 000000000..04792a152 --- /dev/null +++ b/docs/archetypes/news.md @@ -0,0 +1,7 @@ +--- +title: {{ replace .File.ContentBaseName "-" " " | strings.FirstUpper }} +description: +categories: [] +keywords: [] +publishDate: {{ .Date }} +--- diff --git a/docs/assets/css/components/all.css b/docs/assets/css/components/all.css new file mode 100644 index 000000000..f5002fd50 --- /dev/null +++ b/docs/assets/css/components/all.css @@ -0,0 +1,7 @@ +/* The order of these does not matter. */ +@import "./content.css"; +@import "./fonts.css"; +@import "./helpers.css"; +@import "./shortcodes.css"; +@import "./tableofcontents.css"; +@import "./view-transitions.css"; diff --git a/docs/assets/css/components/chroma.css b/docs/assets/css/components/chroma.css new file mode 100644 index 000000000..9d4c91f7b --- /dev/null +++ b/docs/assets/css/components/chroma.css @@ -0,0 +1,85 @@ +/* Background */ .bg { background-color: var(--color-light); } +/* PreWrapper */ .chroma { background-color: var(--color-light); } +/* Other */ .chroma .x { } +/* Error */ .chroma .err { color: #a61717; background-color: #e3d2d2 } +/* CodeLine */ .chroma .cl { } +/* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; } +/* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; } +/* LineHighlight */ .chroma .hl { background-color: #ffffcc } +/* LineNumbersTable */ .chroma .lnt { white-space: pre; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #7f7f7f } +/* LineNumbers */ .chroma .ln { white-space: pre; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #7f7f7f } +/* Line */ .chroma .line { display: flex; } +/* 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 } +/* Name */ .chroma .n { } +/* NameAttribute */ .chroma .na { color: #008080 } +/* NameBuiltin */ .chroma .nb { color: #999999 } +/* NameBuiltinPseudo */ .chroma .bp { } +/* NameClass */ .chroma .nc { color: #445588; font-weight: bold } +/* NameConstant */ .chroma .no { color: #008080 } +/* NameDecorator */ .chroma .nd { } +/* NameEntity */ .chroma .ni { color: #800080 } +/* NameException */ .chroma .ne { color: #990000; font-weight: bold } +/* NameFunction */ .chroma .nf { color: #990000; font-weight: bold } +/* NameFunctionMagic */ .chroma .fm { } +/* NameLabel */ .chroma .nl { } +/* NameNamespace */ .chroma .nn { color: #555555 } +/* NameOther */ .chroma .nx { } +/* NameProperty */ .chroma .py { } +/* NameTag */ .chroma .nt { color: #000080 } +/* NameVariable */ .chroma .nv { color: #008080 } +/* NameVariableClass */ .chroma .vc { } +/* NameVariableGlobal */ .chroma .vg { } +/* NameVariableInstance */ .chroma .vi { } +/* NameVariableMagic */ .chroma .vm { } +/* Literal */ .chroma .l { } +/* LiteralDate */ .chroma .ld { } +/* 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 } +/* Punctuation */ .chroma .p { } +/* 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 } +/* Generic */ .chroma .g { } +/* 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 } +/* GenericUnderline */ .chroma .gl { text-decoration: underline } +/* TextWhitespace */ .chroma .w { color: #bbbbbb } diff --git a/docs/assets/css/components/chroma_dark.css b/docs/assets/css/components/chroma_dark.css new file mode 100644 index 000000000..0b0ae3000 --- /dev/null +++ b/docs/assets/css/components/chroma_dark.css @@ -0,0 +1,85 @@ +/* Background */.dark .bg { background-color: var(--color-dark); } +/* PreWrapper */ .dark .chroma { background-color: var(--color-dark); } +/* Other */ .dark .chroma .x { } +/* Error */ .dark .chroma .err { color: #ef6155 } +/* CodeLine */ .dark .chroma .cl { } +/* LineTableTD */ .dark .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; } +/* LineTable */ .dark .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; } +/* LineHighlight */ .dark .chroma .hl { background-color: rgb(0,19,28) } +/* LineNumbersTable */ .dark .chroma .lnt { white-space: pre; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #7f7f7f } +/* LineNumbers */ .dark .chroma .ln { white-space: pre; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #7f7f7f } +/* Line */ .dark .chroma .line { display: flex; } +/* Keyword */ .dark .chroma .k { color: #815ba4 } +/* KeywordConstant */ .dark .chroma .kc { color: #815ba4 } +/* KeywordDeclaration */ .dark .chroma .kd { color: #815ba4 } +/* KeywordNamespace */ .dark .chroma .kn { color: #5bc4bf } +/* KeywordPseudo */ .dark .chroma .kp { color: #815ba4 } +/* KeywordReserved */ .dark .chroma .kr { color: #815ba4 } +/* KeywordType */ .dark .chroma .kt { color: #fec418 } +/* Name */ .dark .chroma .n { } +/* NameAttribute */ .dark .chroma .na { color: #06b6ef } +/* NameBuiltin */ .dark .chroma .nb { } +/* NameBuiltinPseudo */ .dark .chroma .bp { } +/* NameClass */ .dark .chroma .nc { color: #fec418 } +/* NameConstant */ .dark .chroma .no { color: #ef6155 } +/* NameDecorator */ .dark .chroma .nd { color: #5bc4bf } +/* NameEntity */ .dark .chroma .ni { } +/* NameException */ .dark .chroma .ne { color: #ef6155 } +/* NameFunction */ .dark .chroma .nf { color: #06b6ef } +/* NameFunctionMagic */ .dark .chroma .fm { } +/* NameLabel */ .dark .chroma .nl { } +/* NameNamespace */ .dark .chroma .nn { color: #fec418 } +/* NameOther */ .dark .chroma .nx { color: #06b6ef } +/* NameProperty */ .dark .chroma .py { } +/* NameTag */ .dark .chroma .nt { color: #5bc4bf } +/* NameVariable */ .dark .chroma .nv { color: #ef6155 } +/* NameVariableClass */ .dark .chroma .vc { } +/* NameVariableGlobal */ .dark .chroma .vg { } +/* NameVariableInstance */ .dark .chroma .vi { } +/* NameVariableMagic */ .dark .chroma .vm { } +/* Literal */ .dark .chroma .l { color: #f99b15 } +/* LiteralDate */ .dark .chroma .ld { color: #48b685 } +/* LiteralString */ .dark .chroma .s { color: #48b685 } +/* LiteralStringAffix */ .dark .chroma .sa { color: #48b685 } +/* LiteralStringBacktick */ .dark .chroma .sb { color: #48b685 } +/* LiteralStringChar */ .dark .chroma .sc { } +/* LiteralStringDelimiter */ .dark .chroma .dl { color: #48b685 } +/* LiteralStringDoc */ .dark .chroma .sd { color: #776e71 } +/* LiteralStringDouble */ .dark .chroma .s2 { color: #48b685 } +/* LiteralStringEscape */ .dark .chroma .se { color: #f99b15 } +/* LiteralStringHeredoc */ .dark .chroma .sh { color: #48b685 } +/* LiteralStringInterpol */ .dark .chroma .si { color: #f99b15 } +/* LiteralStringOther */ .dark .chroma .sx { color: #48b685 } +/* LiteralStringRegex */ .dark .chroma .sr { color: #48b685 } +/* LiteralStringSingle */ .dark .chroma .s1 { color: #48b685 } +/* LiteralStringSymbol */ .dark .chroma .ss { color: #48b685 } +/* LiteralNumber */ .dark .chroma .m { color: #f99b15 } +/* LiteralNumberBin */ .dark .chroma .mb { color: #f99b15 } +/* LiteralNumberFloat */ .dark .chroma .mf { color: #f99b15 } +/* LiteralNumberHex */ .dark .chroma .mh { color: #f99b15 } +/* LiteralNumberInteger */ .dark .chroma .mi { color: #f99b15 } +/* LiteralNumberIntegerLong */ .dark .chroma .il { color: #f99b15 } +/* LiteralNumberOct */ .dark .chroma .mo { color: #f99b15 } +/* Operator */ .dark .chroma .o { color: #5bc4bf } +/* OperatorWord */ .dark .chroma .ow { color: #5bc4bf } +/* Punctuation */ .dark .chroma .p { } +/* Comment */ .dark .chroma .c { color: #776e71 } +/* CommentHashbang */ .dark .chroma .ch { color: #776e71 } +/* CommentMultiline */ .dark .chroma .cm { color: #776e71 } +/* CommentSingle */ .dark .chroma .c1 { color: #776e71 } +/* CommentSpecial */ .dark .chroma .cs { color: #776e71 } +/* CommentPreproc */ .dark .chroma .cp { color: #776e71 } +/* CommentPreprocFile */ .dark .chroma .cpf { color: #776e71 } +/* Generic */ .dark .chroma .g { } +/* GenericDeleted */ .dark .chroma .gd { color: #ef6155 } +/* GenericEmph */ .dark .chroma .ge { font-style: italic } +/* GenericError */ .dark .chroma .gr { } +/* GenericHeading */ .dark .chroma .gh { font-weight: bold } +/* GenericInserted */ .dark .chroma .gi { color: #48b685 } +/* GenericOutput */ .dark .chroma .go { } +/* GenericPrompt */ .dark .chroma .gp { color: #776e71; font-weight: bold } +/* GenericStrong */ .dark .chroma .gs { font-weight: bold } +/* GenericSubheading */ .dark .chroma .gu { color: #5bc4bf; font-weight: bold } +/* GenericTraceback */ .dark .chroma .gt { } +/* GenericUnderline */ .dark .chroma .gl { } +/* TextWhitespace */ .dark .chroma .w { } diff --git a/docs/assets/css/components/content.css b/docs/assets/css/components/content.css new file mode 100644 index 000000000..e9064f439 --- /dev/null +++ b/docs/assets/css/components/content.css @@ -0,0 +1,49 @@ +@import "./chroma_dark.css"; +@import "./chroma.css"; +@import "./highlight.css"; + +/* Some contrast ratio fixes as reported by Google Page Speed. */ +.chroma .c1 { + @apply text-gray-500; +} + +.dark .chroma .c1 { + @apply text-gray-400; +} + +.highlight code { + @apply text-sm/6; +} + +.content { + @apply prose prose-sm sm:prose-base prose-stone max-w-none dark:prose-invert dark:text-slate-200; + /* headings */ + @apply prose-headings:font-semibold; + /* lead */ + @apply prose-lead:text-slate-500 prose-lead:text-xl prose-lead:mt-2 sm:prose-lead:mt-4 prose-lead:leading-relaxed dark:prose-lead:text-slate-400; + /* links */ + @apply prose-a:text-primary dark:prose-a:text-blue-500 prose-a:hover:text-blue-500 dark:prose-a:hover:text-blue-400 prose-a:underline; + @apply prose-a:prose-code:underline prose-a:prose-code:hover:text-blue-500 prose-a:prose-code:hover:underline; + /* pre */ + @apply prose-pre:text-gray-800 prose-pre:border-1 prose-pre:border-gray-100 prose-pre:bg-light dark:prose-pre:bg-dark dark:prose-pre:ring-1 dark:prose-pre:ring-slate-300/10; + /* code */ + @apply prose-code:px-0.5 prose-code:text-gray-500 prose-code:dark:text-gray-300 border-none; + @apply prose-code:before:hidden prose-code:after:hidden prose-code:font-mono; + @apply prose-table:prose-th:prose-code:text-white; + /* tables */ + @apply prose-table:w-auto prose-table:border-2 prose-table:border-gray-100 prose-table:dark:border-gray-800 prose-table:prose-th:font-bold prose-table:prose-th:bg-blue-500 dark:prose-table:prose-th:bg-blue-500/50 prose-table:prose-th:p-2 prose-table:prose-td:p-2 prose-table:prose-th:text-white; + /* hr */ + @apply dark:prose-hr:border-slate-800; + /* ol */ + @apply prose-ol:marker:prose dark:prose-ol:marker:text-gray-300; + /* ul */ + @apply prose-ul:marker:text-gray-500 dark:prose-ul:marker:text-gray-300; +} + +/* This will not match highlighting inside e.g. the code-toggle shortcode. */ +/* For more fine grained control of this, see components/shortcodes.css. */ +.content > .highlight, +.content dd > .highlight, +.content li > .highlight { + @apply border-1 border-gray-200 dark:border-slate-600 mt-6 mb-8; +} diff --git a/docs/assets/css/components/fonts.css b/docs/assets/css/components/fonts.css new file mode 100644 index 000000000..06f40b4bf --- /dev/null +++ b/docs/assets/css/components/fonts.css @@ -0,0 +1,15 @@ +@font-face { + font-family: "Mulish"; + font-style: normal; + src: url("../fonts/Mulish-VariableFont_wght.ttf") format("truetype"); + font-weight: 1 999; + font-display: swap; +} + +@font-face { + font-family: "Mulish"; + font-style: italic; + src: url("../fonts/Mulish-Italic-VariableFont_wght.ttf") format("truetype"); + font-weight: 1 999; + font-display: swap; +} diff --git a/docs/assets/css/components/helpers.css b/docs/assets/css/components/helpers.css new file mode 100644 index 000000000..8eb6930b8 --- /dev/null +++ b/docs/assets/css/components/helpers.css @@ -0,0 +1,19 @@ +/* Helper class to limit a text block to two lines. */ +.two-lines-ellipsis { + display: block; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Helper class to limit a text block to three lines. */ +.three-lines-ellipsis { + display: block; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/docs/assets/css/components/highlight.css b/docs/assets/css/components/highlight.css new file mode 100644 index 000000000..5f25fe368 --- /dev/null +++ b/docs/assets/css/components/highlight.css @@ -0,0 +1,11 @@ +.highlight { + @apply bg-light dark:bg-dark rounded-none; +} + +.highlight pre { + @apply m-0 p-3 w-full h-full overflow-x-auto dark:border-black rounded-none; +} + +.highlight pre code { + @apply m-0 p-0 w-full h-full; +} diff --git a/docs/assets/css/components/shortcodes.css b/docs/assets/css/components/shortcodes.css new file mode 100644 index 000000000..7314d5b20 --- /dev/null +++ b/docs/assets/css/components/shortcodes.css @@ -0,0 +1,4 @@ +.shortcode-code { + .highlight { + } +} diff --git a/docs/assets/css/components/tableofcontents.css b/docs/assets/css/components/tableofcontents.css new file mode 100644 index 000000000..3640adf6d --- /dev/null +++ b/docs/assets/css/components/tableofcontents.css @@ -0,0 +1,14 @@ +.tableofcontents { + ul { + @apply list-none; + li { + @apply mb-2; + a { + @apply text-primary; + &:hover { + @apply text-primary/60; + } + } + } + } +} diff --git a/docs/assets/css/components/view-transitions.css b/docs/assets/css/components/view-transitions.css new file mode 100644 index 000000000..cf68ed3d7 --- /dev/null +++ b/docs/assets/css/components/view-transitions.css @@ -0,0 +1,20 @@ +/* Global slight fade */ +::view-transition-old(root), +::view-transition-new(root) { + animation-duration: 200ms; +} + +::view-transition-old(qr), +::view-transition-new(qr) { + animation-duration: 800ms; + animation-delay: 250ms; +} + +.view-transition-qr { + view-transition-name: qr; +} + +/* Turbo styles */ +.turbo-progress-bar { + visibility: hidden; +} diff --git a/docs/assets/css/styles.css b/docs/assets/css/styles.css new file mode 100644 index 000000000..6665a7e2b --- /dev/null +++ b/docs/assets/css/styles.css @@ -0,0 +1,131 @@ +@import "tailwindcss"; +@plugin "@tailwindcss/typography"; +@variant dark (&:where(.dark, .dark *)); + +@import "components/all.css"; + +/* TailwindCSS ignores files in .gitignore, so make it explicit. */ +@source "hugo_stats.json"; + +@theme { + /* Breakpoints. */ + --breakpoint-sm: 40rem; + --breakpoint-md: 48rem; + --breakpoint-lg: 68rem; /* Default 64rem; */ + --breakpoint-xl: 80rem; + --breakpoint-2xl: 96rem; + + /* Colors. */ + --color-primary: var(--color-blue-600); + --color-dark: #000; + --color-light: var(--color-gray-50); + --color-accent: var(--color-orange-500); + --color-accent-light: var(--color-pink-500); + --color-accent-dark: var(--color-green-500); + + /* https://www.tints.dev/blue/0594CB */ + --color-blue-50: #e1f6fe; + --color-blue-100: #c3edfe; + --color-blue-200: #88dbfc; + --color-blue-300: #4cc9fb; + --color-blue-400: #15b9f9; + --color-blue-500: #0594cb; + --color-blue-600: #0477a4; + --color-blue-700: #035677; + --color-blue-800: #023a50; + --color-blue-900: #011d28; + --color-blue-950: #000e14; + + /* https://www.tints.dev/orange/EBB951 */ + --color-orange-50: #fdf8ed; + --color-orange-100: #fbf1da; + --color-orange-200: #f7e4ba; + --color-orange-300: #f3d596; + --color-orange-400: #efc976; + --color-orange-500: #ebb951; + --color-orange-600: #e5a51a; + --color-orange-700: #a97a13; + --color-orange-800: #72520d; + --color-orange-900: #372806; + --color-orange-950: #1b1403; + + /* https://www.tints.dev/pink/FF4088 */ + --color-pink-50: #ffebf2; + --color-pink-100: #ffdbe9; + --color-pink-200: #ffb3d0; + --color-pink-300: #ff8fba; + --color-pink-400: #ff66a1; + --color-pink-500: #ff4088; + --color-pink-600: #ff0062; + --color-pink-700: #c2004a; + --color-pink-800: #800031; + --color-pink-900: #420019; + --color-pink-950: #1f000c; + + /* https://www.tints.dev/green/33BA91 */ + --color-green-50: #ebfaf5; + --color-green-100: #d3f3e9; + --color-green-200: #abe8d6; + --color-green-300: #7fdcc0; + --color-green-400: #53d0aa; + --color-green-500: #33ba91; + --color-green-600: #299474; + --color-green-700: #1f7058; + --color-green-800: #154c3b; + --color-green-900: #0a241c; + --color-green-950: #051410; + + /* Fonts. */ + --font-sans: + "Mulish", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", + "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; +} + +html { + scroll-padding-top: 100px; +} + +body { + @apply antialiased font-sans text-black dark:text-gray-100; +} + +.p-safe-area-x { + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); +} + +.p-safe-area-y { + padding-top: env(safe-area-inset-top); + padding-bottom: env(safe-area-inset-bottom); +} + +.px-main { + padding-left: max(env(safe-area-inset-left), 1rem); + padding-right: max(env(safe-area-inset-right), 1rem); +} + +@media screen(md) { + .px-main { + padding-left: max(env(safe-area-inset-left), 2rem); + padding-right: max(env(safe-area-inset-right), 2rem); + } +} + +@media screen(lg) { + .px-main { + padding-left: max(env(safe-area-inset-left), 3rem); + padding-right: max(env(safe-area-inset-right), 3rem); + } +} + +/* Algolia DocSearch */ +.algolia-docsearch-suggestion--highlight { + color: var(--color-primary); +} + +/* Footnotes */ +.footnote-backref, +.footnote-ref { + text-decoration: none; + padding-left: .0625em; +} diff --git a/docs/assets/images/examples/landscape-exif-orientation-5.jpg b/docs/assets/images/examples/landscape-exif-orientation-5.jpg new file mode 100644 index 000000000..ad64835eb Binary files /dev/null and b/docs/assets/images/examples/landscape-exif-orientation-5.jpg differ diff --git a/docs/assets/images/examples/mask.png b/docs/assets/images/examples/mask.png new file mode 100644 index 000000000..c3005a669 Binary files /dev/null and b/docs/assets/images/examples/mask.png differ diff --git a/docs/assets/images/examples/zion-national-park.jpg b/docs/assets/images/examples/zion-national-park.jpg new file mode 100644 index 000000000..7980abccb Binary files /dev/null and b/docs/assets/images/examples/zion-national-park.jpg differ diff --git a/docs/assets/images/hugo-github-screenshot.png b/docs/assets/images/hugo-github-screenshot.png new file mode 100644 index 000000000..275b6969d Binary files /dev/null and b/docs/assets/images/hugo-github-screenshot.png differ diff --git a/docs/assets/images/logos/logo-128x128.png b/docs/assets/images/logos/logo-128x128.png new file mode 100644 index 000000000..ec1a2d6e1 Binary files /dev/null and b/docs/assets/images/logos/logo-128x128.png differ diff --git a/docs/assets/images/logos/logo-256x256.png b/docs/assets/images/logos/logo-256x256.png new file mode 100644 index 000000000..d9fdb888a Binary files /dev/null and b/docs/assets/images/logos/logo-256x256.png differ diff --git a/docs/assets/images/logos/logo-512x512.png b/docs/assets/images/logos/logo-512x512.png new file mode 100644 index 000000000..76d463600 Binary files /dev/null and b/docs/assets/images/logos/logo-512x512.png differ diff --git a/docs/assets/images/logos/logo-64x64.png b/docs/assets/images/logos/logo-64x64.png new file mode 100644 index 000000000..9857bcea1 Binary files /dev/null and b/docs/assets/images/logos/logo-64x64.png differ diff --git a/docs/assets/images/logos/logo-96x96.png b/docs/assets/images/logos/logo-96x96.png new file mode 100644 index 000000000..48d0cb98e Binary files /dev/null and b/docs/assets/images/logos/logo-96x96.png differ diff --git a/docs/assets/images/sponsors/Route4MeLogoBlueOnWhite.svg b/docs/assets/images/sponsors/Route4MeLogoBlueOnWhite.svg new file mode 100644 index 000000000..d4334e8d8 --- /dev/null +++ b/docs/assets/images/sponsors/Route4MeLogoBlueOnWhite.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/docs/assets/images/sponsors/bep-consulting.svg b/docs/assets/images/sponsors/bep-consulting.svg new file mode 100644 index 000000000..598a1eb71 --- /dev/null +++ b/docs/assets/images/sponsors/bep-consulting.svg @@ -0,0 +1,4 @@ + + + + diff --git a/docs/assets/images/sponsors/butter-dark.svg b/docs/assets/images/sponsors/butter-dark.svg new file mode 100644 index 000000000..657b75c50 --- /dev/null +++ b/docs/assets/images/sponsors/butter-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/assets/images/sponsors/butter-light.svg b/docs/assets/images/sponsors/butter-light.svg new file mode 100644 index 000000000..a0697df08 --- /dev/null +++ b/docs/assets/images/sponsors/butter-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/assets/images/sponsors/cloudcannon-blue.svg b/docs/assets/images/sponsors/cloudcannon-blue.svg new file mode 100644 index 000000000..79b13f431 --- /dev/null +++ b/docs/assets/images/sponsors/cloudcannon-blue.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/docs/assets/images/sponsors/cloudcannon-white.svg b/docs/assets/images/sponsors/cloudcannon-white.svg new file mode 100644 index 000000000..83e319a6d --- /dev/null +++ b/docs/assets/images/sponsors/cloudcannon-white.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/docs/assets/images/sponsors/esolia-logo.svg b/docs/assets/images/sponsors/esolia-logo.svg new file mode 100644 index 000000000..3f5344c61 --- /dev/null +++ b/docs/assets/images/sponsors/esolia-logo.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/images/sponsors/goland.svg b/docs/assets/images/sponsors/goland.svg new file mode 100644 index 000000000..c32f25d7f --- /dev/null +++ b/docs/assets/images/sponsors/goland.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/images/sponsors/graitykit-dark.svg b/docs/assets/images/sponsors/graitykit-dark.svg new file mode 100644 index 000000000..fd7d12f5c --- /dev/null +++ b/docs/assets/images/sponsors/graitykit-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/assets/images/sponsors/linode-logo.svg b/docs/assets/images/sponsors/linode-logo.svg new file mode 100644 index 000000000..873678398 --- /dev/null +++ b/docs/assets/images/sponsors/linode-logo.svg @@ -0,0 +1 @@ + diff --git a/docs/assets/images/sponsors/linode-logo_standard_light_medium.png b/docs/assets/images/sponsors/linode-logo_standard_light_medium.png new file mode 100644 index 000000000..269e6af84 Binary files /dev/null and b/docs/assets/images/sponsors/linode-logo_standard_light_medium.png differ diff --git a/docs/assets/images/sponsors/your-company-dark.svg b/docs/assets/images/sponsors/your-company-dark.svg new file mode 100644 index 000000000..58fd601f5 --- /dev/null +++ b/docs/assets/images/sponsors/your-company-dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/docs/assets/images/sponsors/your-company.svg b/docs/assets/images/sponsors/your-company.svg new file mode 100644 index 000000000..3b85ece5c --- /dev/null +++ b/docs/assets/images/sponsors/your-company.svg @@ -0,0 +1,4 @@ + + + + diff --git a/docs/assets/js/alpinejs/data/explorer.js b/docs/assets/js/alpinejs/data/explorer.js new file mode 100644 index 000000000..783db58f4 --- /dev/null +++ b/docs/assets/js/alpinejs/data/explorer.js @@ -0,0 +1,123 @@ +var debug = 0 ? console.log.bind(console, '[explorer]') : function () {}; + +// This is currently not used, but kept in case I change my mind. +export const explorer = (Alpine) => ({ + uiState: { + containerScrollTop: -1, + lastActiveRef: '', + }, + treeState: { + // The href of the current page. + currentNode: '', + // The state of each node in the tree. + nodes: {}, + + // We currently only list the sections, not regular pages, in the side bar. + // This strikes me as the right balance. The pages gets listed on the section pages. + // This array is sorted by length, so we can find the longest prefix of the current page + // without having to iterate over all the keys. + nodeRefsByLength: [], + }, + async init() { + let keys = Reflect.ownKeys(this.$refs); + for (let key of keys) { + let n = { + open: false, + active: false, + }; + this.treeState.nodes[key] = n; + this.treeState.nodeRefsByLength.push(key); + } + + this.treeState.nodeRefsByLength.sort((a, b) => b.length - a.length); + + this.setCurrentActive(); + }, + + longestPrefix(ref) { + let longestPrefix = ''; + for (let key of this.treeState.nodeRefsByLength) { + if (ref.startsWith(key)) { + longestPrefix = key; + break; + } + } + return longestPrefix; + }, + + setCurrentActive() { + let ref = this.longestPrefix(window.location.pathname); + let activeChanged = this.uiState.lastActiveRef !== ref; + debug('setCurrentActive', this.uiState.lastActiveRef, window.location.pathname, '=>', ref, activeChanged); + this.uiState.lastActiveRef = ref; + if (this.uiState.containerScrollTop === -1 && activeChanged) { + // Navigation outside of the explorer menu. + let el = document.querySelector(`[x-ref="${ref}"]`); + if (el) { + this.$nextTick(() => { + debug('scrolling to', ref); + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }); + } + } + this.treeState.currentNode = ref; + for (let key in this.treeState.nodes) { + let n = this.treeState.nodes[key]; + n.active = false; + n.open = ref == key || ref.startsWith(key); + if (n.open) { + debug('open', key); + } + } + + let n = this.treeState.nodes[this.longestPrefix(ref)]; + if (n) { + n.active = true; + } + }, + + getScrollingContainer() { + return document.getElementById('leftsidebar'); + }, + + onLoad() { + debug('onLoad', this.uiState.containerScrollTop); + if (this.uiState.containerScrollTop >= 0) { + debug('onLoad: scrolling to', this.uiState.containerScrollTop); + this.getScrollingContainer().scrollTo(0, this.uiState.containerScrollTop); + } + this.uiState.containerScrollTop = -1; + }, + + onBeforeRender() { + debug('onBeforeRender', this.uiState.containerScrollTop); + this.setCurrentActive(); + }, + + toggleNode(ref) { + this.uiState.containerScrollTop = this.getScrollingContainer().scrollTop; + this.uiState.lastActiveRef = ''; + debug('toggleNode', ref, this.uiState.containerScrollTop); + + let node = this.treeState.nodes[ref]; + if (!node) { + debug('node not found', ref); + return; + } + let wasOpen = node.open; + }, + + isCurrent(ref) { + let n = this.treeState.nodes[ref]; + return n && n.active; + }, + + isOpen(ref) { + let node = this.treeState.nodes[ref]; + if (!node) return false; + if (node.open) { + debug('isOpen', ref); + } + return node.open; + }, +}); diff --git a/docs/assets/js/alpinejs/data/index.js b/docs/assets/js/alpinejs/data/index.js new file mode 100644 index 000000000..7bf0532e3 --- /dev/null +++ b/docs/assets/js/alpinejs/data/index.js @@ -0,0 +1,3 @@ +export * from './navbar'; +export * from './search'; +export * from './toc'; diff --git a/docs/assets/js/alpinejs/data/navbar.js b/docs/assets/js/alpinejs/data/navbar.js new file mode 100644 index 000000000..1075f3f29 --- /dev/null +++ b/docs/assets/js/alpinejs/data/navbar.js @@ -0,0 +1,28 @@ +export const navbar = (Alpine) => ({ + init: function () { + Alpine.bind(this.$root, this.root); + + return this.$nextTick(() => { + let contentEl = document.querySelector('.content:not(.content--ready)'); + if (contentEl) { + contentEl.classList.add('content--ready'); + let anchorTemplate = document.getElementById('anchor-heading'); + if (anchorTemplate) { + let els = contentEl.querySelectorAll('h2[id], h3[id], h4[id], h5[id], h6[id], dt[id]'); + for (let i = 0; i < els.length; i++) { + let el = els[i]; + el.classList.add('group'); + let a = anchorTemplate.content.cloneNode(true).firstElementChild; + a.href = '#' + el.id; + el.appendChild(a); + } + } + } + }); + }, + root: { + ['@scroll.window.debounce.10ms'](event) { + this.$store.nav.scroll.atTop = window.scrollY < 40 ? true : false; + }, + }, +}); diff --git a/docs/assets/js/alpinejs/data/search.js b/docs/assets/js/alpinejs/data/search.js new file mode 100644 index 000000000..c633799a1 --- /dev/null +++ b/docs/assets/js/alpinejs/data/search.js @@ -0,0 +1,119 @@ +import { LRUCache } from '../../helpers'; + +const designMode = false; + +const groupByLvl0 = (array) => { + if (!array) return []; + return array.reduce((result, currentValue) => { + (result[currentValue.hierarchy.lvl0] = result[currentValue.hierarchy.lvl0] || []).push(currentValue); + return result; + }, {}); +}; + +const applyHelperFuncs = (array) => { + if (!array) return []; + return array.map((item) => { + item.getHeadingHTML = function () { + let lvl2 = this._highlightResult.hierarchy.lvl2; + let lvl3 = this._highlightResult.hierarchy.lvl3; + + if (!lvl3) { + if (lvl2) { + return lvl2.value; + } + return ''; + } + + if (!lvl2) { + return lvl3.value; + } + + return `${lvl2.value}  >  ${lvl3.value}`; + }; + return item; + }); +}; + +export const search = (Alpine, cfg) => ({ + query: designMode ? 'apac' : '', + open: designMode, + result: {}, + cache: new LRUCache(10), // Small cache, avoids network requests on e.g. backspace. + init() { + Alpine.bind(this.$root, this.root); + + this.checkOpen(); + return this.$nextTick(() => { + this.$watch('query', () => { + this.search(); + }); + }); + }, + toggleOpen: function () { + this.open = !this.open; + this.checkOpen(); + }, + checkOpen: function () { + if (!this.open) { + return; + } + this.search(); + this.$nextTick(() => { + this.$refs.input.focus(); + }); + }, + + search: function () { + if (!this.query) { + this.result = {}; + return; + } + + // Check cache first. + const cached = this.cache.get(this.query); + if (cached) { + this.result = cached; + return; + } + var queries = { + requests: [ + { + indexName: cfg.index, + params: `query=${encodeURIComponent(this.query)}`, + attributesToHighlight: ['hierarchy', 'content'], + attributesToRetrieve: ['hierarchy', 'url', 'content'], + }, + ], + }; + + const host = `https://${cfg.app_id}-dsn.algolia.net`; + const url = `${host}/1/indexes/*/queries`; + + fetch(url, { + method: 'POST', + headers: { + 'X-Algolia-Application-Id': cfg.app_id, + 'X-Algolia-API-Key': cfg.api_key, + }, + body: JSON.stringify(queries), + }) + .then((response) => response.json()) + .then((data) => { + this.result = groupByLvl0(applyHelperFuncs(data.results[0].hits)); + this.cache.put(this.query, this.result); + }); + }, + root: { + ['@click']() { + if (!this.open) { + this.toggleOpen(); + } + }, + ['@search-toggle.window']() { + this.toggleOpen(); + }, + ['@keydown.slash.window.prevent']() { + this.toggleOpen(); + }, + }, +}); diff --git a/docs/assets/js/alpinejs/data/toc.js b/docs/assets/js/alpinejs/data/toc.js new file mode 100644 index 000000000..233f8777f --- /dev/null +++ b/docs/assets/js/alpinejs/data/toc.js @@ -0,0 +1,71 @@ +var debug = 0 ? console.log.bind(console, '[toc]') : function () {}; + +export const toc = (Alpine) => ({ + contentScrollSpy: null, + activeHeading: '', + justClicked: false, + + setActive(id) { + debug('setActive', id); + this.activeHeading = id; + // Prevent the intersection observer from changing the active heading right away. + this.justClicked = true; + setTimeout(() => { + this.justClicked = false; + }, 200); + }, + + init() { + this.$watch('$store.nav.scroll.atTop', (value) => { + if (!value) return; + this.activeHeading = ''; + this.$root.scrollTop = 0; + }); + + return this.$nextTick(() => { + let contentEl = document.getElementById('article'); + if (contentEl) { + const handleIntersect = (entries) => { + if (this.justClicked) { + return; + } + for (let entry of entries) { + if (entry.isIntersecting) { + let id = entry.target.id; + this.activeHeading = id; + let liEl = this.$refs[id]; + if (liEl) { + // If liEl is not in the viewport, scroll it into view. + let bounding = liEl.getBoundingClientRect(); + if (bounding.top < 0 || bounding.bottom > window.innerHeight) { + this.$root.scrollTop = liEl.offsetTop - 100; + } + } + debug('intersecting', id); + break; + } + } + }; + + let opts = { + rootMargin: '0px 0px -75%', + threshold: 0.75, + }; + + this.contentScrollSpy = new IntersectionObserver(handleIntersect, opts); + // Observe all headings. + let headings = contentEl.querySelectorAll('h2, h3, h4, h5, h6'); + for (let heading of headings) { + this.contentScrollSpy.observe(heading); + } + } + }); + }, + + destroy() { + if (this.contentScrollSpy) { + debug('disconnecting'); + this.contentScrollSpy.disconnect(); + } + }, +}); diff --git a/docs/assets/js/alpinejs/magics/helpers.js b/docs/assets/js/alpinejs/magics/helpers.js new file mode 100644 index 000000000..de9fa24e9 --- /dev/null +++ b/docs/assets/js/alpinejs/magics/helpers.js @@ -0,0 +1,36 @@ +'use strict'; + +export function registerMagics(Alpine) { + Alpine.magic('copy', (currentEl) => { + return function (el) { + if (!el) { + el = currentEl; + } + + // Select the element to copy. + let range = document.createRange(); + range.selectNode(el); + window.getSelection().removeAllRanges(); + window.getSelection().addRange(range); + + // Remove the selection after some time. + setTimeout(() => { + window.getSelection().removeAllRanges(); + }, 500); + + // Trim whitespace. + let text = el.textContent.trim(); + + navigator.clipboard.writeText(text); + }; + }); + + Alpine.magic('isScrollX', (currentEl) => { + return function (el) { + if (!el) { + el = currentEl; + } + return el.clientWidth < el.scrollWidth; + }; + }); +} diff --git a/docs/assets/js/alpinejs/magics/index.js b/docs/assets/js/alpinejs/magics/index.js new file mode 100644 index 000000000..c5f595cf9 --- /dev/null +++ b/docs/assets/js/alpinejs/magics/index.js @@ -0,0 +1 @@ +export * from './helpers'; diff --git a/docs/assets/js/alpinejs/stores/index.js b/docs/assets/js/alpinejs/stores/index.js new file mode 100644 index 000000000..17e2a347b --- /dev/null +++ b/docs/assets/js/alpinejs/stores/index.js @@ -0,0 +1 @@ +export * from './nav.js'; diff --git a/docs/assets/js/alpinejs/stores/nav.js b/docs/assets/js/alpinejs/stores/nav.js new file mode 100644 index 000000000..6409cd86c --- /dev/null +++ b/docs/assets/js/alpinejs/stores/nav.js @@ -0,0 +1,94 @@ +var debug = 1 ? console.log.bind(console, '[navStore]') : function () {}; + +var ColorScheme = { + System: 1, + Light: 2, + Dark: 3, +}; + +const localStorageUserSettingsKey = 'hugoDocsUserSettings'; + +export const navStore = (Alpine) => ({ + init() { + // There is no $watch available in Alpine stores, + // but this has the same effect. + this.userSettings.onColorSchemeChanged = Alpine.effect(() => { + if (this.userSettings.settings.colorScheme) { + this.userSettings.isDark = isDark(this.userSettings.settings.colorScheme); + toggleDarkMode(this.userSettings.isDark); + } + }); + + // Also react to changes in system settings. + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + this.userSettings.setColorScheme(ColorScheme.System); + }); + }, + + destroy() {}, + + scroll: { + atTop: true, + }, + + userSettings: { + // settings gets persisted between page navigations. + settings: Alpine.$persist({ + // light, dark or system mode. + // If not set, we use the OS setting. + colorScheme: ColorScheme.System, + // Used to show the most relevant tab in config listings etc. + configFileType: 'toml', + }).as(localStorageUserSettingsKey), + + isDark: false, + + setColorScheme(colorScheme) { + this.settings.colorScheme = colorScheme; + this.isDark = isDark(colorScheme); + }, + + toggleColorScheme() { + let next = this.settings.colorScheme + 1; + if (next > ColorScheme.Dark) { + next = ColorScheme.System; + } + this.setColorScheme(next); + }, + colorScheme() { + return this.settings.colorScheme ? this.settings.colorScheme : ColorScheme.System; + }, + }, +}); + +function isMediaDark() { + return window.matchMedia('(prefers-color-scheme: dark)').matches; +} + +function isDark(colorScheme) { + if (!colorScheme || colorScheme == ColorScheme.System) { + return isMediaDark(); + } + + return colorScheme == ColorScheme.Dark; +} + +export function initColorScheme() { + // The AlpineJS store has not have been initialized yet, so access the + // localStorage directly. + let settingsJSON = localStorage[localStorageUserSettingsKey]; + if (settingsJSON) { + let settings = JSON.parse(settingsJSON); + toggleDarkMode(isDark(settings.colorScheme)); + return; + } + toggleDarkMode(isDark(null)); +} + +const toggleDarkMode = function (dark) { + if (dark) { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } +}; diff --git a/docs/assets/js/body-start.js b/docs/assets/js/body-start.js new file mode 100644 index 000000000..f9b596671 --- /dev/null +++ b/docs/assets/js/body-start.js @@ -0,0 +1,6 @@ +import { initColorScheme } from './alpinejs/stores/index'; + +(function () { + // This allows us to initialize the color scheme before AlpineJS etc. is loaded. + initColorScheme(); +})(); diff --git a/docs/assets/js/head-early.js b/docs/assets/js/head-early.js new file mode 100644 index 000000000..250bdd6cb --- /dev/null +++ b/docs/assets/js/head-early.js @@ -0,0 +1,16 @@ +import { scrollToActive } from 'js/helpers/index'; + +(function () { + // Now we know that the browser has JS enabled. + document.documentElement.classList.remove('no-js'); + + // Add os-macos class to body if user is using macOS. + if (navigator.userAgent.indexOf('Mac') > -1) { + document.documentElement.classList.add('os-macos'); + } + + // Wait for the DOM to be ready. + document.addEventListener('DOMContentLoaded', function () { + scrollToActive('DOMContentLoaded'); + }); +})(); diff --git a/docs/assets/js/helpers/bridgeTurboAndAlpine.js b/docs/assets/js/helpers/bridgeTurboAndAlpine.js new file mode 100644 index 000000000..0494d02f2 --- /dev/null +++ b/docs/assets/js/helpers/bridgeTurboAndAlpine.js @@ -0,0 +1,67 @@ +export function bridgeTurboAndAlpine(Alpine) { + document.addEventListener('turbo:before-render', (event) => { + event.detail.newBody.querySelectorAll('[data-alpine-generated]').forEach((el) => { + if (el.hasAttribute('data-alpine-generated')) { + el.removeAttribute('data-alpine-generated'); + el.remove(); + } + }); + }); + + document.addEventListener('turbo:render', () => { + if (document.documentElement.hasAttribute('data-turbo-preview')) { + return; + } + + document.querySelectorAll('[data-alpine-ignored]').forEach((el) => { + el.removeAttribute('x-ignore'); + el.removeAttribute('data-alpine-ignored'); + }); + + document.body.querySelectorAll('[x-data]').forEach((el) => { + if (el.hasAttribute('data-turbo-permanent')) { + return; + } + Alpine.initTree(el); + }); + + Alpine.startObservingMutations(); + }); + + // Cleanup Alpine state on navigation. + document.addEventListener('turbo:before-cache', () => { + // This will be restarted in turbo:render. + Alpine.stopObservingMutations(); + + document.body.querySelectorAll('[data-turbo-permanent]').forEach((el) => { + if (!el.hasAttribute('x-ignore')) { + el.setAttribute('x-ignore', true); + el.setAttribute('data-alpine-ignored', true); + } + }); + + document.body.querySelectorAll('[x-for],[x-if],[x-teleport]').forEach((el) => { + if (el.hasAttribute('x-for') && el._x_lookup) { + Object.values(el._x_lookup).forEach((el) => el.setAttribute('data-alpine-generated', true)); + } + + if (el.hasAttribute('x-if') && el._x_currentIfEl) { + el._x_currentIfEl.setAttribute('data-alpine-generated', true); + } + + if (el.hasAttribute('x-teleport') && el._x_teleport) { + el._x_teleport.setAttribute('data-alpine-generated', true); + } + }); + + document.body.querySelectorAll('[x-data]').forEach((el) => { + if (!el.hasAttribute('data-turbo-permanent')) { + Alpine.destroyTree(el); + // Turbo leaks DOM elements via their data-turbo-permanent handling. + // That needs to be fixed upstream, but until then. + let clone = el.cloneNode(true); + el.replaceWith(clone); + } + }); + }); +} diff --git a/docs/assets/js/helpers/helpers.js b/docs/assets/js/helpers/helpers.js new file mode 100644 index 000000000..818eac40c --- /dev/null +++ b/docs/assets/js/helpers/helpers.js @@ -0,0 +1,17 @@ +export const scrollToActive = (when) => { + let els = document.querySelectorAll('.scroll-active'); + if (!els.length) { + return; + } + els.forEach((el) => { + // Find scrolling container. + let container = el.closest('[data-turbo-preserve-scroll-container]'); + if (container) { + // Avoid scrolling if el is already in view. + if (el.offsetTop >= container.scrollTop && el.offsetTop <= container.scrollTop + container.clientHeight) { + return; + } + container.scrollTop = el.offsetTop - container.offsetTop; + } + }); +}; diff --git a/docs/assets/js/helpers/index.js b/docs/assets/js/helpers/index.js new file mode 100644 index 000000000..41ffa3c39 --- /dev/null +++ b/docs/assets/js/helpers/index.js @@ -0,0 +1,3 @@ +export * from './bridgeTurboAndAlpine'; +export * from './helpers'; +export * from './lrucache'; diff --git a/docs/assets/js/helpers/lrucache.js b/docs/assets/js/helpers/lrucache.js new file mode 100644 index 000000000..258848c95 --- /dev/null +++ b/docs/assets/js/helpers/lrucache.js @@ -0,0 +1,19 @@ +// A simple LRU cache implementation backed by a map. +export class LRUCache { + constructor(maxSize) { + this.maxSize = maxSize; + this.cache = new Map(); + } + + get(key) { + return this.cache.get(key); + } + + put(key, value) { + if (this.cache.size >= this.maxSize) { + const firstKey = this.cache.keys().next().value; + this.cache.delete(firstKey); + } + this.cache.set(key, value); + } +} diff --git a/docs/assets/js/main.js b/docs/assets/js/main.js new file mode 100644 index 000000000..14440044b --- /dev/null +++ b/docs/assets/js/main.js @@ -0,0 +1,89 @@ +import Alpine from 'alpinejs'; +import { registerMagics } from './alpinejs/magics/index'; +import { navbar, search, toc } from './alpinejs/data/index'; +import { navStore, initColorScheme } from './alpinejs/stores/index'; +import { bridgeTurboAndAlpine } from './helpers/index'; +import persist from '@alpinejs/persist'; +import focus from '@alpinejs/focus'; + +var debug = 0 ? console.log.bind(console, '[index]') : function () {}; + +// Turbolinks init. +(function () { + document.addEventListener('turbo:render', function (e) { + // This is also called right after the body start. This is added to prevent flicker on navigation. + initColorScheme(); + }); +})(); + +// Set up and start Alpine. +(function () { + // Register AlpineJS plugins. + { + Alpine.plugin(focus); + Alpine.plugin(persist); + } + // Register AlpineJS magics and directives. + { + // Handles copy to clipboard etc. + registerMagics(Alpine); + } + + // Register AlpineJS controllers. + { + // Register AlpineJS data controllers. + let searchConfig = { + index: 'hugodocs', + app_id: 'D1BPLZHGYQ', + api_key: '6df94e1e5d55d258c56f60d974d10314', + }; + + Alpine.data('navbar', () => navbar(Alpine)); + Alpine.data('search', () => search(Alpine, searchConfig)); + Alpine.data('toc', () => toc(Alpine)); + } + + // Register AlpineJS stores. + { + Alpine.store('nav', navStore(Alpine)); + } + + // Start AlpineJS. + Alpine.start(); + + // Start the Turbo-Alpine bridge. + bridgeTurboAndAlpine(Alpine); + + { + let containerScrollTops = {}; + + // To preserve scroll position in scrolling elements on navigation add data-turbo-preserve-scroll-container="somename" to the scrolling container. + addEventListener('turbo:click', () => { + document.querySelectorAll('[data-turbo-preserve-scroll-container]').forEach((el2) => { + containerScrollTops[el2.dataset.turboPreserveScrollContainer] = el2.scrollTop; + }); + }); + + addEventListener('turbo:render', () => { + document.querySelectorAll('[data-turbo-preserve-scroll-container]').forEach((ele) => { + const containerScrollTop = containerScrollTops[ele.dataset.turboPreserveScrollContainer]; + if (containerScrollTop) { + ele.scrollTop = containerScrollTop; + } else { + let els = ele.querySelectorAll('.scroll-active'); + if (els.length) { + els.forEach((el) => { + // Avoid scrolling if el is already in view. + if (el.offsetTop >= ele.scrollTop && el.offsetTop <= ele.scrollTop + ele.clientHeight) { + return; + } + ele.scrollTop = el.offsetTop - ele.offsetTop; + }); + } + } + }); + + containerScrollTops = {}; + }); + } +})(); diff --git a/docs/assets/js/turbo.js b/docs/assets/js/turbo.js new file mode 100644 index 000000000..c007896f6 --- /dev/null +++ b/docs/assets/js/turbo.js @@ -0,0 +1 @@ +import * as Turbo from '@hotwired/turbo'; diff --git a/docs/assets/jsconfig.json b/docs/assets/jsconfig.json new file mode 100644 index 000000000..377218ccb --- /dev/null +++ b/docs/assets/jsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "*": [ + "*" + ] + } + } +} \ No newline at end of file diff --git a/docs/assets/opengraph/gohugoio-card-base-1.png b/docs/assets/opengraph/gohugoio-card-base-1.png new file mode 100644 index 000000000..65555845b Binary files /dev/null and b/docs/assets/opengraph/gohugoio-card-base-1.png differ diff --git a/docs/assets/opengraph/mulish-black.ttf b/docs/assets/opengraph/mulish-black.ttf new file mode 100644 index 000000000..db680a088 Binary files /dev/null and b/docs/assets/opengraph/mulish-black.ttf differ diff --git a/docs/config.toml b/docs/config.toml deleted file mode 100644 index 25cc01a0d..000000000 --- a/docs/config.toml +++ /dev/null @@ -1,273 +0,0 @@ -baseURL = "https://gohugo.io/" -paginate = 100 -defaultContentLanguage = "en" -enableEmoji = true -# Set the unicode character used for the "return" link in page footnotes. -footnotereturnlinkcontents = "↩" -languageCode = "en-us" -metaDataFormat = "yaml" -title = "Hugo" -theme = "gohugoioTheme" - -googleAnalytics = "UA-7131036-4" - -pluralizeListTitles = false - -# We do redirects via Netlify's _redirects file, generated by Hugo (see "outputs" below). -disableAliases = true - -# Highlighting config (Pygments) -# It is (currently) not in use, but you can do ```go in a content file if you want to. -pygmentsCodeFences = true - -pygmentsOptions = "" -# Use the Chroma stylesheet -pygmentsUseClasses = true -pygmentsUseClassic = false - -# See https://help.farbox.com/pygments.html -pygmentsStyle = "trac" - -[outputs] -home = [ "HTML", "RSS", "REDIR", "HEADERS" ] -section = [ "HTML", "RSS"] - -[mediaTypes] -[mediaTypes."text/netlify"] -suffix = "" -delimiter = "" - -[outputFormats] -[outputFormats.REDIR] -mediatype = "text/netlify" -baseName = "_redirects" -isPlainText = true -notAlternative = true -[outputFormats.HEADERS] -mediatype = "text/netlify" -baseName = "_headers" -isPlainText = true -notAlternative = true - -[related] - -threshold = 80 -includeNewer = true -toLower = false - -[[related.indices]] -name = "keywords" -weight = 100 -[[related.indices]] -name = "date" -weight = 10 -pattern = "2006" - -[social] -twitter = "GoHugoIO" - -#CUSTOM PARAMS -[params] - description = "The world’s fastest framework for building websites" - ## Used for views in rendered HTML (i.e., rather than using the .Hugo variable) - release = "0.38.1" - ## Setting this to true will add a "noindex" to *EVERY* page on the site - removefromexternalsearch = false - ## Gh repo for site footer (include trailing slash) - ghrepo = "https://github.com/gohugoio/hugoDocs/" - ## GH Repo for filing a new issue - github_repo = "https://github.com/gohugoio/hugo/issues/new" - ### Edit content repo (set to automatically enter "edit" mode; this is good for "improve this page" links) - ghdocsrepo = "https://github.com/gohugoio/hugoDocs/tree/master/docs" - ## Gitter URL - gitter = "https://gitter.im/spf13/hugo" - ## Discuss Forum URL - forum = "https://discourse.gohugo.io/" - ## Google Tag Manager - gtmid = "" - - # First one is picked as the Twitter card image if not set on page. - images = ["images/gohugoio-card.png"] - - flex_box_interior_classes = "flex-auto w-100 w-40-l mr3 mb3 bg-white ba b--moon-gray nested-copy-line-height" - - #sidebar_direction = "sidebar_left" - -# MARKDOWN -## Configuration for BlackFriday markdown parser: https://github.com/russross/blackfriday -[blackfriday] - plainIDAnchors = true - # See https://github.com/gohugoio/hugo/issues/2424 - hrefTargetBlank = false - angledQuotes = false - latexDashes = true - -[imaging] -# See https://github.com/disintegration/imaging -# CatmullRom is a sharp bicubic filter which should fit the docs site well with its many screenshots. -# Note that you can also set this per image processing. -resampleFilter = "CatmullRom" - -# Defatult JPEG quality setting. Default is 75. -quality = 75 - -anchor = "smart" - - -## As of v0.20, all content files include a default "categories" value that's the same as the section. This was a cheap future-proofing method and should/could be changed accordingly. -[taxonomies] - category = "categories" - -# High level items - -[[menu.docs]] - name = "About Hugo" - weight = 1 - identifier = "about" - url = "/about/" - -[[menu.docs]] - name = "Getting Started" - weight = 5 - identifier = "getting-started" - url = "/getting-started/" - - -[[menu.docs]] - name = "Themes" - weight = 15 - identifier = "themes" - post = "break" - url = "/themes/" - -# Core Menus - -[[menu.docs]] - name = "Content Management" - weight = 20 - identifier = "content-management" - post = "expanded" - url = "/content-management/" - -[[menu.docs]] - name = "Templates" - weight = 25 - identifier = "templates" - - url = "/templates/" - -[[menu.docs]] - name = "Functions" - weight = 30 - identifier = "functions" - url = "/functions/" - -[[menu.docs]] - name = "Variables" - weight = 35 - identifier = "variables" - url = "/variables/" - -[[menu.docs]] - name = "CLI" - weight = 40 - post = "break" - identifier = "commands" - url = "/commands/" - - - -# LOW LEVEL ITEMS - - -[[menu.docs]] - name = "Troubleshooting" - weight = 60 - identifier = "troubleshooting" - url = "/troubleshooting/" - -[[menu.docs]] - name = "Tools" - weight = 70 - identifier = "tools" - url = "/tools/" - -[[menu.docs]] - name = "Hosting & Deployment" - weight = 80 - identifier = "hosting-and-deployment" - url = "/hosting-and-deployment/" - -[[menu.docs]] - name = "Contribute" - weight = 100 - post = "break" - identifier = "contribute" - url = "/contribute/" - -#[[menu.docs]] -# name = "Tags" -# weight = 120 -# identifier = "tags" -# url = "/tags/" - - -# [[menu.docs]] -# name = "Categories" -# weight = 140 -# identifier = "categories" -# url = "/categories/" - -######## QUICKLINKS - - [[menu.quicklinks]] - name = "Fundamentals" - weight = 1 - identifier = "fundamentals" - url = "/tags/fundamentals/" - - - - -######## GLOBAL ITEMS TO BE SHARED WITH THE HUGO SITES - -[[menu.global]] - name = "News" - weight = 1 - identifier = "news" - url = "/news/" - - [[menu.global]] - name = "Docs" - weight = 5 - identifier = "docs" - url = "/documentation/" - - [[menu.global]] - name = "Themes" - weight = 10 - identifier = "themes" - url = "https://themes.gohugo.io/" - - [[menu.global]] - name = "Showcase" - weight = 20 - identifier = "showcase" - url = "/showcase/" - - # Anything with a weight > 100 gets an external icon - [[menu.global]] - name = "Community" - weight = 150 - icon = true - identifier = "community" - post = "external" - url = "https://discourse.gohugo.io/" - - - [[menu.global]] - name = "GitHub" - weight = 200 - identifier = "github" - post = "external" - url = "https://github.com/gohugoio/hugo" diff --git a/docs/content/LICENSE.md b/docs/content/LICENSE.md new file mode 100644 index 000000000..b09cd7856 --- /dev/null +++ b/docs/content/LICENSE.md @@ -0,0 +1,201 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + 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. diff --git a/docs/content/_index.md b/docs/content/_index.md deleted file mode 100644 index c8976eb94..000000000 --- a/docs/content/_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/about/_index.md b/docs/content/about/_index.md deleted file mode 100644 index 8ed441b61..000000000 --- a/docs/content/about/_index.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: About Hugo -linktitle: Overview -description: Hugo's features, roadmap, license, and motivation. -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2017-02-01 -categories: [] -keywords: [] -menu: - docs: - parent: "about" - weight: 1 -weight: 1 -draft: false -aliases: [/about-hugo/,/docs/] -toc: false ---- - -Hugo is not your average static site generator. diff --git a/docs/content/about/benefits.md b/docs/content/about/benefits.md deleted file mode 100644 index 0ba28c5cc..000000000 --- a/docs/content/about/benefits.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -title: The Benefits of Static Site Generators -linktitle: The Benefits of Static -description: Improved performance, security and ease of use are just a few of the reasons static site generators are so appealing. -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2017-02-01 -keywords: [ssg,static,performance,security] -menu: - docs: - parent: "about" - weight: 30 -weight: 30 -sections_weight: 30 -draft: false -aliases: [] -toc: false ---- - -The purpose of website generators is to render content into HTML files. Most are "dynamic site generators." That means the HTTP server---i.e., the program that sends files to the browser to be viewed---runs the generator to create a new HTML file every time an end user requests a page. - -Over time, dynamic site generators were programmed to cache their HTML files to prevent unnecessary delays in delivering pages to end users. A cached page is a static version of a web page. - -Hugo takes caching a step further and all HTML files are rendered on your computer. You can review the files locally before copying them to the computer hosting the HTTP server. Since the HTML files aren't generated dynamically, we say that Hugo is a *static site generator*. - -This has many benefits. The most noticeable is performance. HTTP servers are *very* good at sending files---so good, in fact, that you can effectively serve the same number of pages with a fraction of the memory and CPU needed for a dynamic site. - -## More on Static Site Generators - -* ["An Introduction to Static Site Generators", David Walsh][] -* ["Hugo vs. Wordpress page load speed comparison: Hugo leaves WordPress in its dust", GettingThingsTech][hugovwordpress] -* ["Static Site Generators", O'Reilly][] -* [StaticGen: Top Open-Source Static Site Generators (GitHub Stars)][] -* ["Top 10 Static Website Generators", Netlify blog][] -* ["The Resurgence of Static", dotCMS][dotcms] - - -["An Introduction to Static Site Generators", David Walsh]: https://davidwalsh.name/introduction-static-site-generators -["Static Site Generators", O'Reilly]: http://www.oreilly.com/web-platform/free/files/static-site-generators.pdf -["Top 10 Static Website Generators", Netlify blog]: https://www.netlify.com/blog/2016/05/02/top-ten-static-website-generators/ -[hugovwordpress]: https://gettingthingstech.com/hugo-vs.-wordpress-page-load-speed-comparison-hugo-leaves-wordpress-in-its-dust/ -[StaticGen: Top Open-Source Static Site Generators (GitHub Stars)]: https://www.staticgen.com/ -[dotcms]: https://dotcms.com/blog/post/the-resurgence-of-static diff --git a/docs/content/about/features.md b/docs/content/about/features.md deleted file mode 100644 index 9d29c5bd3..000000000 --- a/docs/content/about/features.md +++ /dev/null @@ -1,88 +0,0 @@ ---- -title: Hugo Features -linktitle: Hugo Features -description: Hugo boasts blistering speed, robust content management, and a powerful templating language making it a great fit for all kinds of static websites. -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2017-02-01 -menu: - docs: - parent: "about" - weight: 20 -weight: 20 -sections_weight: 20 -draft: false -aliases: [/about/features] -toc: true ---- - -## General - -* [Extremely fast][] build times (< 1 ms per page) -* Completely cross platform, with [easy installation][install] on macOS, Linux, Windows, and more -* Renders changes on the fly with [LiveReload][] as you develop -* [Powerful theming][] -* [Host your site anywhere][hostanywhere] - -## Organization - -* Straightforward [organization for your projects][], including website sections -* Customizable [URLs][] -* Support for configurable [taxonomies][], including categories and tags -* [Sort content][] as you desire through powerful template [functions][] -* Automatic [table of contents][] generation -* [Dynamic menu][] creation -* [Pretty URLs][] support -* [Permalink][] pattern support -* Redirects via [aliases][] - -## Content - -* Native Markdown and Emacs Org-Mode support, as well as other languages via *external helpers* (see [supported formats][]) -* TOML, YAML, and JSON metadata support in [front matter][] -* Customizable [homepage][] -* Multiple [content types][] -* Automatic and user defined [content summaries][] -* [Shortcodes][] to enable rich content inside of Markdown -* ["Minutes to Read"][pagevars] functionality -* ["Wordcount"][pagevars] functionality - -## Additional Features - -* Integrated [Disqus][] comment support -* Integrated [Google Analytics][] support -* Automatic [RSS][] creation -* Support for [Go][], [Amber], and [Ace][] HTML templates -* [Syntax highlighting][] powered by [Pygments][] - - -[Ace]: /templates/alternatives/ -[aliases]: /content-management/urls/#aliases -[Amber]: https://github.com/eknkc/amber -[content summaries]: /content-management/summaries/ -[content types]: /content-management/types/ -[Disqus]: https://disqus.com/ -[Dynamic menu]: /templates/menus/ -[Extremely fast]: https://github.com/bep/hugo-benchmark -[front matter]: /content-management/front-matter/ -[functions]: /functions/ -[Go]: http://golang.org/pkg/html/template/ -[Google Analytics]: https://google-analytics.com/ -[homepage]: /templates/homepage/ -[hostanywhere]: /hosting-and-deployment/ -[install]: /getting-started/installing/ -[LiveReload]: /getting-started/usage/ -[organization for your projects]: /getting-started/directory-structure/ -[pagevars]: /variables/page/ -[Permalink]: /content-management/urls/#permalinks -[Powerful theming]: /themes/ -[Pretty URLs]: /content-management/urls/ -[Pygments]: http://pygments.org/ -[RSS]: /templates/rss/ -[Shortcodes]: /content-management/shortcodes/ -[sort content]: /templates/ -[supported formats]: /content-management/formats/ -[Syntax highlighting]: /tools/syntax-highlighting/ -[table of contents]: /content-management/toc/ -[taxonomies]: /content-management/taxonomies/ -[URLs]: /content-management/urls/ diff --git a/docs/content/about/license.md b/docs/content/about/license.md deleted file mode 100644 index a8e7c4abd..000000000 --- a/docs/content/about/license.md +++ /dev/null @@ -1,165 +0,0 @@ ---- -title: Apache License -linktitle: License -description: Hugo v0.15 and later are released under the Apache 2.0 license. -date: 2016-02-01 -publishdate: 2016-02-01 -lastmod: 2016-03-02 -categories: ["about hugo"] -keywords: ["License","apache"] -menu: - docs: - parent: "about" - weight: 60 -weight: 60 -sections_weight: 60 -aliases: [/meta/license] -toc: true ---- - -{{% note %}} -Hugo v0.15 and later are released under the Apache 2.0 license. -Earlier versions of Hugo were released under the [Simple Public License](https://opensource.org/licenses/Simple-2.0). -{{% /note %}} - -_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. - -{{< code file="apache-notice.txt" download="apache-notice.txt" >}} -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. -{{< /code >}} diff --git a/docs/content/about/new-in-032/index.md b/docs/content/about/new-in-032/index.md deleted file mode 100644 index 41bd58937..000000000 --- a/docs/content/about/new-in-032/index.md +++ /dev/null @@ -1,209 +0,0 @@ ---- -title: Hugo 0.32 HOWTO -description: About page bundles, image processing and more. -date: 2017-12-28 -keywords: [ssg,static,performance,security] -menu: - docs: - parent: "about" - weight: 10 -weight: 10 -sections_weight: 10 -draft: false -aliases: [] -toc: true -images: -- images/blog/sunset.jpg ---- - - -{{% note %}} -This documentation belongs in other places in this documentation site, but is put here first ... to get something up and running fast. -{{% /note %}} - - -Also see this demo project from [bep](https://github.com/bep/), the clever Norwegian behind these new features: - -* http://hugotest.bep.is/ -* https://github.com/bep/hugotest (source) - -## Page Resources - -### Organize Your Content - -{{< figure src="/images/hugo-content-bundles.png" title="Pages with image resources" >}} - -The content folder above shows a mix of content pages (`md` (i.e. markdown) files) and image resources. - -{{% note %}} -You can use any file type as a content resource as long as it is a MIME type recognized by Hugo (`json` files will, as one example, work fine). If you want to get exotic, you can define your [own media type](/templates/output-formats/#media-types). -{{% /note %}} - -The 3 page bundles marked in red explained from top to bottom: - -1. The home page with one image resource (`1-logo.png`) -2. The blog section with two images resources and two pages resources (`content1.md`, `content2.md`). Note that the `_index.md` represents the URL for this section. -3. An article (`hugo-is-cool`) with a folder with some images and one content resource (`cats-info.md`). Note that the `index.md` represents the URL for this article. - -The content files below `blog/posts` are just regular standalone pages. - -{{% note %}} -Note that changes to any resource inside the `content` folder will trigger a reload when running in watch (aka server or live reload mode), it will even work with `--navigateToChanged`. -{{% /note %}} - -#### Sort Order - -* Pages are sorted according to standard Hugo page sorting rules. -* Images and other resources are sorted in lexicographical order. - -### Handle Page Resources in Templates - - -#### List all Resources - -```html -{{ range .Resources }} -
  • {{ .ResourceType | title }}
  • -{{ end }} -``` - -For an absolute URL, use `.Permalink`. - -**Note:** The permalink will be relative to the content page, respecting permalink settings. Also, included page resources will not have a value for `RelPermalink`. - -#### List All Resources by Type - -```html -{{ with .Resources.ByType "image" }} -{{ end }} - -``` - -Type here is `page` for pages, else the main type in the MIME type, so `image`, `json` etc. - -#### Get a Specific Resource - -```html -{{ $logo := .Resources.GetByPrefix "logo" }} -{{ with $logo }} -{{ end }} -``` - -#### Include Page Resource Content - -```html -{{ with .Resources.ByType "page" }} -{{ range . }} -

    {{ .Title }}

    -{{ .Content }} -{{ end }} -{{ end }} - -``` - - -## Image Processing - -The `image` resource implements the methods `Resize`, `Fit` and `Fill`: - -Resize -: Resize to the given dimension, `{{ $logo.Resize "200x" }}` will resize to 200 pixels wide and preserve the aspect ratio. Use `{{ $logo.Resize "200x100" }}` to control both height and width. - -Fit -: Scale down the image to fit the given dimensions, e.g. `{{ $logo.Fit "200x100" }}` will fit the image inside a box that is 200 pixels wide and 100 pixels high. - -Fill -: Resize and crop the image given dimensions, e.g. `{{ $logo.Fill "200x100" }}` will resize and crop to width 200 and height 100 - - -{{% note %}} -Image operations in Hugo currently **do not preserve EXIF data** as this is not supported by Go's [image package](https://github.com/golang/go/search?q=exif&type=Issues&utf8=%E2%9C%93). This will be improved on in the future. -{{% /note %}} - - -### 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: - -```html -{{}} -``` - -### Image Processing Options - -In addition to the dimensions (e.g. `200x100`) where either height or width can be omitted, Hugo supports a set of additional image options: - -Anchor -: Only relevant for `Fill`. 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`. Example: `{{ $logo.Fill "200x100 BottomLeft" }}` - -JPEG Quality -: Only relevant for JPEG images, values 1 to 100 inclusive, higher is better. Default is 75. `{{ $logo.Resize "200x q50" }}` - -Rotate -: Rotates an image by the given angle counter-clockwise. The rotation will be performed first to get the dimensions correct. `{{ $logo.Resize "200x r90" }}`. 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. - -Resample Filter -: Filter used in resizing. Default is `Box`, a simple and fast resampling filter appropriate for downscaling. See https://github.com/disintegration/imaging for more. If you want to trade quality for faster processing, this may be a option to test. - - - -### Performance - -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 -hugo --gc -``` - - -{{% note %}} -**GC** is short for **Garbage Collection**. -{{% /note %}} - - -## Configuration - -### Default Image Processing Config - -You can configure an `imaging` section in `config.toml` with default image processing options: - -```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" - -# Defatult JPEG quality setting. Default is 75. -quality = 68 -``` - - - - - diff --git a/docs/content/about/what-is-hugo.md b/docs/content/about/what-is-hugo.md deleted file mode 100644 index 2c7339f7c..000000000 --- a/docs/content/about/what-is-hugo.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -title: What is Hugo -linktitle: What is Hugo -description: Hugo is a fast and modern static site generator written in Go, and designed to make website creation fun again. -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2017-02-01 -layout: single -menu: - docs: - parent: "about" - weight: 10 -weight: 10 -sections_weight: 10 -draft: false -aliases: [/overview/introduction/,/about/why-i-built-hugo/] -toc: true ---- - -Hugo is a general-purpose website framework. Technically speaking, Hugo is a [static site generator][]. Unlike systems that dynamically build a page with each visitor request, Hugo builds pages when you create or update your content. Since websites are viewed far more often than they are edited, Hugo is designed to provide an optimal viewing experience for your website's end users and an ideal writing experience for website authors. - -Websites built with Hugo are extremely fast and secure. Hugo sites can be hosted anywhere, including [Netlify][], [Heroku][], [GoDaddy][], [DreamHost][], [GitHub Pages][], [GitLab Pages][], [Surge][], [Aerobatic][], [Firebase][], [Google Cloud Storage][], [Amazon S3][], [Rackspace][], [Azure][], and [CloudFront][] and work well with CDNs. Hugo sites run without the need for a database or dependencies on expensive runtimes like Ruby, Python, or PHP. - -We think of Hugo as the ideal website creation tool with nearly instant build times, able to rebuild whenever a change is made. - -## How Fast is Hugo? - -{{< youtube "CdiDYZ51a2o" >}} - -## What Does Hugo Do? - -In technical terms, Hugo takes a source directory of files and templates and uses these as input to create a complete website. - -## Who Should Use Hugo? - -Hugo is for people that prefer writing in a text editor over a browser. - -Hugo is for people who want to hand code their own website without worrying about setting up complicated runtimes, dependencies and databases. - -Hugo is for people building a blog, a company site, a portfolio site, documentation, a single landing page, or a website with thousands of pages. - - - -[@spf13]: https://twitter.com/@spf13 -[Aerobatic]: https://www.aerobatic.com/ -[Amazon S3]: http://aws.amazon.com/s3/ -[Azure]: https://blogs.msdn.microsoft.com/acoat/2016/01/28/publish-a-static-web-site-using-azure-web-apps/ -[CloudFront]: http://aws.amazon.com/cloudfront/ "Amazon CloudFront" -[contributing to it]: https://github.com/gohugoio/hugo -[DreamHost]: http://www.dreamhost.com/ -[Firebase]: https://firebase.google.com/docs/hosting/ "Firebase static hosting" -[GitHub Pages]: https://pages.github.com/ -[GitLab Pages]: https://about.gitlab.com/features/pages/ -[Go language]: https://golang.org/ -[GoDaddy]: https://www.godaddy.com/ "Godaddy.com Hosting" -[Google Cloud Storage]: http://cloud.google.com/storage/ -[Heroku]: https://www.heroku.com/ -[Jekyll]: http://jekyllrb.com/ -[Jekyll]: https://jekyllrb.com/ -[Middleman]: https://middlemanapp.com/ -[Middleman]: https://middlemanapp.com/ -[Nanoc]: http://nanoc.ws/ -[Nanoc]: https://nanoc.ws/ -[Netlify]: https://netlify.com -[rackspace]: https://www.rackspace.com/cloud/files -[static site generator]: /about/benefits/ -[Rackspace]: https://www.rackspace.com/cloud/files -[static site generator]: /about/benefits/ -[Surge]: https://surge.sh diff --git a/docs/content/commands/hugo.md b/docs/content/commands/hugo.md deleted file mode 100644 index 9e2097585..000000000 --- a/docs/content/commands/hugo.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -date: 2018-01-31T11:40:21+01:00 -title: "hugo" -slug: hugo -url: /commands/hugo/ ---- -## hugo - -hugo builds your site - -### Synopsis - -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/. - -``` -hugo [flags] -``` - -### Options - -``` - -b, --baseURL string hostname (and path) to the root, e.g. http://spf13.com/ - -D, --buildDrafts include content marked as draft - -E, --buildExpired include expired content - -F, --buildFuture include content with publishdate in the future - --cacheDir string filesystem path to cache directory. Defaults: $TMPDIR/hugo_cache/ - --canonifyURLs (deprecated) if true, all relative URLs will be canonicalized using baseURL - --cleanDestinationDir remove files from destination not found in static directories - --config string config file (default is path/config.yaml|json|toml) - -c, --contentDir string filesystem path to content directory - --debug debug output - -d, --destination string filesystem path to write files to - --disableKinds stringSlice disable different kind of pages (home, RSS etc.) - --enableGitInfo add Git revision, date and author info to the pages - --forceSyncStatic copy all files when static is changed. - --gc enable to run some cleanup tasks (remove unused cache files) after the build - -h, --help help for hugo - --i18n-warnings print missing translations - --ignoreCache ignores the cache directory - -l, --layoutDir string filesystem path to layout directory - --log enable Logging - --logFile string log File path (if set, logging enabled automatically) - --noChmod don't sync permission mode of files - --noTimes don't sync modification time of files - --pluralizeListTitles (deprecated) pluralize titles in lists using inflect (default true) - --preserveTaxonomyNames (deprecated) preserve taxonomy names as written ("Gérard Depardieu" vs "gerard-depardieu") - --quiet build in quiet mode - --renderToMemory render to memory (only useful for benchmark testing) - -s, --source string filesystem path to read files relative from - --stepAnalysis display memory and timing of different steps of the program - --templateMetrics display metrics about template executions - --templateMetricsHints calculate some improvement hints when combined with --templateMetrics - -t, --theme string theme to use (located in /themes/THEMENAME/) - --themesDir string filesystem path to themes directory - --uglyURLs (deprecated) if true, use /filename.html instead of /filename/ - -v, --verbose verbose output - --verboseLog verbose logging - -w, --watch watch filesystem for changes and recreate as needed -``` - -### SEE ALSO - -* [hugo benchmark](/commands/hugo_benchmark/) - Benchmark Hugo by building a site a number of times. -* [hugo check](/commands/hugo_check/) - Contains some verification checks -* [hugo config](/commands/hugo_config/) - Print the site configuration -* [hugo convert](/commands/hugo_convert/) - Convert your content to different formats -* [hugo env](/commands/hugo_env/) - Print Hugo version and environment info -* [hugo gen](/commands/hugo_gen/) - A collection of several useful generators. -* [hugo import](/commands/hugo_import/) - Import your site from others. -* [hugo list](/commands/hugo_list/) - Listing out various types of content -* [hugo new](/commands/hugo_new/) - Create new content for your site -* [hugo server](/commands/hugo_server/) - A high performance webserver -* [hugo version](/commands/hugo_version/) - Print the version number of Hugo - -###### Auto generated by spf13/cobra on 31-Jan-2018 diff --git a/docs/content/commands/hugo_benchmark.md b/docs/content/commands/hugo_benchmark.md deleted file mode 100644 index c76ab271d..000000000 --- a/docs/content/commands/hugo_benchmark.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -date: 2018-01-31T11:40:21+01:00 -title: "hugo benchmark" -slug: hugo_benchmark -url: /commands/hugo_benchmark/ ---- -## hugo benchmark - -Benchmark Hugo by building a site a number of times. - -### Synopsis - -Hugo can build a site many times over and analyze the running process -creating a benchmark. - -``` -hugo benchmark [flags] -``` - -### Options - -``` - -b, --baseURL string hostname (and path) to the root, e.g. http://spf13.com/ - -D, --buildDrafts include content marked as draft - -E, --buildExpired include expired content - -F, --buildFuture include content with publishdate in the future - --cacheDir string filesystem path to cache directory. Defaults: $TMPDIR/hugo_cache/ - --canonifyURLs (deprecated) if true, all relative URLs will be canonicalized using baseURL - --cleanDestinationDir remove files from destination not found in static directories - -c, --contentDir string filesystem path to content directory - -n, --count int number of times to build the site (default 13) - --cpuprofile string path/filename for the CPU profile file - -d, --destination string filesystem path to write files to - --disableKinds stringSlice disable different kind of pages (home, RSS etc.) - --enableGitInfo add Git revision, date and author info to the pages - --forceSyncStatic copy all files when static is changed. - --gc enable to run some cleanup tasks (remove unused cache files) after the build - -h, --help help for benchmark - --i18n-warnings print missing translations - --ignoreCache ignores the cache directory - -l, --layoutDir string filesystem path to layout directory - --memprofile string path/filename for the memory profile file - --noChmod don't sync permission mode of files - --noTimes don't sync modification time of files - --pluralizeListTitles (deprecated) pluralize titles in lists using inflect (default true) - --preserveTaxonomyNames (deprecated) preserve taxonomy names as written ("Gérard Depardieu" vs "gerard-depardieu") - --renderToMemory render to memory (only useful for benchmark testing) - -s, --source string filesystem path to read files relative from - --stepAnalysis display memory and timing of different steps of the program - --templateMetrics display metrics about template executions - --templateMetricsHints calculate some improvement hints when combined with --templateMetrics - -t, --theme string theme to use (located in /themes/THEMENAME/) - --themesDir string filesystem path to themes directory - --uglyURLs (deprecated) if true, use /filename.html instead of /filename/ -``` - -### Options inherited from parent commands - -``` - --config string config file (default is path/config.yaml|json|toml) - --debug debug output - --log enable Logging - --logFile string log File path (if set, logging enabled automatically) - --quiet build in quiet mode - -v, --verbose verbose output - --verboseLog verbose logging -``` - -### SEE ALSO - -* [hugo](/commands/hugo/) - hugo builds your site - -###### Auto generated by spf13/cobra on 31-Jan-2018 diff --git a/docs/content/commands/hugo_check.md b/docs/content/commands/hugo_check.md deleted file mode 100644 index 27bde257a..000000000 --- a/docs/content/commands/hugo_check.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -date: 2018-01-31T11:40:21+01:00 -title: "hugo check" -slug: hugo_check -url: /commands/hugo_check/ ---- -## hugo check - -Contains some verification checks - -### Synopsis - -Contains some verification checks - -### Options - -``` - -h, --help help for check -``` - -### Options inherited from parent commands - -``` - --config string config file (default is path/config.yaml|json|toml) - --debug debug output - --log enable Logging - --logFile string log File path (if set, logging enabled automatically) - --quiet build in quiet mode - -v, --verbose verbose output - --verboseLog verbose logging -``` - -### SEE ALSO - -* [hugo](/commands/hugo/) - hugo builds your site -* [hugo check ulimit](/commands/hugo_check_ulimit/) - Check system ulimit settings - -###### Auto generated by spf13/cobra on 31-Jan-2018 diff --git a/docs/content/commands/hugo_check_ulimit.md b/docs/content/commands/hugo_check_ulimit.md deleted file mode 100644 index 73183c085..000000000 --- a/docs/content/commands/hugo_check_ulimit.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -date: 2018-01-31T11:40:21+01:00 -title: "hugo check ulimit" -slug: hugo_check_ulimit -url: /commands/hugo_check_ulimit/ ---- -## hugo check ulimit - -Check system ulimit settings - -### Synopsis - -Hugo will inspect the current ulimit settings on the system. -This is primarily to ensure that Hugo can watch enough files on some OSs - -``` -hugo check ulimit [flags] -``` - -### Options - -``` - -h, --help help for ulimit -``` - -### Options inherited from parent commands - -``` - --config string config file (default is path/config.yaml|json|toml) - --debug debug output - --log enable Logging - --logFile string log File path (if set, logging enabled automatically) - --quiet build in quiet mode - -v, --verbose verbose output - --verboseLog verbose logging -``` - -### SEE ALSO - -* [hugo check](/commands/hugo_check/) - Contains some verification checks - -###### Auto generated by spf13/cobra on 31-Jan-2018 diff --git a/docs/content/commands/hugo_config.md b/docs/content/commands/hugo_config.md deleted file mode 100644 index 031e3cbc7..000000000 --- a/docs/content/commands/hugo_config.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -date: 2018-01-31T11:40:21+01:00 -title: "hugo config" -slug: hugo_config -url: /commands/hugo_config/ ---- -## hugo config - -Print the site configuration - -### Synopsis - -Print the site configuration, both default and custom settings. - -``` -hugo config [flags] -``` - -### Options - -``` - -h, --help help for config -``` - -### Options inherited from parent commands - -``` - --config string config file (default is path/config.yaml|json|toml) - --debug debug output - --log enable Logging - --logFile string log File path (if set, logging enabled automatically) - --quiet build in quiet mode - -v, --verbose verbose output - --verboseLog verbose logging -``` - -### SEE ALSO - -* [hugo](/commands/hugo/) - hugo builds your site - -###### Auto generated by spf13/cobra on 31-Jan-2018 diff --git a/docs/content/commands/hugo_convert.md b/docs/content/commands/hugo_convert.md deleted file mode 100644 index 180669103..000000000 --- a/docs/content/commands/hugo_convert.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -date: 2018-01-31T11:40:21+01:00 -title: "hugo convert" -slug: hugo_convert -url: /commands/hugo_convert/ ---- -## hugo convert - -Convert your content to different formats - -### Synopsis - -Convert your content (e.g. front matter) to different formats. - -See convert's subcommands toJSON, toTOML and toYAML for more information. - -### Options - -``` - -h, --help help for convert - -o, --output string filesystem path to write files to - -s, --source string filesystem path to read files relative from - --unsafe enable less safe operations, please backup first -``` - -### Options inherited from parent commands - -``` - --config string config file (default is path/config.yaml|json|toml) - --debug debug output - --log enable Logging - --logFile string log File path (if set, logging enabled automatically) - --quiet build in quiet mode - -v, --verbose verbose output - --verboseLog verbose logging -``` - -### SEE ALSO - -* [hugo](/commands/hugo/) - hugo builds your site -* [hugo convert toJSON](/commands/hugo_convert_tojson/) - Convert front matter to JSON -* [hugo convert toTOML](/commands/hugo_convert_totoml/) - Convert front matter to TOML -* [hugo convert toYAML](/commands/hugo_convert_toyaml/) - Convert front matter to YAML - -###### Auto generated by spf13/cobra on 31-Jan-2018 diff --git a/docs/content/commands/hugo_convert_toJSON.md b/docs/content/commands/hugo_convert_toJSON.md deleted file mode 100644 index 2142cb194..000000000 --- a/docs/content/commands/hugo_convert_toJSON.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -date: 2018-01-31T11:40:21+01:00 -title: "hugo convert toJSON" -slug: hugo_convert_toJSON -url: /commands/hugo_convert_tojson/ ---- -## hugo convert toJSON - -Convert front matter to JSON - -### Synopsis - -toJSON converts all front matter in the content directory -to use JSON for the front matter. - -``` -hugo convert toJSON [flags] -``` - -### Options - -``` - -h, --help help for toJSON -``` - -### Options inherited from parent commands - -``` - --config string config file (default is path/config.yaml|json|toml) - --debug debug output - --log enable Logging - --logFile string log File path (if set, logging enabled automatically) - -o, --output string filesystem path to write files to - --quiet build in quiet mode - -s, --source string filesystem path to read files relative from - --unsafe enable less safe operations, please backup first - -v, --verbose verbose output - --verboseLog verbose logging -``` - -### SEE ALSO - -* [hugo convert](/commands/hugo_convert/) - Convert your content to different formats - -###### Auto generated by spf13/cobra on 31-Jan-2018 diff --git a/docs/content/commands/hugo_convert_toTOML.md b/docs/content/commands/hugo_convert_toTOML.md deleted file mode 100644 index 8d821d497..000000000 --- a/docs/content/commands/hugo_convert_toTOML.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -date: 2018-01-31T11:40:21+01:00 -title: "hugo convert toTOML" -slug: hugo_convert_toTOML -url: /commands/hugo_convert_totoml/ ---- -## hugo convert toTOML - -Convert front matter to TOML - -### Synopsis - -toTOML converts all front matter in the content directory -to use TOML for the front matter. - -``` -hugo convert toTOML [flags] -``` - -### Options - -``` - -h, --help help for toTOML -``` - -### Options inherited from parent commands - -``` - --config string config file (default is path/config.yaml|json|toml) - --debug debug output - --log enable Logging - --logFile string log File path (if set, logging enabled automatically) - -o, --output string filesystem path to write files to - --quiet build in quiet mode - -s, --source string filesystem path to read files relative from - --unsafe enable less safe operations, please backup first - -v, --verbose verbose output - --verboseLog verbose logging -``` - -### SEE ALSO - -* [hugo convert](/commands/hugo_convert/) - Convert your content to different formats - -###### Auto generated by spf13/cobra on 31-Jan-2018 diff --git a/docs/content/commands/hugo_convert_toYAML.md b/docs/content/commands/hugo_convert_toYAML.md deleted file mode 100644 index 79b777e46..000000000 --- a/docs/content/commands/hugo_convert_toYAML.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -date: 2018-01-31T11:40:21+01:00 -title: "hugo convert toYAML" -slug: hugo_convert_toYAML -url: /commands/hugo_convert_toyaml/ ---- -## hugo convert toYAML - -Convert front matter to YAML - -### Synopsis - -toYAML converts all front matter in the content directory -to use YAML for the front matter. - -``` -hugo convert toYAML [flags] -``` - -### Options - -``` - -h, --help help for toYAML -``` - -### Options inherited from parent commands - -``` - --config string config file (default is path/config.yaml|json|toml) - --debug debug output - --log enable Logging - --logFile string log File path (if set, logging enabled automatically) - -o, --output string filesystem path to write files to - --quiet build in quiet mode - -s, --source string filesystem path to read files relative from - --unsafe enable less safe operations, please backup first - -v, --verbose verbose output - --verboseLog verbose logging -``` - -### SEE ALSO - -* [hugo convert](/commands/hugo_convert/) - Convert your content to different formats - -###### Auto generated by spf13/cobra on 31-Jan-2018 diff --git a/docs/content/commands/hugo_env.md b/docs/content/commands/hugo_env.md deleted file mode 100644 index 09811c844..000000000 --- a/docs/content/commands/hugo_env.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -date: 2018-01-31T11:40:21+01:00 -title: "hugo env" -slug: hugo_env -url: /commands/hugo_env/ ---- -## hugo env - -Print Hugo version and environment info - -### Synopsis - -Print Hugo version and environment info. This is useful in Hugo bug reports. - -``` -hugo env [flags] -``` - -### Options - -``` - -h, --help help for env -``` - -### Options inherited from parent commands - -``` - --config string config file (default is path/config.yaml|json|toml) - --debug debug output - --log enable Logging - --logFile string log File path (if set, logging enabled automatically) - --quiet build in quiet mode - -v, --verbose verbose output - --verboseLog verbose logging -``` - -### SEE ALSO - -* [hugo](/commands/hugo/) - hugo builds your site - -###### Auto generated by spf13/cobra on 31-Jan-2018 diff --git a/docs/content/commands/hugo_gen.md b/docs/content/commands/hugo_gen.md deleted file mode 100644 index f28700f63..000000000 --- a/docs/content/commands/hugo_gen.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -date: 2018-01-31T11:40:21+01:00 -title: "hugo gen" -slug: hugo_gen -url: /commands/hugo_gen/ ---- -## hugo gen - -A collection of several useful generators. - -### Synopsis - -A collection of several useful generators. - -### Options - -``` - -h, --help help for gen -``` - -### Options inherited from parent commands - -``` - --config string config file (default is path/config.yaml|json|toml) - --debug debug output - --log enable Logging - --logFile string log File path (if set, logging enabled automatically) - --quiet build in quiet mode - -v, --verbose verbose output - --verboseLog verbose logging -``` - -### SEE ALSO - -* [hugo](/commands/hugo/) - hugo builds your site -* [hugo gen autocomplete](/commands/hugo_gen_autocomplete/) - Generate shell autocompletion script for Hugo -* [hugo gen chromastyles](/commands/hugo_gen_chromastyles/) - Generate CSS stylesheet for the Chroma code highlighter -* [hugo gen doc](/commands/hugo_gen_doc/) - Generate Markdown documentation for the Hugo CLI. -* [hugo gen man](/commands/hugo_gen_man/) - Generate man pages for the Hugo CLI - -###### Auto generated by spf13/cobra on 31-Jan-2018 diff --git a/docs/content/commands/hugo_gen_autocomplete.md b/docs/content/commands/hugo_gen_autocomplete.md deleted file mode 100644 index 1f9819b48..000000000 --- a/docs/content/commands/hugo_gen_autocomplete.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -date: 2018-01-31T11:40:21+01:00 -title: "hugo gen autocomplete" -slug: hugo_gen_autocomplete -url: /commands/hugo_gen_autocomplete/ ---- -## hugo gen autocomplete - -Generate shell autocompletion script for Hugo - -### Synopsis - -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 - -``` -hugo gen autocomplete [flags] -``` - -### Options - -``` - --completionfile string autocompletion file (default "/etc/bash_completion.d/hugo.sh") - -h, --help help for autocomplete - --type string autocompletion type (currently only bash supported) (default "bash") -``` - -### Options inherited from parent commands - -``` - --config string config file (default is path/config.yaml|json|toml) - --debug debug output - --log enable Logging - --logFile string log File path (if set, logging enabled automatically) - --quiet build in quiet mode - -v, --verbose verbose output - --verboseLog verbose logging -``` - -### SEE ALSO - -* [hugo gen](/commands/hugo_gen/) - A collection of several useful generators. - -###### Auto generated by spf13/cobra on 31-Jan-2018 diff --git a/docs/content/commands/hugo_gen_chromastyles.md b/docs/content/commands/hugo_gen_chromastyles.md deleted file mode 100644 index 353e2381b..000000000 --- a/docs/content/commands/hugo_gen_chromastyles.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -date: 2018-01-31T11:40:21+01:00 -title: "hugo gen chromastyles" -slug: hugo_gen_chromastyles -url: /commands/hugo_gen_chromastyles/ ---- -## hugo gen chromastyles - -Generate CSS stylesheet for the Chroma code highlighter - -### Synopsis - -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 - -``` -hugo gen chromastyles [flags] -``` - -### Options - -``` - -h, --help help for chromastyles - --highlightStyle string style used for highlighting lines (see https://github.com/alecthomas/chroma) (default "bg:#ffffcc") - --linesStyle string style used for line numbers (see https://github.com/alecthomas/chroma) - --style string highlighter style (see https://help.farbox.com/pygments.html) (default "friendly") -``` - -### Options inherited from parent commands - -``` - --config string config file (default is path/config.yaml|json|toml) - --debug debug output - --log enable Logging - --logFile string log File path (if set, logging enabled automatically) - --quiet build in quiet mode - -v, --verbose verbose output - --verboseLog verbose logging -``` - -### SEE ALSO - -* [hugo gen](/commands/hugo_gen/) - A collection of several useful generators. - -###### Auto generated by spf13/cobra on 31-Jan-2018 diff --git a/docs/content/commands/hugo_gen_doc.md b/docs/content/commands/hugo_gen_doc.md deleted file mode 100644 index 321c2387e..000000000 --- a/docs/content/commands/hugo_gen_doc.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -date: 2018-01-31T11:40:21+01:00 -title: "hugo gen doc" -slug: hugo_gen_doc -url: /commands/hugo_gen_doc/ ---- -## hugo gen doc - -Generate Markdown documentation for the Hugo CLI. - -### Synopsis - -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. - -``` -hugo gen doc [flags] -``` - -### Options - -``` - --dir string the directory to write the doc. (default "/tmp/hugodoc/") - -h, --help help for doc -``` - -### Options inherited from parent commands - -``` - --config string config file (default is path/config.yaml|json|toml) - --debug debug output - --log enable Logging - --logFile string log File path (if set, logging enabled automatically) - --quiet build in quiet mode - -v, --verbose verbose output - --verboseLog verbose logging -``` - -### SEE ALSO - -* [hugo gen](/commands/hugo_gen/) - A collection of several useful generators. - -###### Auto generated by spf13/cobra on 31-Jan-2018 diff --git a/docs/content/commands/hugo_gen_man.md b/docs/content/commands/hugo_gen_man.md deleted file mode 100644 index ae55cdd81..000000000 --- a/docs/content/commands/hugo_gen_man.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -date: 2018-01-31T11:40:21+01:00 -title: "hugo gen man" -slug: hugo_gen_man -url: /commands/hugo_gen_man/ ---- -## hugo gen man - -Generate man pages for the Hugo CLI - -### Synopsis - -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. - -``` -hugo gen man [flags] -``` - -### Options - -``` - --dir string the directory to write the man pages. (default "man/") - -h, --help help for man -``` - -### Options inherited from parent commands - -``` - --config string config file (default is path/config.yaml|json|toml) - --debug debug output - --log enable Logging - --logFile string log File path (if set, logging enabled automatically) - --quiet build in quiet mode - -v, --verbose verbose output - --verboseLog verbose logging -``` - -### SEE ALSO - -* [hugo gen](/commands/hugo_gen/) - A collection of several useful generators. - -###### Auto generated by spf13/cobra on 31-Jan-2018 diff --git a/docs/content/commands/hugo_import.md b/docs/content/commands/hugo_import.md deleted file mode 100644 index f6d42da4c..000000000 --- a/docs/content/commands/hugo_import.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -date: 2018-01-31T11:40:21+01:00 -title: "hugo import" -slug: hugo_import -url: /commands/hugo_import/ ---- -## hugo import - -Import your site from others. - -### Synopsis - -Import your site from other web site generators like Jekyll. - -Import requires a subcommand, e.g. `hugo import jekyll jekyll_root_path target_path`. - -### Options - -``` - -h, --help help for import -``` - -### Options inherited from parent commands - -``` - --config string config file (default is path/config.yaml|json|toml) - --debug debug output - --log enable Logging - --logFile string log File path (if set, logging enabled automatically) - --quiet build in quiet mode - -v, --verbose verbose output - --verboseLog verbose logging -``` - -### SEE ALSO - -* [hugo](/commands/hugo/) - hugo builds your site -* [hugo import jekyll](/commands/hugo_import_jekyll/) - hugo import from Jekyll - -###### Auto generated by spf13/cobra on 31-Jan-2018 diff --git a/docs/content/commands/hugo_import_jekyll.md b/docs/content/commands/hugo_import_jekyll.md deleted file mode 100644 index 6442aa6cf..000000000 --- a/docs/content/commands/hugo_import_jekyll.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -date: 2018-01-31T11:40:21+01:00 -title: "hugo import jekyll" -slug: hugo_import_jekyll -url: /commands/hugo_import_jekyll/ ---- -## hugo import jekyll - -hugo import from Jekyll - -### Synopsis - -hugo import from Jekyll. - -Import from Jekyll requires two paths, e.g. `hugo import jekyll jekyll_root_path target_path`. - -``` -hugo import jekyll [flags] -``` - -### Options - -``` - --force allow import into non-empty target directory - -h, --help help for jekyll -``` - -### Options inherited from parent commands - -``` - --config string config file (default is path/config.yaml|json|toml) - --debug debug output - --log enable Logging - --logFile string log File path (if set, logging enabled automatically) - --quiet build in quiet mode - -v, --verbose verbose output - --verboseLog verbose logging -``` - -### SEE ALSO - -* [hugo import](/commands/hugo_import/) - Import your site from others. - -###### Auto generated by spf13/cobra on 31-Jan-2018 diff --git a/docs/content/commands/hugo_list.md b/docs/content/commands/hugo_list.md deleted file mode 100644 index 3d68a1859..000000000 --- a/docs/content/commands/hugo_list.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -date: 2018-01-31T11:40:21+01:00 -title: "hugo list" -slug: hugo_list -url: /commands/hugo_list/ ---- -## hugo list - -Listing out various types of content - -### Synopsis - -Listing out various types of content. - -List requires a subcommand, e.g. `hugo list drafts`. - -### Options - -``` - -h, --help help for list - -s, --source string filesystem path to read files relative from -``` - -### Options inherited from parent commands - -``` - --config string config file (default is path/config.yaml|json|toml) - --debug debug output - --log enable Logging - --logFile string log File path (if set, logging enabled automatically) - --quiet build in quiet mode - -v, --verbose verbose output - --verboseLog verbose logging -``` - -### SEE ALSO - -* [hugo](/commands/hugo/) - hugo builds your site -* [hugo list drafts](/commands/hugo_list_drafts/) - List all drafts -* [hugo list expired](/commands/hugo_list_expired/) - List all posts already expired -* [hugo list future](/commands/hugo_list_future/) - List all posts dated in the future - -###### Auto generated by spf13/cobra on 31-Jan-2018 diff --git a/docs/content/commands/hugo_list_drafts.md b/docs/content/commands/hugo_list_drafts.md deleted file mode 100644 index d9b1d7228..000000000 --- a/docs/content/commands/hugo_list_drafts.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -date: 2018-01-31T11:40:21+01:00 -title: "hugo list drafts" -slug: hugo_list_drafts -url: /commands/hugo_list_drafts/ ---- -## hugo list drafts - -List all drafts - -### Synopsis - -List all of the drafts in your content directory. - -``` -hugo list drafts [flags] -``` - -### Options - -``` - -h, --help help for drafts -``` - -### Options inherited from parent commands - -``` - --config string config file (default is path/config.yaml|json|toml) - --debug debug output - --log enable Logging - --logFile string log File path (if set, logging enabled automatically) - --quiet build in quiet mode - -s, --source string filesystem path to read files relative from - -v, --verbose verbose output - --verboseLog verbose logging -``` - -### SEE ALSO - -* [hugo list](/commands/hugo_list/) - Listing out various types of content - -###### Auto generated by spf13/cobra on 31-Jan-2018 diff --git a/docs/content/commands/hugo_list_expired.md b/docs/content/commands/hugo_list_expired.md deleted file mode 100644 index cf7cd64e4..000000000 --- a/docs/content/commands/hugo_list_expired.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -date: 2018-01-31T11:40:21+01:00 -title: "hugo list expired" -slug: hugo_list_expired -url: /commands/hugo_list_expired/ ---- -## hugo list expired - -List all posts already expired - -### Synopsis - -List all of the posts in your content directory which has already -expired. - -``` -hugo list expired [flags] -``` - -### Options - -``` - -h, --help help for expired -``` - -### Options inherited from parent commands - -``` - --config string config file (default is path/config.yaml|json|toml) - --debug debug output - --log enable Logging - --logFile string log File path (if set, logging enabled automatically) - --quiet build in quiet mode - -s, --source string filesystem path to read files relative from - -v, --verbose verbose output - --verboseLog verbose logging -``` - -### SEE ALSO - -* [hugo list](/commands/hugo_list/) - Listing out various types of content - -###### Auto generated by spf13/cobra on 31-Jan-2018 diff --git a/docs/content/commands/hugo_list_future.md b/docs/content/commands/hugo_list_future.md deleted file mode 100644 index 4d9c1494f..000000000 --- a/docs/content/commands/hugo_list_future.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -date: 2018-01-31T11:40:21+01:00 -title: "hugo list future" -slug: hugo_list_future -url: /commands/hugo_list_future/ ---- -## hugo list future - -List all posts dated in the future - -### Synopsis - -List all of the posts in your content directory which will be -posted in the future. - -``` -hugo list future [flags] -``` - -### Options - -``` - -h, --help help for future -``` - -### Options inherited from parent commands - -``` - --config string config file (default is path/config.yaml|json|toml) - --debug debug output - --log enable Logging - --logFile string log File path (if set, logging enabled automatically) - --quiet build in quiet mode - -s, --source string filesystem path to read files relative from - -v, --verbose verbose output - --verboseLog verbose logging -``` - -### SEE ALSO - -* [hugo list](/commands/hugo_list/) - Listing out various types of content - -###### Auto generated by spf13/cobra on 31-Jan-2018 diff --git a/docs/content/commands/hugo_new.md b/docs/content/commands/hugo_new.md deleted file mode 100644 index 6d2986f85..000000000 --- a/docs/content/commands/hugo_new.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -date: 2018-01-31T11:40:21+01:00 -title: "hugo new" -slug: hugo_new -url: /commands/hugo_new/ ---- -## hugo new - -Create new content for your site - -### Synopsis - -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. - -``` -hugo new [path] [flags] -``` - -### Options - -``` - --editor string edit new content with this editor, if provided - -h, --help help for new - -k, --kind string content type to create - -s, --source string filesystem path to read files relative from -``` - -### Options inherited from parent commands - -``` - --config string config file (default is path/config.yaml|json|toml) - --debug debug output - --log enable Logging - --logFile string log File path (if set, logging enabled automatically) - --quiet build in quiet mode - -v, --verbose verbose output - --verboseLog verbose logging -``` - -### SEE ALSO - -* [hugo](/commands/hugo/) - hugo builds your site -* [hugo new site](/commands/hugo_new_site/) - Create a new site (skeleton) -* [hugo new theme](/commands/hugo_new_theme/) - Create a new theme - -###### Auto generated by spf13/cobra on 31-Jan-2018 diff --git a/docs/content/commands/hugo_new_site.md b/docs/content/commands/hugo_new_site.md deleted file mode 100644 index d71e29788..000000000 --- a/docs/content/commands/hugo_new_site.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -date: 2018-01-31T11:40:21+01:00 -title: "hugo new site" -slug: hugo_new_site -url: /commands/hugo_new_site/ ---- -## hugo new site - -Create a new site (skeleton) - -### Synopsis - -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. - -``` -hugo new site [path] [flags] -``` - -### Options - -``` - --force init inside non-empty directory - -f, --format string config & frontmatter format (default "toml") - -h, --help help for site -``` - -### Options inherited from parent commands - -``` - --config string config file (default is path/config.yaml|json|toml) - --debug debug output - --log enable Logging - --logFile string log File path (if set, logging enabled automatically) - --quiet build in quiet mode - -s, --source string filesystem path to read files relative from - -v, --verbose verbose output - --verboseLog verbose logging -``` - -### SEE ALSO - -* [hugo new](/commands/hugo_new/) - Create new content for your site - -###### Auto generated by spf13/cobra on 31-Jan-2018 diff --git a/docs/content/commands/hugo_new_theme.md b/docs/content/commands/hugo_new_theme.md deleted file mode 100644 index 4a21ceab7..000000000 --- a/docs/content/commands/hugo_new_theme.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -date: 2018-01-31T11:40:21+01:00 -title: "hugo new theme" -slug: hugo_new_theme -url: /commands/hugo_new_theme/ ---- -## hugo new theme - -Create a new theme - -### Synopsis - -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. - -``` -hugo new theme [name] [flags] -``` - -### Options - -``` - -h, --help help for theme -``` - -### Options inherited from parent commands - -``` - --config string config file (default is path/config.yaml|json|toml) - --debug debug output - --log enable Logging - --logFile string log File path (if set, logging enabled automatically) - --quiet build in quiet mode - -s, --source string filesystem path to read files relative from - -v, --verbose verbose output - --verboseLog verbose logging -``` - -### SEE ALSO - -* [hugo new](/commands/hugo_new/) - Create new content for your site - -###### Auto generated by spf13/cobra on 31-Jan-2018 diff --git a/docs/content/commands/hugo_server.md b/docs/content/commands/hugo_server.md deleted file mode 100644 index 8f524452b..000000000 --- a/docs/content/commands/hugo_server.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -date: 2018-01-31T11:40:21+01:00 -title: "hugo server" -slug: hugo_server -url: /commands/hugo_server/ ---- -## hugo server - -A high performance webserver - -### Synopsis - -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. - -'hugo server' will avoid writing the rendered and served content to disk, -preferring to store it in memory. - -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. - -``` -hugo server [flags] -``` - -### Options - -``` - --appendPort append port to baseURL (default true) - -b, --baseURL string hostname (and path) to the root, e.g. http://spf13.com/ - --bind string interface to which the server will bind (default "127.0.0.1") - -D, --buildDrafts include content marked as draft - -E, --buildExpired include expired content - -F, --buildFuture include content with publishdate in the future - --cacheDir string filesystem path to cache directory. Defaults: $TMPDIR/hugo_cache/ - --canonifyURLs (deprecated) if true, all relative URLs will be canonicalized using baseURL - --cleanDestinationDir remove files from destination not found in static directories - -c, --contentDir string filesystem path to content directory - -d, --destination string filesystem path to write files to - --disableFastRender enables full re-renders on changes - --disableKinds stringSlice disable different kind of pages (home, RSS etc.) - --disableLiveReload watch without enabling live browser reload on rebuild - --enableGitInfo add Git revision, date and author info to the pages - --forceSyncStatic copy all files when static is changed. - --gc enable to run some cleanup tasks (remove unused cache files) after the build - -h, --help help for server - --i18n-warnings print missing translations - --ignoreCache ignores the cache directory - -l, --layoutDir string filesystem path to layout directory - --liveReloadPort int port for live reloading (i.e. 443 in HTTPS proxy situations) (default -1) - --meminterval string interval to poll memory usage (requires --memstats), valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h". (default "100ms") - --memstats string log memory usage to this file - --navigateToChanged navigate to changed content file on live browser reload - --noChmod don't sync permission mode of files - --noHTTPCache prevent HTTP caching - --noTimes don't sync modification time of files - --pluralizeListTitles (deprecated) pluralize titles in lists using inflect (default true) - -p, --port int port on which the server will listen (default 1313) - --preserveTaxonomyNames (deprecated) preserve taxonomy names as written ("Gérard Depardieu" vs "gerard-depardieu") - --renderToDisk render to Destination path (default is render to memory & serve from there) - -s, --source string filesystem path to read files relative from - --stepAnalysis display memory and timing of different steps of the program - --templateMetrics display metrics about template executions - --templateMetricsHints calculate some improvement hints when combined with --templateMetrics - -t, --theme string theme to use (located in /themes/THEMENAME/) - --themesDir string filesystem path to themes directory - --uglyURLs (deprecated) if true, use /filename.html instead of /filename/ - -w, --watch watch filesystem for changes and recreate as needed (default true) -``` - -### Options inherited from parent commands - -``` - --config string config file (default is path/config.yaml|json|toml) - --debug debug output - --log enable Logging - --logFile string log File path (if set, logging enabled automatically) - --quiet build in quiet mode - -v, --verbose verbose output - --verboseLog verbose logging -``` - -### SEE ALSO - -* [hugo](/commands/hugo/) - hugo builds your site - -###### Auto generated by spf13/cobra on 31-Jan-2018 diff --git a/docs/content/commands/hugo_version.md b/docs/content/commands/hugo_version.md deleted file mode 100644 index ab9441458..000000000 --- a/docs/content/commands/hugo_version.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -date: 2018-01-31T11:40:21+01:00 -title: "hugo version" -slug: hugo_version -url: /commands/hugo_version/ ---- -## hugo version - -Print the version number of Hugo - -### Synopsis - -All software has versions. This is Hugo's. - -``` -hugo version [flags] -``` - -### Options - -``` - -h, --help help for version -``` - -### Options inherited from parent commands - -``` - --config string config file (default is path/config.yaml|json|toml) - --debug debug output - --log enable Logging - --logFile string log File path (if set, logging enabled automatically) - --quiet build in quiet mode - -v, --verbose verbose output - --verboseLog verbose logging -``` - -### SEE ALSO - -* [hugo](/commands/hugo/) - hugo builds your site - -###### Auto generated by spf13/cobra on 31-Jan-2018 diff --git a/docs/content/content-management/_index.md b/docs/content/content-management/_index.md deleted file mode 100644 index 28f2ecf82..000000000 --- a/docs/content/content-management/_index.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: Content Management -linktitle: Content Management Overview -description: Hugo makes managing large static sites easy with support for archetypes, content types, menus, cross references, summaries, and more. -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2017-02-01 -menu: - docs: - parent: "content-management" - weight: 1 -keywords: [source, organization] -categories: [content management] -weight: 01 #rem -draft: false -aliases: [/content/,/content/organization] -toc: false ---- - -A static site generator needs to extend beyond front matter and a couple of templates to be both scalable and *manageable*. Hugo was designed with not only developers in mind, but also content managers and authors. diff --git a/docs/content/content-management/archetypes.md b/docs/content/content-management/archetypes.md deleted file mode 100644 index 902373d83..000000000 --- a/docs/content/content-management/archetypes.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -title: Archetypes -linktitle: Archetypes -description: Archetypes are templates used when creating new content. -date: 2017-02-01 -publishdate: 2017-02-01 -keywords: [archetypes,generators,metadata,front matter] -categories: ["content management"] -menu: - docs: - parent: "content-management" - weight: 70 - quicklinks: -weight: 70 #rem -draft: false -aliases: [/content/archetypes/] -toc: true ---- - -## What are Archetypes? - -**Archetypes** are content template files in the [archetypes directory][] of your project that contain preconfigured [front matter][] and possibly also a content disposition for your website's [content types][]. These will be used when you run `hugo new`. - - -The `hugo new` uses the `content-section` to find the most suitable archetype template in your project. If your project does not contain any archetype files, it will also look in the theme. - -{{< code file="archetype-example.sh" >}} -hugo new posts/my-first-post.md -{{< /code >}} - -The above will create a new content file in `content/posts/my-first-post.md` using the first archetype file found of these: - -1. `archetypes/posts.md` -2. `archetypes/default.md` -3. `themes/my-theme/posts.md` -4. `themes/my-theme/default.md` - -The last two list items is only applicable if you use a theme and it uses the `my-theme` theme name as an example. - -## Create a New Archetype Template - -A fictional example for the section `newsletter` and the archetype file `archetypes/newsletter.md`. Create a new file in `archetypes/newsletter.md` and open it in a text editor. - -{{< code file="archetypes/newsletter.md" >}} ---- -title: "{{ replace .Name "-" " " | title }}" -date: {{ .Date }} -draft: true ---- - -**Insert Lead paragraph here.** - -## New Cool Posts - -{{ range first 10 ( where .Site.RegularPages "Type" "cool" ) }} -* {{ .Title }} -{{ end }} -{{< /code >}} - -When you create a new newsletter with: - -```bash -hugo new newsletter/the-latest-cool.stuff.md -``` - -It will create a new newsletter type of content file based on the archetype template. - -**Note:** the site will only be built if the `.Site` is in use in the archetype file, and this can be time consuming for big sites. - -The above _newsletter type archetype_ illustrates the possibilities: The full Hugo `.Site` and all of Hugo's template funcs can be used in the archetype file. - - -[archetypes directory]: /getting-started/directory-structure/ -[content types]: /content-management/types/ -[front matter]: /content-management/front-matter/ diff --git a/docs/content/content-management/authors.md b/docs/content/content-management/authors.md deleted file mode 100644 index afc94fa62..000000000 --- a/docs/content/content-management/authors.md +++ /dev/null @@ -1,185 +0,0 @@ ---- -title: Authors -linktitle: Authors -description: -date: 2016-08-22 -publishdate: 2017-03-12 -lastmod: 2017-03-12 -keywords: [authors] -categories: ["content management"] -menu: - docs: - parent: "content-management" - weight: 55 -weight: 55 #rem -draft: true -aliases: [/content/archetypes/] -toc: true -comments: Before this page is published, need to also update both site- and page-level variables documentation. ---- - - - -Larger sites often have multiple content authors. Hugo provides standardized author profiles to organize relationships between content and content creators for sites operating under a distributed authorship model. - -## Author Profiles - -You can create a profile containing metadata for each author on your website. These profiles have to be saved under `data/_authors/`. The filename of the profile will later be used as an identifier. This way Hugo can associate content with one or multiple authors. An author's profile can be defined in the JSON, YAML, or TOML format. - -### Example: Author Profile - -Let's suppose Alice Allison is a blogger. A simple unique identifier would be `alice`. Now, we have to create a file called `alice.toml` in the `data/_authors/` directory. The following example is the standardized template written in TOML: - -{{< code file="data/_authors/alice.toml" >}} -givenName = "Alice" # or firstName as alias -familyName = "Allison" # or lastName as alias -displayName = "Alice Allison" -thumbnail = "static/authors/alice-thumb.jpg" -image = "static/authors/alice-full.jpg" -shortBio = "My name is Alice and I'm a blogger." -bio = "My name is Alice and I'm a blogger... some other stuff" -email = "alice.allison@email.com" -weight = 10 - -[social] - facebook = "alice.allison" - twitter = "alice" - googleplus = "aliceallison1" - website = "www.example.com" - -[params] - random = "whatever you want" -{{< /code >}} - -All variables are optional but it's advised to fill all important ones (e.g. names and biography) because themes can vary in their usage. - -You can store files for the `thumbnail` and `image` attributes in the `static` folder. Then add the path to the photos relative to `static`; e.g., `/static/path/to/thumbnail.jpg`. - -`weight` allows you to define the order of an author in an `.Authors` list and can be accessed on list or via the `.Site.Authors` variable. - -The `social` section contains all the links to the social network accounts of an author. Hugo is able to generate the account links for the most popular social networks automatically. This way, you only have to enter your username. You can find a list of all supported social networks [here](#linking-social-network-accounts-automatically). All other variables, like `website` in the example above remain untouched. - -The `params` section can contain arbitrary data much like the same-named section in the config file. What it contains is up to you. - -## Associate Content Through Identifiers - -Earlier it was mentioned that content can be associated with an author through their corresponding identifier. In our case, blogger Alice has the identifier `alice`. In the front matter of a content file, you can create a list of identifiers and assign it to the `authors` variable. Here are examples for `alice` using YAML and TOML, respectively. - -``` ---- -title: Why Hugo is so Awesome -date: 2016-08-22T14:27:502:00 -authors: ["alice"] ---- - -Nothing to read here. Move along... -``` - -``` -+++ -title = Why Hugo is so Awesome -date = "2016-08-22T14:27:502:00" -authors: ["alice"] -+++ - -Nothing to read here. Move along... -``` - -Future authors who might work on this blog post can append their identifiers to the `authors` array in the front matter as well. - -## Work with Templates - -After a successful setup it's time to give some credit to the authors by showing them on the website. Within the templates Hugo provides a list of the author's profiles if they are listed in the `authors` variable within the front matter. - -The list is accessible via the `.Authors` template variable. Printing all authors of a the blog post is straight forward: - -``` -{{ range .Authors }} - {{ .DisplayName }} -{{ end }} -=> Alice Allison -``` - -Even if there are co-authors you may only want to show the main author. For this case you can use the `.Author` template variable **(note the singular form)**. The template variable contains the profile of the author that is first listed with his identifier in the front matter. - -{{% note %}} -You can find a list of all template variables to access the profile information in [Author Variables](/variables/authors/). -{{% /note %}} - -### Link Social Network Accounts - -As aforementioned, Hugo is able to generate links to profiles of the most popular social networks. The following social networks with their corrersponding identifiers are supported: `github`, `facebook`, `twitter`, `googleplus`, `pinterest`, `instagram`, `youtube` and `linkedin`. - -This is can be done with the `.Social.URL` function. Its only parameter is the name of the social network as they are defined in the profile (e.g. `facebook`, `googleplus`). Custom variables like `website` remain as they are. - -Most articles feature a small section with information about the author at the end. Let's create one containing the author's name, a thumbnail, a (summarized) biography and links to all social networks: - -{{< code file="layouts/partials/author-info.html" download="author-info.html" >}} -{{ with .Author }} -

    {{ .DisplayName }}

    - {{ .DisplayName }} -

    {{ .ShortBio }}

    -
      - {{ range $network, $username := .Social }} -
    • {{ $network }}
    • - {{ end }} -
    -{{ end }} -{{< /code >}} - -## Who Published What? - -That question can be answered with a list of all authors and another list containing all articles that they each have written. Now we have to translate this idea into templates. The [taxonomy][] feature allows us to logically group content based on information that they have in common; e.g. a tag or a category. Well, many articles share the same author, so this should sound familiar, right? - -In order to let Hugo know that we want to group content based on their author, we have to create a new taxonomy called `author` (the name corresponds to the variable in the front matter). Here is the snippet in a `config.yaml` and `config.toml`, respectively: - -``` -taxonomies: - author: authors -``` - -``` -[taxonomies] - author = "authors" -``` - - -### List All Authors - -In the next step we can create a template to list all authors of your website. Later, the list can be accessed at `www.example.com/authors/`. Create a new template in the `layouts/taxonomy/` directory called `authors.term.html`. This template will be exclusively used for this taxonomy. - -{{< code file="layouts/taxonomy/author.term.html" download="author.term.html" >}} - -{{< /code >}} - -`.Data.Terms` contains the identifiers of all authors and we can range over it to create a list with all author names. The `$profile` variable gives us access to the profile of the current author. This allows you to generate a nice info box with a thumbnail, a biography and social media links, like at the [end of a blog post](#linking-social-network-accounts-automatically). - -### List Each Author's Publications - -Last but not least, we have to create the second list that contains all publications of an author. Each list will be shown in its own page and can be accessed at `www.example.com/authors/`. Replace `` with a valid author identifier like `alice`. - -The layout for this page can be defined in the template `layouts/taxonomy/author.html`. - -{{< code file="layouts/taxonomy/author.html" download="author.html" >}} -{{ range .Data.Pages }} -

    {{ .Title }}

    - written by {{ .Author.DisplayName }} - {{ .Summary }} -{{ end }} -{{< /code >}} - -The example above generates a simple list of all posts written by a single author. Inside the loop you've access to the complete set of [page variables][pagevars]. Therefore, you can add additional information about the current posts like the publishing date or the tags. - -With a lot of content this list can quickly become very long. Consider to use the [pagination][] feature. It splits the list into smaller chunks and spreads them over multiple pages. - -[pagevars]: /variables/page/ -[pagination]: /templates/pagination/ diff --git a/docs/content/content-management/comments.md b/docs/content/content-management/comments.md deleted file mode 100644 index 355bf0042..000000000 --- a/docs/content/content-management/comments.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -title: Comments -linktitle: Comments -description: Hugo ships with an internal Disqus template, but this isn't the only commenting system that will work with your new Hugo website. -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2017-03-09 -keywords: [sections,content,organization] -categories: [project organization, fundamentals] -menu: - docs: - parent: "content-management" - weight: 140 -weight: 140 #rem -draft: false -aliases: [/extras/comments/] -toc: true ---- - -Hugo ships with support for [Disqus](https://disqus.com/), a third-party service that provides comment and community capabilities to websites via JavaScript. - -Your theme may already support Disqus, but if not, it is easy to add to your templates via [Hugo's built-in Disqus partial][disquspartial]. - -## Add Disqus - -Hugo comes with all the code you need to load Disqus into your templates. Before adding Disqus to your site, you'll need to [set up an account][disqussetup]. - -### Configure Disqus - -Disqus comments require you set a single value in your [site's configuration file][configuration]. The following show the configuration variable in a `config.toml` and `config.yml`, respectively: - -``` -disqusShortname = "yourdiscussshortname" -``` - -``` -disqusShortname: "yourdiscussshortname" -``` - -For many websites, this is enough configuration. However, you also have the option to set the following in the [front matter][] of a single content file: - -* `disqus_identifier` -* `disqus_title` -* `disqus_url` - -### Render Hugo's Built-in Disqus Partial Template - -See [Partial Templates][partials] to learn how to add the Disqus partial to your Hugo website's templates. - -## Comments Alternatives - -There are a few alternatives to commenting on static sites for those who do not want to use Disqus: - -* [Static Man](https://staticman.net/) -* [txtpen](https://txtpen.com) -* [IntenseDebate](http://intensedebate.com/) -* [Graph Comment][] -* [Muut](http://muut.com/) -* [isso](http://posativ.org/isso/) (Self-hosted, Python) - * [Tutorial on Implementing Isso with Hugo][issotutorial] - - - - - - - -[configuration]: /getting-started/configuration/ -[disquspartial]: /templates/partials/#disqus -[disqussetup]: https://disqus.com/profile/signup/ -[forum]: https://discourse.gohugo.io -[front matter]: /content-management/front-matter/ -[Graph Comment]: https://graphcomment.com/ -[kaijuissue]: https://github.com/spf13/kaiju/issues/new -[issotutorial]: https://stiobhart.net/2017-02-24-isso-comments/ -[partials]: /templates/partials/ -[MongoDB]: https://www.mongodb.com/ -[tweet]: https://twitter.com/spf13 diff --git a/docs/content/content-management/cross-references.md b/docs/content/content-management/cross-references.md deleted file mode 100644 index 2980719e9..000000000 --- a/docs/content/content-management/cross-references.md +++ /dev/null @@ -1,130 +0,0 @@ ---- -title: Links and Cross References -description: Hugo makes it easy to link documents together. -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2017-03-31 -categories: [content management] -keywords: ["cross references","references", "anchors", "urls"] -menu: - docs: - parent: "content-management" - weight: 100 -weight: 100 #rem -aliases: [/extras/crossreferences/] -toc: true ---- - - - The `ref` and `relref` shortcodes link documents together, both of which are [built-in Hugo shortcodes][]. These shortcodes are also used to provide links to headings inside of your content, whether across documents or within a document. The only difference between `ref` and `relref` is whether the resulting URL is absolute (`http://1.com/about/`) or relative (`/about/`), respectively. - -## Use `ref` and `relref` - -``` -{{}} -{{}} -{{}} -{{}} -{{}} -{{}} -``` - -The single parameter to `ref` is a string with a content `documentname` (e.g., `about.md`) with or without an appended in-document `anchor` (`#who`) without spaces. - -### Document Names - -The `documentname` is the name of a document, including the format extension; this may be just the filename, or the relative path from the `content/` directory. With a document `content/blog/post.md`, either format will produce the same result: - -``` -{{}} => `/blog/post/` -{{}} => `/blog/post/` -``` - -If you have the same filename used across multiple sections, you should only use the relative path format; otherwise, the behavior will be `undefined`. This is best illustrated with an example `content` directory: - -``` -. -└── content - ├── events - │   └── my-birthday.md - ├── galleries - │   └── my-birthday.md - ├── meta - │   └── my-article.md - └── posts - └── my-birthday.md -``` - -To be sure to get the correct reference in this case, use the full path: - -{{< code file="content/meta/my-article.md" copy="false" >}} -{{}} => /events/my-birthday/ -{{< /code >}} - -### With Multiple Output Formats - -If the page exists in multiple [output formats][], `ref` or `relref` can be used with a output format name: - -``` - [Neat]({{}}) -``` - -### Anchors - -When an `anchor` is provided by itself, the current page’s unique identifier will be appended; when an `anchor` is provided appended to `documentname`, the found page's unique identifier will be appended: - -``` -{{}} => #anchors:9decaf7 -{{}} => /blog/post/#who:badcafe -``` - -The above examples render as follows for this very page as well as a reference to the "Content" heading in the Hugo docs features pageyoursite - -``` -{{}} => #who:9decaf7 -{{}} => /blog/post/#who:badcafe -``` - -More information about document unique identifiers and headings can be found [below]({{< ref "#hugo-heading-anchors" >}}). - -### Examples - -* `{{}}` => `https://example.com/blog/post/` -* `{{}}` => `https://example.com/blog/post/#tldr:caffebad` -* `{{}}` => `/blog/post/` -* `{{}}` => `/blog/post/#tldr:caffebad` -* `{{}}` => `#tldr:badcaffe` -* `{{}}` => `#tldr:badcaffe` - -## Hugo Heading Anchors - -When using Markdown document types, Hugo generates heading anchors automatically. The generated anchor for this section is `hugo-heading-anchors`. Because the heading anchors are generated automatically, Hugo takes some effort to ensure that heading anchors are unique both inside a document and across the entire site. - -Ensuring heading uniqueness across the site is accomplished with a unique identifier for each document based on its path. Unless a document is renamed or moved between sections *in the filesystem*, the unique identifier for the document will not change: `blog/post.md` will always have a unique identifier of `81df004c333b392d34a49fd3a91ba720`. - -`ref` and `relref` were added so you can make these reference links without having to know the document’s unique identifier. (The links in document tables of contents are automatically up-to-date with this value.) - -``` -{{}} -/content-management/cross-references/#hugo-heading-anchors:77cd9ea530577debf4ce0f28c8dca242 -``` - -### Manually Specifying Anchors - -For Markdown content files, if the `headerIds` [Blackfriday extension][bfext] is -enabled (which it is by default), user can manually specify the anchor for any -heading. - -Few examples: - -``` -## Alpha 101 {#alpha} - -## Version 1.0 {#version-1-dot-0} -``` - -[built-in Hugo shortcodes]: /content-management/shortcodes/#using-the-built-in-shortcodes -[lists]: /templates/lists/ -[output formats]: /templates/output-formats/ -[shortcode]: /content-management/shortcodes/ -[bfext]: /content-management/formats/#blackfriday-extensions diff --git a/docs/content/content-management/formats.md b/docs/content/content-management/formats.md deleted file mode 100644 index be6fb40e4..000000000 --- a/docs/content/content-management/formats.md +++ /dev/null @@ -1,249 +0,0 @@ ---- -title: Supported Content Formats -linktitle: Supported Content Formats -description: Markdown and Emacs Org-Mode have native support, and additional formats (e.g. Asciidoc) come via external helpers. -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 -aliases: [/content/markdown-extras/,/content/supported-formats/,/doc/supported-formats/,/tutorials/mathjax/] -toc: true ---- - -**Markdown is the main content format** and comes in two flavours: The excellent [Blackfriday project][blackfriday] (name your files `*.md` or set `markup = "markdown"` in front matter) or its fork [Mmark][mmark] (name your files `*.mmark` or set `markup = "mmark"` in front matter), both very fast markdown engines written in Go. - -For Emacs users, [goorgeous](https://github.com/chaseadamsio/goorgeous) provides built-in native support for Org-mode (name your files `*.org` or set `markup = "org"` in front matter) - -{{% note "Deeply Nested Lists" %}} -Before you begin writing your content in markdown, Blackfriday has a known issue [(#329)](https://github.com/russross/blackfriday/issues/329) with handling deeply nested lists. Luckily, there is an easy workaround. Use 4-spaces (i.e., tab) rather than 2-space indentations. -{{% /note %}} - -## Configure BlackFriday Markdown Rendering - -You can configure multiple aspects of Blackfriday as show in the following list. See the docs on [Configuration][config] for the full list of explicit directions you can give to Hugo when rendering your site. - -{{< readfile file="/content/readfiles/bfconfig.md" markdown="true" >}} - -## Extend Markdown - -Hugo provides some convenient methods for extending markdown. - -### Task Lists - -Hugo supports [GitHub-styled task lists (i.e., TODO lists)][gfmtasks] for the Blackfriday markdown renderer. If you do not want to use this feature, you can disable it in your configuration. - -#### Example Task List Input - -{{< code file="content/my-to-do-list.md" >}} -- [ ] a task list item -- [ ] list syntax required -- [ ] incomplete -- [x] completed -{{< /code >}} - -#### Example Task List Output - -The preceding markdown produces the following HTML in your rendered website: - -``` -
      -
    • a task list item
    • -
    • list syntax required
    • -
    • incomplete
    • -
    • completed
    • -
    -``` - -#### Example Task List Display - -The following shows how the example task list will look to the end users of your website. Note that visual styling of lists is up to you. This list has been styled according to [the Hugo Docs stylesheet][hugocss]. - -- [ ] a task list item -- [ ] list syntax required -- [ ] incomplete -- [x] completed - -### Emojis - -To add emojis directly to content, set `enableEmoji` to `true` in your [site configuration][config]. To use emojis in templates or shortcodes, see [`emojify` function][]. - -For a full list of emojis, see the [Emoji cheat sheet][emojis]. - -### Shortcodes - -If you write in Markdown and find yourself frequently embedding your content with raw HTML, Hugo provides built-in shortcodes functionality. This is one of the most powerful features in Hugo and allows you to create your own Markdown extensions very quickly. - -See [Shortcodes][sc] for usage, particularly for the built-in shortcodes that ship with Hugo, and [Shortcode Templating][sct] to learn how to build your own. - -### Code Blocks - -Hugo supports GitHub-flavored markdown's use of triple back ticks, as well as provides a special [`highlight` shortcode][hlsc], and syntax highlights those code blocks natively using *Chroma*. Users also have an option to use *Pygments* instead. See the [Syntax Highlighting][hl] section for details. - -## Mmark - -Mmark is a [fork of BlackFriday][mmark] and markdown superset that is well suited for writing [IETF documentation][ietf]. You can see examples of the syntax in the [Mmark GitHub repository][mmarkgh] or the full syntax on [Miek Gieben's website][]. - -### Use Mmark - -As Hugo ships with Mmark, using the syntax is as easy as changing the extension of your content files from `.md` to `.mmark`. - -In the event that you want to only use Mmark in specific files, you can also define the Mmark syntax in your content's front matter: - -``` ---- -title: My Post -date: 2017-04-01 -markup: mmark ---- -``` - -{{% warning %}} -Thare are some features not available in Mmark; one example being that shortcodes are not translated when used in an included `.mmark` file ([#3131](https://github.com/gohugoio/hugo/issues/3137)), and `EXTENSION_ABBREVIATION` ([#1970](https://github.com/gohugoio/hugo/issues/1970)) and the aforementioned GFM todo lists ([#2270](https://github.com/gohugoio/hugo/issues/2270)) are not fully supported. Contributions are welcome. -{{% /warning %}} - -## MathJax with Hugo - -[MathJax](http://www.mathjax.org/) is a JavaScript library that allows the display of mathematical expressions described via a LaTeX-style syntax in the HTML (or Markdown) source of a web page. As it is a pure a JavaScript library, getting it to work within Hugo is fairly straightforward, but does have some oddities that will be discussed here. - -This is not an introduction into actually using MathJax to render typeset mathematics on your website. Instead, this page is a collection of tips and hints for one way to get MathJax working on a website built with Hugo. - -### Enable MathJax - -The first step is to enable MathJax on pages that you would like to have typeset math. There are multiple ways to do this (adventurous readers can consult the [Loading and Configuring](http://docs.mathjax.org/en/latest/configuration.html) section of the MathJax documentation for additional methods of including MathJax), but the easiest way is to use the secure MathJax CDN by include a ` -{{< /code >}} - -One way to ensure that this code is included in all pages is to put it in one of the templates that live in the `layouts/partials/` directory. For example, I have included this in the bottom of my template `footer.html` because I know that the footer will be included in every page of my website. - -### Options and Features - -MathJax is a stable open-source library with many features. I encourage the interested reader to view the [MathJax Documentation](http://docs.mathjax.org/en/latest/index.html), specifically the sections on [Basic Usage](http://docs.mathjax.org/en/latest/index.html#basic-usage) and [MathJax Configuration Options](http://docs.mathjax.org/en/latest/index.html#mathjax-configuration-options). - -### Issues with Markdown - -{{% note %}} -The following issues with Markdown assume you are using `.md` for content and BlackFriday for parsing. Using [Mmark](#mmark) as your content format will obviate the need for the following workarounds. - -When using Mmark with MathJax, use `displayMath: [['$$','$$'], ['\\[','\\]']]`. See the [Mmark `README.md`](https://github.com/miekg/mmark/wiki/Syntax#math-blocks) for more information. In addition to MathJax, Mmark has been shown to work well with [KaTeX](https://github.com/Khan/KaTeX). See this [related blog post from a Hugo user](http://nosubstance.me/post/a-great-toolset-for-static-blogging/). -{{% /note %}} - -After enabling MathJax, any math entered between proper markers (see the [MathJax documentation][mathjaxdocs]) will be processed and typeset in the web page. One issue that comes up, however, with Markdown is that the underscore character (`_`) is interpreted by Markdown as a way to wrap text in `emph` blocks while LaTeX (MathJax) interprets the underscore as a way to create a subscript. This "double speak" of the underscore can result in some unexpected and unwanted behavior. - -### Solution - -There are multiple ways to remedy this problem. One solution is to simply escape each underscore in your math code by entering `\_` instead of `_`. This can become quite tedious if the equations you are entering are full of subscripts. - -Another option is to tell Markdown to treat the MathJax code as verbatim code and not process it. One way to do this is to wrap the math expression inside a `
    ` `
    ` block. Markdown would ignore these sections and they would get passed directly on to MathJax and processed correctly. This works great for display style mathematics, but for inline math expressions the line break induced by the `
    ` is not acceptable. The syntax for instructing Markdown to treat inline text as verbatim is by wrapping it in backticks (`` ` ``). You might have noticed, however, that the text included in between backticks is rendered differently than standard text (on this site these are items highlighted in red). To get around this problem, we could create a new CSS entry that would apply standard styling to all inline verbatim text that includes MathJax code. Below I will show the HTML and CSS source that would accomplish this (note this solution was adapted from [this blog post](http://doswa.com/2011/07/20/mathjax-in-markdown.html)---all credit goes to the original author). - -{{< code file="mathjax-markdown-solution.html" >}} - - - -{{< /code >}} - - - -As before, this content should be included in the HTML source of each page that will be using MathJax. The next code snippet contains the CSS that is used to have verbatim MathJax blocks render with the same font style as the body of the page. - -{{< code file="mathjax-style.css" >}} -code.has-jax { - font: inherit; - font-size: 100%; - background: inherit; - border: inherit; - color: #515151; -} -{{< /code >}} - -In the CSS snippet, notice the line `color: #515151;`. `#515151` is the value assigned to the `color` attribute of the `body` class in my CSS. In order for the equations to fit in with the body of a web page, this value should be the same as the color of the body. - -### Usage - -With this setup, everything is in place for a natural usage of MathJax on pages generated using Hugo. In order to include inline mathematics, just put LaTeX code in between `` `$ TeX Code $` `` or `` `\( TeX Code \)` ``. To include display style mathematics, just put LaTeX code in between `
    $$TeX Code$$
    `. All the math will be properly typeset and displayed within your Hugo generated web page! - -## Additional Formats Through External Helpers - -Hugo has a new concept called _external helpers_. It means that you can write your content using [Asciidoc][ascii], [reStructuredText][rest], or [pandoc]. If you have files with associated extensions, Hugo will call external commands to generate the content. ([See the Hugo source code for external helpers][helperssource].) - -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](http://asciidoctor.org/docs/install-toolchain/)). - -To use these formats, just use the standard extension and the front matter exactly as you would do with natively supported `.md` files. - -Hugo passes reasonable default arguments to these external helpers by default: - -- `asciidoc`: `--no-header-footer --safe -` -- `asciidoctor`: `--no-header-footer --safe --trace -` -- `rst2html`: `--leave-comments --initial-header-level=2` -- `pandoc`: `--mathjax` - -{{% 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 %}} - -## Learn Markdown - -Markdown syntax is simple enough to learn in a single sitting. The following are excellent resources to get you up and running: - -* [Daring Fireball: Markdown, John Gruber (Creator of Markdown)][fireball] -* [Markdown Cheatsheet, Adam Pritchard][mdcheatsheet] -* [Markdown Tutorial (Interactive), Garen Torikian][mdtutorial] - -[`emojify` function]: /functions/emojify/ -[ascii]: http://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 -[mdtutorial]: http://www.markdowntutorial.com/ -[Miek Gieben's website]: https://miek.nl/2016/March/05/mmark-syntax-document/ -[mmark]: https://github.com/miekg/mmark -[mmarkgh]: https://github.com/miekg/mmark/wiki/Syntax -[org]: http://orgmode.org/ -[pandoc]: http://www.pandoc.org/ -[Pygments]: http://pygments.org/ -[rest]: http://docutils.sourceforge.net/rst.html -[sc]: /content-management/shortcodes/ -[sct]: /templates/shortcode-templates/ diff --git a/docs/content/content-management/front-matter.md b/docs/content/content-management/front-matter.md deleted file mode 100644 index a6a3f2403..000000000 --- a/docs/content/content-management/front-matter.md +++ /dev/null @@ -1,206 +0,0 @@ ---- -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 -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. - -{{< youtube Yh2xKRJGff4 >}} - -## Front Matter Formats - -Hugo supports three formats for front matter, each with their own identifying tokens. - -TOML -: identified by opening and closing `+++`. - -YAML -: identified by opening and closing `---`. - -JSON -: a single JSON object surrounded by '`{`' and '`}`', followed by a new line. - -### TOML Example - -``` -+++ -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" -+++ -``` - -### YAML Example - -``` ---- -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" ] -lastmod: 2015-12-23 -date: "2012-04-06" -categories: - - "Development" - - "VIM" -slug: "spf13-vim-3-0-release-and-new-website" ---- -``` - -### JSON Example - -``` -{ - "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" -} -``` - -## Front Matter Variables - -### Predefined - -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. - -`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. - -`date` -: the datetime at which the content was created; note this value is auto-populated according to Hugo's built-in [archetype][]. - -`description` -: the description for the content. - -`draft` -: if `true`, the content will not be rendered unless the `--buildDrafts` flag is passed to the `hugo` command. - -`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. - -`headless` -: if `true`, sets a leaf bundle to be [headless][headless-bundle]. - -`isCJKLanguage` -: if `true`, Hugo will explicitly treat the content as a CJK language; both `.Summary` and `.WordCount` work properly in CJK languages. - -`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] - -`lastmod` -: the datetime at which the content was last modified. - -`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]. - -`markup` -: **experimental**; specify `"rst"` for reStructuredText (requires`rst2html`) or `"md"` (default) for Markdown. - -`outputs` -: allows you to specify output formats specific to the content. See [output formats][outputs]. - -`publishDate` -: if in the future, content will not be rendered unless the `--buildFuture` flag is passed to `hugo`. - -`resources` -: used for configuring page bundle resources. See [Page Resources][page-resources]. - -`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. - -`taxonomies` -: these will use the field name of the plural form of the index; see the `tags` and `categories` in the above front matter examples. - -`title` -: the title for the content. - -`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. - -`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. - -`weight` -: used for [ordering your content in lists][ordering]. - -{{% 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 %}} - -### User-Defined - -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. - -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. - -``` -include_toc: true -show_comments: false -``` - - -## Order Content Through Front Matter - -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. - -## Override Global Markdown Configuration - -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]. - -## Front Matter Format Specs - -* [TOML Spec][toml] -* [YAML Spec][yaml] -* [JSON Spec][json] - -[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]: http://yaml.org/spec/ "Specification for YAML, YAML Ain't Markup Language" diff --git a/docs/content/content-management/image-processing/index.md b/docs/content/content-management/image-processing/index.md deleted file mode 100644 index 1f84ba04d..000000000 --- a/docs/content/content-management/image-processing/index.md +++ /dev/null @@ -1,195 +0,0 @@ ---- -title: "Image Processing" -description: "Image Page resources can be resized and cropped." -date: 2018-01-24T13:10:00-05:00 -lastmod: 2018-01-26T15:59:07-05:00 -linktitle: "Image Processing" -categories: ["content management"] -keywords: [bundle,content,resources,images] -weight: 4004 -draft: false -toc: true -menu: - docs: - parent: "content-management" - weight: 32 ---- - -## The Image Page Resource - -The `image` is a [Page Resource]({{< relref "content-management/page-resources" >}}), and the processing methods listed below does not work on images inside your `/static` folder. - - -To get all images in a [Page Bundle]({{< relref "content-management/organization#page-bundles" >}}): - - -```html -{{ with .Resources.ByType "image" }} -{{ end }} - -``` - -## Image Processing Methods - - -The `image` resource implements the methods `Resize`, `Fit` and `Fill`, each returning the transformed image using the specified dimensions and processing options. - -Resize -: Resizes the image to the specified width and height. - -```go -// Resize to a width of 600px and preserve ratio -{{ $image := $resource.Resize "600x" }} - -// Resize to a height of 400px and preserve ratio -{{ $image := $resource.Resize "x400" }} - -// Resize to a width 600px and a height of 400px -{{ $image := $resource.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" }} -``` - -Fill -: Resize and crop the image to match the given dimensions. Both height and width are required. - -```go -{{ $image := $resource.Fill "600x400" }} -``` - - -{{% note %}} -Image operations in Hugo currently **do not preserve EXIF data** as this is not supported by Go's [image package](https://github.com/golang/go/search?q=exif&type=Issues&utf8=%E2%9C%93). This will be improved on in the future. -{{% /note %}} - - -## Image Processing Options - -In addition to the dimensions (e.g. `600x400`), Hugo supports a set of additional image options. - - -JPEG Quality -: Only relevant for JPEG images, values 1 to 100 inclusive, higher is better. Default is 75. - -```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" }} -``` - -## 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: - -```html -{{}} -``` - - -{{% note %}} -**Tip:** Note the self-closing shortcode syntax above. The `imgproc` shortcode can be called both with and without **inner content**. -{{% /note %}} - -## Image Processing Config - -You can configure an `imaging` section in `config.toml` with default image processing options: - -```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" - -# Defatult JPEG quality setting. Default is 75. -quality = 75 - -# 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" - -``` - -All of the above settings can also be set per image procecssing. - -## Smart Cropping of Images - -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 libray author to improve this in the future. - -An example using the sunset image from above: - - -{{< imgproc sunset Fill "200x200 smart" />}} - - -## 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 -hugo --gc -``` - - -{{% note %}} -**GC** is short for **Garbage Collection**. -{{% /note %}} - - - diff --git a/docs/content/content-management/menus.md b/docs/content/content-management/menus.md deleted file mode 100644 index 1353ce0e2..000000000 --- a/docs/content/content-management/menus.md +++ /dev/null @@ -1,177 +0,0 @@ ---- -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 -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 %}} - -You can do this: - -* 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) - -## What is a Menu in Hugo? - -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`. - -{{% note "Menus on Multilingual Sites" %}} -If you make use of the [multilingual feature](/content-management/multilingual/), you can define language-independent menus. -{{% /note %}} - -A menu entry has the following properties (i.e., variables) available to it: - -`.URL` -: string - -`.Name` -: string - -`.Menu` -: string - -`.Identifier` -: string - -`.Pre` -: template.HTML - -`.Post` -: template.HTML - -`.Weight` -: int - -`.Parent` -: string - -`.Children` -: Menu - -Note that menus also have the following functions available as well: - -`.HasChildren` -: boolean - -Additionally, there are some relevant functions available to menus on a page: - -`.IsMenuCurrent` -: (menu string, menuEntry *MenuEntry ) boolean - -`.HasMenuCurrent` -: (menu string, menuEntry *MenuEntry) boolean - -## Add content to menus - -Hugo allows you to add content to a menu via the content's [front matter](/content-management/front-matter/). - -### 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 `config.toml`: - -{{< code file="config.toml" >}} -[[menu.main]] - name = "about hugo" - pre = "" - weight = -110 - identifier = "about" - url = "/about/" -[[menu.main]] - name = "getting started" - pre = "" - weight = -100 - url = "/getting-started/" -{{< /code >}} - -Here's the equivalent snippet in a `config.yaml`: - -{{< code file="config.yml" >}} -menu: - main: - - name: "about hugo" - pre: "" - weight: -110 - identifier: "about" - url: "/about/" - - name: "getting started" - pre: "" - weight: -100 - url: "/getting-started/" -{{< /code >}} - -{{% 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 %}} - -## Nesting - -All nesting of content is done via the `parent` field. - -The parent of an entry should be the identifier of another entry. The identifier should be unique (within a menu). - -The following order is used to determine an Identifier: - -`.Name > .LinkTitle > .Title` - -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. - -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. - -## Render Menus - -See [Menu Templates](/templates/menu-templates/) for information on how to render your site menus within your templates. - -[config]: /getting-started/configuration/ -[multilingual]: /content-management/multilingual/ -[sitevars]: /variables/ diff --git a/docs/content/content-management/multilingual.md b/docs/content/content-management/multilingual.md deleted file mode 100644 index d27195a9a..000000000 --- a/docs/content/content-management/multilingual.md +++ /dev/null @@ -1,386 +0,0 @@ ---- -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 -aliases: [/content/multilingual/,/content-management/multilingual/,/tutorials/create-a-multilingual-site/] -toc: true ---- - -You should define the available languages in a `languages` section in your site configuration. - -## Configure Languages - -The following is an example of a TOML site configuration for a multilingual Hugo project: - -{{< code file="config.toml" download="config.toml" >}} -DefaultContentLanguage = "en" -copyright = "Everything is mine" - -[params.navigation] -help = "Help" - -[languages] -[languages.en] -title = "My blog" -weight = 1 -[languages.en.params] -linkedin = "english-link" - -[languages.fr] -copyright = "Tout est à moi" -title = "Mon blog" -weight = 2 -[languages.fr.params] -linkedin = "lien-francais" -[languages.fr.params.navigation] -help = "Aide" -{{< /code >}} - -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). - -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. - -If you want all of the languages to be put below their respective language code, enable `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", "jp"] -``` - -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 jp" 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: - -```bash -[languages] -[languages.no] -baseURL = "https://example.no" -languageName = "Norsk" -weight = 1 -title = "På norsk" - -[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 -└── no -``` - -**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 file="bf-config.toml" >}} -[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" -{{< /code >}} - -## Translate Your Content - -Translated articles are identified by the name of the content file. - -### Examples of Translated Articles - -1. `/content/about.en.md` -2. `/content/about.fr.md` - -In this example, the `about.md` will be assigned the configured `defaultContentLanguage`. - -1. `/content/about.md` -2. `/content/about.fr.md` - -This way, you can slowly start to translate your current content without having to rename everything. If left unspecified, the default value for `defaultContentLanguage` is `en`. - -By having the same **directory and base filename**, the content pieces are linked together as translated pieces. - -You can also set the key used to link the translations explicitly in front matter: - -```yaml -translationKey: "my-story" -``` - -If you need distinct URLs per language, you can set the slug in the non-default language file. For example, you can define a custom slug for a French translation in the front matter of `content/about.fr.md` as follows: - -```yaml -slug: "a-propos" - -``` - -At render, Hugo will build both `/about/` and `/a-propos/` as properly linked translated pages. - -For merging of content from other languages (i.e. missing content translations), see [lang.Merge](/functions/lang.merge/). - -## Link to Translated Content - -To create a list of links to translated content, use a template similar to the following: - -{{< code 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, be it for a [single content page][contenttemplate] or the [homepage][]. 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 - -`.AllTranslations` on a `Page` can be used to list all translations, including itself. Called on the home page it can be used to build a language navigator: - - -{{< code 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`: - -``` -[home] -other = "Home" -``` - -Often you will want to use to the page variables in the translations strings. To do that, pass on the "." context when calling `i18n`: - -``` -{{ i18n "wordCount" . }} -``` - -This uses a definition like this one in `i18n/en-US.toml`: - -``` -[wordCount] -other = "This article has {{ .WordCount }} words." -``` -An example of singular and plural form: - -``` -[readingTime] -one = "One minute read" -other = "{{.Count}} minutes read" -``` -And then in the template: - -``` -{{ i18n "readingTime" .ReadingTime }} -``` -To track down missing translation strings, run Hugo with the `--i18n-warnings` flag: - -``` - hugo --i18n-warnings | grep i18n -i18n|MISSING_TRANSLATION|en|wordCount -``` - -## Customize Dates - -At the time of this writing, Golang does not yet have support for internationalized locales, 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: - -~~~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" -~~~ - -... then index the non-English date names in your templates like so: - -~~~html - -~~~ - -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. - -## Menus - -You can define your menus for each language independently. The [creation of a menu][menus] works analogous to earlier versions of Hugo, except that they have to be defined in their language-specific block in the configuration file: - -``` -defaultContentLanguage = "en" - -[languages.en] -weight = 0 -languageName = "English" - -[[languages.en.menu.main]] -url = "/" -name = "Home" -weight = 0 - - -[languages.de] -weight = 10 -languageName = "Deutsch" - -[[languages.de.menu.main]] -url = "/" -name = "Startseite" -weight = 0 -``` - -The rendering of the main navigation works as usual. `.Site.Menus` will just contain the menu of the current language. Pay attention to the generation of the menu links. `absLangURL` takes care that you link to the correct locale of your website. Otherwise, both menu entries would link to the English version as the default content language that resides in the root directory. - -``` -
      - {{- $currentPage := . -}} - {{ range .Site.Menus.main -}} -
    • - {{ .Name }} -
    • - {{- end }} -
    - -``` - -## 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 suited for production environments. -{{% /note %}} - -For merging of content from other languages (i.e. missing content translations), see [lang.Merge](/functions/lang.merge/). - -## 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 `.URL` -* Be constructed with - * The [`relLangURL` template function][rellangurl] or the [`absLangURL` template function][abslangurl] **OR** - * 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. - -[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/ diff --git a/docs/content/content-management/organization/1-featured-content-bundles.png b/docs/content/content-management/organization/1-featured-content-bundles.png deleted file mode 100644 index 1706a29d6..000000000 Binary files a/docs/content/content-management/organization/1-featured-content-bundles.png and /dev/null differ diff --git a/docs/content/content-management/organization/index.md b/docs/content/content-management/organization/index.md deleted file mode 100644 index b810f6179..000000000 --- a/docs/content/content-management/organization/index.md +++ /dev/null @@ -1,240 +0,0 @@ ---- -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 -aliases: [/content/sections/] -toc: true ---- - -## 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. - -{{% 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 %}} - - -{{% note %}} -The bundle documentation is **work in progress**. We will publish more comprehensive docs about this soon. -{{% /note %}} - - -# 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][]. - -Without any additional configuration, the following will just work: - -``` -. -└── content - └── about - | └── _index.md // <- https://example.com/about/ - ├── post - | ├── firstpost.md // <- https://example.com/post/firstpost/ - | ├── happy - | | └── ness.md // <- https://example.com/post/happy/ness/ - | └── secondpost.md // <- https://example.com/post/secondpost/ - └── quote - ├── first.md // <- https://example.com/quote/first/ - └── second.md // <- https://example.com/quote/second/ -``` - -## 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.com"` in your [site's configuration file][config]. - -### Index Pages: `_index.md` - -`_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 %}} -**Tip:** You can get a reference to the content and metadata in `_index.md` using the [`.Site.GetPage` function](/functions/getpage/). -{{% /note %}} - -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: - - -``` -. url -. ⊢--^-⊣ -. path slug -. ⊢--^-⊣⊢---^---⊣ -. filepath -. ⊢------^------⊣ -content/posts/_index.md -``` - -At build, this will output to the following destination with the associated values: - -``` - - url ("/posts/") - ⊢-^-⊣ - baseurl section ("posts") -⊢--------^---------⊣⊢-^-⊣ - permalink -⊢----------^-------------⊣ -https://example.com/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`). - - -### Single Pages in Sections - -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`: - - -``` - path ("posts/my-first-hugo-post.md") -. ⊢-----------^------------⊣ -. section slug -. ⊢-^-⊣⊢--------^----------⊣ -content/posts/my-first-hugo-post.md -``` - -At the time Hugo builds your site, the content will be output to the following destination: - -``` - - url ("/posts/my-first-hugo-post/") - ⊢------------^----------⊣ - baseurl section slug -⊢--------^--------⊣⊢-^--⊣⊢-------^---------⊣ - permalink -⊢--------------------^---------------------⊣ -https://example.com/posts/my-first-hugo-post/index.html -``` - - -## 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. - -### `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. - -### `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 - -### `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 - -### `url` - -The `url` is the relative URL for the piece of content. The `url` - -* 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/ -[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/ diff --git a/docs/content/content-management/page-bundles.md b/docs/content/content-management/page-bundles.md deleted file mode 100644 index 09aeae8ea..000000000 --- a/docs/content/content-management/page-bundles.md +++ /dev/null @@ -1,182 +0,0 @@ ---- -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 ---- - -Page Bundles are a way to group [Page Resources](/content-management/page-resources/). - -A Page Bundle can be one of: - -- Leaf Bundle (leaf means it has no children) -- Branch Bundle (home page, section, taxonomy terms, taxonomy list) - -| | Leaf Bundle | Branch Bundle | -|-----------------|--------------------------------------------------------|---------------------------------------------------------| -| Usage           | Collection of resources (pages, images etc.) for single pages | Collection of non-page resources (images etc.)for list pages | -| Index file name | `index.md` [^fn:1] | `_index.md` [^fn:1] | -| Layout type | `single` | `list` | -| Nesting | Doesn't allow nesting of more bundles under it | Allows nesting of leaf/branch bundles under it | -| Example | `content/posts/my-post/index.md` | `content/posts/_index.md` | - - -## Leaf Bundles {#leaf-bundles} - -A _Leaf Bundle_ is a directory at any hierarchy within the `content/` -directory, that contains an **`index.md`** file. - -### Examples of Leaf Bundle organization {#examples-of-leaf-bundle-organization} - -```text -content/ -├── about -│ ├── index.md -├── posts -│ ├── my-post -│ │ ├── content1.md -│ │ ├── content2.md -│ │ ├── image1.jpg -│ │ ├── image2.png -│ │ └── index.md -│ └── my-another-post -│    └── index.md -│ -└── another-section - ├── .. -    └── not-a-leaf-bundle - ├── .. -    └── another-leaf-bundle -    └── index.md -``` - -In the above example `content/` directory, there are four leaf -bundles: - -about -: This leaf bundle is at the root level (directly under - `content` directory) and has only the `index.md`. - -my-post -: This leaf bundle has the `index.md`, two other content - Markdown files and two image files. - -my-another-post -: This leaf bundle has only the `index.md`. - -another-leaf-bundle -: This leaf bundle is nested under couple of - directories. This bundle also has only the `index.md`. - -{{% 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 %}} - - -### 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: - -```html -{{ $headless := .Site.GetPage "page" "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} - -```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 -``` - -In the above example `content/` directory, there are two branch -bundles (and a leaf bundle): - -`branch-bundle-1` -: This branch bundle has the `_index.md`, two - other content Markdown files and two image files. - -`branch-bundle-2` -: This branch bundle has the `_index.md` and a - nested leaf bundle. - -{{% note %}} -The hierarchy depth at which a branch bundle is created does not -matter. -{{% /note %}} - -[^fn:1]: The `.md` extension is just an example. The extension can be `.html`, `.json` or any of any valid MIME type. diff --git a/docs/content/content-management/page-resources.md b/docs/content/content-management/page-resources.md deleted file mode 100644 index f3b12d8c4..000000000 --- a/docs/content/content-management/page-resources.md +++ /dev/null @@ -1,184 +0,0 @@ ---- -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 ---- - -## Properties - -ResourceType -: The main type of the resource. For example, a file of MIME type `image/jpg` has for ResourceType `image`. - -Name -: Default value is the filename (relative to the owning page). Can be set in front matter. - -Title -: Default blank. 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. - -## 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/*" }} -``` - -GetMatch -: Same as `Match` but will return the first match. - -### 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" 🚫 - -``` - -## Page Resources Metadata - -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). - -{{% note %}} -Resources of type `page` get `Title` etc. from their own front matter. -{{% /note %}} - -name -: Sets the value returned in `Name`. - -{{% warning %}} -The methods `Match` and `GetMatch` use `Name` to match the resources. -{{%/ warning %}} - -title -: Sets the value returned in `Title` - -params -: A map of custom key/values. - - -### Resources metadata: YAML Example - -~~~yaml -title: Application -date : 2018-01-25 -resources : -- src : "images/sunset.jpg" - name : "header" -- src : "documents/photo_specs.pdf" - title : "Photo Specifications" - params: - icon : "photo" -- src : "documents/guide.pdf" - title : "Instruction Guide" -- src : "documents/checklist.pdf" - title : "Document Checklist" -- src : "documents/payment.docx" - title : "Proof of Payment" -- src : "**.pdf" - name : "pdf-file-:counter" - params : - icon : "pdf" -- src : "**.docx" - params : - icon : "word" -~~~ - -### Resources metadata: TOML Example - -~~~toml -title = Application -date : 2018-01-25 -[[resources]] - src = "images/sunset.jpg" - name = "header" -[[resources]] - src = "documents/photo_specs.pdf" - title = "Photo Specifications" - [resources.params] - icon = "photo" -[[resources]] - src = "documents/guide.pdf" - title = "Instruction Guide" -[[resources]] - src = "documents/checklist.pdf" - title = "Document Checklist" -[[resources]] - src = "documents/payment.docx" - title = "Proof of Payment" -[[resources]] - src = "**.pdf" - name = "pdf-file-:counter" - [resources.params] - icon = "pdf" -[[resources]] - src = "**.docx" - [resources.params] - icon = "word" -~~~ - - -From the example above: - -- `sunset.jpg` will receive a new `Name` and can now be found with `.GetMatch "header"`. -- `documents/photo_specs.pdf` will get the `photo` icon. -- `documents/checklist.pdf`, `documents/guide.pdf` and `documents/payment.docx` will get `Title` as set by `title`. -- Every `PDF` in the bundle except `documents/photo_specs.pdf` will get the `pdf` icon. -- All `PDF` files will get a new `Name`. The `name` parameter contains a special placeholder [`:counter`](#counter), 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 %}} - -### The `:counter` placeholder in `name` and `title` - -The `:counter` is a special placeholder recognized in `name` and `title` parameters `resources`. - -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: - -~~~toml -[[resources]] - src = "*specs.pdf" - title = "Specification #:counter" -[[resources]] - src = "**.pdf" - name = "pdf-file-:counter" -~~~ - -the `Name` and `Title` will be assigned to the resource files as follows: - -| Resource file | `Name` | `Title` | -|-------------------|-------------------|-----------------------| -| checklist.pdf | `"pdf-file-1.pdf` | `"checklist.pdf"` | -| 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"` | diff --git a/docs/content/content-management/related.md b/docs/content/content-management/related.md deleted file mode 100644 index 8ae6e79ce..000000000 --- a/docs/content/content-management/related.md +++ /dev/null @@ -1,138 +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 ---- - -{{% 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 %}} - -## List Related Content - -To list up to 5 related pages 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 >}} - -The full set of methods available on the page lists can bee seen in this Go interface: - -```go -// A PageGenealogist finds related pages in a page collection. This interface is implemented -// by Pages and PageGroup, which makes it available as `{{ .RegularPages.Related . }}` etc. -type PageGenealogist interface { - - // Template example: - // {{ $related := .RegularPages.Related . }} - Related(doc related.Document) (Pages, error) - - // Template example: - // {{ $related := .RegularPages.RelatedIndices . "tags" "date" }} - RelatedIndices(doc related.Document, indices ...interface{}) (Pages, error) - - // Template example: - // {{ $related := .RegularPages.RelatedTo ( keyVals "tags" "hugo" "rocks") ( keyVals "date" .Date ) }} - RelatedTo(args ...types.KeyValues) (Pages, error) -} -``` -## 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. - -{{% 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 %}} - -Below is a sample `config.toml` section: - -``` -[related] - -# Only include matches with rank >= threshold. This is a normalized rank between 0 and 100. -threshold = 80 - -# To get stable "See also" sections we, by default, exclude newer related pages. -includeNewer = false - -# Will lower case keywords in both queries and in the indexes. -toLower = false - -[[related.indices]] -name = "keywords" -weight = 150 -[[related.indices]] -name = "author" -toLower = true -weight = 30 -[[related.indices]] -name = "tags" -weight = 100 -[[related.indices]] -name = "date" -weight = 10 -pattern = "2006" -``` -### 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. - - - - - - - diff --git a/docs/content/content-management/sections.md b/docs/content/content-management/sections.md deleted file mode 100644 index e53e0feb7..000000000 --- a/docs/content/content-management/sections.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -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 -aliases: [/content/sections/] -toc: true ---- - -A **Section** is a collection of pages that gets defined based on the -organization structure under the `content/` directory. - -By default, all the **first-level** directories under `content/` form their own -sections (**root sections**). - -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 -``` - -**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`).** - -{{% 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`). - -If you need a specific template for a sub-section, you need to adjust either the `type` or `layout` in front matter. -{{% /note %}} - -## Example: Breadcrumb Navigation - -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: - -{{< 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 >}} - -## Section Page Variables and Methods - -Also see [Page Variables](/variables/page/). - -{{< readfile file="/content/readfiles/sectionvars.md" markdown="true" >}} - -## Content Section Lists - -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 *Section* vs Content *Type* - -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`. - -[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 diff --git a/docs/content/content-management/shortcodes.md b/docs/content/content-management/shortcodes.md deleted file mode 100644 index e5bc85265..000000000 --- a/docs/content/content-management/shortcodes.md +++ /dev/null @@ -1,424 +0,0 @@ ---- -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: 2017-03-31 -menu: - docs: - parent: "content-management" - weight: 35 -weight: 35 #rem -categories: [content management] -keywords: [markdown,content,shortcodes] -draft: false -aliases: [/extras/shortcodes/] -toc: true ---- - -## What a Shortcode is - -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. - -Hugo created **shortcodes** to circumvent these limitations. - -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. - -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. - -## Use Shortcodes - -{{< youtube 2xkNJL4gJ9E >}} - -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. - -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 */%}} -``` - -``` -{{}} A bunch of code here {{}} -``` - -The examples above use two different delimiters, the difference being the `%` character in the first and the `<>` characters in the second. - -### Shortcodes with Markdown - -The `%` character indicates that the shortcode's inner content---called in the [shortcode template][sctemps] with the [`.Inner` variable][scvars]---needs further processing by the page's rendering processor (i.e. markdown via Blackfriday). In the following example, Blackfriday would convert `**World**` to `World`: - -``` -{{%/* myshortcode */%}}Hello **World!**{{%/* /myshortcode */%}} -``` - -### 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!

    {{}} -``` - -### Nested Shortcodes - -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]. - -## Use Hugo's Built-in Shortcodes - -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. - -### `figure` - -`figure` is an extension of the image syntax in markdown, which does not provide a shorthand for the more semantic [HTML5 `
    ` element][figureelement]. - -The `figure` shortcode can use the following named parameters: - -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. - -class -: `class` attribute of the HTML `figure` tag. - -height -: `height` attribute of the image. - -width -: `width` attribute of the image. - -attr -: Image attribution text. - -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 -``` - -We can embed the gist in our content via username and gist ID pulled from the URL: - -``` -{{}} -``` - -#### Example `gist` Input - -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 .Data.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 .Data.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/ -``` - -#### Example `instagram` Input - -{{< 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 >}} - - -### `ref` and `relref` - -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]({{}}) -``` - -#### Example `ref` and `relref` Output - -Assuming that standard Hugo pretty URLs are turned on. - -``` -Neat -Who -``` - -### `speakerdeck` - -To embed slides from [Speaker Deck][], click on "< /> Embed" (under Share right next to the template on Speaker Deck) and copy the URL: - -``` - -``` - -#### `speakerdeck` Example Input - -Extract the value from the field `data-id` and pass it to the shortcode: - -{{< code file="speakerdeck-example-input.md" >}} -{{}} -{{< /code >}} - -#### `speakerdeck` Example Output - -{{< output file="speakerdeck-example-input.md" >}} -{{< speakerdeck 4e8126e72d853c0060001f97 >}} -{{< /output >}} - -#### `speakerdeck` Example Display - -For the preceding `speakerdeck` 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. - -{{< speakerdeck 4e8126e72d853c0060001f97 >}} - -### `tweet` - -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 -``` - -#### Example `tweet` Input - -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 -``` - -#### Example `vimeo` Input - -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 >}} - -{{% note %}} -The preceding shortcode makes use of a Hugo-specific template function called `highlight`, which uses [Pygments](http://pygments.org) to add syntax highlighting to the example HTML code block. See the [developer tools page on syntax highlighting](/tools/syntax-highlighting/) for more information. -{{% /note %}} - -### 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`: - -``` - - -``` - -## 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]. - -[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/templates/single-page-templates.md b/docs/content/templates/single-page-templates.md deleted file mode 100644 index 79e1312b2..000000000 --- a/docs/content/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. - -### `post/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/post/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]: http://spf13.com/ -[`with`]: /functions/with/ diff --git a/docs/content/templates/sitemap-template.md b/docs/content/templates/sitemap-template.md deleted file mode 100644 index 98a4c2b1d..000000000 --- a/docs/content/templates/sitemap-template.md +++ /dev/null @@ -1,75 +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. - -## Hugo’s sitemap.xml - -This template respects the version 0.9 of the [Sitemap Protocol](http://www.sitemaps.org/protocol.html). - -``` - - {{ 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 }} - - {{ end }} - -``` - -{{% note %}} -Hugo will automatically add the following header line to this file -on render. Please don't include this in the template as it's not valid HTML. - -`` -{{% /note %}} - -## Configure `sitemap.xml` - -Defaults for ``, `` and `filename` values can be set in the site's config file, e.g.: - -``` -[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/ \ No newline at end of file diff --git a/docs/content/templates/taxonomy-templates.md b/docs/content/templates/taxonomy-templates.md deleted file mode 100644 index f3b349a39..000000000 --- a/docs/content/templates/taxonomy-templates.md +++ /dev/null @@ -1,347 +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. - -``` -[]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. - -``` -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 it's 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 `.Data.Pages` as such: - -``` -
      - {{ range .Data.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 - -``` -
      - {{ $data := .Data }} - {{ range $key, $value := .Data.Terms.Alphabetical }} -
    • {{ $value.Name }} {{ $value.Count }}
    • - {{ end }} -
    -``` - -### Order by Popularity Example - -``` -
      - {{ $data := .Data }} - {{ range $key, $value := .Data.Terms.ByCount }} -
    • {{ $value.Name }} {{ $value.Count }}
    • - {{ end }} -
    -``` - -### Order by Least Popular Example - -``` -
      - {{ $data := .Data }} - {{ range $key, $value := .Data.Terms.ByCount.Reverse }} -
    • {{ $value.Name }} {{ $value.Count }}
    • - {{ 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 - -``` -
      - {{ range .Params.tags }} -
    • {{ . }}
    • - {{ 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 - -``` -{{ if .Params.directors }} - Director{{ if gt (len .Params.directors) 1 }}s{{ end }}: - {{ range $index, $director := .Params.directors }}{{ if gt $index 0 }}, {{ 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 - -``` - -``` - -## 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 - -``` - -``` - -## 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 - -``` -
      - {{ range $name, $taxonomy := .Site.Taxonomies.tags }} -
    • {{ $name }}
    • - {{ 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 $taxonomyname, $taxonomy := .Site.Taxonomies }} -
    • {{ $taxonomyname }} -
        - {{ range $key, $value := $taxonomy }} -
      • {{ $key }}
      • - - {{ 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: - -{{< code file="links-to-all-tags" >}} -
      - {{ range ($.Site.GetPage "taxonomyTerm" "tags").Pages }} -
    • {{ .Title}}
    • - {{ 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/templates/template-debugging.md b/docs/content/templates/template-debugging.md deleted file mode 100644 index e94a073af..000000000 --- a/docs/content/templates/template-debugging.md +++ /dev/null @@ -1,81 +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: http://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](http://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 .Data.Pages }} - {{/* The context, ".", is now each one of the pages as it goes through the loop */}} - {{ printf "%#v" . }} -{{ end }} -``` - -{{% note "`.Data.Pages` on the Homepage" %}} -`.Data.Pages` on the homepage is equivalent to `.Site.Pages`. -{{% /note %}} - -## 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/ \ No newline at end of file diff --git a/docs/content/templates/views.md b/docs/content/templates/views.md deleted file mode 100644 index ac863646b..000000000 --- a/docs/content/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 `post` 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/ - ▾ post/ - 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 .Data.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]: http://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/themes/_index.md b/docs/content/themes/_index.md deleted file mode 100644 index 9e2bc170b..000000000 --- a/docs/content/themes/_index.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -title: Themes -linktitle: Themes Overview -description: Install, use, and create Hugo themes. -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2017-02-01 -menu: - docs: - parent: "themes" - weight: 01 -weight: 01 -sections_weight: 01 -categories: [themes] -keywords: [themes,introduction,overview] -draft: false -aliases: [/themes/overview/] -toc: false ---- - -Hugo provides a robust theming system that is easy to implement yet feature complete. You can view the themes created by the Hugo community on the [Hugo themes website][hugothemes]. - -Hugo themes are powered by the excellent Go template library and are designed to reduce code duplication. They are easy to both customize and keep in synch with the upstream theme. - -[goprimer]: /templates/introduction/ -[hugothemes]: http://themes.gohugo.io/ diff --git a/docs/content/themes/creating.md b/docs/content/themes/creating.md deleted file mode 100644 index a62f7c71b..000000000 --- a/docs/content/themes/creating.md +++ /dev/null @@ -1,87 +0,0 @@ ---- -title: Create a Theme -linktitle: Create a Theme -description: The `hugo new theme` command will scaffold the beginnings of a new theme for you to get you on your way. -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2017-02-01 -categories: [themes] -keywords: [themes, source, organization, directories] -menu: - docs: - parent: "themes" - weight: 30 -weight: 30 -sections_weight: 30 -draft: false -aliases: [/themes/creation/,/tutorials/creating-a-new-theme/] -toc: true -wip: true ---- - -{{% warning "Use Relative Links" %}} -If you're creating a theme with plans to share it with the community, use relative URLs since users of your theme may not publish from the root of their website. See [relURL](/functions/relurl) and [absURL](/functions/absurl). -{{% /warning %}} - -Hugo can initialize a new blank theme directory within your existing `themes` using the `hugo new` command: - -``` -hugo new theme [name] -``` - -## Theme Components - -A theme consists of templates and static assets such as javascript and css files. Themes can also provide [archetypes][], which are archetypal content types used by the `hugo new` command to scaffold new content files with preconfigured front matter. - - -{{% note "Use the Hugo Generator Tag" %}} -The [`.Hugo.Generator`](/variables/hugo/) tag is included in all themes featured in the [Hugo Themes Showcase](http://themes.gohugo.io). We ask that you include the generator tag in all sites and themes you create with Hugo to help the core team track Hugo's usage and popularity. -{{% /note %}} - -## Layouts - -Hugo is built around the concept that things should be as simple as possible. -Fundamentally, website content is displayed in two different ways, a single -piece of content and a list of content items. With Hugo, a theme layout starts -with the defaults. As additional layouts are defined, they are used for the -content type or section they apply to. This keeps layouts simple, but permits -a large amount of flexibility. - -## Single Content - -The default single file layout is located at `layouts/_default/single.html`. - -## List of Contents - -The default list file layout is located at `layouts/_default/list.html`. - -## Partial Templates - -Theme creators should liberally use [partial templates](/templates/partials/) -throughout their theme files. Not only is a good DRY practice to include shared -code, but partials are a special template type that enables the themes end user -to be able to overwrite just a small piece of a file or inject code into the -theme from their local /layouts. These partial templates are perfect for easy -injection into the theme with minimal maintenance to ensure future -compatibility. - -## Static - -Everything in the static directory will be copied directly into the final site -when rendered. No structure is provided here to enable complete freedom. It is -common to organize the static content into: - -``` -/css -/js -/img -``` - -The actual structure is entirely up to you, the theme creator, on how you would like to organize your files. - -## Archetypes - -If your theme makes use of specific keys in the front matter, it is a good idea -to provide an archetype for each content type you have. [Read more about archetypes][archetypes]. - -[archetypes]: /content-management/archetypes/ diff --git a/docs/content/themes/customizing.md b/docs/content/themes/customizing.md deleted file mode 100644 index 3444880f2..000000000 --- a/docs/content/themes/customizing.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -title: Customize a Theme -linktitle: Customize a Theme -description: Customize a theme by overriding theme layouts and static assets in your top-level project directories. -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2017-02-01 -categories: [themes] -keywords: [themes, source, organization, directories] -menu: - docs: - parent: "themes" - weight: 20 -weight: 20 -sections_weight: 20 -draft: false -aliases: [/themes/customize/] -toc: true -wip: true ---- - -The following are key concepts for Hugo site customization with themes. Hugo permits you to supplement *or* override any theme template or static file with files in your working directory. - -{{% note %}} -When you use a theme cloned from its git repository, do not edit the theme's files directly. Instead, theme customization in Hugo is a matter of *overriding* the templates made available to you in a theme. This provides the added flexibility of tweaking a theme to meet your needs while staying current with a theme's upstream. -{{% /note %}} - -## Override Static Files - -There are times where you want to include static assets that differ from versions of the same asset that ships with a theme. - -For example, a theme may use jQuery 1.8 in the following location: - -``` -/themes//static/js/jquery.min.js -``` - -You want to replace the version of jQuery that ships with the theme with the newer `jquery-3.1.1.js`. The easiest way to do this is to replace the file *with a file of the same name* in the same relative path in your project's root. Therefore, change `jquery-3.1.1.js` to `jquery.min.js` so that it is *identical* to the theme's version and place the file here: - -``` -/static/js/jquery.min.js -``` - -## Override Template Files - -Anytime Hugo looks for a matching template, it will first check the working directory before looking in the theme directory. If you would like to modify a template, simply create that template in your local `layouts` directory. - -The [template lookup order][lookup] explains the rules Hugo uses to determine which template to use for a given piece of content. Read and understand these rules carefully. - -This is especially helpful when the theme creator used [partial templates][partials]. These partial templates are perfect for easy injection into the theme with minimal maintenance to ensure future compatibility. - -For example: - -``` -/themes//layouts/_default/single.html -``` - -Would be overwritten by - -``` -/layouts/_default/single.html -``` - -{{% warning %}} -This only works for templates that Hugo "knows about" (i.e., that follow its convention for folder structure and naming). If a theme imports template files in a creatively named directory, Hugo won’t know to look for the local `/layouts` first. -{{% /warning %}} - -## Override Archetypes - -If the archetype that ships with the theme for a given content type (or all content types) doesn’t fit with how you are using the theme, feel free to copy it to your `/archetypes` directory and make modifications as you see fit. - -{{% warning "Beware of `layouts/_default`" %}} -The `_default` directory is a very powerful force in Hugo, especially as it pertains to overwriting theme files. If a default file is located in the local [archetypes](/content-management/archetypes/) or layout directory (i.e., `archetypes/default.md` or `/layouts/_default/*.html`, respectively), it will override the file of the same name in the corresponding theme directory (i.e., `themes//archetypes/default.md` or `themes//layout/_defaults/*.html`, respectively). - -It is usually better to override specific files; i.e. rather than using `layouts/_default/*.html` in your working directory. -{{% /warning %}} - -[archetypes]: /content-management/archetypes/ -[lookup]: /templates/lookup-order/ -[partials]: /templates/partials/ \ No newline at end of file diff --git a/docs/content/themes/installing-and-using-themes.md b/docs/content/themes/installing-and-using-themes.md deleted file mode 100644 index 93d814231..000000000 --- a/docs/content/themes/installing-and-using-themes.md +++ /dev/null @@ -1,114 +0,0 @@ ---- -title: Install and Use Themes -linktitle: Install and Use Themes -description: Install and use a theme from the Hugo theme showcase easily through the CLI. -date: 2017-02-01 -publishdate: 2017-02-01 -lastmod: 2017-02-01 -categories: [themes] -keywords: [install, themes, source, organization, directories,usage] -menu: - docs: - parent: "themes" - weight: 10 -weight: 10 -sections_weight: 10 -draft: false -aliases: [/themes/usage/,/themes/installing/] -toc: true -wip: true ---- - -{{% note "No Default Theme" %}} -Hugo currently doesn’t ship with a “default” theme. This decision is intentional. We leave it up to you to decide which theme best suits your Hugo project. -{{% /note %}} - -## Assumptions - -1. You have already [installed Hugo on your development machine][install]. -2. You have git installed on your machine and you are familiar with basic git usage. - -## Install Themes - -{{< youtube L34JL_3Jkyc >}} - -The community-contributed themes featured on [themes.gohugo.io](//themes.gohugo.io/) are hosted in a [centralized GitHub repository][themesrepo]. The Hugo Themes Repo at is really a meta repository that contains pointers to a set of contributed themes. - -{{% warning "Get `git` First" %}} -Without [Git](https://git-scm.com/) installed on your computer, none of the following theme instructions will work. Git tutorials are beyond the scope of the Hugo docs, but [GitHub](https://try.github.io/) and [codecademy](https://www.codecademy.com/learn/learn-git) offer free, interactive courses for beginners. -{{% /warning %}} - -### Install All Themes - -You can install *all* available Hugo themes by cloning the entire [Hugo Theme repository on GitHub][themesrepo] from within your working directory. Depending on your internet connection the download of all themes might take a while. - -``` -git clone --depth 1 --recursive https://github.com/gohugoio/hugoThemes.git themes -``` - -Before you use a theme, remove the .git folder in that theme's root folder. Otherwise, this will cause problem if you deploy using Git. - -### Install a Single Theme - -Change into the `themes` directory and download a theme by replacing `URL_TO_THEME` with the URL of the theme repository: - -``` -cd themes -git clone URL_TO_THEME -``` - -The following example shows how to use the "Hyde" theme, which has its source hosted at : - -{{< code file="clone-theme.sh" >}} -cd themes -git clone https://github.com/spf13/hyde -{{< /code >}} - -Alternatively, you can download the theme as a `.zip` file, unzip the theme contents, and then move the unzipped source into your `themes` directory. - -{{% note "Read the `README`" %}} -Always review the `README.md` file that is shipped with a theme. Often, these files contain further instructions required for theme setup; e.g., copying values from an example configuration file. -{{% /note %}} - -## Theme Placement - -Please make certain you have installed the themes you want to use in the -`/themes` directory. This is the default directory used by Hugo. Hugo comes with the ability to change the themes directory via the [`themesDir` variable in your site configuration][config], but this is not recommended. - -## Use Themes - -Hugo applies the decided theme first and then applies anything that is in the local directory. This allows for easier customization while retaining compatibility with the upstream version of the theme. To learn more, go to [customizing themes][customizethemes]. - -### Command Line - -There are two different approaches to using a theme with your Hugo website: via the Hugo CLI or as part of your [site configuration file][config]. - -To change a theme via the Hugo CLI, you can pass the `-t` [flag][] when building your site: - -``` -hugo -t themename -``` - -Likely, you will want to add the theme when running the Hugo local server, especially if you are going to [customize the theme][customizethemes]: - -``` -hugo server -t themename -``` - -### `config` File - -If you've already decided on the theme for your site and do not want to fiddle with the command line, you can add the theme directly to your [site configuration file][config]: - -``` -theme: themename -``` - -{{% note "A Note on `themename`" %}} -The `themename` in the above examples must match the name of the specific theme directory inside `/themes`; i.e., the directory name (likely lowercase and urlized) rather than the name of the theme displayed in the [Themes Showcase site](http://themes.gohugo.io). -{{% /note %}} - -[customizethemes]: /themes/customizing/ -[flag]: /getting-started/usage/ "See the full list of flags in Hugo's basic usage." -[install]: /getting-started/installing/ -[config]: /getting-started/configuration/ "Learn how to customize your Hugo website configuration file in yaml, toml, or json." -[themesrepo]: https://github.com/gohugoio/hugoThemes diff --git a/docs/content/tools/_index.md b/docs/content/tools/_index.md deleted file mode 100644 index 47cfeb1e3..000000000 --- a/docs/content/tools/_index.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -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] -keywords: [] -menu: - docs: - parent: "tools" - weight: 01 -weight: 01 -sections_weight: 01 -draft: false -aliases: [/tools/] ---- - -One of Hugo's greatest strengths is it's 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/tools/editors.md b/docs/content/tools/editors.md deleted file mode 100644 index 419a2a04c..000000000 --- a/docs/content/tools/editors.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -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 ---- - -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). - -## 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. - -## Vim - -* [Vim Hugo Helper](https://github.com/robertbasic/vim-hugo-helper). A small Vim plugin to help me with writing posts with Hugo. - -## Atom - -* [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. - -[formats]: /content-management/formats/ diff --git a/docs/content/tools/frontends.md b/docs/content/tools/frontends.md deleted file mode 100644 index f41751b49..000000000 --- a/docs/content/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! -* [caddy-hugo](https://github.com/hacdias/caddy-hugo). `caddy-hugo` is an add-on for [Caddy](https://caddyserver.com/) that delivers a good UI to edit the content of your Hugo website. -* [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. - - -## 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 simple CMS for Jekyll and Hugo websites with support for GitHub, GitLab, and Bitbucket. Every time an update is made via the CMS, Forestry will commit changes back to your repo and will compile/deploy your website to S3, GitHub Pages, FTP, etc. -* [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/tools/migrations.md b/docs/content/tools/migrations.md deleted file mode 100644 index ef4f169d3..000000000 --- a/docs/content/tools/migrations.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -title: Migrate to Hugo -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 ---- - -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. - -{{% 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. - -## Jekyll - -Alternatively, you can use the new [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. - -## Ghost - -- [ghostToHugo](https://github.com/jbarone/ghostToHugo) - Convert Ghost blog posts and export them to Hugo. - -## Octopress - -- [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. - -## 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.) - -## 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. - -## Drupal - -- [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. - -## 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. - -## Contentful - -- [contentful2hugo](https://github.com/ArnoNuyts/contentful2hugo) - A tool to create content-files for Hugo from content on [Contentful](https://www.contentful.com/). diff --git a/docs/content/tools/other.md b/docs/content/tools/other.md deleted file mode 100644 index 0502e1cdf..000000000 --- a/docs/content/tools/other.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -title: Other Hugo 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 ---- - -And for all the other small things 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. diff --git a/docs/content/tools/search.md b/docs/content/tools/search.md deleted file mode 100644 index 149bdfe05..000000000 --- a/docs/content/tools/search.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -title: Search for your Hugo Website -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 ---- - -A static website with a dynamic search function? Yes. As alternatives to embeddable scripts from Google or other search engines, you can provide your visitors a custom search 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](http://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](http://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](http://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! - -## Commercial Search Services - -* [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. diff --git a/docs/content/tools/starter-kits.md b/docs/content/tools/starter-kits.md deleted file mode 100644 index 0ce81cc4e..000000000 --- a/docs/content/tools/starter-kits.md +++ /dev/null @@ -1,40 +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: 2017-02-22 -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 %}} - -* [Victor Hugo][]. Victor Hugo is a Hugo boilerplate for creating truly epic websites using Gulp + 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, Netlify CMS, Gulp, Linting, SCSS, ES6 & more. It's actively maintained and contributions are always welcome. - - -[addkit]: https://github.com/gohugoio/hugo/edit/master/docs/content/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/ -[hugulp]: https://github.com/jbrodriguez/hugulp -[Victor Hugo]: https://github.com/netlify/victor-hugo -[Atlas]: https://github.com/indigotree/atlas \ No newline at end of file diff --git a/docs/content/troubleshooting/_index.md b/docs/content/troubleshooting/_index.md deleted file mode 100644 index 3b0e93725..000000000 --- a/docs/content/troubleshooting/_index.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -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: ---- - -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/troubleshooting/build-performance.md b/docs/content/troubleshooting/build-performance.md deleted file mode 100644 index bc4d30d54..000000000 --- a/docs/content/troubleshooting/build-performance.md +++ /dev/null @@ -1,111 +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 post/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 post/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 post/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 %}} - - -## Step Analysis - -Hugo provides a means of seeing metrics about each step in the site build -process. We call that *Step Analysis*. The *step analysis* output shows the -total time per step, the cumulative time after each step (in parentheses), -the memory usage per step, and the total memory allocations per step. - -To enable *step analysis*, use the `--stepAnalysis` option when running Hugo. - - -[partialCached]:{{< ref "functions/partialCached.md" >}} diff --git a/docs/content/troubleshooting/faq.md b/docs/content/troubleshooting/faq.md deleted file mode 100644 index 392d7a8df..000000000 --- a/docs/content/troubleshooting/faq.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -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/] ---- - -{{% note %}} -**Note:** The answers/solutions presented below are short, and may not be note 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 %}} - -## Can I set configuration variables via OS environment? - -Yes you can! See [Configure with Environment Variables](/getting-started/configuration/#configure-with-environment-variables). - -## How do I schedule posts? - -1. Set `publishDate` in the page [Front Matter](/content-management/front-matter/) to a date in the future. -2. Build and publish at intervals. - -How to automate the "publish at intervals" part depends on your situation: - -* 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. - -Also see this Twitter thread: - -{{< tweet 962380712027590657 >}} - -## Can I use the latest Hugo version on Netlify? - -Yes you can! Read [this](/hosting-and-deployment/hosting-on-netlify/#configure-hugo-version-in-netlify). diff --git a/docs/content/variables/_index.md b/docs/content/variables/_index.md deleted file mode 100644 index 382ee25d4..000000000 --- a/docs/content/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/variables/files.md b/docs/content/variables/files.md deleted file mode 100644 index ac5376dbd..000000000 --- a/docs/content/variables/files.md +++ /dev/null @@ -1,48 +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 (e.g., `content/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.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/`) - -[Multilingual]: /content-management/multilingual/ \ No newline at end of file diff --git a/docs/content/variables/git.md b/docs/content/variables/git.md deleted file mode 100644 index f9c154764..000000000 --- a/docs/content/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, **and** if the `lastmod` field in the content's front matter is not set, `.Lastmod` (on `Page`) is fetched from Git i.e. `.GitInfo.AuthorDate`. - -[configuration]: /getting-started/configuration/ diff --git a/docs/content/variables/hugo.md b/docs/content/variables/hugo.md deleted file mode 100644 index c0c5c9601..000000000 --- a/docs/content/variables/hugo.md +++ /dev/null @@ -1,39 +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 ---- - -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.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](http://themes.gohugo.io). The generator tag allows the Hugo team to track the usage and popularity of Hugo. -{{% /note %}} - diff --git a/docs/content/variables/menus.md b/docs/content/variables/menus.md deleted file mode 100644 index 4216d9763..000000000 --- a/docs/content/variables/menus.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -title: Menu Variables -linktitle: Menu Variables -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 ---- - -The [menu template][] has the following properties: - -.URL -: string - -.Name -: string - -.Title -: string - -This is a link title, meant to be used in `title`-Attributes of the menu's ``-tags. -By default it returns `.Page.LinkTitle`, as long as the menu entry was created -through the page's front matter and not through the site config. -Setting it explicitly in the site config or the page's front matter overrides this behaviour. - -.Page -: [Page Object](/variables/page/) - -The `.Page` variable holds a reference to the page. -It's only set when the menu entry is created from the page's front matter, -not when it's created from the site config. - - -.Menu -: string - -.Identifier -: string - -.Pre -: template.HTML - -.Post -: template.HTML - -.Weight -: int - -.Parent -: string - -.Children -: Menu - -[menu template]: /templates/menu-templates/ diff --git a/docs/content/variables/page.md b/docs/content/variables/page.md deleted file mode 100644 index f3a50d21d..000000000 --- a/docs/content/variables/page.md +++ /dev/null @@ -1,286 +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: [/variables/page/] -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/).) - -.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. - -.Lang -: language taken from the language extension notation. - -.Language -: a language object that points to the language's definition in the site -`config`. - -.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 -: pointer to the following content (based on the `publishdate` field in front matter). - -.NextInSection -: pointer to the following content within the same section (based on `publishdate` field in front matter). - -.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` for regular content pages. `.Pages` is an alias for `.Data.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 -: Pointer to the previous content (based on `publishdate` in front matter). - -.PrevInSection -: Pointer to the previous content within the same section (based on `publishdate` in front matter). For example, `{{if .PrevInSection}}{{.PrevInSection.Permalink}}{{end}}`. - -.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 -: link to the taxonomies' RSS link. - -.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. - -.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/). - -.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. 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. - -.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., `post`). - -.URL -: the URL for the page relative to the web root. Note that a `url` set directly in front matter overrides the default relative URL for the rendered page. - -.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/readfiles/sectionvars.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/variables/shortcodes.md b/docs/content/variables/shortcodes.md deleted file mode 100644 index b194eb7db..000000000 --- a/docs/content/variables/shortcodes.md +++ /dev/null @@ -1,36 +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: - -.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. - -.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/variables/site.md b/docs/content/variables/site.md deleted file mode 100644 index f0c041ecb..000000000 --- a/docs/content/variables/site.md +++ /dev/null @@ -1,128 +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.Files -: all source files for the Hugo website. - -.Site.GoogleAnalytics -: a string representing your tracking code for Google Analytics as defined in the site configuration. - -.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. - -.Site.Permalinks -: a string to override the default [permalink](/content-management/urls/) format as defined in the site configuration. - -.Site.RegularPages -: a shortcut to the *regular* page collection. `.Site.RegularPages` is equivalent to `where .Site.Pages "Kind" "page"`. - -.Site.RSSLink -: the URL for the site RSS. - -.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.toml` defines a site-wide param for `description`: - -``` -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 >}} - -[config]: /getting-started/configuration/ diff --git a/docs/content/variables/sitemap.md b/docs/content/variables/sitemap.md deleted file mode 100644 index dd926f2b3..000000000 --- a/docs/content/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/variables/taxonomy.md b/docs/content/variables/taxonomy.md deleted file mode 100644 index 5bcdffee5..000000000 --- a/docs/content/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/data/articles.toml b/docs/data/articles.toml index 379ae5562..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" @@ -84,7 +84,7 @@ [[article]] title = "How to use Firebase to host a Hugo site" - url = "https://www.m0d3rnc0ad.com/post/static-site-firebase/" + url = "https://code.selfmadefighter.com/post/static-site-firebase/" author = "Andrew Cuga" date= "2017-02-04" @@ -107,7 +107,7 @@ date = "2016-10-22" [[article]] - title = "通过 Gitlab-cl 将 Hugo blog 自动部署至 GitHub (Chinese, Continious integration)" + title = "通过 Gitlab-cl 将 Hugo blog 自动部署至 GitHub (Chinese, Continuous integration)" url = "https://zetaoyang.github.io/post/2016/10/17/gitlab-cl.html" author = "Zetao Yang" date = "2016-10-17" @@ -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 6400900af..000000000 --- a/docs/data/docs.json +++ /dev/null @@ -1,3671 +0,0 @@ -{ - "chroma": { - "lexers": [ - { - "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": "Awk", - "Aliases": [ - "awk", - "gawk", - "mawk", - "nawk" - ] - }, - { - "Name": "BNF", - "Aliases": [ - "bnf" - ] - }, - { - "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": "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": "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": "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" - ] - }, - { - "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": "Groovy", - "Aliases": [ - "gradle", - "groovy" - ] - }, - { - "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", - "inf", - "ini" - ] - }, - { - "Name": "Idris", - "Aliases": [ - "idr", - "idris" - ] - }, - { - "Name": "Io", - "Aliases": [ - "io" - ] - }, - { - "Name": "JSON", - "Aliases": [ - "json" - ] - }, - { - "Name": "JSX", - "Aliases": [ - "jsx", - "react" - ] - }, - { - "Name": "Java", - "Aliases": [ - "java" - ] - }, - { - "Name": "JavaScript", - "Aliases": [ - "javascript", - "js", - "jsm" - ] - }, - { - "Name": "Julia", - "Aliases": [ - "jl", - "julia" - ] - }, - { - "Name": "Kotlin", - "Aliases": [ - "kotlin", - "kt" - ] - }, - { - "Name": "LLVM", - "Aliases": [ - "ll", - "llvm" - ] - }, - { - "Name": "Lighttpd configuration file", - "Aliases": [ - "lighttpd", - "lighty" - ] - }, - { - "Name": "Lua", - "Aliases": [ - "lua", - "wlua" - ] - }, - { - "Name": "Mako", - "Aliases": [ - "mako", - "mao" - ] - }, - { - "Name": "Mason", - "Aliases": [ - "m", - "mason", - "mc", - "mhtml", - "mi" - ] - }, - { - "Name": "Mathematica", - "Aliases": [ - "cdf", - "ma", - "mathematica", - "mma", - "nb", - "nbp" - ] - }, - { - "Name": "MiniZinc", - "Aliases": [ - "MZN", - "dzn", - "fzn", - "minizinc", - "mzn" - ] - }, - { - "Name": "Modula-2", - "Aliases": [ - "def", - "m2", - "mod", - "modula2" - ] - }, - { - "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": "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": "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": "Swift", - "Aliases": [ - "swift" - ] - }, - { - "Name": "TASM", - "Aliases": [ - "ASM", - "asm", - "tasm" - ] - }, - { - "Name": "TOML", - "Aliases": [ - "toml" - ] - }, - { - "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": "Transact-SQL", - "Aliases": [ - "t-sql", - "tsql" - ] - }, - { - "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": "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": "reStructuredText", - "Aliases": [ - "rest", - "restructuredtext", - "rst" - ] - }, - { - "Name": "reg", - "Aliases": [ - "reg", - "registry" - ] - }, - { - "Name": "systemverilog", - "Aliases": [ - "sv", - "svh", - "systemverilog" - ] - }, - { - "Name": "verilog", - "Aliases": [ - "v", - "verilog" - ] - } - ] - }, - "media": { - "types": [ - { - "type": "application/javascript", - "string": "application/javascript+js", - "mainType": "application", - "subType": "javascript", - "suffix": "js", - "delimiter": "." - }, - { - "type": "application/json", - "string": "application/json+json", - "mainType": "application", - "subType": "json", - "suffix": "json", - "delimiter": "." - }, - { - "type": "application/rss", - "string": "application/rss+xml", - "mainType": "application", - "subType": "rss", - "suffix": "xml", - "delimiter": "." - }, - { - "type": "application/xml", - "string": "application/xml+xml", - "mainType": "application", - "subType": "xml", - "suffix": "xml", - "delimiter": "." - }, - { - "type": "text/calendar", - "string": "text/calendar+ics", - "mainType": "text", - "subType": "calendar", - "suffix": "ics", - "delimiter": "." - }, - { - "type": "text/css", - "string": "text/css+css", - "mainType": "text", - "subType": "css", - "suffix": "css", - "delimiter": "." - }, - { - "type": "text/csv", - "string": "text/csv+csv", - "mainType": "text", - "subType": "csv", - "suffix": "csv", - "delimiter": "." - }, - { - "type": "text/html", - "string": "text/html+html", - "mainType": "text", - "subType": "html", - "suffix": "html", - "delimiter": "." - }, - { - "type": "text/plain", - "string": "text/plain+txt", - "mainType": "text", - "subType": "plain", - "suffix": "txt", - "delimiter": "." - } - ] - }, - "output": { - "formats": [ - { - "MediaType": "text/html+html", - "name": "AMP", - "mediaType": { - "type": "text/html", - "string": "text/html+html", - "mainType": "text", - "subType": "html", - "suffix": "html", - "delimiter": "." - }, - "path": "amp", - "baseName": "index", - "rel": "amphtml", - "protocol": "", - "isPlainText": false, - "isHTML": true, - "noUgly": false, - "notAlternative": false - }, - { - "MediaType": "text/css+css", - "name": "CSS", - "mediaType": { - "type": "text/css", - "string": "text/css+css", - "mainType": "text", - "subType": "css", - "suffix": "css", - "delimiter": "." - }, - "path": "", - "baseName": "styles", - "rel": "stylesheet", - "protocol": "", - "isPlainText": true, - "isHTML": false, - "noUgly": false, - "notAlternative": true - }, - { - "MediaType": "text/csv+csv", - "name": "CSV", - "mediaType": { - "type": "text/csv", - "string": "text/csv+csv", - "mainType": "text", - "subType": "csv", - "suffix": "csv", - "delimiter": "." - }, - "path": "", - "baseName": "index", - "rel": "alternate", - "protocol": "", - "isPlainText": true, - "isHTML": false, - "noUgly": false, - "notAlternative": false - }, - { - "MediaType": "text/calendar+ics", - "name": "Calendar", - "mediaType": { - "type": "text/calendar", - "string": "text/calendar+ics", - "mainType": "text", - "subType": "calendar", - "suffix": "ics", - "delimiter": "." - }, - "path": "", - "baseName": "index", - "rel": "alternate", - "protocol": "webcal://", - "isPlainText": true, - "isHTML": false, - "noUgly": false, - "notAlternative": false - }, - { - "MediaType": "text/html+html", - "name": "HTML", - "mediaType": { - "type": "text/html", - "string": "text/html+html", - "mainType": "text", - "subType": "html", - "suffix": "html", - "delimiter": "." - }, - "path": "", - "baseName": "index", - "rel": "canonical", - "protocol": "", - "isPlainText": false, - "isHTML": true, - "noUgly": false, - "notAlternative": false - }, - { - "MediaType": "application/json+json", - "name": "JSON", - "mediaType": { - "type": "application/json", - "string": "application/json+json", - "mainType": "application", - "subType": "json", - "suffix": "json", - "delimiter": "." - }, - "path": "", - "baseName": "index", - "rel": "alternate", - "protocol": "", - "isPlainText": true, - "isHTML": false, - "noUgly": false, - "notAlternative": false - }, - { - "MediaType": "text/plain+txt", - "name": "ROBOTS", - "mediaType": { - "type": "text/plain", - "string": "text/plain+txt", - "mainType": "text", - "subType": "plain", - "suffix": "txt", - "delimiter": "." - }, - "path": "", - "baseName": "robots", - "rel": "alternate", - "protocol": "", - "isPlainText": true, - "isHTML": false, - "noUgly": false, - "notAlternative": false - }, - { - "MediaType": "application/rss+xml", - "name": "RSS", - "mediaType": { - "type": "application/rss", - "string": "application/rss+xml", - "mainType": "application", - "subType": "rss", - "suffix": "xml", - "delimiter": "." - }, - "path": "", - "baseName": "index", - "rel": "alternate", - "protocol": "", - "isPlainText": false, - "isHTML": false, - "noUgly": true, - "notAlternative": false - }, - { - "MediaType": "application/xml+xml", - "name": "Sitemap", - "mediaType": { - "type": "application/xml", - "string": "application/xml+xml", - "mainType": "application", - "subType": "xml", - "suffix": "xml", - "delimiter": "." - }, - "path": "", - "baseName": "sitemap", - "rel": "sitemap", - "protocol": "", - "isPlainText": false, - "isHTML": false, - "noUgly": true, - "notAlternative": false - } - ], - "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": "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": "Single page in \"posts\" section with theme", - "Kind": "page", - "OutputFormat": "HTML", - "Suffix": "html", - "Template Lookup Order": [ - "layouts/posts/single.html.html", - "demoTheme/layouts/posts/single.html.html", - "layouts/posts/single.html", - "demoTheme/layouts/posts/single.html", - "layouts/_default/single.html.html", - "demoTheme/layouts/_default/single.html.html", - "layouts/_default/single.html", - "demoTheme/layouts/_default/single.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": "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": "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": "Home page with theme", - "Kind": "home", - "OutputFormat": "HTML", - "Suffix": "html", - "Template Lookup Order": [ - "layouts/index.html.html", - "demoTheme/layouts/index.html.html", - "layouts/home.html.html", - "demoTheme/layouts/home.html.html", - "layouts/list.html.html", - "demoTheme/layouts/list.html.html", - "layouts/index.html", - "demoTheme/layouts/index.html", - "layouts/home.html", - "demoTheme/layouts/home.html", - "layouts/list.html", - "demoTheme/layouts/list.html", - "layouts/_default/index.html.html", - "demoTheme/layouts/_default/index.html.html", - "layouts/_default/home.html.html", - "demoTheme/layouts/_default/home.html.html", - "layouts/_default/list.html.html", - "demoTheme/layouts/_default/list.html.html", - "layouts/_default/index.html", - "demoTheme/layouts/_default/index.html", - "layouts/_default/home.html", - "demoTheme/layouts/_default/home.html", - "layouts/_default/list.html", - "demoTheme/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 with theme", - "Kind": "home", - "OutputFormat": "RSS", - "Suffix": "xml", - "Template Lookup Order": [ - "layouts/index.rss.xml", - "demoTheme/layouts/index.rss.xml", - "layouts/home.rss.xml", - "demoTheme/layouts/home.rss.xml", - "layouts/rss.xml", - "demoTheme/layouts/rss.xml", - "layouts/list.rss.xml", - "demoTheme/layouts/list.rss.xml", - "layouts/index.xml", - "demoTheme/layouts/index.xml", - "layouts/home.xml", - "demoTheme/layouts/home.xml", - "layouts/list.xml", - "demoTheme/layouts/list.xml", - "layouts/_default/index.rss.xml", - "demoTheme/layouts/_default/index.rss.xml", - "layouts/_default/home.rss.xml", - "demoTheme/layouts/_default/home.rss.xml", - "layouts/_default/rss.xml", - "demoTheme/layouts/_default/rss.xml", - "layouts/_default/list.rss.xml", - "demoTheme/layouts/_default/list.rss.xml", - "layouts/_default/index.xml", - "demoTheme/layouts/_default/index.xml", - "layouts/_default/home.xml", - "demoTheme/layouts/_default/home.xml", - "layouts/_default/list.xml", - "demoTheme/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.", - "Args": [ - "x", - "y" - ], - "Aliases": [ - "eq" - ], - "Examples": [ - [ - "{{ if eq .Section \"blog\" }}current{{ end }}", - "current" - ] - ] - }, - "Ge": { - "Description": "Ge returns the boolean truth of arg1 \u003e= arg2.", - "Args": [ - "a", - "b" - ], - "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.", - "Args": [ - "a", - "b" - ], - "Aliases": [ - "gt" - ], - "Examples": [] - }, - "Le": { - "Description": "Le returns the boolean truth of arg1 \u003c= arg2.", - "Args": [ - "a", - "b" - ], - "Aliases": [ - "le" - ], - "Examples": [] - }, - "Lt": { - "Description": "Lt returns the boolean truth of arg1 \u003c arg2.", - "Args": [ - "a", - "b" - ], - "Aliases": [ - "lt" - ], - "Examples": [] - }, - "Ne": { - "Description": "Ne returns the boolean truth of arg1 != arg2.", - "Args": [ - "x", - "y" - ], - "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": [] - }, - "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": [] - }, - "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.", - "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": [] - }, - "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", - "indices" - ], - "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": [] - }, - "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" - ] - ] - }, - "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": [] - }, - "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": [ - "l" - ], - "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": "", - "Args": [ - "format", - "a" - ], - "Aliases": [ - "errorf" - ], - "Examples": [ - [ - "{{ errorf \"%s.\" \"failed\" }}", - "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" - ] - ] - } - }, - "images": { - "Config": { - "Description": "Config returns the image.Config for the specified path relative to the\nworking directory.", - "Args": [ - "path" - ], - "Aliases": [ - "imageConfig" - ], - "Examples": [] - } - }, - "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 ...", - "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 \".\") }}{{ .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 \"README.txt\" }}", - "Hugo Rocks!" - ] - ] - } - }, - "partials": { - "Include": { - "Description": "Include executes the named partial and returns either a string,\nwhen 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. An optional variant\nstring parameter (a string slice actually, but be only use a variadic\nargument to make it optional) can be passed so that a given partial can have\nmultiple uses. The cache is created with name+variant as the key.", - "Args": [ - "name", - "context", - "variant" - ], - "Aliases": [ - "partialCached" - ], - "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" - ] - ] - } - }, - "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]" - ] - ] - }, - "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 - }, - "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": [] - }, - "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" - ] - ] - } - }, - "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" - ] - ] - } - }, - "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": [] - }, - "Parse": { - "Description": "", - "Args": null, - "Aliases": null, - "Examples": null - }, - "Ref": { - "Description": "Ref returns the absolute URL path to a given content item.", - "Args": [ - "in", - "refs" - ], - "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", - "refs" - ], - "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 e5dbbefac..b440c21df 100644 --- a/docs/data/homepagetweets.toml +++ b/docs/data/homepagetweets.toml @@ -1,272 +1,265 @@ [[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 +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://x.com/heinrichhartman/status/1199736512264462341" +date = 2019-11-12T00:00:00Z [[tweet]] -name = "carriecoxwell" -twitter_handle = "@carriecoxwell" -quote = "Having a lot of fun with @GoHugoIO! It's exactly what I didn't even know I wanted." -link = "https://twitter.com/carriecoxwell/status/948402470144872448" -date = 2018-01-03T03: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 +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://x.com/jscarto/status/1039648827815485440" +date = 2018-09-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/references.toml b/docs/data/references.toml deleted file mode 100644 index 84d8c5935..000000000 --- a/docs/data/references.toml +++ /dev/null @@ -1,211 +0,0 @@ - -[[quotes]] -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" -date = 2016-12-16T00:00:00Z - - -[[quotes]] -name = "Janez Čadež‏" -twitter_handle = "@jamziSLO" -quote = "Building @garazaFRI website in #hugo. This static site generator is soooo damn fast!" -link = "https://twitter.com/jamziSLO/status/817720283977183234" -date = 2017-01-07T00:00:00Z - -[[quotes]] -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" -date = 2017-03-01T00:00:00Z - -[[quotes]] -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" -date = 2015-01-09T00:00:00Z - -[[quotes]] -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" -date = 2015-01-18T00:00:00Z - -[[quotes]] -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" -date = 2014-04-26T00:00:00Z - -[[quotes]] -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" -date = 2013-11-29T00:00:00Z - -[[quotes]] -name = "David Gay" -twitter_handle = "@oddshocks" -quote = "Hugo is super-rad." -link = "https://twitter.com/oddshocks/statuses/405083217893421056" -date = 2013-11-25T00:00:00Z - -[[quotes]] -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" -date = 2014-05-30T00:00:00Z - -[[quotes]] -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" -date = 2013-08-05T00:00:00Z - -[[quotes]] -name = "Hugo Rodger-Brown" -twitter_handle = "@hugorodgerbrown" -quote = "Finally someone builds me my own static site generator" -link = "https://twitter.com/hugorodgerbrown/statuses/364417910153818112" -date = 2013-05-08T00:00:00Z - -[[quotes]] -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" -date = 2014-08-19T00:00:00Z - -[[quotes]] -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" -date = 2016-03-01T00:00:00Z - -[[quotes]] -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" -date = 2014-05-12T00:00:00Z - -[[quotes]] -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" -date = 2013-05-12T00:00:00Z - -[[quotes]] -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" -date = 2013-12-19T00:00:00Z - -[[quotes]] -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" -date = 2013-12-04T00:00:00Z - -[[quotes]] -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" -date = 2013-08-05T00:00:00Z - -[[quotes]] -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" -date = 2014-02-22T00:00:00Z - -[[quotes]] -name = "Ludovic Chabant" -twitter_handle = "@ludovicchabant" -quote = "Good work on Hugo, I’m impressed with the speed!" -link = "https://twitter.com/ludovicchabant/statuses/408806199602053120" -date = 2013-12-06T00:00:00Z - -[[quotes]] -name = "Luke Holder" -twitter_handle = "@lukeholder" -quote = "this is AWESOME. a single little executable and so fast." -link = "https://twitter.com/lukeholder/status/430352287936946176" -date = 2014-02-03T00:00:00Z - -[[quotes]] -name = "Markus Eliasson" -twitter_handle = "@markuseliasson" -quote = "Hugo is fast, dead simple to setup and well documented" -link = "https://twitter.com/markuseliasson/status/501594865877008384" -date = 2014-08-19T00:00:00Z - -[[quotes]] -name = "mercime" -twitter_handle = "@mercime_one" -quote = "Hugo: Makes the Web Fun Again" -link = "https://twitter.com/mercime_one/status/500547145087205377" -date = 2014-08-16T00:00:00Z - -[[quotes]] -name = "Michael Whatcott" -twitter_handle = "@mdwhatcott" -quote = "One more satisfied #Hugo blogger. Thanks @spf13 and friends!" -link = "https://twitter.com/mdwhatcott/status/469980686531571712" -date = 2014-05-23T00:00:00Z - -[[quotes]] -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" -date = 2014-01-15T00:00:00Z - -[[quotes]] -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" -date = 2014-05-31T00:00:00Z - -[[quotes]] -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" -date = 2014-12-30T00:00:00Z - -[[quotes]] -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" -date = 2014-08-02T00:00:00Z - -[[quotes]] -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" -date = 2014-08-02T00:00:00Z - -[[quotes]] -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" -date = 2015-02-04T00:00:00Z 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 new file mode 100644 index 000000000..4b9e0a369 --- /dev/null +++ b/docs/go.mod @@ -0,0 +1,3 @@ +module github.com/gohugoio/hugoDocs + +go 1.22.0 diff --git a/docs/go.sum b/docs/go.sum new file mode 100644 index 000000000..af9b5febf --- /dev/null +++ b/docs/go.sum @@ -0,0 +1,2 @@ +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 a29f0405c..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/code.html b/docs/layouts/shortcodes/code.html deleted file mode 100644 index 83220c47d..000000000 --- a/docs/layouts/shortcodes/code.html +++ /dev/null @@ -1,22 +0,0 @@ -{{ $file := .Get "file" }} -{{ $.Scratch.Set "codeLang" "" }} -{{ $suffix := findRE "(\\.[^.]+)$" $file 1 }} -{{ with $suffix }} -{{ $.Scratch.Set "codeLang" (index . 0 | strings.TrimPrefix ".") }} -{{ end }} -{{ with .Get "codeLang" }}{{ $.Scratch.Set "codeLang" . }}{{ 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 $.Scratch.Get "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/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/imgproc.html b/docs/layouts/shortcodes/imgproc.html deleted file mode 100644 index 6ff73e1f9..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/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 fcf081bd5..000000000 --- a/docs/layouts/shortcodes/note.html +++ /dev/null @@ -1,8 +0,0 @@ - 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 0b56ac560..000000000 --- a/docs/layouts/shortcodes/tip.html +++ /dev/null @@ -1,8 +0,0 @@ - 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 d05057e59..000000000 --- a/docs/layouts/shortcodes/warning.html +++ /dev/null @@ -1,8 +0,0 @@ - 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 5955d26cd..c24a32a60 100644 --- a/docs/netlify.toml +++ b/docs/netlify.toml @@ -1,31 +1,55 @@ [build] -publish = "public" -command = "hugo" + publish = "public" + command = "hugo --gc --minify" + + [build.environment] + HUGO_VERSION = "0.146.7" [context.production.environment] -HUGO_VERSION = "0.37.1" -HUGO_ENV = "production" -HUGO_ENABLEGITINFO = "true" + HUGO_ENV = "production" + HUGO_ENABLEGITINFO = "true" [context.split1] -command = "hugo --enableGitInfo" + command = "hugo --gc --minify --enableGitInfo" -[context.split1.environment] -HUGO_VERSION = "0.37.1" -HUGO_ENV = "production" + [context.split1.environment] + HUGO_ENV = "production" [context.deploy-preview] -command = "hugo -b $DEPLOY_PRIME_URL" - -[context.deploy-preview.environment] -HUGO_VERSION = "0.37.1" + command = "hugo --gc --minify --buildFuture -b $DEPLOY_PRIME_URL --enableGitInfo" [context.branch-deploy] -command = "hugo -b $DEPLOY_PRIME_URL" - -[context.branch-deploy.environment] -HUGO_VERSION = "0.37.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/requirements.txt b/docs/requirements.txt deleted file mode 100644 index e0f2f62df..000000000 --- a/docs/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -Pygments==2.1.3 diff --git a/docs/resources/_gen/images/about/new-in-032/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x0_resize_q10_box.jpg b/docs/resources/_gen/images/about/new-in-032/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x0_resize_q10_box.jpg deleted file mode 100644 index 8736f0376..000000000 Binary files a/docs/resources/_gen/images/about/new-in-032/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x0_resize_q10_box.jpg and /dev/null differ diff --git a/docs/resources/_gen/images/about/new-in-032/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x0_resize_q10_catmullrom.jpg b/docs/resources/_gen/images/about/new-in-032/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x0_resize_q10_catmullrom.jpg deleted file mode 100644 index 6499e8341..000000000 Binary files a/docs/resources/_gen/images/about/new-in-032/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x0_resize_q10_catmullrom.jpg and /dev/null differ diff --git a/docs/resources/_gen/images/about/new-in-032/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x0_resize_q75_box.jpg b/docs/resources/_gen/images/about/new-in-032/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x0_resize_q75_box.jpg deleted file mode 100644 index 47d62fce7..000000000 Binary files a/docs/resources/_gen/images/about/new-in-032/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x0_resize_q75_box.jpg and /dev/null differ diff --git a/docs/resources/_gen/images/about/new-in-032/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x0_resize_q75_catmullrom.jpg b/docs/resources/_gen/images/about/new-in-032/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x0_resize_q75_catmullrom.jpg deleted file mode 100644 index 8caa58798..000000000 Binary files a/docs/resources/_gen/images/about/new-in-032/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x0_resize_q75_catmullrom.jpg and /dev/null differ diff --git a/docs/resources/_gen/images/about/new-in-032/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_90x120_fill_q75_catmullrom_left.jpg b/docs/resources/_gen/images/about/new-in-032/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_90x120_fill_q75_catmullrom_left.jpg deleted file mode 100644 index 69549b2df..000000000 Binary files a/docs/resources/_gen/images/about/new-in-032/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_90x120_fill_q75_catmullrom_left.jpg and /dev/null differ diff --git a/docs/resources/_gen/images/about/new-in-032/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_90x120_fill_q75_catmullrom_right.jpg b/docs/resources/_gen/images/about/new-in-032/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_90x120_fill_q75_catmullrom_right.jpg deleted file mode 100644 index 249708765..000000000 Binary files a/docs/resources/_gen/images/about/new-in-032/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_90x120_fill_q75_catmullrom_right.jpg and /dev/null differ diff --git a/docs/resources/_gen/images/about/new-in-032/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_90x90_fit_q75_box.jpg b/docs/resources/_gen/images/about/new-in-032/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_90x90_fit_q75_box.jpg deleted file mode 100644 index b8ad2659f..000000000 Binary files a/docs/resources/_gen/images/about/new-in-032/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_90x90_fit_q75_box.jpg and /dev/null differ diff --git a/docs/resources/_gen/images/about/new-in-032/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_90x90_fit_q75_catmullrom.jpg b/docs/resources/_gen/images/about/new-in-032/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_90x90_fit_q75_catmullrom.jpg deleted file mode 100644 index 634fb0ce1..000000000 Binary files a/docs/resources/_gen/images/about/new-in-032/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_90x90_fit_q75_catmullrom.jpg and /dev/null differ diff --git a/docs/resources/_gen/images/content-management/image-processing/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x200_fill_q75_box_smart1.jpg b/docs/resources/_gen/images/content-management/image-processing/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x200_fill_q75_box_smart1.jpg deleted file mode 100644 index 1ef91ac52..000000000 Binary files a/docs/resources/_gen/images/content-management/image-processing/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x200_fill_q75_box_smart1.jpg and /dev/null differ diff --git a/docs/resources/_gen/images/content-management/image-processing/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x200_fill_q75_catmullrom_smart1.jpg b/docs/resources/_gen/images/content-management/image-processing/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x200_fill_q75_catmullrom_smart1.jpg deleted file mode 100644 index 094f2a4f1..000000000 Binary files a/docs/resources/_gen/images/content-management/image-processing/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x200_fill_q75_catmullrom_smart1.jpg and /dev/null differ diff --git a/docs/resources/_gen/images/content-management/image-processing/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x0_resize_q10_box.jpg b/docs/resources/_gen/images/content-management/image-processing/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x0_resize_q10_box.jpg deleted file mode 100644 index 8736f0376..000000000 Binary files a/docs/resources/_gen/images/content-management/image-processing/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x0_resize_q10_box.jpg and /dev/null differ diff --git a/docs/resources/_gen/images/content-management/image-processing/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x0_resize_q10_catmullrom.jpg b/docs/resources/_gen/images/content-management/image-processing/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x0_resize_q10_catmullrom.jpg deleted file mode 100644 index 6499e8341..000000000 Binary files a/docs/resources/_gen/images/content-management/image-processing/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x0_resize_q10_catmullrom.jpg and /dev/null differ diff --git a/docs/resources/_gen/images/content-management/image-processing/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x0_resize_q75_box.jpg b/docs/resources/_gen/images/content-management/image-processing/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x0_resize_q75_box.jpg deleted file mode 100644 index 47d62fce7..000000000 Binary files a/docs/resources/_gen/images/content-management/image-processing/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x0_resize_q75_box.jpg and /dev/null differ diff --git a/docs/resources/_gen/images/content-management/image-processing/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x0_resize_q75_catmullrom.jpg b/docs/resources/_gen/images/content-management/image-processing/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x0_resize_q75_catmullrom.jpg deleted file mode 100644 index 8caa58798..000000000 Binary files a/docs/resources/_gen/images/content-management/image-processing/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x0_resize_q75_catmullrom.jpg and /dev/null differ diff --git a/docs/resources/_gen/images/content-management/image-processing/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_90x120_fill_q75_catmullrom_left.jpg b/docs/resources/_gen/images/content-management/image-processing/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_90x120_fill_q75_catmullrom_left.jpg deleted file mode 100644 index 69549b2df..000000000 Binary files a/docs/resources/_gen/images/content-management/image-processing/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_90x120_fill_q75_catmullrom_left.jpg and /dev/null differ diff --git a/docs/resources/_gen/images/content-management/image-processing/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_90x120_fill_q75_catmullrom_right.jpg b/docs/resources/_gen/images/content-management/image-processing/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_90x120_fill_q75_catmullrom_right.jpg deleted file mode 100644 index 249708765..000000000 Binary files a/docs/resources/_gen/images/content-management/image-processing/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_90x120_fill_q75_catmullrom_right.jpg and /dev/null differ diff --git a/docs/resources/_gen/images/content-management/image-processing/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_90x90_fit_q75_box.jpg b/docs/resources/_gen/images/content-management/image-processing/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_90x90_fit_q75_box.jpg deleted file mode 100644 index b8ad2659f..000000000 Binary files a/docs/resources/_gen/images/content-management/image-processing/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_90x90_fit_q75_box.jpg and /dev/null differ diff --git a/docs/resources/_gen/images/content-management/image-processing/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_90x90_fit_q75_catmullrom.jpg b/docs/resources/_gen/images/content-management/image-processing/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_90x90_fit_q75_catmullrom.jpg deleted file mode 100644 index 634fb0ce1..000000000 Binary files a/docs/resources/_gen/images/content-management/image-processing/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_90x90_fit_q75_catmullrom.jpg and /dev/null differ diff --git a/docs/resources/_gen/images/content-management/organization/1-featured-content-bundles_hu3e3ae7839b071119f32acaa20f204198_63640_300x0_resize_box.png b/docs/resources/_gen/images/content-management/organization/1-featured-content-bundles_hu3e3ae7839b071119f32acaa20f204198_63640_300x0_resize_box.png deleted file mode 100644 index 7d29ca620..000000000 Binary files a/docs/resources/_gen/images/content-management/organization/1-featured-content-bundles_hu3e3ae7839b071119f32acaa20f204198_63640_300x0_resize_box.png and /dev/null differ diff --git a/docs/resources/_gen/images/content-management/organization/1-featured-content-bundles_hu3e3ae7839b071119f32acaa20f204198_63640_300x0_resize_catmullrom_2.png b/docs/resources/_gen/images/content-management/organization/1-featured-content-bundles_hu3e3ae7839b071119f32acaa20f204198_63640_300x0_resize_catmullrom_2.png deleted file mode 100644 index bc604e562..000000000 Binary files a/docs/resources/_gen/images/content-management/organization/1-featured-content-bundles_hu3e3ae7839b071119f32acaa20f204198_63640_300x0_resize_catmullrom_2.png and /dev/null differ diff --git a/docs/resources/_gen/images/news/0.33-relnotes/featured-hugo-33-poster_hu45ce9da1cdea6ca61c5f4f5baccdcad4_70230_480x0_resize_box.png b/docs/resources/_gen/images/news/0.33-relnotes/featured-hugo-33-poster_hu45ce9da1cdea6ca61c5f4f5baccdcad4_70230_480x0_resize_box.png deleted file mode 100644 index b0e1fe05b..000000000 Binary files a/docs/resources/_gen/images/news/0.33-relnotes/featured-hugo-33-poster_hu45ce9da1cdea6ca61c5f4f5baccdcad4_70230_480x0_resize_box.png and /dev/null differ diff --git a/docs/resources/_gen/images/news/0.33-relnotes/featured-hugo-33-poster_hu45ce9da1cdea6ca61c5f4f5baccdcad4_70230_480x0_resize_catmullrom_2.png b/docs/resources/_gen/images/news/0.33-relnotes/featured-hugo-33-poster_hu45ce9da1cdea6ca61c5f4f5baccdcad4_70230_480x0_resize_catmullrom_2.png deleted file mode 100644 index 69284a4d8..000000000 Binary files a/docs/resources/_gen/images/news/0.33-relnotes/featured-hugo-33-poster_hu45ce9da1cdea6ca61c5f4f5baccdcad4_70230_480x0_resize_catmullrom_2.png and /dev/null differ diff --git a/docs/resources/_gen/images/news/0.33-relnotes/featured-hugo-33-poster_hu45ce9da1cdea6ca61c5f4f5baccdcad4_70230_640x0_resize_catmullrom_2.png b/docs/resources/_gen/images/news/0.33-relnotes/featured-hugo-33-poster_hu45ce9da1cdea6ca61c5f4f5baccdcad4_70230_640x0_resize_catmullrom_2.png deleted file mode 100644 index 90877e6ae..000000000 Binary files a/docs/resources/_gen/images/news/0.33-relnotes/featured-hugo-33-poster_hu45ce9da1cdea6ca61c5f4f5baccdcad4_70230_640x0_resize_catmullrom_2.png and /dev/null differ diff --git a/docs/resources/_gen/images/news/0.34-relnotes/featured-34-poster_hud8d73dc5df8d5a35383849a78eea35dd_78317_480x0_resize_box.png b/docs/resources/_gen/images/news/0.34-relnotes/featured-34-poster_hud8d73dc5df8d5a35383849a78eea35dd_78317_480x0_resize_box.png deleted file mode 100644 index 961fead6e..000000000 Binary files a/docs/resources/_gen/images/news/0.34-relnotes/featured-34-poster_hud8d73dc5df8d5a35383849a78eea35dd_78317_480x0_resize_box.png and /dev/null differ diff --git a/docs/resources/_gen/images/news/0.34-relnotes/featured-34-poster_hud8d73dc5df8d5a35383849a78eea35dd_78317_480x0_resize_catmullrom_2.png b/docs/resources/_gen/images/news/0.34-relnotes/featured-34-poster_hud8d73dc5df8d5a35383849a78eea35dd_78317_480x0_resize_catmullrom_2.png deleted file mode 100644 index 21fce414d..000000000 Binary files a/docs/resources/_gen/images/news/0.34-relnotes/featured-34-poster_hud8d73dc5df8d5a35383849a78eea35dd_78317_480x0_resize_catmullrom_2.png and /dev/null differ diff --git a/docs/resources/_gen/images/news/0.34-relnotes/featured-34-poster_hud8d73dc5df8d5a35383849a78eea35dd_78317_640x0_resize_catmullrom_2.png b/docs/resources/_gen/images/news/0.34-relnotes/featured-34-poster_hud8d73dc5df8d5a35383849a78eea35dd_78317_640x0_resize_catmullrom_2.png deleted file mode 100644 index 89e5e9c76..000000000 Binary files a/docs/resources/_gen/images/news/0.34-relnotes/featured-34-poster_hud8d73dc5df8d5a35383849a78eea35dd_78317_640x0_resize_catmullrom_2.png and /dev/null differ diff --git a/docs/resources/_gen/images/news/0.35-relnotes/featured-hugo-35-poster_hua42b1310dd72f60a34e02851ebf2f82e_88519_480x0_resize_box.png b/docs/resources/_gen/images/news/0.35-relnotes/featured-hugo-35-poster_hua42b1310dd72f60a34e02851ebf2f82e_88519_480x0_resize_box.png deleted file mode 100644 index d13befecf..000000000 Binary files a/docs/resources/_gen/images/news/0.35-relnotes/featured-hugo-35-poster_hua42b1310dd72f60a34e02851ebf2f82e_88519_480x0_resize_box.png and /dev/null differ diff --git a/docs/resources/_gen/images/news/0.35-relnotes/featured-hugo-35-poster_hua42b1310dd72f60a34e02851ebf2f82e_88519_480x0_resize_catmullrom_2.png b/docs/resources/_gen/images/news/0.35-relnotes/featured-hugo-35-poster_hua42b1310dd72f60a34e02851ebf2f82e_88519_480x0_resize_catmullrom_2.png deleted file mode 100644 index 370628aec..000000000 Binary files a/docs/resources/_gen/images/news/0.35-relnotes/featured-hugo-35-poster_hua42b1310dd72f60a34e02851ebf2f82e_88519_480x0_resize_catmullrom_2.png and /dev/null differ diff --git a/docs/resources/_gen/images/news/0.35-relnotes/featured-hugo-35-poster_hua42b1310dd72f60a34e02851ebf2f82e_88519_640x0_resize_catmullrom_2.png b/docs/resources/_gen/images/news/0.35-relnotes/featured-hugo-35-poster_hua42b1310dd72f60a34e02851ebf2f82e_88519_640x0_resize_catmullrom_2.png deleted file mode 100644 index 86a93de25..000000000 Binary files a/docs/resources/_gen/images/news/0.35-relnotes/featured-hugo-35-poster_hua42b1310dd72f60a34e02851ebf2f82e_88519_640x0_resize_catmullrom_2.png and /dev/null differ diff --git a/docs/resources/_gen/images/news/0.36-relnotes/featured-hugo-36-poster_huf2fee368f65c75d3878561ed4225c39a_67640_480x0_resize_catmullrom_2.png b/docs/resources/_gen/images/news/0.36-relnotes/featured-hugo-36-poster_huf2fee368f65c75d3878561ed4225c39a_67640_480x0_resize_catmullrom_2.png deleted file mode 100644 index f57f33902..000000000 Binary files a/docs/resources/_gen/images/news/0.36-relnotes/featured-hugo-36-poster_huf2fee368f65c75d3878561ed4225c39a_67640_480x0_resize_catmullrom_2.png and /dev/null differ diff --git a/docs/resources/_gen/images/news/0.36-relnotes/featured-hugo-36-poster_huf2fee368f65c75d3878561ed4225c39a_67640_640x0_resize_catmullrom_2.png b/docs/resources/_gen/images/news/0.36-relnotes/featured-hugo-36-poster_huf2fee368f65c75d3878561ed4225c39a_67640_640x0_resize_catmullrom_2.png deleted file mode 100644 index cc9072507..000000000 Binary files a/docs/resources/_gen/images/news/0.36-relnotes/featured-hugo-36-poster_huf2fee368f65c75d3878561ed4225c39a_67640_640x0_resize_catmullrom_2.png and /dev/null differ diff --git a/docs/resources/_gen/images/news/0.37-relnotes/featured-hugo-37-poster_hue9685d25c387d657b0640498bf6a10ee_186693_480x0_resize_catmullrom_2.png b/docs/resources/_gen/images/news/0.37-relnotes/featured-hugo-37-poster_hue9685d25c387d657b0640498bf6a10ee_186693_480x0_resize_catmullrom_2.png deleted file mode 100644 index d0f3670b2..000000000 Binary files a/docs/resources/_gen/images/news/0.37-relnotes/featured-hugo-37-poster_hue9685d25c387d657b0640498bf6a10ee_186693_480x0_resize_catmullrom_2.png and /dev/null differ diff --git a/docs/resources/_gen/images/news/0.37-relnotes/featured-hugo-37-poster_hue9685d25c387d657b0640498bf6a10ee_186693_640x0_resize_catmullrom_2.png b/docs/resources/_gen/images/news/0.37-relnotes/featured-hugo-37-poster_hue9685d25c387d657b0640498bf6a10ee_186693_640x0_resize_catmullrom_2.png deleted file mode 100644 index a91566c1e..000000000 Binary files a/docs/resources/_gen/images/news/0.37-relnotes/featured-hugo-37-poster_hue9685d25c387d657b0640498bf6a10ee_186693_640x0_resize_catmullrom_2.png and /dev/null differ diff --git a/docs/resources/_gen/images/showcase/1password-support/featured_hu870838c23243880857c2e418dd7ac099_165718_1024x512_fill_catmullrom_top_2.png b/docs/resources/_gen/images/showcase/1password-support/featured_hu870838c23243880857c2e418dd7ac099_165718_1024x512_fill_catmullrom_top_2.png deleted file mode 100644 index b7399ff8e..000000000 Binary files a/docs/resources/_gen/images/showcase/1password-support/featured_hu870838c23243880857c2e418dd7ac099_165718_1024x512_fill_catmullrom_top_2.png and /dev/null differ diff --git a/docs/resources/_gen/images/showcase/1password-support/featured_hu870838c23243880857c2e418dd7ac099_165718_640x0_resize_catmullrom_2.png b/docs/resources/_gen/images/showcase/1password-support/featured_hu870838c23243880857c2e418dd7ac099_165718_640x0_resize_catmullrom_2.png deleted file mode 100644 index e953c86df..000000000 Binary files a/docs/resources/_gen/images/showcase/1password-support/featured_hu870838c23243880857c2e418dd7ac099_165718_640x0_resize_catmullrom_2.png and /dev/null differ diff --git a/docs/resources/_gen/images/showcase/1password-support/featured_hu870838c23243880857c2e418dd7ac099_165718_8714c8c914d32c12c7eb833a42713319.png b/docs/resources/_gen/images/showcase/1password-support/featured_hu870838c23243880857c2e418dd7ac099_165718_8714c8c914d32c12c7eb833a42713319.png deleted file mode 100644 index d28d99662..000000000 Binary files a/docs/resources/_gen/images/showcase/1password-support/featured_hu870838c23243880857c2e418dd7ac099_165718_8714c8c914d32c12c7eb833a42713319.png and /dev/null differ diff --git a/docs/resources/_gen/images/showcase/forestry/featured_hu77de7d99834fa13b854b7fc62e2912a7_227009_1024x512_fill_catmullrom_top_2.png b/docs/resources/_gen/images/showcase/forestry/featured_hu77de7d99834fa13b854b7fc62e2912a7_227009_1024x512_fill_catmullrom_top_2.png deleted file mode 100644 index 9a88db50a..000000000 Binary files a/docs/resources/_gen/images/showcase/forestry/featured_hu77de7d99834fa13b854b7fc62e2912a7_227009_1024x512_fill_catmullrom_top_2.png and /dev/null differ diff --git a/docs/resources/_gen/images/showcase/forestry/featured_hu77de7d99834fa13b854b7fc62e2912a7_227009_192a300d3ccaa4371c674791fb50a62c.png b/docs/resources/_gen/images/showcase/forestry/featured_hu77de7d99834fa13b854b7fc62e2912a7_227009_192a300d3ccaa4371c674791fb50a62c.png deleted file mode 100644 index a25b83ef2..000000000 Binary files a/docs/resources/_gen/images/showcase/forestry/featured_hu77de7d99834fa13b854b7fc62e2912a7_227009_192a300d3ccaa4371c674791fb50a62c.png and /dev/null differ diff --git a/docs/resources/_gen/images/showcase/forestry/featured_hu77de7d99834fa13b854b7fc62e2912a7_227009_640x0_resize_catmullrom_2.png b/docs/resources/_gen/images/showcase/forestry/featured_hu77de7d99834fa13b854b7fc62e2912a7_227009_640x0_resize_catmullrom_2.png deleted file mode 100644 index 7c98b0459..000000000 Binary files a/docs/resources/_gen/images/showcase/forestry/featured_hu77de7d99834fa13b854b7fc62e2912a7_227009_640x0_resize_catmullrom_2.png and /dev/null differ diff --git a/docs/resources/_gen/images/showcase/hartwell-insurance/featured_hu642e730c6f819b15fc6ebbaa25b0243f_446603_1024x512_fill_catmullrom_top_2.png b/docs/resources/_gen/images/showcase/hartwell-insurance/featured_hu642e730c6f819b15fc6ebbaa25b0243f_446603_1024x512_fill_catmullrom_top_2.png deleted file mode 100644 index 020a3f7fa..000000000 Binary files a/docs/resources/_gen/images/showcase/hartwell-insurance/featured_hu642e730c6f819b15fc6ebbaa25b0243f_446603_1024x512_fill_catmullrom_top_2.png and /dev/null differ diff --git a/docs/resources/_gen/images/showcase/hartwell-insurance/featured_hu642e730c6f819b15fc6ebbaa25b0243f_446603_640x0_resize_catmullrom_2.png b/docs/resources/_gen/images/showcase/hartwell-insurance/featured_hu642e730c6f819b15fc6ebbaa25b0243f_446603_640x0_resize_catmullrom_2.png deleted file mode 100644 index d18131eb5..000000000 Binary files a/docs/resources/_gen/images/showcase/hartwell-insurance/featured_hu642e730c6f819b15fc6ebbaa25b0243f_446603_640x0_resize_catmullrom_2.png and /dev/null differ diff --git a/docs/resources/_gen/images/showcase/hartwell-insurance/featured_hu642e730c6f819b15fc6ebbaa25b0243f_446603_a6f43693b7589a8d91c844654967eb51.png b/docs/resources/_gen/images/showcase/hartwell-insurance/featured_hu642e730c6f819b15fc6ebbaa25b0243f_446603_a6f43693b7589a8d91c844654967eb51.png deleted file mode 100644 index 319316d00..000000000 Binary files a/docs/resources/_gen/images/showcase/hartwell-insurance/featured_hu642e730c6f819b15fc6ebbaa25b0243f_446603_a6f43693b7589a8d91c844654967eb51.png and /dev/null differ diff --git a/docs/resources/_gen/images/showcase/letsencrypt/featured_hu51cfa254cfc1fb105704d2cdd6ae4737_147459_1024x512_fill_catmullrom_top_2.png b/docs/resources/_gen/images/showcase/letsencrypt/featured_hu51cfa254cfc1fb105704d2cdd6ae4737_147459_1024x512_fill_catmullrom_top_2.png deleted file mode 100644 index 5df68ea1f..000000000 Binary files a/docs/resources/_gen/images/showcase/letsencrypt/featured_hu51cfa254cfc1fb105704d2cdd6ae4737_147459_1024x512_fill_catmullrom_top_2.png and /dev/null differ diff --git a/docs/resources/_gen/images/showcase/letsencrypt/featured_hu51cfa254cfc1fb105704d2cdd6ae4737_147459_640x0_resize_catmullrom_2.png b/docs/resources/_gen/images/showcase/letsencrypt/featured_hu51cfa254cfc1fb105704d2cdd6ae4737_147459_640x0_resize_catmullrom_2.png deleted file mode 100644 index 7589afd5b..000000000 Binary files a/docs/resources/_gen/images/showcase/letsencrypt/featured_hu51cfa254cfc1fb105704d2cdd6ae4737_147459_640x0_resize_catmullrom_2.png and /dev/null differ diff --git a/docs/resources/_gen/images/showcase/letsencrypt/featured_hu51cfa254cfc1fb105704d2cdd6ae4737_147459_825bc0f79626434a7ab711238e84984a.png b/docs/resources/_gen/images/showcase/letsencrypt/featured_hu51cfa254cfc1fb105704d2cdd6ae4737_147459_825bc0f79626434a7ab711238e84984a.png deleted file mode 100644 index 9e531de4b..000000000 Binary files a/docs/resources/_gen/images/showcase/letsencrypt/featured_hu51cfa254cfc1fb105704d2cdd6ae4737_147459_825bc0f79626434a7ab711238e84984a.png and /dev/null differ diff --git a/docs/resources/_gen/images/showcase/linode/featured_hu6acc14b2375e47c4c764fef09fdb54c0_126664_1024x512_fill_catmullrom_top_2.png b/docs/resources/_gen/images/showcase/linode/featured_hu6acc14b2375e47c4c764fef09fdb54c0_126664_1024x512_fill_catmullrom_top_2.png deleted file mode 100644 index 3f5b94403..000000000 Binary files a/docs/resources/_gen/images/showcase/linode/featured_hu6acc14b2375e47c4c764fef09fdb54c0_126664_1024x512_fill_catmullrom_top_2.png and /dev/null differ diff --git a/docs/resources/_gen/images/showcase/linode/featured_hu6acc14b2375e47c4c764fef09fdb54c0_126664_640x0_resize_catmullrom_2.png b/docs/resources/_gen/images/showcase/linode/featured_hu6acc14b2375e47c4c764fef09fdb54c0_126664_640x0_resize_catmullrom_2.png deleted file mode 100644 index b2d501efb..000000000 Binary files a/docs/resources/_gen/images/showcase/linode/featured_hu6acc14b2375e47c4c764fef09fdb54c0_126664_640x0_resize_catmullrom_2.png and /dev/null differ diff --git a/docs/resources/_gen/images/showcase/linode/featured_hu6acc14b2375e47c4c764fef09fdb54c0_126664_97b33e8221e700cd517d4ce317c69e48.png b/docs/resources/_gen/images/showcase/linode/featured_hu6acc14b2375e47c4c764fef09fdb54c0_126664_97b33e8221e700cd517d4ce317c69e48.png deleted file mode 100644 index 9f8b5a918..000000000 Binary files a/docs/resources/_gen/images/showcase/linode/featured_hu6acc14b2375e47c4c764fef09fdb54c0_126664_97b33e8221e700cd517d4ce317c69e48.png and /dev/null differ diff --git a/docs/resources/_gen/images/showcase/pace-revenue-management/featured_hu143b6afebcd8780a08aa0a9f8e95dd02_298908_1024x512_fill_catmullrom_top_2.png b/docs/resources/_gen/images/showcase/pace-revenue-management/featured_hu143b6afebcd8780a08aa0a9f8e95dd02_298908_1024x512_fill_catmullrom_top_2.png deleted file mode 100644 index c295aafad..000000000 Binary files a/docs/resources/_gen/images/showcase/pace-revenue-management/featured_hu143b6afebcd8780a08aa0a9f8e95dd02_298908_1024x512_fill_catmullrom_top_2.png and /dev/null differ diff --git a/docs/resources/_gen/images/showcase/pace-revenue-management/featured_hu143b6afebcd8780a08aa0a9f8e95dd02_298908_640x0_resize_catmullrom_2.png b/docs/resources/_gen/images/showcase/pace-revenue-management/featured_hu143b6afebcd8780a08aa0a9f8e95dd02_298908_640x0_resize_catmullrom_2.png deleted file mode 100644 index 3bb9e2a67..000000000 Binary files a/docs/resources/_gen/images/showcase/pace-revenue-management/featured_hu143b6afebcd8780a08aa0a9f8e95dd02_298908_640x0_resize_catmullrom_2.png and /dev/null differ diff --git a/docs/resources/_gen/images/showcase/pace-revenue-management/featured_hu143b6afebcd8780a08aa0a9f8e95dd02_298908_7e3f008d047fb3522bf02df4e9229522.png b/docs/resources/_gen/images/showcase/pace-revenue-management/featured_hu143b6afebcd8780a08aa0a9f8e95dd02_298908_7e3f008d047fb3522bf02df4e9229522.png deleted file mode 100644 index 8d1c41943..000000000 Binary files a/docs/resources/_gen/images/showcase/pace-revenue-management/featured_hu143b6afebcd8780a08aa0a9f8e95dd02_298908_7e3f008d047fb3522bf02df4e9229522.png and /dev/null differ diff --git a/docs/resources/_gen/images/showcase/quiply-employee-communications-app/featured_hua0e0d1ed0f0bc15921e78476b0c86c95_631206_1024x512_fill_catmullrom_top_2.png b/docs/resources/_gen/images/showcase/quiply-employee-communications-app/featured_hua0e0d1ed0f0bc15921e78476b0c86c95_631206_1024x512_fill_catmullrom_top_2.png deleted file mode 100644 index 4afe5049c..000000000 Binary files a/docs/resources/_gen/images/showcase/quiply-employee-communications-app/featured_hua0e0d1ed0f0bc15921e78476b0c86c95_631206_1024x512_fill_catmullrom_top_2.png and /dev/null differ diff --git a/docs/resources/_gen/images/showcase/quiply-employee-communications-app/featured_hua0e0d1ed0f0bc15921e78476b0c86c95_631206_3b6053b86d6afebe8262ece1955ed6cf.png b/docs/resources/_gen/images/showcase/quiply-employee-communications-app/featured_hua0e0d1ed0f0bc15921e78476b0c86c95_631206_3b6053b86d6afebe8262ece1955ed6cf.png deleted file mode 100644 index e9e149400..000000000 Binary files a/docs/resources/_gen/images/showcase/quiply-employee-communications-app/featured_hua0e0d1ed0f0bc15921e78476b0c86c95_631206_3b6053b86d6afebe8262ece1955ed6cf.png and /dev/null differ diff --git a/docs/resources/_gen/images/showcase/quiply-employee-communications-app/featured_hua0e0d1ed0f0bc15921e78476b0c86c95_631206_640x0_resize_catmullrom_2.png b/docs/resources/_gen/images/showcase/quiply-employee-communications-app/featured_hua0e0d1ed0f0bc15921e78476b0c86c95_631206_640x0_resize_catmullrom_2.png deleted file mode 100644 index d8c6222d1..000000000 Binary files a/docs/resources/_gen/images/showcase/quiply-employee-communications-app/featured_hua0e0d1ed0f0bc15921e78476b0c86c95_631206_640x0_resize_catmullrom_2.png and /dev/null differ diff --git a/docs/resources/_gen/images/showcase/stackimpact/featured_hu863cdba7b6e18bb95f64289a25912f5c_153794_1024x512_fill_catmullrom_top_2.png b/docs/resources/_gen/images/showcase/stackimpact/featured_hu863cdba7b6e18bb95f64289a25912f5c_153794_1024x512_fill_catmullrom_top_2.png deleted file mode 100644 index 4041d28df..000000000 Binary files a/docs/resources/_gen/images/showcase/stackimpact/featured_hu863cdba7b6e18bb95f64289a25912f5c_153794_1024x512_fill_catmullrom_top_2.png and /dev/null differ diff --git a/docs/resources/_gen/images/showcase/stackimpact/featured_hu863cdba7b6e18bb95f64289a25912f5c_153794_640x0_resize_catmullrom_2.png b/docs/resources/_gen/images/showcase/stackimpact/featured_hu863cdba7b6e18bb95f64289a25912f5c_153794_640x0_resize_catmullrom_2.png deleted file mode 100644 index 7dbd463bb..000000000 Binary files a/docs/resources/_gen/images/showcase/stackimpact/featured_hu863cdba7b6e18bb95f64289a25912f5c_153794_640x0_resize_catmullrom_2.png and /dev/null differ diff --git a/docs/resources/_gen/images/showcase/stackimpact/featured_hu863cdba7b6e18bb95f64289a25912f5c_153794_671a5c232ffa27a2cf198d2c39f253eb.png b/docs/resources/_gen/images/showcase/stackimpact/featured_hu863cdba7b6e18bb95f64289a25912f5c_153794_671a5c232ffa27a2cf198d2c39f253eb.png deleted file mode 100644 index d27a44e98..000000000 Binary files a/docs/resources/_gen/images/showcase/stackimpact/featured_hu863cdba7b6e18bb95f64289a25912f5c_153794_671a5c232ffa27a2cf198d2c39f253eb.png and /dev/null differ diff --git a/docs/resources/_gen/images/showcase/template/featured-template_hu2f0d11388f944348b232a431caeb965b_41270_0be9b039f9029effab69b9239e224cf7.png b/docs/resources/_gen/images/showcase/template/featured-template_hu2f0d11388f944348b232a431caeb965b_41270_0be9b039f9029effab69b9239e224cf7.png deleted file mode 100644 index 0026f811e..000000000 Binary files a/docs/resources/_gen/images/showcase/template/featured-template_hu2f0d11388f944348b232a431caeb965b_41270_0be9b039f9029effab69b9239e224cf7.png and /dev/null differ diff --git a/docs/resources/_gen/images/showcase/template/featured-template_hu2f0d11388f944348b232a431caeb965b_41270_1024x512_fill_catmullrom_top_2.png b/docs/resources/_gen/images/showcase/template/featured-template_hu2f0d11388f944348b232a431caeb965b_41270_1024x512_fill_catmullrom_top_2.png deleted file mode 100644 index 10265e45e..000000000 Binary files a/docs/resources/_gen/images/showcase/template/featured-template_hu2f0d11388f944348b232a431caeb965b_41270_1024x512_fill_catmullrom_top_2.png and /dev/null differ diff --git a/docs/resources/_gen/images/showcase/template/featured-template_hu2f0d11388f944348b232a431caeb965b_41270_640x0_resize_catmullrom_2.png b/docs/resources/_gen/images/showcase/template/featured-template_hu2f0d11388f944348b232a431caeb965b_41270_640x0_resize_catmullrom_2.png deleted file mode 100644 index a58f0b78c..000000000 Binary files a/docs/resources/_gen/images/showcase/template/featured-template_hu2f0d11388f944348b232a431caeb965b_41270_640x0_resize_catmullrom_2.png and /dev/null differ diff --git a/docs/src/css/_chroma.css b/docs/src/css/_chroma.css deleted file mode 100644 index 1ad06604b..000000000 --- a/docs/src/css/_chroma.css +++ /dev/null @@ -1,43 +0,0 @@ -/* Background */ .chroma { background-color: #f0f0f0 } -/* Error */ .chroma .ss4 { } -/* LineHighlight */ .chroma .hl { background-color: #ffffcc; display: block; width: 100% } -/* LineNumbers */ .chroma .ln { ; margin-right: 0.4em; padding: 0 0.4em 0 0.4em; } -/* Keyword */ .chroma .s3e8 { color: #007020; font-weight: bold } -/* KeywordPseudo */ .chroma .s3ec { color: #007020 } -/* KeywordType */ .chroma .s3ee { color: #902000 } -/* NameAttribute */ .chroma .s7d1 { color: #4070a0 } -/* NameBuiltin */ .chroma .s7d2 { color: #007020 } -/* NameClass */ .chroma .s7d4 { color: #0e84b5; font-weight: bold } -/* NameConstant */ .chroma .s7d5 { color: #60add5 } -/* NameDecorator */ .chroma .s7d6 { color: #555555; font-weight: bold } -/* NameEntity */ .chroma .s7d7 { color: #d55537; font-weight: bold } -/* NameException */ .chroma .s7d8 { color: #007020 } -/* NameFunction */ .chroma .s7d9 { color: #06287e } -/* NameLabel */ .chroma .s7dc { color: #002070; font-weight: bold } -/* NameNamespace */ .chroma .s7dd { color: #0e84b5; font-weight: bold } -/* NameTag */ .chroma .s7e2 { color: #062873; font-weight: bold } -/* NameVariable */ .chroma .s7e3 { color: #bb60d5 } -/* LiteralString */ .chroma .sc1c { color: #4070a0 } -/* LiteralStringDoc */ .chroma .sc23 { color: #4070a0; font-style: italic } -/* LiteralStringEscape */ .chroma .sc25 { color: #4070a0; font-weight: bold } -/* LiteralStringInterpol */ .chroma .sc27 { color: #70a0d0; font-style: italic } -/* LiteralStringOther */ .chroma .sc29 { color: #c65d09 } -/* LiteralStringRegex */ .chroma .sc2a { color: #235388 } -/* LiteralStringSymbol */ .chroma .sc2c { color: #517918 } -/* LiteralNumber */ .chroma .sc80 { color: #40a070 } -/* Operator */ .chroma .sfa0 { color: #666666 } -/* OperatorWord */ .chroma .sfa1 { color: #007020; font-weight: bold } -/* Comment */ .chroma .s1770 { color: #60a0b0; font-style: italic } -/* CommentSpecial */ .chroma .s1774 { color: #60a0b0; background-color: #fff0f0 } -/* CommentPreproc */ .chroma .s17d4 { color: #007020 } -/* GenericDeleted */ .chroma .s1b59 { color: #a00000 } -/* GenericEmph */ .chroma .s1b5a { font-style: italic } -/* GenericError */ .chroma .s1b5b { color: #ff0000 } -/* GenericHeading */ .chroma .s1b5c { color: #000080; font-weight: bold } -/* GenericInserted */ .chroma .s1b5d { color: #00a000 } -/* GenericOutput */ .chroma .s1b5e { color: #888888 } -/* GenericPrompt */ .chroma .s1b5f { color: #c65d09; font-weight: bold } -/* GenericStrong */ .chroma .s1b60 { font-weight: bold } -/* GenericSubheading */ .chroma .s1b61 { color: #800080; font-weight: bold } -/* GenericTraceback */ .chroma .s1b62 { color: #0044dd } -/* TextWhitespace */ .chroma .s1f41 { color: #bbbbbb } diff --git a/docs/src/package-lock.json b/docs/src/package-lock.json deleted file mode 100644 index 48e341a09..000000000 --- a/docs/src/package-lock.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "lockfileVersion": 1 -} diff --git a/docs/themes/gohugoioTheme/static/android-chrome-144x144.png b/docs/static/android-chrome-144x144.png similarity index 100% rename from docs/themes/gohugoioTheme/static/android-chrome-144x144.png rename to docs/static/android-chrome-144x144.png diff --git a/docs/themes/gohugoioTheme/static/android-chrome-192x192.png b/docs/static/android-chrome-192x192.png similarity index 100% rename from docs/themes/gohugoioTheme/static/android-chrome-192x192.png rename to docs/static/android-chrome-192x192.png diff --git a/docs/themes/gohugoioTheme/static/android-chrome-256x256.png b/docs/static/android-chrome-256x256.png similarity index 100% rename from docs/themes/gohugoioTheme/static/android-chrome-256x256.png rename to docs/static/android-chrome-256x256.png diff --git a/docs/themes/gohugoioTheme/static/android-chrome-36x36.png b/docs/static/android-chrome-36x36.png similarity index 100% rename from docs/themes/gohugoioTheme/static/android-chrome-36x36.png rename to docs/static/android-chrome-36x36.png diff --git a/docs/themes/gohugoioTheme/static/android-chrome-48x48.png b/docs/static/android-chrome-48x48.png similarity index 100% rename from docs/themes/gohugoioTheme/static/android-chrome-48x48.png rename to docs/static/android-chrome-48x48.png diff --git a/docs/themes/gohugoioTheme/static/android-chrome-72x72.png b/docs/static/android-chrome-72x72.png similarity index 100% rename from docs/themes/gohugoioTheme/static/android-chrome-72x72.png rename to docs/static/android-chrome-72x72.png diff --git a/docs/themes/gohugoioTheme/static/android-chrome-96x96.png b/docs/static/android-chrome-96x96.png similarity index 100% rename from docs/themes/gohugoioTheme/static/android-chrome-96x96.png rename to docs/static/android-chrome-96x96.png diff --git a/docs/static/apple-touch-icon.png b/docs/static/apple-touch-icon.png index 50e23ce1d..ecf1fc020 100644 Binary files a/docs/static/apple-touch-icon.png and b/docs/static/apple-touch-icon.png differ diff --git a/docs/static/contribute/development/accept-cla.png b/docs/static/contribute/development/accept-cla.png deleted file mode 100755 index 929fda6ab..000000000 Binary files a/docs/static/contribute/development/accept-cla.png and /dev/null differ diff --git a/docs/static/contribute/development/ci-errors.png b/docs/static/contribute/development/ci-errors.png deleted file mode 100755 index 95cd290b6..000000000 Binary files a/docs/static/contribute/development/ci-errors.png and /dev/null differ diff --git a/docs/static/contribute/development/copy-remote-url.png b/docs/static/contribute/development/copy-remote-url.png deleted file mode 100755 index 9006f4a48..000000000 Binary files a/docs/static/contribute/development/copy-remote-url.png and /dev/null differ diff --git a/docs/static/contribute/development/forking-a-repository.png b/docs/static/contribute/development/forking-a-repository.png deleted file mode 100755 index ea132cab3..000000000 Binary files a/docs/static/contribute/development/forking-a-repository.png and /dev/null differ diff --git a/docs/static/contribute/development/open-pull-request.png b/docs/static/contribute/development/open-pull-request.png deleted file mode 100755 index 63b504fb2..000000000 Binary files a/docs/static/contribute/development/open-pull-request.png and /dev/null differ diff --git a/docs/static/css/bootstrap-additions-gohugo.css b/docs/static/css/bootstrap-additions-gohugo.css deleted file mode 100644 index 4128f3b8c..000000000 --- a/docs/static/css/bootstrap-additions-gohugo.css +++ /dev/null @@ -1,65 +0,0 @@ -/*! - * Bootstrap for http://gohugo.io/ - * additional property-value pairs, for selectors already in Bootstrap; - * also, additional Bootstrap-like selectors - * - * Keep all such property additions to Bootstrap v3.3.6 here. - * - * Here, maintain the same order as the original Bootstrap file. - * - * Keep any additional Bootstrap-like selectors at the bottom. - * - * Copyright 2013-2016 Steve Francia and the Hugo Authors - * - * Based on 'dist/css/bootstrap.css' from: - * - * Bootstrap v3.3.6 (http://getbootstrap.com) - * Copyright 2011-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - */ -.btn { - -webkit-transition: all 0.15s; - -moz-transition: all 0.15s; - transition: all 0.15s; -} -.btn-primary:focus, -.btn-primary:active, -.btn-success:focus, -.btn-success:active, -.btn-info:focus, -.btn-info:active { - background-color: transparent; -} -.btn-default:hover, -.btn-default:active, -.btn-primary:hover, -.btn-primary:active, -.btn-success:hover, -.btn-success:active, -.btn-info:hover, -.btn-info:active { - outline: none; -} - -/* additional Bootstrap-like selectors */ - -.btn-repo { - border-color: black; - background-color: rgba(30, 30, 30, 0.8); - color: white; -} -.btn-repo:focus, -.btn-repo:active { - color: black; -} -.btn-repo:hover { - background-color: aliceblue; -} -.btn-repo:focus, -.btn-repo:active { - background-color: transparent; -} -.btn-repo:hover, -.btn-repo:active { - outline: none; -} diff --git a/docs/static/css/bootstrap-changes-gohugo.css b/docs/static/css/bootstrap-changes-gohugo.css deleted file mode 100644 index d4ef99bcc..000000000 --- a/docs/static/css/bootstrap-changes-gohugo.css +++ /dev/null @@ -1,136 +0,0 @@ -/*! - * Bootstrap for http://gohugo.io/ - * value changes to properties in Bootstrap selectors; - * Bootstrap must already have the same properties in the same selectors. - * - * Keep all such value changes to Bootstrap v3.3.6 here. - * - * Here, maintain the same order as the original Bootstrap file. - * - * Keep all new properties, for new or existing selectors, - * in other stylesheets. - * - * Copyright 2013-2016 Steve Francia and the Hugo Authors - * - * Based on 'dist/css/bootstrap.css' from: - * - * Bootstrap v3.3.6 (http://getbootstrap.com) - * Copyright 2011-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - */ -body { - font-family: Lato; - color: #2b2b2b; -} -a { - color: #ff4088; -} -a:hover, -a:focus { - color: #ff4088; -} -h1, -h2, -h3, -h4, -h5, -h6 { - font-family: Lato; - font-weight: 400; -} -code, -kbd, -pre, -samp { - font-family: Menlo, Consolas, 'DejaVu Sans Mono', - 'Bitstream Vera Sans Mono', 'Lucida Console', Monaco, - 'Droid Sans Mono', monospace; -} -legend { - color: #2b2b2b; - border-bottom: 1px solid #c7c7cc; -} -.btn:focus { - /*outline: thin dotted; - outline: 5px auto -webkit-focus-ring-color; - outline-offset: -2px;*/ - outline: none; -} -.btn:hover, -.btn:focus { - color: #2b2b2b; -} -.btn-default:focus { - color: #fff; - background-color: #9e9e9e; - border-color: #9e9e9e; -} -.btn-default:hover { - color: #fff; - background-color: #9e9e9e; - border-color: #9e9e9e; -} -.btn-default:active { - color: #fff; - background-color: #9e9e9e; - border-color: #9e9e9e; -} -.btn-primary { - background-color: #007aff; - border-color: #007aff; -} -.btn-primary:focus { - color: #007aff; - border-color: #007aff; -} -.btn-primary:hover { - color: #007aff; - background-color: aliceblue; - border-color: #007aff; -} -.btn-primary:active { - color: #007aff; - border-color: #007aff; -} -.btn-success { - background-color: #4cd964; - border-color: #4cd964; -} -.btn-success:focus { - color: #4cd964; - border-color: #4cd964; -} -.btn-success:hover { - color: #4cd964; - background-color: aliceblue; - border-color: #4cd964; -} -.btn-success:active { - color: #4cd964; - border-color: #4cd964; -} -.btn-info { - background-color: #34aadc; - border-color: #34aadc; -} -.btn-info:focus { - color: #34aadc; - border-color: #34aadc; -} -.btn-info:hover { - color: #34aadc; - background-color: aliceblue; - border-color: #34aadc; -} -.btn-info:active { - color: #34aadc; - border-color: #34aadc; -} -.panel { - border: 0px solid transparent; - -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1); - box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1); -} -.panel-body { - padding: 15px 15px 0px 15px; -} diff --git a/docs/static/css/bootstrap-stripped-gohugo.css b/docs/static/css/bootstrap-stripped-gohugo.css deleted file mode 100644 index aafb0df53..000000000 --- a/docs/static/css/bootstrap-stripped-gohugo.css +++ /dev/null @@ -1,1380 +0,0 @@ -/*! - * Bootstrap for http://gohugo.io/ - * with many unused property-value pairs, as well as selectors, removed - * - * Copyright 2013-2016 Steve Francia and the Hugo Authors - * - * Based on 'dist/css/bootstrap.css' from: - * - * 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; - -webkit-text-size-adjust: 100%; - -ms-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; -} -a:active, -a:hover { - outline: 0; -} -b, -strong { - font-weight: bold; -} -dfn { - font-style: italic; -} -h1 { - margin: .67em 0; - font-size: 2em; -} -mark { - color: #000; - background: #ff0; -} -small { - font-size: 80%; -} -sub, -sup { - position: relative; - font-size: 75%; - line-height: 0; - vertical-align: baseline; -} -sup { - top: -.5em; -} -sub { - bottom: -.25em; -} -img { - border: 0; -} -figure { - margin: 1em 40px; -} -hr { - height: 0; - -webkit-box-sizing: content-box; - -moz-box-sizing: content-box; - box-sizing: content-box; -} -pre { - overflow: auto; -} -code, -kbd, -pre, -samp { - font-family: monospace, monospace; - font-size: 1em; -} -button, -input, -optgroup, -select, -textarea { - margin: 0; - font: inherit; - color: inherit; -} -button { - overflow: visible; -} -button, -select { - text-transform: none; -} -button { - -webkit-appearance: button; - cursor: pointer; -} -button::-moz-focus-inner, -input::-moz-focus-inner { - padding: 0; - border: 0; -} -input { - line-height: normal; -} -input[type="number"]::-webkit-inner-spin-button, -input[type="number"]::-webkit-outer-spin-button { - height: auto; -} -input[type="search"]::-webkit-search-cancel-button, -input[type="search"]::-webkit-search-decoration { - -webkit-appearance: none; -} -fieldset { - padding: .35em .625em .75em; - margin: 0 2px; - border: 1px solid #c0c0c0; -} -legend { - padding: 0; - border: 0; -} -textarea { - overflow: auto; -} -optgroup { - font-weight: bold; -} -table { - border-spacing: 0; - border-collapse: collapse; -} -td, -th { - padding: 0; -} -/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */ -@media print { - *, - *:before, - *:after { - color: #000 !important; - text-shadow: none !important; - background: transparent !important; - -webkit-box-shadow: none !important; - box-shadow: none !important; - } - a, - a:visited { - text-decoration: underline; - } - a[href]:after { - content: " (" attr(href) ")"; - } - 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; - } -} -@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'); -} -* { - -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: "Helvetica Neue", Helvetica, Arial, sans-serif; - font-size: 14px; - line-height: 1.42857143; - color: #333; - background-color: #fff; -} -input, -button, -select, -textarea { - font-family: inherit; - font-size: inherit; - line-height: inherit; -} -a { - color: #337ab7; - text-decoration: none; -} -a:hover, -a:focus { - color: #23527c; - 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; -} -hr { - margin-top: 20px; - margin-bottom: 20px; - border: 0; - border-top: 1px solid #eee; -} -h1, -h2, -h3, -h4, -h5, -h6 { - font-family: inherit; - font-weight: 500; - line-height: 1.1; - color: inherit; -} -h1, -h2, -h3 { - margin-top: 20px; - margin-bottom: 10px; -} -h4, -h5, -h6 { - margin-top: 10px; - margin-bottom: 10px; -} -h1 { - font-size: 36px; -} -h2 { - font-size: 30px; -} -h3 { - font-size: 24px; -} -h4 { - font-size: 18px; -} -h5 { - font-size: 14px; -} -h6 { - font-size: 12px; -} -p { - margin: 0 0 10px; -} -.lead { - margin-bottom: 20px; - font-size: 16px; - font-weight: 300; - line-height: 1.4; -} -@media (min-width: 768px) { - .lead { - font-size: 21px; - } -} -small, -.small { - font-size: 85%; -} -.text-center { - text-align: center; -} -ul, -ol { - margin-top: 0; - margin-bottom: 10px; -} -ul ul { - margin-bottom: 0; -} -.list-inline { - padding-left: 0; - margin-left: -5px; - list-style: none; -} -.list-inline > li { - display: inline-block; - padding-right: 5px; - padding-left: 5px; -} -dl { - margin-top: 0; - margin-bottom: 20px; -} -dt, -dd { - line-height: 1.42857143; -} -dt { - font-weight: bold; -} -dd { - margin-left: 0; -} -blockquote { - padding: 10px 20px; - margin: 0 0 20px; - font-size: 17.5px; - border-left: 5px solid #eee; -} -blockquote p:last-child, -blockquote ul:last-child, -blockquote ol:last-child { - margin-bottom: 0; -} -address { - margin-bottom: 20px; - font-style: normal; - line-height: 1.42857143; -} -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: 4px; -} -pre { - display: block; - padding: 9.5px; - margin: 0 0 10px; - font-size: 13px; - line-height: 1.42857143; - color: #333; - word-break: break-all; - word-wrap: break-word; - background-color: #f5f5f5; - border: 1px solid #ccc; - border-radius: 4px; -} -pre code { - padding: 0; - font-size: inherit; - color: inherit; - white-space: pre-wrap; - background-color: transparent; - border-radius: 0; -} -.container { - padding-right: 15px; - padding-left: 15px; - margin-right: auto; - margin-left: auto; -} -@media (min-width: 768px) { - .container { - width: 750px; - } -} -@media (min-width: 992px) { - .container { - width: 970px; - } -} -@media (min-width: 1200px) { - .container { - width: 1170px; - } -} -.row { - margin-right: -15px; - margin-left: -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-right: 15px; - padding-left: 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: #777; - text-align: left; -} -th { - text-align: left; -} -fieldset { - min-width: 0; - padding: 0; - margin: 0; - border: 0; -} -legend { - display: block; - width: 100%; - padding: 0; - margin-bottom: 20px; - font-size: 21px; - line-height: inherit; - color: #333; - border: 0; - border-bottom: 1px solid #e5e5e5; -} -label { - display: inline-block; - max-width: 100%; - margin-bottom: 5px; - font-weight: bold; -} -.form-control::-moz-placeholder { - color: #999; - opacity: 1; -} -.form-control:-ms-input-placeholder { - color: #999; -} -.form-control::-webkit-input-placeholder { - color: #999; -} -.btn { - display: inline-block; - padding: 6px 12px; - margin-bottom: 0; - font-size: 14px; - font-weight: normal; - line-height: 1.42857143; - text-align: center; - white-space: nowrap; - vertical-align: middle; - -ms-touch-action: manipulation; - touch-action: manipulation; - cursor: pointer; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - background-image: none; - border: 1px solid transparent; - border-radius: 4px; -} -.btn:focus { - outline: thin dotted; - outline: 5px auto -webkit-focus-ring-color; - outline-offset: -2px; -} -.btn:hover, -.btn:focus { - color: #333; - text-decoration: none; -} -.btn:active { - outline: 0; -} -.btn-default { - color: #333; - background-color: #fff; - border-color: #ccc; -} -.btn-default:focus { - color: #333; - background-color: #e6e6e6; - border-color: #8c8c8c; -} -.btn-default:hover { - color: #333; - background-color: #e6e6e6; - border-color: #adadad; -} -.btn-default:active { - color: #333; - background-color: #e6e6e6; - border-color: #adadad; -} -.btn-default:active { - background-image: none; -} -.btn-primary { - color: #fff; - background-color: #337ab7; - border-color: #2e6da4; -} -.btn-primary:focus { - color: #fff; - border-color: #122b40; -} -.btn-primary:hover { - color: #fff; - background-color: #286090; - border-color: #204d74; -} -.btn-primary:active { - color: #fff; - border-color: #204d74; -} -.btn-primary:active { - background-image: none; -} -.btn-success { - color: #fff; - background-color: #5cb85c; - border-color: #4cae4c; -} -.btn-success:focus { - color: #fff; - border-color: #255625; -} -.btn-success:hover { - color: #fff; - background-color: #449d44; - border-color: #398439; -} -.btn-success:active { - color: #fff; - border-color: #398439; -} -.btn-success:active { - background-image: none; -} -.btn-info { - color: #fff; - background-color: #5bc0de; - border-color: #46b8da; -} -.btn-info:focus { - color: #fff; - border-color: #1b6d85; -} -.btn-info:hover { - color: #fff; - background-color: #31b0d5; - border-color: #269abc; -} -.btn-info:active { - color: #fff; - border-color: #269abc; -} -.btn-lg, -.btn-group-lg > .btn { - padding: 10px 16px; - font-size: 18px; - line-height: 1.3333333; - border-radius: 6px; -} -.btn-sm, -.btn-group-sm > .btn { - padding: 5px 10px; - font-size: 12px; - line-height: 1.5; - border-radius: 3px; -} -.btn-xs, -.btn-group-xs > .btn { - padding: 1px 5px; - font-size: 12px; - line-height: 1.5; - border-radius: 3px; -} -.nav { - padding-left: 0; - margin-bottom: 0; - list-style: none; -} -@-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; - } -} -.panel { - margin-bottom: 20px; - background-color: #fff; - border: 1px solid transparent; - -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, .05); - box-shadow: 0 1px 1px rgba(0, 0, 0, .05); -} -.panel-body { - padding: 15px; -} -.carousel { - position: relative; -} -.carousel-inner { - position: relative; - width: 100%; - overflow: hidden; -} -.carousel-inner > .item { - position: relative; - display: none; - -webkit-transition: .6s ease-in-out left; - -o-transition: .6s ease-in-out left; - transition: .6s ease-in-out left; -} -.carousel-inner > .active { - display: block; -} -.carousel-inner > .active { - left: 0; -} -.carousel-indicators { - position: absolute; - bottom: 10px; - left: 50%; - z-index: 15; - width: 60%; - padding-left: 0; - margin-left: -30%; - text-align: center; - list-style: none; -} -.carousel-indicators li { - display: inline-block; - width: 10px; - height: 10px; - margin: 1px; - text-indent: -999px; - cursor: pointer; - background-color: #000 \9; - background-color: rgba(0, 0, 0, 0); - border: 1px solid #fff; - border-radius: 10px; -} -.carousel-indicators .active { - width: 12px; - height: 12px; - margin: 0; - background-color: #fff; -} -@media screen and (min-width: 768px) { - .carousel-indicators { - bottom: 20px; - } -} -.container:before, -.container:after, -.row:before, -.row:after, -.nav:before, -.nav:after, -.panel-body:before, -.panel-body:after { - display: table; - content: " "; -} -.container:after, -.row:after, -.nav:after, -.panel-body:after { - clear: both; -} -.pull-right { - float: right !important; -} -@-ms-viewport { - width: device-width; -} -@media (max-width: 767px) { - .hidden-xs { - display: none !important; - } -} diff --git a/docs/static/css/content-style.css b/docs/static/css/content-style.css deleted file mode 100644 index 1356b0495..000000000 --- a/docs/static/css/content-style.css +++ /dev/null @@ -1,84 +0,0 @@ -/* Styles used by tables at the URLs: - - 1. /overview/configuration/#configure-blackfriday-rendering - 2. /templates/functions/#math - -Their HTML is in the files: - - 1. ./docs/content/overview/configuration.md - 2. ./docs/content/templates/functions.md -*/ - -table.table { - margin: 1em 0; -} -table.table-bordered tr th, -table.table-bordered tr td { - border-width: 2px; - border-color: #dddddd; - border-style: solid; - padding: 0 0.5em; -} -table.table-bordered-configuration { - max-width: 100%; -} -table.table-bordered-configuration, -table.table-bordered-configuration tr, -table.table-bordered-configuration tr th, -table.table-bordered-configuration tr td:not(.purpose-description) { - border-width: 2px; -} -table.table-bordered-configuration tr td.purpose-description { - border-width: 1px; -} -table.table-bordered-configuration, -table.table-bordered-configuration tr, -table.table-bordered-configuration tr th, -table.table-bordered-configuration tr td { - border-color: #dddddd; -} -table.table-bordered-configuration { - border-right-style: solid; - border-bottom-style: solid; -} -table.table-bordered-configuration tr, -table.table-bordered-configuration tr th, -table.table-bordered-configuration tr td:not(.purpose-description) { - border-left-style: solid; -} -table.table-bordered-configuration tr th, -table.table-bordered-configuration tr td:not(.purpose-description) { - border-top-style: solid; -} -table.table-bordered-configuration tr td.purpose-description { - border-top-style: dotted; -} -table.table-bordered-configuration tr th, -table.table-bordered-configuration tr td { - padding-top: 0; - padding-right: 0.5em; - padding-left: 0.5em; -} -table.table-bordered-configuration tr th, -table.table-bordered-configuration tr td:not(.purpose-description) { - padding-bottom: 0; -} -table.table-bordered-configuration tr td.purpose-description { - padding-bottom: 0.5em; -} -table.table-bordered-configuration tr th, -table.table-bordered-configuration tr td:not(.purpose-description) { - text-align: center; -} -table.table-bordered-configuration tr td:not(.purpose-description) code { - padding: 0; - border-radius: 0; - font-size: 14px; - background-color: inherit; - color: darkgreen; -} -table.table-bordered-configuration tr td span.purpose-title { - padding-right: 0.15em; - font-style: italic; - color: chocolate; -} diff --git a/docs/static/css/home-page-style-responsive.css b/docs/static/css/home-page-style-responsive.css deleted file mode 100644 index 46ce3eb43..000000000 --- a/docs/static/css/home-page-style-responsive.css +++ /dev/null @@ -1,44 +0,0 @@ -/* full page image header area */ - -@media (min-width: 1024.1px) { - .header { - background-image: url('../img/desk.jpg'); - } -} -@media (max-width: 319.9px) { - .header { - background-image: url('../img/desk-sm.jpg'); - } -} -@media (max-width: 319.9px), (min-width: 1024.1px) { - .header { - background-position: center center; - -webkit-background-size: cover; - -moz-background-size: cover; - -o-background-size: cover; - background-size: cover; - background-attachment: fixed; - } -} -@media (min-width: 320px) and (max-width: 1024px) { - .header { - background-position: 0% 0%; - -webkit-background-size: 100% 100%; - -moz-background-size: 100% 100%; - -o-background-size: 100% 100%; - background-size: 100% 100%; - background-attachment: scroll; - background-clip: border-box; - background-origin: padding-box; - } -} -@media (min-width: 320px) and (max-width: 1024px) and (orientation: portrait) { - .header { - background-image: url('../img/desk-mini.jpg'); - } -} -@media (min-width: 320px) and (max-width: 1024px) and (orientation: landscape) { - .header { - background-image: url('../img/desk-wide.jpg'); - } -} diff --git a/docs/static/css/home-page-style.css b/docs/static/css/home-page-style.css deleted file mode 100644 index 4ff3bc815..000000000 --- a/docs/static/css/home-page-style.css +++ /dev/null @@ -1,194 +0,0 @@ -/* global styles */ - -html { - width: 100%; - height: 100%; - letter-spacing: 0.5px; -} -body { - width: 100%; - height: 100%; - letter-spacing: 0.5px; - line-height: 1.6; - font-family: 'Arbutus Slab', "Helvetica Neue", "Helvetica", sans-serif !important; -} -h1, h2, h3, h4, h5, h6 { - font-family: 'Cabin', "Helvetica Neue", "Helvetica", sans-serif; -} -div.vert-text { - display: table-cell; - vertical-align: middle; - text-align: center; -} - -/* full page image header area */ - -div.buttonbox { - margin: 2em 0 4em; -} -img.logo { - padding: 2em; - width: 100%; - max-width: 35em; -} -div#main { - position: relative; - z-index: 99999; - background: rgb(255, 255, 255); - box-shadow: 0 0 15px rgba(0, 0, 0, 0.5); -} -.header { - display: table; - position: relative; - width: 100%; - height: 70%; - min-height: 70%; - z-index: 99999; - background-color: black; - background-repeat: no-repeat; -} -.header a.btn { - margin: 10px; - font-weight: 100; -} - -/* intro */ - -a:hover { - color: rgb(52, 73, 94); -} -i.callout-icon, i.lead-icon, i.point-icon { - display: inline-block; - vertical-align: middle; - text-align: center; - width: 140px; - height: 140px; - font-size: 56px; - line-height: 136px; - border-radius: 50%; - -webkit-transition: box-shadow 0.2s; - -moz-transition: box-shadow 0.2s; - transition: box-shadow 0.2s; -} -div.counterpoint { - background-color: rgb(255, 252, 244); -} -div.counterpoint, div.point { - padding: 50px 0; -} -div.counterpoint a, div.point a { - color: rgb(7,162,166); -} -div.counterpoint h2, div.point h2 { - line-height: 1.7; - font-size: 32pt; -} -a.icon-2x { - font-size: 200%; -} -i.lead-icon { - border: 3px solid #222222; -} -i.lead-icon:hover { - border: 3px solid black; - background: black; - color: #ffffff; -} -div.point { - background: rgb(96,210,211); - color: #ffffff; -} -div.point h2 > i { - color:#FF4088; -} -i.point-icon { - border: 3px solid #ffffff; -} -i.point-icon:hover { - background: #ffffff; - color: rgb(22, 203, 230); -} - -/* callout */ - -div.callout { - display: table; - table-layout: fixed; - width: 100%; - height: 420px; - padding: 50px 0; - background-color: rgb(118,156,172); - color: #ffffff; -} -i.callout-icon { - border: 3px solid #ffffff; -} -i.callout-icon:hover { - background: #ffffff; - color: rgb(249, 176, 190); -} - -/* call to action */ - -div#action.call-to-action { - padding: 30px 0px 40px; - padding: 30px 0px 50px; - width: 100%; - background-color: rgba(255, 255, 255, 0.19); - background: url('../img/gray.png'); - color: #ffffff; -} -div#action.call-to-action code { - font-size: 14pt; -} -div#action.call-to-action h1 { - padding-bottom: .5em; -} -div#action.call-to-action pre { - background-color: #545454; - color: #f9f2f4; - margin-bottom: 0; -} -div#action.call-to-action pre:hover { - background-color: #f9f2f4; - color: #545454; -} -div#action.call-to-action a.btn { - margin: 10px; -} -div#action.call-to-action a.quickstart { - font-weight: 300; - color: white; -} - -/* footer */ - -footer { - padding: 50px 0px 25px 0px; - font-size: 14px; - background: rgb(255, 255, 255); -} -footer a { - color: black; -} -footer a:focus, -footer a:hover { - text-decoration: none; - outline: none; -} -footer a:active { - color: green; -} - -/* Bootstrap addons */ - -div.owl-carousel a { - white-space: nowrap; - color: #243382; -} -div.owl-carousel blockquote { - border-left: 0px; -} -div.owl-carousel blockquote p { - font-size: 20pt; -} diff --git a/docs/static/css/hugofont.css b/docs/static/css/hugofont.css deleted file mode 100644 index 09d6ce070..000000000 --- a/docs/static/css/hugofont.css +++ /dev/null @@ -1,184 +0,0 @@ -@font-face { - font-family: 'hugo'; - src:url('../fonts/hugo.eot'); - src:url('../fonts/hugo.eot?#iefix') format('embedded-opentype'), - url('../fonts/hugo.woff') format('woff'), - url('../fonts/hugo.ttf') format('truetype'), - url('../fonts/hugo.svg#hugo') format('svg'); - font-weight: normal; - font-style: normal; -} - -[class^="icon-"], [class*=" icon-"] { - font-family: 'hugo'; - speak: none; - font-style: normal; - font-weight: normal; - font-variant: normal; - text-transform: none; - line-height: 1; - - /* Better Font Rendering =========== */ - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -.icon-home:before { - content: "\21"; -} -.icon-html5:before { - content: "\23"; -} -.icon-css3:before { - content: "\24"; -} -.icon-console:before { - content: "\25"; -} -.icon-link:before { - content: "\26"; -} -.icon-fire:before { - content: "\28"; -} -.icon-check-alt:before { - content: "\29"; -} -.icon-hugo_serif:before { - content: "\e600"; -} -.icon-x-altx-alt:before { - content: "\2a"; -} -.icon-circlestar:before { - content: "\2b"; -} -.icon-file-css:before { - content: "\2c"; -} -.icon-radio-checked:before { - content: "\2e"; -} -.icon-quote:before { - content: "\44"; -} -.icon-airplane2:before { - content: "\45"; -} -.icon-heart:before { - content: "\46"; -} -.icon-rocket:before { - content: "\47"; -} -.icon-house:before { - content: "\48"; -} -.icon-arrow-right:before { - content: "\e001"; -} -.icon-arrow-left:before { - content: "\e002"; -} -.icon-flow-branch:before { - content: "\e004"; -} -.icon-pen:before { - content: "\e005"; -} -.icon-idea:before { - content: "\3b"; -} -.icon-gears:before { - content: "\3c"; -} -.icon-talking:before { - content: "\3d"; -} -.icon-tag:before { - content: "\3e"; -} -.icon-rocket2:before { - content: "\3f"; -} -.icon-octocat:before { - content: "\41"; -} -.icon-announce:before { - content: "\42"; -} -.icon-edit:before { - content: "\43"; -} -.icon-power-cord:before { - content: "\50"; -} -.icon-apple:before { - content: "\51"; -} -.icon-windows8:before { - content: "\52"; -} -.icon-tux:before { - content: "\53"; -} -.icon-file-xml:before { - content: "\54"; -} -.icon-fork:before { - content: "\55"; -} -.icon-arrow-down:before { - content: "\56"; -} -.icon-pacman:before { - content: "\e000"; -} -.icon-embed:before { - content: "\2f"; -} -.icon-code:before { - content: "\30"; -} -.icon-cc:before { - content: "\31"; -} -.icon-cc-by:before { - content: "\32"; -} -.icon-cc-nc:before { - content: "\33"; -} -.icon-beaker-alt:before { - content: "\39"; -} -.icon-w3c:before { - content: "\3a"; -} -.icon-bolt:before { - content: "\49"; -} -.icon-flow-tree:before { - content: "\4a"; -} -.icon-twitter:before { - content: "\4b"; -} -.icon-beaker:before { - content: "\4c"; -} -.icon-images:before { - content: "\4d"; -} -.icon-bubbles:before { - content: "\4e"; -} -.icon-meter2:before { - content: "\4f"; -} -.icon-hugo_sans:before { - content: "\68"; -} -.icon-spf13:before { - content: "\27"; -} diff --git a/docs/static/css/style-responsive.css b/docs/static/css/style-responsive.css deleted file mode 100644 index 58f614bb4..000000000 --- a/docs/static/css/style-responsive.css +++ /dev/null @@ -1,72 +0,0 @@ -@media (max-width: 768px) { - - .header { - position: absolute; - } - - /*sidebar*/ - - #sidebar { - height: auto; - overflow: hidden; - position: absolute; - width: 100%; - z-index: 1001; - } - - - /* body container */ - #main-content { - margin: 0px!important; - position: none !important; - } - - .wrapper { - padding: inherit; - } - - #sidebar > ul > li { - margin: 0 10px 5px 10px; - } - #sidebar > ul > li > a { - height:35px; - line-height:35px; - padding: 0 10px; - text-align: left; - } - - #sidebar > ul > li > a, #sidebar > ul > li > ul.sub > li { - width: 100%; - } - #sidebar > ul > li > ul.sub > li > a { - background: transparent !important ; - } - - /* sidebar */ - #sidebar { - margin: 0px !important; - } - - .btn { - margin-bottom: 5px; - } - - .navigation.next { - display: none; - } - -} - -@media (max-width: 480px) { - - .notification-row { - display: none; - } -} - -@media (max-width:360px) { - - h1 { - font-size: 1.9em; - } -} diff --git a/docs/static/css/style.css b/docs/static/css/style.css deleted file mode 100644 index 312c247c9..000000000 --- a/docs/static/css/style.css +++ /dev/null @@ -1,684 +0,0 @@ -/* Import fonts */ -@import url(//fonts.googleapis.com/css?family=Lato:300,400,700,900,300italic,400italic,700italic,900italic); - -/* ****************************** - For the github btn -****************************** */ - -.github-btn { - font-size: 11px; -} -.github-btn, -.github-btn .btn { - font-weight: bold; -} -.github-btn .btn-default { - text-shadow: 0 1px 0 #fff; - background-image: -webkit-gradient(linear, left 0%, left 100%, from(#ffffff), to(#e0e0e0)); - background-image: -webkit-linear-gradient(top, #ffffff, 0%, #e0e0e0, 100%); - background-image: -moz-linear-gradient(top, #ffffff 0%, #e0e0e0 100%); - background-image: linear-gradient(to bottom, #ffffff 0%, #e0e0e0 100%); - background-repeat: repeat-x; - border-color: #dbdbdb; - border-color: #ccc; - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0); -} - -.github-btn .btn-default:hover, .github-btn .btn-default:focus { - background-color: #e0e0e0; - background-position: 0 -15px; - color: #333; - border-color: #adadad; -} - -.nav-github { - width: 325px; -} - .nav-github > span { - padding-right: 0.5em; - } - - .icon-github { - display: inline-block; - font-family: FontAwesome; - font-style: normal; - font-weight: normal; - line-height: 1; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - } - - .github-watchers .icon-github:before{ - content: "\f005"; - } - - .github-forks .icon-github:before{ - content: "\f126"; - } - -.gh-count{ - padding: 2px 5px 3px 4px; - color: #555; - text-decoration: none; - text-shadow:0 1px 0 #fff; - white-space:nowrap; - cursor:pointer; - border-radius:3px; - position:relative; - display:none; - margin-left:4px; - background-color:#fafafa; - border:1px solid #d4d4d4; -} - -.gh-count:hover,.gh-count:focus{color:#4183c4;text-decoration: none;} -.gh-count:before,.gh-count:after{content:' ';position:absolute;display:inline-block;width:0;height:0;border-color:transparent;border-style:solid} -.gh-count:before{top:50%;left:-3px;margin-top:-4px;border-width:4px 4px 4px 0;border-right-color:#fafafa} -.gh-count:after{top:50%;left:-4px;z-index:-1;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#d4d4d4} - -thead { - font-weight: bold; -} - -table { - width: 100%; -} - - -h1, h2, h3 { - margin-top: .8em; - margin-bottom: .7em; -} - -pre code { - font-size: 15px !important; - font-family: Menlo, Consolas, 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', 'Lucida Console', Monaco, 'Droid Sans Mono', monospace; -} - -body { - color: #353b44; - background: #edece4; - font-family: 'Lato', sans-serif; - padding: 0px !important; - margin: 0px !important; - font-size: 16px !important; - font-weight: 400; -} - -h2,h3,h4,h5{ - font-weight: 700; -} - - -h1[id]:before, h2[id]:before, h3[id]:before, h4[id]:before, h5[id]:before { - display: block; - content: " "; - margin-top: -75px; - height: 75px; - visibility: hidden; -} - -label{ - font-weight: 400; -} - -.sidebar-menu .fa { - width: 30px; - text-align: center; -} - -a, a:hover, a:focus { - text-decoration: none; - outline: none; - outline: 0; -} - -img { - max-width: 100%; - height: auto; -} - -.panel-body a { - line-height: 1.1; - display: inline-block; -} -.panel-body a:after { - display: block; - content: ""; - height: 1px; - width: 0%; - background-color: #ff4088; - -webkit-transition: width 0.5s ease; - -moz-transition: width 0.5s ease; - -ms-transition: width 0.5s ease; - transition: width 0.5s ease; -} - -.panel-body a:hover:after, .panel-body a:focus:after { - width: 100%; -} - -input:focus, textarea:focus { outline: none; } -*:focus {outline: none;} -::selection { - background: #ff4088; - color: #fff; -} -::-moz-selection { - background: #ff4088; - color: #fff; -} - -#container { - width: 100%; - height: 100%; -} - -/*sidebar navigation*/ - -#sidebar { - width: 214px; - height: 100%; - position: fixed; - background: #ffffff; - overflow-y: auto; -} - - -ul.sidebar-menu , ul.sidebar-menu li ul.sub{ - margin: -2px 0 0; - padding: 0; -} - -ul.sidebar-menu { - margin-top: 60px; -} - -#sidebar > ul > li > ul.sub { - display: none; -} - -#sidebar > ul > li.active > ul.sub, #sidebar > ul > li > ul.sub > li > a { - display: block; -} - -ul.sidebar-menu li ul.sub li{ - background: #eeeeee; - margin-bottom: 0; - margin-left: 0; - margin-right: 0; -} - -ul.sidebar-menu li ul.sub li:last-child{ - border-radius: 0 0 4px 4px; - -webkit-border-radius: 0 0 4px 4px; -} - -ul.sidebar-menu li ul.sub li a { - font-size: 12px; - padding: 0 0 0 32px; - line-height: 35px; - height: 35px; - -webkit-transition: all 0.3s ease; - -moz-transition: all 0.3s ease; - -o-transition: all 0.3s ease; - -ms-transition: all 0.3s ease; - transition: all 0.3s ease; - color: #656C73; - font-size: 14px; -} - -ul.sidebar-menu li ul.sub li a:hover, ul.sidebar-menu li ul.sub li.active a { - color: #ff4088; - -webkit-transition: all 0.3s ease; - -moz-transition: all 0.3s ease; - -o-transition: all 0.3s ease; - -ms-transition: all 0.3s ease; - transition: all 0.3s ease; - display: block; -} - -ul.sidebar-menu li{ - line-height: 20px !important; -} - -ul.sidebar-menu li.sub-menu{ - line-height: 15px; - font-size: 16px; -} - -ul.sidebar-menu li a span{ - display: inline-block; -} - -ul.sidebar-menu li a{ - color: #72767D; - text-decoration: none; - display: block; - padding: 10px 0 10px 10px; - font-size: 16px; - font-weight: 400; - outline: none; - -webkit-transition: all 0.3s ease; - -moz-transition: all 0.3s ease; - -o-transition: all 0.3s ease; - -ms-transition: all 0.3s ease; - transition: all 0.3s ease; - border-right: 1px solid #D7D7D7; - border-bottom: 1px solid #D7D7D7; - white-space: nowrap; -} - -ul.sidebar-menu li.active a, ul.sidebar-menu li a:hover, ul.sidebar-menu li a:focus { - background: #eeeeee; - color: #ff4088; - display: block; - /*border-radius: 4px; - -webkit-border-radius: 4px;*/ - -webkit-transition: all 0.3s ease; - -moz-transition: all 0.3s ease; - -o-transition: all 0.3s ease; - -ms-transition: all 0.3s ease; - transition: all 0.3s ease; -} -ul.sidebar-menu li a:hover, ul.sidebar-menu li a:focus { - border-bottom: 1px solid #ff4088; -} -/*ul.sidebar-menu li.active a,*/ ul.sidebar-menu .sub-menu li.active a{ - border-bottom: 1px solid #ff4088; -} - -ul.sidebar-menu li a i { - font-size: 18px; - padding-right: 6px; - /*color: #ff4088;*/ -} - -ul.sidebar-menu li a:hover i, ul.sidebar-menu li a:focus i { - color: #ff4088; -} - -ul.sidebar-menu li.active a i { - color: #ff4088; -} - - -#sidebar ul > li > a .menu-arrow { - float: right; - margin-right: 8px; - margin-top: 6px; -} - -@-moz-document url-prefix() { - #sidebar ul > li > a .menu-arrow { - float: right; - margin-right: 8px; - margin-top: -16px; - } -} - -#main-content { - margin-left: 200px; - line-height: 1.8; - font-size: 18px; -} - -.header { - min-height: 60px; - padding: 0 10px; -} -.header { - position: fixed; - left: 0; - right: 0; - z-index: 1002; - text-align:center; -} - - -.black-bg { - background: rgba(20,20,20,0.9); - border-bottom: 1px solid #f1f2f7; -} - -.wrapper { - display: inline-block; - margin-top: 60px; - padding: 0px 15px 15px 0px; - width: 100%; -} - -a.logo { - font-size: 22px; - font-weight: 400; - color: #8E8E93; - float: left; - margin-top: 10px; - text-transform: uppercase; -} - -a.logo:hover, a.logo:focus { - text-decoration: none; - outline: none; -} - -h1.top-menu { - margin-top: -5px; -} -.title-row { - margin-top: 15px; - margin-left: 16px; - color: #EEE; -} -.notification-row { - float: right; - margin-top: 15px; - margin-left: 65px; -} - - -.top-nav { - margin-top: 15px; -} - -/*--sidebar toggle---*/ - -.toggle-nav { - float: left; - padding-right: 5px; - margin-top: 20px; - cursor: pointer; - color: gray; -} - -.toggle-nav .icon-reorder { - cursor: pointer; - display: inline-block; - font-size: 20px; -} - - -@-webkit-keyframes square { - 0% { background-position: 0 0; } - 25% { background-position: 100% 0; } - 50% { background-position: 100% 100%; } - 75% { background-position: 0 100%; } - 100% { background-position: 0 0; } -} - -@-ms-keyframes square { - 0% { background-position: 0 0; } - 25% { background-position: 100% 0; } - 50% { background-position: 100% 100%; } - 75% { background-position: 0 100%; } - 100% { background-position: 0 0; } -} - -@keyframes square { - 0% { background-position: 0 0; } - 25% { background-position: 100% 0; } - 50% { background-position: 100% 100%; } - 75% { background-position: 0 100%; } - 100% { background-position: 0 0; } -} - -.navigation { - position: absolute; - top: 0; - bottom: 0; - margin: 0; - max-width: 150px; - min-width: 90px; - width:100%; - min-height:1200px; - cursor:pointer; - display: flex; - justify-content: center; - align-content: center; - flex-direction: column; - font-size: 6em; - color: rgba(0,0,0,0.5); - text-align: center; - -webkit-transition: all 350ms ease; - transition: all 350ms ease; -} - -.navigation.next { - right:0; -} - - -.navigation:hover { - background-color: rgba(0,0,0,0.1); -} - -/* Google Custom Search box */ - -input.gsc-input, -.gsc-input-box, -.gsc-input-box-hover, -.gsc-input-box-focus, -.gsc-search-button, -.gsc-inline-block { - box-sizing: content-box; - line-height: normal; -} - -.gsc-control-cse { - padding: 0.1em 0 0.5em 1em !important; - width: 16em !important; - float: right; -} - -input.gsc-search-button-v2 { - padding: 6px 12px !important; -} - -.gsc-search-box-tools .gsc-search-box .gsc-input { - padding-right: 1px !important; -} - -/* Styled keypress from Wikipedia */ - -kbd { - border: 1px solid #aaa; - -moz-border-radius: 0.2em; - -webkit-border-radius: 0.2em; - border-radius: 0.2em; - -moz-box-shadow: 0.1em 0.2em 0.2em #ddd; - -webkit-box-shadow: 0.1em 0.2em 0.2em #ddd; - box-shadow: 0.1em 0.2em 0.2em #ddd; - background-color: #f9f9f9; - background-image: -moz-linear-gradient(top, #eee, #f9f9f9, #eee); - background-image: -o-linear-gradient(top, #eee, #f9f9f9, #eee); - background-image: -webkit-linear-gradient(top, #eee, #f9f9f9, #eee); - background-image: linear-gradient(to bottom, #eee, #f9f9f9, #eee); - padding: 0.1em 0.3em; - font-family: inherit; - font-size: 0.85em; -} - -/* For definitions of variables */ - -dl { - margin: 1em; - border-bottom: 1px solid #ccc; -} - -dt { - float: left; - clear: left; - width: 9.5em; - margin: 0.125em; - padding: 2px 4px; -} - -dd { - padding: 0.2em 0 0.2em 10em; - border-top: 1px solid #ccc; -} - -/* Prevent linebreak right after an icon */ -#main-content .fa { - display: inline; -} - -/* Logo for FreeBSD until Font Awesome adds it, see https://github.com/FortAwesome/Font-Awesome/issues/1116 */ -i.freebsd-19px:before { - content: url(/img/freebsd-19px.svg); - vertical-align: -7%; -} - -/* Responsive videos */ -.video-container { - position: relative; - padding-bottom: 56.25%; /* 16:9 */ - padding-top: 30px; - height: 0; - overflow: hidden; - margin: 20px 0; -} - -.video-container iframe, -.video-container object, -.video-container embed { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; -} - -/* Google custom search */ -.cse { - margin-top: 20px; - padding-right: 20px; -} - - -/* Table of contents */ - -.toc ul { list-style: none; margin: 0; padding: 0 5px; } -.toc ul li { display: inline; } -#TableOfContents > ul > li > ul > li > ul li { margin-right: 8px; } -#TableOfContents > ul > li > ul > li > a, #TableOfContents > ul > li > a { font-weight: bold; background-color: #eeeeee; padding: 0 10px; margin: 0 2px; } -#TableOfContents > ul > li > ul > li > a { font-style: italic; } -.toc.compact ul > li > ul > li > ul { display: none; } - -#toc { - position:fixed; - background-color: rgba(0, 0, 0, 0.1); - padding: 10px 50px 10px 20px; -} - -.showcase-container { - display: inline-block; - position: relative; - width: 100%; -} - -.showcase-container img { - border: 1px solid #555; -} - -.showcase-container h4 { - margin-top: 0; - margin-bottom: 0; -} -.dummy { - padding-top: 90%; /* Making rows line up even if img proportions off */ -} - -.thumbnail { - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; -} - -@media(max-width:1200px) { - .toc { - display: none; - } -} - - -/* Footer panel */ -.footer-panel { - width: 100%; - border-top:1px #efefef solid; - line-height: 30px; - padding: 25px 0px 15px; - margin-top: 15px; - background: #f9f9f9; - display: inline-block; - float: left; -} - -.footer-panel p { - padding-left: 20px; - padding-right: 20px; - font-size: medium; - font-style: italic; -} - - -/* Search form */ -#search-input { - width: 100%; - border: 1px solid #B3B3B3; - border-radius: 3px; - padding: 5px; -} - -#search-input:focus { - border-color: #F04A9C; -} - -/* Search result wrapper */ -.algolia-autocomplete { - width: 100%; -} - -/* List of search results */ -.aa-dropdown-menu { - box-sizing: border-box; - width: 100%; - background-color: #FFFFFF; - border: 1px solid #B3B3B3; - padding: 0; - font-size: 16px; - margin: 4 0 4 0; -} - -/* Highlight terms in search result headers */ -.algolia-docsearch-suggestion--category-header .algolia-docsearch-suggestion--highlight { - background-color: #F04A9C; -} - -/* Highlight terms in search result body */ -.algolia-docsearch-suggestion--highlight { - color: #F04A9C; - font-weight: 900; -} - -/* Currently selected search result */ -.aa-cursor .algolia-docsearch-suggestion--content { - color: inherit; -} - -.aa-cursor .algolia-docsearch-suggestion { - background: #EFEFEF; - color: #353B44; -} - -.algolia-docsearch-suggestion { - font-size: 16px; - color: #9AA2AB; -} - -.algolia-docsearch-suggestion--category-header, -.algolia-docsearch-suggestion--subcategory-column { - display: none !important; -} diff --git a/docs/themes/gohugoioTheme/static/favicon-16x16.png b/docs/static/favicon-16x16.png similarity index 100% rename from docs/themes/gohugoioTheme/static/favicon-16x16.png rename to docs/static/favicon-16x16.png diff --git a/docs/themes/gohugoioTheme/static/favicon-32x32.png b/docs/static/favicon-32x32.png similarity index 100% rename from docs/themes/gohugoioTheme/static/favicon-32x32.png rename to docs/static/favicon-32x32.png diff --git a/docs/static/favicon.ico b/docs/static/favicon.ico index 36693330b..dc007a99e 100644 Binary files a/docs/static/favicon.ico and b/docs/static/favicon.ico differ diff --git a/docs/static/fonts/Mulish-Italic-VariableFont_wght.ttf b/docs/static/fonts/Mulish-Italic-VariableFont_wght.ttf new file mode 100644 index 000000000..e5425c75e Binary files /dev/null and b/docs/static/fonts/Mulish-Italic-VariableFont_wght.ttf differ diff --git a/docs/static/fonts/Mulish-VariableFont_wght.ttf b/docs/static/fonts/Mulish-VariableFont_wght.ttf new file mode 100644 index 000000000..410f7aa63 Binary files /dev/null and b/docs/static/fonts/Mulish-VariableFont_wght.ttf differ diff --git a/docs/static/fonts/glyphicons-halflings-regular.eot b/docs/static/fonts/glyphicons-halflings-regular.eot deleted file mode 100644 index b93a4953f..000000000 Binary files a/docs/static/fonts/glyphicons-halflings-regular.eot and /dev/null differ diff --git a/docs/static/fonts/glyphicons-halflings-regular.svg b/docs/static/fonts/glyphicons-halflings-regular.svg deleted file mode 100644 index 94fb5490a..000000000 --- a/docs/static/fonts/glyphicons-halflings-regular.svg +++ /dev/null @@ -1,288 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/docs/static/fonts/glyphicons-halflings-regular.ttf b/docs/static/fonts/glyphicons-halflings-regular.ttf deleted file mode 100644 index 1413fc609..000000000 Binary files a/docs/static/fonts/glyphicons-halflings-regular.ttf and /dev/null differ diff --git a/docs/static/fonts/glyphicons-halflings-regular.woff b/docs/static/fonts/glyphicons-halflings-regular.woff deleted file mode 100644 index 9e612858f..000000000 Binary files a/docs/static/fonts/glyphicons-halflings-regular.woff and /dev/null differ diff --git a/docs/static/fonts/glyphicons-halflings-regular.woff2 b/docs/static/fonts/glyphicons-halflings-regular.woff2 deleted file mode 100644 index 64539b54c..000000000 Binary files a/docs/static/fonts/glyphicons-halflings-regular.woff2 and /dev/null differ diff --git a/docs/static/fonts/hugo.eot b/docs/static/fonts/hugo.eot deleted file mode 100644 index b92f00f93..000000000 Binary files a/docs/static/fonts/hugo.eot and /dev/null differ diff --git a/docs/static/fonts/hugo.svg b/docs/static/fonts/hugo.svg deleted file mode 100644 index 7913f7c1f..000000000 --- a/docs/static/fonts/hugo.svg +++ /dev/null @@ -1,63 +0,0 @@ - - - -Generated by IcoMoon - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/docs/static/fonts/hugo.ttf b/docs/static/fonts/hugo.ttf deleted file mode 100644 index 962914d33..000000000 Binary files a/docs/static/fonts/hugo.ttf and /dev/null differ diff --git a/docs/static/fonts/hugo.woff b/docs/static/fonts/hugo.woff deleted file mode 100644 index 4693fbe7f..000000000 Binary files a/docs/static/fonts/hugo.woff and /dev/null differ diff --git a/docs/static/hosting-and-deployment/deployment-with-wercker/adding-a-github-pages-step.png b/docs/static/hosting-and-deployment/deployment-with-wercker/adding-a-github-pages-step.png deleted file mode 100755 index ff28a0661..000000000 Binary files a/docs/static/hosting-and-deployment/deployment-with-wercker/adding-a-github-pages-step.png and /dev/null differ diff --git a/docs/static/hosting-and-deployment/deployment-with-wercker/adding-the-project-to-github.png b/docs/static/hosting-and-deployment/deployment-with-wercker/adding-the-project-to-github.png deleted file mode 100755 index e1065bb00..000000000 Binary files a/docs/static/hosting-and-deployment/deployment-with-wercker/adding-the-project-to-github.png and /dev/null differ diff --git a/docs/static/hosting-and-deployment/deployment-with-wercker/and-we-ve-got-an-app.png b/docs/static/hosting-and-deployment/deployment-with-wercker/and-we-ve-got-an-app.png deleted file mode 100755 index 7f8e10e70..000000000 Binary files a/docs/static/hosting-and-deployment/deployment-with-wercker/and-we-ve-got-an-app.png and /dev/null differ diff --git a/docs/static/hosting-and-deployment/deployment-with-wercker/configure-the-deploy-step.png b/docs/static/hosting-and-deployment/deployment-with-wercker/configure-the-deploy-step.png deleted file mode 100755 index 550ea1bf2..000000000 Binary files a/docs/static/hosting-and-deployment/deployment-with-wercker/configure-the-deploy-step.png and /dev/null differ diff --git a/docs/static/hosting-and-deployment/deployment-with-wercker/creating-a-basic-hugo-site.png b/docs/static/hosting-and-deployment/deployment-with-wercker/creating-a-basic-hugo-site.png deleted file mode 100755 index 78d238f88..000000000 Binary files a/docs/static/hosting-and-deployment/deployment-with-wercker/creating-a-basic-hugo-site.png and /dev/null differ diff --git a/docs/static/hosting-and-deployment/deployment-with-wercker/public-or-not.png b/docs/static/hosting-and-deployment/deployment-with-wercker/public-or-not.png deleted file mode 100755 index 9d81a8ba4..000000000 Binary files a/docs/static/hosting-and-deployment/deployment-with-wercker/public-or-not.png and /dev/null differ diff --git a/docs/static/hosting-and-deployment/deployment-with-wercker/using-hugo-build.png b/docs/static/hosting-and-deployment/deployment-with-wercker/using-hugo-build.png deleted file mode 100755 index b0dbec94c..000000000 Binary files a/docs/static/hosting-and-deployment/deployment-with-wercker/using-hugo-build.png and /dev/null differ diff --git a/docs/static/hosting-and-deployment/deployment-with-wercker/wercker-access.png b/docs/static/hosting-and-deployment/deployment-with-wercker/wercker-access.png deleted file mode 100755 index 6e89c0ef3..000000000 Binary files a/docs/static/hosting-and-deployment/deployment-with-wercker/wercker-access.png and /dev/null differ diff --git a/docs/static/hosting-and-deployment/deployment-with-wercker/wercker-account-settings.png b/docs/static/hosting-and-deployment/deployment-with-wercker/wercker-account-settings.png deleted file mode 100644 index 993a1d9e9..000000000 Binary files a/docs/static/hosting-and-deployment/deployment-with-wercker/wercker-account-settings.png and /dev/null differ diff --git a/docs/static/hosting-and-deployment/deployment-with-wercker/wercker-add-app.png b/docs/static/hosting-and-deployment/deployment-with-wercker/wercker-add-app.png deleted file mode 100755 index 94ccef518..000000000 Binary files a/docs/static/hosting-and-deployment/deployment-with-wercker/wercker-add-app.png and /dev/null differ diff --git a/docs/static/hosting-and-deployment/deployment-with-wercker/wercker-git-connections.png b/docs/static/hosting-and-deployment/deployment-with-wercker/wercker-git-connections.png deleted file mode 100755 index d89c0cd8b..000000000 Binary files a/docs/static/hosting-and-deployment/deployment-with-wercker/wercker-git-connections.png and /dev/null differ diff --git a/docs/static/hosting-and-deployment/deployment-with-wercker/wercker-search.png b/docs/static/hosting-and-deployment/deployment-with-wercker/wercker-search.png deleted file mode 100755 index d099cfd5c..000000000 Binary files a/docs/static/hosting-and-deployment/deployment-with-wercker/wercker-search.png and /dev/null differ diff --git a/docs/static/hosting-and-deployment/deployment-with-wercker/wercker-select-owner.png b/docs/static/hosting-and-deployment/deployment-with-wercker/wercker-select-owner.png deleted file mode 100755 index 111308508..000000000 Binary files a/docs/static/hosting-and-deployment/deployment-with-wercker/wercker-select-owner.png and /dev/null differ diff --git a/docs/static/hosting-and-deployment/deployment-with-wercker/wercker-select-repository.png b/docs/static/hosting-and-deployment/deployment-with-wercker/wercker-select-repository.png deleted file mode 100755 index e8835f21a..000000000 Binary files a/docs/static/hosting-and-deployment/deployment-with-wercker/wercker-select-repository.png and /dev/null differ diff --git a/docs/static/hosting-and-deployment/deployment-with-wercker/wercker-sign-up-page.png b/docs/static/hosting-and-deployment/deployment-with-wercker/wercker-sign-up-page.png deleted file mode 100644 index 28f469649..000000000 Binary files a/docs/static/hosting-and-deployment/deployment-with-wercker/wercker-sign-up-page.png and /dev/null differ diff --git a/docs/static/hosting-and-deployment/deployment-with-wercker/wercker-sign-up.png b/docs/static/hosting-and-deployment/deployment-with-wercker/wercker-sign-up.png deleted file mode 100644 index f24996889..000000000 Binary files a/docs/static/hosting-and-deployment/deployment-with-wercker/wercker-sign-up.png and /dev/null differ diff --git a/docs/static/hosting-and-deployment/deployment-with-wercker/werckeryml.png b/docs/static/hosting-and-deployment/deployment-with-wercker/werckeryml.png deleted file mode 100755 index be46e6136..000000000 Binary files a/docs/static/hosting-and-deployment/deployment-with-wercker/werckeryml.png and /dev/null differ diff --git a/docs/static/hosting-and-deployment/hosting-on-bitbucket/bitbucket-blog-post.png b/docs/static/hosting-and-deployment/hosting-on-bitbucket/bitbucket-blog-post.png deleted file mode 100755 index b78f6fd15..000000000 Binary files a/docs/static/hosting-and-deployment/hosting-on-bitbucket/bitbucket-blog-post.png and /dev/null differ diff --git a/docs/static/hosting-and-deployment/hosting-on-bitbucket/bitbucket-create-repo.png b/docs/static/hosting-and-deployment/hosting-on-bitbucket/bitbucket-create-repo.png deleted file mode 100755 index e97f13465..000000000 Binary files a/docs/static/hosting-and-deployment/hosting-on-bitbucket/bitbucket-create-repo.png and /dev/null differ diff --git a/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-add-new-site.jpg b/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-add-new-site.jpg deleted file mode 100644 index 17698d34a..000000000 Binary files a/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-add-new-site.jpg and /dev/null differ diff --git a/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-authorize-added-permissions.jpg b/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-authorize-added-permissions.jpg deleted file mode 100644 index eaae924e4..000000000 Binary files a/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-authorize-added-permissions.jpg and /dev/null differ diff --git a/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-create-new-site-step-1.jpg b/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-create-new-site-step-1.jpg deleted file mode 100644 index 347477dd2..000000000 Binary files a/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-create-new-site-step-1.jpg and /dev/null differ diff --git a/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-create-new-site-step-2.jpg b/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-create-new-site-step-2.jpg deleted file mode 100644 index 18bfd6fed..000000000 Binary files a/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-create-new-site-step-2.jpg and /dev/null differ diff --git a/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-create-new-site-step-3.jpg b/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-create-new-site-step-3.jpg deleted file mode 100644 index 6f9b6477c..000000000 Binary files a/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-create-new-site-step-3.jpg and /dev/null differ diff --git a/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-deploy-published.jpg b/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-deploy-published.jpg deleted file mode 100644 index ed5eaf3c8..000000000 Binary files a/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-deploy-published.jpg and /dev/null differ diff --git a/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-deploying-site.gif b/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-deploying-site.gif deleted file mode 100644 index c1f27c236..000000000 Binary files a/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-deploying-site.gif and /dev/null differ diff --git a/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-first-authorize.jpg b/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-first-authorize.jpg deleted file mode 100644 index 748122e89..000000000 Binary files a/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-first-authorize.jpg and /dev/null differ diff --git a/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-live-site.jpg b/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-live-site.jpg deleted file mode 100644 index 3edc49c43..000000000 Binary files a/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-live-site.jpg and /dev/null differ diff --git a/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-push-to-deploy.jpg b/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-push-to-deploy.jpg deleted file mode 100644 index f23626218..000000000 Binary files a/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-push-to-deploy.jpg and /dev/null differ diff --git a/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-signup.jpg b/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-signup.jpg deleted file mode 100644 index cd9a218b4..000000000 Binary files a/docs/static/hosting-and-deployment/hosting-on-netlify/netlify-signup.jpg and /dev/null differ diff --git a/docs/static/hosting-and-deployment/hosting-on-netlify/tibobeijennl.jpg b/docs/static/hosting-and-deployment/hosting-on-netlify/tibobeijennl.jpg deleted file mode 100644 index ad8726820..000000000 Binary files a/docs/static/hosting-and-deployment/hosting-on-netlify/tibobeijennl.jpg and /dev/null differ diff --git a/docs/static/images/blog/hugo-26-poster.png b/docs/static/images/blog/hugo-26-poster.png deleted file mode 100644 index 827f1f7bb..000000000 Binary files a/docs/static/images/blog/hugo-26-poster.png and /dev/null differ diff --git a/docs/static/images/blog/hugo-27-poster.png b/docs/static/images/blog/hugo-27-poster.png deleted file mode 100644 index 69efa36bc..000000000 Binary files a/docs/static/images/blog/hugo-27-poster.png and /dev/null differ diff --git a/docs/static/images/blog/hugo-28-poster.png b/docs/static/images/blog/hugo-28-poster.png deleted file mode 100644 index ae3d6ac16..000000000 Binary files a/docs/static/images/blog/hugo-28-poster.png and /dev/null differ diff --git a/docs/static/images/blog/hugo-29-poster.png b/docs/static/images/blog/hugo-29-poster.png deleted file mode 100644 index dbe2d434f..000000000 Binary files a/docs/static/images/blog/hugo-29-poster.png and /dev/null differ diff --git a/docs/static/images/blog/hugo-30-poster.png b/docs/static/images/blog/hugo-30-poster.png deleted file mode 100644 index 214369e89..000000000 Binary files a/docs/static/images/blog/hugo-30-poster.png and /dev/null differ diff --git a/docs/static/images/blog/hugo-31-poster.png b/docs/static/images/blog/hugo-31-poster.png deleted file mode 100644 index e11e53aa7..000000000 Binary files a/docs/static/images/blog/hugo-31-poster.png and /dev/null differ diff --git a/docs/static/images/blog/hugo-32-poster.png b/docs/static/images/blog/hugo-32-poster.png deleted file mode 100644 index f915247ad..000000000 Binary files a/docs/static/images/blog/hugo-32-poster.png and /dev/null differ diff --git a/docs/static/images/blog/hugo-bug-poster.png b/docs/static/images/blog/hugo-bug-poster.png deleted file mode 100644 index cd236682d..000000000 Binary files a/docs/static/images/blog/hugo-bug-poster.png and /dev/null differ diff --git a/docs/static/images/blog/hugo-http2-push.png b/docs/static/images/blog/hugo-http2-push.png deleted file mode 100644 index 1ddfd4653..000000000 Binary files a/docs/static/images/blog/hugo-http2-push.png and /dev/null differ diff --git a/docs/static/images/contribute/development/accept-cla.png b/docs/static/images/contribute/development/accept-cla.png deleted file mode 100755 index 929fda6ab..000000000 Binary files a/docs/static/images/contribute/development/accept-cla.png and /dev/null differ diff --git a/docs/static/images/contribute/development/ci-errors.png b/docs/static/images/contribute/development/ci-errors.png deleted file mode 100755 index 95cd290b6..000000000 Binary files a/docs/static/images/contribute/development/ci-errors.png and /dev/null differ diff --git a/docs/static/images/contribute/development/copy-remote-url.png b/docs/static/images/contribute/development/copy-remote-url.png deleted file mode 100755 index 9006f4a48..000000000 Binary files a/docs/static/images/contribute/development/copy-remote-url.png and /dev/null differ diff --git a/docs/static/images/contribute/development/forking-a-repository.png b/docs/static/images/contribute/development/forking-a-repository.png deleted file mode 100755 index ea132cab3..000000000 Binary files a/docs/static/images/contribute/development/forking-a-repository.png and /dev/null differ diff --git a/docs/static/images/contribute/development/open-pull-request.png b/docs/static/images/contribute/development/open-pull-request.png deleted file mode 100755 index 63b504fb2..000000000 Binary files a/docs/static/images/contribute/development/open-pull-request.png and /dev/null differ diff --git a/docs/themes/gohugoioTheme/layouts/partials/svg/gopher-hero.svg b/docs/static/images/gopher-hero.svg similarity index 100% rename from docs/themes/gohugoioTheme/layouts/partials/svg/gopher-hero.svg rename to docs/static/images/gopher-hero.svg diff --git a/docs/themes/gohugoioTheme/static/images/gopher-side_color.svg b/docs/static/images/gopher-side_color.svg old mode 100755 new mode 100644 similarity index 100% rename from docs/themes/gohugoioTheme/static/images/gopher-side_color.svg rename to docs/static/images/gopher-side_color.svg diff --git a/docs/static/images/hosting-and-deployment/deployment-with-nanobox/hugo-server.png b/docs/static/images/hosting-and-deployment/deployment-with-nanobox/hugo-server.png deleted file mode 100644 index b37b2c375..000000000 Binary files a/docs/static/images/hosting-and-deployment/deployment-with-nanobox/hugo-server.png and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/deployment-with-nanobox/hugo-with-nanobox.png b/docs/static/images/hosting-and-deployment/deployment-with-nanobox/hugo-with-nanobox.png deleted file mode 100644 index 8b889b34b..000000000 Binary files a/docs/static/images/hosting-and-deployment/deployment-with-nanobox/hugo-with-nanobox.png and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/deployment-with-nanobox/nanobox-deploy-dry-run.png b/docs/static/images/hosting-and-deployment/deployment-with-nanobox/nanobox-deploy-dry-run.png deleted file mode 100644 index 55c438308..000000000 Binary files a/docs/static/images/hosting-and-deployment/deployment-with-nanobox/nanobox-deploy-dry-run.png and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/deployment-with-nanobox/nanobox-run.png b/docs/static/images/hosting-and-deployment/deployment-with-nanobox/nanobox-run.png deleted file mode 100644 index 3432df8c9..000000000 Binary files a/docs/static/images/hosting-and-deployment/deployment-with-nanobox/nanobox-run.png and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/deployment-with-wercker/adding-a-github-pages-step.png b/docs/static/images/hosting-and-deployment/deployment-with-wercker/adding-a-github-pages-step.png deleted file mode 100755 index ff28a0661..000000000 Binary files a/docs/static/images/hosting-and-deployment/deployment-with-wercker/adding-a-github-pages-step.png and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/deployment-with-wercker/adding-the-project-to-github.png b/docs/static/images/hosting-and-deployment/deployment-with-wercker/adding-the-project-to-github.png deleted file mode 100755 index e1065bb00..000000000 Binary files a/docs/static/images/hosting-and-deployment/deployment-with-wercker/adding-the-project-to-github.png and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/deployment-with-wercker/and-we-ve-got-an-app.png b/docs/static/images/hosting-and-deployment/deployment-with-wercker/and-we-ve-got-an-app.png deleted file mode 100755 index 7f8e10e70..000000000 Binary files a/docs/static/images/hosting-and-deployment/deployment-with-wercker/and-we-ve-got-an-app.png and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/deployment-with-wercker/configure-the-deploy-step.png b/docs/static/images/hosting-and-deployment/deployment-with-wercker/configure-the-deploy-step.png deleted file mode 100755 index 550ea1bf2..000000000 Binary files a/docs/static/images/hosting-and-deployment/deployment-with-wercker/configure-the-deploy-step.png and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/deployment-with-wercker/creating-a-basic-hugo-site.png b/docs/static/images/hosting-and-deployment/deployment-with-wercker/creating-a-basic-hugo-site.png deleted file mode 100755 index 78d238f88..000000000 Binary files a/docs/static/images/hosting-and-deployment/deployment-with-wercker/creating-a-basic-hugo-site.png and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/deployment-with-wercker/public-or-not.png b/docs/static/images/hosting-and-deployment/deployment-with-wercker/public-or-not.png deleted file mode 100755 index 9d81a8ba4..000000000 Binary files a/docs/static/images/hosting-and-deployment/deployment-with-wercker/public-or-not.png and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/deployment-with-wercker/using-hugo-build.png b/docs/static/images/hosting-and-deployment/deployment-with-wercker/using-hugo-build.png deleted file mode 100755 index b0dbec94c..000000000 Binary files a/docs/static/images/hosting-and-deployment/deployment-with-wercker/using-hugo-build.png and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/deployment-with-wercker/wercker-access.png b/docs/static/images/hosting-and-deployment/deployment-with-wercker/wercker-access.png deleted file mode 100755 index 6e89c0ef3..000000000 Binary files a/docs/static/images/hosting-and-deployment/deployment-with-wercker/wercker-access.png and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/deployment-with-wercker/wercker-account-settings.png b/docs/static/images/hosting-and-deployment/deployment-with-wercker/wercker-account-settings.png deleted file mode 100644 index 993a1d9e9..000000000 Binary files a/docs/static/images/hosting-and-deployment/deployment-with-wercker/wercker-account-settings.png and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/deployment-with-wercker/wercker-add-app.png b/docs/static/images/hosting-and-deployment/deployment-with-wercker/wercker-add-app.png deleted file mode 100755 index 94ccef518..000000000 Binary files a/docs/static/images/hosting-and-deployment/deployment-with-wercker/wercker-add-app.png and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/deployment-with-wercker/wercker-git-connections.png b/docs/static/images/hosting-and-deployment/deployment-with-wercker/wercker-git-connections.png deleted file mode 100755 index d89c0cd8b..000000000 Binary files a/docs/static/images/hosting-and-deployment/deployment-with-wercker/wercker-git-connections.png and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/deployment-with-wercker/wercker-search.png b/docs/static/images/hosting-and-deployment/deployment-with-wercker/wercker-search.png deleted file mode 100755 index d099cfd5c..000000000 Binary files a/docs/static/images/hosting-and-deployment/deployment-with-wercker/wercker-search.png and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/deployment-with-wercker/wercker-select-owner.png b/docs/static/images/hosting-and-deployment/deployment-with-wercker/wercker-select-owner.png deleted file mode 100755 index 111308508..000000000 Binary files a/docs/static/images/hosting-and-deployment/deployment-with-wercker/wercker-select-owner.png and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/deployment-with-wercker/wercker-select-repository.png b/docs/static/images/hosting-and-deployment/deployment-with-wercker/wercker-select-repository.png deleted file mode 100755 index e8835f21a..000000000 Binary files a/docs/static/images/hosting-and-deployment/deployment-with-wercker/wercker-select-repository.png and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/deployment-with-wercker/wercker-sign-up-page.png b/docs/static/images/hosting-and-deployment/deployment-with-wercker/wercker-sign-up-page.png deleted file mode 100644 index 28f469649..000000000 Binary files a/docs/static/images/hosting-and-deployment/deployment-with-wercker/wercker-sign-up-page.png and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/deployment-with-wercker/wercker-sign-up.png b/docs/static/images/hosting-and-deployment/deployment-with-wercker/wercker-sign-up.png deleted file mode 100644 index f24996889..000000000 Binary files a/docs/static/images/hosting-and-deployment/deployment-with-wercker/wercker-sign-up.png and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/deployment-with-wercker/werckeryml.png b/docs/static/images/hosting-and-deployment/deployment-with-wercker/werckeryml.png deleted file mode 100755 index be46e6136..000000000 Binary files a/docs/static/images/hosting-and-deployment/deployment-with-wercker/werckeryml.png and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/hosting-on-bitbucket/bitbucket-blog-post.png b/docs/static/images/hosting-and-deployment/hosting-on-bitbucket/bitbucket-blog-post.png deleted file mode 100755 index b78f6fd15..000000000 Binary files a/docs/static/images/hosting-and-deployment/hosting-on-bitbucket/bitbucket-blog-post.png and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/hosting-on-bitbucket/bitbucket-create-repo.png b/docs/static/images/hosting-and-deployment/hosting-on-bitbucket/bitbucket-create-repo.png deleted file mode 100755 index e97f13465..000000000 Binary files a/docs/static/images/hosting-and-deployment/hosting-on-bitbucket/bitbucket-create-repo.png and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/hosting-on-keycdn/keycdn-pull-zone.png b/docs/static/images/hosting-and-deployment/hosting-on-keycdn/keycdn-pull-zone.png deleted file mode 100644 index 3cfc61138..000000000 Binary files a/docs/static/images/hosting-and-deployment/hosting-on-keycdn/keycdn-pull-zone.png and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/hosting-on-keycdn/secret-api-key.png b/docs/static/images/hosting-and-deployment/hosting-on-keycdn/secret-api-key.png deleted file mode 100644 index 26ac44857..000000000 Binary files a/docs/static/images/hosting-and-deployment/hosting-on-keycdn/secret-api-key.png and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/hosting-on-keycdn/secret-zone-id.png b/docs/static/images/hosting-and-deployment/hosting-on-keycdn/secret-zone-id.png deleted file mode 100644 index c0ef6c571..000000000 Binary files a/docs/static/images/hosting-and-deployment/hosting-on-keycdn/secret-zone-id.png and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-add-new-site.jpg b/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-add-new-site.jpg deleted file mode 100644 index 17698d34a..000000000 Binary files a/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-add-new-site.jpg and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-authorize-added-permissions.jpg b/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-authorize-added-permissions.jpg deleted file mode 100644 index eaae924e4..000000000 Binary files a/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-authorize-added-permissions.jpg and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-create-new-site-step-1.jpg b/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-create-new-site-step-1.jpg deleted file mode 100644 index 347477dd2..000000000 Binary files a/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-create-new-site-step-1.jpg and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-create-new-site-step-2.jpg b/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-create-new-site-step-2.jpg deleted file mode 100644 index 18bfd6fed..000000000 Binary files a/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-create-new-site-step-2.jpg and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-create-new-site-step-3.jpg b/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-create-new-site-step-3.jpg deleted file mode 100644 index 6f9b6477c..000000000 Binary files a/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-create-new-site-step-3.jpg and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-deploy-published.jpg b/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-deploy-published.jpg deleted file mode 100644 index ed5eaf3c8..000000000 Binary files a/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-deploy-published.jpg and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-deploying-site.gif b/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-deploying-site.gif deleted file mode 100644 index c1f27c236..000000000 Binary files a/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-deploying-site.gif and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-first-authorize.jpg b/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-first-authorize.jpg deleted file mode 100644 index 748122e89..000000000 Binary files a/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-first-authorize.jpg and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-live-site.jpg b/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-live-site.jpg deleted file mode 100644 index 3edc49c43..000000000 Binary files a/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-live-site.jpg and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-push-to-deploy.jpg b/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-push-to-deploy.jpg deleted file mode 100644 index f23626218..000000000 Binary files a/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-push-to-deploy.jpg and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-signup.jpg b/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-signup.jpg deleted file mode 100644 index cd9a218b4..000000000 Binary files a/docs/static/images/hosting-and-deployment/hosting-on-netlify/netlify-signup.jpg and /dev/null differ diff --git a/docs/static/images/hosting-and-deployment/hosting-on-netlify/tibobeijennl.jpg b/docs/static/images/hosting-and-deployment/hosting-on-netlify/tibobeijennl.jpg deleted file mode 100644 index ad8726820..000000000 Binary files a/docs/static/images/hosting-and-deployment/hosting-on-netlify/tibobeijennl.jpg and /dev/null differ diff --git a/docs/static/images/hugo-content-bundles.png b/docs/static/images/hugo-content-bundles.png deleted file mode 100644 index 1706a29d6..000000000 Binary files a/docs/static/images/hugo-content-bundles.png and /dev/null differ diff --git a/docs/static/images/hugo-logo-wide.svg b/docs/static/images/hugo-logo-wide.svg new file mode 100644 index 000000000..1f6a79ea6 --- /dev/null +++ b/docs/static/images/hugo-logo-wide.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/docs/static/images/icon-custom-outputs.svg b/docs/static/images/icon-custom-outputs.svg deleted file mode 100644 index ccf581f31..000000000 --- a/docs/static/images/icon-custom-outputs.svg +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/static/images/quickstart/bookshelf-bleak-theme.png b/docs/static/images/quickstart/bookshelf-bleak-theme.png deleted file mode 100755 index ccd18c42d..000000000 Binary files a/docs/static/images/quickstart/bookshelf-bleak-theme.png and /dev/null differ diff --git a/docs/static/images/quickstart/bookshelf-disqus.png b/docs/static/images/quickstart/bookshelf-disqus.png deleted file mode 100755 index 3ce645a0c..000000000 Binary files a/docs/static/images/quickstart/bookshelf-disqus.png and /dev/null differ diff --git a/docs/static/images/quickstart/bookshelf-new-default-image.png b/docs/static/images/quickstart/bookshelf-new-default-image.png deleted file mode 100755 index d7274c7a6..000000000 Binary files a/docs/static/images/quickstart/bookshelf-new-default-image.png and /dev/null differ diff --git a/docs/static/images/quickstart/bookshelf-only-picture.png b/docs/static/images/quickstart/bookshelf-only-picture.png deleted file mode 100755 index a363383bc..000000000 Binary files a/docs/static/images/quickstart/bookshelf-only-picture.png and /dev/null differ diff --git a/docs/static/images/quickstart/bookshelf-robust-theme.png b/docs/static/images/quickstart/bookshelf-robust-theme.png deleted file mode 100755 index 7c5e6b8d2..000000000 Binary files a/docs/static/images/quickstart/bookshelf-robust-theme.png and /dev/null differ diff --git a/docs/static/images/quickstart/bookshelf-updated-config.png b/docs/static/images/quickstart/bookshelf-updated-config.png deleted file mode 100755 index bbda606c7..000000000 Binary files a/docs/static/images/quickstart/bookshelf-updated-config.png and /dev/null differ diff --git a/docs/static/images/quickstart/bookshelf.png b/docs/static/images/quickstart/bookshelf.png deleted file mode 100755 index 3b572adbb..000000000 Binary files a/docs/static/images/quickstart/bookshelf.png and /dev/null differ diff --git a/docs/static/images/quickstart/default.jpg b/docs/static/images/quickstart/default.jpg deleted file mode 100755 index 78d7bd28e..000000000 Binary files a/docs/static/images/quickstart/default.jpg and /dev/null differ diff --git a/docs/static/images/quickstart/gh-pages-ui.png b/docs/static/images/quickstart/gh-pages-ui.png deleted file mode 100644 index 3a7d10305..000000000 Binary files a/docs/static/images/quickstart/gh-pages-ui.png and /dev/null differ diff --git a/docs/static/images/site-hierarchy.svg b/docs/static/images/site-hierarchy.svg deleted file mode 100644 index 7d1a043e8..000000000 --- a/docs/static/images/site-hierarchy.svg +++ /dev/null @@ -1,634 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/static/img/2626info-tn.png b/docs/static/img/2626info-tn.png deleted file mode 100644 index fb6b11757..000000000 Binary files a/docs/static/img/2626info-tn.png and /dev/null differ diff --git a/docs/static/img/antzucaro-tn.jpg b/docs/static/img/antzucaro-tn.jpg deleted file mode 100644 index 188769c0f..000000000 Binary files a/docs/static/img/antzucaro-tn.jpg and /dev/null differ diff --git a/docs/static/img/apperneticioblog.png b/docs/static/img/apperneticioblog.png deleted file mode 100644 index f2fcf6d96..000000000 Binary files a/docs/static/img/apperneticioblog.png and /dev/null differ diff --git a/docs/static/img/arresteddevops-tn.png b/docs/static/img/arresteddevops-tn.png deleted file mode 100644 index 868df1d77..000000000 Binary files a/docs/static/img/arresteddevops-tn.png and /dev/null differ diff --git a/docs/static/img/asc-tn.jpg b/docs/static/img/asc-tn.jpg deleted file mode 100644 index a5148e236..000000000 Binary files a/docs/static/img/asc-tn.jpg and /dev/null differ diff --git a/docs/static/img/astrochili-tn.png b/docs/static/img/astrochili-tn.png deleted file mode 100644 index ec11741ee..000000000 Binary files a/docs/static/img/astrochili-tn.png and /dev/null differ diff --git a/docs/static/img/aydoscom.png b/docs/static/img/aydoscom.png deleted file mode 100644 index f2cfc3982..000000000 Binary files a/docs/static/img/aydoscom.png and /dev/null differ diff --git a/docs/static/img/balaramadurai-net-tn.jpg b/docs/static/img/balaramadurai-net-tn.jpg deleted file mode 100644 index 207a4a840..000000000 Binary files a/docs/static/img/balaramadurai-net-tn.jpg and /dev/null differ diff --git a/docs/static/img/barricade-tn.png b/docs/static/img/barricade-tn.png deleted file mode 100644 index 96eed0fbe..000000000 Binary files a/docs/static/img/barricade-tn.png and /dev/null differ diff --git a/docs/static/img/bepsays-tn.png b/docs/static/img/bepsays-tn.png deleted file mode 100644 index ca9119cc5..000000000 Binary files a/docs/static/img/bepsays-tn.png and /dev/null differ diff --git a/docs/static/img/bharathpalavalli-tn.png b/docs/static/img/bharathpalavalli-tn.png deleted file mode 100644 index bcf15ed0a..000000000 Binary files a/docs/static/img/bharathpalavalli-tn.png and /dev/null differ diff --git a/docs/static/img/bugtrackersio-tn.jpg b/docs/static/img/bugtrackersio-tn.jpg deleted file mode 100644 index a56e94009..000000000 Binary files a/docs/static/img/bugtrackersio-tn.jpg and /dev/null differ diff --git a/docs/static/img/bullion-investor-com.png b/docs/static/img/bullion-investor-com.png deleted file mode 100644 index 3cd78b97d..000000000 Binary files a/docs/static/img/bullion-investor-com.png and /dev/null differ diff --git a/docs/static/img/camunda-blog.png b/docs/static/img/camunda-blog.png deleted file mode 100644 index 95a004ff7..000000000 Binary files a/docs/static/img/camunda-blog.png and /dev/null differ diff --git a/docs/static/img/camunda-docs.png b/docs/static/img/camunda-docs.png deleted file mode 100644 index e008fdabb..000000000 Binary files a/docs/static/img/camunda-docs.png and /dev/null differ diff --git a/docs/static/img/carnivorousplants-tn.png b/docs/static/img/carnivorousplants-tn.png deleted file mode 100644 index 2e45bc013..000000000 Binary files a/docs/static/img/carnivorousplants-tn.png and /dev/null differ diff --git a/docs/static/img/cdnoverview-tn.png b/docs/static/img/cdnoverview-tn.png deleted file mode 100644 index a95852c45..000000000 Binary files a/docs/static/img/cdnoverview-tn.png and /dev/null differ diff --git a/docs/static/img/chinese-grammar-tn.png b/docs/static/img/chinese-grammar-tn.png deleted file mode 100644 index 3d84184cf..000000000 Binary files a/docs/static/img/chinese-grammar-tn.png and /dev/null differ diff --git a/docs/static/img/chingli-tn.jpg b/docs/static/img/chingli-tn.jpg deleted file mode 100644 index 61ee53e04..000000000 Binary files a/docs/static/img/chingli-tn.jpg and /dev/null differ diff --git a/docs/static/img/chipsncookies-tn.png b/docs/static/img/chipsncookies-tn.png deleted file mode 100644 index f355cb5a4..000000000 Binary files a/docs/static/img/chipsncookies-tn.png and /dev/null differ diff --git a/docs/static/img/christianmendoza-tn.jpg b/docs/static/img/christianmendoza-tn.jpg deleted file mode 100644 index 82b45afa4..000000000 Binary files a/docs/static/img/christianmendoza-tn.jpg and /dev/null differ diff --git a/docs/static/img/cinegyopen-tn.png b/docs/static/img/cinegyopen-tn.png deleted file mode 100644 index 3216259fc..000000000 Binary files a/docs/static/img/cinegyopen-tn.png and /dev/null differ diff --git a/docs/static/img/clearhaus-tn.png b/docs/static/img/clearhaus-tn.png deleted file mode 100644 index 4785019a6..000000000 Binary files a/docs/static/img/clearhaus-tn.png and /dev/null differ diff --git a/docs/static/img/cloudshark-tn.jpg b/docs/static/img/cloudshark-tn.jpg deleted file mode 100644 index 68f8018ef..000000000 Binary files a/docs/static/img/cloudshark-tn.jpg and /dev/null differ diff --git a/docs/static/img/codingjournal-tn.png b/docs/static/img/codingjournal-tn.png deleted file mode 100644 index e2bfde580..000000000 Binary files a/docs/static/img/codingjournal-tn.png and /dev/null differ diff --git a/docs/static/img/consequently.jpg b/docs/static/img/consequently.jpg deleted file mode 100644 index fdb1ebd7b..000000000 Binary files a/docs/static/img/consequently.jpg and /dev/null differ diff --git a/docs/static/img/content/archetypes/archetype-hierarchy.png b/docs/static/img/content/archetypes/archetype-hierarchy.png deleted file mode 100644 index cb0d0bcf4..000000000 Binary files a/docs/static/img/content/archetypes/archetype-hierarchy.png and /dev/null differ diff --git a/docs/static/img/ctlcompiled-tn.png b/docs/static/img/ctlcompiled-tn.png deleted file mode 100644 index e5137da94..000000000 Binary files a/docs/static/img/ctlcompiled-tn.png and /dev/null differ diff --git a/docs/static/img/danmux-tn.jpg b/docs/static/img/danmux-tn.jpg deleted file mode 100644 index e6c82a2ef..000000000 Binary files a/docs/static/img/danmux-tn.jpg and /dev/null differ diff --git a/docs/static/img/datapipelinearchitect-tn.jpg b/docs/static/img/datapipelinearchitect-tn.jpg deleted file mode 100644 index 597ecdbba..000000000 Binary files a/docs/static/img/datapipelinearchitect-tn.jpg and /dev/null differ diff --git a/docs/static/img/davidepetilli-tn.jpg b/docs/static/img/davidepetilli-tn.jpg deleted file mode 100644 index a46fb9149..000000000 Binary files a/docs/static/img/davidepetilli-tn.jpg and /dev/null differ diff --git a/docs/static/img/davidrallen-tn.png b/docs/static/img/davidrallen-tn.png deleted file mode 100644 index 937147dee..000000000 Binary files a/docs/static/img/davidrallen-tn.png and /dev/null differ diff --git a/docs/static/img/davidyates-tn.png b/docs/static/img/davidyates-tn.png deleted file mode 100644 index a6a0a6143..000000000 Binary files a/docs/static/img/davidyates-tn.png and /dev/null differ diff --git a/docs/static/img/dbzman-online-tn.png b/docs/static/img/dbzman-online-tn.png deleted file mode 100644 index eb9d4ea0c..000000000 Binary files a/docs/static/img/dbzman-online-tn.png and /dev/null differ diff --git a/docs/static/img/desk-mini.jpg b/docs/static/img/desk-mini.jpg deleted file mode 100644 index ff296c8f7..000000000 Binary files a/docs/static/img/desk-mini.jpg and /dev/null differ diff --git a/docs/static/img/desk-sm.jpg b/docs/static/img/desk-sm.jpg deleted file mode 100644 index d4a21ad55..000000000 Binary files a/docs/static/img/desk-sm.jpg and /dev/null differ diff --git a/docs/static/img/desk-wide.jpg b/docs/static/img/desk-wide.jpg deleted file mode 100644 index 8ff17bc4d..000000000 Binary files a/docs/static/img/desk-wide.jpg and /dev/null differ diff --git a/docs/static/img/desk.jpg b/docs/static/img/desk.jpg deleted file mode 100644 index 43ecc6694..000000000 Binary files a/docs/static/img/desk.jpg and /dev/null differ diff --git a/docs/static/img/devmonk-tn.jpg b/docs/static/img/devmonk-tn.jpg deleted file mode 100644 index 05331d119..000000000 Binary files a/docs/static/img/devmonk-tn.jpg and /dev/null differ diff --git a/docs/static/img/dmitriid.com.png b/docs/static/img/dmitriid.com.png deleted file mode 100644 index 9c7217f6e..000000000 Binary files a/docs/static/img/dmitriid.com.png and /dev/null differ diff --git a/docs/static/img/docs.eurie.io-tn.png b/docs/static/img/docs.eurie.io-tn.png deleted file mode 100644 index 43443167b..000000000 Binary files a/docs/static/img/docs.eurie.io-tn.png and /dev/null differ diff --git a/docs/static/img/emilyhorsman.com-tn.jpg b/docs/static/img/emilyhorsman.com-tn.jpg deleted file mode 100644 index 99d2f9559..000000000 Binary files a/docs/static/img/emilyhorsman.com-tn.jpg and /dev/null differ diff --git a/docs/static/img/enjoyablerecipes-tn.png b/docs/static/img/enjoyablerecipes-tn.png deleted file mode 100644 index 460c804bc..000000000 Binary files a/docs/static/img/enjoyablerecipes-tn.png and /dev/null differ diff --git a/docs/static/img/esaezgil_com-tn.png b/docs/static/img/esaezgil_com-tn.png deleted file mode 100644 index f9b4087b6..000000000 Binary files a/docs/static/img/esaezgil_com-tn.png and /dev/null differ diff --git a/docs/static/img/esolia_com-tn.png b/docs/static/img/esolia_com-tn.png deleted file mode 100644 index 4574b085f..000000000 Binary files a/docs/static/img/esolia_com-tn.png and /dev/null differ diff --git a/docs/static/img/esolia_pro-tn.png b/docs/static/img/esolia_pro-tn.png deleted file mode 100644 index 021911456..000000000 Binary files a/docs/static/img/esolia_pro-tn.png and /dev/null differ diff --git a/docs/static/img/examples/trees.svg b/docs/static/img/examples/trees.svg new file mode 100644 index 000000000..0aaccfcff --- /dev/null +++ b/docs/static/img/examples/trees.svg @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1 +2 +3 +4 +1 +2 +3 +4 +1 +2 +3 +4 +1 +2 +3 +4 +1 +2 +3 +4 +1 +2 +3 +4 + + diff --git a/docs/static/img/fale-tn.png b/docs/static/img/fale-tn.png deleted file mode 100644 index 2c5f53f84..000000000 Binary files a/docs/static/img/fale-tn.png and /dev/null differ diff --git a/docs/static/img/firstnameclub.png b/docs/static/img/firstnameclub.png deleted file mode 100644 index b5bf80847..000000000 Binary files a/docs/static/img/firstnameclub.png and /dev/null differ diff --git a/docs/static/img/fixatom-tn.png b/docs/static/img/fixatom-tn.png deleted file mode 100644 index 39ded2463..000000000 Binary files a/docs/static/img/fixatom-tn.png and /dev/null differ diff --git a/docs/static/img/freebsd-19px.svg b/docs/static/img/freebsd-19px.svg deleted file mode 100644 index 4215b83a9..000000000 --- a/docs/static/img/freebsd-19px.svg +++ /dev/null @@ -1,127 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/static/img/furqansoftware-tn.png b/docs/static/img/furqansoftware-tn.png deleted file mode 100644 index e1d0e964f..000000000 Binary files a/docs/static/img/furqansoftware-tn.png and /dev/null differ diff --git a/docs/static/img/fxsitecompat-tn.png b/docs/static/img/fxsitecompat-tn.png deleted file mode 100644 index 3df542f59..000000000 Binary files a/docs/static/img/fxsitecompat-tn.png and /dev/null differ diff --git a/docs/static/img/gntech-tn.png b/docs/static/img/gntech-tn.png deleted file mode 100644 index 0a3fad9ba..000000000 Binary files a/docs/static/img/gntech-tn.png and /dev/null differ diff --git a/docs/static/img/gogb-tn.jpg b/docs/static/img/gogb-tn.jpg deleted file mode 100644 index caed5cfbb..000000000 Binary files a/docs/static/img/gogb-tn.jpg and /dev/null differ diff --git a/docs/static/img/goin5minutes-tn.png b/docs/static/img/goin5minutes-tn.png deleted file mode 100644 index eef26f110..000000000 Binary files a/docs/static/img/goin5minutes-tn.png and /dev/null differ diff --git a/docs/static/img/gray.png b/docs/static/img/gray.png deleted file mode 100644 index 3807691d3..000000000 Binary files a/docs/static/img/gray.png and /dev/null differ diff --git a/docs/static/img/h10n.me-tn.png b/docs/static/img/h10n.me-tn.png deleted file mode 100644 index 74bfee21b..000000000 Binary files a/docs/static/img/h10n.me-tn.png and /dev/null differ diff --git a/docs/static/img/heimatverein-niederjosbach-tn.png b/docs/static/img/heimatverein-niederjosbach-tn.png deleted file mode 100644 index f47425b7e..000000000 Binary files a/docs/static/img/heimatverein-niederjosbach-tn.png and /dev/null differ diff --git a/docs/static/img/horeaporutiu-tn.jpg b/docs/static/img/horeaporutiu-tn.jpg deleted file mode 100644 index 7e0d0fc80..000000000 Binary files a/docs/static/img/horeaporutiu-tn.jpg and /dev/null differ diff --git a/docs/static/img/hugo-logo-med.png b/docs/static/img/hugo-logo-med.png index dcc141690..11d91b320 100644 Binary files a/docs/static/img/hugo-logo-med.png and b/docs/static/img/hugo-logo-med.png differ diff --git a/docs/static/img/hugo-logo.png b/docs/static/img/hugo-logo.png index a4f1321b0..0a78f8eaa 100644 Binary files a/docs/static/img/hugo-logo.png and b/docs/static/img/hugo-logo.png differ diff --git a/docs/static/img/hugo-tn.jpg b/docs/static/img/hugo-tn.jpg deleted file mode 100644 index 9ac04a38a..000000000 Binary files a/docs/static/img/hugo-tn.jpg and /dev/null differ diff --git a/docs/static/img/invincible-tn.jpg b/docs/static/img/invincible-tn.jpg deleted file mode 100644 index f8c11f82a..000000000 Binary files a/docs/static/img/invincible-tn.jpg and /dev/null differ diff --git a/docs/static/img/invision-tn.png b/docs/static/img/invision-tn.png deleted file mode 100644 index 097f71bb3..000000000 Binary files a/docs/static/img/invision-tn.png and /dev/null differ diff --git a/docs/static/img/jamescampbell-tn.png b/docs/static/img/jamescampbell-tn.png deleted file mode 100644 index 31fb7dc28..000000000 Binary files a/docs/static/img/jamescampbell-tn.png and /dev/null differ diff --git a/docs/static/img/jonbeebe.net-thumbnail.png b/docs/static/img/jonbeebe.net-thumbnail.png deleted file mode 100644 index 1e31d97c6..000000000 Binary files a/docs/static/img/jonbeebe.net-thumbnail.png and /dev/null differ diff --git a/docs/static/img/jorgennilsson-tn.png b/docs/static/img/jorgennilsson-tn.png deleted file mode 100644 index abcc54ab3..000000000 Binary files a/docs/static/img/jorgennilsson-tn.png and /dev/null differ diff --git a/docs/static/img/kjhealy-tn.jpg b/docs/static/img/kjhealy-tn.jpg deleted file mode 100644 index dd2561ae3..000000000 Binary files a/docs/static/img/kjhealy-tn.jpg and /dev/null differ diff --git a/docs/static/img/klingt-net-tn.png b/docs/static/img/klingt-net-tn.png deleted file mode 100644 index 5ffd3d6a7..000000000 Binary files a/docs/static/img/klingt-net-tn.png and /dev/null differ diff --git a/docs/static/img/launchcode-tn.jpg b/docs/static/img/launchcode-tn.jpg deleted file mode 100644 index c422450a1..000000000 Binary files a/docs/static/img/launchcode-tn.jpg and /dev/null differ diff --git a/docs/static/img/leepenney-tn.jpg b/docs/static/img/leepenney-tn.jpg deleted file mode 100644 index c1085d779..000000000 Binary files a/docs/static/img/leepenney-tn.jpg and /dev/null differ diff --git a/docs/static/img/leowkahman-tn.png b/docs/static/img/leowkahman-tn.png deleted file mode 100644 index ae7803f57..000000000 Binary files a/docs/static/img/leowkahman-tn.png and /dev/null differ diff --git a/docs/static/img/lk4d4-tn.jpg b/docs/static/img/lk4d4-tn.jpg deleted file mode 100644 index 687606814..000000000 Binary files a/docs/static/img/lk4d4-tn.jpg and /dev/null differ diff --git a/docs/static/img/losslesslife-tn.png b/docs/static/img/losslesslife-tn.png deleted file mode 100644 index cc9e286aa..000000000 Binary files a/docs/static/img/losslesslife-tn.png and /dev/null differ diff --git a/docs/static/img/lucumt.info.png b/docs/static/img/lucumt.info.png deleted file mode 100644 index 15a3a213d..000000000 Binary files a/docs/static/img/lucumt.info.png and /dev/null differ diff --git a/docs/static/img/mariosanchez-tn.jpg b/docs/static/img/mariosanchez-tn.jpg deleted file mode 100644 index 75d116c22..000000000 Binary files a/docs/static/img/mariosanchez-tn.jpg and /dev/null differ diff --git a/docs/static/img/mayan-edms-tn.png b/docs/static/img/mayan-edms-tn.png deleted file mode 100644 index 8feca78e4..000000000 Binary files a/docs/static/img/mayan-edms-tn.png and /dev/null differ diff --git a/docs/static/img/michaelwhatcott-tn.jpg b/docs/static/img/michaelwhatcott-tn.jpg deleted file mode 100644 index 4cb1b5e80..000000000 Binary files a/docs/static/img/michaelwhatcott-tn.jpg and /dev/null differ diff --git a/docs/static/img/mongodb-eng-tn.png b/docs/static/img/mongodb-eng-tn.png deleted file mode 100644 index 6b223e745..000000000 Binary files a/docs/static/img/mongodb-eng-tn.png and /dev/null differ diff --git a/docs/static/img/mtbhomer-tn.png b/docs/static/img/mtbhomer-tn.png deleted file mode 100644 index c53f68792..000000000 Binary files a/docs/static/img/mtbhomer-tn.png and /dev/null differ diff --git a/docs/static/img/myearworms-tn.jpg b/docs/static/img/myearworms-tn.jpg deleted file mode 100644 index 49992889b..000000000 Binary files a/docs/static/img/myearworms-tn.jpg and /dev/null differ diff --git a/docs/static/img/neavey-tn.jpg b/docs/static/img/neavey-tn.jpg deleted file mode 100644 index 6f81fcb69..000000000 Binary files a/docs/static/img/neavey-tn.jpg and /dev/null differ diff --git a/docs/static/img/nickoneill-tn.jpg b/docs/static/img/nickoneill-tn.jpg deleted file mode 100644 index b8f1d1ae5..000000000 Binary files a/docs/static/img/nickoneill-tn.jpg and /dev/null differ diff --git a/docs/static/img/ninjaducks-tn.png b/docs/static/img/ninjaducks-tn.png deleted file mode 100644 index dd70268be..000000000 Binary files a/docs/static/img/ninjaducks-tn.png and /dev/null differ diff --git a/docs/static/img/ninya-tn.jpg b/docs/static/img/ninya-tn.jpg deleted file mode 100644 index 06bba8083..000000000 Binary files a/docs/static/img/ninya-tn.jpg and /dev/null differ diff --git a/docs/static/img/nodesk-tn.png b/docs/static/img/nodesk-tn.png deleted file mode 100644 index 76457d994..000000000 Binary files a/docs/static/img/nodesk-tn.png and /dev/null differ diff --git a/docs/static/img/novelist-xyz.png b/docs/static/img/novelist-xyz.png deleted file mode 100644 index c2ebed74e..000000000 Binary files a/docs/static/img/novelist-xyz.png and /dev/null differ diff --git a/docs/static/img/npf-tn.jpg b/docs/static/img/npf-tn.jpg deleted file mode 100644 index d3eba9c8d..000000000 Binary files a/docs/static/img/npf-tn.jpg and /dev/null differ diff --git a/docs/static/img/nutspubcrawl.jpg b/docs/static/img/nutspubcrawl.jpg deleted file mode 100644 index 34862b5a2..000000000 Binary files a/docs/static/img/nutspubcrawl.jpg and /dev/null differ diff --git a/docs/static/img/ocul-maps.png b/docs/static/img/ocul-maps.png deleted file mode 100644 index 298d55ecd..000000000 Binary files a/docs/static/img/ocul-maps.png and /dev/null differ diff --git a/docs/static/img/petanikode.png b/docs/static/img/petanikode.png deleted file mode 100644 index 3935a5c96..000000000 Binary files a/docs/static/img/petanikode.png and /dev/null differ diff --git a/docs/static/img/peteraba-tn.jpg b/docs/static/img/peteraba-tn.jpg deleted file mode 100644 index f30d3f042..000000000 Binary files a/docs/static/img/peteraba-tn.jpg and /dev/null differ diff --git a/docs/static/img/picturingjordan-tn.png b/docs/static/img/picturingjordan-tn.png deleted file mode 100644 index 75e6ea115..000000000 Binary files a/docs/static/img/picturingjordan-tn.png and /dev/null differ diff --git a/docs/static/img/promotive.png b/docs/static/img/promotive.png deleted file mode 100644 index 9f3f6209f..000000000 Binary files a/docs/static/img/promotive.png and /dev/null differ diff --git a/docs/static/img/quickstart/bookshelf-bleak-theme.png b/docs/static/img/quickstart/bookshelf-bleak-theme.png deleted file mode 100644 index ccd18c42d..000000000 Binary files a/docs/static/img/quickstart/bookshelf-bleak-theme.png and /dev/null differ diff --git a/docs/static/img/quickstart/bookshelf-disqus.png b/docs/static/img/quickstart/bookshelf-disqus.png deleted file mode 100644 index 3ce645a0c..000000000 Binary files a/docs/static/img/quickstart/bookshelf-disqus.png and /dev/null differ diff --git a/docs/static/img/quickstart/bookshelf-new-default-image.png b/docs/static/img/quickstart/bookshelf-new-default-image.png deleted file mode 100644 index d7274c7a6..000000000 Binary files a/docs/static/img/quickstart/bookshelf-new-default-image.png and /dev/null differ diff --git a/docs/static/img/quickstart/bookshelf-only-picture.png b/docs/static/img/quickstart/bookshelf-only-picture.png deleted file mode 100644 index a363383bc..000000000 Binary files a/docs/static/img/quickstart/bookshelf-only-picture.png and /dev/null differ diff --git a/docs/static/img/quickstart/bookshelf-robust-theme.png b/docs/static/img/quickstart/bookshelf-robust-theme.png deleted file mode 100644 index 7c5e6b8d2..000000000 Binary files a/docs/static/img/quickstart/bookshelf-robust-theme.png and /dev/null differ diff --git a/docs/static/img/quickstart/bookshelf-updated-config.png b/docs/static/img/quickstart/bookshelf-updated-config.png deleted file mode 100644 index bbda606c7..000000000 Binary files a/docs/static/img/quickstart/bookshelf-updated-config.png and /dev/null differ diff --git a/docs/static/img/quickstart/bookshelf.png b/docs/static/img/quickstart/bookshelf.png deleted file mode 100644 index 3b572adbb..000000000 Binary files a/docs/static/img/quickstart/bookshelf.png and /dev/null differ diff --git a/docs/static/img/quickstart/default.jpg b/docs/static/img/quickstart/default.jpg deleted file mode 100644 index 78d7bd28e..000000000 Binary files a/docs/static/img/quickstart/default.jpg and /dev/null differ diff --git a/docs/static/img/rahulrai_in-tn.png b/docs/static/img/rahulrai_in-tn.png deleted file mode 100644 index cd146dce5..000000000 Binary files a/docs/static/img/rahulrai_in-tn.png and /dev/null differ diff --git a/docs/static/img/rakutentech-tn.png b/docs/static/img/rakutentech-tn.png deleted file mode 100644 index 04f56e314..000000000 Binary files a/docs/static/img/rakutentech-tn.png and /dev/null differ diff --git a/docs/static/img/rdegges-tn.png b/docs/static/img/rdegges-tn.png deleted file mode 100644 index a2e4b6c86..000000000 Binary files a/docs/static/img/rdegges-tn.png and /dev/null differ diff --git a/docs/static/img/readtext-tn.png b/docs/static/img/readtext-tn.png deleted file mode 100644 index 9e71627b0..000000000 Binary files a/docs/static/img/readtext-tn.png and /dev/null differ diff --git a/docs/static/img/richardsumilang-tn.png b/docs/static/img/richardsumilang-tn.png deleted file mode 100644 index 68815495f..000000000 Binary files a/docs/static/img/richardsumilang-tn.png and /dev/null differ diff --git a/docs/static/img/rick_cogley_info-tn.jpg b/docs/static/img/rick_cogley_info-tn.jpg deleted file mode 100644 index 414e0108c..000000000 Binary files a/docs/static/img/rick_cogley_info-tn.jpg and /dev/null differ diff --git a/docs/static/img/ridingbytes-tn.png b/docs/static/img/ridingbytes-tn.png deleted file mode 100644 index 624cab96d..000000000 Binary files a/docs/static/img/ridingbytes-tn.png and /dev/null differ diff --git a/docs/static/img/robertbasic-tn.png b/docs/static/img/robertbasic-tn.png deleted file mode 100644 index 5ceecfead..000000000 Binary files a/docs/static/img/robertbasic-tn.png and /dev/null differ diff --git a/docs/static/img/sanjay-saxena-tn.png b/docs/static/img/sanjay-saxena-tn.png deleted file mode 100644 index 85bc5cc58..000000000 Binary files a/docs/static/img/sanjay-saxena-tn.png and /dev/null differ diff --git a/docs/static/img/scottcwilson-tn.png b/docs/static/img/scottcwilson-tn.png deleted file mode 100644 index 5517edf22..000000000 Binary files a/docs/static/img/scottcwilson-tn.png and /dev/null differ diff --git a/docs/static/img/shapeshed-tn.png b/docs/static/img/shapeshed-tn.png deleted file mode 100644 index 218b96c5e..000000000 Binary files a/docs/static/img/shapeshed-tn.png and /dev/null differ diff --git a/docs/static/img/shelan-tn.png b/docs/static/img/shelan-tn.png deleted file mode 100644 index 0f7634041..000000000 Binary files a/docs/static/img/shelan-tn.png and /dev/null differ diff --git a/docs/static/img/siba-tn.png b/docs/static/img/siba-tn.png deleted file mode 100644 index 52373df20..000000000 Binary files a/docs/static/img/siba-tn.png and /dev/null differ diff --git a/docs/static/img/silvergeko.jpg b/docs/static/img/silvergeko.jpg deleted file mode 100644 index 19b8f98cb..000000000 Binary files a/docs/static/img/silvergeko.jpg and /dev/null differ diff --git a/docs/static/img/softinio-tn.png b/docs/static/img/softinio-tn.png deleted file mode 100644 index ad94ae876..000000000 Binary files a/docs/static/img/softinio-tn.png and /dev/null differ diff --git a/docs/static/img/spf13-tn.jpg b/docs/static/img/spf13-tn.jpg deleted file mode 100644 index a987c71f9..000000000 Binary files a/docs/static/img/spf13-tn.jpg and /dev/null differ diff --git a/docs/static/img/steambap.png b/docs/static/img/steambap.png deleted file mode 100644 index bad21f438..000000000 Binary files a/docs/static/img/steambap.png and /dev/null differ diff --git a/docs/static/img/stefano.chiodino-tn.jpg b/docs/static/img/stefano.chiodino-tn.jpg deleted file mode 100644 index 7747798d5..000000000 Binary files a/docs/static/img/stefano.chiodino-tn.jpg and /dev/null differ diff --git a/docs/static/img/stou-tn.png b/docs/static/img/stou-tn.png deleted file mode 100644 index fe449b797..000000000 Binary files a/docs/static/img/stou-tn.png and /dev/null differ diff --git a/docs/static/img/szymonkatra-tn.png b/docs/static/img/szymonkatra-tn.png deleted file mode 100644 index e64f2b33d..000000000 Binary files a/docs/static/img/szymonkatra-tn.png and /dev/null differ diff --git a/docs/static/img/techmadeplain-tn.jpg b/docs/static/img/techmadeplain-tn.jpg deleted file mode 100644 index cae544861..000000000 Binary files a/docs/static/img/techmadeplain-tn.jpg and /dev/null differ diff --git a/docs/static/img/tendermint-tn.jpg b/docs/static/img/tendermint-tn.jpg deleted file mode 100644 index 807b42d74..000000000 Binary files a/docs/static/img/tendermint-tn.jpg and /dev/null differ diff --git a/docs/static/img/thecodeking-tn.jpg b/docs/static/img/thecodeking-tn.jpg deleted file mode 100644 index 158384d4e..000000000 Binary files a/docs/static/img/thecodeking-tn.jpg and /dev/null differ diff --git a/docs/static/img/thehome-tn.png b/docs/static/img/thehome-tn.png deleted file mode 100644 index 7b66e6215..000000000 Binary files a/docs/static/img/thehome-tn.png and /dev/null differ diff --git a/docs/static/img/thislittleduck-tn.png b/docs/static/img/thislittleduck-tn.png deleted file mode 100644 index 0d7407d62..000000000 Binary files a/docs/static/img/thislittleduck-tn.png and /dev/null differ diff --git a/docs/static/img/tibobeijen-nl-tn.png b/docs/static/img/tibobeijen-nl-tn.png deleted file mode 100644 index 801e34a12..000000000 Binary files a/docs/static/img/tibobeijen-nl-tn.png and /dev/null differ diff --git a/docs/static/img/ttsreader-tn.png b/docs/static/img/ttsreader-tn.png deleted file mode 100644 index db33322b4..000000000 Binary files a/docs/static/img/ttsreader-tn.png and /dev/null differ diff --git a/docs/static/img/tutorialonfly-tn.jpg b/docs/static/img/tutorialonfly-tn.jpg deleted file mode 100644 index 5ff99fff6..000000000 Binary files a/docs/static/img/tutorialonfly-tn.jpg and /dev/null differ diff --git a/docs/static/img/tutorials/automated-deployments/adding-a-deploy-pipeline.png b/docs/static/img/tutorials/automated-deployments/adding-a-deploy-pipeline.png deleted file mode 100644 index 29f637b06..000000000 Binary files a/docs/static/img/tutorials/automated-deployments/adding-a-deploy-pipeline.png and /dev/null differ diff --git a/docs/static/img/tutorials/automated-deployments/adding-a-deploy-step.png b/docs/static/img/tutorials/automated-deployments/adding-a-deploy-step.png deleted file mode 100644 index 346187927..000000000 Binary files a/docs/static/img/tutorials/automated-deployments/adding-a-deploy-step.png and /dev/null differ diff --git a/docs/static/img/tutorials/automated-deployments/adding-the-project-to-github.png b/docs/static/img/tutorials/automated-deployments/adding-the-project-to-github.png deleted file mode 100644 index e1065bb00..000000000 Binary files a/docs/static/img/tutorials/automated-deployments/adding-the-project-to-github.png and /dev/null differ diff --git a/docs/static/img/tutorials/automated-deployments/creating-a-basic-hugo-site.png b/docs/static/img/tutorials/automated-deployments/creating-a-basic-hugo-site.png deleted file mode 100644 index 78d238f88..000000000 Binary files a/docs/static/img/tutorials/automated-deployments/creating-a-basic-hugo-site.png and /dev/null differ diff --git a/docs/static/img/tutorials/automated-deployments/public-or-not.png b/docs/static/img/tutorials/automated-deployments/public-or-not.png deleted file mode 100644 index 9d81a8ba4..000000000 Binary files a/docs/static/img/tutorials/automated-deployments/public-or-not.png and /dev/null differ diff --git a/docs/static/img/tutorials/automated-deployments/using-hugo-build.png b/docs/static/img/tutorials/automated-deployments/using-hugo-build.png deleted file mode 100644 index b0dbec94c..000000000 Binary files a/docs/static/img/tutorials/automated-deployments/using-hugo-build.png and /dev/null differ diff --git a/docs/static/img/tutorials/automated-deployments/wercker-access.png b/docs/static/img/tutorials/automated-deployments/wercker-access.png deleted file mode 100644 index 6e89c0ef3..000000000 Binary files a/docs/static/img/tutorials/automated-deployments/wercker-access.png and /dev/null differ diff --git a/docs/static/img/tutorials/automated-deployments/wercker-add-app.png b/docs/static/img/tutorials/automated-deployments/wercker-add-app.png deleted file mode 100644 index 94ccef518..000000000 Binary files a/docs/static/img/tutorials/automated-deployments/wercker-add-app.png and /dev/null differ diff --git a/docs/static/img/tutorials/automated-deployments/wercker-git-connections.png b/docs/static/img/tutorials/automated-deployments/wercker-git-connections.png deleted file mode 100644 index d89c0cd8b..000000000 Binary files a/docs/static/img/tutorials/automated-deployments/wercker-git-connections.png and /dev/null differ diff --git a/docs/static/img/tutorials/automated-deployments/wercker-search.png b/docs/static/img/tutorials/automated-deployments/wercker-search.png deleted file mode 100644 index d099cfd5c..000000000 Binary files a/docs/static/img/tutorials/automated-deployments/wercker-search.png and /dev/null differ diff --git a/docs/static/img/tutorials/automated-deployments/wercker-select-repository.png b/docs/static/img/tutorials/automated-deployments/wercker-select-repository.png deleted file mode 100644 index 0f7d63d98..000000000 Binary files a/docs/static/img/tutorials/automated-deployments/wercker-select-repository.png and /dev/null differ diff --git a/docs/static/img/tutorials/automated-deployments/wercker-sign-up-page.png b/docs/static/img/tutorials/automated-deployments/wercker-sign-up-page.png deleted file mode 100644 index 55b0ebd52..000000000 Binary files a/docs/static/img/tutorials/automated-deployments/wercker-sign-up-page.png and /dev/null differ diff --git a/docs/static/img/tutorials/automated-deployments/wercker-sign-up.png b/docs/static/img/tutorials/automated-deployments/wercker-sign-up.png deleted file mode 100644 index 9c6270061..000000000 Binary files a/docs/static/img/tutorials/automated-deployments/wercker-sign-up.png and /dev/null differ diff --git a/docs/static/img/tutorials/automated-deployments/werckeryml.png b/docs/static/img/tutorials/automated-deployments/werckeryml.png deleted file mode 100644 index daa392b4a..000000000 Binary files a/docs/static/img/tutorials/automated-deployments/werckeryml.png and /dev/null differ diff --git a/docs/static/img/tutorials/hosting-on-bitbucket/bitbucket-blog-post.png b/docs/static/img/tutorials/hosting-on-bitbucket/bitbucket-blog-post.png deleted file mode 100644 index b78f6fd15..000000000 Binary files a/docs/static/img/tutorials/hosting-on-bitbucket/bitbucket-blog-post.png and /dev/null differ diff --git a/docs/static/img/tutorials/hosting-on-bitbucket/bitbucket-create-repo.png b/docs/static/img/tutorials/hosting-on-bitbucket/bitbucket-create-repo.png deleted file mode 100644 index e97f13465..000000000 Binary files a/docs/static/img/tutorials/hosting-on-bitbucket/bitbucket-create-repo.png and /dev/null differ diff --git a/docs/static/img/tutorials/how-to-contribute-to-hugo/accept-cla.png b/docs/static/img/tutorials/how-to-contribute-to-hugo/accept-cla.png deleted file mode 100644 index 929fda6ab..000000000 Binary files a/docs/static/img/tutorials/how-to-contribute-to-hugo/accept-cla.png and /dev/null differ diff --git a/docs/static/img/tutorials/how-to-contribute-to-hugo/ci-errors.png b/docs/static/img/tutorials/how-to-contribute-to-hugo/ci-errors.png deleted file mode 100644 index 95cd290b6..000000000 Binary files a/docs/static/img/tutorials/how-to-contribute-to-hugo/ci-errors.png and /dev/null differ diff --git a/docs/static/img/tutorials/how-to-contribute-to-hugo/copy-remote-url.png b/docs/static/img/tutorials/how-to-contribute-to-hugo/copy-remote-url.png deleted file mode 100644 index 9006f4a48..000000000 Binary files a/docs/static/img/tutorials/how-to-contribute-to-hugo/copy-remote-url.png and /dev/null differ diff --git a/docs/static/img/tutorials/how-to-contribute-to-hugo/forking-a-repository.png b/docs/static/img/tutorials/how-to-contribute-to-hugo/forking-a-repository.png deleted file mode 100644 index ea132cab3..000000000 Binary files a/docs/static/img/tutorials/how-to-contribute-to-hugo/forking-a-repository.png and /dev/null differ diff --git a/docs/static/img/tutorials/how-to-contribute-to-hugo/open-pull-request.png b/docs/static/img/tutorials/how-to-contribute-to-hugo/open-pull-request.png deleted file mode 100644 index 63b504fb2..000000000 Binary files a/docs/static/img/tutorials/how-to-contribute-to-hugo/open-pull-request.png and /dev/null differ diff --git a/docs/static/img/tutswiki-tn.jpg b/docs/static/img/tutswiki-tn.jpg deleted file mode 100644 index 11efb2a5b..000000000 Binary files a/docs/static/img/tutswiki-tn.jpg and /dev/null differ diff --git a/docs/static/img/ucsb-tn.jpg b/docs/static/img/ucsb-tn.jpg deleted file mode 100644 index 45962027d..000000000 Binary files a/docs/static/img/ucsb-tn.jpg and /dev/null differ diff --git a/docs/static/img/upbeat.png b/docs/static/img/upbeat.png deleted file mode 100644 index e7a6a694c..000000000 Binary files a/docs/static/img/upbeat.png and /dev/null differ diff --git a/docs/static/img/vamp_landingpage-tn.png b/docs/static/img/vamp_landingpage-tn.png deleted file mode 100644 index 474261e0e..000000000 Binary files a/docs/static/img/vamp_landingpage-tn.png and /dev/null differ diff --git a/docs/static/img/viglug-tn.png b/docs/static/img/viglug-tn.png deleted file mode 100644 index d18ab4023..000000000 Binary files a/docs/static/img/viglug-tn.png and /dev/null differ diff --git a/docs/static/img/vurt.co-tn.jpg b/docs/static/img/vurt.co-tn.jpg deleted file mode 100644 index 5e7f131a0..000000000 Binary files a/docs/static/img/vurt.co-tn.jpg and /dev/null differ diff --git a/docs/static/img/worldtowriters-com.jpg b/docs/static/img/worldtowriters-com.jpg deleted file mode 100644 index 570d06fa9..000000000 Binary files a/docs/static/img/worldtowriters-com.jpg and /dev/null differ diff --git a/docs/static/img/yslow-rules-tn.png b/docs/static/img/yslow-rules-tn.png deleted file mode 100644 index 5c75a6943..000000000 Binary files a/docs/static/img/yslow-rules-tn.png and /dev/null differ diff --git a/docs/static/img/ysqi-blog.png b/docs/static/img/ysqi-blog.png deleted file mode 100644 index 6dd234109..000000000 Binary files a/docs/static/img/ysqi-blog.png and /dev/null differ diff --git a/docs/static/img/yulinling-tn.jpg b/docs/static/img/yulinling-tn.jpg deleted file mode 100644 index bdb12f0e7..000000000 Binary files a/docs/static/img/yulinling-tn.jpg and /dev/null differ diff --git a/docs/static/js/livereload.js b/docs/static/js/livereload.js deleted file mode 100644 index f6c3b7f90..000000000 --- a/docs/static/js/livereload.js +++ /dev/null @@ -1,1155 +0,0 @@ -(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o tag"); - return; - } - this.reloader = new Reloader(this.window, this.console, Timer); - this.connector = new Connector(this.options, this.WebSocket, Timer, { - connecting: (function(_this) { - return function() {}; - })(this), - socketConnected: (function(_this) { - return function() {}; - })(this), - connected: (function(_this) { - return function(protocol) { - var _base; - if (typeof (_base = _this.listeners).connect === "function") { - _base.connect(); - } - _this.log("LiveReload is connected to " + _this.options.host + ":" + _this.options.port + " (protocol v" + protocol + ")."); - return _this.analyze(); - }; - })(this), - error: (function(_this) { - return function(e) { - if (e instanceof ProtocolError) { - if (typeof console !== "undefined" && console !== null) { - return console.log("" + e.message + "."); - } - } else { - if (typeof console !== "undefined" && console !== null) { - return console.log("LiveReload internal error: " + e.message); - } - } - }; - })(this), - disconnected: (function(_this) { - return function(reason, nextDelay) { - var _base; - if (typeof (_base = _this.listeners).disconnect === "function") { - _base.disconnect(); - } - switch (reason) { - case 'cannot-connect': - return _this.log("LiveReload cannot connect to " + _this.options.host + ":" + _this.options.port + ", will retry in " + nextDelay + " sec."); - case 'broken': - return _this.log("LiveReload disconnected from " + _this.options.host + ":" + _this.options.port + ", reconnecting in " + nextDelay + " sec."); - case 'handshake-timeout': - return _this.log("LiveReload cannot connect to " + _this.options.host + ":" + _this.options.port + " (handshake timeout), will retry in " + nextDelay + " sec."); - case 'handshake-failed': - return _this.log("LiveReload cannot connect to " + _this.options.host + ":" + _this.options.port + " (handshake failed), will retry in " + nextDelay + " sec."); - case 'manual': - break; - case 'error': - break; - default: - return _this.log("LiveReload disconnected from " + _this.options.host + ":" + _this.options.port + " (" + reason + "), reconnecting in " + nextDelay + " sec."); - } - }; - })(this), - message: (function(_this) { - return function(message) { - switch (message.command) { - case 'reload': - return _this.performReload(message); - case 'alert': - return _this.performAlert(message); - } - }; - })(this) - }); - } - - LiveReload.prototype.on = function(eventName, handler) { - return this.listeners[eventName] = handler; - }; - - LiveReload.prototype.log = function(message) { - return this.console.log("" + message); - }; - - LiveReload.prototype.performReload = function(message) { - var _ref, _ref1; - this.log("LiveReload received reload request: " + (JSON.stringify(message, null, 2))); - return this.reloader.reload(message.path, { - liveCSS: (_ref = message.liveCSS) != null ? _ref : true, - liveImg: (_ref1 = message.liveImg) != null ? _ref1 : true, - originalPath: message.originalPath || '', - overrideURL: message.overrideURL || '', - serverURL: "http://" + this.options.host + ":" + this.options.port - }); - }; - - LiveReload.prototype.performAlert = function(message) { - return alert(message.message); - }; - - LiveReload.prototype.shutDown = function() { - var _base; - this.connector.disconnect(); - this.log("LiveReload disconnected."); - return typeof (_base = this.listeners).shutdown === "function" ? _base.shutdown() : void 0; - }; - - LiveReload.prototype.hasPlugin = function(identifier) { - return !!this.pluginIdentifiers[identifier]; - }; - - LiveReload.prototype.addPlugin = function(pluginClass) { - var plugin; - if (this.hasPlugin(pluginClass.identifier)) { - return; - } - this.pluginIdentifiers[pluginClass.identifier] = true; - plugin = new pluginClass(this.window, { - _livereload: this, - _reloader: this.reloader, - _connector: this.connector, - console: this.console, - Timer: Timer, - generateCacheBustUrl: (function(_this) { - return function(url) { - return _this.reloader.generateCacheBustUrl(url); - }; - })(this) - }); - this.plugins.push(plugin); - this.reloader.addPlugin(plugin); - }; - - LiveReload.prototype.analyze = function() { - var plugin, pluginData, pluginsData, _i, _len, _ref; - if (!(this.connector.protocol >= 7)) { - return; - } - pluginsData = {}; - _ref = this.plugins; - for (_i = 0, _len = _ref.length; _i < _len; _i++) { - plugin = _ref[_i]; - pluginsData[plugin.constructor.identifier] = pluginData = (typeof plugin.analyze === "function" ? plugin.analyze() : void 0) || {}; - pluginData.version = plugin.constructor.version; - } - this.connector.sendCommand({ - command: 'info', - plugins: pluginsData, - url: this.window.location.href - }); - }; - - return LiveReload; - - })(); - -}).call(this); - -},{"./connector":1,"./options":5,"./reloader":7,"./timer":9}],5:[function(require,module,exports){ -(function() { - var Options; - - exports.Options = Options = (function() { - function Options() { - this.host = null; - this.port = 35729; - this.snipver = null; - this.ext = null; - this.extver = null; - this.mindelay = 1000; - this.maxdelay = 60000; - this.handshake_timeout = 5000; - } - - Options.prototype.set = function(name, value) { - if (typeof value === 'undefined') { - return; - } - if (!isNaN(+value)) { - value = +value; - } - return this[name] = value; - }; - - return Options; - - })(); - - Options.extract = function(document) { - var element, keyAndValue, m, mm, options, pair, src, _i, _j, _len, _len1, _ref, _ref1; - _ref = document.getElementsByTagName('script'); - for (_i = 0, _len = _ref.length; _i < _len; _i++) { - element = _ref[_i]; - if ((src = element.src) && (m = src.match(/^[^:]+:\/\/(.*)\/z?livereload\.js(?:\?(.*))?$/))) { - options = new Options(); - if (mm = m[1].match(/^([^\/:]+)(?::(\d+))?$/)) { - options.host = mm[1]; - if (mm[2]) { - options.port = parseInt(mm[2], 10); - } - } - if (m[2]) { - _ref1 = m[2].split('&'); - for (_j = 0, _len1 = _ref1.length; _j < _len1; _j++) { - pair = _ref1[_j]; - if ((keyAndValue = pair.split('=')).length > 1) { - options.set(keyAndValue[0].replace(/-/g, '_'), keyAndValue.slice(1).join('=')); - } - } - } - return options; - } - } - return null; - }; - -}).call(this); - -},{}],6:[function(require,module,exports){ -(function() { - var PROTOCOL_6, PROTOCOL_7, Parser, ProtocolError, - __indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; - - exports.PROTOCOL_6 = PROTOCOL_6 = 'http://livereload.com/protocols/official-6'; - - exports.PROTOCOL_7 = PROTOCOL_7 = 'http://livereload.com/protocols/official-7'; - - exports.ProtocolError = ProtocolError = (function() { - function ProtocolError(reason, data) { - this.message = "LiveReload protocol error (" + reason + ") after receiving data: \"" + data + "\"."; - } - - return ProtocolError; - - })(); - - exports.Parser = Parser = (function() { - function Parser(handlers) { - this.handlers = handlers; - this.reset(); - } - - Parser.prototype.reset = function() { - return this.protocol = null; - }; - - Parser.prototype.process = function(data) { - var command, e, message, options, _ref; - try { - if (this.protocol == null) { - if (data.match(/^!!ver:([\d.]+)$/)) { - this.protocol = 6; - } else if (message = this._parseMessage(data, ['hello'])) { - if (!message.protocols.length) { - throw new ProtocolError("no protocols specified in handshake message"); - } else if (__indexOf.call(message.protocols, PROTOCOL_7) >= 0) { - this.protocol = 7; - } else if (__indexOf.call(message.protocols, PROTOCOL_6) >= 0) { - this.protocol = 6; - } else { - throw new ProtocolError("no supported protocols found"); - } - } - return this.handlers.connected(this.protocol); - } else if (this.protocol === 6) { - message = JSON.parse(data); - if (!message.length) { - throw new ProtocolError("protocol 6 messages must be arrays"); - } - command = message[0], options = message[1]; - if (command !== 'refresh') { - throw new ProtocolError("unknown protocol 6 command"); - } - return this.handlers.message({ - command: 'reload', - path: options.path, - liveCSS: (_ref = options.apply_css_live) != null ? _ref : true - }); - } else { - message = this._parseMessage(data, ['reload', 'alert']); - return this.handlers.message(message); - } - } catch (_error) { - e = _error; - if (e instanceof ProtocolError) { - return this.handlers.error(e); - } else { - throw e; - } - } - }; - - Parser.prototype._parseMessage = function(data, validCommands) { - var e, message, _ref; - try { - message = JSON.parse(data); - } catch (_error) { - e = _error; - throw new ProtocolError('unparsable JSON', data); - } - if (!message.command) { - throw new ProtocolError('missing "command" key', data); - } - if (_ref = message.command, __indexOf.call(validCommands, _ref) < 0) { - throw new ProtocolError("invalid command '" + message.command + "', only valid commands are: " + (validCommands.join(', ')) + ")", data); - } - return message; - }; - - return Parser; - - })(); - -}).call(this); - -},{}],7:[function(require,module,exports){ -(function() { - var IMAGE_STYLES, Reloader, numberOfMatchingSegments, pathFromUrl, pathsMatch, pickBestMatch, splitUrl; - - splitUrl = function(url) { - var hash, index, params; - if ((index = url.indexOf('#')) >= 0) { - hash = url.slice(index); - url = url.slice(0, index); - } else { - hash = ''; - } - if ((index = url.indexOf('?')) >= 0) { - params = url.slice(index); - url = url.slice(0, index); - } else { - params = ''; - } - return { - url: url, - params: params, - hash: hash - }; - }; - - pathFromUrl = function(url) { - var path; - url = splitUrl(url).url; - if (url.indexOf('file://') === 0) { - path = url.replace(/^file:\/\/(localhost)?/, ''); - } else { - path = url.replace(/^([^:]+:)?\/\/([^:\/]+)(:\d*)?\//, '/'); - } - return decodeURIComponent(path); - }; - - pickBestMatch = function(path, objects, pathFunc) { - var bestMatch, object, score, _i, _len; - bestMatch = { - score: 0 - }; - for (_i = 0, _len = objects.length; _i < _len; _i++) { - object = objects[_i]; - score = numberOfMatchingSegments(path, pathFunc(object)); - if (score > bestMatch.score) { - bestMatch = { - object: object, - score: score - }; - } - } - if (bestMatch.score > 0) { - return bestMatch; - } else { - return null; - } - }; - - numberOfMatchingSegments = function(path1, path2) { - var comps1, comps2, eqCount, len; - path1 = path1.replace(/^\/+/, '').toLowerCase(); - path2 = path2.replace(/^\/+/, '').toLowerCase(); - if (path1 === path2) { - return 10000; - } - comps1 = path1.split('/').reverse(); - comps2 = path2.split('/').reverse(); - len = Math.min(comps1.length, comps2.length); - eqCount = 0; - while (eqCount < len && comps1[eqCount] === comps2[eqCount]) { - ++eqCount; - } - return eqCount; - }; - - pathsMatch = function(path1, path2) { - return numberOfMatchingSegments(path1, path2) > 0; - }; - - IMAGE_STYLES = [ - { - selector: 'background', - styleNames: ['backgroundImage'] - }, { - selector: 'border', - styleNames: ['borderImage', 'webkitBorderImage', 'MozBorderImage'] - } - ]; - - exports.Reloader = Reloader = (function() { - function Reloader(window, console, Timer) { - this.window = window; - this.console = console; - this.Timer = Timer; - this.document = this.window.document; - this.importCacheWaitPeriod = 200; - this.plugins = []; - } - - Reloader.prototype.addPlugin = function(plugin) { - return this.plugins.push(plugin); - }; - - Reloader.prototype.analyze = function(callback) { - return results; - }; - - Reloader.prototype.reload = function(path, options) { - var plugin, _base, _i, _len, _ref; - this.options = options; - if ((_base = this.options).stylesheetReloadTimeout == null) { - _base.stylesheetReloadTimeout = 15000; - } - _ref = this.plugins; - for (_i = 0, _len = _ref.length; _i < _len; _i++) { - plugin = _ref[_i]; - if (plugin.reload && plugin.reload(path, options)) { - return; - } - } - if (options.liveCSS) { - if (path.match(/\.css$/i)) { - if (this.reloadStylesheet(path)) { - return; - } - } - } - if (options.liveImg) { - if (path.match(/\.(jpe?g|png|gif)$/i)) { - this.reloadImages(path); - return; - } - } - return this.reloadPage(); - }; - - Reloader.prototype.reloadPage = function() { - return this.window.document.location.reload(); - }; - - Reloader.prototype.reloadImages = function(path) { - var expando, img, selector, styleNames, styleSheet, _i, _j, _k, _l, _len, _len1, _len2, _len3, _ref, _ref1, _ref2, _ref3, _results; - expando = this.generateUniqueString(); - _ref = this.document.images; - for (_i = 0, _len = _ref.length; _i < _len; _i++) { - img = _ref[_i]; - if (pathsMatch(path, pathFromUrl(img.src))) { - img.src = this.generateCacheBustUrl(img.src, expando); - } - } - if (this.document.querySelectorAll) { - for (_j = 0, _len1 = IMAGE_STYLES.length; _j < _len1; _j++) { - _ref1 = IMAGE_STYLES[_j], selector = _ref1.selector, styleNames = _ref1.styleNames; - _ref2 = this.document.querySelectorAll("[style*=" + selector + "]"); - for (_k = 0, _len2 = _ref2.length; _k < _len2; _k++) { - img = _ref2[_k]; - this.reloadStyleImages(img.style, styleNames, path, expando); - } - } - } - if (this.document.styleSheets) { - _ref3 = this.document.styleSheets; - _results = []; - for (_l = 0, _len3 = _ref3.length; _l < _len3; _l++) { - styleSheet = _ref3[_l]; - _results.push(this.reloadStylesheetImages(styleSheet, path, expando)); - } - return _results; - } - }; - - Reloader.prototype.reloadStylesheetImages = function(styleSheet, path, expando) { - var e, rule, rules, styleNames, _i, _j, _len, _len1; - try { - rules = styleSheet != null ? styleSheet.cssRules : void 0; - } catch (_error) { - e = _error; - } - if (!rules) { - return; - } - for (_i = 0, _len = rules.length; _i < _len; _i++) { - rule = rules[_i]; - switch (rule.type) { - case CSSRule.IMPORT_RULE: - this.reloadStylesheetImages(rule.styleSheet, path, expando); - break; - case CSSRule.STYLE_RULE: - for (_j = 0, _len1 = IMAGE_STYLES.length; _j < _len1; _j++) { - styleNames = IMAGE_STYLES[_j].styleNames; - this.reloadStyleImages(rule.style, styleNames, path, expando); - } - break; - case CSSRule.MEDIA_RULE: - this.reloadStylesheetImages(rule, path, expando); - } - } - }; - - Reloader.prototype.reloadStyleImages = function(style, styleNames, path, expando) { - var newValue, styleName, value, _i, _len; - for (_i = 0, _len = styleNames.length; _i < _len; _i++) { - styleName = styleNames[_i]; - value = style[styleName]; - if (typeof value === 'string') { - newValue = value.replace(/\burl\s*\(([^)]*)\)/, (function(_this) { - return function(match, src) { - if (pathsMatch(path, pathFromUrl(src))) { - return "url(" + (_this.generateCacheBustUrl(src, expando)) + ")"; - } else { - return match; - } - }; - })(this)); - if (newValue !== value) { - style[styleName] = newValue; - } - } - } - }; - - Reloader.prototype.reloadStylesheet = function(path) { - var imported, link, links, match, style, _i, _j, _k, _l, _len, _len1, _len2, _len3, _ref, _ref1; - links = (function() { - var _i, _len, _ref, _results; - _ref = this.document.getElementsByTagName('link'); - _results = []; - for (_i = 0, _len = _ref.length; _i < _len; _i++) { - link = _ref[_i]; - if (link.rel.match(/^stylesheet$/i) && !link.__LiveReload_pendingRemoval) { - _results.push(link); - } - } - return _results; - }).call(this); - imported = []; - _ref = this.document.getElementsByTagName('style'); - for (_i = 0, _len = _ref.length; _i < _len; _i++) { - style = _ref[_i]; - if (style.sheet) { - this.collectImportedStylesheets(style, style.sheet, imported); - } - } - for (_j = 0, _len1 = links.length; _j < _len1; _j++) { - link = links[_j]; - this.collectImportedStylesheets(link, link.sheet, imported); - } - if (this.window.StyleFix && this.document.querySelectorAll) { - _ref1 = this.document.querySelectorAll('style[data-href]'); - for (_k = 0, _len2 = _ref1.length; _k < _len2; _k++) { - style = _ref1[_k]; - links.push(style); - } - } - this.console.log("LiveReload found " + links.length + " LINKed stylesheets, " + imported.length + " @imported stylesheets"); - match = pickBestMatch(path, links.concat(imported), (function(_this) { - return function(l) { - return pathFromUrl(_this.linkHref(l)); - }; - })(this)); - if (match) { - if (match.object.rule) { - this.console.log("LiveReload is reloading imported stylesheet: " + match.object.href); - this.reattachImportedRule(match.object); - } else { - this.console.log("LiveReload is reloading stylesheet: " + (this.linkHref(match.object))); - this.reattachStylesheetLink(match.object); - } - } else { - this.console.log("LiveReload will reload all stylesheets because path '" + path + "' did not match any specific one"); - for (_l = 0, _len3 = links.length; _l < _len3; _l++) { - link = links[_l]; - this.reattachStylesheetLink(link); - } - } - return true; - }; - - Reloader.prototype.collectImportedStylesheets = function(link, styleSheet, result) { - var e, index, rule, rules, _i, _len; - try { - rules = styleSheet != null ? styleSheet.cssRules : void 0; - } catch (_error) { - e = _error; - } - if (rules && rules.length) { - for (index = _i = 0, _len = rules.length; _i < _len; index = ++_i) { - rule = rules[index]; - switch (rule.type) { - case CSSRule.CHARSET_RULE: - continue; - case CSSRule.IMPORT_RULE: - result.push({ - link: link, - rule: rule, - index: index, - href: rule.href - }); - this.collectImportedStylesheets(link, rule.styleSheet, result); - break; - default: - break; - } - } - } - }; - - Reloader.prototype.waitUntilCssLoads = function(clone, func) { - var callbackExecuted, executeCallback, poll; - callbackExecuted = false; - executeCallback = (function(_this) { - return function() { - if (callbackExecuted) { - return; - } - callbackExecuted = true; - return func(); - }; - })(this); - clone.onload = (function(_this) { - return function() { - _this.console.log("LiveReload: the new stylesheet has finished loading"); - _this.knownToSupportCssOnLoad = true; - return executeCallback(); - }; - })(this); - if (!this.knownToSupportCssOnLoad) { - (poll = (function(_this) { - return function() { - if (clone.sheet) { - _this.console.log("LiveReload is polling until the new CSS finishes loading..."); - return executeCallback(); - } else { - return _this.Timer.start(50, poll); - } - }; - })(this))(); - } - return this.Timer.start(this.options.stylesheetReloadTimeout, executeCallback); - }; - - Reloader.prototype.linkHref = function(link) { - return link.href || link.getAttribute('data-href'); - }; - - Reloader.prototype.reattachStylesheetLink = function(link) { - var clone, parent; - if (link.__LiveReload_pendingRemoval) { - return; - } - link.__LiveReload_pendingRemoval = true; - if (link.tagName === 'STYLE') { - clone = this.document.createElement('link'); - clone.rel = 'stylesheet'; - clone.media = link.media; - clone.disabled = link.disabled; - } else { - clone = link.cloneNode(false); - } - clone.href = this.generateCacheBustUrl(this.linkHref(link)); - parent = link.parentNode; - if (parent.lastChild === link) { - parent.appendChild(clone); - } else { - parent.insertBefore(clone, link.nextSibling); - } - return this.waitUntilCssLoads(clone, (function(_this) { - return function() { - var additionalWaitingTime; - if (/AppleWebKit/.test(navigator.userAgent)) { - additionalWaitingTime = 5; - } else { - additionalWaitingTime = 200; - } - return _this.Timer.start(additionalWaitingTime, function() { - var _ref; - if (!link.parentNode) { - return; - } - link.parentNode.removeChild(link); - clone.onreadystatechange = null; - return (_ref = _this.window.StyleFix) != null ? _ref.link(clone) : void 0; - }); - }; - })(this)); - }; - - Reloader.prototype.reattachImportedRule = function(_arg) { - var href, index, link, media, newRule, parent, rule, tempLink; - rule = _arg.rule, index = _arg.index, link = _arg.link; - parent = rule.parentStyleSheet; - href = this.generateCacheBustUrl(rule.href); - media = rule.media.length ? [].join.call(rule.media, ', ') : ''; - newRule = "@import url(\"" + href + "\") " + media + ";"; - rule.__LiveReload_newHref = href; - tempLink = this.document.createElement("link"); - tempLink.rel = 'stylesheet'; - tempLink.href = href; - tempLink.__LiveReload_pendingRemoval = true; - if (link.parentNode) { - link.parentNode.insertBefore(tempLink, link); - } - return this.Timer.start(this.importCacheWaitPeriod, (function(_this) { - return function() { - if (tempLink.parentNode) { - tempLink.parentNode.removeChild(tempLink); - } - if (rule.__LiveReload_newHref !== href) { - return; - } - parent.insertRule(newRule, index); - parent.deleteRule(index + 1); - rule = parent.cssRules[index]; - rule.__LiveReload_newHref = href; - return _this.Timer.start(_this.importCacheWaitPeriod, function() { - if (rule.__LiveReload_newHref !== href) { - return; - } - parent.insertRule(newRule, index); - return parent.deleteRule(index + 1); - }); - }; - })(this)); - }; - - Reloader.prototype.generateUniqueString = function() { - return 'livereload=' + Date.now(); - }; - - Reloader.prototype.generateCacheBustUrl = function(url, expando) { - var hash, oldParams, originalUrl, params, _ref; - if (expando == null) { - expando = this.generateUniqueString(); - } - _ref = splitUrl(url), url = _ref.url, hash = _ref.hash, oldParams = _ref.params; - if (this.options.overrideURL) { - if (url.indexOf(this.options.serverURL) < 0) { - originalUrl = url; - url = this.options.serverURL + this.options.overrideURL + "?url=" + encodeURIComponent(url); - this.console.log("LiveReload is overriding source URL " + originalUrl + " with " + url); - } - } - params = oldParams.replace(/(\?|&)livereload=(\d+)/, function(match, sep) { - return "" + sep + expando; - }); - if (params === oldParams) { - if (oldParams.length === 0) { - params = "?" + expando; - } else { - params = "" + oldParams + "&" + expando; - } - } - return url + params + hash; - }; - - return Reloader; - - })(); - -}).call(this); - -},{}],8:[function(require,module,exports){ -(function() { - var CustomEvents, LiveReload, k; - - CustomEvents = require('./customevents'); - - LiveReload = window.LiveReload = new (require('./livereload').LiveReload)(window); - - for (k in window) { - if (k.match(/^LiveReloadPlugin/)) { - LiveReload.addPlugin(window[k]); - } - } - - LiveReload.addPlugin(require('./less')); - - LiveReload.on('shutdown', function() { - return delete window.LiveReload; - }); - - LiveReload.on('connect', function() { - return CustomEvents.fire(document, 'LiveReloadConnect'); - }); - - LiveReload.on('disconnect', function() { - return CustomEvents.fire(document, 'LiveReloadDisconnect'); - }); - - CustomEvents.bind(document, 'LiveReloadShutDown', function() { - return LiveReload.shutDown(); - }); - -}).call(this); - -},{"./customevents":2,"./less":3,"./livereload":4}],9:[function(require,module,exports){ -(function() { - var Timer; - - exports.Timer = Timer = (function() { - function Timer(func) { - this.func = func; - this.running = false; - this.id = null; - this._handler = (function(_this) { - return function() { - _this.running = false; - _this.id = null; - return _this.func(); - }; - })(this); - } - - Timer.prototype.start = function(timeout) { - if (this.running) { - clearTimeout(this.id); - } - this.id = setTimeout(this._handler, timeout); - return this.running = true; - }; - - Timer.prototype.stop = function() { - if (this.running) { - clearTimeout(this.id); - this.running = false; - return this.id = null; - } - }; - - return Timer; - - })(); - - Timer.start = function(timeout, func) { - return setTimeout(func, timeout); - }; - -}).call(this); - -},{}]},{},[8]); diff --git a/docs/static/js/owl.carousel-custom.js b/docs/static/js/owl.carousel-custom.js deleted file mode 100644 index 685c8e361..000000000 --- a/docs/static/js/owl.carousel-custom.js +++ /dev/null @@ -1,16 +0,0 @@ -$('.owl-carousel').owlCarousel({ - loop:true, - margin:10, - nav:true, - autoplay:true, - autoplayHoverPause:true, - autoplayTimeout:3000, - responsive:{ - 0:{ - items:1 - }, - 1000:{ - items:3 - }, - } -}) diff --git a/docs/static/js/scripts.js b/docs/static/js/scripts.js deleted file mode 100644 index ef6074f55..000000000 --- a/docs/static/js/scripts.js +++ /dev/null @@ -1,283 +0,0 @@ -function initializeJS() { - - //tool tips - jQuery('.tooltips').tooltip(); - - //popovers - jQuery('.popovers').popover(); - - //sidebar dropdown menu - jQuery('#sidebar .sub-menu > a').click(function () { - // Close previous open submenu - var last = jQuery('.sub.open', jQuery('#sidebar')); - jQuery(last).slideUp(200); - jQuery(last).removeClass("open"); - jQuery('.menu-arrow', jQuery(last).parent()).addClass('fa-angle-right'); - jQuery('.menu-arrow', jQuery(last).parent()).removeClass('fa-angle-down'); - - // Toggle current submenu - var sub = jQuery(this).next(); - if (sub.is(":visible")) { - jQuery('.menu-arrow', this).addClass('fa-angle-right'); - jQuery('.menu-arrow', this).removeClass('fa-angle-down'); - sub.slideUp(200); - jQuery(sub).removeClass("open") - } else { - jQuery('.menu-arrow', this).addClass('fa-angle-down'); - jQuery('.menu-arrow', this).removeClass('fa-angle-right'); - sub.slideDown(200); - jQuery(sub).addClass("open") - } - - // Center menu on screen - var o = (jQuery(this).offset()); - diff = 200 - o.top; - if(diff>0) - jQuery("#sidebar").scrollTo("-="+Math.abs(diff),500); - else - jQuery("#sidebar").scrollTo("+="+Math.abs(diff),500); - }); - - - // sidebar menu toggle - jQuery(function() { - function responsiveView() { - var wSize = jQuery(window).width(); - if (wSize <= 768) { - jQuery('#container').addClass('sidebar-close'); - jQuery('#sidebar > ul').hide(); - } - - if (wSize > 768) { - jQuery('#container').removeClass('sidebar-close'); - jQuery('#sidebar > ul').show(); - } - } - jQuery(window).on('load', responsiveView); - jQuery(window).on('resize', responsiveView); - }); - - jQuery('.toggle-nav').click(function () { - if (jQuery('#sidebar > ul').is(":visible") === true) { - jQuery('#main-content').css({ - 'margin-left': '0px' - }); - jQuery('#sidebar').css({ - 'margin-left': '-180px' - }); - jQuery('#sidebar > ul').hide(); - jQuery("#container").addClass("sidebar-closed"); - } else { - jQuery('#main-content').css({ - 'margin-left': '180px' - }); - jQuery('#sidebar > ul').show(); - jQuery('#sidebar').css({ - 'margin-left': '0' - }); - jQuery("#container").removeClass("sidebar-closed"); - } - }); - - //bar chart - if (jQuery(".custom-custom-bar-chart")) { - jQuery(".bar").each(function () { - var i = jQuery(this).find(".value").html(); - jQuery(this).find(".value").html(""); - jQuery(this).find(".value").animate({ - height: i - }, 2000) - }) - } - -} - -(function(){ - var caches = {}; - $.fn.showGithub = function(user, repo, type, count){ - $(this).each(function(){ - var $e = $(this); - var user = $e.data('user') || user, - repo = $e.data('repo') || repo, - type = $e.data('type') || type || 'watch', - count = $e.data('count') == 'true' || count || true; - var $mainButton = $e.html(' ').find('.github-btn'), - $button = $mainButton.find('.btn'), - $text = $mainButton.find('.gh-text'), - $counter = $mainButton.find('.gh-count'); - - function addCommas(a) { - return String(a).replace(/(\d)(?=(\d{3})+$)/g, '$1,'); - } - - function callback(a) { - if (type == 'watch') { - $counter.html(addCommas(a.watchers)); - } else { - if (type == 'fork') { - $counter.html(addCommas(a.forks)); - } else { - if (type == 'follow') { - $counter.html(addCommas(a.followers)); - } - } - } - - if (count) { - $counter.css('display', 'inline-block'); - } - } - - function jsonp(url) { - var ctx = caches[url] || {}; - caches[url] = ctx; - if(ctx.onload || ctx.data){ - if(ctx.data){ - callback(ctx.data); - } else { - setTimeout(jsonp, 500, url); - } - }else{ - ctx.onload = true; - $.getJSON(url, function(a){ - ctx.onload = false; - ctx.data = a; - callback(a); - }); - } - } - - var urlBase = 'https://github.com/' + user + '/' + repo; - - $button.attr('href', urlBase + '/'); - - if (type == 'watch') { - $mainButton.addClass('github-watchers'); - $text.html('Star'); - $counter.attr('href', urlBase + '/stargazers'); - } else { - if (type == 'fork') { - $mainButton.addClass('github-forks'); - $text.html('Fork'); - $counter.attr('href', urlBase + '/network'); - } else { - if (type == 'follow') { - $mainButton.addClass('github-me'); - $text.html('Follow @' + user); - $button.attr('href', 'https://github.com/' + user); - $counter.attr('href', 'https://github.com/' + user + '/followers'); - } - } - } - - if (type == 'follow') { - jsonp('https://api.github.com/users/' + user); - } else { - jsonp('https://api.github.com/repos/' + user + '/' + repo); - } - - }); - }; - - })(); - - -(function($){ - (function(){ - var caches = {}; - $.fn.showGithub = function(user, repo, type, count){ - - $(this).each(function(){ - var $e = $(this); - - var user = $e.data('user') || user, - repo = $e.data('repo') || repo, - type = $e.data('type') || type || 'watch', - count = $e.data('count') == 'true' || count || true; - - var $mainButton = $e.html(' ').find('.github-btn'), - $button = $mainButton.find('.btn'), - $text = $mainButton.find('.gh-text'), - $counter = $mainButton.find('.gh-count'); - - function addCommas(a) { - return String(a).replace(/(\d)(?=(\d{3})+$)/g, '$1,'); - } - - function callback(a) { - if (type == 'watch') { - $counter.html(addCommas(a.watchers)); - } else { - if (type == 'fork') { - $counter.html(addCommas(a.forks)); - } else { - if (type == 'follow') { - $counter.html(addCommas(a.followers)); - } - } - } - - if (count) { - $counter.css('display', 'inline-block'); - } - } - - function jsonp(url) { - var ctx = caches[url] || {}; - caches[url] = ctx; - if(ctx.onload || ctx.data){ - if(ctx.data){ - callback(ctx.data); - } else { - setTimeout(jsonp, 500, url); - } - }else{ - ctx.onload = true; - $.getJSON(url, function(a){ - ctx.onload = false; - ctx.data = a; - callback(a); - }); - } - } - - var urlBase = 'https://github.com/' + user + '/' + repo; - - $button.attr('href', urlBase + '/'); - - if (type == 'watch') { - $mainButton.addClass('github-watchers'); - $text.html('Star'); - $counter.attr('href', urlBase + '/stargazers'); - } else { - if (type == 'fork') { - $mainButton.addClass('github-forks'); - $text.html('Fork'); - $counter.attr('href', urlBase + '/network'); - } else { - if (type == 'follow') { - $mainButton.addClass('github-me'); - $text.html('@' + user); - $button.attr('href', 'https://github.com/' + user); - $counter.attr('href', 'https://github.com/' + user + '/followers'); - } - } - } - - if (type == 'follow') { - jsonp('https://api.github.com/users/' + user); - } else { - jsonp('https://api.github.com/repos/' + user + '/' + repo); - } - - }); - }; - - })(); -})(jQuery); - -jQuery(document).ready(function(){ - initializeJS(); - $('[rel=show-github]').showGithub(); -}); - diff --git a/docs/themes/gohugoioTheme/static/manifest.json b/docs/static/manifest.json similarity index 100% rename from docs/themes/gohugoioTheme/static/manifest.json rename to docs/static/manifest.json diff --git a/docs/themes/gohugoioTheme/static/mstile-144x144.png b/docs/static/mstile-144x144.png similarity index 100% rename from docs/themes/gohugoioTheme/static/mstile-144x144.png rename to docs/static/mstile-144x144.png diff --git a/docs/themes/gohugoioTheme/static/mstile-150x150.png b/docs/static/mstile-150x150.png similarity index 100% rename from docs/themes/gohugoioTheme/static/mstile-150x150.png rename to docs/static/mstile-150x150.png diff --git a/docs/themes/gohugoioTheme/static/mstile-310x310.png b/docs/static/mstile-310x310.png similarity index 100% rename from docs/themes/gohugoioTheme/static/mstile-310x310.png rename to docs/static/mstile-310x310.png diff --git a/docs/static/quickstart/bookshelf-bleak-theme.png b/docs/static/quickstart/bookshelf-bleak-theme.png deleted file mode 100755 index ccd18c42d..000000000 Binary files a/docs/static/quickstart/bookshelf-bleak-theme.png and /dev/null differ diff --git a/docs/static/quickstart/bookshelf-disqus.png b/docs/static/quickstart/bookshelf-disqus.png deleted file mode 100755 index 3ce645a0c..000000000 Binary files a/docs/static/quickstart/bookshelf-disqus.png and /dev/null differ diff --git a/docs/static/quickstart/bookshelf-new-default-image.png b/docs/static/quickstart/bookshelf-new-default-image.png deleted file mode 100755 index d7274c7a6..000000000 Binary files a/docs/static/quickstart/bookshelf-new-default-image.png and /dev/null differ diff --git a/docs/static/quickstart/bookshelf-only-picture.png b/docs/static/quickstart/bookshelf-only-picture.png deleted file mode 100755 index a363383bc..000000000 Binary files a/docs/static/quickstart/bookshelf-only-picture.png and /dev/null differ diff --git a/docs/static/quickstart/bookshelf-robust-theme.png b/docs/static/quickstart/bookshelf-robust-theme.png deleted file mode 100755 index 7c5e6b8d2..000000000 Binary files a/docs/static/quickstart/bookshelf-robust-theme.png and /dev/null differ diff --git a/docs/static/quickstart/bookshelf-updated-config.png b/docs/static/quickstart/bookshelf-updated-config.png deleted file mode 100755 index bbda606c7..000000000 Binary files a/docs/static/quickstart/bookshelf-updated-config.png and /dev/null differ diff --git a/docs/static/quickstart/bookshelf.png b/docs/static/quickstart/bookshelf.png deleted file mode 100755 index 3b572adbb..000000000 Binary files a/docs/static/quickstart/bookshelf.png and /dev/null differ diff --git a/docs/static/quickstart/default.jpg b/docs/static/quickstart/default.jpg deleted file mode 100755 index 78d7bd28e..000000000 Binary files a/docs/static/quickstart/default.jpg and /dev/null differ diff --git a/docs/static/quickstart/gh-pages-ui.png b/docs/static/quickstart/gh-pages-ui.png deleted file mode 100644 index 3a7d10305..000000000 Binary files a/docs/static/quickstart/gh-pages-ui.png and /dev/null differ diff --git a/docs/themes/gohugoioTheme/static/safari-pinned-tab.svg b/docs/static/safari-pinned-tab.svg similarity index 100% rename from docs/themes/gohugoioTheme/static/safari-pinned-tab.svg rename to docs/static/safari-pinned-tab.svg diff --git a/docs/static/share/hugo-tall.png b/docs/static/share/hugo-tall.png deleted file mode 100644 index 001ce5eb3..000000000 Binary files a/docs/static/share/hugo-tall.png and /dev/null differ diff --git a/docs/static/share/made-with-hugo-dark.png b/docs/static/share/made-with-hugo-dark.png deleted file mode 100644 index c6cadf283..000000000 Binary files a/docs/static/share/made-with-hugo-dark.png and /dev/null differ diff --git a/docs/static/share/made-with-hugo-long-dark.png b/docs/static/share/made-with-hugo-long-dark.png deleted file mode 100644 index 1e49995fb..000000000 Binary files a/docs/static/share/made-with-hugo-long-dark.png and /dev/null differ diff --git a/docs/static/share/made-with-hugo-long.png b/docs/static/share/made-with-hugo-long.png deleted file mode 100644 index c5df534cf..000000000 Binary files a/docs/static/share/made-with-hugo-long.png and /dev/null differ diff --git a/docs/static/share/made-with-hugo.png b/docs/static/share/made-with-hugo.png deleted file mode 100644 index 52dfd19e5..000000000 Binary files a/docs/static/share/made-with-hugo.png and /dev/null differ diff --git a/docs/static/share/powered-by-hugo-dark.png b/docs/static/share/powered-by-hugo-dark.png deleted file mode 100644 index a8e2ebc80..000000000 Binary files a/docs/static/share/powered-by-hugo-dark.png and /dev/null differ diff --git a/docs/static/share/powered-by-hugo-long-dark.png b/docs/static/share/powered-by-hugo-long-dark.png deleted file mode 100644 index 1b760b1bf..000000000 Binary files a/docs/static/share/powered-by-hugo-long-dark.png and /dev/null differ diff --git a/docs/static/share/powered-by-hugo-long.png b/docs/static/share/powered-by-hugo-long.png deleted file mode 100644 index 37131359d..000000000 Binary files a/docs/static/share/powered-by-hugo-long.png and /dev/null differ diff --git a/docs/static/share/powered-by-hugo.png b/docs/static/share/powered-by-hugo.png deleted file mode 100644 index 27ff099d5..000000000 Binary files a/docs/static/share/powered-by-hugo.png and /dev/null differ diff --git a/docs/static/shared/examples/data/books.json b/docs/static/shared/examples/data/books.json new file mode 100644 index 000000000..ae2f36db2 --- /dev/null +++ b/docs/static/shared/examples/data/books.json @@ -0,0 +1,55 @@ +[ + { + "author": "Victor Hugo", + "cover": "https://gohugo.io/shared/examples/images/the-hunchback-of-notre-dame.webp", + "date": "2024-05-06", + "isbn": "978-0140443530", + "rating": 4, + "summary": "In the vaulted Gothic towers of **Notre-Dame Cathedral** lives Quasimodo, the hunchbacked bellringer. Mocked and shunned for his appearance, he is pitied only by Esmerelda, a beautiful gypsy dancer to whom he becomes completely devoted. Esmerelda, however, has also attracted the attention of the sinister archdeacon Claude Frollo, and when she rejects his lecherous approaches, Frollo hatches a plot to destroy her, that only Quasimodo can prevent. Victor Hugo's sensational, evocative novel brings life to the medieval Paris he loved, and mourns its passing in one of the greatest historical romances of the nineteenth century.", + "tags": [ + "fiction", + "historical" + ], + "title": "The Hunchback of Notre Dame" + }, + { + "author": "Victor Hugo", + "cover": "https://gohugo.io/shared/examples/images/les-misérables.webp", + "date": "2022-12-30", + "isbn": "978-0451419439", + "rating": 5, + "summary": "Introducing one of the most **famous characters** in literature, Jean Valjean—the noble peasant imprisoned for stealing a loaf of bread—Les Misérables ranks among the greatest novels of all time. In it, Victor Hugo takes readers deep into the Parisian underworld, immerses them in a battle between good and evil, and carries them to the barricades during the uprising of 1832 with a breathtaking realism that is unsurpassed in modern prose.", + "tags": [ + "fiction", + "historical", + "revolution" + ], + "title": "Les Misérables" + }, + { + "author": "Alexis de Tocqueville", + "cover": "https://gohugo.io/shared/examples/images/the-ancien-régime-and-the-revolution.webp", + "date": "2023-04-01", + "isbn": "978-0141441641", + "rating": 3, + "summary": "The Ancien Régime and the Revolution is a comparison of **revolutionary France** and the despotic rule it toppled. Alexis de Tocqueville (1805–59) is an objective observer of both periods – providing a merciless critique of the ancien régime, with its venality, oppression and inequality, yet acknowledging the reforms introduced under Louis XVI, and claiming that the post-Revolution state was in many ways as tyrannical as that of the King; its once lofty and egalitarian ideals corrupted and forgotten. Writing in the 1850s, Tocqueville wished to expose the return to despotism he witnessed in his own time under Napoleon III, by illuminating the grand, but ultimately doomed, call to liberty made by the French people in 1789. His eloquent and instructive study raises questions about liberty, nationalism and justice that remain urgent today.", + "tags": [ + "nonfiction", + "revolution" + ], + "title": "The Ancien Régime and the Revolution" + }, + { + "author": "François Furet", + "cover": "https://gohugo.io/shared/examples/images/interpreting-the-french-revolution.webp", + "date": "2024-01-12", + "isbn": "978-0521280495", + "rating": 5, + "summary": "The French Revolution is an historical event **unlike any other**. It is more than just a topic of intellectual interest: it has become part of a moral and political heritage. But after two centuries, this central event in French history has usually been thought of in much the same terms as it was by its contemporaries. There have been many accounts of the French Revolution, and though their opinions differ, they have often been commemorative or anniversary interpretations of the original event. The dividing line of revolutionary historiography, in intellectual terms, is therefore not between the right and the left, but between commemorative and conceptual history, as exemplified respectively in the works of Michelet and Tocquevifle. In this book, François Furet analyses how an event like the French Revolution can be conceptualised, and identifies the radically new changes the Revolution produced as well as the continuity it provided, albeit under the appearance of change. This question has become a riddle for the European left, answered neither by Marx nor by the theorists of our own century. In his analysis of the tragic relevance of the Revolution, Furet both refers to contemporary experience and discusses various elements in the work of Alexis de Tocclueville and that of Augustin Cochin, which has never been systematically applied by historians of the Revolution. Furet's book is based on the complementary ideas of these two writers in an attempt to cut through the apparent and misleading clarity of various contradictory views of the Revolution, and to help decipher some of the enigmatic problems of revolutionary ideology. It will be of value to historians of modern Europe and their students; to political, social and economic historians; to sociologists; and to students of political thought.", + "tags": [ + "nonfiction", + "revolution" + ], + "title": "Interpreting the French Revolution" + } +] diff --git a/docs/static/shared/examples/images/interpreting-the-french-revolution.webp b/docs/static/shared/examples/images/interpreting-the-french-revolution.webp new file mode 100644 index 000000000..4004b6613 Binary files /dev/null and b/docs/static/shared/examples/images/interpreting-the-french-revolution.webp differ diff --git a/docs/static/shared/examples/images/les-misérables.webp b/docs/static/shared/examples/images/les-misérables.webp new file mode 100644 index 000000000..e336a5f16 Binary files /dev/null and b/docs/static/shared/examples/images/les-misérables.webp differ diff --git a/docs/static/shared/examples/images/the-ancien-régime-and-the-revolution.webp b/docs/static/shared/examples/images/the-ancien-régime-and-the-revolution.webp new file mode 100644 index 000000000..f35183945 Binary files /dev/null and b/docs/static/shared/examples/images/the-ancien-régime-and-the-revolution.webp differ diff --git a/docs/static/shared/examples/images/the-hunchback-of-notre-dame.webp b/docs/static/shared/examples/images/the-hunchback-of-notre-dame.webp new file mode 100644 index 000000000..f13e2224a Binary files /dev/null and b/docs/static/shared/examples/images/the-hunchback-of-notre-dame.webp differ diff --git a/docs/static/vendor/OwlCarousel2/LICENSE b/docs/static/vendor/OwlCarousel2/LICENSE deleted file mode 100644 index 7162d578b..000000000 --- a/docs/static/vendor/OwlCarousel2/LICENSE +++ /dev/null @@ -1,22 +0,0 @@ -Copyright (c) 2014 Owl - -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. diff --git a/docs/static/vendor/OwlCarousel2/css/owl.carousel.css b/docs/static/vendor/OwlCarousel2/css/owl.carousel.css deleted file mode 100644 index aaf80dd13..000000000 --- a/docs/static/vendor/OwlCarousel2/css/owl.carousel.css +++ /dev/null @@ -1,179 +0,0 @@ -/* - * Owl Carousel - Animate Plugin - */ -.owl-carousel .animated { - -webkit-animation-duration: 1000ms; - animation-duration: 1000ms; - -webkit-animation-fill-mode: both; - animation-fill-mode: both; } -.owl-carousel .owl-animated-in { - z-index: 0; } -.owl-carousel .owl-animated-out { - z-index: 1; } -.owl-carousel .fadeOut { - -webkit-animation-name: fadeOut; - animation-name: fadeOut; } - -@-webkit-keyframes fadeOut { - 0% { - opacity: 1; } - - 100% { - opacity: 0; } } - -@keyframes fadeOut { - 0% { - opacity: 1; } - - 100% { - opacity: 0; } } - -/* - * Owl Carousel - Auto Height Plugin - */ -.owl-height { - -webkit-transition: height 500ms ease-in-out; - -moz-transition: height 500ms ease-in-out; - -ms-transition: height 500ms ease-in-out; - -o-transition: height 500ms ease-in-out; - transition: height 500ms ease-in-out; } - -/* - * Core Owl Carousel CSS File - */ -.owl-carousel { - display: none; - width: 100%; - -webkit-tap-highlight-color: transparent; - /* position relative and z-index fix webkit rendering fonts issue */ - position: relative; - z-index: 1; } - .owl-carousel .owl-stage { - position: relative; - -ms-touch-action: pan-Y; } - .owl-carousel .owl-stage:after { - content: "."; - display: block; - clear: both; - visibility: hidden; - line-height: 0; - height: 0; } - .owl-carousel .owl-stage-outer { - position: relative; - overflow: hidden; - /* fix for flashing background */ - -webkit-transform: translate3d(0px, 0px, 0px); } - .owl-carousel .owl-item { - position: relative; - min-height: 1px; - float: left; - -webkit-backface-visibility: hidden; - -webkit-tap-highlight-color: transparent; - -webkit-touch-callout: none; } - .owl-carousel .owl-item img { - display: block; - width: 100%; - -webkit-transform-style: preserve-3d; } - .owl-carousel .owl-nav.disabled, .owl-carousel .owl-dots.disabled { - display: none; } - .owl-carousel .owl-nav .owl-prev, .owl-carousel .owl-nav .owl-next, .owl-carousel .owl-dot { - cursor: pointer; - cursor: hand; - -webkit-user-select: none; - -khtml-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; } - .owl-carousel.owl-loaded { - display: block; } - .owl-carousel.owl-loading { - opacity: 0; - display: block; } - .owl-carousel.owl-hidden { - opacity: 0; } - .owl-carousel.owl-refresh .owl-item { - display: none; } - .owl-carousel.owl-drag .owl-item { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; } - .owl-carousel.owl-grab { - cursor: move; - cursor: -webkit-grab; - cursor: -o-grab; - cursor: -ms-grab; - cursor: grab; } - .owl-carousel.owl-rtl { - direction: rtl; } - .owl-carousel.owl-rtl .owl-item { - float: right; } - -/* No Js */ -.no-js .owl-carousel { - display: block; } - -/* - * Owl Carousel - Lazy Load Plugin - */ -.owl-carousel .owl-item .owl-lazy { - opacity: 0; - -webkit-transition: opacity 400ms ease; - -moz-transition: opacity 400ms ease; - -ms-transition: opacity 400ms ease; - -o-transition: opacity 400ms ease; - transition: opacity 400ms ease; } -.owl-carousel .owl-item img { - transform-style: preserve-3d; } - -/* - * Owl Carousel - Video Plugin - */ -.owl-carousel .owl-video-wrapper { - position: relative; - height: 100%; - background: #000; } -.owl-carousel .owl-video-play-icon { - position: absolute; - height: 80px; - width: 80px; - left: 50%; - top: 50%; - margin-left: -40px; - margin-top: -40px; - background: url("owl.video.play.png") no-repeat; - cursor: pointer; - z-index: 1; - -webkit-backface-visibility: hidden; - -webkit-transition: scale 100ms ease; - -moz-transition: scale 100ms ease; - -ms-transition: scale 100ms ease; - -o-transition: scale 100ms ease; - transition: scale 100ms ease; } -.owl-carousel .owl-video-play-icon:hover { - -webkit-transition: scale(1.3, 1.3); - -moz-transition: scale(1.3, 1.3); - -ms-transition: scale(1.3, 1.3); - -o-transition: scale(1.3, 1.3); - transition: scale(1.3, 1.3); } -.owl-carousel .owl-video-playing .owl-video-tn, .owl-carousel .owl-video-playing .owl-video-play-icon { - display: none; } -.owl-carousel .owl-video-tn { - opacity: 0; - height: 100%; - background-position: center center; - background-repeat: no-repeat; - -webkit-background-size: contain; - -moz-background-size: contain; - -o-background-size: contain; - background-size: contain; - -webkit-transition: opacity 400ms ease; - -moz-transition: opacity 400ms ease; - -ms-transition: opacity 400ms ease; - -o-transition: opacity 400ms ease; - transition: opacity 400ms ease; } -.owl-carousel .owl-video-frame { - position: relative; - z-index: 1; - height: 100%; - width: 100%; } diff --git a/docs/static/vendor/OwlCarousel2/css/owl.theme.default.css b/docs/static/vendor/OwlCarousel2/css/owl.theme.default.css deleted file mode 100644 index dcd4c82ae..000000000 --- a/docs/static/vendor/OwlCarousel2/css/owl.theme.default.css +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Default theme - Owl Carousel CSS File - */ -.owl-theme .owl-nav { - margin-top: 10px; - text-align: center; - -webkit-tap-highlight-color: transparent; } - .owl-theme .owl-nav [class*='owl-'] { - color: #FFF; - font-size: 14px; - margin: 5px; - padding: 4px 7px; - background: #D6D6D6; - display: inline-block; - cursor: pointer; - -webkit-border-radius: 3px; - -moz-border-radius: 3px; - border-radius: 3px; } - .owl-theme .owl-nav [class*='owl-']:hover { - background: #869791; - color: #FFF; - text-decoration: none; } - .owl-theme .owl-nav .disabled { - opacity: 0.5; - cursor: default; } -.owl-theme .owl-nav.disabled + .owl-dots { - margin-top: 10px; } -.owl-theme .owl-dots { - text-align: center; - -webkit-tap-highlight-color: transparent; } - .owl-theme .owl-dots .owl-dot { - display: inline-block; - zoom: 1; - *display: inline; } - .owl-theme .owl-dots .owl-dot span { - width: 10px; - height: 10px; - margin: 5px 7px; - background: #D6D6D6; - display: block; - -webkit-backface-visibility: visible; - -webkit-transition: opacity 200ms ease; - -moz-transition: opacity 200ms ease; - -ms-transition: opacity 200ms ease; - -o-transition: opacity 200ms ease; - transition: opacity 200ms ease; - -webkit-border-radius: 30px; - -moz-border-radius: 30px; - border-radius: 30px; } - .owl-theme .owl-dots .owl-dot.active span, .owl-theme .owl-dots .owl-dot:hover span { - background: #869791; } diff --git a/docs/static/vendor/OwlCarousel2/js/owl.carousel.min.js b/docs/static/vendor/OwlCarousel2/js/owl.carousel.min.js deleted file mode 100644 index cd327896d..000000000 --- a/docs/static/vendor/OwlCarousel2/js/owl.carousel.min.js +++ /dev/null @@ -1,2 +0,0 @@ -!function(a,b,c,d){function e(b,c){this.settings=null,this.options=a.extend({},e.Defaults,c),this.$element=a(b),this._handlers={},this._plugins={},this._supress={},this._current=null,this._speed=null,this._coordinates=[],this._breakpoint=null,this._width=null,this._items=[],this._clones=[],this._mergers=[],this._widths=[],this._invalidated={},this._pipe=[],this._drag={time:null,target:null,pointer:null,stage:{start:null,current:null},direction:null},this._states={current:{},tags:{initializing:["busy"],animating:["busy"],dragging:["interacting"]}},a.each(["onResize","onThrottledResize"],a.proxy(function(b,c){this._handlers[c]=a.proxy(this[c],this)},this)),a.each(e.Plugins,a.proxy(function(a,b){this._plugins[a.charAt(0).toLowerCase()+a.slice(1)]=new b(this)},this)),a.each(e.Workers,a.proxy(function(b,c){this._pipe.push({filter:c.filter,run:a.proxy(c.run,this)})},this)),this.setup(),this.initialize()}e.Defaults={items:3,loop:!1,center:!1,rewind:!1,mouseDrag:!0,touchDrag:!0,pullDrag:!0,freeDrag:!1,margin:0,stagePadding:0,merge:!1,mergeFit:!0,autoWidth:!1,startPosition:0,rtl:!1,smartSpeed:250,fluidSpeed:!1,dragEndSpeed:!1,responsive:{},responsiveRefreshRate:200,responsiveBaseElement:b,fallbackEasing:"swing",info:!1,nestedItemSelector:!1,itemElement:"div",stageElement:"div",refreshClass:"owl-refresh",loadedClass:"owl-loaded",loadingClass:"owl-loading",rtlClass:"owl-rtl",responsiveClass:"owl-responsive",dragClass:"owl-drag",itemClass:"owl-item",stageClass:"owl-stage",stageOuterClass:"owl-stage-outer",grabClass:"owl-grab"},e.Width={Default:"default",Inner:"inner",Outer:"outer"},e.Type={Event:"event",State:"state"},e.Plugins={},e.Workers=[{filter:["width","settings"],run:function(){this._width=this.$element.width()}},{filter:["width","items","settings"],run:function(a){a.current=this._items&&this._items[this.relative(this._current)]}},{filter:["items","settings"],run:function(){this.$stage.children(".cloned").remove()}},{filter:["width","items","settings"],run:function(a){var b=this.settings.margin||"",c=!this.settings.autoWidth,d=this.settings.rtl,e={width:"auto","margin-left":d?b:"","margin-right":d?"":b};!c&&this.$stage.children().css(e),a.css=e}},{filter:["width","items","settings"],run:function(a){var b=(this.width()/this.settings.items).toFixed(3)-this.settings.margin,c=null,d=this._items.length,e=!this.settings.autoWidth,f=[];for(a.items={merge:!1,width:b};d--;)c=this._mergers[d],c=this.settings.mergeFit&&Math.min(c,this.settings.items)||c,a.items.merge=c>1||a.items.merge,f[d]=e?b*c:this._items[d].width();this._widths=f}},{filter:["items","settings"],run:function(){var b=[],c=this._items,d=this.settings,e=Math.max(2*d.items,4),f=2*Math.ceil(c.length/2),g=d.loop&&c.length?d.rewind?e:Math.max(e,f):0,h="",i="";for(g/=2;g--;)b.push(this.normalize(b.length/2,!0)),h+=c[b[b.length-1]][0].outerHTML,b.push(this.normalize(c.length-1-(b.length-1)/2,!0)),i=c[b[b.length-1]][0].outerHTML+i;this._clones=b,a(h).addClass("cloned").appendTo(this.$stage),a(i).addClass("cloned").prependTo(this.$stage)}},{filter:["width","items","settings"],run:function(){for(var a=this.settings.rtl?1:-1,b=this._clones.length+this._items.length,c=-1,d=0,e=0,f=[];++cc;c++)a=this._coordinates[c-1]||0,b=Math.abs(this._coordinates[c])+f*e,(this.op(a,"<=",g)&&this.op(a,">",h)||this.op(b,"<",g)&&this.op(b,">",h))&&i.push(c);this.$stage.children(".active").removeClass("active"),this.$stage.children(":eq("+i.join("), :eq(")+")").addClass("active"),this.settings.center&&(this.$stage.children(".center").removeClass("center"),this.$stage.children().eq(this.current()).addClass("center"))}}],e.prototype.initialize=function(){if(this.enter("initializing"),this.trigger("initialize"),this.$element.toggleClass(this.settings.rtlClass,this.settings.rtl),this.settings.autoWidth&&!this.is("pre-loading")){var b,c,e;b=this.$element.find("img"),c=this.settings.nestedItemSelector?"."+this.settings.nestedItemSelector:d,e=this.$element.children(c).width(),b.length&&0>=e&&this.preloadAutoWidthImages(b)}this.$element.addClass(this.options.loadingClass),this.$stage=a("<"+this.settings.stageElement+' class="'+this.settings.stageClass+'"/>').wrap('
    '),this.$element.append(this.$stage.parent()),this.replace(this.$element.children().not(this.$stage.parent())),this.$element.is(":visible")?this.refresh():this.invalidate("width"),this.$element.removeClass(this.options.loadingClass).addClass(this.options.loadedClass),this.registerEventHandlers(),this.leave("initializing"),this.trigger("initialized")},e.prototype.setup=function(){var b=this.viewport(),c=this.options.responsive,d=-1,e=null;c?(a.each(c,function(a){b>=a&&a>d&&(d=Number(a))}),e=a.extend({},this.options,c[d]),delete e.responsive,e.responsiveClass&&this.$element.attr("class",this.$element.attr("class").replace(new RegExp("("+this.options.responsiveClass+"-)\\S+\\s","g"),"$1"+d))):e=a.extend({},this.options),(null===this.settings||this._breakpoint!==d)&&(this.trigger("change",{property:{name:"settings",value:e}}),this._breakpoint=d,this.settings=e,this.invalidate("settings"),this.trigger("changed",{property:{name:"settings",value:this.settings}}))},e.prototype.optionsLogic=function(){this.settings.autoWidth&&(this.settings.stagePadding=!1,this.settings.merge=!1)},e.prototype.prepare=function(b){var c=this.trigger("prepare",{content:b});return c.data||(c.data=a("<"+this.settings.itemElement+"/>").addClass(this.options.itemClass).append(b)),this.trigger("prepared",{content:c.data}),c.data},e.prototype.update=function(){for(var b=0,c=this._pipe.length,d=a.proxy(function(a){return this[a]},this._invalidated),e={};c>b;)(this._invalidated.all||a.grep(this._pipe[b].filter,d).length>0)&&this._pipe[b].run(e),b++;this._invalidated={},!this.is("valid")&&this.enter("valid")},e.prototype.width=function(a){switch(a=a||e.Width.Default){case e.Width.Inner:case e.Width.Outer:return this._width;default:return this._width-2*this.settings.stagePadding+this.settings.margin}},e.prototype.refresh=function(){this.enter("refreshing"),this.trigger("refresh"),this.setup(),this.optionsLogic(),this.$element.addClass(this.options.refreshClass),this.update(),this.$element.removeClass(this.options.refreshClass),this.leave("refreshing"),this.trigger("refreshed")},e.prototype.onThrottledResize=function(){b.clearTimeout(this.resizeTimer),this.resizeTimer=b.setTimeout(this._handlers.onResize,this.settings.responsiveRefreshRate)},e.prototype.onResize=function(){return this._items.length?this._width===this.$element.width()?!1:this.$element.is(":visible")?(this.enter("resizing"),this.trigger("resize").isDefaultPrevented()?(this.leave("resizing"),!1):(this.invalidate("width"),this.refresh(),this.leave("resizing"),void this.trigger("resized"))):!1:!1},e.prototype.registerEventHandlers=function(){a.support.transition&&this.$stage.on(a.support.transition.end+".owl.core",a.proxy(this.onTransitionEnd,this)),this.settings.responsive!==!1&&this.on(b,"resize",this._handlers.onThrottledResize),this.settings.mouseDrag&&(this.$element.addClass(this.options.dragClass),this.$stage.on("mousedown.owl.core",a.proxy(this.onDragStart,this)),this.$stage.on("dragstart.owl.core selectstart.owl.core",function(){return!1})),this.settings.touchDrag&&(this.$stage.on("touchstart.owl.core",a.proxy(this.onDragStart,this)),this.$stage.on("touchcancel.owl.core",a.proxy(this.onDragEnd,this)))},e.prototype.onDragStart=function(b){var d=null;3!==b.which&&(a.support.transform?(d=this.$stage.css("transform").replace(/.*\(|\)| /g,"").split(","),d={x:d[16===d.length?12:4],y:d[16===d.length?13:5]}):(d=this.$stage.position(),d={x:this.settings.rtl?d.left+this.$stage.width()-this.width()+this.settings.margin:d.left,y:d.top}),this.is("animating")&&(a.support.transform?this.animate(d.x):this.$stage.stop(),this.invalidate("position")),this.$element.toggleClass(this.options.grabClass,"mousedown"===b.type),this.speed(0),this._drag.time=(new Date).getTime(),this._drag.target=a(b.target),this._drag.stage.start=d,this._drag.stage.current=d,this._drag.pointer=this.pointer(b),a(c).on("mouseup.owl.core touchend.owl.core",a.proxy(this.onDragEnd,this)),a(c).one("mousemove.owl.core touchmove.owl.core",a.proxy(function(b){var d=this.difference(this._drag.pointer,this.pointer(b));a(c).on("mousemove.owl.core touchmove.owl.core",a.proxy(this.onDragMove,this)),Math.abs(d.x)0^this.settings.rtl?"left":"right";a(c).off(".owl.core"),this.$element.removeClass(this.options.grabClass),(0!==d.x&&this.is("dragging")||!this.is("valid"))&&(this.speed(this.settings.dragEndSpeed||this.settings.smartSpeed),this.current(this.closest(e.x,0!==d.x?f:this._drag.direction)),this.invalidate("position"),this.update(),this._drag.direction=f,(Math.abs(d.x)>3||(new Date).getTime()-this._drag.time>300)&&this._drag.target.one("click.owl.core",function(){return!1})),this.is("dragging")&&(this.leave("dragging"),this.trigger("dragged"))},e.prototype.closest=function(b,c){var d=-1,e=30,f=this.width(),g=this.coordinates();return this.settings.freeDrag||a.each(g,a.proxy(function(a,h){return b>h-e&&h+e>b?d=a:this.op(b,"<",h)&&this.op(b,">",g[a+1]||h-f)&&(d="left"===c?a+1:a),-1===d},this)),this.settings.loop||(this.op(b,">",g[this.minimum()])?d=b=this.minimum():this.op(b,"<",g[this.maximum()])&&(d=b=this.maximum())),d},e.prototype.animate=function(b){var c=this.speed()>0;this.is("animating")&&this.onTransitionEnd(),c&&(this.enter("animating"),this.trigger("translate")),a.support.transform3d&&a.support.transition?this.$stage.css({transform:"translate3d("+b+"px,0px,0px)",transition:this.speed()/1e3+"s"}):c?this.$stage.animate({left:b+"px"},this.speed(),this.settings.fallbackEasing,a.proxy(this.onTransitionEnd,this)):this.$stage.css({left:b+"px"})},e.prototype.is=function(a){return this._states.current[a]&&this._states.current[a]>0},e.prototype.current=function(a){if(a===d)return this._current;if(0===this._items.length)return d;if(a=this.normalize(a),this._current!==a){var b=this.trigger("change",{property:{name:"position",value:a}});b.data!==d&&(a=this.normalize(b.data)),this._current=a,this.invalidate("position"),this.trigger("changed",{property:{name:"position",value:this._current}})}return this._current},e.prototype.invalidate=function(b){return"string"===a.type(b)&&(this._invalidated[b]=!0,this.is("valid")&&this.leave("valid")),a.map(this._invalidated,function(a,b){return b})},e.prototype.reset=function(a){a=this.normalize(a),a!==d&&(this._speed=0,this._current=a,this.suppress(["translate","translated"]),this.animate(this.coordinates(a)),this.release(["translate","translated"]))},e.prototype.normalize=function(b,c){var e=this._items.length,f=c?0:this._clones.length;return!a.isNumeric(b)||1>e?b=d:(0>b||b>=e+f)&&(b=((b-f/2)%e+e)%e+f/2),b},e.prototype.relative=function(a){return a-=this._clones.length/2,this.normalize(a,!0)},e.prototype.maximum=function(a){var b,c=this.settings,d=this._coordinates.length,e=Math.abs(this._coordinates[d-1])-this._width,f=-1;if(c.loop)d=this._clones.length/2+this._items.length-1;else if(c.autoWidth||c.merge)for(;d-f>1;)Math.abs(this._coordinates[b=d+f>>1])0)-(0>e),g=this._items.length,h=this.minimum(),i=this.maximum();this.settings.loop?(!this.settings.rewind&&Math.abs(e)>g/2&&(e+=-1*f*g),a=c+e,d=((a-h)%g+g)%g+h,d!==a&&i>=d-e&&d-e>0&&(c=d-e,a=d,this.reset(c))):this.settings.rewind?(i+=1,a=(a%i+i)%i):a=Math.max(h,Math.min(i,a)),this.speed(this.duration(c,a,b)),this.current(a),this.$element.is(":visible")&&this.update()},e.prototype.next=function(a){a=a||!1,this.to(this.relative(this.current())+1,a)},e.prototype.prev=function(a){a=a||!1,this.to(this.relative(this.current())-1,a)},e.prototype.onTransitionEnd=function(a){return a!==d&&(a.stopPropagation(),(a.target||a.srcElement||a.originalTarget)!==this.$stage.get(0))?!1:(this.leave("animating"),void this.trigger("translated"))},e.prototype.viewport=function(){var d;if(this.options.responsiveBaseElement!==b)d=a(this.options.responsiveBaseElement).width();else if(b.innerWidth)d=b.innerWidth;else{if(!c.documentElement||!c.documentElement.clientWidth)throw"Can not detect viewport width.";d=c.documentElement.clientWidth}return d},e.prototype.replace=function(b){this.$stage.empty(),this._items=[],b&&(b=b instanceof jQuery?b:a(b)),this.settings.nestedItemSelector&&(b=b.find("."+this.settings.nestedItemSelector)),b.filter(function(){return 1===this.nodeType}).each(a.proxy(function(a,b){b=this.prepare(b),this.$stage.append(b),this._items.push(b),this._mergers.push(1*b.find("[data-merge]").andSelf("[data-merge]").attr("data-merge")||1)},this)),this.reset(a.isNumeric(this.settings.startPosition)?this.settings.startPosition:0),this.invalidate("items")},e.prototype.add=function(b,c){var e=this.relative(this._current);c=c===d?this._items.length:this.normalize(c,!0),b=b instanceof jQuery?b:a(b),this.trigger("add",{content:b,position:c}),b=this.prepare(b),0===this._items.length||c===this._items.length?(0===this._items.length&&this.$stage.append(b),0!==this._items.length&&this._items[c-1].after(b),this._items.push(b),this._mergers.push(1*b.find("[data-merge]").andSelf("[data-merge]").attr("data-merge")||1)):(this._items[c].before(b),this._items.splice(c,0,b),this._mergers.splice(c,0,1*b.find("[data-merge]").andSelf("[data-merge]").attr("data-merge")||1)),this._items[e]&&this.reset(this._items[e].index()),this.invalidate("items"),this.trigger("added",{content:b,position:c})},e.prototype.remove=function(a){a=this.normalize(a,!0),a!==d&&(this.trigger("remove",{content:this._items[a],position:a}),this._items[a].remove(),this._items.splice(a,1),this._mergers.splice(a,1),this.invalidate("items"),this.trigger("removed",{content:null,position:a}))},e.prototype.preloadAutoWidthImages=function(b){b.each(a.proxy(function(b,c){this.enter("pre-loading"),c=a(c),a(new Image).one("load",a.proxy(function(a){c.attr("src",a.target.src),c.css("opacity",1),this.leave("pre-loading"),!this.is("pre-loading")&&!this.is("initializing")&&this.refresh()},this)).attr("src",c.attr("src")||c.attr("data-src")||c.attr("data-src-retina"))},this))},e.prototype.destroy=function(){this.$element.off(".owl.core"),this.$stage.off(".owl.core"),a(c).off(".owl.core"),this.settings.responsive!==!1&&(b.clearTimeout(this.resizeTimer),this.off(b,"resize",this._handlers.onThrottledResize));for(var d in this._plugins)this._plugins[d].destroy();this.$stage.children(".cloned").remove(),this.$stage.unwrap(),this.$stage.children().contents().unwrap(),this.$stage.children().unwrap(),this.$element.removeClass(this.options.refreshClass).removeClass(this.options.loadingClass).removeClass(this.options.loadedClass).removeClass(this.options.rtlClass).removeClass(this.options.dragClass).removeClass(this.options.grabClass).attr("class",this.$element.attr("class").replace(new RegExp(this.options.responsiveClass+"-\\S+\\s","g"),"")).removeData("owl.carousel")},e.prototype.op=function(a,b,c){var d=this.settings.rtl;switch(b){case"<":return d?a>c:c>a;case">":return d?c>a:a>c;case">=":return d?c>=a:a>=c;case"<=":return d?a>=c:c>=a}},e.prototype.on=function(a,b,c,d){a.addEventListener?a.addEventListener(b,c,d):a.attachEvent&&a.attachEvent("on"+b,c)},e.prototype.off=function(a,b,c,d){a.removeEventListener?a.removeEventListener(b,c,d):a.detachEvent&&a.detachEvent("on"+b,c)},e.prototype.trigger=function(b,c,d,f,g){var h={item:{count:this._items.length,index:this.current()}},i=a.camelCase(a.grep(["on",b,d],function(a){return a}).join("-").toLowerCase()),j=a.Event([b,"owl",d||"carousel"].join(".").toLowerCase(),a.extend({relatedTarget:this},h,c));return this._supress[b]||(a.each(this._plugins,function(a,b){b.onTrigger&&b.onTrigger(j)}),this.register({type:e.Type.Event,name:b}),this.$element.trigger(j),this.settings&&"function"==typeof this.settings[i]&&this.settings[i].call(this,j)),j},e.prototype.enter=function(b){a.each([b].concat(this._states.tags[b]||[]),a.proxy(function(a,b){this._states.current[b]===d&&(this._states.current[b]=0),this._states.current[b]++},this))},e.prototype.leave=function(b){a.each([b].concat(this._states.tags[b]||[]),a.proxy(function(a,b){this._states.current[b]--},this))},e.prototype.register=function(b){if(b.type===e.Type.Event){if(a.event.special[b.name]||(a.event.special[b.name]={}),!a.event.special[b.name].owl){var c=a.event.special[b.name]._default;a.event.special[b.name]._default=function(a){return!c||!c.apply||a.namespace&&-1!==a.namespace.indexOf("owl")?a.namespace&&a.namespace.indexOf("owl")>-1:c.apply(this,arguments)},a.event.special[b.name].owl=!0}}else b.type===e.Type.State&&(this._states.tags[b.name]?this._states.tags[b.name]=this._states.tags[b.name].concat(b.tags):this._states.tags[b.name]=b.tags,this._states.tags[b.name]=a.grep(this._states.tags[b.name],a.proxy(function(c,d){return a.inArray(c,this._states.tags[b.name])===d},this)))},e.prototype.suppress=function(b){a.each(b,a.proxy(function(a,b){this._supress[b]=!0},this))},e.prototype.release=function(b){a.each(b,a.proxy(function(a,b){delete this._supress[b]},this))},e.prototype.pointer=function(a){var c={x:null,y:null};return a=a.originalEvent||a||b.event,a=a.touches&&a.touches.length?a.touches[0]:a.changedTouches&&a.changedTouches.length?a.changedTouches[0]:a,a.pageX?(c.x=a.pageX,c.y=a.pageY):(c.x=a.clientX,c.y=a.clientY),c},e.prototype.difference=function(a,b){return{x:a.x-b.x,y:a.y-b.y}},a.fn.owlCarousel=function(b){var c=Array.prototype.slice.call(arguments,1);return this.each(function(){var d=a(this),f=d.data("owl.carousel");f||(f=new e(this,"object"==typeof b&&b),d.data("owl.carousel",f),a.each(["next","prev","to","destroy","refresh","replace","add","remove"],function(b,c){f.register({type:e.Type.Event,name:c}),f.$element.on(c+".owl.carousel.core",a.proxy(function(a){a.namespace&&a.relatedTarget!==this&&(this.suppress([c]),f[c].apply(this,[].slice.call(arguments,1)),this.release([c]))},f))})),"string"==typeof b&&"_"!==b.charAt(0)&&f[b].apply(f,c)})},a.fn.owlCarousel.Constructor=e}(window.Zepto||window.jQuery,window,document),function(a,b,c,d){var e=function(b){this._core=b,this._interval=null,this._visible=null,this._handlers={"initialized.owl.carousel":a.proxy(function(a){a.namespace&&this._core.settings.autoRefresh&&this.watch()},this)},this._core.options=a.extend({},e.Defaults,this._core.options),this._core.$element.on(this._handlers)};e.Defaults={autoRefresh:!0,autoRefreshInterval:500},e.prototype.watch=function(){this._interval||(this._visible=this._core.$element.is(":visible"),this._interval=b.setInterval(a.proxy(this.refresh,this),this._core.settings.autoRefreshInterval))},e.prototype.refresh=function(){this._core.$element.is(":visible")!==this._visible&&(this._visible=!this._visible,this._core.$element.toggleClass("owl-hidden",!this._visible),this._visible&&this._core.invalidate("width")&&this._core.refresh())},e.prototype.destroy=function(){var a,c;b.clearInterval(this._interval);for(a in this._handlers)this._core.$element.off(a,this._handlers[a]);for(c in Object.getOwnPropertyNames(this))"function"!=typeof this[c]&&(this[c]=null)},a.fn.owlCarousel.Constructor.Plugins.AutoRefresh=e}(window.Zepto||window.jQuery,window,document),function(a,b,c,d){var e=function(b){this._core=b,this._loaded=[],this._handlers={"initialized.owl.carousel change.owl.carousel":a.proxy(function(b){if(b.namespace&&this._core.settings&&this._core.settings.lazyLoad&&(b.property&&"position"==b.property.name||"initialized"==b.type))for(var c=this._core.settings,d=c.center&&Math.ceil(c.items/2)||c.items,e=c.center&&-1*d||0,f=(b.property&&b.property.value||this._core.current())+e,g=this._core.clones().length,h=a.proxy(function(a,b){this.load(b)},this);e++-1||(e.each(a.proxy(function(c,d){var e,f=a(d),g=b.devicePixelRatio>1&&f.attr("data-src-retina")||f.attr("data-src");this._core.trigger("load",{element:f,url:g},"lazy"),f.is("img")?f.one("load.owl.lazy",a.proxy(function(){f.css("opacity",1),this._core.trigger("loaded",{element:f,url:g},"lazy")},this)).attr("src",g):(e=new Image,e.onload=a.proxy(function(){f.css({"background-image":"url("+g+")",opacity:"1"}),this._core.trigger("loaded",{element:f,url:g},"lazy")},this),e.src=g)},this)),this._loaded.push(d.get(0)))},e.prototype.destroy=function(){var a,b;for(a in this.handlers)this._core.$element.off(a,this.handlers[a]);for(b in Object.getOwnPropertyNames(this))"function"!=typeof this[b]&&(this[b]=null)},a.fn.owlCarousel.Constructor.Plugins.Lazy=e}(window.Zepto||window.jQuery,window,document),function(a,b,c,d){var e=function(b){this._core=b,this._handlers={"initialized.owl.carousel refreshed.owl.carousel":a.proxy(function(a){a.namespace&&this._core.settings.autoHeight&&this.update()},this),"changed.owl.carousel":a.proxy(function(a){a.namespace&&this._core.settings.autoHeight&&"position"==a.property.name&&this.update()},this),"loaded.owl.lazy":a.proxy(function(a){a.namespace&&this._core.settings.autoHeight&&a.element.closest("."+this._core.settings.itemClass).index()===this._core.current()&&this.update()},this)},this._core.options=a.extend({},e.Defaults,this._core.options),this._core.$element.on(this._handlers)};e.Defaults={autoHeight:!1,autoHeightClass:"owl-height"},e.prototype.update=function(){var b=this._core._current,c=b+this._core.settings.items,d=this._core.$stage.children().toArray().slice(b,c);heights=[],maxheight=0,a.each(d,function(b,c){heights.push(a(c).height())}),maxheight=Math.max.apply(null,heights),this._core.$stage.parent().height(maxheight).addClass(this._core.settings.autoHeightClass)},e.prototype.destroy=function(){var a,b;for(a in this._handlers)this._core.$element.off(a,this._handlers[a]);for(b in Object.getOwnPropertyNames(this))"function"!=typeof this[b]&&(this[b]=null)},a.fn.owlCarousel.Constructor.Plugins.AutoHeight=e}(window.Zepto||window.jQuery,window,document),function(a,b,c,d){var e=function(b){this._core=b,this._videos={},this._playing=null,this._handlers={"initialized.owl.carousel":a.proxy(function(a){a.namespace&&this._core.register({type:"state",name:"playing",tags:["interacting"]})},this),"resize.owl.carousel":a.proxy(function(a){a.namespace&&this._core.settings.video&&this.isInFullScreen()&&a.preventDefault()},this),"refreshed.owl.carousel":a.proxy(function(a){a.namespace&&this._core.is("resizing")&&this._core.$stage.find(".cloned .owl-video-frame").remove()},this),"changed.owl.carousel":a.proxy(function(a){a.namespace&&"position"===a.property.name&&this._playing&&this.stop()},this),"prepared.owl.carousel":a.proxy(function(b){if(b.namespace){var c=a(b.content).find(".owl-video");c.length&&(c.css("display","none"),this.fetch(c,a(b.content)))}},this)},this._core.options=a.extend({},e.Defaults,this._core.options),this._core.$element.on(this._handlers),this._core.$element.on("click.owl.video",".owl-video-play-icon",a.proxy(function(a){this.play(a)},this))};e.Defaults={video:!1,videoHeight:!1,videoWidth:!1},e.prototype.fetch=function(a,b){var c=a.attr("data-vimeo-id")?"vimeo":"youtube",d=a.attr("data-vimeo-id")||a.attr("data-youtube-id"),e=a.attr("data-width")||this._core.settings.videoWidth,f=a.attr("data-height")||this._core.settings.videoHeight,g=a.attr("href");if(!g)throw new Error("Missing video URL.");if(d=g.match(/(http:|https:|)\/\/(player.|www.)?(vimeo\.com|youtu(be\.com|\.be|be\.googleapis\.com))\/(video\/|embed\/|watch\?v=|v\/)?([A-Za-z0-9._%-]*)(\&\S+)?/),d[3].indexOf("youtu")>-1)c="youtube";else{if(!(d[3].indexOf("vimeo")>-1))throw new Error("Video URL not supported.");c="vimeo"}d=d[6],this._videos[g]={type:c,id:d,width:e,height:f},b.attr("data-video",g),this.thumbnail(a,this._videos[g])},e.prototype.thumbnail=function(b,c){var d,e,f,g=c.width&&c.height?'style="width:'+c.width+"px;height:"+c.height+'px;"':"",h=b.find("img"),i="src",j="",k=this._core.settings,l=function(a){e='
    ',d=k.lazyLoad?'
    ':'
    ',b.after(d),b.after(e)};return b.wrap('
    "),this._core.settings.lazyLoad&&(i="data-src",j="owl-lazy"),h.length?(l(h.attr(i)),h.remove(),!1):void("youtube"===c.type?(f="http://img.youtube.com/vi/"+c.id+"/hqdefault.jpg",l(f)):"vimeo"===c.type&&a.ajax({type:"GET",url:"http://vimeo.com/api/v2/video/"+c.id+".json",jsonp:"callback",dataType:"jsonp",success:function(a){f=a[0].thumbnail_large,l(f)}}))},e.prototype.stop=function(){this._core.trigger("stop",null,"video"),this._playing.find(".owl-video-frame").remove(),this._playing.removeClass("owl-video-playing"),this._playing=null,this._core.leave("playing"),this._core.trigger("stopped",null,"video")},e.prototype.play=function(b){var c,d=a(b.target),e=d.closest("."+this._core.settings.itemClass),f=this._videos[e.attr("data-video")],g=f.width||"100%",h=f.height||this._core.$stage.height();this._playing||(this._core.enter("playing"),this._core.trigger("play",null,"video"),e=this._core.items(this._core.relative(e.index())),this._core.reset(e.index()),"youtube"===f.type?c='':"vimeo"===f.type&&(c=''),a('
    '+c+"
    ").insertAfter(e.find(".owl-video")),this._playing=e.addClass("owl-video-playing"))},e.prototype.isInFullScreen=function(){var b=c.fullscreenElement||c.mozFullScreenElement||c.webkitFullscreenElement;return b&&a(b).parent().hasClass("owl-video-frame")},e.prototype.destroy=function(){var a,b;this._core.$element.off("click.owl.video");for(a in this._handlers)this._core.$element.off(a,this._handlers[a]);for(b in Object.getOwnPropertyNames(this))"function"!=typeof this[b]&&(this[b]=null)},a.fn.owlCarousel.Constructor.Plugins.Video=e}(window.Zepto||window.jQuery,window,document),function(a,b,c,d){var e=function(b){this.core=b,this.core.options=a.extend({},e.Defaults,this.core.options),this.swapping=!0,this.previous=d,this.next=d,this.handlers={"change.owl.carousel":a.proxy(function(a){a.namespace&&"position"==a.property.name&&(this.previous=this.core.current(),this.next=a.property.value)},this),"drag.owl.carousel dragged.owl.carousel translated.owl.carousel":a.proxy(function(a){a.namespace&&(this.swapping="translated"==a.type)},this),"translate.owl.carousel":a.proxy(function(a){a.namespace&&this.swapping&&(this.core.options.animateOut||this.core.options.animateIn)&&this.swap()},this)},this.core.$element.on(this.handlers)};e.Defaults={animateOut:!1,animateIn:!1},e.prototype.swap=function(){if(1===this.core.settings.items&&a.support.animation&&a.support.transition){this.core.speed(0);var b,c=a.proxy(this.clear,this),d=this.core.$stage.children().eq(this.previous),e=this.core.$stage.children().eq(this.next),f=this.core.settings.animateIn,g=this.core.settings.animateOut;this.core.current()!==this.previous&&(g&&(b=this.core.coordinates(this.previous)-this.core.coordinates(this.next),d.one(a.support.animation.end,c).css({left:b+"px"}).addClass("animated owl-animated-out").addClass(g)),f&&e.one(a.support.animation.end,c).addClass("animated owl-animated-in").addClass(f))}},e.prototype.clear=function(b){a(b.target).css({left:""}).removeClass("animated owl-animated-out owl-animated-in").removeClass(this.core.settings.animateIn).removeClass(this.core.settings.animateOut),this.core.onTransitionEnd()},e.prototype.destroy=function(){var a,b;for(a in this.handlers)this.core.$element.off(a,this.handlers[a]);for(b in Object.getOwnPropertyNames(this))"function"!=typeof this[b]&&(this[b]=null)},a.fn.owlCarousel.Constructor.Plugins.Animate=e}(window.Zepto||window.jQuery,window,document),function(a,b,c,d){var e=function(b){this._core=b,this._interval=null,this._paused=!1,this._handlers={"changed.owl.carousel":a.proxy(function(a){a.namespace&&"settings"===a.property.name&&(this._core.settings.autoplay?this.play():this.stop())},this),"initialized.owl.carousel":a.proxy(function(a){a.namespace&&this._core.settings.autoplay&&this.play()},this),"play.owl.autoplay":a.proxy(function(a,b,c){a.namespace&&this.play(b,c)},this),"stop.owl.autoplay":a.proxy(function(a){a.namespace&&this.stop()},this),"mouseover.owl.autoplay":a.proxy(function(){this._core.settings.autoplayHoverPause&&this._core.is("rotating")&&this.pause()},this),"mouseleave.owl.autoplay":a.proxy(function(){ -this._core.settings.autoplayHoverPause&&this._core.is("rotating")&&this.play()},this)},this._core.$element.on(this._handlers),this._core.options=a.extend({},e.Defaults,this._core.options)};e.Defaults={autoplay:!1,autoplayTimeout:5e3,autoplayHoverPause:!1,autoplaySpeed:!1},e.prototype.play=function(d,e){this._paused=!1,this._core.is("rotating")||(this._core.enter("rotating"),this._interval=b.setInterval(a.proxy(function(){this._paused||this._core.is("busy")||this._core.is("interacting")||c.hidden||this._core.next(e||this._core.settings.autoplaySpeed)},this),d||this._core.settings.autoplayTimeout))},e.prototype.stop=function(){this._core.is("rotating")&&(b.clearInterval(this._interval),this._core.leave("rotating"))},e.prototype.pause=function(){this._core.is("rotating")&&(this._paused=!0)},e.prototype.destroy=function(){var a,b;this.stop();for(a in this._handlers)this._core.$element.off(a,this._handlers[a]);for(b in Object.getOwnPropertyNames(this))"function"!=typeof this[b]&&(this[b]=null)},a.fn.owlCarousel.Constructor.Plugins.autoplay=e}(window.Zepto||window.jQuery,window,document),function(a,b,c,d){"use strict";var e=function(b){this._core=b,this._initialized=!1,this._pages=[],this._controls={},this._templates=[],this.$element=this._core.$element,this._overrides={next:this._core.next,prev:this._core.prev,to:this._core.to},this._handlers={"prepared.owl.carousel":a.proxy(function(b){b.namespace&&this._core.settings.dotsData&&this._templates.push('
    '+a(b.content).find("[data-dot]").andSelf("[data-dot]").attr("data-dot")+"
    ")},this),"added.owl.carousel":a.proxy(function(a){a.namespace&&this._core.settings.dotsData&&this._templates.splice(a.position,0,this._templates.pop())},this),"remove.owl.carousel":a.proxy(function(a){a.namespace&&this._core.settings.dotsData&&this._templates.splice(a.position,1)},this),"changed.owl.carousel":a.proxy(function(a){a.namespace&&"position"==a.property.name&&this.draw()},this),"initialized.owl.carousel":a.proxy(function(a){a.namespace&&!this._initialized&&(this._core.trigger("initialize",null,"navigation"),this.initialize(),this.update(),this.draw(),this._initialized=!0,this._core.trigger("initialized",null,"navigation"))},this),"refreshed.owl.carousel":a.proxy(function(a){a.namespace&&this._initialized&&(this._core.trigger("refresh",null,"navigation"),this.update(),this.draw(),this._core.trigger("refreshed",null,"navigation"))},this)},this._core.options=a.extend({},e.Defaults,this._core.options),this.$element.on(this._handlers)};e.Defaults={nav:!1,navText:["prev","next"],navSpeed:!1,navElement:"div",navContainer:!1,navContainerClass:"owl-nav",navClass:["owl-prev","owl-next"],slideBy:1,dotClass:"owl-dot",dotsClass:"owl-dots",dots:!0,dotsEach:!1,dotsData:!1,dotsSpeed:!1,dotsContainer:!1},e.prototype.initialize=function(){var b,c=this._core.settings;this._controls.$relative=(c.navContainer?a(c.navContainer):a("
    ").addClass(c.navContainerClass).appendTo(this.$element)).addClass("disabled"),this._controls.$previous=a("<"+c.navElement+">").addClass(c.navClass[0]).html(c.navText[0]).prependTo(this._controls.$relative).on("click",a.proxy(function(a){this.prev(c.navSpeed)},this)),this._controls.$next=a("<"+c.navElement+">").addClass(c.navClass[1]).html(c.navText[1]).appendTo(this._controls.$relative).on("click",a.proxy(function(a){this.next(c.navSpeed)},this)),c.dotsData||(this._templates=[a("
    ").addClass(c.dotClass).append(a("")).prop("outerHTML")]),this._controls.$absolute=(c.dotsContainer?a(c.dotsContainer):a("
    ").addClass(c.dotsClass).appendTo(this.$element)).addClass("disabled"),this._controls.$absolute.on("click","div",a.proxy(function(b){var d=a(b.target).parent().is(this._controls.$absolute)?a(b.target).index():a(b.target).parent().index();b.preventDefault(),this.to(d,c.dotsSpeed)},this));for(b in this._overrides)this._core[b]=a.proxy(this[b],this)},e.prototype.destroy=function(){var a,b,c,d;for(a in this._handlers)this.$element.off(a,this._handlers[a]);for(b in this._controls)this._controls[b].remove();for(d in this.overides)this._core[d]=this._overrides[d];for(c in Object.getOwnPropertyNames(this))"function"!=typeof this[c]&&(this[c]=null)},e.prototype.update=function(){var a,b,c,d=this._core.clones().length/2,e=d+this._core.items().length,f=this._core.maximum(!0),g=this._core.settings,h=g.center||g.autoWidth||g.dotsData?1:g.dotsEach||g.items;if("page"!==g.slideBy&&(g.slideBy=Math.min(g.slideBy,g.items)),g.dots||"page"==g.slideBy)for(this._pages=[],a=d,b=0,c=0;e>a;a++){if(b>=h||0===b){if(this._pages.push({start:Math.min(f,a-d),end:a-d+h-1}),Math.min(f,a-d)===f)break;b=0,++c}b+=this._core.mergers(this._core.relative(a))}},e.prototype.draw=function(){var b,c=this._core.settings,d=this._core.items().length<=c.items,e=this._core.relative(this._core.current()),f=c.loop||c.rewind;this._controls.$relative.toggleClass("disabled",!c.nav||d),c.nav&&(this._controls.$previous.toggleClass("disabled",!f&&e<=this._core.minimum(!0)),this._controls.$next.toggleClass("disabled",!f&&e>=this._core.maximum(!0))),this._controls.$absolute.toggleClass("disabled",!c.dots||d),c.dots&&(b=this._pages.length-this._controls.$absolute.children().length,c.dotsData&&0!==b?this._controls.$absolute.html(this._templates.join("")):b>0?this._controls.$absolute.append(new Array(b+1).join(this._templates[0])):0>b&&this._controls.$absolute.children().slice(b).remove(),this._controls.$absolute.find(".active").removeClass("active"),this._controls.$absolute.children().eq(a.inArray(this.current(),this._pages)).addClass("active"))},e.prototype.onTrigger=function(b){var c=this._core.settings;b.page={index:a.inArray(this.current(),this._pages),count:this._pages.length,size:c&&(c.center||c.autoWidth||c.dotsData?1:c.dotsEach||c.items)}},e.prototype.current=function(){var b=this._core.relative(this._core.current());return a.grep(this._pages,a.proxy(function(a,c){return a.start<=b&&a.end>=b},this)).pop()},e.prototype.getPosition=function(b){var c,d,e=this._core.settings;return"page"==e.slideBy?(c=a.inArray(this.current(),this._pages),d=this._pages.length,b?++c:--c,c=this._pages[(c%d+d)%d].start):(c=this._core.relative(this._core.current()),d=this._core.items().length,b?c+=e.slideBy:c-=e.slideBy),c},e.prototype.next=function(b){a.proxy(this._overrides.to,this._core)(this.getPosition(!0),b)},e.prototype.prev=function(b){a.proxy(this._overrides.to,this._core)(this.getPosition(!1),b)},e.prototype.to=function(b,c,d){var e;d?a.proxy(this._overrides.to,this._core)(b,c):(e=this._pages.length,a.proxy(this._overrides.to,this._core)(this._pages[(b%e+e)%e].start,c))},a.fn.owlCarousel.Constructor.Plugins.Navigation=e}(window.Zepto||window.jQuery,window,document),function(a,b,c,d){"use strict";var e=function(c){this._core=c,this._hashes={},this.$element=this._core.$element,this._handlers={"initialized.owl.carousel":a.proxy(function(c){c.namespace&&"URLHash"===this._core.settings.startPosition&&a(b).trigger("hashchange.owl.navigation")},this),"prepared.owl.carousel":a.proxy(function(b){if(b.namespace){var c=a(b.content).find("[data-hash]").andSelf("[data-hash]").attr("data-hash");if(!c)return;this._hashes[c]=b.content}},this),"changed.owl.carousel":a.proxy(function(c){if(c.namespace&&"position"===c.property.name){var d=this._core.items(this._core.relative(this._core.current())),e=a.map(this._hashes,function(a,b){return a===d?b:null}).join();if(!e||b.location.hash.slice(1)===e)return;b.location.hash=e}},this)},this._core.options=a.extend({},e.Defaults,this._core.options),this.$element.on(this._handlers),a(b).on("hashchange.owl.navigation",a.proxy(function(a){var c=b.location.hash.substring(1),e=this._core.$stage.children(),f=this._hashes[c]&&e.index(this._hashes[c]);f!==d&&f!==this._core.current()&&this._core.to(this._core.relative(f),!1,!0)},this))};e.Defaults={URLhashListener:!1},e.prototype.destroy=function(){var c,d;a(b).off("hashchange.owl.navigation");for(c in this._handlers)this._core.$element.off(c,this._handlers[c]);for(d in Object.getOwnPropertyNames(this))"function"!=typeof this[d]&&(this[d]=null)},a.fn.owlCarousel.Constructor.Plugins.Hash=e}(window.Zepto||window.jQuery,window,document),function(a,b,c,d){function e(b,c){var e=!1,f=b.charAt(0).toUpperCase()+b.slice(1);return a.each((b+" "+h.join(f+" ")+f).split(" "),function(a,b){return g[b]!==d?(e=c?b:!0,!1):void 0}),e}function f(a){return e(a,!0)}var g=a("").get(0).style,h="Webkit Moz O ms".split(" "),i={transition:{end:{WebkitTransition:"webkitTransitionEnd",MozTransition:"transitionend",OTransition:"oTransitionEnd",transition:"transitionend"}},animation:{end:{WebkitAnimation:"webkitAnimationEnd",MozAnimation:"animationend",OAnimation:"oAnimationEnd",animation:"animationend"}}},j={csstransforms:function(){return!!e("transform")},csstransforms3d:function(){return!!e("perspective")},csstransitions:function(){return!!e("transition")},cssanimations:function(){return!!e("animation")}};j.csstransitions()&&(a.support.transition=new String(f("transition")),a.support.transition.end=i.transition.end[a.support.transition]),j.cssanimations()&&(a.support.animation=new String(f("animation")),a.support.animation.end=i.animation.end[a.support.animation]),j.csstransforms()&&(a.support.transform=new String(f("transform")),a.support.transform3d=j.csstransforms3d())}(window.Zepto||window.jQuery,window,document); \ No newline at end of file diff --git a/docs/static/vendor/OwlCarousel2/notes.txt b/docs/static/vendor/OwlCarousel2/notes.txt deleted file mode 100644 index eeb8f7a89..000000000 --- a/docs/static/vendor/OwlCarousel2/notes.txt +++ /dev/null @@ -1,12 +0,0 @@ -Version: 2.0.0-beta.3 - -Source: - https://github.com/OwlCarousel2/OwlCarousel2 - -Files (there): - dist/assets/owl.carousel.css - dist/assets/owl.theme.default.css - dist/owl.carousel.js - -Roadmap (from August 22, 2016): - https://github.com/OwlCarousel2/OwlCarousel2/issues/1538 diff --git a/docs/static/vendor/dieulot/js/instantclick.min.js b/docs/static/vendor/dieulot/js/instantclick.min.js deleted file mode 100644 index a2539d884..000000000 --- a/docs/static/vendor/dieulot/js/instantclick.min.js +++ /dev/null @@ -1,13 +0,0 @@ -/* InstantClick 3.1.0 | (C) 2014 Alexandre Dieulot | http://instantclick.io/license */ -var InstantClick=function(d,e){function w(a){var b=a.indexOf("#");return 0>b?a:a.substr(0,b)}function z(a){for(;a&&"A"!=a.nodeName;)a=a.parentNode;return a}function A(a){var b=e.protocol+"//"+e.host;if(!(b=a.target||a.hasAttribute("download")||0!=a.href.indexOf(b+"/")||-1+new Date-500||(a=z(a.target))&&A(a)&&x(a.href)}function N(a){G>+new Date-500||(a=z(a.target))&&A(a)&&(a.addEventListener("mouseout",T),H?(O=a.href,l=setTimeout(x,H)):x(a.href))}function U(a){G=+new Date;(a=z(a.target))&&A(a)&&(D?a.removeEventListener("mousedown", -M):a.removeEventListener("mouseover",N),x(a.href))}function V(a){var b=z(a.target);!b||!A(b)||1p.readyState)&&0!=p.status){q.ready=+new Date-q.start;if(p.getResponseHeader("Content-Type").match(/\/(x|ht|xht)ml/)){var a=d.implementation.createHTMLDocument("");a.documentElement.innerHTML=p.responseText.replace(//gi,"");y=a.title; -u=a.body;var b=t("receive",r,u,y);b&&("body"in b&&(u=b.body),"title"in b&&(y=b.title));b=w(r);h[b]={body:u,title:y,scrollY:b in h?h[b].scrollY:0};for(var a=a.head.children,b=0,c,g=a.length-1;0<=g;g--)if(c=a[g],c.hasAttribute("data-instant-track")){c=c.getAttribute("href")||c.getAttribute("src")||c.innerHTML;for(var e=E.length-1;0<=e;e--)E[e]==c&&b++}b!=E.length&&(F=!0)}else F=!0;m&&(m=!1,P(r))}}function L(a){d.body.addEventListener("touchstart",U,!0);D?d.body.addEventListener("mousedown",M,!0):d.body.addEventListener("mouseover", -N,!0);d.body.addEventListener("click",V,!0);if(!a){a=d.body.getElementsByTagName("script");var b,c,g,e;i=0;for(j=a.length;i+new Date-(q.start+q.display)||(l&&(clearTimeout(l),l=!1),a||(a=O),v&&(a==r||m))||(v=!0,m=!1,r=a,F=u=!1,q={start:+new Date},t("fetch"), -p.open("GET",a),p.send())}function P(a){"display"in q||(q.display=+new Date-q.start);l||!v?l&&r&&r!=a?e.href=a:(x(a),C.start(0,!0),t("wait"),m=!0):m?e.href=a:F?e.href=r:u?(h[k].scrollY=pageYOffset,m=v=!1,K(y,u,r)):(C.start(0,!0),t("wait"),m=!0)}var I=navigator.userAgent,S=-1b;b++)a[b]+"Transform"in h.style&&(k=a[b]+"Transform");var c="transition";if(!(c in h.style))for(b=0;3>b;b++)a[b]+"Transition"in -h.style&&(c="-"+a[b].toLowerCase()+"-"+c);a=d.createElement("style");a.innerHTML="#instantclick{position:"+(Q?"absolute":"fixed")+";top:0;left:0;width:100%;pointer-events:none;z-index:2147483647;"+c+":opacity .25s .1s}.instantclick-bar{background:#29d;width:100%;margin-left:-100%;height:2px;"+c+":all .25s}";d.head.appendChild(a);Q&&(m(),addEventListener("resize",m),addEventListener("scroll",m))},start:a,done:e}}(),R="pushState"in history&&(!I.match("Android")||I.match("Chrome/"))&&"file:"!=e.protocol; -return{supported:R,init:function(){if(!k)if(R){for(var a=arguments.length-1;0<=a;a--){var b=arguments[a];!0===b?J=!0:"mousedown"==b?D=!0:"number"==typeof b&&(H=b)}k=w(e.href);h[k]={body:d.body,title:d.title,scrollY:pageYOffset};for(var b=d.head.children,c,a=b.length-1;0<=a;a--)c=b[a],c.hasAttribute("data-instant-track")&&(c=c.getAttribute("href")||c.getAttribute("src")||c.innerHTML,E.push(c));p=new XMLHttpRequest;p.addEventListener("readystatechange",W);L(!0);C.init();t("change",!0);addEventListener("popstate", -function(){var a=w(e.href);a!=k&&(a in h?(h[k].scrollY=pageYOffset,k=a,K(h[a].title,h[a].body,!1,h[a].scrollY)):e.href=e.href)})}else t("change",!0)},on:function(a,b){B[a].push(b)}}}(document,location); diff --git a/docs/static/vendor/flesler/js/jquery.scrollTo.min.js b/docs/static/vendor/flesler/js/jquery.scrollTo.min.js deleted file mode 100644 index 65a020d92..000000000 --- a/docs/static/vendor/flesler/js/jquery.scrollTo.min.js +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Copyright (c) 2007-2015 Ariel Flesler - afleslergmailcom | http://flesler.blogspot.com - * Licensed under MIT - * @author Ariel Flesler - * @version 2.1.2 - */ -;(function(f){"use strict";"function"===typeof define&&define.amd?define(["jquery"],f):"undefined"!==typeof module&&module.exports?module.exports=f(require("jquery")):f(jQuery)})(function($){"use strict";function n(a){return!a.nodeName||-1!==$.inArray(a.nodeName.toLowerCase(),["iframe","#document","html","body"])}function h(a){return $.isFunction(a)||$.isPlainObject(a)?a:{top:a,left:a}}var p=$.scrollTo=function(a,d,b){return $(window).scrollTo(a,d,b)};p.defaults={axis:"xy",duration:0,limit:!0};$.fn.scrollTo=function(a,d,b){"object"=== typeof d&&(b=d,d=0);"function"===typeof b&&(b={onAfter:b});"max"===a&&(a=9E9);b=$.extend({},p.defaults,b);d=d||b.duration;var u=b.queue&&1=f[g]?0:Math.min(f[g],n));!a&&1li{position:relative}.fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em;text-align:center}.fa-li.fa-lg{left:-1.85714286em}.fa-border{padding:.2em .25em .15em;border:solid .08em #eee;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}.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:#fff}.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/docs/static/vendor/font-awesome/fonts/FontAwesome.otf b/docs/static/vendor/font-awesome/fonts/FontAwesome.otf deleted file mode 100644 index 3ed7f8b48..000000000 Binary files a/docs/static/vendor/font-awesome/fonts/FontAwesome.otf and /dev/null differ diff --git a/docs/static/vendor/font-awesome/fonts/fontawesome-webfont.eot b/docs/static/vendor/font-awesome/fonts/fontawesome-webfont.eot deleted file mode 100644 index 9b6afaedc..000000000 Binary files a/docs/static/vendor/font-awesome/fonts/fontawesome-webfont.eot and /dev/null differ diff --git a/docs/static/vendor/font-awesome/fonts/fontawesome-webfont.svg b/docs/static/vendor/font-awesome/fonts/fontawesome-webfont.svg deleted file mode 100644 index d05688e9e..000000000 --- a/docs/static/vendor/font-awesome/fonts/fontawesome-webfont.svg +++ /dev/null @@ -1,655 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/docs/static/vendor/font-awesome/fonts/fontawesome-webfont.ttf b/docs/static/vendor/font-awesome/fonts/fontawesome-webfont.ttf deleted file mode 100644 index 26dea7951..000000000 Binary files a/docs/static/vendor/font-awesome/fonts/fontawesome-webfont.ttf and /dev/null differ diff --git a/docs/static/vendor/font-awesome/fonts/fontawesome-webfont.woff b/docs/static/vendor/font-awesome/fonts/fontawesome-webfont.woff deleted file mode 100644 index dc35ce3c2..000000000 Binary files a/docs/static/vendor/font-awesome/fonts/fontawesome-webfont.woff and /dev/null differ diff --git a/docs/static/vendor/font-awesome/fonts/fontawesome-webfont.woff2 b/docs/static/vendor/font-awesome/fonts/fontawesome-webfont.woff2 deleted file mode 100644 index 500e51725..000000000 Binary files a/docs/static/vendor/font-awesome/fonts/fontawesome-webfont.woff2 and /dev/null differ diff --git a/docs/static/vendor/highlightjs/css/monokai-sublime.css b/docs/static/vendor/highlightjs/css/monokai-sublime.css deleted file mode 100644 index 2864170da..000000000 --- a/docs/static/vendor/highlightjs/css/monokai-sublime.css +++ /dev/null @@ -1,83 +0,0 @@ -/* - -Monokai Sublime style. Derived from Monokai by noformnocontent http://nn.mit-license.org/ - -*/ - -.hljs { - display: block; - overflow-x: auto; - padding: 0.5em; - background: #23241f; -} - -.hljs, -.hljs-tag, -.hljs-subst { - color: #f8f8f2; -} - -.hljs-strong, -.hljs-emphasis { - color: #a8a8a2; -} - -.hljs-bullet, -.hljs-quote, -.hljs-number, -.hljs-regexp, -.hljs-literal, -.hljs-link { - color: #ae81ff; -} - -.hljs-code, -.hljs-title, -.hljs-section, -.hljs-selector-class { - color: #a6e22e; -} - -.hljs-strong { - font-weight: bold; -} - -.hljs-emphasis { - font-style: italic; -} - -.hljs-keyword, -.hljs-selector-tag, -.hljs-name, -.hljs-attr { - color: #f92672; -} - -.hljs-symbol, -.hljs-attribute { - color: #66d9ef; -} - -.hljs-params, -.hljs-class .hljs-title { - color: #f8f8f2; -} - -.hljs-string, -.hljs-type, -.hljs-built_in, -.hljs-builtin-name, -.hljs-selector-id, -.hljs-selector-attr, -.hljs-selector-pseudo, -.hljs-addition, -.hljs-variable, -.hljs-template-variable { - color: #e6db74; -} - -.hljs-comment, -.hljs-deletion, -.hljs-meta { - color: #75715e; -} diff --git a/docs/static/vendor/highlightjs/js/highlight.pack.js b/docs/static/vendor/highlightjs/js/highlight.pack.js deleted file mode 100644 index ab4712ecf..000000000 --- a/docs/static/vendor/highlightjs/js/highlight.pack.js +++ /dev/null @@ -1,3 +0,0 @@ -/*! highlight.js v9.0.0 | BSD3 License | git.io/hljslicense */ -!function(e){"undefined"!=typeof exports?e(exports):(self.hljs=e({}),"function"==typeof define&&define.amd&&define("hljs",[],function(){return self.hljs}))}(function(e){function t(e){return e.replace(/&/gm,"&").replace(//gm,">")}function r(e){return e.nodeName.toLowerCase()}function n(e,t){var r=e&&e.exec(t);return r&&0==r.index}function a(e){return/^(no-?highlight|plain|text)$/i.test(e)}function i(e){var t,r,n,i=e.className+" ";if(i+=e.parentNode?e.parentNode.className:"",r=/\blang(?:uage)?-([\w-]+)\b/i.exec(i))return w(r[1])?r[1]:"no-highlight";for(i=i.split(/\s+/),t=0,n=i.length;n>t;t++)if(w(i[t])||a(i[t]))return i[t]}function o(e,t){var r,n={};for(r in e)n[r]=e[r];if(t)for(r in t)n[r]=t[r];return n}function c(e){var t=[];return function n(e,a){for(var i=e.firstChild;i;i=i.nextSibling)3==i.nodeType?a+=i.nodeValue.length:1==i.nodeType&&(t.push({event:"start",offset:a,node:i}),a=n(i,a),r(i).match(/br|hr|img|input/)||t.push({event:"stop",offset:a,node:i}));return a}(e,0),t}function s(e,n,a){function i(){return e.length&&n.length?e[0].offset!=n[0].offset?e[0].offset"}function c(e){b+=""}function s(e){("start"==e.event?o:c)(e.node)}for(var l=0,b="",u=[];e.length||n.length;){var d=i();if(b+=t(a.substr(l,d[0].offset-l)),l=d[0].offset,d==e){u.reverse().forEach(c);do s(d.splice(0,1)[0]),d=i();while(d==e&&d.length&&d[0].offset==l);u.reverse().forEach(o)}else"start"==d[0].event?u.push(d[0].node):u.pop(),s(d.splice(0,1)[0])}return b+t(a.substr(l))}function l(e){function t(e){return e&&e.source||e}function r(r,n){return new RegExp(t(r),"m"+(e.cI?"i":"")+(n?"g":""))}function n(a,i){if(!a.compiled){if(a.compiled=!0,a.k=a.k||a.bK,a.k){var c={},s=function(t,r){e.cI&&(r=r.toLowerCase()),r.split(" ").forEach(function(e){var r=e.split("|");c[r[0]]=[t,r[1]?Number(r[1]):1]})};"string"==typeof a.k?s("keyword",a.k):Object.keys(a.k).forEach(function(e){s(e,a.k[e])}),a.k=c}a.lR=r(a.l||/\b\w+\b/,!0),i&&(a.bK&&(a.b="\\b("+a.bK.split(" ").join("|")+")\\b"),a.b||(a.b=/\B|\b/),a.bR=r(a.b),a.e||a.eW||(a.e=/\B|\b/),a.e&&(a.eR=r(a.e)),a.tE=t(a.e)||"",a.eW&&i.tE&&(a.tE+=(a.e?"|":"")+i.tE)),a.i&&(a.iR=r(a.i)),void 0===a.r&&(a.r=1),a.c||(a.c=[]);var l=[];a.c.forEach(function(e){e.v?e.v.forEach(function(t){l.push(o(e,t))}):l.push("self"==e?a:e)}),a.c=l,a.c.forEach(function(e){n(e,a)}),a.starts&&n(a.starts,i);var b=a.c.map(function(e){return e.bK?"\\.?("+e.b+")\\.?":e.b}).concat([a.tE,a.i]).map(t).filter(Boolean);a.t=b.length?r(b.join("|"),!0):{exec:function(){return null}}}}n(e)}function b(e,r,a,i){function o(e,t){for(var r=0;r";return i+=e+'">',i+t+o}function f(){if(!x.k)return t(S);var e="",r=0;x.lR.lastIndex=0;for(var n=x.lR.exec(S);n;){e+=t(S.substr(r,n.index-r));var a=d(x,n);a?(E+=a[1],e+=p(a[0],t(n[0]))):e+=t(n[0]),r=x.lR.lastIndex,n=x.lR.exec(S)}return e+t(S.substr(r))}function m(){var e="string"==typeof x.sL;if(e&&!k[x.sL])return t(S);var r=e?b(x.sL,S,!0,_[x.sL]):u(S,x.sL.length?x.sL:void 0);return x.r>0&&(E+=r.r),e&&(_[x.sL]=r.top),p(r.language,r.value,!1,!0)}function g(){return void 0!==x.sL?m():f()}function h(e,r){var n=e.cN?p(e.cN,"",!0):"";e.rB?(M+=n,S=""):e.eB?(M+=t(r)+n,S=""):(M+=n,S=r),x=Object.create(e,{parent:{value:x}})}function N(e,r){if(S+=e,void 0===r)return M+=g(),0;var n=o(r,x);if(n)return M+=g(),h(n,r),n.rB?0:r.length;var a=c(x,r);if(a){var i=x;i.rE||i.eE||(S+=r),M+=g();do x.cN&&(M+=""),E+=x.r,x=x.parent;while(x!=a.parent);return i.eE&&(M+=t(r)),S="",a.starts&&h(a.starts,""),i.rE?0:r.length}if(s(r,x))throw new Error('Illegal lexeme "'+r+'" for mode "'+(x.cN||"")+'"');return S+=r,r.length||1}var v=w(e);if(!v)throw new Error('Unknown language: "'+e+'"');l(v);var C,x=i||v,_={},M="";for(C=x;C!=v;C=C.parent)C.cN&&(M=p(C.cN,"",!0)+M);var S="",E=0;try{for(var $,A,z=0;;){if(x.t.lastIndex=z,$=x.t.exec(r),!$)break;A=N(r.substr(z,$.index-z),$[0]),z=$.index+A}for(N(r.substr(z)),C=x;C.parent;C=C.parent)C.cN&&(M+="");return{r:E,value:M,language:e,top:x}}catch(B){if(-1!=B.message.indexOf("Illegal"))return{r:0,value:t(r)};throw B}}function u(e,r){r=r||y.languages||Object.keys(k);var n={r:0,value:t(e)},a=n;return r.forEach(function(t){if(w(t)){var r=b(t,e,!1);r.language=t,r.r>a.r&&(a=r),r.r>n.r&&(a=n,n=r)}}),a.language&&(n.second_best=a),n}function d(e){return y.tabReplace&&(e=e.replace(/^((<[^>]+>|\t)+)/gm,function(e,t){return t.replace(/\t/g,y.tabReplace)})),y.useBR&&(e=e.replace(/\n/g,"
    ")),e}function p(e,t,r){var n=t?C[t]:r,a=[e.trim()];return e.match(/\bhljs\b/)||a.push("hljs"),-1===e.indexOf(n)&&a.push(n),a.join(" ").trim()}function f(e){var t=i(e);if(!a(t)){var r;y.useBR?(r=document.createElementNS("http://www.w3.org/1999/xhtml","div"),r.innerHTML=e.innerHTML.replace(/\n/g,"").replace(//g,"\n")):r=e;var n=r.textContent,o=t?b(t,n,!0):u(n),l=c(r);if(l.length){var f=document.createElementNS("http://www.w3.org/1999/xhtml","div");f.innerHTML=o.value,o.value=s(l,c(f),n)}o.value=d(o.value),e.innerHTML=o.value,e.className=p(e.className,t,o.language),e.result={language:o.language,re:o.r},o.second_best&&(e.second_best={language:o.second_best.language,re:o.second_best.r})}}function m(e){y=o(y,e)}function g(){if(!g.called){g.called=!0;var e=document.querySelectorAll("pre code");Array.prototype.forEach.call(e,f)}}function h(){addEventListener("DOMContentLoaded",g,!1),addEventListener("load",g,!1)}function N(t,r){var n=k[t]=r(e);n.aliases&&n.aliases.forEach(function(e){C[e]=t})}function v(){return Object.keys(k)}function w(e){return e=(e||"").toLowerCase(),k[e]||k[C[e]]}var y={classPrefix:"hljs-",tabReplace:null,useBR:!1,languages:void 0},k={},C={};return e.highlight=b,e.highlightAuto=u,e.fixMarkup=d,e.highlightBlock=f,e.configure=m,e.initHighlighting=g,e.initHighlightingOnLoad=h,e.registerLanguage=N,e.listLanguages=v,e.getLanguage=w,e.inherit=o,e.IR="[a-zA-Z]\\w*",e.UIR="[a-zA-Z_]\\w*",e.NR="\\b\\d+(\\.\\d+)?",e.CNR="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",e.BNR="\\b(0b[01]+)",e.RSR="!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",e.BE={b:"\\\\[\\s\\S]",r:0},e.ASM={cN:"string",b:"'",e:"'",i:"\\n",c:[e.BE]},e.QSM={cN:"string",b:'"',e:'"',i:"\\n",c:[e.BE]},e.PWM={b:/\b(a|an|the|are|I|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|like)\b/},e.C=function(t,r,n){var a=e.inherit({cN:"comment",b:t,e:r,c:[]},n||{});return a.c.push(e.PWM),a.c.push({cN:"doctag",b:"(?:TODO|FIXME|NOTE|BUG|XXX):",r:0}),a},e.CLCM=e.C("//","$"),e.CBCM=e.C("/\\*","\\*/"),e.HCM=e.C("#","$"),e.NM={cN:"number",b:e.NR,r:0},e.CNM={cN:"number",b:e.CNR,r:0},e.BNM={cN:"number",b:e.BNR,r:0},e.CSSNM={cN:"number",b:e.NR+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",r:0},e.RM={cN:"regexp",b:/\//,e:/\/[gimuy]*/,i:/\n/,c:[e.BE,{b:/\[/,e:/\]/,r:0,c:[e.BE]}]},e.TM={cN:"title",b:e.IR,r:0},e.UTM={cN:"title",b:e.UIR,r:0},e.registerLanguage("apache",function(e){var t={cN:"number",b:"[\\$%]\\d+"};return{aliases:["apacheconf"],cI:!0,c:[e.HCM,{cN:"section",b:""},{cN:"attribute",b:/\w+/,r:0,k:{nomarkup:"order deny allow setenv rewriterule rewriteengine rewritecond documentroot sethandler errordocument loadmodule options header listen serverroot servername"},starts:{e:/$/,r:0,k:{literal:"on off all"},c:[{cN:"meta",b:"\\s\\[",e:"\\]$"},{cN:"variable",b:"[\\$%]\\{",e:"\\}",c:["self",t]},t,e.QSM]}}],i:/\S/}}),e.registerLanguage("xml",function(e){var t="[A-Za-z0-9\\._:-]+",r={b:/<\?(php)?(?!\w)/,e:/\?>/,sL:"php"},n={eW:!0,i:/]+/}]}]}]};return{aliases:["html","xhtml","rss","atom","xsl","plist"],cI:!0,c:[{cN:"meta",b:"",r:10,c:[{b:"\\[",e:"\\]"}]},e.C("",{r:10}),{b:"<\\!\\[CDATA\\[",e:"\\]\\]>",r:10},{cN:"tag",b:"|$)",e:">",k:{name:"style"},c:[n],starts:{e:"",rE:!0,sL:["css","xml"]}},{cN:"tag",b:"|$)",e:">",k:{name:"script"},c:[n],starts:{e:"",rE:!0,sL:["actionscript","javascript","handlebars","xml"]}},r,{cN:"meta",b:/<\?\w+/,e:/\?>/,r:10},{cN:"tag",b:"",c:[{cN:"name",b:/[^\/><\s]+/,r:0},n]}]}}),e.registerLanguage("asciidoc",function(e){return{aliases:["adoc"],c:[e.C("^/{4,}\\n","\\n/{4,}$",{r:10}),e.C("^//","$",{r:0}),{cN:"title",b:"^\\.\\w.*$"},{b:"^[=\\*]{4,}\\n",e:"\\n^[=\\*]{4,}$",r:10},{cN:"section",r:10,v:[{b:"^(={1,5}) .+?( \\1)?$"},{b:"^[^\\[\\]\\n]+?\\n[=\\-~\\^\\+]{2,}$"}]},{cN:"meta",b:"^:.+?:",e:"\\s",eE:!0,r:10},{cN:"meta",b:"^\\[.+?\\]$",r:0},{cN:"quote",b:"^_{4,}\\n",e:"\\n_{4,}$",r:10},{cN:"code",b:"^[\\-\\.]{4,}\\n",e:"\\n[\\-\\.]{4,}$",r:10},{b:"^\\+{4,}\\n",e:"\\n\\+{4,}$",c:[{b:"<",e:">",sL:"xml",r:0}],r:10},{cN:"bullet",b:"^(\\*+|\\-+|\\.+|[^\\n]+?::)\\s+"},{cN:"symbol",b:"^(NOTE|TIP|IMPORTANT|WARNING|CAUTION):\\s+",r:10},{cN:"strong",b:"\\B\\*(?![\\*\\s])",e:"(\\n{2}|\\*)",c:[{b:"\\\\*\\w",r:0}]},{cN:"emphasis",b:"\\B'(?!['\\s])",e:"(\\n{2}|')",c:[{b:"\\\\'\\w",r:0}],r:0},{cN:"emphasis",b:"_(?![_\\s])",e:"(\\n{2}|_)",r:0},{cN:"string",v:[{b:"``.+?''"},{b:"`.+?'"}]},{cN:"code",b:"(`.+?`|\\+.+?\\+)",r:0},{cN:"code",b:"^[ \\t]",e:"$",r:0},{b:"^'{3,}[ \\t]*$",r:10},{b:"(link:)?(http|https|ftp|file|irc|image:?):\\S+\\[.*?\\]",rB:!0,c:[{b:"(link|image:?):",r:0},{cN:"link",b:"\\w",e:"[^\\[]+",r:0},{cN:"string",b:"\\[",e:"\\]",eB:!0,eE:!0,r:0}],r:10}]}}),e.registerLanguage("bash",function(e){var t={cN:"variable",v:[{b:/\$[\w\d#@][\w\d_]*/},{b:/\$\{(.*?)}/}]},r={cN:"string",b:/"/,e:/"/,c:[e.BE,t,{cN:"variable",b:/\$\(/,e:/\)/,c:[e.BE]}]},n={cN:"string",b:/'/,e:/'/};return{aliases:["sh","zsh"],l:/-?[a-z\.]+/,k:{keyword:"if then else elif fi for while in do done case esac function",literal:"true false",built_in:"break cd continue eval exec exit export getopts hash pwd readonly return shift test times trap umask unset alias bind builtin caller command declare echo enable help let local logout mapfile printf read readarray source type typeset ulimit unalias set shopt autoload bg bindkey bye cap chdir clone comparguments compcall compctl compdescribe compfiles compgroups compquote comptags comptry compvalues dirs disable disown echotc echoti emulate fc fg float functions getcap getln history integer jobs kill limit log noglob popd print pushd pushln rehash sched setcap setopt stat suspend ttyctl unfunction unhash unlimit unsetopt vared wait whence where which zcompile zformat zftp zle zmodload zparseopts zprof zpty zregexparse zsocket zstyle ztcp",_:"-ne -eq -lt -gt -f -d -e -s -l -a"},c:[{cN:"meta",b:/^#![^\n]+sh\s*$/,r:10},{cN:"function",b:/\w[\w\d_]*\s*\(\s*\)\s*\{/,rB:!0,c:[e.inherit(e.TM,{b:/\w[\w\d_]*/})],r:0},e.HCM,r,n,t]}}),e.registerLanguage("coffeescript",function(e){var t={keyword:"in if for while finally new do return else break catch instanceof throw try this switch continue typeof delete debugger super then unless until loop of by when and or is isnt not",literal:"true false null undefined yes no on off",built_in:"npm require console print module global window document"},r="[A-Za-z$_][0-9A-Za-z$_]*",n={cN:"subst",b:/#\{/,e:/}/,k:t},a=[e.BNM,e.inherit(e.CNM,{starts:{e:"(\\s*/)?",r:0}}),{cN:"string",v:[{b:/'''/,e:/'''/,c:[e.BE]},{b:/'/,e:/'/,c:[e.BE]},{b:/"""/,e:/"""/,c:[e.BE,n]},{b:/"/,e:/"/,c:[e.BE,n]}]},{cN:"regexp",v:[{b:"///",e:"///",c:[n,e.HCM]},{b:"//[gim]*",r:0},{b:/\/(?![ *])(\\\/|.)*?\/[gim]*(?=\W|$)/}]},{b:"@"+r},{b:"`",e:"`",eB:!0,eE:!0,sL:"javascript"}];n.c=a;var i=e.inherit(e.TM,{b:r}),o="(\\(.*\\))?\\s*\\B[-=]>",c={cN:"params",b:"\\([^\\(]",rB:!0,c:[{b:/\(/,e:/\)/,k:t,c:["self"].concat(a)}]};return{aliases:["coffee","cson","iced"],k:t,i:/\/\*/,c:a.concat([e.C("###","###"),e.HCM,{cN:"function",b:"^\\s*"+r+"\\s*=\\s*"+o,e:"[-=]>",rB:!0,c:[i,c]},{b:/[:\(,=]\s*/,r:0,c:[{cN:"function",b:o,e:"[-=]>",rB:!0,c:[c]}]},{cN:"class",bK:"class",e:"$",i:/[:="\[\]]/,c:[{bK:"extends",eW:!0,i:/[:="\[\]]/,c:[i]},i]},{b:r+":",e:":",rB:!0,rE:!0,r:0}])}}),e.registerLanguage("css",function(e){var t="[a-zA-Z-][a-zA-Z0-9_-]*",r={b:/[A-Z\_\.\-]+\s*:/,rB:!0,e:";",eW:!0,c:[{cN:"attribute",b:/\S/,e:":",eE:!0,starts:{eW:!0,eE:!0,c:[{b:/[\w-]+\s*\(/,rB:!0,c:[{cN:"built_in",b:/[\w-]+/}]},e.CSSNM,e.QSM,e.ASM,e.CBCM,{cN:"number",b:"#[0-9A-Fa-f]+"},{cN:"meta",b:"!important"}]}}]};return{cI:!0,i:/[=\/|'\$]/,c:[e.CBCM,{cN:"selector-id",b:/#[A-Za-z0-9_-]+/},{cN:"selector-class",b:/\.[A-Za-z0-9_-]+/},{cN:"selector-attr",b:/\[/,e:/\]/,i:"$"},{cN:"selector-pseudo",b:/:(:)?[a-zA-Z0-9\_\-\+\(\)"']+/},{b:"@(font-face|page)",l:"[a-z-]+",k:"font-face page"},{b:"@",e:"[{;]",c:[{cN:"keyword",b:/\S+/},{b:/\s/,eW:!0,eE:!0,r:0,c:[e.ASM,e.QSM,e.CSSNM]}]},{cN:"selector-tag",b:t,r:0},{b:"{",e:"}",i:/\S/,c:[e.CBCM,r]}]}}),e.registerLanguage("diff",function(e){return{aliases:["patch"],c:[{cN:"meta",r:10,v:[{b:/^@@ +\-\d+,\d+ +\+\d+,\d+ +@@$/},{b:/^\*\*\* +\d+,\d+ +\*\*\*\*$/},{b:/^\-\-\- +\d+,\d+ +\-\-\-\-$/}]},{cN:"comment",v:[{b:/Index: /,e:/$/},{b:/=====/,e:/=====$/},{b:/^\-\-\-/,e:/$/},{b:/^\*{3} /,e:/$/},{b:/^\+\+\+/,e:/$/},{b:/\*{5}/,e:/\*{5}$/}]},{cN:"addition",b:"^\\+",e:"$"},{cN:"deletion",b:"^\\-",e:"$"},{cN:"addition",b:"^\\!",e:"$"}]}}),e.registerLanguage("django",function(e){var t={b:/\|[A-Za-z]+:?/,k:{name:"truncatewords removetags linebreaksbr yesno get_digit timesince random striptags filesizeformat escape linebreaks length_is ljust rjust cut urlize fix_ampersands title floatformat capfirst pprint divisibleby add make_list unordered_list urlencode timeuntil urlizetrunc wordcount stringformat linenumbers slice date dictsort dictsortreversed default_if_none pluralize lower join center default truncatewords_html upper length phone2numeric wordwrap time addslashes slugify first escapejs force_escape iriencode last safe safeseq truncatechars localize unlocalize localtime utc timezone"},c:[e.QSM,e.ASM]};return{aliases:["jinja"],cI:!0,sL:"xml",c:[e.C(/\{%\s*comment\s*%}/,/\{%\s*endcomment\s*%}/),e.C(/\{#/,/#}/),{cN:"template-tag",b:/\{%/,e:/%}/,c:[{cN:"name",b:/\w+/,k:{name:"comment endcomment load templatetag ifchanged endifchanged if endif firstof for endfor ifnotequal endifnotequal widthratio extends include spaceless endspaceless regroup ifequal endifequal ssi now with cycle url filter endfilter debug block endblock else autoescape endautoescape csrf_token empty elif endwith static trans blocktrans endblocktrans get_static_prefix get_media_prefix plural get_current_language language get_available_languages get_current_language_bidi get_language_info get_language_info_list localize endlocalize localtime endlocaltime timezone endtimezone get_current_timezone verbatim"},starts:{eW:!0,k:"in by as",c:[t],r:0}}]},{cN:"template-variable",b:/\{\{/,e:/}}/,c:[t]}]}}),e.registerLanguage("dockerfile",function(e){return{aliases:["docker"],cI:!0,k:"from maintainer cmd expose add copy entrypoint volume user workdir onbuild run env label",c:[e.HCM,{k:"run cmd entrypoint volume add copy workdir onbuild label",b:/^ *(onbuild +)?(run|cmd|entrypoint|volume|add|copy|workdir|label) +/,starts:{e:/[^\\]\n/,sL:"bash"}},{k:"from maintainer expose env user onbuild",b:/^ *(onbuild +)?(from|maintainer|expose|env|user|onbuild) +/,e:/[^\\]\n/,c:[e.ASM,e.QSM,e.NM,e.HCM]}]}}),e.registerLanguage("dos",function(e){var t=e.C(/@?rem\b/,/$/,{r:10}),r={cN:"symbol",b:"^\\s*[A-Za-z._?][A-Za-z0-9_$#@~.?]*(:|\\s+label)",r:0};return{aliases:["bat","cmd"],cI:!0,i:/\/\*/,k:{keyword:"if else goto for in do call exit not exist errorlevel defined equ neq lss leq gtr geq",built_in:"prn nul lpt3 lpt2 lpt1 con com4 com3 com2 com1 aux shift cd dir echo setlocal endlocal set pause copy append assoc at attrib break cacls cd chcp chdir chkdsk chkntfs cls cmd color comp compact convert date dir diskcomp diskcopy doskey erase fs find findstr format ftype graftabl help keyb label md mkdir mode more move path pause print popd pushd promt rd recover rem rename replace restore rmdir shiftsort start subst time title tree type ver verify vol ping net ipconfig taskkill xcopy ren del"},c:[{cN:"variable",b:/%%[^ ]|%[^ ]+?%|![^ ]+?!/},{cN:"function",b:r.b,e:"goto:eof",c:[e.inherit(e.TM,{b:"([_a-zA-Z]\\w*\\.)*([_a-zA-Z]\\w*:)?[_a-zA-Z]\\w*"}),t]},{cN:"number",b:"\\b\\d+",r:0},t]}}),e.registerLanguage("go",function(e){var t={keyword:"break default func interface select case map struct chan else goto package switch const fallthrough if range 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",literal:"true false iota nil",built_in:"append cap close complex copy imag len make new panic print println real recover delete"};return{aliases:["golang"],k:t,i:">|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?",r="and false then defined module in return redo if BEGIN retry end for true self when next until do begin unless END rescue nil else break undef not super class case require yield alias while ensure elsif or include attr_reader attr_writer attr_accessor",n={cN:"doctag",b:"@[A-Za-z]+"},a={b:"#<",e:">"},i=[e.C("#","$",{c:[n]}),e.C("^\\=begin","^\\=end",{c:[n],r:10}),e.C("^__END__","\\n$")],o={cN:"subst",b:"#\\{",e:"}",k:r},c={cN:"string",c:[e.BE,o],v:[{b:/'/,e:/'/},{b:/"/,e:/"/},{b:/`/,e:/`/},{b:"%[qQwWx]?\\(",e:"\\)"},{b:"%[qQwWx]?\\[",e:"\\]"},{b:"%[qQwWx]?{",e:"}"},{b:"%[qQwWx]?<",e:">"},{b:"%[qQwWx]?/",e:"/"},{b:"%[qQwWx]?%",e:"%"},{b:"%[qQwWx]?-",e:"-"},{b:"%[qQwWx]?\\|",e:"\\|"},{b:/\B\?(\\\d{1,3}|\\x[A-Fa-f0-9]{1,2}|\\u[A-Fa-f0-9]{4}|\\?\S)\b/}]},s={cN:"params",b:"\\(",e:"\\)",endsParent:!0,k:r},l=[c,a,{cN:"class",bK:"class module",e:"$|;",i:/=/,c:[e.inherit(e.TM,{b:"[A-Za-z_]\\w*(::\\w+)*(\\?|\\!)?"}),{b:"<\\s*",c:[{b:"("+e.IR+"::)?"+e.IR}]}].concat(i)},{cN:"function",bK:"def",e:"$|;",c:[e.inherit(e.TM,{b:t}),s].concat(i)},{cN:"symbol",b:e.UIR+"(\\!|\\?)?:",r:0},{cN:"symbol",b:":",c:[c,{b:t}],r:0},{cN:"number",b:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",r:0},{b:"(\\$\\W)|((\\$|\\@\\@?)(\\w+))"},{b:"("+e.RSR+")\\s*",c:[a,{cN:"regexp",c:[e.BE,o],i:/\n/,v:[{b:"/",e:"/[a-z]*"},{b:"%r{",e:"}[a-z]*"},{b:"%r\\(",e:"\\)[a-z]*"},{b:"%r!",e:"![a-z]*"},{b:"%r\\[",e:"\\][a-z]*"}]}].concat(i),r:0}].concat(i);o.c=l,s.c=l;var b="[>?]>",u="[\\w#]+\\(\\w+\\):\\d+:\\d+>",d="(\\w+-)?\\d+\\.\\d+\\.\\d(p\\d+)?[^>]+>",p=[{b:/^\s*=>/,starts:{e:"$",c:l}},{cN:"meta",b:"^("+b+"|"+u+"|"+d+")",starts:{e:"$",c:l}}];return{aliases:["rb","gemspec","podspec","thor","irb"],k:r,i:/\/\*/,c:i.concat(p).concat(l)}}),e.registerLanguage("haml",function(e){return{cI:!0,c:[{cN:"meta",b:"^!!!( (5|1\\.1|Strict|Frameset|Basic|Mobile|RDFa|XML\\b.*))?$",r:10},e.C("^\\s*(!=#|=#|-#|/).*$",!1,{r:0}),{b:"^\\s*(-|=|!=)(?!#)",starts:{e:"\\n",sL:"ruby"}},{cN:"tag",b:"^\\s*%",c:[{cN:"selector-tag",b:"\\w+"},{cN:"selector-id",b:"#[\\w-]+"},{cN:"selector-class",b:"\\.[\\w-]+"},{b:"{\\s*",e:"\\s*}",c:[{b:":\\w+\\s*=>",e:",\\s+",rB:!0,eW:!0,c:[{cN:"attr",b:":\\w+"},e.ASM,e.QSM,{b:"\\w+",r:0}]}]},{b:"\\(\\s*",e:"\\s*\\)",eE:!0,c:[{b:"\\w+\\s*=",e:"\\s+",rB:!0,eW:!0,c:[{cN:"attr",b:"\\w+",r:0},e.ASM,e.QSM,{b:"\\w+",r:0}]}]}]},{b:"^\\s*[=~]\\s*"},{b:"#{",starts:{e:"}",sL:"ruby"}}]}}),e.registerLanguage("handlebars",function(e){var t={"builtin-name":"each in with if else unless bindattr action collection debugger log outlet template unbound view yield"};return{aliases:["hbs","html.hbs","html.handlebars"],cI:!0,sL:"xml",c:[e.C("{{!(--)?","(--)?}}"),{cN:"template-tag",b:/\{\{[#\/]/,e:/\}\}/,c:[{cN:"name",b:/[a-zA-Z\.-]+/,k:t,starts:{eW:!0,r:0,c:[e.QSM]}}]},{cN:"template-variable",b:/\{\{/,e:/\}\}/,k:t}]}}),e.registerLanguage("http",function(e){var t="HTTP/[0-9\\.]+";return{aliases:["https"],i:"\\S",c:[{b:"^"+t,e:"$",c:[{cN:"number",b:"\\b\\d{3}\\b"}]},{b:"^[A-Z]+ (.*?) "+t+"$",rB:!0,e:"$",c:[{cN:"string",b:" ",e:" ",eB:!0,eE:!0},{b:t},{cN:"keyword",b:"[A-Z]+"}]},{cN:"attribute",b:"^\\w",e:": ",eE:!0,i:"\\n|\\s|=",starts:{e:"$",r:0}},{b:"\\n\\n",starts:{sL:[],eW:!0}}]}}),e.registerLanguage("ini",function(e){var t={cN:"string",c:[e.BE],v:[{b:"'''",e:"'''",r:10},{b:'"""',e:'"""',r:10},{b:'"',e:'"'},{b:"'",e:"'"}]};return{aliases:["toml"],cI:!0,i:/\S/,c:[e.C(";","$"),e.HCM,{cN:"section",b:/^\s*\[+/,e:/\]+/},{b:/^[a-z0-9\[\]_-]+\s*=\s*/,e:"$",rB:!0,c:[{cN:"attr",b:/[a-z0-9\[\]_-]+/},{b:/=/,eW:!0,r:0,c:[{cN:"literal",b:/\bon|off|true|false|yes|no\b/},{cN:"variable",v:[{b:/\$[\w\d"][\w\d_]*/},{b:/\$\{(.*?)}/}]},t,{cN:"number",b:/([\+\-]+)?[\d]+_[\d_]+/},e.NM]}]}]}}),e.registerLanguage("javascript",function(e){return{aliases:["js"],k:{keyword:"in of if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const export super debugger as async await import from as",literal:"true false null undefined NaN Infinity",built_in:"eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Error EvalError InternalError RangeError ReferenceError StopIteration SyntaxError TypeError URIError Number Math Date String RegExp Array Float32Array Float64Array Int16Array Int32Array Int8Array Uint16Array Uint32Array Uint8Array Uint8ClampedArray ArrayBuffer DataView JSON Intl arguments require module console window document Symbol Set Map WeakSet WeakMap Proxy Reflect Promise"},c:[{cN:"meta",r:10,b:/^\s*['"]use (strict|asm)['"]/},e.ASM,e.QSM,{cN:"string",b:"`",e:"`",c:[e.BE,{cN:"subst",b:"\\$\\{",e:"\\}"}]},e.CLCM,e.CBCM,{cN:"number",v:[{b:"\\b(0[bB][01]+)"},{b:"\\b(0[oO][0-7]+)"},{b:e.CNR}],r:0},{b:"("+e.RSR+"|\\b(case|return|throw)\\b)\\s*",k:"return throw case",c:[e.CLCM,e.CBCM,e.RM,{b:/\s*[);\]]/,r:0,sL:"xml"}],r:0},{cN:"function",bK:"function",e:/\{/,eE:!0,c:[e.inherit(e.TM,{b:/[A-Za-z$_][0-9A-Za-z$_]*/}),{cN:"params",b:/\(/,e:/\)/,eB:!0,eE:!0,c:[e.CLCM,e.CBCM]}],i:/\[|%/},{b:/\$[(.]/},{b:"\\."+e.IR,r:0},{cN:"class",bK:"class",e:/[{;=]/,eE:!0,i:/[:"\[\]]/,c:[{bK:"extends"},e.UTM]},{bK:"constructor",e:/\{/,eE:!0}],i:/#/}}),e.registerLanguage("json",function(e){var t={literal:"true false null"},r=[e.QSM,e.CNM],n={e:",",eW:!0,eE:!0,c:r,k:t},a={b:"{",e:"}",c:[{cN:"attr",b:'\\s*"',e:'"\\s*:\\s*',eB:!0,eE:!0,c:[e.BE],i:"\\n",starts:n}],i:"\\S"},i={b:"\\[",e:"\\]",c:[e.inherit(n)],i:"\\S"};return r.splice(r.length,0,a,i),{c:r,k:t,i:"\\S"}}),e.registerLanguage("less",function(e){var t="[\\w-]+",r="("+t+"|@{"+t+"})",n=[],a=[],i=function(e){return{cN:"string",b:"~?"+e+".*?"+e}},o=function(e,t,r){return{cN:e,b:t,r:r}},c={b:"\\(",e:"\\)",c:a,r:0};a.push(e.CLCM,e.CBCM,i("'"),i('"'),e.CSSNM,{b:"(url|data-uri)\\(",starts:{cN:"string",e:"[\\)\\n]",eE:!0}},o("number","#[0-9A-Fa-f]+\\b"),c,o("variable","@@?"+t,10),o("variable","@{"+t+"}"),o("built_in","~?`[^`]*?`"),{cN:"attribute",b:t+"\\s*:",e:":",rB:!0,eE:!0},{cN:"meta",b:"!important"});var s=a.concat({b:"{",e:"}",c:n}),l={bK:"when",eW:!0,c:[{bK:"and not"}].concat(a)},b={cN:"attribute",b:r,e:":",eE:!0,c:[e.CLCM,e.CBCM],i:/\S/,starts:{e:"[;}]",rE:!0,c:a,i:"[<=$]"}},u={cN:"keyword",b:"@(import|media|charset|font-face|(-[a-z]+-)?keyframes|supports|document|namespace|page|viewport|host)\\b",starts:{e:"[;{}]",rE:!0,c:a,r:0}},d={cN:"variable",v:[{b:"@"+t+"\\s*:",r:15},{b:"@"+t}],starts:{e:"[;}]",rE:!0,c:s}},p={v:[{b:"[\\.#:&\\[]",e:"[;{}]"},{b:r+"[^;]*{",e:"{"}],rB:!0,rE:!0,i:"[<='$\"]",c:[e.CLCM,e.CBCM,l,o("keyword","all\\b"),o("variable","@{"+t+"}"),o("selector-tag",r+"%?",0),o("selector-id","#"+r),o("selector-class","\\."+r,0),o("selector-tag","&",0),{cN:"selector-attr",b:"\\[",e:"\\]"},{b:"\\(",e:"\\)",c:s},{b:"!important"}]};return n.push(e.CLCM,e.CBCM,u,d,p,b),{cI:!0,i:"[=>'/<($\"]",c:n}}),e.registerLanguage("makefile",function(e){var t={cN:"variable",b:/\$\(/,e:/\)/,c:[e.BE]};return{aliases:["mk","mak"],c:[e.HCM,{b:/^\w+\s*\W*=/,rB:!0,r:0,starts:{e:/\s*\W*=/,eE:!0,starts:{e:/$/,r:0,c:[t]}}},{cN:"section",b:/^[\w]+:\s*$/},{cN:"meta",b:/^\.PHONY:/,e:/$/,k:{"meta-keyword":".PHONY"},l:/[\.\w]+/},{b:/^\t+/,e:/$/,r:0,c:[e.QSM,t]}]}}),e.registerLanguage("markdown",function(e){return{aliases:["md","mkdown","mkd"],c:[{cN:"section",v:[{b:"^#{1,6}",e:"$"},{b:"^.+?\\n[=-]{2,}$"}]},{b:"<",e:">",sL:"xml",r:0},{cN:"bullet",b:"^([*+-]|(\\d+\\.))\\s+"},{cN:"strong",b:"[*_]{2}.+?[*_]{2}"},{cN:"emphasis",v:[{b:"\\*.+?\\*"},{b:"_.+?_",r:0}]},{cN:"quote",b:"^>\\s+",e:"$"},{cN:"code",v:[{b:"`.+?`"},{b:"^( {4}| )",e:"$",r:0}]},{b:"^[-\\*]{3,}",e:"$"},{b:"\\[.+?\\][\\(\\[].*?[\\)\\]]",rB:!0,c:[{cN:"string",b:"\\[",e:"\\]",eB:!0,rE:!0,r:0},{cN:"link",b:"\\]\\(",e:"\\)",eB:!0,eE:!0},{cN:"symbol",b:"\\]\\[",e:"\\]",eB:!0,eE:!0}],r:10},{b:"^\\[.+\\]:",rB:!0,c:[{cN:"symbol",b:"\\[",e:"\\]:",eB:!0,eE:!0,starts:{cN:"link",e:"$"}}]}]}}),e.registerLanguage("nginx",function(e){var t={cN:"variable",v:[{b:/\$\d+/},{b:/\$\{/,e:/}/},{b:"[\\$\\@]"+e.UIR}]},r={eW:!0,l:"[a-z/_]+",k:{literal:"on off yes no true false none blocked debug info notice warn error crit select break last permanent redirect kqueue rtsig epoll poll /dev/poll"},r:0,i:"=>",c:[e.HCM,{cN:"string",c:[e.BE,t],v:[{b:/"/,e:/"/},{b:/'/,e:/'/}]},{b:"([a-z]+):/",e:"\\s",eW:!0,eE:!0,c:[t]},{cN:"regexp",c:[e.BE,t],v:[{b:"\\s\\^",e:"\\s|{|;",rE:!0},{b:"~\\*?\\s+",e:"\\s|{|;",rE:!0},{b:"\\*(\\.[a-z\\-]+)+"},{b:"([a-z\\-]+\\.)+\\*"}]},{cN:"number",b:"\\b\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d{1,5})?\\b"},{cN:"number",b:"\\b\\d+[kKmMgGdshdwy]*\\b",r:0},t]};return{aliases:["nginxconf"],c:[e.HCM,{b:e.UIR+"\\s+{",rB:!0,e:"{",c:[{cN:"section",b:e.UIR}],r:0},{b:e.UIR+"\\s",e:";|{",rB:!0,c:[{cN:"attribute",b:e.UIR,starts:r}],r:0}],i:"[^\\s\\}]"}}),e.registerLanguage("php",function(e){var t={b:"\\$+[a-zA-Z_-ÿ][a-zA-Z0-9_-ÿ]*"},r={cN:"meta",b:/<\?(php)?|\?>/},n={cN:"string",c:[e.BE,r],v:[{b:'b"',e:'"'},{b:"b'",e:"'"},e.inherit(e.ASM,{i:null}),e.inherit(e.QSM,{i:null})]},a={v:[e.BNM,e.CNM]};return{aliases:["php3","php4","php5","php6"],cI:!0,k:"and include_once list abstract global private echo interface as static endswitch array null if endwhile or const for endforeach self var while isset public protected exit foreach throw elseif include __FILE__ empty require_once do xor return parent clone use __CLASS__ __LINE__ else break print eval new catch __METHOD__ case exception default die require __FUNCTION__ enddeclare final try switch continue endfor endif declare unset true false trait goto instanceof insteadof __DIR__ __NAMESPACE__ yield finally",c:[e.CLCM,e.HCM,e.C("/\\*","\\*/",{c:[{cN:"doctag",b:"@[A-Za-z]+"},r]}),e.C("__halt_compiler.+?;",!1,{eW:!0,k:"__halt_compiler",l:e.UIR}),{cN:"string",b:/<<<['"]?\w+['"]?$/,e:/^\w+;?$/,c:[e.BE,{cN:"subst",v:[{b:/\$\w+/},{b:/\{\$/,e:/\}/}]}]},r,t,{b:/(::|->)+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/},{cN:"function",bK:"function",e:/[;{]/,eE:!0,i:"\\$|\\[|%",c:[e.UTM,{cN:"params",b:"\\(",e:"\\)",c:["self",t,e.CBCM,n,a]}]},{cN:"class",bK:"class interface",e:"{",eE:!0,i:/[:\(\$"]/,c:[{bK:"extends implements"},e.UTM]},{bK:"namespace",e:";",i:/[\.']/,c:[e.UTM]},{bK:"use",e:";",c:[e.UTM]},{b:"=>"},n,a]}}),e.registerLanguage("powershell",function(e){var t={b:"`[\\s\\S]",r:0},r={cN:"variable",v:[{b:/\$[\w\d][\w\d_:]*/}]},n={cN:"literal",b:/\$(null|true|false)\b/},a={cN:"string",b:/"/,e:/"/,c:[t,r,{cN:"variable",b:/\$[A-z]/,e:/[^A-z]/}]},i={cN:"string",b:/'/,e:/'/};return{aliases:["ps"],l:/-?[A-z\.\-]+/,cI:!0,k:{keyword:"if else foreach return function do while until elseif begin for trap data dynamicparam end break throw param continue finally in switch exit filter try process catch",built_in:"Add-Content Add-History Add-Member Add-PSSnapin Clear-Content Clear-Item Clear-Item Property Clear-Variable Compare-Object ConvertFrom-SecureString Convert-Path ConvertTo-Html ConvertTo-SecureString Copy-Item Copy-ItemProperty Export-Alias Export-Clixml Export-Console Export-Csv ForEach-Object Format-Custom Format-List Format-Table Format-Wide Get-Acl Get-Alias Get-AuthenticodeSignature Get-ChildItem Get-Command Get-Content Get-Credential Get-Culture Get-Date Get-EventLog Get-ExecutionPolicy Get-Help Get-History Get-Host Get-Item Get-ItemProperty Get-Location Get-Member Get-PfxCertificate Get-Process Get-PSDrive Get-PSProvider Get-PSSnapin Get-Service Get-TraceSource Get-UICulture Get-Unique Get-Variable Get-WmiObject Group-Object Import-Alias Import-Clixml Import-Csv Invoke-Expression Invoke-History Invoke-Item Join-Path Measure-Command Measure-Object Move-Item Move-ItemProperty New-Alias New-Item New-ItemProperty New-Object New-PSDrive New-Service New-TimeSpan New-Variable Out-Default Out-File Out-Host Out-Null Out-Printer Out-String Pop-Location Push-Location Read-Host Remove-Item Remove-ItemProperty Remove-PSDrive Remove-PSSnapin Remove-Variable Rename-Item Rename-ItemProperty Resolve-Path Restart-Service Resume-Service Select-Object Select-String Set-Acl Set-Alias Set-AuthenticodeSignature Set-Content Set-Date Set-ExecutionPolicy Set-Item Set-ItemProperty Set-Location Set-PSDebug Set-Service Set-TraceSource Set-Variable Sort-Object Split-Path Start-Service Start-Sleep Start-Transcript Stop-Process Stop-Service Stop-Transcript Suspend-Service Tee-Object Test-Path Trace-Command Update-FormatData Update-TypeData Where-Object Write-Debug Write-Error Write-Host Write-Output Write-Progress Write-Verbose Write-Warning",nomarkup:"-ne -eq -lt -gt -ge -le -not -like -notlike -match -notmatch -contains -notcontains -in -notin -replace"},c:[e.HCM,e.NM,a,i,n,r]}}),e.registerLanguage("python",function(e){var t={cN:"meta",b:/^(>>>|\.\.\.) /},r={cN:"string",c:[e.BE],v:[{b:/(u|b)?r?'''/,e:/'''/,c:[t],r:10},{b:/(u|b)?r?"""/,e:/"""/,c:[t],r:10},{b:/(u|r|ur)'/,e:/'/,r:10},{b:/(u|r|ur)"/,e:/"/,r:10},{b:/(b|br)'/,e:/'/},{b:/(b|br)"/,e:/"/},e.ASM,e.QSM]},n={cN:"number",r:0,v:[{b:e.BNR+"[lLjJ]?"},{b:"\\b(0o[0-7]+)[lLjJ]?"},{b:e.CNR+"[lLjJ]?"}]},a={cN:"params",b:/\(/,e:/\)/,c:["self",t,n,r]};return{aliases:["py","gyp"],k:{keyword:"and elif is global as in if from raise for except finally print import pass return exec else break not with class assert yield try while continue del or def lambda async await nonlocal|10 None True False",built_in:"Ellipsis NotImplemented"},i:/(<\/|->|\?)/,c:[t,n,r,e.HCM,{v:[{cN:"function",bK:"def",r:10},{cN:"class",bK:"class"}],e:/:/,i:/[${=;\n,]/,c:[e.UTM,a]},{cN:"meta",b:/^[\t ]*@/,e:/$/},{b:/\b(print|exec)\(/}]}}),e.registerLanguage("scss",function(e){var t="[a-zA-Z-][a-zA-Z0-9_-]*",r={cN:"variable",b:"(\\$"+t+")\\b"},n={cN:"number",b:"#[0-9A-Fa-f]+"};({cN:"attribute",b:"[A-Z\\_\\.\\-]+",e:":",eE:!0,i:"[^\\s]",starts:{eW:!0,eE:!0,c:[n,e.CSSNM,e.QSM,e.ASM,e.CBCM,{cN:"meta",b:"!important"}]}});return{cI:!0,i:"[=/|']",c:[e.CLCM,e.CBCM,{cN:"selector-id",b:"\\#[A-Za-z0-9_-]+",r:0},{cN:"selector-class",b:"\\.[A-Za-z0-9_-]+",r:0},{cN:"selector-attr",b:"\\[",e:"\\]",i:"$"},{cN:"selector-tag",b:"\\b(a|abbr|acronym|address|area|article|aside|audio|b|base|big|blockquote|body|br|button|canvas|caption|cite|code|col|colgroup|command|datalist|dd|del|details|dfn|div|dl|dt|em|embed|fieldset|figcaption|figure|footer|form|frame|frameset|(h[1-6])|head|header|hgroup|hr|html|i|iframe|img|input|ins|kbd|keygen|label|legend|li|link|map|mark|meta|meter|nav|noframes|noscript|object|ol|optgroup|option|output|p|param|pre|progress|q|rp|rt|ruby|samp|script|section|select|small|span|strike|strong|style|sub|sup|table|tbody|td|textarea|tfoot|th|thead|time|title|tr|tt|ul|var|video)\\b", -r:0},{b:":(visited|valid|root|right|required|read-write|read-only|out-range|optional|only-of-type|only-child|nth-of-type|nth-last-of-type|nth-last-child|nth-child|not|link|left|last-of-type|last-child|lang|invalid|indeterminate|in-range|hover|focus|first-of-type|first-line|first-letter|first-child|first|enabled|empty|disabled|default|checked|before|after|active)"},{b:"::(after|before|choices|first-letter|first-line|repeat-index|repeat-item|selection|value)"},r,{cN:"attribute",b:"\\b(z-index|word-wrap|word-spacing|word-break|width|widows|white-space|visibility|vertical-align|unicode-bidi|transition-timing-function|transition-property|transition-duration|transition-delay|transition|transform-style|transform-origin|transform|top|text-underline-position|text-transform|text-shadow|text-rendering|text-overflow|text-indent|text-decoration-style|text-decoration-line|text-decoration-color|text-decoration|text-align-last|text-align|tab-size|table-layout|right|resize|quotes|position|pointer-events|perspective-origin|perspective|page-break-inside|page-break-before|page-break-after|padding-top|padding-right|padding-left|padding-bottom|padding|overflow-y|overflow-x|overflow-wrap|overflow|outline-width|outline-style|outline-offset|outline-color|outline|orphans|order|opacity|object-position|object-fit|normal|none|nav-up|nav-right|nav-left|nav-index|nav-down|min-width|min-height|max-width|max-height|mask|marks|margin-top|margin-right|margin-left|margin-bottom|margin|list-style-type|list-style-position|list-style-image|list-style|line-height|letter-spacing|left|justify-content|initial|inherit|ime-mode|image-orientation|image-resolution|image-rendering|icon|hyphens|height|font-weight|font-variant-ligatures|font-variant|font-style|font-stretch|font-size-adjust|font-size|font-language-override|font-kerning|font-feature-settings|font-family|font|float|flex-wrap|flex-shrink|flex-grow|flex-flow|flex-direction|flex-basis|flex|filter|empty-cells|display|direction|cursor|counter-reset|counter-increment|content|column-width|column-span|column-rule-width|column-rule-style|column-rule-color|column-rule|column-gap|column-fill|column-count|columns|color|clip-path|clip|clear|caption-side|break-inside|break-before|break-after|box-sizing|box-shadow|box-decoration-break|bottom|border-width|border-top-width|border-top-style|border-top-right-radius|border-top-left-radius|border-top-color|border-top|border-style|border-spacing|border-right-width|border-right-style|border-right-color|border-right|border-radius|border-left-width|border-left-style|border-left-color|border-left|border-image-width|border-image-source|border-image-slice|border-image-repeat|border-image-outset|border-image|border-color|border-collapse|border-bottom-width|border-bottom-style|border-bottom-right-radius|border-bottom-left-radius|border-bottom-color|border-bottom|border|background-size|background-repeat|background-position|background-origin|background-image|background-color|background-clip|background-attachment|background-blend-mode|background|backface-visibility|auto|animation-timing-function|animation-play-state|animation-name|animation-iteration-count|animation-fill-mode|animation-duration|animation-direction|animation-delay|animation|align-self|align-items|align-content)\\b",i:"[^\\s]"},{b:"\\b(whitespace|wait|w-resize|visible|vertical-text|vertical-ideographic|uppercase|upper-roman|upper-alpha|underline|transparent|top|thin|thick|text|text-top|text-bottom|tb-rl|table-header-group|table-footer-group|sw-resize|super|strict|static|square|solid|small-caps|separate|se-resize|scroll|s-resize|rtl|row-resize|ridge|right|repeat|repeat-y|repeat-x|relative|progress|pointer|overline|outside|outset|oblique|nowrap|not-allowed|normal|none|nw-resize|no-repeat|no-drop|newspaper|ne-resize|n-resize|move|middle|medium|ltr|lr-tb|lowercase|lower-roman|lower-alpha|loose|list-item|line|line-through|line-edge|lighter|left|keep-all|justify|italic|inter-word|inter-ideograph|inside|inset|inline|inline-block|inherit|inactive|ideograph-space|ideograph-parenthesis|ideograph-numeric|ideograph-alpha|horizontal|hidden|help|hand|groove|fixed|ellipsis|e-resize|double|dotted|distribute|distribute-space|distribute-letter|distribute-all-lines|disc|disabled|default|decimal|dashed|crosshair|collapse|col-resize|circle|char|center|capitalize|break-word|break-all|bottom|both|bolder|bold|block|bidi-override|below|baseline|auto|always|all-scroll|absolute|table|table-cell)\\b"},{b:":",e:";",c:[r,n,e.CSSNM,e.QSM,e.ASM,{cN:"meta",b:"!important"}]},{b:"@",e:"[{;]",k:"mixin include extend for if else each while charset import debug media page content font-face namespace warn",c:[r,e.QSM,e.ASM,n,e.CSSNM,{b:"\\s[A-Za-z0-9_.-]+",r:0}]}]}}),e.registerLanguage("tex",function(e){var t={cN:"tag",b:/\\/,r:0,c:[{cN:"name",v:[{b:/[a-zA-Zа-яА-я]+[*]?/},{b:/[^a-zA-Zа-яА-я0-9]/}],starts:{eW:!0,r:0,c:[{cN:"string",v:[{b:/\[/,e:/\]/},{b:/\{/,e:/\}/}]},{b:/\s*=\s*/,eW:!0,r:0,c:[{cN:"number",b:/-?\d*\.?\d+(pt|pc|mm|cm|in|dd|cc|ex|em)?/}]}]}}]};return{c:[t,{cN:"formula",c:[t],r:0,v:[{b:/\$\$/,e:/\$\$/},{b:/\$/,e:/\$/}]},e.C("%","$",{r:0})]}}),e.registerLanguage("yaml",function(e){var t={literal:"{ } true false yes no Yes No True False null"},r="^[ \\-]*",n="[a-zA-Z_][\\w\\-]*",a={cN:"attr",v:[{b:r+n+":"},{b:r+'"'+n+'":'},{b:r+"'"+n+"':"}]},i={cN:"template-variable",v:[{b:"{{",e:"}}"},{b:"%{",e:"}"}]},o={cN:"string",r:0,v:[{b:/'/,e:/'/},{b:/"/,e:/"/}],c:[e.BE,i]};return{cI:!0,aliases:["yml","YAML","yaml"],c:[a,{cN:"meta",b:"^---s*$",r:10},{cN:"string",b:"[\\|>] *$",rE:!0,c:o.c,e:a.v[0].b},{b:"<%[%=-]?",e:"[%-]?%>",sL:"ruby",eB:!0,eE:!0,r:0},{cN:"type",b:"!!"+e.UIR},{cN:"meta",b:"&"+e.UIR+"$"},{cN:"meta",b:"\\*"+e.UIR+"$"},{cN:"bullet",b:"^ *-",r:0},o,e.HCM,e.CNM],k:t}}),e}); \ No newline at end of file diff --git a/docs/static/vendor/highlightjs/notes.txt b/docs/static/vendor/highlightjs/notes.txt deleted file mode 100644 index 43475e5f9..000000000 --- a/docs/static/vendor/highlightjs/notes.txt +++ /dev/null @@ -1,13 +0,0 @@ -Version: 9.0.0 - -Source: - https://github.com/isagalaev/highlight.js - -Also see Hugo commit: - 7cf7f85ad66a1cd35ab4e6026ed6b9da95c53c34. - -Files (there): - src/styles/monokai-sublime.css - -Files (we build): - highlight.pack.js diff --git a/docs/static/vendor/jquery/js/jquery-2.1.4.min.js b/docs/static/vendor/jquery/js/jquery-2.1.4.min.js deleted file mode 100644 index 49990d6e1..000000000 --- a/docs/static/vendor/jquery/js/jquery-2.1.4.min.js +++ /dev/null @@ -1,4 +0,0 @@ -/*! jQuery v2.1.4 | (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=a.document,m="2.1.4",n=function(a,b){return new n.fn.init(a,b)},o=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,p=/^-ms-/,q=/-([\da-z])/gi,r=function(a,b){return b.toUpperCase()};n.fn=n.prototype={jquery:m,constructor:n,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=n.merge(this.constructor(),a);return b.prevObject=this,b.context=this.context,b},each:function(a,b){return n.each(this,a,b)},map:function(a){return this.pushStack(n.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},n.extend=n.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||n.isFunction(g)||(g={}),h===i&&(g=this,h--);i>h;h++)if(null!=(a=arguments[h]))for(b in a)c=g[b],d=a[b],g!==d&&(j&&d&&(n.isPlainObject(d)||(e=n.isArray(d)))?(e?(e=!1,f=c&&n.isArray(c)?c:[]):f=c&&n.isPlainObject(c)?c:{},g[b]=n.extend(j,f,d)):void 0!==d&&(g[b]=d));return g},n.extend({expando:"jQuery"+(m+Math.random()).replace(/\D/g,""),isReady:!0,error:function(a){throw new Error(a)},noop:function(){},isFunction:function(a){return"function"===n.type(a)},isArray:Array.isArray,isWindow:function(a){return null!=a&&a===a.window},isNumeric:function(a){return!n.isArray(a)&&a-parseFloat(a)+1>=0},isPlainObject:function(a){return"object"!==n.type(a)||a.nodeType||n.isWindow(a)?!1:a.constructor&&!j.call(a.constructor.prototype,"isPrototypeOf")?!1:!0},isEmptyObject:function(a){var b;for(b in a)return!1;return!0},type:function(a){return null==a?a+"":"object"==typeof a||"function"==typeof a?h[i.call(a)]||"object":typeof a},globalEval:function(a){var b,c=eval;a=n.trim(a),a&&(1===a.indexOf("use strict")?(b=l.createElement("script"),b.text=a,l.head.appendChild(b).parentNode.removeChild(b)):c(a))},camelCase:function(a){return a.replace(p,"ms-").replace(q,r)},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=s(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(o,"")},makeArray:function(a,b){var c=b||[];return null!=a&&(s(Object(a))?n.merge(c,"string"==typeof a?[a]:a):f.call(c,a)),c},inArray:function(a,b,c){return null==b?-1:g.call(b,a,c)},merge:function(a,b){for(var c=+b.length,d=0,e=a.length;c>d;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=s(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&&(c=a[b],b=a,a=c),n.isFunction(a)?(e=d.call(arguments,2),f=function(){return a.apply(b||this,e.concat(d.call(arguments)))},f.guid=a.guid=a.guid||n.guid++,f):void 0},now:Date.now,support:k}),n.each("Boolean Number String Function Array Date RegExp Object Error".split(" "),function(a,b){h["[object "+b+"]"]=b.toLowerCase()});function s(a){var b="length"in a&&a.length,c=n.type(a);return"function"===c||n.isWindow(a)?!1:1===a.nodeType&&b?!0:"array"===c||0===b||"number"==typeof b&&b>0&&b-1 in a}var t=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);n.find=t,n.expr=t.selectors,n.expr[":"]=n.expr.pseudos,n.unique=t.uniqueSort,n.text=t.getText,n.isXMLDoc=t.isXML,n.contains=t.contains;var u=n.expr.match.needsContext,v=/^<(\w+)\s*\/?>(?:<\/\1>|)$/,w=/^.[^:#\[\.,]*$/;function x(a,b,c){if(n.isFunction(b))return n.grep(a,function(a,d){return!!b.call(a,d,a)!==c});if(b.nodeType)return n.grep(a,function(a){return a===b!==c});if("string"==typeof b){if(w.test(b))return n.filter(b,a,c);b=n.filter(b,a)}return n.grep(a,function(a){return g.call(b,a)>=0!==c})}n.filter=function(a,b,c){var d=b[0];return c&&(a=":not("+a+")"),1===b.length&&1===d.nodeType?n.find.matchesSelector(d,a)?[d]:[]:n.find.matches(a,n.grep(b,function(a){return 1===a.nodeType}))},n.fn.extend({find:function(a){var b,c=this.length,d=[],e=this;if("string"!=typeof a)return this.pushStack(n(a).filter(function(){for(b=0;c>b;b++)if(n.contains(e[b],this))return!0}));for(b=0;c>b;b++)n.find(a,e[b],d);return d=this.pushStack(c>1?n.unique(d):d),d.selector=this.selector?this.selector+" "+a:a,d},filter:function(a){return this.pushStack(x(this,a||[],!1))},not:function(a){return this.pushStack(x(this,a||[],!0))},is:function(a){return!!x(this,"string"==typeof a&&u.test(a)?n(a):a||[],!1).length}});var y,z=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/,A=n.fn.init=function(a,b){var c,d;if(!a)return this;if("string"==typeof a){if(c="<"===a[0]&&">"===a[a.length-1]&&a.length>=3?[null,a,null]:z.exec(a),!c||!c[1]&&b)return!b||b.jquery?(b||y).find(a):this.constructor(b).find(a);if(c[1]){if(b=b instanceof n?b[0]:b,n.merge(this,n.parseHTML(c[1],b&&b.nodeType?b.ownerDocument||b:l,!0)),v.test(c[1])&&n.isPlainObject(b))for(c in b)n.isFunction(this[c])?this[c](b[c]):this.attr(c,b[c]);return this}return d=l.getElementById(c[2]),d&&d.parentNode&&(this.length=1,this[0]=d),this.context=l,this.selector=a,this}return a.nodeType?(this.context=this[0]=a,this.length=1,this):n.isFunction(a)?"undefined"!=typeof y.ready?y.ready(a):a(n):(void 0!==a.selector&&(this.selector=a.selector,this.context=a.context),n.makeArray(a,this))};A.prototype=n.fn,y=n(l);var B=/^(?:parents|prev(?:Until|All))/,C={children:!0,contents:!0,next:!0,prev:!0};n.extend({dir:function(a,b,c){var d=[],e=void 0!==c;while((a=a[b])&&9!==a.nodeType)if(1===a.nodeType){if(e&&n(a).is(c))break;d.push(a)}return d},sibling:function(a,b){for(var c=[];a;a=a.nextSibling)1===a.nodeType&&a!==b&&c.push(a);return c}}),n.fn.extend({has:function(a){var b=n(a,this),c=b.length;return this.filter(function(){for(var a=0;c>a;a++)if(n.contains(this,b[a]))return!0})},closest:function(a,b){for(var c,d=0,e=this.length,f=[],g=u.test(a)||"string"!=typeof a?n(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&&n.find.matchesSelector(c,a))){f.push(c);break}return this.pushStack(f.length>1?n.unique(f):f)},index:function(a){return a?"string"==typeof a?g.call(n(a),this[0]):g.call(this,a.jquery?a[0]:a):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(a,b){return this.pushStack(n.unique(n.merge(this.get(),n(a,b))))},addBack:function(a){return this.add(null==a?this.prevObject:this.prevObject.filter(a))}});function D(a,b){while((a=a[b])&&1!==a.nodeType);return a}n.each({parent:function(a){var b=a.parentNode;return b&&11!==b.nodeType?b:null},parents:function(a){return n.dir(a,"parentNode")},parentsUntil:function(a,b,c){return n.dir(a,"parentNode",c)},next:function(a){return D(a,"nextSibling")},prev:function(a){return D(a,"previousSibling")},nextAll:function(a){return n.dir(a,"nextSibling")},prevAll:function(a){return n.dir(a,"previousSibling")},nextUntil:function(a,b,c){return n.dir(a,"nextSibling",c)},prevUntil:function(a,b,c){return n.dir(a,"previousSibling",c)},siblings:function(a){return n.sibling((a.parentNode||{}).firstChild,a)},children:function(a){return n.sibling(a.firstChild)},contents:function(a){return a.contentDocument||n.merge([],a.childNodes)}},function(a,b){n.fn[a]=function(c,d){var e=n.map(this,b,c);return"Until"!==a.slice(-5)&&(d=c),d&&"string"==typeof d&&(e=n.filter(d,e)),this.length>1&&(C[a]||n.unique(e),B.test(a)&&e.reverse()),this.pushStack(e)}});var E=/\S+/g,F={};function G(a){var b=F[a]={};return n.each(a.match(E)||[],function(a,c){b[c]=!0}),b}n.Callbacks=function(a){a="string"==typeof a?F[a]||G(a):n.extend({},a);var b,c,d,e,f,g,h=[],i=!a.once&&[],j=function(l){for(b=a.memory&&l,c=!0,g=e||0,e=0,f=h.length,d=!0;h&&f>g;g++)if(h[g].apply(l[0],l[1])===!1&&a.stopOnFalse){b=!1;break}d=!1,h&&(i?i.length&&j(i.shift()):b?h=[]:k.disable())},k={add:function(){if(h){var c=h.length;!function g(b){n.each(b,function(b,c){var d=n.type(c);"function"===d?a.unique&&k.has(c)||h.push(c):c&&c.length&&"string"!==d&&g(c)})}(arguments),d?f=h.length:b&&(e=c,j(b))}return this},remove:function(){return h&&n.each(arguments,function(a,b){var c;while((c=n.inArray(b,h,c))>-1)h.splice(c,1),d&&(f>=c&&f--,g>=c&&g--)}),this},has:function(a){return a?n.inArray(a,h)>-1:!(!h||!h.length)},empty:function(){return h=[],f=0,this},disable:function(){return h=i=b=void 0,this},disabled:function(){return!h},lock:function(){return i=void 0,b||k.disable(),this},locked:function(){return!i},fireWith:function(a,b){return!h||c&&!i||(b=b||[],b=[a,b.slice?b.slice():b],d?i.push(b):j(b)),this},fire:function(){return k.fireWith(this,arguments),this},fired:function(){return!!c}};return k},n.extend({Deferred:function(a){var b=[["resolve","done",n.Callbacks("once memory"),"resolved"],["reject","fail",n.Callbacks("once memory"),"rejected"],["notify","progress",n.Callbacks("memory")]],c="pending",d={state:function(){return c},always:function(){return e.done(arguments).fail(arguments),this},then:function(){var a=arguments;return n.Deferred(function(c){n.each(b,function(b,f){var g=n.isFunction(a[b])&&a[b];e[f[1]](function(){var a=g&&g.apply(this,arguments);a&&n.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?n.extend(a,d):d}},e={};return d.pipe=d.then,n.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&&n.isFunction(a.promise)?e:0,g=1===f?a:n.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]&&n.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;n.fn.ready=function(a){return n.ready.promise().done(a),this},n.extend({isReady:!1,readyWait:1,holdReady:function(a){a?n.readyWait++:n.ready(!0)},ready:function(a){(a===!0?--n.readyWait:n.isReady)||(n.isReady=!0,a!==!0&&--n.readyWait>0||(H.resolveWith(l,[n]),n.fn.triggerHandler&&(n(l).triggerHandler("ready"),n(l).off("ready"))))}});function I(){l.removeEventListener("DOMContentLoaded",I,!1),a.removeEventListener("load",I,!1),n.ready()}n.ready.promise=function(b){return H||(H=n.Deferred(),"complete"===l.readyState?setTimeout(n.ready):(l.addEventListener("DOMContentLoaded",I,!1),a.addEventListener("load",I,!1))),H.promise(b)},n.ready.promise();var J=n.access=function(a,b,c,d,e,f,g){var h=0,i=a.length,j=null==c;if("object"===n.type(c)){e=!0;for(h in c)n.access(a,b,h,c[h],!0,f,g)}else if(void 0!==d&&(e=!0,n.isFunction(d)||(g=!0),j&&(g?(b.call(a,d),b=null):(j=b,b=function(a,b,c){return j.call(n(a),c)})),b))for(;i>h;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};n.acceptData=function(a){return 1===a.nodeType||9===a.nodeType||!+a.nodeType};function K(){Object.defineProperty(this.cache={},0,{get:function(){return{}}}),this.expando=n.expando+K.uid++}K.uid=1,K.accepts=n.acceptData,K.prototype={key:function(a){if(!K.accepts(a))return 0;var b={},c=a[this.expando];if(!c){c=K.uid++;try{b[this.expando]={value:c},Object.defineProperties(a,b)}catch(d){b[this.expando]=c,n.extend(a,b)}}return this.cache[c]||(this.cache[c]={}),c},set:function(a,b,c){var d,e=this.key(a),f=this.cache[e];if("string"==typeof b)f[b]=c;else if(n.isEmptyObject(f))n.extend(this.cache[e],b);else for(d in b)f[d]=b[d];return f},get:function(a,b){var c=this.cache[this.key(a)];return void 0===b?c:c[b]},access:function(a,b,c){var d;return void 0===b||b&&"string"==typeof b&&void 0===c?(d=this.get(a,b),void 0!==d?d:this.get(a,n.camelCase(b))):(this.set(a,b,c),void 0!==c?c:b)},remove:function(a,b){var c,d,e,f=this.key(a),g=this.cache[f];if(void 0===b)this.cache[f]={};else{n.isArray(b)?d=b.concat(b.map(n.camelCase)):(e=n.camelCase(b),b in g?d=[b,e]:(d=e,d=d in g?[d]:d.match(E)||[])),c=d.length;while(c--)delete g[d[c]]}},hasData:function(a){return!n.isEmptyObject(this.cache[a[this.expando]]||{})},discard:function(a){a[this.expando]&&delete this.cache[a[this.expando]]}};var L=new K,M=new K,N=/^(?:\{[\w\W]*\}|\[[\w\W]*\])$/,O=/([A-Z])/g;function P(a,b,c){var d;if(void 0===c&&1===a.nodeType)if(d="data-"+b.replace(O,"-$1").toLowerCase(),c=a.getAttribute(d),"string"==typeof c){try{c="true"===c?!0:"false"===c?!1:"null"===c?null:+c+""===c?+c:N.test(c)?n.parseJSON(c):c}catch(e){}M.set(a,b,c)}else c=void 0;return c}n.extend({hasData:function(a){return M.hasData(a)||L.hasData(a)},data:function(a,b,c){ -return M.access(a,b,c)},removeData:function(a,b){M.remove(a,b)},_data:function(a,b,c){return L.access(a,b,c)},_removeData:function(a,b){L.remove(a,b)}}),n.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.get(f),1===f.nodeType&&!L.get(f,"hasDataAttrs"))){c=g.length;while(c--)g[c]&&(d=g[c].name,0===d.indexOf("data-")&&(d=n.camelCase(d.slice(5)),P(f,d,e[d])));L.set(f,"hasDataAttrs",!0)}return e}return"object"==typeof a?this.each(function(){M.set(this,a)}):J(this,function(b){var c,d=n.camelCase(a);if(f&&void 0===b){if(c=M.get(f,a),void 0!==c)return c;if(c=M.get(f,d),void 0!==c)return c;if(c=P(f,d,void 0),void 0!==c)return c}else this.each(function(){var c=M.get(this,d);M.set(this,d,b),-1!==a.indexOf("-")&&void 0!==c&&M.set(this,a,b)})},null,b,arguments.length>1,null,!0)},removeData:function(a){return this.each(function(){M.remove(this,a)})}}),n.extend({queue:function(a,b,c){var d;return a?(b=(b||"fx")+"queue",d=L.get(a,b),c&&(!d||n.isArray(c)?d=L.access(a,b,n.makeArray(c)):d.push(c)),d||[]):void 0},dequeue:function(a,b){b=b||"fx";var c=n.queue(a,b),d=c.length,e=c.shift(),f=n._queueHooks(a,b),g=function(){n.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 L.get(a,c)||L.access(a,c,{empty:n.Callbacks("once memory").add(function(){L.remove(a,[b+"queue",c])})})}}),n.fn.extend({queue:function(a,b){var c=2;return"string"!=typeof a&&(b=a,a="fx",c--),arguments.lengthx",k.noCloneChecked=!!b.cloneNode(!0).lastChild.defaultValue}();var U="undefined";k.focusinBubbles="onfocusin"in a;var V=/^key/,W=/^(?:mouse|pointer|contextmenu)|click/,X=/^(?:focusinfocus|focusoutblur)$/,Y=/^([^.]*)(?:\.(.+)|)$/;function Z(){return!0}function $(){return!1}function _(){try{return l.activeElement}catch(a){}}n.event={global:{},add:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,o,p,q,r=L.get(a);if(r){c.handler&&(f=c,c=f.handler,e=f.selector),c.guid||(c.guid=n.guid++),(i=r.events)||(i=r.events={}),(g=r.handle)||(g=r.handle=function(b){return typeof n!==U&&n.event.triggered!==b.type?n.event.dispatch.apply(a,arguments):void 0}),b=(b||"").match(E)||[""],j=b.length;while(j--)h=Y.exec(b[j])||[],o=q=h[1],p=(h[2]||"").split(".").sort(),o&&(l=n.event.special[o]||{},o=(e?l.delegateType:l.bindType)||o,l=n.event.special[o]||{},k=n.extend({type:o,origType:q,data:d,handler:c,guid:c.guid,selector:e,needsContext:e&&n.expr.match.needsContext.test(e),namespace:p.join(".")},f),(m=i[o])||(m=i[o]=[],m.delegateCount=0,l.setup&&l.setup.call(a,d,p,g)!==!1||a.addEventListener&&a.addEventListener(o,g,!1)),l.add&&(l.add.call(a,k),k.handler.guid||(k.handler.guid=c.guid)),e?m.splice(m.delegateCount++,0,k):m.push(k),n.event.global[o]=!0)}},remove:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,o,p,q,r=L.hasData(a)&&L.get(a);if(r&&(i=r.events)){b=(b||"").match(E)||[""],j=b.length;while(j--)if(h=Y.exec(b[j])||[],o=q=h[1],p=(h[2]||"").split(".").sort(),o){l=n.event.special[o]||{},o=(d?l.delegateType:l.bindType)||o,m=i[o]||[],h=h[2]&&new RegExp("(^|\\.)"+p.join("\\.(?:.*\\.|)")+"(\\.|$)"),g=f=m.length;while(f--)k=m[f],!e&&q!==k.origType||c&&c.guid!==k.guid||h&&!h.test(k.namespace)||d&&d!==k.selector&&("**"!==d||!k.selector)||(m.splice(f,1),k.selector&&m.delegateCount--,l.remove&&l.remove.call(a,k));g&&!m.length&&(l.teardown&&l.teardown.call(a,p,r.handle)!==!1||n.removeEvent(a,o,r.handle),delete i[o])}else for(o in i)n.event.remove(a,o+b[j],c,d,!0);n.isEmptyObject(i)&&(delete r.handle,L.remove(a,"events"))}},trigger:function(b,c,d,e){var f,g,h,i,k,m,o,p=[d||l],q=j.call(b,"type")?b.type:b,r=j.call(b,"namespace")?b.namespace.split("."):[];if(g=h=d=d||l,3!==d.nodeType&&8!==d.nodeType&&!X.test(q+n.event.triggered)&&(q.indexOf(".")>=0&&(r=q.split("."),q=r.shift(),r.sort()),k=q.indexOf(":")<0&&"on"+q,b=b[n.expando]?b:new n.Event(q,"object"==typeof b&&b),b.isTrigger=e?2:3,b.namespace=r.join("."),b.namespace_re=b.namespace?new RegExp("(^|\\.)"+r.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,b.result=void 0,b.target||(b.target=d),c=null==c?[b]:n.makeArray(c,[b]),o=n.event.special[q]||{},e||!o.trigger||o.trigger.apply(d,c)!==!1)){if(!e&&!o.noBubble&&!n.isWindow(d)){for(i=o.delegateType||q,X.test(i+q)||(g=g.parentNode);g;g=g.parentNode)p.push(g),h=g;h===(d.ownerDocument||l)&&p.push(h.defaultView||h.parentWindow||a)}f=0;while((g=p[f++])&&!b.isPropagationStopped())b.type=f>1?i:o.bindType||q,m=(L.get(g,"events")||{})[b.type]&&L.get(g,"handle"),m&&m.apply(g,c),m=k&&g[k],m&&m.apply&&n.acceptData(g)&&(b.result=m.apply(g,c),b.result===!1&&b.preventDefault());return b.type=q,e||b.isDefaultPrevented()||o._default&&o._default.apply(p.pop(),c)!==!1||!n.acceptData(d)||k&&n.isFunction(d[q])&&!n.isWindow(d)&&(h=d[k],h&&(d[k]=null),n.event.triggered=q,d[q](),n.event.triggered=void 0,h&&(d[k]=h)),b.result}},dispatch:function(a){a=n.event.fix(a);var b,c,e,f,g,h=[],i=d.call(arguments),j=(L.get(this,"events")||{})[a.type]||[],k=n.event.special[a.type]||{};if(i[0]=a,a.delegateTarget=this,!k.preDispatch||k.preDispatch.call(this,a)!==!1){h=n.event.handlers.call(this,a,j),b=0;while((f=h[b++])&&!a.isPropagationStopped()){a.currentTarget=f.elem,c=0;while((g=f.handlers[c++])&&!a.isImmediatePropagationStopped())(!a.namespace_re||a.namespace_re.test(g.namespace))&&(a.handleObj=g,a.data=g.data,e=((n.event.special[g.origType]||{}).handle||g.handler).apply(f.elem,i),void 0!==e&&(a.result=e)===!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(i.disabled!==!0||"click"!==a.type){for(d=[],c=0;h>c;c++)f=b[c],e=f.selector+" ",void 0===d[e]&&(d[e]=f.needsContext?n(e,this).index(i)>=0:n.find(e,this,null,[i]).length),d[e]&&d.push(f);d.length&&g.push({elem:i,handlers:d})}return h]*)\/>/gi,ba=/<([\w:]+)/,ca=/<|&#?\w+;/,da=/<(?:script|style|link)/i,ea=/checked\s*(?:[^=]|=\s*.checked.)/i,fa=/^$|\/(?:java|ecma)script/i,ga=/^true\/(.*)/,ha=/^\s*\s*$/g,ia={option:[1,""],thead:[1,"","
    "],col:[2,"","
    "],tr:[2,"","
    "],td:[3,"","
    "],_default:[0,"",""]};ia.optgroup=ia.option,ia.tbody=ia.tfoot=ia.colgroup=ia.caption=ia.thead,ia.th=ia.td;function ja(a,b){return n.nodeName(a,"table")&&n.nodeName(11!==b.nodeType?b:b.firstChild,"tr")?a.getElementsByTagName("tbody")[0]||a.appendChild(a.ownerDocument.createElement("tbody")):a}function ka(a){return a.type=(null!==a.getAttribute("type"))+"/"+a.type,a}function la(a){var b=ga.exec(a.type);return b?a.type=b[1]:a.removeAttribute("type"),a}function ma(a,b){for(var c=0,d=a.length;d>c;c++)L.set(a[c],"globalEval",!b||L.get(b[c],"globalEval"))}function na(a,b){var c,d,e,f,g,h,i,j;if(1===b.nodeType){if(L.hasData(a)&&(f=L.access(a),g=L.set(b,f),j=f.events)){delete g.handle,g.events={};for(e in j)for(c=0,d=j[e].length;d>c;c++)n.event.add(b,e,j[e][c])}M.hasData(a)&&(h=M.access(a),i=n.extend({},h),M.set(b,i))}}function oa(a,b){var c=a.getElementsByTagName?a.getElementsByTagName(b||"*"):a.querySelectorAll?a.querySelectorAll(b||"*"):[];return void 0===b||b&&n.nodeName(a,b)?n.merge([a],c):c}function pa(a,b){var c=b.nodeName.toLowerCase();"input"===c&&T.test(a.type)?b.checked=a.checked:("input"===c||"textarea"===c)&&(b.defaultValue=a.defaultValue)}n.extend({clone:function(a,b,c){var d,e,f,g,h=a.cloneNode(!0),i=n.contains(a.ownerDocument,a);if(!(k.noCloneChecked||1!==a.nodeType&&11!==a.nodeType||n.isXMLDoc(a)))for(g=oa(h),f=oa(a),d=0,e=f.length;e>d;d++)pa(f[d],g[d]);if(b)if(c)for(f=f||oa(a),g=g||oa(h),d=0,e=f.length;e>d;d++)na(f[d],g[d]);else na(a,h);return g=oa(h,"script"),g.length>0&&ma(g,!i&&oa(a,"script")),h},buildFragment:function(a,b,c,d){for(var e,f,g,h,i,j,k=b.createDocumentFragment(),l=[],m=0,o=a.length;o>m;m++)if(e=a[m],e||0===e)if("object"===n.type(e))n.merge(l,e.nodeType?[e]:e);else if(ca.test(e)){f=f||k.appendChild(b.createElement("div")),g=(ba.exec(e)||["",""])[1].toLowerCase(),h=ia[g]||ia._default,f.innerHTML=h[1]+e.replace(aa,"<$1>")+h[2],j=h[0];while(j--)f=f.lastChild;n.merge(l,f.childNodes),f=k.firstChild,f.textContent=""}else l.push(b.createTextNode(e));k.textContent="",m=0;while(e=l[m++])if((!d||-1===n.inArray(e,d))&&(i=n.contains(e.ownerDocument,e),f=oa(k.appendChild(e),"script"),i&&ma(f),c)){j=0;while(e=f[j++])fa.test(e.type||"")&&c.push(e)}return k},cleanData:function(a){for(var b,c,d,e,f=n.event.special,g=0;void 0!==(c=a[g]);g++){if(n.acceptData(c)&&(e=c[L.expando],e&&(b=L.cache[e]))){if(b.events)for(d in b.events)f[d]?n.event.remove(c,d):n.removeEvent(c,d,b.handle);L.cache[e]&&delete L.cache[e]}delete M.cache[c[M.expando]]}}}),n.fn.extend({text:function(a){return J(this,function(a){return void 0===a?n.text(this):this.empty().each(function(){(1===this.nodeType||11===this.nodeType||9===this.nodeType)&&(this.textContent=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=ja(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=ja(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?n.filter(a,this):this,e=0;null!=(c=d[e]);e++)b||1!==c.nodeType||n.cleanData(oa(c)),c.parentNode&&(b&&n.contains(c.ownerDocument,c)&&ma(oa(c,"script")),c.parentNode.removeChild(c));return this},empty:function(){for(var a,b=0;null!=(a=this[b]);b++)1===a.nodeType&&(n.cleanData(oa(a,!1)),a.textContent="");return this},clone:function(a,b){return a=null==a?!1:a,b=null==b?a:b,this.map(function(){return n.clone(this,a,b)})},html:function(a){return J(this,function(a){var b=this[0]||{},c=0,d=this.length;if(void 0===a&&1===b.nodeType)return b.innerHTML;if("string"==typeof a&&!da.test(a)&&!ia[(ba.exec(a)||["",""])[1].toLowerCase()]){a=a.replace(aa,"<$1>");try{for(;d>c;c++)b=this[c]||{},1===b.nodeType&&(n.cleanData(oa(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,n.cleanData(oa(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,m=this,o=l-1,p=a[0],q=n.isFunction(p);if(q||l>1&&"string"==typeof p&&!k.checkClone&&ea.test(p))return this.each(function(c){var d=m.eq(c);q&&(a[0]=p.call(this,c,d.html())),d.domManip(a,b)});if(l&&(c=n.buildFragment(a,this[0].ownerDocument,!1,this),d=c.firstChild,1===c.childNodes.length&&(c=d),d)){for(f=n.map(oa(c,"script"),ka),g=f.length;l>j;j++)h=c,j!==o&&(h=n.clone(h,!0,!0),g&&n.merge(f,oa(h,"script"))),b.call(this[j],h,j);if(g)for(i=f[f.length-1].ownerDocument,n.map(f,la),j=0;g>j;j++)h=f[j],fa.test(h.type||"")&&!L.access(h,"globalEval")&&n.contains(i,h)&&(h.src?n._evalUrl&&n._evalUrl(h.src):n.globalEval(h.textContent.replace(ha,"")))}return this}}),n.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(a,b){n.fn[a]=function(a){for(var c,d=[],e=n(a),g=e.length-1,h=0;g>=h;h++)c=h===g?this:this.clone(!0),n(e[h])[b](c),f.apply(d,c.get());return this.pushStack(d)}});var qa,ra={};function sa(b,c){var d,e=n(c.createElement(b)).appendTo(c.body),f=a.getDefaultComputedStyle&&(d=a.getDefaultComputedStyle(e[0]))?d.display:n.css(e[0],"display");return e.detach(),f}function ta(a){var b=l,c=ra[a];return c||(c=sa(a,b),"none"!==c&&c||(qa=(qa||n(".*?
    \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 = testHelper{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 class (using named params) - { - `{{< vimeo id="146022717" class="video" >}}`, - "(?s)^
    .*?.*?
    ", - }, - } { - var ( - cfg, fs = newTestCfg() - th = testHelper{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 = testHelper{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 { - in, resp, expected string - }{ - { - `{{< 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 ...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 = testHelper{cfg, fs, t} - ) - - withTemplate := func(templ tpl.TemplateHandler) error { - templ.(tpl.TemplateTestMocker).SetFuncs(tweetFuncMap) - return nil - } - - 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, WithTemplate: withTemplate}, 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 = testHelper{cfg, fs, t} - ) - - withTemplate := func(templ tpl.TemplateHandler) error { - templ.(tpl.TemplateTestMocker).SetFuncs(instagramFuncMap) - return nil - } - - 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, WithTemplate: withTemplate}, 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 new file mode 100644 index 000000000..ec59751f3 --- /dev/null +++ b/hugolib/embedded_templates_test.go @@ -0,0 +1,135 @@ +// 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 hugolib + +import ( + "testing" +) + +func TestInternalTemplatesImage(t *testing.T) { + config := ` +baseURL = "https://example.org" + +[params] +images=["siteimg1.jpg", "siteimg2.jpg"] + +` + b := newTestSitesBuilder(t).WithConfigFile("toml", config) + + b.WithContent("mybundle/index.md", `--- +title: My Bundle +date: 2021-02-26T18:02:00-01:00 +lastmod: 2021-05-22T19:25:00-01:00 +--- +`) + + b.WithContent("mypage/index.md", `--- +title: My Page +images: ["pageimg1.jpg", "pageimg2.jpg", "https://example.local/logo.png", "sample.jpg"] +date: 2021-02-26T18:02:00+01:00 +lastmod: 2021-05-22T19:25:00+01:00 +--- +`) + + b.WithContent("mysite.md", `--- +title: My Site +--- +`) + + b.WithTemplatesAdded("_default/single.html", ` + +{{ template "_internal/twitter_cards.html" . }} +{{ template "_internal/opengraph.html" . }} +{{ template "_internal/schema.html" . }} + +`) + + b.WithSunset("content/mybundle/featured-sunset.jpg") + b.WithSunset("content/mypage/sample.jpg") + b.Build(BuildCfg{}) + + b.AssertFileContent("public/mybundle/index.html", ` + + + + + + + + + + + + +`) + b.AssertFileContent("public/mypage/index.html", ` + + + + + + + + + + + + + +`) + b.AssertFileContent("public/mysite/index.html", ` + + + +`) +} + +func TestEmbeddedPaginationTemplate(t *testing.T) { + t.Parallel() + + test := func(variant string, expectedOutput string) { + b := newTestSitesBuilder(t) + b.WithConfigFile("toml", `pagination.pagerSize = 1`) + b.WithContent( + "s1/p01.md", "---\ntitle: p01\n---", + "s1/p02.md", "---\ntitle: p02\n---", + "s1/p03.md", "---\ntitle: p03\n---", + "s1/p04.md", "---\ntitle: p04\n---", + "s1/p05.md", "---\ntitle: p05\n---", + "s1/p06.md", "---\ntitle: p06\n---", + "s1/p07.md", "---\ntitle: p07\n---", + "s1/p08.md", "---\ntitle: p08\n---", + "s1/p09.md", "---\ntitle: p09\n---", + "s1/p10.md", "---\ntitle: p10\n---", + ) + b.WithTemplates("index.html", `{{ .Paginate (where site.RegularPages "Section" "s1") }}`+variant) + b.Build(BuildCfg{}) + b.AssertFileContent("public/index.html", expectedOutput) + } + + expectedOutputDefaultFormat := "Pager 1\n
      \n
    • \n ««\n
    • \n
    • \n «\n
    • \n
    • \n 1\n
    • \n
    • \n 2\n
    • \n
    • \n 3\n
    • \n
    • \n 4\n
    • \n
    • \n 5\n
    • \n
    • \n »\n
    • \n
    • \n »»\n
    • \n
    " + expectedOutputTerseFormat := "Pager 1\n
      \n
    • \n 1\n
    • \n
    • \n 2\n
    • \n
    • \n 3\n
    • \n
    • \n »\n
    • \n
    • \n »»\n
    • \n
    " + + variant := `{{ template "_internal/pagination.html" . }}` + test(variant, expectedOutputDefaultFormat) + + variant = `{{ template "_internal/pagination.html" (dict "page" .) }}` + test(variant, expectedOutputDefaultFormat) + + variant = `{{ template "_internal/pagination.html" (dict "page" . "format" "default") }}` + test(variant, expectedOutputDefaultFormat) + + variant = `{{ template "_internal/pagination.html" (dict "page" . "format" "terse") }}` + test(variant, expectedOutputTerseFormat) +} diff --git a/hugolib/fileInfo.go b/hugolib/fileInfo.go index 90cf91377..a01b37008 100644 --- a/hugolib/fileInfo.go +++ b/hugolib/fileInfo.go @@ -14,113 +14,38 @@ package hugolib import ( - "strings" + "fmt" + + "github.com/spf13/afero" - "github.com/gohugoio/hugo/helpers" "github.com/gohugoio/hugo/source" ) -// fileInfo implements the File and ReadableFile interface. -var ( - _ source.File = (*fileInfo)(nil) - _ source.ReadableFile = (*fileInfo)(nil) - _ pathLangFile = (*fileInfo)(nil) -) - -// A partial interface to prevent ambigous compiler error. -type basePather interface { - Filename() string - RealName() string - BaseDir() string -} - type fileInfo struct { - bundleTp bundleDirType - - source.ReadableFile - basePather + *source.File overriddenLang string +} - // Set if the content language for this file is disabled. - disabled bool +func (fi *fileInfo) Open() (afero.File, error) { + f, err := fi.FileInfo().Meta().Open() + if err != nil { + err = fmt.Errorf("fileInfo: %w", err) + } + + return f, err } func (fi *fileInfo) Lang() string { if fi.overriddenLang != "" { return fi.overriddenLang } - return fi.ReadableFile.Lang() + return fi.File.Lang() } -func (fi *fileInfo) Filename() string { - return fi.basePather.Filename() -} - -func (fi *fileInfo) isOwner() bool { - return fi.bundleTp > bundleNot -} - -func isContentFile(filename string) bool { - return contentFileExtensionsSet[strings.TrimPrefix(helpers.Ext(filename), ".")] -} - -func (fi *fileInfo) isContentFile() bool { - return contentFileExtensionsSet[fi.Ext()] -} - -func newFileInfo(sp *source.SourceSpec, baseDir, filename string, fi pathLangFileFi, tp bundleDirType) *fileInfo { - - baseFi := sp.NewFileInfo(baseDir, filename, tp == bundleLeaf, fi) - f := &fileInfo{ - bundleTp: tp, - ReadableFile: baseFi, - basePather: fi, +func (fi *fileInfo) String() string { + if fi == nil || fi.File == nil { + return "" } - - lang := f.Lang() - f.disabled = lang != "" && sp.DisabledLanguages[lang] - - return f - -} - -type bundleDirType int - -const ( - bundleNot bundleDirType = iota - - // All from here are bundles in one form or another. - bundleLeaf - bundleBranch -) - -// Returns the given file's name's bundle type and whether it is a content -// file or not. -func classifyBundledFile(name string) (bundleDirType, bool) { - if !isContentFile(name) { - return bundleNot, false - } - if strings.HasPrefix(name, "_index.") { - return bundleBranch, true - } - - if strings.HasPrefix(name, "index.") { - return bundleLeaf, true - } - - return bundleNot, true -} - -func (b bundleDirType) String() string { - switch b { - case bundleNot: - return "Not a bundle" - case bundleLeaf: - return "Regular bundle" - case bundleBranch: - return "Branch bundle" - } - - return "" + return fi.Path() } diff --git a/hugolib/fileInfo_test.go b/hugolib/fileInfo_test.go new file mode 100644 index 000000000..d8a70e9d3 --- /dev/null +++ b/hugolib/fileInfo_test.go @@ -0,0 +1,31 @@ +// 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 hugolib + +import ( + "testing" + + qt "github.com/frankban/quicktest" + "github.com/spf13/cast" +) + +func TestFileInfo(t *testing.T) { + t.Run("String", func(t *testing.T) { + t.Parallel() + c := qt.New(t) + fi := &fileInfo{} + _, err := cast.ToStringE(fi) + c.Assert(err, qt.IsNil) + }) +} diff --git a/hugolib/filesystems/basefs.go b/hugolib/filesystems/basefs.go new file mode 100644 index 000000000..b32b8796f --- /dev/null +++ b/hugolib/filesystems/basefs.go @@ -0,0 +1,851 @@ +// 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 filesystems provides the fine grained file systems used by Hugo. These +// are typically virtual filesystems that are composites of project and theme content. +package filesystems + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/bep/overlayfs" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/htesting" + "github.com/gohugoio/hugo/hugofs/glob" + + "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/common/types" + + "github.com/rogpeppe/go-internal/lockedfile" + + "github.com/gohugoio/hugo/hugofs/files" + + "github.com/gohugoio/hugo/modules" + + hpaths "github.com/gohugoio/hugo/common/paths" + "github.com/gohugoio/hugo/hugofs" + "github.com/gohugoio/hugo/hugolib/paths" + "github.com/spf13/afero" +) + +const ( + // Used to control concurrency between multiple Hugo instances, e.g. + // a running server and building new content with 'hugo new'. + // It's placed in the project root. + lockFileBuild = ".hugo_build.lock" +) + +var filePathSeparator = string(filepath.Separator) + +// BaseFs contains the core base filesystems used by Hugo. The name "base" is used +// to underline that even if they can be composites, they all have a base path set to a specific +// resource folder, e.g "/my-project/content". So, no absolute filenames needed. +type BaseFs struct { + // SourceFilesystems contains the different source file systems. + *SourceFilesystems + + // The source filesystem (needs absolute filenames). + SourceFs afero.Fs + + // The project source. + ProjectSourceFs afero.Fs + + // The filesystem used to publish the rendered site. + // This usually maps to /my-project/public. + PublishFs afero.Fs + + // The filesystem used for static files. + PublishFsStatic afero.Fs + + // A read-only filesystem starting from the project workDir. + WorkDir afero.Fs + + theBigFs *filesystemsCollector + + workingDir string + + // Locks. + buildMu Lockable // /.hugo_build.lock +} + +type Lockable interface { + Lock() (unlock func(), err error) +} + +type fakeLockfileMutex struct { + mu sync.Mutex +} + +func (f *fakeLockfileMutex) Lock() (func(), error) { + f.mu.Lock() + return func() { f.mu.Unlock() }, nil +} + +// Tries to acquire a build lock. +func (b *BaseFs) LockBuild() (unlock func(), err error) { + return b.buildMu.Lock() +} + +func (b *BaseFs) WatchFilenames() []string { + var filenames []string + sourceFs := b.SourceFs + + for _, rfs := range b.RootFss { + for _, component := range files.ComponentFolders { + fis, err := rfs.Mounts(component) + if err != nil { + continue + } + + for _, fim := range fis { + meta := fim.Meta() + if !meta.Watch { + continue + } + + if !fim.IsDir() { + filenames = append(filenames, meta.Filename) + continue + } + + w := hugofs.NewWalkway(hugofs.WalkwayConfig{ + Fs: sourceFs, + Root: meta.Filename, + WalkFn: func(path string, fi hugofs.FileMetaInfo) error { + if !fi.IsDir() { + return nil + } + if fi.Name() == ".git" || + fi.Name() == "node_modules" || fi.Name() == "bower_components" { + return filepath.SkipDir + } + filenames = append(filenames, fi.Meta().Filename) + return nil + }, + }) + + w.Walk() + } + + } + } + + return filenames +} + +func (b *BaseFs) mountsForComponent(component string) []hugofs.FileMetaInfo { + var result []hugofs.FileMetaInfo + for _, rfs := range b.RootFss { + dirs, err := rfs.Mounts(component) + if err == nil { + result = append(result, dirs...) + } + } + return result +} + +// AbsProjectContentDir tries to construct a filename below the most +// relevant content directory. +func (b *BaseFs) AbsProjectContentDir(filename string) (string, string, error) { + isAbs := filepath.IsAbs(filename) + for _, fi := range b.mountsForComponent(files.ComponentFolderContent) { + if !fi.IsDir() { + continue + } + meta := fi.Meta() + if !meta.IsProject { + continue + } + + if isAbs { + if strings.HasPrefix(filename, meta.Filename) { + return strings.TrimPrefix(filename, meta.Filename+filePathSeparator), filename, nil + } + } else { + contentDir := strings.TrimPrefix(strings.TrimPrefix(meta.Filename, meta.BaseDir), filePathSeparator) + filePathSeparator + + if strings.HasPrefix(filename, contentDir) { + relFilename := strings.TrimPrefix(filename, contentDir) + absFilename := filepath.Join(meta.Filename, relFilename) + return relFilename, absFilename, nil + } + } + + } + + if !isAbs { + // A filename on the form "posts/mypage.md", put it inside + // the first content folder, usually /content. + // Pick the first project dir (which is probably the most important one). + for _, dir := range b.SourceFilesystems.Content.mounts() { + if !dir.IsDir() { + continue + } + meta := dir.Meta() + if meta.IsProject { + return filename, filepath.Join(meta.Filename, filename), nil + } + } + } + + return "", "", fmt.Errorf("could not determine content directory for %q", filename) +} + +// ResolveJSConfigFile resolves the JS-related config file to a absolute +// filename. One example of such would be postcss.config.js. +func (b *BaseFs) ResolveJSConfigFile(name string) string { + // First look in assets/_jsconfig + fi, err := b.Assets.Fs.Stat(filepath.Join(files.FolderJSConfig, name)) + if err == nil { + return fi.(hugofs.FileMetaInfo).Meta().Filename + } + // Fall back to the work dir. + fi, err = b.Work.Stat(name) + if err == nil { + return fi.(hugofs.FileMetaInfo).Meta().Filename + } + + return "" +} + +// SourceFilesystems contains the different source file systems. These can be +// composite file systems (theme and project etc.), and they have all root +// set to the source type the provides: data, i18n, static, layouts. +type SourceFilesystems struct { + Content *SourceFilesystem + Data *SourceFilesystem + I18n *SourceFilesystem + Layouts *SourceFilesystem + Archetypes *SourceFilesystem + Assets *SourceFilesystem + + AssetsWithDuplicatesPreserved *SourceFilesystem + + RootFss []*hugofs.RootMappingFs + + // Writable filesystem on top the project's resources directory, + // with any sub module's resource fs layered below. + ResourcesCache afero.Fs + + // The work folder (may be a composite of project and theme components). + Work afero.Fs + + // When in multihost we have one static filesystem per language. The sync + // static files is currently done outside of the Hugo build (where there is + // a concept of a site per language). + // When in non-multihost mode there will be one entry in this map with a blank key. + Static map[string]*SourceFilesystem + + conf config.AllProvider +} + +// A SourceFilesystem holds the filesystem for a given source type in Hugo (data, +// i18n, layouts, static) and additional metadata to be able to use that filesystem +// in server mode. +type SourceFilesystem struct { + // Name matches one in files.ComponentFolders + Name string + + // This is a virtual composite filesystem. It expects path relative to a context. + Fs afero.Fs + + // The source filesystem (usually the OS filesystem). + SourceFs afero.Fs + + // When syncing a source folder to the target (e.g. /public), this may + // be set to publish into a subfolder. This is used for static syncing + // in multihost mode. + PublishFolder string +} + +// StaticFs returns the static filesystem for the given language. +// This can be a composite filesystem. +func (s SourceFilesystems) StaticFs(lang string) afero.Fs { + var staticFs afero.Fs = hugofs.NoOpFs + + if fs, ok := s.Static[lang]; ok { + staticFs = fs.Fs + } else if fs, ok := s.Static[""]; ok { + staticFs = fs.Fs + } + + return staticFs +} + +// StatResource looks for a resource in these filesystems in order: static, assets and finally content. +// If found in any of them, it returns FileInfo and the relevant filesystem. +// Any non herrors.IsNotExist error will be returned. +// An herrors.IsNotExist error will be returned only if all filesystems return such an error. +// Note that if we only wanted to find the file, we could create a composite Afero fs, +// but we also need to know which filesystem root it lives in. +func (s SourceFilesystems) StatResource(lang, filename string) (fi os.FileInfo, fs afero.Fs, err error) { + for _, fsToCheck := range []afero.Fs{s.StaticFs(lang), s.Assets.Fs, s.Content.Fs} { + fs = fsToCheck + fi, err = fs.Stat(filename) + if err == nil || !herrors.IsNotExist(err) { + return + } + } + // Not found. + return +} + +// IsStatic returns true if the given filename is a member of one of the static +// filesystems. +func (s SourceFilesystems) IsStatic(filename string) bool { + for _, staticFs := range s.Static { + if staticFs.Contains(filename) { + return true + } + } + return false +} + +// IsContent returns true if the given filename is a member of the content filesystem. +func (s SourceFilesystems) IsContent(filename string) bool { + return s.Content.Contains(filename) +} + +// ResolvePaths resolves the given filename to a list of paths in the filesystems. +func (s *SourceFilesystems) ResolvePaths(filename string) []hugofs.ComponentPath { + var cpss []hugofs.ComponentPath + for _, rfs := range s.RootFss { + cps, err := rfs.ReverseLookup(filename) + if err != nil { + panic(err) + } + cpss = append(cpss, cps...) + } + return cpss +} + +// MakeStaticPathRelative makes an absolute static filename into a relative one. +// It will return an empty string if the filename is not a member of a static filesystem. +func (s SourceFilesystems) MakeStaticPathRelative(filename string) string { + for _, staticFs := range s.Static { + rel, _ := staticFs.MakePathRelative(filename, true) + if rel != "" { + return rel + } + } + return "" +} + +// MakePathRelative creates a relative path from the given filename. +func (d *SourceFilesystem) MakePathRelative(filename string, checkExists bool) (string, bool) { + cps, err := d.ReverseLookup(filename, checkExists) + if err != nil { + panic(err) + } + if len(cps) == 0 { + return "", false + } + + return filepath.FromSlash(cps[0].Path), true +} + +// ReverseLookup returns the component paths for the given filename. +func (d *SourceFilesystem) ReverseLookup(filename string, checkExists bool) ([]hugofs.ComponentPath, error) { + var cps []hugofs.ComponentPath + hugofs.WalkFilesystems(d.Fs, func(fs afero.Fs) bool { + if rfs, ok := fs.(hugofs.ReverseLookupProvder); ok { + if c, err := rfs.ReverseLookupComponent(d.Name, filename); err == nil { + if checkExists { + n := 0 + for _, cp := range c { + if _, err := d.Fs.Stat(filepath.FromSlash(cp.Path)); err == nil { + c[n] = cp + n++ + } + } + c = c[:n] + } + cps = append(cps, c...) + } + } + return false + }) + return cps, nil +} + +func (d *SourceFilesystem) mounts() []hugofs.FileMetaInfo { + var m []hugofs.FileMetaInfo + hugofs.WalkFilesystems(d.Fs, func(fs afero.Fs) bool { + if rfs, ok := fs.(*hugofs.RootMappingFs); ok { + mounts, err := rfs.Mounts(d.Name) + if err == nil { + m = append(m, mounts...) + } + } + return false + }) + + // Filter out any mounts not belonging to this filesystem. + // TODO(bep) I think this is superfluous. + n := 0 + for _, mm := range m { + if mm.Meta().Component == d.Name { + m[n] = mm + n++ + } + } + m = m[:n] + + return m +} + +func (d *SourceFilesystem) RealFilename(rel string) string { + fi, err := d.Fs.Stat(rel) + if err != nil { + return rel + } + if realfi, ok := fi.(hugofs.FileMetaInfo); ok { + return realfi.Meta().Filename + } + + return rel +} + +// Contains returns whether the given filename is a member of the current filesystem. +func (d *SourceFilesystem) Contains(filename string) bool { + for _, dir := range d.mounts() { + if !dir.IsDir() { + continue + } + if strings.HasPrefix(filename, dir.Meta().Filename) { + return true + } + } + return false +} + +// RealDirs gets a list of absolute paths to directories starting from the given +// path. +func (d *SourceFilesystem) RealDirs(from string) []string { + var dirnames []string + for _, m := range d.mounts() { + if !m.IsDir() { + continue + } + dirname := filepath.Join(m.Meta().Filename, from) + if _, err := d.SourceFs.Stat(dirname); err == nil { + dirnames = append(dirnames, dirname) + } + } + return dirnames +} + +// WithBaseFs allows reuse of some potentially expensive to create parts that remain +// the same across sites/languages. +func WithBaseFs(b *BaseFs) func(*BaseFs) error { + return func(bb *BaseFs) error { + bb.theBigFs = b.theBigFs + bb.SourceFilesystems = b.SourceFilesystems + return nil + } +} + +// NewBase builds the filesystems used by Hugo given the paths and options provided.NewBase +func NewBase(p *paths.Paths, logger loggers.Logger, options ...func(*BaseFs) error) (*BaseFs, error) { + fs := p.Fs + if logger == nil { + logger = loggers.NewDefault() + } + + publishFs := hugofs.NewBaseFileDecorator(fs.PublishDir) + projectSourceFs := hugofs.NewBaseFileDecorator(hugofs.NewBasePathFs(fs.Source, p.Cfg.BaseConfig().WorkingDir)) + sourceFs := hugofs.NewBaseFileDecorator(fs.Source) + publishFsStatic := fs.PublishDirStatic + + var buildMu Lockable + if p.Cfg.NoBuildLock() || htesting.IsTest { + buildMu = &fakeLockfileMutex{} + } else { + buildMu = lockedfile.MutexAt(filepath.Join(p.Cfg.BaseConfig().WorkingDir, lockFileBuild)) + } + + b := &BaseFs{ + SourceFs: sourceFs, + ProjectSourceFs: projectSourceFs, + WorkDir: fs.WorkingDirReadOnly, + PublishFs: publishFs, + PublishFsStatic: publishFsStatic, + workingDir: p.Cfg.BaseConfig().WorkingDir, + buildMu: buildMu, + } + + for _, opt := range options { + if err := opt(b); err != nil { + return nil, err + } + } + + if b.theBigFs != nil && b.SourceFilesystems != nil { + return b, nil + } + + builder := newSourceFilesystemsBuilder(p, logger, b) + sourceFilesystems, err := builder.Build() + if err != nil { + return nil, fmt.Errorf("build filesystems: %w", err) + } + + b.SourceFilesystems = sourceFilesystems + b.theBigFs = builder.theBigFs + + return b, nil +} + +type sourceFilesystemsBuilder struct { + logger loggers.Logger + p *paths.Paths + sourceFs afero.Fs + result *SourceFilesystems + theBigFs *filesystemsCollector +} + +func newSourceFilesystemsBuilder(p *paths.Paths, logger loggers.Logger, b *BaseFs) *sourceFilesystemsBuilder { + sourceFs := hugofs.NewBaseFileDecorator(p.Fs.Source) + return &sourceFilesystemsBuilder{ + p: p, logger: logger, sourceFs: sourceFs, theBigFs: b.theBigFs, + result: &SourceFilesystems{ + conf: p.Cfg, + }, + } +} + +func (b *sourceFilesystemsBuilder) newSourceFilesystem(name string, fs afero.Fs) *SourceFilesystem { + return &SourceFilesystem{ + Name: name, + Fs: fs, + SourceFs: b.sourceFs, + } +} + +func (b *sourceFilesystemsBuilder) Build() (*SourceFilesystems, error) { + if b.theBigFs == nil { + theBigFs, err := b.createMainOverlayFs(b.p) + if err != nil { + return nil, fmt.Errorf("create main fs: %w", err) + } + + b.theBigFs = theBigFs + } + + createView := func(componentID string, overlayFs *overlayfs.OverlayFs) *SourceFilesystem { + if b.theBigFs == nil || b.theBigFs.overlayMounts == nil { + return b.newSourceFilesystem(componentID, hugofs.NoOpFs) + } + + fs := hugofs.NewComponentFs( + hugofs.ComponentFsOptions{ + Fs: overlayFs, + Component: componentID, + DefaultContentLanguage: b.p.Cfg.DefaultContentLanguage(), + PathParser: b.p.Cfg.PathParser(), + }, + ) + + return b.newSourceFilesystem(componentID, fs) + } + + b.result.Archetypes = createView(files.ComponentFolderArchetypes, b.theBigFs.overlayMounts) + b.result.Layouts = createView(files.ComponentFolderLayouts, b.theBigFs.overlayMounts) + b.result.Assets = createView(files.ComponentFolderAssets, b.theBigFs.overlayMounts) + b.result.ResourcesCache = b.theBigFs.overlayResources + b.result.RootFss = b.theBigFs.rootFss + + // data and i18n needs a different merge strategy. + overlayMountsPreserveDupes := b.theBigFs.overlayMounts.WithDirsMerger(hugofs.AppendDirsMerger) + b.result.Data = createView(files.ComponentFolderData, overlayMountsPreserveDupes) + b.result.I18n = createView(files.ComponentFolderI18n, overlayMountsPreserveDupes) + b.result.AssetsWithDuplicatesPreserved = createView(files.ComponentFolderAssets, overlayMountsPreserveDupes) + + contentFs := hugofs.NewComponentFs( + hugofs.ComponentFsOptions{ + Fs: b.theBigFs.overlayMountsContent, + Component: files.ComponentFolderContent, + DefaultContentLanguage: b.p.Cfg.DefaultContentLanguage(), + PathParser: b.p.Cfg.PathParser(), + }, + ) + + b.result.Content = b.newSourceFilesystem(files.ComponentFolderContent, contentFs) + b.result.Work = hugofs.NewReadOnlyFs(b.theBigFs.overlayFull) + + // Create static filesystem(s) + ms := make(map[string]*SourceFilesystem) + b.result.Static = ms + + if b.theBigFs.staticPerLanguage != nil { + // Multihost mode + for k, v := range b.theBigFs.staticPerLanguage { + sfs := b.newSourceFilesystem(files.ComponentFolderStatic, v) + sfs.PublishFolder = k + ms[k] = sfs + } + } else { + bfs := hugofs.NewBasePathFs(b.theBigFs.overlayMountsStatic, files.ComponentFolderStatic) + ms[""] = b.newSourceFilesystem(files.ComponentFolderStatic, bfs) + } + + return b.result, nil +} + +func (b *sourceFilesystemsBuilder) createMainOverlayFs(p *paths.Paths) (*filesystemsCollector, error) { + var staticFsMap map[string]*overlayfs.OverlayFs + if b.p.Cfg.IsMultihost() { + languages := b.p.Cfg.Languages() + staticFsMap = make(map[string]*overlayfs.OverlayFs) + for _, l := range languages { + staticFsMap[l.Lang] = overlayfs.New(overlayfs.Options{}) + } + } + + collector := &filesystemsCollector{ + sourceProject: b.sourceFs, + sourceModules: b.sourceFs, + staticPerLanguage: staticFsMap, + + overlayMounts: overlayfs.New(overlayfs.Options{}), + overlayMountsContent: overlayfs.New(overlayfs.Options{DirsMerger: hugofs.LanguageDirsMerger}), + overlayMountsStatic: overlayfs.New(overlayfs.Options{DirsMerger: hugofs.LanguageDirsMerger}), + overlayFull: overlayfs.New(overlayfs.Options{}), + overlayResources: overlayfs.New(overlayfs.Options{FirstWritable: true}), + } + + mods := p.AllModules() + + mounts := make([]mountsDescriptor, len(mods)) + + for i := range mods { + mod := mods[i] + dir := mod.Dir() + + isMainProject := mod.Owner() == nil + mounts[i] = mountsDescriptor{ + Module: mod, + dir: dir, + isMainProject: isMainProject, + ordinal: i, + } + + } + + err := b.createOverlayFs(collector, mounts) + + return collector, err +} + +func (b *sourceFilesystemsBuilder) isContentMount(mnt modules.Mount) bool { + return strings.HasPrefix(mnt.Target, files.ComponentFolderContent) +} + +func (b *sourceFilesystemsBuilder) isStaticMount(mnt modules.Mount) bool { + return strings.HasPrefix(mnt.Target, files.ComponentFolderStatic) +} + +func (b *sourceFilesystemsBuilder) createOverlayFs( + collector *filesystemsCollector, + mounts []mountsDescriptor, +) error { + if len(mounts) == 0 { + appendNopIfEmpty := func(ofs *overlayfs.OverlayFs) *overlayfs.OverlayFs { + if ofs.NumFilesystems() > 0 { + return ofs + } + return ofs.Append(hugofs.NoOpFs) + } + collector.overlayMounts = appendNopIfEmpty(collector.overlayMounts) + collector.overlayMountsContent = appendNopIfEmpty(collector.overlayMountsContent) + collector.overlayMountsStatic = appendNopIfEmpty(collector.overlayMountsStatic) + collector.overlayMountsFull = appendNopIfEmpty(collector.overlayMountsFull) + collector.overlayFull = appendNopIfEmpty(collector.overlayFull) + collector.overlayResources = appendNopIfEmpty(collector.overlayResources) + + return nil + } + + for _, md := range mounts { + var ( + fromTo []hugofs.RootMapping + fromToContent []hugofs.RootMapping + fromToStatic []hugofs.RootMapping + ) + + absPathify := func(path string) (string, string) { + if filepath.IsAbs(path) { + return "", path + } + return md.dir, hpaths.AbsPathify(md.dir, path) + } + + for i, mount := range md.Mounts() { + // Add more weight to early mounts. + // When two mounts contain the same filename, + // the first entry wins. + mountWeight := (10 + md.ordinal) * (len(md.Mounts()) - i) + + inclusionFilter, err := glob.NewFilenameFilter( + types.ToStringSlicePreserveString(mount.IncludeFiles), + types.ToStringSlicePreserveString(mount.ExcludeFiles), + ) + if err != nil { + return err + } + + base, filename := absPathify(mount.Source) + + rm := hugofs.RootMapping{ + From: mount.Target, + To: filename, + ToBase: base, + Module: md.Module.Path(), + ModuleOrdinal: md.ordinal, + IsProject: md.isMainProject, + Meta: &hugofs.FileMeta{ + Watch: !mount.DisableWatch && md.Watch(), + Weight: mountWeight, + InclusionFilter: inclusionFilter, + }, + } + + isContentMount := b.isContentMount(mount) + + lang := mount.Lang + if lang == "" && isContentMount { + lang = b.p.Cfg.DefaultContentLanguage() + } + + rm.Meta.Lang = lang + + if isContentMount { + fromToContent = append(fromToContent, rm) + } else if b.isStaticMount(mount) { + fromToStatic = append(fromToStatic, rm) + } else { + fromTo = append(fromTo, rm) + } + } + + modBase := collector.sourceProject + if !md.isMainProject { + modBase = collector.sourceModules + } + + sourceStatic := modBase + + rmfs, err := hugofs.NewRootMappingFs(modBase, fromTo...) + if err != nil { + return err + } + rmfsContent, err := hugofs.NewRootMappingFs(modBase, fromToContent...) + if err != nil { + return err + } + rmfsStatic, err := hugofs.NewRootMappingFs(sourceStatic, fromToStatic...) + if err != nil { + return err + } + + // We need to keep the list of directories for watching. + collector.addRootFs(rmfs) + collector.addRootFs(rmfsContent) + collector.addRootFs(rmfsStatic) + + if collector.staticPerLanguage != nil { + for _, l := range b.p.Cfg.Languages() { + lang := l.Lang + + lfs := rmfsStatic.Filter(func(rm hugofs.RootMapping) bool { + rlang := rm.Meta.Lang + return rlang == "" || rlang == lang + }) + bfs := hugofs.NewBasePathFs(lfs, files.ComponentFolderStatic) + collector.staticPerLanguage[lang] = collector.staticPerLanguage[lang].Append(bfs) + } + } + + getResourcesDir := func() string { + if md.isMainProject { + return b.p.AbsResourcesDir + } + _, filename := absPathify(files.FolderResources) + return filename + } + + collector.overlayMounts = collector.overlayMounts.Append(rmfs) + collector.overlayMountsContent = collector.overlayMountsContent.Append(rmfsContent) + collector.overlayMountsStatic = collector.overlayMountsStatic.Append(rmfsStatic) + collector.overlayFull = collector.overlayFull.Append(hugofs.NewBasePathFs(modBase, md.dir)) + collector.overlayResources = collector.overlayResources.Append(hugofs.NewBasePathFs(modBase, getResourcesDir())) + + } + + return nil +} + +//lint:ignore U1000 useful for debugging +func printFs(fs afero.Fs, path string, w io.Writer) { + if fs == nil { + return + } + afero.Walk(fs, path, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + var filename string + if fim, ok := info.(hugofs.FileMetaInfo); ok { + filename = fim.Meta().Filename + } + fmt.Fprintf(w, " %q %q\n", path, filename) + return nil + }) +} + +type filesystemsCollector struct { + sourceProject afero.Fs // Source for project folders + sourceModules afero.Fs // Source for modules/themes + + overlayMounts *overlayfs.OverlayFs + overlayMountsContent *overlayfs.OverlayFs + overlayMountsStatic *overlayfs.OverlayFs + overlayMountsFull *overlayfs.OverlayFs + overlayFull *overlayfs.OverlayFs + overlayResources *overlayfs.OverlayFs + + rootFss []*hugofs.RootMappingFs + + // Set if in multihost mode + staticPerLanguage map[string]*overlayfs.OverlayFs +} + +func (c *filesystemsCollector) addRootFs(rfs *hugofs.RootMappingFs) { + c.rootFss = append(c.rootFss, rfs) +} + +type mountsDescriptor struct { + modules.Module + dir string + isMainProject bool + ordinal int // zero based starting from the project. +} diff --git a/hugolib/filesystems/basefs_test.go b/hugolib/filesystems/basefs_test.go new file mode 100644 index 000000000..abe06ac4a --- /dev/null +++ b/hugolib/filesystems/basefs_test.go @@ -0,0 +1,690 @@ +// 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 filesystems_test + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/config/testconfig" + "github.com/gohugoio/hugo/hugolib" + + "github.com/spf13/afero" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/hugofs" + "github.com/gohugoio/hugo/hugolib/filesystems" + "github.com/gohugoio/hugo/hugolib/paths" +) + +func TestNewBaseFs(t *testing.T) { + c := qt.New(t) + v := config.New() + + themes := []string{"btheme", "atheme"} + + workingDir := filepath.FromSlash("/my/work") + v.Set("workingDir", workingDir) + v.Set("contentDir", "content") + v.Set("themesDir", "themes") + v.Set("defaultContentLanguage", "en") + v.Set("theme", themes[:1]) + v.Set("publishDir", "public") + + afs := afero.NewMemMapFs() + + // Write some data to the themes + for _, theme := range themes { + for _, dir := range []string{"i18n", "data", "archetypes", "layouts"} { + base := filepath.Join(workingDir, "themes", theme, dir) + filenameTheme := filepath.Join(base, fmt.Sprintf("theme-file-%s.txt", theme)) + filenameOverlap := filepath.Join(base, "f3.txt") + afs.Mkdir(base, 0o755) + content := fmt.Appendf(nil, "content:%s:%s", theme, dir) + afero.WriteFile(afs, filenameTheme, content, 0o755) + afero.WriteFile(afs, filenameOverlap, content, 0o755) + } + // Write some files to the root of the theme + base := filepath.Join(workingDir, "themes", theme) + afero.WriteFile(afs, filepath.Join(base, fmt.Sprintf("theme-root-%s.txt", theme)), fmt.Appendf(nil, "content:%s", theme), 0o755) + afero.WriteFile(afs, filepath.Join(base, "file-theme-root.txt"), fmt.Appendf(nil, "content:%s", theme), 0o755) + } + + afero.WriteFile(afs, filepath.Join(workingDir, "file-root.txt"), []byte("content-project"), 0o755) + + afero.WriteFile(afs, filepath.Join(workingDir, "themes", "btheme", "config.toml"), []byte(` +theme = ["atheme"] +`), 0o755) + + setConfigAndWriteSomeFilesTo(afs, v, "contentDir", "mycontent", 3) + setConfigAndWriteSomeFilesTo(afs, v, "i18nDir", "myi18n", 4) + setConfigAndWriteSomeFilesTo(afs, v, "layoutDir", "mylayouts", 5) + setConfigAndWriteSomeFilesTo(afs, v, "staticDir", "mystatic", 6) + setConfigAndWriteSomeFilesTo(afs, v, "dataDir", "mydata", 7) + setConfigAndWriteSomeFilesTo(afs, v, "archetypeDir", "myarchetypes", 8) + setConfigAndWriteSomeFilesTo(afs, v, "assetDir", "myassets", 9) + setConfigAndWriteSomeFilesTo(afs, v, "resourceDir", "myrsesource", 10) + + conf := testconfig.GetTestConfig(afs, v) + fs := hugofs.NewFrom(afs, conf.BaseConfig()) + + p, err := paths.New(fs, conf) + c.Assert(err, qt.IsNil) + + bfs, err := filesystems.NewBase(p, nil) + c.Assert(err, qt.IsNil) + c.Assert(bfs, qt.Not(qt.IsNil)) + + root, err := bfs.I18n.Fs.Open("") + c.Assert(err, qt.IsNil) + dirnames, err := root.Readdirnames(-1) + c.Assert(err, qt.IsNil) + c.Assert(dirnames, qt.DeepEquals, []string{"f1.txt", "f2.txt", "f3.txt", "f4.txt", "f3.txt", "theme-file-btheme.txt", "f3.txt", "theme-file-atheme.txt"}) + + root, err = bfs.Data.Fs.Open("") + c.Assert(err, qt.IsNil) + dirnames, err = root.Readdirnames(-1) + c.Assert(err, qt.IsNil) + c.Assert(dirnames, qt.DeepEquals, []string{"f1.txt", "f2.txt", "f3.txt", "f4.txt", "f5.txt", "f6.txt", "f7.txt", "f3.txt", "theme-file-btheme.txt", "f3.txt", "theme-file-atheme.txt"}) + + checkFileCount(bfs.Layouts.Fs, "", c, 7) + + checkFileCount(bfs.Content.Fs, "", c, 3) + checkFileCount(bfs.I18n.Fs, "", c, 8) // 4 + 4 themes + + checkFileCount(bfs.Static[""].Fs, "", c, 6) + checkFileCount(bfs.Data.Fs, "", c, 11) // 7 + 4 themes + checkFileCount(bfs.Archetypes.Fs, "", c, 10) // 8 + 2 themes + checkFileCount(bfs.Assets.Fs, "", c, 9) + checkFileCount(bfs.Work, "", c, 90) + + c.Assert(bfs.IsStatic(filepath.Join(workingDir, "mystatic", "file1.txt")), qt.Equals, true) + + contentFilename := filepath.Join(workingDir, "mycontent", "file1.txt") + c.Assert(bfs.IsContent(contentFilename), qt.Equals, true) + // Check Work fs vs theme + checkFileContent(bfs.Work, "file-root.txt", c, "content-project") + checkFileContent(bfs.Work, "theme-root-atheme.txt", c, "content:atheme") + + // https://github.com/gohugoio/hugo/issues/5318 + // Check both project and theme. + for _, fs := range []afero.Fs{bfs.Archetypes.Fs, bfs.Layouts.Fs} { + for _, filename := range []string{"/f1.txt", "/theme-file-atheme.txt"} { + filename = filepath.FromSlash(filename) + f, err := fs.Open(filename) + c.Assert(err, qt.IsNil) + f.Close() + } + } +} + +func TestNewBaseFsEmpty(t *testing.T) { + c := qt.New(t) + afs := afero.NewMemMapFs() + conf := testconfig.GetTestConfig(afs, nil) + fs := hugofs.NewFrom(afs, conf.BaseConfig()) + p, err := paths.New(fs, conf) + c.Assert(err, qt.IsNil) + bfs, err := filesystems.NewBase(p, nil) + c.Assert(err, qt.IsNil) + c.Assert(bfs, qt.Not(qt.IsNil)) + c.Assert(bfs.Archetypes.Fs, qt.Not(qt.IsNil)) + c.Assert(bfs.Layouts.Fs, qt.Not(qt.IsNil)) + c.Assert(bfs.Data.Fs, qt.Not(qt.IsNil)) + c.Assert(bfs.I18n.Fs, qt.Not(qt.IsNil)) + c.Assert(bfs.Work, qt.Not(qt.IsNil)) + c.Assert(bfs.Content.Fs, qt.Not(qt.IsNil)) + c.Assert(bfs.Static, qt.Not(qt.IsNil)) +} + +func TestRealDirs(t *testing.T) { + c := qt.New(t) + v := config.New() + root, themesDir := t.TempDir(), t.TempDir() + v.Set("workingDir", root) + v.Set("themesDir", themesDir) + v.Set("assetDir", "myassets") + v.Set("theme", "mytheme") + + afs := &hugofs.OpenFilesFs{Fs: hugofs.Os} + + c.Assert(afs.MkdirAll(filepath.Join(root, "myassets", "scss", "sf1"), 0o755), qt.IsNil) + c.Assert(afs.MkdirAll(filepath.Join(root, "myassets", "scss", "sf2"), 0o755), qt.IsNil) + c.Assert(afs.MkdirAll(filepath.Join(themesDir, "mytheme", "assets", "scss", "sf2"), 0o755), qt.IsNil) + c.Assert(afs.MkdirAll(filepath.Join(themesDir, "mytheme", "assets", "scss", "sf3"), 0o755), qt.IsNil) + c.Assert(afs.MkdirAll(filepath.Join(root, "resources"), 0o755), qt.IsNil) + c.Assert(afs.MkdirAll(filepath.Join(themesDir, "mytheme", "resources"), 0o755), qt.IsNil) + + c.Assert(afs.MkdirAll(filepath.Join(root, "myassets", "js", "f2"), 0o755), qt.IsNil) + + afero.WriteFile(afs, filepath.Join(filepath.Join(root, "myassets", "scss", "sf1", "a1.scss")), []byte("content"), 0o755) + afero.WriteFile(afs, filepath.Join(filepath.Join(root, "myassets", "scss", "sf2", "a3.scss")), []byte("content"), 0o755) + afero.WriteFile(afs, filepath.Join(filepath.Join(root, "myassets", "scss", "a2.scss")), []byte("content"), 0o755) + afero.WriteFile(afs, filepath.Join(filepath.Join(themesDir, "mytheme", "assets", "scss", "sf2", "a3.scss")), []byte("content"), 0o755) + afero.WriteFile(afs, filepath.Join(filepath.Join(themesDir, "mytheme", "assets", "scss", "sf3", "a4.scss")), []byte("content"), 0o755) + + afero.WriteFile(afs, filepath.Join(filepath.Join(themesDir, "mytheme", "resources", "t1.txt")), []byte("content"), 0o755) + afero.WriteFile(afs, filepath.Join(filepath.Join(root, "resources", "p1.txt")), []byte("content"), 0o755) + afero.WriteFile(afs, filepath.Join(filepath.Join(root, "resources", "p2.txt")), []byte("content"), 0o755) + + afero.WriteFile(afs, filepath.Join(filepath.Join(root, "myassets", "js", "f2", "a1.js")), []byte("content"), 0o755) + afero.WriteFile(afs, filepath.Join(filepath.Join(root, "myassets", "js", "a2.js")), []byte("content"), 0o755) + + conf := testconfig.GetTestConfig(afs, v) + fs := hugofs.NewFrom(afs, conf.BaseConfig()) + p, err := paths.New(fs, conf) + c.Assert(err, qt.IsNil) + bfs, err := filesystems.NewBase(p, nil) + c.Assert(err, qt.IsNil) + c.Assert(bfs, qt.Not(qt.IsNil)) + + checkFileCount(bfs.Assets.Fs, "", c, 6) + + realDirs := bfs.Assets.RealDirs("scss") + c.Assert(len(realDirs), qt.Equals, 2) + c.Assert(realDirs[0], qt.Equals, filepath.Join(root, "myassets/scss")) + c.Assert(realDirs[len(realDirs)-1], qt.Equals, filepath.Join(themesDir, "mytheme/assets/scss")) + + realDirs = bfs.Assets.RealDirs("foo") + c.Assert(len(realDirs), qt.Equals, 0) + + c.Assert(afs.OpenFiles(), qt.HasLen, 0) +} + +func TestWatchFilenames(t *testing.T) { + t.Parallel() + files := ` +-- hugo.toml -- +theme = "t1" +[[module.mounts]] +source = 'content' +target = 'content' +[[module.mounts]] +source = 'content2' +target = 'content/c2' +[[module.mounts]] +source = 'content3' +target = 'content/watchdisabled' +disableWatch = true +[[module.mounts]] +source = 'content4' +target = 'content/excludedsome' +excludeFiles = 'p1.md' +[[module.mounts]] +source = 'content5' +target = 'content/excludedall' +excludeFiles = '/**' +[[module.mounts]] +source = "hugo_stats.json" +target = "assets/watching/hugo_stats.json" +-- hugo_stats.json -- +Some stats. +-- content/foo.md -- +foo +-- content2/bar.md -- +-- themes/t1/layouts/_default/single.html -- +{{ .Content }} +-- themes/t1/static/f1.txt -- +-- content3/p1.md -- +-- content4/p1.md -- +-- content4/p2.md -- +-- content5/p3.md -- +-- content5/p4.md -- +` + b := hugolib.Test(t, files) + bfs := b.H.BaseFs + watchFilenames := toSlashes(bfs.WatchFilenames()) + + // content3 has disableWatch = true + // content5 has excludeFiles = '/**' + b.Assert(watchFilenames, qt.DeepEquals, []string{"/hugo_stats.json", "/content", "/content2", "/content4", "/themes/t1/layouts", "/themes/t1/layouts/_default", "/themes/t1/static"}) +} + +func toSlashes(in []string) []string { + out := make([]string, len(in)) + for i, s := range in { + out[i] = filepath.ToSlash(s) + } + return out +} + +func TestNoSymlinks(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skip on Windows") + } + files := ` +-- hugo.toml -- +theme = "t1" +-- content/a/foo.md -- +foo +-- static/a/f1.txt -- +F1 text +-- themes/t1/layouts/_default/single.html -- +{{ .Content }} +-- themes/t1/static/a/f1.txt -- +` + tmpDir := t.TempDir() + + wd, _ := os.Getwd() + + for _, component := range []string{"content", "static"} { + aDir := filepath.Join(tmpDir, component, "a") + bDir := filepath.Join(tmpDir, component, "b") + os.MkdirAll(aDir, 0o755) + os.MkdirAll(bDir, 0o755) + os.Chdir(bDir) + os.Symlink("../a", "c") + } + + os.Chdir(wd) + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + NeedsOsFS: true, + WorkingDir: tmpDir, + }, + ).Build() + + bfs := b.H.BaseFs + watchFilenames := bfs.WatchFilenames() + b.Assert(watchFilenames, qt.HasLen, 10) +} + +func TestStaticFs(t *testing.T) { + c := qt.New(t) + v := config.New() + workDir := "mywork" + v.Set("workingDir", workDir) + v.Set("themesDir", "themes") + v.Set("staticDir", "mystatic") + v.Set("theme", []string{"t1", "t2"}) + + afs := afero.NewMemMapFs() + + themeStaticDir := filepath.Join(workDir, "themes", "t1", "static") + themeStaticDir2 := filepath.Join(workDir, "themes", "t2", "static") + + afero.WriteFile(afs, filepath.Join(workDir, "mystatic", "f1.txt"), []byte("Hugo Rocks!"), 0o755) + afero.WriteFile(afs, filepath.Join(themeStaticDir, "f1.txt"), []byte("Hugo Themes Rocks!"), 0o755) + afero.WriteFile(afs, filepath.Join(themeStaticDir, "f2.txt"), []byte("Hugo Themes Still Rocks!"), 0o755) + afero.WriteFile(afs, filepath.Join(themeStaticDir2, "f2.txt"), []byte("Hugo Themes Rocks in t2!"), 0o755) + + conf := testconfig.GetTestConfig(afs, v) + fs := hugofs.NewFrom(afs, conf.BaseConfig()) + p, err := paths.New(fs, conf) + + c.Assert(err, qt.IsNil) + bfs, err := filesystems.NewBase(p, nil) + c.Assert(err, qt.IsNil) + + sfs := bfs.StaticFs("en") + + checkFileContent(sfs, "f1.txt", c, "Hugo Rocks!") + checkFileContent(sfs, "f2.txt", c, "Hugo Themes Still Rocks!") +} + +func TestStaticFsMultihost(t *testing.T) { + c := qt.New(t) + v := config.New() + workDir := "mywork" + v.Set("workingDir", workDir) + v.Set("themesDir", "themes") + v.Set("staticDir", "mystatic") + v.Set("theme", "t1") + v.Set("defaultContentLanguage", "en") + + langConfig := map[string]any{ + "no": map[string]any{ + "staticDir": "static_no", + "baseURL": "https://example.org/no/", + }, + "en": map[string]any{ + "baseURL": "https://example.org/en/", + }, + } + + v.Set("languages", langConfig) + + afs := afero.NewMemMapFs() + + themeStaticDir := filepath.Join(workDir, "themes", "t1", "static") + + afero.WriteFile(afs, filepath.Join(workDir, "mystatic", "f1.txt"), []byte("Hugo Rocks!"), 0o755) + afero.WriteFile(afs, filepath.Join(workDir, "static_no", "f1.txt"), []byte("Hugo Rocks in Norway!"), 0o755) + + afero.WriteFile(afs, filepath.Join(themeStaticDir, "f1.txt"), []byte("Hugo Themes Rocks!"), 0o755) + afero.WriteFile(afs, filepath.Join(themeStaticDir, "f2.txt"), []byte("Hugo Themes Still Rocks!"), 0o755) + + conf := testconfig.GetTestConfig(afs, v) + fs := hugofs.NewFrom(afs, conf.BaseConfig()) + + p, err := paths.New(fs, conf) + c.Assert(err, qt.IsNil) + bfs, err := filesystems.NewBase(p, nil) + c.Assert(err, qt.IsNil) + enFs := bfs.StaticFs("en") + checkFileContent(enFs, "f1.txt", c, "Hugo Rocks!") + checkFileContent(enFs, "f2.txt", c, "Hugo Themes Still Rocks!") + + noFs := bfs.StaticFs("no") + checkFileContent(noFs, "f1.txt", c, "Hugo Rocks in Norway!") + checkFileContent(noFs, "f2.txt", c, "Hugo Themes Still Rocks!") +} + +func TestMakePathRelative(t *testing.T) { + files := ` +-- hugo.toml -- +[[module.mounts]] +source = "bar.txt" +target = "assets/foo/baz.txt" +[[module.imports]] +path = "t1" +[[module.imports.mounts]] +source = "src" +target = "assets/foo/bar" +-- bar.txt -- +Bar. +-- themes/t1/src/main.js -- +Main. +` + b := hugolib.Test(t, files) + + rel, found := b.H.BaseFs.Assets.MakePathRelative(filepath.FromSlash("/themes/t1/src/main.js"), true) + b.Assert(found, qt.Equals, true) + b.Assert(rel, qt.Equals, filepath.FromSlash("foo/bar/main.js")) + + rel, found = b.H.BaseFs.Assets.MakePathRelative(filepath.FromSlash("/bar.txt"), true) + b.Assert(found, qt.Equals, true) + b.Assert(rel, qt.Equals, filepath.FromSlash("foo/baz.txt")) +} + +func TestAbsProjectContentDir(t *testing.T) { + tempDir := t.TempDir() + + files := ` +-- hugo.toml -- +[[module.mounts]] +source = "content" +target = "content" +-- content/foo.md -- +--- +title: "Foo" +--- +` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + WorkingDir: tempDir, + TxtarString: files, + }, + ).Build() + + abs1 := filepath.Join(tempDir, "content", "foo.md") + rel, abs2, err := b.H.BaseFs.AbsProjectContentDir("foo.md") + b.Assert(err, qt.IsNil) + b.Assert(abs2, qt.Equals, abs1) + b.Assert(rel, qt.Equals, filepath.FromSlash("foo.md")) + rel2, abs3, err := b.H.BaseFs.AbsProjectContentDir(abs1) + b.Assert(err, qt.IsNil) + b.Assert(abs3, qt.Equals, abs1) + b.Assert(rel2, qt.Equals, rel) +} + +func TestContentReverseLookup(t *testing.T) { + files := ` +-- README.md -- +--- +title: README +--- +-- blog/b1.md -- +--- +title: b1 +--- +-- docs/d1.md -- +--- +title: d1 +--- +-- hugo.toml -- +baseURL = "https://example.com/" +[module] +[[module.mounts]] +source = "layouts" +target = "layouts" +[[module.mounts]] +source = "README.md" +target = "content/_index.md" +[[module.mounts]] +source = "blog" +target = "content/posts" +[[module.mounts]] +source = "docs" +target = "content/mydocs" +-- layouts/index.html -- +Home. + +` + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", "Home.") + + stat := func(path string) hugofs.FileMetaInfo { + ps, err := b.H.BaseFs.Content.ReverseLookup(filepath.FromSlash(path), true) + b.Assert(err, qt.IsNil) + b.Assert(ps, qt.HasLen, 1) + first := ps[0] + fi, err := b.H.BaseFs.Content.Fs.Stat(filepath.FromSlash(first.Path)) + b.Assert(err, qt.IsNil) + b.Assert(fi, qt.Not(qt.IsNil)) + return fi.(hugofs.FileMetaInfo) + } + + sfs := b.H.Fs.Source + + _, err := sfs.Stat("blog/b1.md") + b.Assert(err, qt.Not(qt.IsNil)) + + _ = stat("blog/b1.md") +} + +func TestReverseLookupShouldOnlyConsiderFilesInCurrentComponent(t *testing.T) { + files := ` +-- hugo.toml -- +baseURL = "https://example.com/" +[module] +[[module.mounts]] +source = "files/layouts" +target = "layouts" +[[module.mounts]] +source = "files/layouts/assets" +target = "assets" +-- files/layouts/l1.txt -- +l1 +-- files/layouts/assets/l2.txt -- +l2 +` + b := hugolib.Test(t, files) + + assetsFs := b.H.Assets + + for _, checkExists := range []bool{false, true} { + cps, err := assetsFs.ReverseLookup(filepath.FromSlash("files/layouts/assets/l2.txt"), checkExists) + b.Assert(err, qt.IsNil) + b.Assert(cps, qt.HasLen, 1) + cps, err = assetsFs.ReverseLookup(filepath.FromSlash("files/layouts/l2.txt"), checkExists) + b.Assert(err, qt.IsNil) + b.Assert(cps, qt.HasLen, 0) + } +} + +func TestAssetsIssue12175(t *testing.T) { + files := ` +-- hugo.toml -- +baseURL = "https://example.com/" +[module] +[[module.mounts]] +source = "node_modules/@foo/core/assets" +target = "assets" +[[module.mounts]] +source = "assets" +target = "assets" +-- node_modules/@foo/core/assets/js/app.js -- +JS. +-- node_modules/@foo/core/assets/scss/app.scss -- +body { color: red; } +-- assets/scss/app.scss -- +body { color: blue; } +-- layouts/index.html -- +Home. +SCSS: {{ with resources.Get "scss/app.scss" }}{{ .RelPermalink }}|{{ .Content }}{{ end }}| +# Note that the pattern below will match 2 resources, which doesn't make much sense, +# but is how the current (and also < v0.123.0) merge logic works, and for most practical purposes, it doesn't matter. +SCSS Match: {{ with resources.Match "**.scss" }}{{ . | len }}|{{ range .}}{{ .RelPermalink }}|{{ end }}{{ end }}| + +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", ` +SCSS: /scss/app.scss|body { color: blue; }| +SCSS Match: 2| +`) +} + +func TestStaticComposite(t *testing.T) { + files := ` +-- 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. + +` + b := hugolib.Test(t, files) + + b.AssertFs(b.H.BaseFs.StaticFs(""), ` +. true +f3.txt false +files true +files/f1.txt false +files/f2.txt false +`) +} + +func TestMountIssue12141(t *testing.T) { + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term"] +[module] +[[module.mounts]] +source = "myfiles" +target = "static" +[[module.mounts]] +source = "myfiles/f1.txt" +target = "static/f2.txt" +-- myfiles/f1.txt -- +f1 +` + b := hugolib.Test(t, files) + fs := b.H.BaseFs.StaticFs("") + + b.AssertFs(fs, ` +. true +f1.txt false +f2.txt false +`) +} + +func checkFileCount(fs afero.Fs, dirname string, c *qt.C, expected int) { + c.Helper() + count, names, err := countFilesAndGetFilenames(fs, dirname) + namesComment := qt.Commentf("filenames: %v", names) + c.Assert(err, qt.IsNil, namesComment) + c.Assert(count, qt.Equals, expected, namesComment) +} + +func checkFileContent(fs afero.Fs, filename string, c *qt.C, expected ...string) { + b, err := afero.ReadFile(fs, filename) + c.Assert(err, qt.IsNil) + + content := string(b) + + for _, e := range expected { + c.Assert(content, qt.Contains, e) + } +} + +func countFilesAndGetFilenames(fs afero.Fs, dirname string) (int, []string, error) { + if fs == nil { + return 0, nil, errors.New("no fs") + } + + counter := 0 + var filenames []string + + wf := func(path string, info hugofs.FileMetaInfo) error { + if !info.IsDir() { + counter++ + } + + if info.Name() != "." { + name := info.Name() + name = strings.Replace(name, filepath.FromSlash("/my/work"), "WORK_DIR", 1) + filenames = append(filenames, name) + } + + return nil + } + + w := hugofs.NewWalkway(hugofs.WalkwayConfig{Fs: fs, Root: dirname, WalkFn: wf}) + + if err := w.Walk(); err != nil { + return -1, nil, err + } + + return counter, filenames, nil +} + +func setConfigAndWriteSomeFilesTo(fs afero.Fs, v config.Provider, key, val string, num int) { + workingDir := v.GetString("workingDir") + v.Set(key, val) + fs.Mkdir(val, 0o755) + for i := range num { + filename := filepath.Join(workingDir, val, fmt.Sprintf("f%d.txt", i+1)) + afero.WriteFile(fs, filename, fmt.Appendf(nil, "content:%s:%d", key, i+1), 0o755) + } +} diff --git a/hugolib/frontmatter_test.go b/hugolib/frontmatter_test.go new file mode 100644 index 000000000..3a2080b0e --- /dev/null +++ b/hugolib/frontmatter_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 hugolib + +import "testing" + +// Issue 10624 +func TestFrontmatterPreserveDatatypesForSlices(t *testing.T) { + t.Parallel() + + files := ` +-- content/post/one.md -- +--- +ints: [1, 2, 3] +mixed: ["1", 2, 3] +strings: ["1", "2","3"] +--- +-- layouts/_default/single.html -- +Ints: {{ printf "%T" .Params.ints }} {{ range .Params.ints }}Int: {{ fmt.Printf "%[1]v (%[1]T)" . }}|{{ end }} +Mixed: {{ printf "%T" .Params.mixed }} {{ range .Params.mixed }}Mixed: {{ fmt.Printf "%[1]v (%[1]T)" . }}|{{ end }} +Strings: {{ printf "%T" .Params.strings }} {{ range .Params.strings }}Strings: {{ fmt.Printf "%[1]v (%[1]T)" . }}|{{ end }} +` + b := NewIntegrationTestBuilder( + IntegrationTestConfig{ + T: t, + TxtarString: files, + }, + ) + + b.Build() + + b.AssertFileContent("public/post/one/index.html", "Ints: []interface {} Int: 1 (int)|Int: 2 (int)|Int: 3 (int)|") + b.AssertFileContent("public/post/one/index.html", "Mixed: []interface {} Mixed: 1 (string)|Mixed: 2 (int)|Mixed: 3 (int)|") + b.AssertFileContent("public/post/one/index.html", "Strings: []string Strings: 1 (string)|Strings: 2 (string)|Strings: 3 (string)|") +} diff --git a/hugolib/gitinfo.go b/hugolib/gitinfo.go index affa9cea8..6b5261084 100644 --- a/hugolib/gitinfo.go +++ b/hugolib/gitinfo.go @@ -1,4 +1,4 @@ -// Copyright 2016-present The Hugo Authors. All rights reserved. +// 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. @@ -14,13 +14,15 @@ package hugolib import ( - "path" + "io" "path/filepath" "strings" "github.com/bep/gitmap" - "github.com/gohugoio/hugo/config" - "github.com/gohugoio/hugo/helpers" + "github.com/gohugoio/hugo/common/hexec" + "github.com/gohugoio/hugo/deps" + "github.com/gohugoio/hugo/resources/page" + "github.com/gohugoio/hugo/source" ) type gitInfo struct { @@ -28,32 +30,37 @@ type gitInfo struct { repo *gitmap.GitRepo } -func (g *gitInfo) forPage(p *Page) (*gitmap.GitInfo, bool) { - if g == nil { - return nil, false +func (g *gitInfo) forPage(p page.Page) source.GitInfo { + name := strings.TrimPrefix(filepath.ToSlash(p.File().Filename()), g.contentDir) + name = strings.TrimPrefix(name, "/") + gi, found := g.repo.Files[name] + if !found { + return source.GitInfo{} } - name := path.Join(g.contentDir, filepath.ToSlash(p.Path())) - return g.repo.Files[name], true + return source.NewGitInfo(*gi) } -func newGitInfo(cfg config.Provider) (*gitInfo, error) { - var ( - workingDir = cfg.GetString("workingDir") - contentDir = cfg.GetString("contentDir") - ) +func newGitInfo(d *deps.Deps) (*gitInfo, error) { + opts := gitmap.Options{ + Repository: d.Conf.BaseConfig().WorkingDir, + GetGitCommandFunc: func(stdout, stderr io.Writer, args ...string) (gitmap.Runner, error) { + var argsv []any + for _, arg := range args { + argsv = append(argsv, arg) + } + argsv = append( + argsv, + hexec.WithStdout(stdout), + hexec.WithStderr(stderr), + ) + return d.ExecHelper.New("git", argsv...) + }, + } - gitRepo, err := gitmap.Map(workingDir, "") + gitRepo, err := gitmap.Map(opts) if err != nil { return nil, err } - repoPath := filepath.FromSlash(gitRepo.TopLevelAbsPath) - // The Hugo site may be placed in a sub folder in the Git repo, - // one example being the Hugo docs. - // We have to find the root folder to the Hugo site below the Git root. - contentRoot := strings.TrimPrefix(workingDir, repoPath) - contentRoot = strings.TrimPrefix(contentRoot, helpers.FilePathSeparator) - contentDir = path.Join(filepath.ToSlash(contentRoot), contentDir) - - return &gitInfo{contentDir: contentDir, repo: gitRepo}, nil + return &gitInfo{contentDir: gitRepo.TopLevelAbsPath, repo: gitRepo}, nil } diff --git a/hugolib/hugo_info.go b/hugolib/hugo_info.go deleted file mode 100644 index 303231edb..000000000 --- a/hugolib/hugo_info.go +++ /dev/null @@ -1,49 +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 hugolib - -import ( - "fmt" - "html/template" - - "github.com/gohugoio/hugo/helpers" -) - -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 string -) - -var hugoInfo *HugoInfo - -// HugoInfo contains information about the current Hugo environment -type HugoInfo struct { - Version helpers.HugoVersionString - Generator template.HTML - CommitHash string - BuildDate string -} - -func init() { - hugoInfo = &HugoInfo{ - Version: helpers.CurrentHugoVersion.Version(), - CommitHash: CommitHash, - BuildDate: BuildDate, - Generator: template.HTML(fmt.Sprintf(``, helpers.CurrentHugoVersion.String())), - } -} diff --git a/hugolib/hugo_info_test.go b/hugolib/hugo_info_test.go deleted file mode 100644 index 0a34330ac..000000000 --- a/hugolib/hugo_info_test.go +++ /dev/null @@ -1,33 +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 hugolib - -import ( - "fmt" - "testing" - - "github.com/gohugoio/hugo/helpers" - "github.com/stretchr/testify/require" -) - -func TestHugoInfo(t *testing.T) { - assert := require.New(t) - - assert.Equal(helpers.CurrentHugoVersion.Version(), hugoInfo.Version) - assert.IsType(helpers.HugoVersionString(""), hugoInfo.Version) - assert.Equal(CommitHash, hugoInfo.CommitHash) - assert.Equal(BuildDate, hugoInfo.BuildDate) - assert.Contains(hugoInfo.Generator, fmt.Sprintf("Hugo %s", hugoInfo.Version)) - -} diff --git a/hugolib/hugo_modules_test.go b/hugolib/hugo_modules_test.go new file mode 100644 index 000000000..243447805 --- /dev/null +++ b/hugolib/hugo_modules_test.go @@ -0,0 +1,830 @@ +// 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 hugolib + +import ( + "fmt" + "math/rand" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/bep/logg" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/modules/npm" + + "github.com/spf13/afero" + + "github.com/gohugoio/hugo/common/hugo" + "github.com/gohugoio/hugo/common/loggers" + + "github.com/gohugoio/hugo/htesting" + "github.com/gohugoio/hugo/hugofs" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/testmodBuilder/mods" +) + +func TestHugoModulesVariants(t *testing.T) { + if !htesting.IsCI() { + t.Skip("skip (relative) long running modules test when running locally") + } + + tomlConfig := ` +baseURL="https://example.org" +workingDir = %q + +[module] +[[module.imports]] +path="github.com/gohugoio/hugoTestModule2" +%s +` + + createConfig := func(workingDir, moduleOpts string) string { + return fmt.Sprintf(tomlConfig, workingDir, moduleOpts) + } + + newTestBuilder := func(t testing.TB, moduleOpts string) *sitesBuilder { + b := newTestSitesBuilder(t) + tempDir := t.TempDir() + workingDir := filepath.Join(tempDir, "myhugosite") + b.Assert(os.MkdirAll(workingDir, 0o777), qt.IsNil) + cfg := config.New() + cfg.Set("workingDir", workingDir) + cfg.Set("publishDir", "public") + b.Fs = hugofs.NewDefault(cfg) + b.WithWorkingDir(workingDir).WithConfigFile("toml", createConfig(workingDir, moduleOpts)) + b.WithTemplates( + "index.html", ` +Param from module: {{ site.Params.Hugo }}| +{{ $js := resources.Get "jslibs/alpinejs/alpine.js" }} +JS imported in module: {{ with $js }}{{ .RelPermalink }}{{ end }}| +`, + "_default/single.html", `{{ .Content }}`) + b.WithContent("p1.md", `--- +title: "Page" +--- + +[A link](https://bep.is) + +`) + b.WithSourceFile("go.mod", ` +module github.com/gohugoio/tests/testHugoModules + + +`) + + b.WithSourceFile("go.sum", ` +github.com/gohugoio/hugoTestModule2 v0.0.0-20200131160637-9657d7697877 h1:WLM2bQCKIWo04T6NsIWsX/Vtirhf0TnpY66xyqGlgVY= +github.com/gohugoio/hugoTestModule2 v0.0.0-20200131160637-9657d7697877/go.mod h1:CBFZS3khIAXKxReMwq0le8sEl/D8hcXmixlOHVv+Gd0= +`) + + return b + } + + t.Run("Target in subfolder", func(t *testing.T) { + b := newTestBuilder(t, "ignoreImports=true") + b.Build(BuildCfg{}) + + b.AssertFileContent("public/p1/index.html", `

    Page|https://bep.is|Title: |Text: A link|END

    `) + }) + + t.Run("Ignore config", func(t *testing.T) { + b := newTestBuilder(t, "ignoreConfig=true") + b.Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", ` +Param from module: | +JS imported in module: | +`) + }) + + t.Run("Ignore imports", func(t *testing.T) { + b := newTestBuilder(t, "ignoreImports=true") + b.Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", ` +Param from module: Rocks| +JS imported in module: | +`) + }) + + t.Run("Create package.json", func(t *testing.T) { + b := newTestBuilder(t, "") + + b.WithSourceFile("package.json", `{ + "name": "mypack", + "version": "1.2.3", + "scripts": { + "client": "wait-on http://localhost:1313 && open http://localhost:1313", + "start": "run-p client server", + "test": "echo 'hoge' > hoge" + }, + "dependencies": { + "nonon": "error" + } +}`) + + b.WithSourceFile("package.hugo.json", `{ + "name": "mypack", + "version": "1.2.3", + "scripts": { + "client": "wait-on http://localhost:1313 && open http://localhost:1313", + "start": "run-p client server", + "test": "echo 'hoge' > hoge" + }, + "dependencies": { + "foo": "1.2.3" + }, + "devDependencies": { + "postcss-cli": "7.8.0", + "tailwindcss": "1.8.0" + + } +}`) + + b.Build(BuildCfg{}) + b.Assert(npm.Pack(b.H.BaseFs.ProjectSourceFs, b.H.BaseFs.AssetsWithDuplicatesPreserved.Fs), qt.IsNil) + + b.AssertFileContentFn("package.json", func(s string) bool { + return s == `{ + "comments": { + "dependencies": { + "foo": "project", + "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": "project", + "tailwindcss": "project" + } + }, + "dependencies": { + "foo": "1.2.3", + "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.8.0", + "tailwindcss": "1.8.0" + }, + "name": "mypack", + "scripts": { + "client": "wait-on http://localhost:1313 && open http://localhost:1313", + "start": "run-p client server", + "test": "echo 'hoge' > hoge" + }, + "version": "1.2.3" +} +` + }) + }) + + t.Run("Create package.json, no default", func(t *testing.T) { + b := newTestBuilder(t, "") + + const origPackageJSON = `{ + "name": "mypack", + "version": "1.2.3", + "scripts": { + "client": "wait-on http://localhost:1313 && open http://localhost:1313", + "start": "run-p client server", + "test": "echo 'hoge' > hoge" + }, + "dependencies": { + "moo": "1.2.3" + } +}` + + b.WithSourceFile("package.json", origPackageJSON) + + b.Build(BuildCfg{}) + b.Assert(npm.Pack(b.H.BaseFs.ProjectSourceFs, b.H.BaseFs.AssetsWithDuplicatesPreserved.Fs), qt.IsNil) + + b.AssertFileContentFn("package.json", func(s string) bool { + return s == `{ + "comments": { + "dependencies": { + "moo": "project", + "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": { + "moo": "1.2.3", + "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": "mypack", + "scripts": { + "client": "wait-on http://localhost:1313 && open http://localhost:1313", + "start": "run-p client server", + "test": "echo 'hoge' > hoge" + }, + "version": "1.2.3" +} +` + }) + + // https://github.com/gohugoio/hugo/issues/7690 + b.AssertFileContent("package.hugo.json", origPackageJSON) + }) + + t.Run("Create package.json, no default, no package.json", func(t *testing.T) { + b := newTestBuilder(t, "") + + b.Build(BuildCfg{}) + b.Assert(npm.Pack(b.H.BaseFs.ProjectSourceFs, b.H.BaseFs.AssetsWithDuplicatesPreserved.Fs), qt.IsNil) + + b.AssertFileContentFn("package.json", func(s string) bool { + return s == `{ + "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": "myhugosite", + "version": "0.1.0" +} +` + }) + }) +} + +// TODO(bep) this fails when testmodBuilder is also building ... +func TestHugoModulesMatrix(t *testing.T) { + if !htesting.IsCI() { + t.Skip("skip (relative) long running modules test when running locally") + } + t.Parallel() + + if !htesting.IsCI() || 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 on local host and for Go <= 1.11 due to a bug in Go's stdlib") + } + + if testing.Short() { + t.Skip() + } + + rnd := rand.New(rand.NewSource(time.Now().UnixNano())) + gooss := []string{"linux", "darwin", "windows"} + goos := gooss[rnd.Intn(len(gooss))] + ignoreVendor := rnd.Intn(2) == 0 + testmods := mods.CreateModules(goos).Collect() + rnd.Shuffle(len(testmods), func(i, j int) { testmods[i], testmods[j] = testmods[j], testmods[i] }) + + for _, m := range testmods[:2] { + c := qt.New(t) + + workingDir, clean, err := htesting.CreateTempDir(hugofs.Os, "hugo-modules-test") + c.Assert(err, qt.IsNil) + defer clean() + + v := config.New() + v.Set("workingDir", workingDir) + v.Set("publishDir", "public") + + configTemplate := ` +baseURL = "https://example.com" +title = "My Modular Site" +workingDir = %q +theme = %q +ignoreVendorPaths = %q + +` + + ignoreVendorPaths := "" + if ignoreVendor { + ignoreVendorPaths = "github.com/**" + } + config := fmt.Sprintf(configTemplate, workingDir, m.Path(), ignoreVendorPaths) + + b := newTestSitesBuilder(t) + + // Need to use OS fs for this. + b.Fs = hugofs.NewDefault(v) + + b.WithWorkingDir(workingDir).WithConfigFile("toml", config) + b.WithContent("page.md", ` +--- +title: "Foo" +--- +`) + b.WithTemplates("home.html", ` + +{{ $mod := .Site.Data.modinfo.module }} +Mod Name: {{ $mod.name }} +Mod Version: {{ $mod.version }} +---- +{{ range $k, $v := .Site.Data.modinfo }} +- {{ $k }}: {{ range $kk, $vv := $v }}{{ $kk }}: {{ $vv }}|{{ end -}} +{{ end }} + + +`) + b.WithSourceFile("go.mod", ` +module github.com/gohugoio/tests/testHugoModules + + +`) + + b.Build(BuildCfg{}) + + // Verify that go.mod is autopopulated with all the modules in config.toml. + b.AssertFileContent("go.mod", m.Path()) + + b.AssertFileContent("public/index.html", + "Mod Name: "+m.Name(), + "Mod Version: v1.4.0") + + b.AssertFileContent("public/index.html", createChildModMatchers(m, ignoreVendor, m.Vendor)...) + + } +} + +func createChildModMatchers(m *mods.Md, ignoreVendor, vendored bool) []string { + // Child dependencies are one behind. + expectMinorVersion := 3 + + if !ignoreVendor && vendored { + // Vendored modules are stuck at v1.1.0. + expectMinorVersion = 1 + } + + expectVersion := fmt.Sprintf("v1.%d.0", expectMinorVersion) + + var matchers []string + + for _, mm := range m.Children { + matchers = append( + matchers, + fmt.Sprintf("%s: name: %s|version: %s", mm.Name(), mm.Name(), expectVersion)) + matchers = append(matchers, createChildModMatchers(mm, ignoreVendor, vendored || mm.Vendor)...) + } + return matchers +} + +func TestModulesWithContent(t *testing.T) { + t.Parallel() + + b := newTestSitesBuilder(t).WithWorkingDir("/site").WithConfigFile("toml", ` +baseURL="https://example.org" + +workingDir="/site" + +defaultContentLanguage = "en" + +[module] +[[module.imports]] +path="a" +[[module.imports.mounts]] +source="myacontent" +target="content/blog" +lang="en" +[[module.imports]] +path="b" +[[module.imports.mounts]] +source="mybcontent" +target="content/blog" +lang="nn" +[[module.imports]] +path="c" +[[module.imports]] +path="d" + +[languages] + +[languages.en] +title = "Title in English" +languageName = "English" +weight = 1 +[languages.nn] +languageName = "Nynorsk" +weight = 2 +title = "Tittel på nynorsk" +[languages.nb] +languageName = "Bokmål" +weight = 3 +title = "Tittel på bokmål" +[languages.fr] +languageName = "French" +weight = 4 +title = "French Title" + + +`) + + b.WithTemplatesAdded("index.html", ` +{{ range .Site.RegularPages }} +|{{ .Title }}|{{ .RelPermalink }}|{{ .Plain }} +{{ end }} +{{ $data := .Site.Data }} +Data Common: {{ $data.common.value }} +Data C: {{ $data.c.value }} +Data D: {{ $data.d.value }} +All Data: {{ $data }} + +i18n hello1: {{ i18n "hello1" . }} +i18n theme: {{ i18n "theme" . }} +i18n theme2: {{ i18n "theme2" . }} +`) + + content := func(id string) string { + return fmt.Sprintf(`--- +title: Title %s +--- +Content %s + +`, id, id) + } + + i18nContent := func(id, value string) string { + return fmt.Sprintf(` +[%s] +other = %q +`, id, value) + } + + // Content files + b.WithSourceFile("themes/a/myacontent/page.md", content("theme-a-en")) + b.WithSourceFile("themes/b/mybcontent/page.md", content("theme-b-nn")) + b.WithSourceFile("themes/c/content/blog/c.md", content("theme-c-nn")) + + // Data files + b.WithSourceFile("data/common.toml", `value="Project"`) + b.WithSourceFile("themes/c/data/common.toml", `value="Theme C"`) + b.WithSourceFile("themes/c/data/c.toml", `value="Hugo Rocks!"`) + b.WithSourceFile("themes/d/data/c.toml", `value="Hugo Rodcks!"`) + b.WithSourceFile("themes/d/data/d.toml", `value="Hugo Rodks!"`) + + // i18n files + b.WithSourceFile("i18n/en.toml", i18nContent("hello1", "Project")) + b.WithSourceFile("themes/c/i18n/en.toml", ` +[hello1] +other="Theme C Hello" +[theme] +other="Theme C" +`) + b.WithSourceFile("themes/d/i18n/en.toml", i18nContent("theme", "Theme D")) + b.WithSourceFile("themes/d/i18n/en.toml", i18nContent("theme2", "Theme2 D")) + + // Static files + b.WithSourceFile("themes/c/static/hello.txt", `Hugo Rocks!"`) + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", "|Title theme-a-en|/blog/page/|Content theme-a-en") + b.AssertFileContent("public/nn/index.html", "|Title theme-b-nn|/nn/blog/page/|Content theme-b-nn") + + // Data + b.AssertFileContent("public/index.html", + "Data Common: Project", + "Data C: Hugo Rocks!", + "Data D: Hugo Rodks!", + ) + + // i18n + b.AssertFileContent("public/index.html", + "i18n hello1: Project", + "i18n theme: Theme C", + "i18n theme2: Theme2 D", + ) +} + +func TestModulesIgnoreConfig(t *testing.T) { + b := newTestSitesBuilder(t).WithWorkingDir("/site").WithConfigFile("toml", ` +baseURL="https://example.org" + +workingDir="/site" + +[module] +[[module.imports]] +path="a" +ignoreConfig=true + +`) + + b.WithSourceFile("themes/a/config.toml", ` +[params] +a = "Should Be Ignored!" +`) + + b.WithTemplatesAdded("index.html", `Params: {{ .Site.Params }}`) + + b.Build(BuildCfg{}) + + b.AssertFileContentFn("public/index.html", func(s string) bool { + return !strings.Contains(s, "Ignored") + }) +} + +func TestModulesDisabled(t *testing.T) { + b := newTestSitesBuilder(t).WithWorkingDir("/site").WithConfigFile("toml", ` +baseURL="https://example.org" + +workingDir="/site" + +[module] +[[module.imports]] +path="a" +[[module.imports]] +path="b" +disable=true + + +`) + + b.WithSourceFile("themes/a/config.toml", ` +[params] +a = "A param" +`) + + b.WithSourceFile("themes/b/config.toml", ` +[params] +b = "B param" +`) + + b.WithTemplatesAdded("index.html", `Params: {{ .Site.Params }}`) + + b.Build(BuildCfg{}) + + b.AssertFileContentFn("public/index.html", func(s string) bool { + return strings.Contains(s, "A param") && !strings.Contains(s, "B param") + }) +} + +func TestModulesIncompatible(t *testing.T) { + t.Parallel() + + b := newTestSitesBuilder(t).WithWorkingDir("/site").WithConfigFile("toml", ` +baseURL="https://example.org" + +workingDir="/site" + +[module] +[[module.imports]] +path="ok" +[[module.imports]] +path="incompat1" +[[module.imports]] +path="incompat2" +[[module.imports]] +path="incompat3" + +`) + + b.WithSourceFile("themes/ok/data/ok.toml", `title = "OK"`) + + b.WithSourceFile("themes/incompat1/config.toml", ` + +[module] +[module.hugoVersion] +min = "0.33.2" +max = "0.45.0" + +`) + + // Old setup. + b.WithSourceFile("themes/incompat2/theme.toml", ` +min_version = "5.0.0" + +`) + + // Issue 6162 + b.WithSourceFile("themes/incompat3/theme.toml", ` +min_version = 0.55.0 + +`) + + logger := loggers.NewDefault() + b.WithLogger(logger) + + b.Build(BuildCfg{}) + + c := qt.New(t) + + c.Assert(logger.LoggCount(logg.LevelWarn), qt.Equals, 3) +} + +func TestMountsProject(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +baseURL="https://example.org" + +[module] +[[module.mounts]] +source="mycontent" +target="content" +-- layouts/_default/single.html -- +Permalink: {{ .Permalink }}| +-- mycontent/mypage.md -- +--- +title: "My Page" +--- +` + b := Test(t, files) + + b.AssertFileContent("public/mypage/index.html", "Permalink: https://example.org/mypage/|") +} + +// https://github.com/gohugoio/hugo/issues/6684 +func TestMountsContentFile(t *testing.T) { + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "RSS", "sitemap", "robotsTXT", "page", "section"] +disableLiveReload = true +[module] +[[module.mounts]] +source = "README.md" +target = "content/_index.md" +-- README.md -- +# Hello World +-- layouts/index.html -- +Home: {{ .Title }}|{{ .Content }}| +` + b := Test(t, files) + b.AssertFileContent("public/index.html", "Home: |

    Hello World

    \n|") +} + +// https://github.com/gohugoio/hugo/issues/6299 +func TestSiteWithGoModButNoModules(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + + files := ` +-- hugo.toml -- +baseURL = "https://example.org" +-- go.mod -- + +` + + b := Test(t, files, TestOptWithConfig(func(cfg *IntegrationTestConfig) { + cfg.WorkingDir = tempDir + })) + + b.Build() +} + +// https://github.com/gohugoio/hugo/issues/6622 +func TestModuleAbsMount(t *testing.T) { + t.Parallel() + + c := qt.New(t) + // We need to use the OS fs for this. + workDir, clean1, err := htesting.CreateTempDir(hugofs.Os, "hugo-project") + c.Assert(err, qt.IsNil) + absContentDir, clean2, err := htesting.CreateTempDir(hugofs.Os, "hugo-content") + c.Assert(err, qt.IsNil) + + cfg := config.New() + cfg.Set("workingDir", workDir) + cfg.Set("publishDir", "public") + fs := hugofs.NewFromOld(hugofs.Os, cfg) + + config := fmt.Sprintf(` +workingDir=%q + +[module] + [[module.mounts]] + source = %q + target = "content" + +`, workDir, absContentDir) + + defer clean1() + defer clean2() + + b := newTestSitesBuilder(t) + b.Fs = fs + + contentFilename := filepath.Join(absContentDir, "p1.md") + afero.WriteFile(hugofs.Os, contentFilename, []byte(` +--- +title: Abs +--- + +Content. +`), 0o777) + + b.WithWorkingDir(workDir).WithConfigFile("toml", config) + b.WithContent("dummy.md", "") + + b.WithTemplatesAdded("index.html", ` +{{ $p1 := site.GetPage "p1" }} +P1: {{ $p1.Title }}|{{ $p1.RelPermalink }}|Filename: {{ $p1.File.Filename }} +`) + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", "P1: Abs|/p1/", "Filename: "+contentFilename) +} + +// Issue 9426 +func TestMountSameSource(t *testing.T) { + files := ` +-- hugo.toml -- +baseURL = 'https://example.org/' +languageCode = 'en-us' +title = 'Hugo GitHub Issue #9426' + +disableKinds = ['RSS','sitemap','taxonomy','term'] + +[[module.mounts]] +source = "content" +target = "content" + +[[module.mounts]] +source = "extra-content" +target = "content/resources-a" + +[[module.mounts]] +source = "extra-content" +target = "content/resources-b" +-- layouts/_default/single.html -- +Single +-- content/p1.md -- +-- extra-content/_index.md -- +-- extra-content/subdir/_index.md -- +-- extra-content/subdir/about.md -- +" +` + b := Test(t, files) + + b.AssertFileContent("public/resources-a/subdir/about/index.html", "Single") + b.AssertFileContent("public/resources-b/subdir/about/index.html", "Single") +} + +func TestMountData(t *testing.T) { + files := ` +-- hugo.toml -- +baseURL = 'https://example.org/' +disableKinds = ["taxonomy", "term", "RSS", "sitemap", "robotsTXT", "page", "section"] + +[[module.mounts]] +source = "data" +target = "data" + +[[module.mounts]] +source = "extra-data" +target = "data/extra" +-- extra-data/test.yaml -- +message: Hugo Rocks +-- layouts/index.html -- +{{ site.Data.extra.test.message }} +` + + b := Test(t, files) + + b.AssertFileContent("public/index.html", "Hugo Rocks") +} diff --git a/hugolib/hugo_sites.go b/hugolib/hugo_sites.go index 16beb3db6..0b68af2ec 100644 --- a/hugolib/hugo_sites.go +++ b/hugolib/hugo_sites.go @@ -1,4 +1,4 @@ -// Copyright 2016-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. @@ -14,753 +14,603 @@ package hugolib import ( - "errors" + "context" + "fmt" "io" - "path/filepath" - "sort" "strings" "sync" + "sync/atomic" - "github.com/gohugoio/hugo/resource" + "github.com/bep/logg" + "github.com/gohugoio/hugo/cache/dynacache" + "github.com/gohugoio/hugo/config/allconfig" + "github.com/gohugoio/hugo/hugofs/glob" + "github.com/gohugoio/hugo/hugolib/doctree" + "github.com/gohugoio/hugo/resources" + "github.com/fsnotify/fsnotify" + + "github.com/gohugoio/hugo/output" + "github.com/gohugoio/hugo/parser/metadecoders" + + "github.com/gohugoio/hugo/common/hugo" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/common/para" + "github.com/gohugoio/hugo/common/types" + "github.com/gohugoio/hugo/hugofs" + + "github.com/gohugoio/hugo/source" + + "github.com/gohugoio/hugo/common/herrors" "github.com/gohugoio/hugo/deps" "github.com/gohugoio/hugo/helpers" + "github.com/gohugoio/hugo/lazy" - "github.com/gohugoio/hugo/i18n" - "github.com/gohugoio/hugo/tpl" - "github.com/gohugoio/hugo/tpl/tplimpl" + "github.com/gohugoio/hugo/resources/page" ) // HugoSites represents the sites to build. Each site represents a language. type HugoSites struct { Sites []*Site - multilingual *Multilingual + Configs *allconfig.Configs - // Multihost is set if multilingual and baseURL set on the language level. - multihost bool + hugoInfo hugo.HugoInfo - // If this is running in the dev server. - running bool + // Render output formats for all sites. + renderFormats output.Formats + + // The currently rendered Site. + currentSite *Site *deps.Deps - // Keeps track of bundle directories and symlinks to enable partial rebuilding. - ContentChanges *contentChangeMap + gitInfo *gitInfo + codeownerInfo *codeownerInfo - // If enabled, keeps a revision map for all content. - gitInfo *gitInfo + // As loaded from the /data dirs + data map[string]any + + // Cache for page listings. + cachePages *dynacache.Partition[string, page.Pages] + // Cache for content sources. + cacheContentSource *dynacache.Partition[string, *resources.StaleValue[[]byte]] + + // Before Hugo 0.122.0 we managed all translations in a map using a translationKey + // that could be overridden in front matter. + // Now the different page dimensions (e.g. language) are built-in to the page trees above. + // But we sill need to support the overridden translationKey, but that should + // be relatively rare and low volume. + translationKeyPages *maps.SliceCache[page.Page] + + pageTrees *pageTrees + + printUnusedTemplatesInit sync.Once + printPathWarningsInit sync.Once + + // File change events with filename stored in this map will be skipped. + skipRebuildForFilenamesMu sync.Mutex + skipRebuildForFilenames map[string]bool + + init *hugoSitesInit + + workersSite *para.Workers + numWorkersSites int + numWorkers int + + *fatalErrorHandler + *buildCounters + // Tracks invocations of the Build method. + buildCounter atomic.Uint64 } -func (h *HugoSites) IsMultihost() bool { - return h != nil && h.multihost +// ShouldSkipFileChangeEvent allows skipping filesystem event early before +// the build is started. +func (h *HugoSites) ShouldSkipFileChangeEvent(ev fsnotify.Event) bool { + h.skipRebuildForFilenamesMu.Lock() + defer h.skipRebuildForFilenamesMu.Unlock() + return h.skipRebuildForFilenames[ev.Name] +} + +func (h *HugoSites) Close() error { + return h.Deps.Close() +} + +func (h *HugoSites) isRebuild() bool { + return h.buildCounter.Load() > 0 +} + +func (h *HugoSites) resolveSite(lang string) *Site { + if lang == "" { + lang = h.Conf.DefaultContentLanguage() + } + + for _, s := range h.Sites { + if s.Lang() == lang { + return s + } + } + + return nil +} + +type buildCounters struct { + contentRenderCounter atomic.Uint64 + pageRenderCounter atomic.Uint64 +} + +func (c *buildCounters) loggFields() logg.Fields { + return logg.Fields{ + {Name: "pages", Value: c.pageRenderCounter.Load()}, + {Name: "content", Value: c.contentRenderCounter.Load()}, + } +} + +type fatalErrorHandler struct { + mu sync.Mutex + + h *HugoSites + + err error + + done bool + donec chan bool // will be closed when done +} + +// FatalError error is used in some rare situations where it does not make sense to +// continue processing, to abort as soon as possible and log the error. +func (f *fatalErrorHandler) FatalError(err error) { + f.mu.Lock() + defer f.mu.Unlock() + if !f.done { + f.done = true + close(f.donec) + } + f.err = err +} + +func (f *fatalErrorHandler) getErr() error { + f.mu.Lock() + defer f.mu.Unlock() + return f.err +} + +func (f *fatalErrorHandler) Done() <-chan bool { + return f.donec +} + +type hugoSitesInit struct { + // Loads the data from all of the /data folders. + data *lazy.Init + + // Loads the Git info and CODEOWNERS for all the pages if enabled. + gitInfo *lazy.Init +} + +func (h *HugoSites) Data() map[string]any { + if _, err := h.init.data.Do(context.Background()); err != nil { + h.SendError(fmt.Errorf("failed to load data: %w", err)) + return nil + } + return h.data +} + +// Pages returns all pages for all sites. +func (h *HugoSites) Pages() page.Pages { + key := "pages" + v, err := h.cachePages.GetOrCreate(key, func(string) (page.Pages, error) { + var pages page.Pages + for _, s := range h.Sites { + pages = append(pages, s.Pages()...) + } + page.SortByDefault(pages) + return pages, nil + }) + if err != nil { + panic(err) + } + return v +} + +// Pages returns all regularpages for all sites. +func (h *HugoSites) RegularPages() page.Pages { + key := "regular-pages" + v, err := h.cachePages.GetOrCreate(key, func(string) (page.Pages, error) { + var pages page.Pages + for _, s := range h.Sites { + pages = append(pages, s.RegularPages()...) + } + page.SortByDefault(pages) + + return pages, nil + }) + if err != nil { + panic(err) + } + return v +} + +func (h *HugoSites) gitInfoForPage(p page.Page) (source.GitInfo, error) { + if _, err := h.init.gitInfo.Do(context.Background()); err != nil { + return source.GitInfo{}, err + } + + if h.gitInfo == nil { + return source.GitInfo{}, nil + } + + return h.gitInfo.forPage(p), nil +} + +func (h *HugoSites) codeownersForPage(p page.Page) ([]string, error) { + if _, err := h.init.gitInfo.Do(context.Background()); err != nil { + return nil, err + } + + if h.codeownerInfo == nil { + return nil, nil + } + + return h.codeownerInfo.forPage(p), nil +} + +func (h *HugoSites) pickOneAndLogTheRest(errors []error) error { + if len(errors) == 0 { + return nil + } + + var i int + + for j, err := range errors { + // If this is in server mode, we want to return an error to the client + // with a file context, if possible. + if herrors.UnwrapFileError(err) != nil { + i = j + break + } + } + + // Log the rest, but add a threshold to avoid flooding the log. + const errLogThreshold = 5 + + for j, err := range errors { + if j == i || err == nil { + continue + } + + if j >= errLogThreshold { + break + } + + h.Log.Errorln(err) + } + + return errors[i] +} + +func (h *HugoSites) isMultilingual() bool { + return len(h.Sites) > 1 +} + +// TODO(bep) consolidate +func (h *HugoSites) LanguageSet() map[string]int { + set := make(map[string]int) + for i, s := range h.Sites { + set[s.language.Lang] = i + } + return set +} + +func (h *HugoSites) NumLogErrors() int { + if h == nil { + return 0 + } + return h.Log.LoggCount(logg.LevelError) } func (h *HugoSites) PrintProcessingStats(w io.Writer) { stats := make([]*helpers.ProcessingStats, len(h.Sites)) - for i := 0; i < len(h.Sites); i++ { + for i := range h.Sites { stats[i] = h.Sites[i].PathSpec.ProcessingStats } helpers.ProcessingStatsTable(w, stats...) } -func (h *HugoSites) langSite() map[string]*Site { - m := make(map[string]*Site) - for _, s := range h.Sites { - m[s.Language.Lang] = s - } - return m -} - // GetContentPage finds a Page with content given the absolute filename. // Returns nil if none found. -func (h *HugoSites) GetContentPage(filename string) *Page { - for _, s := range h.Sites { - pos := s.rawAllPages.findPagePosByFilename(filename) - if pos == -1 { - continue - } - return s.rawAllPages[pos] - } +func (h *HugoSites) GetContentPage(filename string) page.Page { + var p page.Page - // If not found already, this may be bundled in another content file. - dir := filepath.Dir(filename) - - for _, s := range h.Sites { - pos := s.rawAllPages.findPagePosByFilnamePrefix(dir) - if pos == -1 { - continue + h.withPage(func(s string, p2 *pageState) bool { + if p2.File() == nil { + return false } - return s.rawAllPages[pos] - } - return nil + + if p2.File().FileInfo().Meta().Filename == filename { + p = p2 + return true + } + + for _, r := range p2.Resources().ByType(pageResourceType) { + p3 := r.(page.Page) + if p3.File() != nil && p3.File().FileInfo().Meta().Filename == filename { + p = p3 + return true + } + } + + return false + }) + + return p } -// NewHugoSites creates a new collection of sites given the input sites, building -// a language configuration based on those. -func newHugoSites(cfg deps.DepsCfg, sites ...*Site) (*HugoSites, error) { - - if cfg.Language != nil { - return nil, errors.New("Cannot provide Language in Cfg when sites are provided") - } - - langConfig, err := newMultiLingualFromSites(cfg.Cfg, sites...) - - if err != nil { - return nil, err - } - - var contentChangeTracker *contentChangeMap - - h := &HugoSites{ - running: cfg.Running, - multilingual: langConfig, - multihost: cfg.Cfg.GetBool("multihost"), - Sites: sites} - - for _, s := range sites { - s.owner = h - } - - if err := applyDepsIfNeeded(cfg, sites...); err != nil { - return nil, err - } - - h.Deps = sites[0].Deps - - // Only needed in server mode. - // TODO(bep) clean up the running vs watching terms - if cfg.Running { - contentChangeTracker = &contentChangeMap{pathSpec: h.PathSpec, symContent: make(map[string]map[string]bool)} - h.ContentChanges = contentChangeTracker - } - - if err := h.initGitInfo(); err != nil { - return nil, err - } - - return h, nil -} - -func (h *HugoSites) initGitInfo() error { - if h.Cfg.GetBool("enableGitInfo") { - gi, err := newGitInfo(h.Cfg) +func (h *HugoSites) loadGitInfo() error { + if h.Configs.Base.EnableGitInfo { + gi, err := newGitInfo(h.Deps) if err != nil { - h.Log.ERROR.Println("Failed to read Git log:", err) + h.Log.Errorln("Failed to read Git log:", err) } else { h.gitInfo = gi } + + co, err := newCodeOwners(h.Configs.LoadingInfo.BaseConfig.WorkingDir) + if err != nil { + h.Log.Errorln("Failed to read CODEOWNERS:", err) + } else { + h.codeownerInfo = co + } } return nil } -func applyDepsIfNeeded(cfg deps.DepsCfg, sites ...*Site) error { - if cfg.TemplateProvider == nil { - cfg.TemplateProvider = tplimpl.DefaultTemplateProvider +// Reset resets the sites and template caches etc., making it ready for a full rebuild. +func (h *HugoSites) reset(config *BuildCfg) { + h.fatalErrorHandler = &fatalErrorHandler{ + h: h, + donec: make(chan bool), } +} - if cfg.TranslationProvider == nil { - cfg.TranslationProvider = i18n.NewTranslationProvider() +// resetLogs resets the log counters etc. Used to do a new build on the same sites. +func (h *HugoSites) resetLogs() { + h.Log.Reset() + for _, s := range h.Sites { + s.Deps.Log.Reset() } +} - var ( - d *deps.Deps - err error - ) - - for _, s := range sites { - if s.Deps != nil { - continue - } - - if d == nil { - cfg.Language = s.Language - cfg.WithTemplate = s.withSiteTemplates(cfg.WithTemplate) - - var err error - d, err = deps.New(cfg) - if err != nil { - return err - } - - d.OutputFormatsConfig = s.outputFormatsConfig - s.Deps = d - - if err = d.LoadResources(); err != nil { - return err - } - - } else { - d, err = d.ForLanguage(s.Language) - if err != nil { - return err - } - d.OutputFormatsConfig = s.outputFormatsConfig - s.Deps = d - } - - s.resourceSpec, err = resource.NewSpec(s.Deps.PathSpec, s.mediaTypesConfig) - if err != nil { +func (h *HugoSites) withSite(fn func(s *Site) error) error { + for _, s := range h.Sites { + if err := fn(s); err != nil { return err } - } - return nil } -// NewHugoSites creates HugoSites from the given config. -func NewHugoSites(cfg deps.DepsCfg) (*HugoSites, error) { - sites, err := createSitesFromConfig(cfg) - if err != nil { - return nil, err - } - return newHugoSites(cfg, sites...) -} - -func (s *Site) withSiteTemplates(withTemplates ...func(templ tpl.TemplateHandler) error) func(templ tpl.TemplateHandler) error { - return func(templ tpl.TemplateHandler) error { - templ.LoadTemplates(s.PathSpec.GetLayoutDirPath(), "") - if s.PathSpec.ThemeSet() { - templ.LoadTemplates(s.PathSpec.GetThemeDir()+"/layouts", "theme") +func (h *HugoSites) withPage(fn func(s string, p *pageState) bool) { + h.withSite(func(s *Site) error { + w := &doctree.NodeShiftTreeWalker[contentNodeI]{ + Tree: s.pageMap.treePages, + LockType: doctree.LockTypeRead, + Handle: func(s string, n contentNodeI, match doctree.DimensionFlag) (bool, error) { + return fn(s, n.(*pageState)), nil + }, } - - for _, wt := range withTemplates { - if wt == nil { - continue - } - if err := wt(templ); err != nil { - return err - } - } - - return nil - } -} - -func createSitesFromConfig(cfg deps.DepsCfg) ([]*Site, error) { - - var ( - sites []*Site - ) - - languages := getLanguages(cfg.Cfg) - - for _, lang := range languages { - if lang.Disabled { - continue - } - var s *Site - var err error - cfg.Language = lang - s, err = newSite(cfg) - - if err != nil { - return nil, err - } - - sites = append(sites, s) - } - - return sites, nil -} - -// Reset resets the sites and template caches, making it ready for a full rebuild. -func (h *HugoSites) reset() { - for i, s := range h.Sites { - h.Sites[i] = s.reset() - } -} - -func (h *HugoSites) createSitesFromConfig() error { - oldLangs, _ := h.Cfg.Get("languagesSorted").(helpers.Languages) - - if err := loadLanguageSettings(h.Cfg, oldLangs); err != nil { - return err - } - - depsCfg := deps.DepsCfg{Fs: h.Fs, Cfg: h.Cfg} - - sites, err := createSitesFromConfig(depsCfg) - - if err != nil { - return err - } - - langConfig, err := newMultiLingualFromSites(depsCfg.Cfg, sites...) - - if err != nil { - return err - } - - h.Sites = sites - - for _, s := range sites { - s.owner = h - } - - if err := applyDepsIfNeeded(depsCfg, sites...); err != nil { - return err - } - - h.Deps = sites[0].Deps - - h.multilingual = langConfig - h.multihost = h.Deps.Cfg.GetBool("multihost") - - return nil -} - -func (h *HugoSites) toSiteInfos() []*SiteInfo { - infos := make([]*SiteInfo, len(h.Sites)) - for i, s := range h.Sites { - infos[i] = &s.Info - } - return infos + return w.Walk(context.Background()) + }) } // BuildCfg holds build options used to, as an example, skip the render step. type BuildCfg struct { - // Reset site state before build. Use to force full rebuilds. - ResetState bool - // Re-creates the sites from configuration before a build. - // This is needed if new languages are added. - CreateSitesFromConfig bool // Skip rendering. Useful for testing. SkipRender bool + // Use this to indicate what changed (for rebuilds). - whatChanged *whatChanged - // Recently visited URLs. This is used for partial re-rendering. - RecentlyVisited map[string]bool + WhatChanged *WhatChanged + + // This is a partial re-render of some selected pages. + PartialReRender bool + + // Set in server mode when the last build failed for some reason. + ErrRecovery bool + + // Recently visited or touched URLs. This is used for partial re-rendering. + RecentlyTouched *types.EvictingQueue[string] + + // Can be set to build only with a sub set of the content source. + ContentInclusionFilter *glob.FilenameFilter + + // Set when the buildlock is already acquired (e.g. the archetype content builder). + NoBuildLock bool + + testCounters *buildCounters } -// shouldRender is used in the Fast Render Mode to determine if we need to re-render -// a Page: If it is recently visited (the home pages will always be in this set) or changed. -// Note that a page does not have to have a content page / file. -// For regular builds, this will allways return true. -func (cfg *BuildCfg) shouldRender(p *Page) bool { - if len(cfg.RecentlyVisited) == 0 { +// shouldRender returns whether this output format should be rendered or not. +func (cfg *BuildCfg) shouldRender(infol logg.LevelLogger, p *pageState) bool { + if p.skipRender() { + return false + } + + if !p.renderOnce { return true } - if cfg.RecentlyVisited[p.RelPermalink()] { - return true + // The render state is incremented on render and reset when a related change is detected. + // Note that this is set per output format. + shouldRender := p.renderState == 0 + + if !shouldRender { + return false } - if cfg.whatChanged != nil && p.File != nil { - return cfg.whatChanged.files[p.File.Filename()] + fastRenderMode := p.s.Conf.FastRenderMode() + + if !fastRenderMode || p.s.h.buildCounter.Load() == 0 { + return shouldRender + } + + if !p.render { + // Not be to rendered for this output format. + return false + } + + if relURL := p.getRelURL(); relURL != "" { + if cfg.RecentlyTouched.Contains(relURL) { + infol.Logf("render recently touched URL %q (%s)", relURL, p.outputFormat().Name) + return true + } + } + + // In fast render mode, we want to avoid re-rendering the sitemaps etc. and + // other big listings whenever we e.g. change a content file, + // but we want partial renders of the recently touched pages to also include + // alternative formats of the same HTML page (e.g. RSS, JSON). + for _, po := range p.pageOutputs { + if po.render && po.f.IsHTML && cfg.RecentlyTouched.Contains(po.getRelURL()) { + infol.Logf("render recently touched URL %q, %s version of %s", po.getRelURL(), po.f.Name, p.outputFormat().Name) + return true + } } return false } -func (h *HugoSites) renderCrossSitesArtifacts() error { +func (s *Site) preparePagesForRender(isRenderingSite bool, idx int) error { + var err error - if !h.multilingual.enabled() || h.IsMultihost() { + initPage := func(p *pageState) error { + if err = p.shiftToOutputFormat(isRenderingSite, idx); err != nil { + return err + } return nil } - sitemapEnabled := false - for _, s := range h.Sites { - if s.isEnabled(kindSitemap) { - sitemapEnabled = true - break + return s.pageMap.forEeachPageIncludingBundledPages(nil, + func(p *pageState) (bool, error) { + return false, initPage(p) + }, + ) +} + +func (h *HugoSites) loadData() error { + h.data = make(map[string]any) + w := hugofs.NewWalkway( + hugofs.WalkwayConfig{ + Fs: h.PathSpec.BaseFs.Data.Fs, + IgnoreFile: h.SourceSpec.IgnoreFile, + PathParser: h.Conf.PathParser(), + WalkFn: func(path string, fi hugofs.FileMetaInfo) error { + if fi.IsDir() { + return nil + } + pi := fi.Meta().PathInfo + if pi == nil { + panic("no path info") + } + return h.handleDataFile(source.NewFileInfo(fi)) + }, + }) + + if err := w.Walk(); err != nil { + return err + } + return nil +} + +func (h *HugoSites) handleDataFile(r *source.File) error { + var current map[string]any + + f, err := r.FileInfo().Meta().Open() + if err != nil { + return fmt.Errorf("data: failed to open %q: %w", r.LogicalName(), err) + } + defer f.Close() + + // Crawl in data tree to insert data + current = h.data + dataPath := r.FileInfo().Meta().PathInfo.Unnormalized().Dir()[1:] + keyParts := strings.Split(dataPath, "/") + + for _, key := range keyParts { + if key != "" { + if _, ok := current[key]; !ok { + current[key] = make(map[string]any) + } + current = current[key].(map[string]any) } } - if !sitemapEnabled { + data, err := h.readData(r) + if err != nil { + return h.errWithFileContext(err, r) + } + + if data == nil { return nil } - // TODO(bep) DRY - sitemapDefault := parseSitemap(h.Cfg.GetStringMap("sitemap")) + // filepath.Walk walks the files in lexical order, '/' comes before '.' + higherPrecedentData := current[r.BaseFileName()] - s := h.Sites[0] + switch data.(type) { + case map[string]any: - smLayouts := []string{"sitemapindex.xml", "_default/sitemapindex.xml", "_internal/_default/sitemapindex.xml"} - - return s.renderAndWriteXML(&s.PathSpec.ProcessingStats.Sitemaps, "sitemapindex", - sitemapDefault.Filename, h.toSiteInfos(), s.appendThemeTemplates(smLayouts)...) -} - -func (h *HugoSites) assignMissingTranslations() error { - - // This looks heavy, but it should be a small number of nodes by now. - allPages := h.findAllPagesByKindNotIn(KindPage) - for _, nodeType := range []string{KindHome, KindSection, KindTaxonomy, KindTaxonomyTerm} { - nodes := h.findPagesByKindIn(nodeType, allPages) - - // Assign translations - for _, t1 := range nodes { - for _, t2 := range nodes { - if t1.isNewTranslation(t2) { - t1.translations = append(t1.translations, t2) - } - } - } - } - - // Now we can sort the translations. - for _, p := range allPages { - if len(p.translations) > 0 { - pageBy(languagePageSort).Sort(p.translations) - } - } - return nil - -} - -// createMissingPages creates home page, taxonomies etc. that isnt't created as an -// effect of having a content file. -func (h *HugoSites) createMissingPages() error { - var newPages Pages - - for _, s := range h.Sites { - if s.isEnabled(KindHome) { - // home pages - home := s.findPagesByKind(KindHome) - if len(home) > 1 { - panic("Too many homes") - } - if len(home) == 0 { - n := s.newHomePage() - s.Pages = append(s.Pages, n) - newPages = append(newPages, n) - } - } - - // Will create content-less root sections. - newSections := s.assembleSections() - s.Pages = append(s.Pages, newSections...) - newPages = append(newPages, newSections...) - - // taxonomy list and terms pages - taxonomies := s.Language.GetStringMapString("taxonomies") - if len(taxonomies) > 0 { - taxonomyPages := s.findPagesByKind(KindTaxonomy) - taxonomyTermsPages := s.findPagesByKind(KindTaxonomyTerm) - for _, plural := range taxonomies { - if s.isEnabled(KindTaxonomyTerm) { - foundTaxonomyTermsPage := false - for _, p := range taxonomyTermsPages { - if p.sections[0] == plural { - foundTaxonomyTermsPage = true - break - } - } - - if !foundTaxonomyTermsPage { - n := s.newTaxonomyTermsPage(plural) - s.Pages = append(s.Pages, n) - newPages = append(newPages, n) - } - } - - if s.isEnabled(KindTaxonomy) { - for key := range s.Taxonomies[plural] { - foundTaxonomyPage := false - origKey := key - - if s.Info.preserveTaxonomyNames { - key = s.PathSpec.MakePathSanitized(key) - } - for _, p := range taxonomyPages { - // Some people may have /authors/MaxMustermann etc. as paths. - // p.sections contains the raw values from the file system. - // See https://github.com/gohugoio/hugo/issues/4238 - singularKey := s.PathSpec.MakePathSanitized(p.sections[1]) - if p.sections[0] == plural && singularKey == key { - foundTaxonomyPage = true - break - } - } - - if !foundTaxonomyPage { - n := s.newTaxonomyPage(plural, origKey) - s.Pages = append(s.Pages, n) - newPages = append(newPages, n) - } - } - } - } - } - } - - if len(newPages) > 0 { - // This resorting is unfortunate, but it also needs to be sorted - // when sections are created. - first := h.Sites[0] - - first.AllPages = append(first.AllPages, newPages...) - - first.AllPages.Sort() - - for _, s := range h.Sites { - s.Pages.Sort() - } - - for i := 1; i < len(h.Sites); i++ { - h.Sites[i].AllPages = first.AllPages - } - } - - return nil -} - -func (h *HugoSites) removePageByFilename(filename string) { - for _, s := range h.Sites { - s.removePageFilename(filename) - } -} - -func (h *HugoSites) setupTranslations() { - for _, s := range h.Sites { - for _, p := range s.rawAllPages { - if p.Kind == kindUnknown { - p.Kind = p.s.kindFromSections(p.sections) - } - - if !p.s.isEnabled(p.Kind) { - continue - } - - shouldBuild := p.shouldBuild() - s.updateBuildStats(p) - if shouldBuild { - if p.headless { - s.headlessPages = append(s.headlessPages, p) + switch higherPrecedentData.(type) { + case nil: + current[r.BaseFileName()] = data + case map[string]any: + // merge maps: insert entries from data for keys that + // don't already exist in higherPrecedentData + higherPrecedentMap := higherPrecedentData.(map[string]any) + for key, value := range data.(map[string]any) { + if _, exists := higherPrecedentMap[key]; exists { + // this warning could happen if + // 1. A theme uses the same key; the main data folder wins + // 2. A sub folder uses the same key: the sub folder wins + // TODO(bep) figure out a way to detect 2) above and make that a WARN + h.Log.Infof("Data for key '%s' in path '%s' is overridden by higher precedence data already in the data tree", key, r.Path()) } else { - s.Pages = append(s.Pages, p) + higherPrecedentMap[key] = value } } - } - } - - allPages := make(Pages, 0) - - for _, s := range h.Sites { - allPages = append(allPages, s.Pages...) - } - - allPages.Sort() - - for _, s := range h.Sites { - s.AllPages = allPages - } - - // Pull over the collections from the master site - for i := 1; i < len(h.Sites); i++ { - h.Sites[i].Data = h.Sites[0].Data - } - - if len(h.Sites) > 1 { - allTranslations := pagesToTranslationsMap(allPages) - assignTranslationsToPages(allTranslations, allPages) - } -} - -func (s *Site) preparePagesForRender(cfg *BuildCfg) { - - pageChan := make(chan *Page) - wg := &sync.WaitGroup{} - - numWorkers := getGoMaxProcs() * 4 - - for i := 0; i < numWorkers; i++ { - wg.Add(1) - go func(pages <-chan *Page, wg *sync.WaitGroup) { - defer wg.Done() - for p := range pages { - if err := p.prepareForRender(cfg); err != nil { - s.Log.ERROR.Printf("Failed to prepare page %q for render: %s", p.BaseFileName(), err) - - } - } - }(pageChan, wg) - } - - for _, p := range s.Pages { - pageChan <- p - } - - for _, p := range s.headlessPages { - pageChan <- p - } - - close(pageChan) - - wg.Wait() - -} - -// Pages returns all pages for all sites. -func (h *HugoSites) Pages() Pages { - return h.Sites[0].AllPages -} - -func handleShortcodes(p *Page, rawContentCopy []byte) ([]byte, error) { - if p.shortcodeState != nil && len(p.shortcodeState.contentShortcodes) > 0 { - p.s.Log.DEBUG.Printf("Replace %d shortcodes in %q", len(p.shortcodeState.contentShortcodes), p.BaseFileName()) - err := p.shortcodeState.executeShortcodesForDelta(p) - - if err != nil { - return rawContentCopy, err + default: + // can't merge: higherPrecedentData is not a map + h.Log.Warnf("The %T data from '%s' overridden by "+ + "higher precedence %T data already in the data tree", data, r.Path(), higherPrecedentData) } - rawContentCopy, err = replaceShortcodeTokens(rawContentCopy, shortcodePlaceholderPrefix, p.shortcodeState.renderedShortcodes) - - if err != nil { - p.s.Log.FATAL.Printf("Failed to replace shortcode tokens in %s:\n%s", p.BaseFileName(), err.Error()) + case []any: + if higherPrecedentData == nil { + current[r.BaseFileName()] = data + } else { + // we don't merge array data + h.Log.Warnf("The %T data from '%s' overridden by "+ + "higher precedence %T data already in the data tree", data, r.Path(), higherPrecedentData) } - } - return rawContentCopy, nil -} - -func (s *Site) updateBuildStats(page *Page) { - if page.IsDraft() { - s.draftCount++ - } - - if page.IsFuture() { - s.futureCount++ - } - - if page.IsExpired() { - s.expiredCount++ - } -} - -func (h *HugoSites) findPagesByKindNotIn(kind string, inPages Pages) Pages { - return h.Sites[0].findPagesByKindNotIn(kind, inPages) -} - -func (h *HugoSites) findPagesByKindIn(kind string, inPages Pages) Pages { - return h.Sites[0].findPagesByKindIn(kind, inPages) -} - -func (h *HugoSites) findAllPagesByKind(kind string) Pages { - return h.findPagesByKindIn(kind, h.Sites[0].AllPages) -} - -func (h *HugoSites) findAllPagesByKindNotIn(kind string) Pages { - return h.findPagesByKindNotIn(kind, h.Sites[0].AllPages) -} - -func (h *HugoSites) findPagesByShortcode(shortcode string) Pages { - var pages Pages - for _, s := range h.Sites { - pages = append(pages, s.findPagesByShortcode(shortcode)...) - } - return pages -} - -// Used in partial reloading to determine if the change is in a bundle. -type contentChangeMap struct { - mu sync.RWMutex - branches []string - leafs []string - - pathSpec *helpers.PathSpec - - // Hugo supports symlinked content (both directories and files). This - // can lead to situations where the same file can be referenced from several - // locations in /content -- which is really cool, but also means we have to - // go an extra mile to handle changes. - // This map is only used in watch mode. - // It maps either file to files or the real dir to a set of content directories where it is in use. - symContent map[string]map[string]bool - symContentMu sync.Mutex -} - -func (m *contentChangeMap) add(filename string, tp bundleDirType) { - m.mu.Lock() - dir := filepath.Dir(filename) + helpers.FilePathSeparator - dir = strings.TrimPrefix(dir, ".") - switch tp { - case bundleBranch: - m.branches = append(m.branches, dir) - case bundleLeaf: - m.leafs = append(m.leafs, dir) default: - panic("invalid bundle type") + h.Log.Errorf("unexpected data type %T in file %s", data, r.LogicalName()) } - m.mu.Unlock() + + return nil } -// Track the addition of bundle dirs. -func (m *contentChangeMap) handleBundles(b *bundleDirs) { - for _, bd := range b.bundles { - m.add(bd.fi.Path(), bd.tp) - } +func (h *HugoSites) errWithFileContext(err error, f *source.File) error { + realFilename := f.FileInfo().Meta().Filename + return herrors.NewFileErrorFromFile(err, realFilename, h.Fs.Source, nil) } -// resolveAndRemove resolves the given filename to the root folder of a bundle, if relevant. -// It also removes the entry from the map. It will be re-added again by the partial -// build if it still is a bundle. -func (m *contentChangeMap) resolveAndRemove(filename string) (string, string, bundleDirType) { - m.mu.RLock() - defer m.mu.RUnlock() - - // Bundles share resources, so we need to start from the virtual root. - relPath, _ := m.pathSpec.RelContentDir(filename) - dir, name := filepath.Split(relPath) - if !strings.HasSuffix(dir, helpers.FilePathSeparator) { - dir += helpers.FilePathSeparator +func (h *HugoSites) readData(f *source.File) (any, error) { + file, err := f.FileInfo().Meta().Open() + if err != nil { + return nil, fmt.Errorf("readData: failed to open data file: %w", err) } + defer file.Close() + content := helpers.ReaderToBytes(file) - fileTp, isContent := classifyBundledFile(name) - - // This may be a member of a bundle. Start with branch bundles, the most specific. - if fileTp != bundleLeaf { - if fileTp == bundleNot && isContent { - // Branch bundles does not contain content pages as resources. - return dir, filename, bundleNot - } - - for i, b := range m.branches { - if b == dir { - m.branches = append(m.branches[:i], m.branches[i+1:]...) - return dir, b, bundleBranch - } - } - } - - // And finally the leaf bundles, which can contain anything. - for i, l := range m.leafs { - if strings.HasPrefix(dir, l) { - m.leafs = append(m.leafs[:i], m.leafs[i+1:]...) - return dir, l, bundleLeaf - } - } - - // Not part of any bundle - return dir, filename, bundleNot -} - -func (m *contentChangeMap) addSymbolicLinkMapping(from, to string) { - m.symContentMu.Lock() - mm, found := m.symContent[from] - if !found { - mm = make(map[string]bool) - m.symContent[from] = mm - } - mm[to] = true - m.symContentMu.Unlock() -} - -func (m *contentChangeMap) GetSymbolicLinkMappings(dir string) []string { - mm, found := m.symContent[dir] - if !found { - return nil - } - dirs := make([]string, len(mm)) - i := 0 - for dir := range mm { - dirs[i] = dir - i++ - } - - sort.Strings(dirs) - return dirs + format := metadecoders.FormatFromString(f.Ext()) + return metadecoders.Default.Unmarshal(content, format) } diff --git a/hugolib/hugo_sites_build.go b/hugolib/hugo_sites_build.go index 1c4ee7b63..ce8ddd143 100644 --- a/hugolib/hugo_sites_build.go +++ b/hugolib/hugo_sites_build.go @@ -1,4 +1,4 @@ -// Copyright 2016-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. @@ -15,230 +15,1248 @@ package hugolib import ( "bytes" - "fmt" - + "context" + "encoding/json" "errors" + "fmt" + "os" + "path" + "path/filepath" + "strings" + "time" - jww "github.com/spf13/jwalterweatherman" + "github.com/bep/logg" + "github.com/gohugoio/hugo/bufferpool" + "github.com/gohugoio/hugo/deps" + "github.com/gohugoio/hugo/hugofs" + "github.com/gohugoio/hugo/hugofs/files" + "github.com/gohugoio/hugo/hugofs/glob" + "github.com/gohugoio/hugo/hugolib/doctree" + "github.com/gohugoio/hugo/hugolib/pagesfromdata" + "github.com/gohugoio/hugo/hugolib/segments" + "github.com/gohugoio/hugo/identity" + "github.com/gohugoio/hugo/output" + "github.com/gohugoio/hugo/publisher" + "github.com/gohugoio/hugo/source" + "github.com/gohugoio/hugo/tpl" + + "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/common/para" + "github.com/gohugoio/hugo/common/paths" + "github.com/gohugoio/hugo/common/rungroup" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/resources/page" + "github.com/gohugoio/hugo/resources/page/siteidentities" + "github.com/gohugoio/hugo/resources/postpub" + + "github.com/spf13/afero" "github.com/fsnotify/fsnotify" - "github.com/gohugoio/hugo/helpers" ) // Build builds all sites. If filesystem events are provided, // this is considered to be a potential partial rebuild. func (h *HugoSites) Build(config BuildCfg, events ...fsnotify.Event) error { + infol := h.Log.InfoCommand("build") + defer loggers.TimeTrackf(infol, time.Now(), nil, "") + defer func() { + h.buildCounter.Add(1) + }() + + if h.Deps == nil { + panic("must have deps") + } + + if !config.NoBuildLock { + unlock, err := h.BaseFs.LockBuild() + if err != nil { + return fmt.Errorf("failed to acquire a build lock: %w", err) + } + defer unlock() + } + + defer func() { + for _, s := range h.Sites { + s.Deps.BuildEndListeners.Notify() + } + }() + + errCollector := h.StartErrorCollector() + errs := make(chan error) + + go func(from, to chan error) { + var errors []error + i := 0 + for e := range from { + i++ + if i > 50 { + break + } + errors = append(errors, e) + } + to <- h.pickOneAndLogTheRest(errors) + + close(to) + }(errCollector, errs) + + for _, s := range h.Sites { + s.state = siteStateInit + } + if h.Metrics != nil { h.Metrics.Reset() } - //t0 := time.Now() + h.buildCounters = config.testCounters + if h.buildCounters == nil { + h.buildCounters = &buildCounters{} + } // Need a pointer as this may be modified. conf := &config - - if conf.whatChanged == nil { + if conf.WhatChanged == nil { // Assume everything has changed - conf.whatChanged = &whatChanged{source: true, other: true} + conf.WhatChanged = &WhatChanged{needsPagesAssembly: true} } - if len(events) > 0 { - // Rebuild - if err := h.initRebuild(conf); err != nil { - return err + var prepareErr error + + if !config.PartialReRender { + prepare := func() error { + init := func(conf *BuildCfg) error { + for _, s := range h.Sites { + s.Deps.BuildStartListeners.Notify() + } + + if len(events) > 0 || len(conf.WhatChanged.Changes()) > 0 { + // Rebuild + if err := h.initRebuild(conf); err != nil { + return fmt.Errorf("initRebuild: %w", err) + } + } else { + if err := h.initSites(conf); err != nil { + return fmt.Errorf("initSites: %w", err) + } + } + + return nil + } + + ctx := context.Background() + + if err := h.process(ctx, infol, conf, init, events...); err != nil { + return fmt.Errorf("process: %w", err) + } + + if err := h.assemble(ctx, infol, conf); err != nil { + return fmt.Errorf("assemble: %w", err) + } + + return nil } - } else { - if err := h.init(conf); err != nil { - return err + + if prepareErr = prepare(); prepareErr != nil { + h.SendError(prepareErr) } } - if err := h.process(conf, events...); err != nil { - return err + for _, s := range h.Sites { + s.state = siteStateReady } - if err := h.assemble(conf); err != nil { - return err - } + if prepareErr == nil { + if err := h.render(infol, conf); err != nil { + h.SendError(fmt.Errorf("render: %w", err)) + } - if err := h.render(conf); err != nil { - return err + // Make sure to write any build stats to disk first so it's available + // to the post processors. + if err := h.writeBuildStats(); err != nil { + return err + } + + // We need to do this before render deferred. + if err := h.printPathWarningsOnce(); err != nil { + h.SendError(fmt.Errorf("printPathWarnings: %w", err)) + } + + if err := h.renderDeferred(infol); err != nil { + h.SendError(fmt.Errorf("renderDeferred: %w", err)) + } + + // This needs to be done after the deferred rendering to get complete template usage coverage. + if err := h.printUnusedTemplatesOnce(); err != nil { + h.SendError(fmt.Errorf("printPathWarnings: %w", err)) + } + + if err := h.postProcess(infol); err != nil { + h.SendError(fmt.Errorf("postProcess: %w", err)) + } } if h.Metrics != nil { var b bytes.Buffer h.Metrics.WriteMetrics(&b) - h.Log.FEEDBACK.Printf("\nTemplate Metrics:\n\n") - h.Log.FEEDBACK.Print(b.String()) - h.Log.FEEDBACK.Println() + h.Log.Printf("\nTemplate Metrics:\n\n") + h.Log.Println(b.String()) } - errorCount := h.Log.LogCountForLevel(jww.LevelError) + h.StopErrorCollector() + + err := <-errs + if err != nil { + return err + } + + if err := h.fatalErrorHandler.getErr(); err != nil { + return err + } + + errorCount := h.Log.LoggCount(logg.LevelError) + loggers.Log().LoggCount(logg.LevelError) if errorCount > 0 { return fmt.Errorf("logged %d error(s)", errorCount) } return nil - } // Build lifecycle methods below. // The order listed matches the order of execution. -func (h *HugoSites) init(config *BuildCfg) error { - - for _, s := range h.Sites { - if s.PageCollections == nil { - s.PageCollections = newPageCollections() - } - } - - if config.ResetState { - h.reset() - } - - if config.CreateSitesFromConfig { - if err := h.createSitesFromConfig(); err != nil { - return err - } - } - +func (h *HugoSites) initSites(config *BuildCfg) error { + h.reset(config) return nil } func (h *HugoSites) initRebuild(config *BuildCfg) error { - if config.CreateSitesFromConfig { - return errors.New("Rebuild does not support 'CreateSitesFromConfig'.") + if !h.Configs.Base.Internal.Watch { + return errors.New("rebuild called when not in watch mode") } - if config.ResetState { - return errors.New("Rebuild does not support 'ResetState'.") - } - - if !h.running { - return errors.New("Rebuild called when not in watch mode") - } - - if config.whatChanged.source { - // This is for the non-renderable content pages (rarely used, I guess). - // We could maybe detect if this is really needed, but it should be - // pretty fast. - h.TemplateHandler().RebuildClone() - } + h.pageTrees.treePagesResources.WalkPrefixRaw("", func(key string, n contentNodeI) bool { + n.resetBuildState() + return false + }) for _, s := range h.Sites { - s.resetBuildState() + s.resetBuildState(config.WhatChanged.needsPagesAssembly) } - helpers.InitLoggers() + h.reset(config) + h.resetLogs() return nil } -func (h *HugoSites) process(config *BuildCfg, events ...fsnotify.Event) error { - // We should probably refactor the Site and pull up most of the logic from there to here, - // but that seems like a daunting task. - // So for now, if there are more than one site (language), - // we pre-process the first one, then configure all the sites based on that. - - firstSite := h.Sites[0] +// process prepares the Sites' sources for a full or partial rebuild. +// This will also parse the source and create all the Page objects. +func (h *HugoSites) process(ctx context.Context, l logg.LevelLogger, config *BuildCfg, init func(config *BuildCfg) error, events ...fsnotify.Event) error { + l = l.WithField("step", "process") + defer loggers.TimeTrackf(l, time.Now(), nil, "") if len(events) > 0 { - // This is a rebuild - changed, err := firstSite.processPartial(events) - config.whatChanged = &changed - return err + // This is a rebuild triggered from file events. + return h.processPartialFileEvents(ctx, l, config, init, events) + } else if len(config.WhatChanged.Changes()) > 0 { + // Rebuild triggered from remote events. + if err := init(config); err != nil { + return err + } + return h.processPartialRebuildChanges(ctx, l, config) } - - return firstSite.process(*config) - + return h.processFull(ctx, l, config) } -func (h *HugoSites) assemble(config *BuildCfg) error { - if config.whatChanged.source { - for _, s := range h.Sites { - s.createTaxonomiesEntries() - } - } +// assemble creates missing sections, applies aggregate values (e.g. dates, cascading params), +// removes disabled pages etc. +func (h *HugoSites) assemble(ctx context.Context, l logg.LevelLogger, bcfg *BuildCfg) error { + l = l.WithField("step", "assemble") + defer loggers.TimeTrackf(l, time.Now(), nil, "") - // TODO(bep) we could probably wait and do this in one go later - h.setupTranslations() - - if len(h.Sites) > 1 { - // The first is initialized during process; initialize the rest - for _, site := range h.Sites[1:] { - site.initializeSiteInfo() - } - } - - if config.whatChanged.source { - for _, s := range h.Sites { - if err := s.buildSiteMeta(); err != nil { + if !bcfg.WhatChanged.needsPagesAssembly { + changes := bcfg.WhatChanged.Drain() + if len(changes) > 0 { + if err := h.resolveAndClearStateForIdentities(ctx, l, nil, changes); err != nil { return err } } + return nil } - if err := h.createMissingPages(); err != nil { - return err - } + h.translationKeyPages.Reset() + assemblers := make([]*sitePagesAssembler, len(h.Sites)) + // Changes detected during assembly (e.g. aggregate date changes) - for _, s := range h.Sites { - for _, pages := range []Pages{s.Pages, s.headlessPages} { - for _, p := range pages { - // May have been set in front matter - if len(p.outputFormats) == 0 { - p.outputFormats = s.outputFormats[p.Kind] - } - - if p.headless { - // headless = 1 output format only - p.outputFormats = p.outputFormats[:1] - } - for _, r := range p.Resources.ByType(pageResourceType) { - r.(*Page).outputFormats = p.outputFormats - } - - if err := p.initPaths(); err != nil { - return err - } - - } - } - s.assembleMenus() - s.refreshPageCaches() - s.setupSitePages() - } - - if err := h.assignMissingTranslations(); err != nil { - return err - } - - return nil - -} - -func (h *HugoSites) render(config *BuildCfg) error { - for _, s := range h.Sites { - s.initRenderFormats() - for i, rf := range s.renderFormats { - s.rc = &siteRenderingContext{Format: rf} - s.preparePagesForRender(config) - - if !config.SkipRender { - if err := s.render(config, i); err != nil { - return err - } - } + for i, s := range h.Sites { + assemblers[i] = &sitePagesAssembler{ + Site: s, + assembleChanges: bcfg.WhatChanged, + ctx: ctx, } } - if !config.SkipRender { - if err := h.renderCrossSitesArtifacts(); err != nil { + g, _ := h.workersSite.Start(ctx) + for _, s := range assemblers { + s := s + g.Run(func() error { + return s.assemblePagesStep1(ctx) + }) + } + if err := g.Wait(); err != nil { + return err + } + + changes := bcfg.WhatChanged.Drain() + + // Changes from the assemble step (e.g. lastMod, cascade) needs a re-calculation + // of what needs to be re-built. + if len(changes) > 0 { + if err := h.resolveAndClearStateForIdentities(ctx, l, nil, changes); err != nil { + return err + } + } + + for _, s := range assemblers { + if err := s.assemblePagesStep2(); err != nil { + return err + } + } + + // Handle new terms from assemblePagesStep2. + changes = bcfg.WhatChanged.Drain() + if len(changes) > 0 { + if err := h.resolveAndClearStateForIdentities(ctx, l, nil, changes); err != nil { + return err + } + } + + h.renderFormats = output.Formats{} + for _, s := range h.Sites { + s.s.initRenderFormats() + h.renderFormats = append(h.renderFormats, s.renderFormats...) + } + + for _, s := range assemblers { + if err := s.assemblePagesStepFinal(); err != nil { return err } } return nil } + +// render renders the sites. +func (h *HugoSites) render(l logg.LevelLogger, config *BuildCfg) error { + l = l.WithField("step", "render") + start := time.Now() + defer func() { + loggers.TimeTrackf(l, start, h.buildCounters.loggFields(), "") + }() + + siteRenderContext := &siteRenderContext{cfg: config, infol: l, multihost: h.Configs.IsMultihost} + + renderErr := func(err error) error { + if err == nil { + return nil + } + // In Hugo 0.141.0 we replaced the special error handling for resources.GetRemote + // with the more general try. + if strings.Contains(err.Error(), "can't evaluate field Err in type") { + if strings.Contains(err.Error(), "resource.Resource") { + return fmt.Errorf("%s: Resource.Err was removed in Hugo v0.141.0 and replaced with a new try keyword, see https://gohugo.io/functions/go-template/try/", err) + } else if strings.Contains(err.Error(), "template.HTML") { + return fmt.Errorf("%s: the return type of transform.ToMath was changed in Hugo v0.141.0 and the error handling replaced with a new try keyword, see https://gohugo.io/functions/go-template/try/", err) + } + } + return err + } + + i := 0 + for _, s := range h.Sites { + segmentFilter := s.conf.C.SegmentFilter + if segmentFilter.ShouldExcludeCoarse(segments.SegmentMatcherFields{Lang: s.language.Lang}) { + l.Logf("skip language %q not matching segments set in --renderSegments", s.language.Lang) + continue + } + + siteRenderContext.languageIdx = s.languagei + h.currentSite = s + for siteOutIdx, renderFormat := range s.renderFormats { + if segmentFilter.ShouldExcludeCoarse(segments.SegmentMatcherFields{Output: renderFormat.Name, Lang: s.language.Lang}) { + l.Logf("skip output format %q for language %q not matching segments set in --renderSegments", renderFormat.Name, s.language.Lang) + continue + } + + if err := func() error { + rc := tpl.RenderingContext{Site: s, SiteOutIdx: siteOutIdx} + h.BuildState.StartStageRender(rc) + defer h.BuildState.StopStageRender(rc) + + siteRenderContext.outIdx = siteOutIdx + siteRenderContext.sitesOutIdx = i + i++ + + select { + case <-h.Done(): + return nil + default: + for _, s2 := range h.Sites { + if err := s2.preparePagesForRender(s == s2, siteRenderContext.sitesOutIdx); err != nil { + return err + } + } + if !config.SkipRender { + ll := l.WithField("substep", "pages"). + WithField("site", s.language.Lang). + WithField("outputFormat", renderFormat.Name) + + start := time.Now() + + if config.PartialReRender { + if err := s.renderPages(siteRenderContext); err != nil { + return err + } + } else { + if err := s.render(siteRenderContext); err != nil { + return renderErr(err) + } + } + loggers.TimeTrackf(ll, start, nil, "") + } + } + return nil + }(); err != nil { + return err + } + + } + } + + return nil +} + +func (h *HugoSites) renderDeferred(l logg.LevelLogger) error { + l = l.WithField("step", "render deferred") + start := time.Now() + + var deferredCount int + + for rc, de := range h.Deps.BuildState.DeferredExecutionsGroupedByRenderingContext { + if de.FilenamesWithPostPrefix.Len() == 0 { + continue + } + + deferredCount += de.FilenamesWithPostPrefix.Len() + + s := rc.Site.(*Site) + for _, s2 := range h.Sites { + if err := s2.preparePagesForRender(s == s2, rc.SiteOutIdx); err != nil { + return err + } + } + if err := s.executeDeferredTemplates(de); err != nil { + return herrors.ImproveRenderErr(err) + } + } + + loggers.TimeTrackf(l, start, logg.Fields{ + logg.Field{Name: "count", Value: deferredCount}, + }, "") + + return nil +} + +func (s *Site) executeDeferredTemplates(de *deps.DeferredExecutions) error { + handleFile := func(filename string) error { + content, err := afero.ReadFile(s.BaseFs.PublishFs, filename) + if err != nil { + return err + } + + k := 0 + changed := false + + for { + if k >= len(content) { + break + } + l := bytes.Index(content[k:], []byte(tpl.HugoDeferredTemplatePrefix)) + if l == -1 { + break + } + m := bytes.Index(content[k+l:], []byte(tpl.HugoDeferredTemplateSuffix)) + len(tpl.HugoDeferredTemplateSuffix) + + low, high := k+l, k+l+m + + forward := l + m + id := string(content[low:high]) + + if err := func() error { + deferred, found := de.Executions.Get(id) + if !found { + panic(fmt.Sprintf("deferred execution with id %q not found", id)) + } + deferred.Mu.Lock() + defer deferred.Mu.Unlock() + + if !deferred.Executed { + tmpl := s.Deps.GetTemplateStore() + ti := s.TemplateStore.LookupByPath(deferred.TemplatePath) + if ti == nil { + panic(fmt.Sprintf("template %q not found", deferred.TemplatePath)) + } + + if err := func() error { + buf := bufferpool.GetBuffer() + defer bufferpool.PutBuffer(buf) + + err = tmpl.ExecuteWithContext(deferred.Ctx, ti, buf, deferred.Data) + if err != nil { + return err + } + deferred.Result = buf.String() + deferred.Executed = true + + return nil + }(); err != nil { + return err + } + } + + content = append(content[:low], append([]byte(deferred.Result), content[high:]...)...) + forward = len(deferred.Result) + changed = true + + return nil + }(); err != nil { + return err + } + + k += forward + } + + if changed { + return afero.WriteFile(s.BaseFs.PublishFs, filename, content, 0o666) + } + + return nil + } + + g := rungroup.Run[string](context.Background(), rungroup.Config[string]{ + NumWorkers: s.h.numWorkers, + Handle: func(ctx context.Context, filename string) error { + return handleFile(filename) + }, + }) + + de.FilenamesWithPostPrefix.ForEeach(func(filename string, _ bool) bool { + g.Enqueue(filename) + return true + }) + + return g.Wait() +} + +// printPathWarningsOnce prints path warnings if enabled. +func (h *HugoSites) printPathWarningsOnce() error { + h.printPathWarningsInit.Do(func() { + conf := h.Configs.Base + if conf.PrintPathWarnings { + // We need to do this before any post processing, as that may write to the same files twice + // and create false positives. + hugofs.WalkFilesystems(h.Fs.PublishDir, func(fs afero.Fs) bool { + if dfs, ok := fs.(hugofs.DuplicatesReporter); ok { + dupes := dfs.ReportDuplicates() + if dupes != "" { + h.Log.Warnln("Duplicate target paths:", dupes) + } + } + return false + }) + } + }) + return nil +} + +// / printUnusedTemplatesOnce prints unused templates if enabled. +func (h *HugoSites) printUnusedTemplatesOnce() error { + h.printUnusedTemplatesInit.Do(func() { + conf := h.Configs.Base + if conf.PrintUnusedTemplates { + unusedTemplates := h.GetTemplateStore().UnusedTemplates() + for _, unusedTemplate := range unusedTemplates { + if unusedTemplate.Fi != nil { + h.Log.Warnf("Template %s is unused, source %q", unusedTemplate.PathInfo.Path(), unusedTemplate.Fi.Meta().Filename) + } else { + h.Log.Warnf("Template %s is unused", unusedTemplate.PathInfo.Path()) + } + } + } + }) + return nil +} + +// postProcess runs the post processors, e.g. writing the hugo_stats.json file. +func (h *HugoSites) postProcess(l logg.LevelLogger) error { + l = l.WithField("step", "postProcess") + defer loggers.TimeTrackf(l, time.Now(), nil, "") + + // This will only be set when js.Build have been triggered with + // imports that resolves to the project or a module. + // Write a jsconfig.json file to the project's /asset directory + // to help JS IntelliSense in VS Code etc. + if !h.ResourceSpec.BuildConfig().NoJSConfigInAssets { + handleJSConfig := func(fi os.FileInfo) { + m := fi.(hugofs.FileMetaInfo).Meta() + if !m.IsProject { + return + } + + if jsConfig := h.ResourceSpec.JSConfigBuilder.Build(m.SourceRoot); jsConfig != nil { + b, err := json.MarshalIndent(jsConfig, "", " ") + if err != nil { + h.Log.Warnf("Failed to create jsconfig.json: %s", err) + } else { + filename := filepath.Join(m.SourceRoot, "jsconfig.json") + if h.Configs.Base.Internal.Running { + h.skipRebuildForFilenamesMu.Lock() + h.skipRebuildForFilenames[filename] = true + h.skipRebuildForFilenamesMu.Unlock() + } + // Make sure it's written to the OS fs as this is used by + // editors. + if err := afero.WriteFile(hugofs.Os, filename, b, 0o666); err != nil { + h.Log.Warnf("Failed to write jsconfig.json: %s", err) + } + } + } + } + + fi, err := h.BaseFs.Assets.Fs.Stat("") + if err != nil { + if !herrors.IsNotExist(err) { + h.Log.Warnf("Failed to resolve jsconfig.json dir: %s", err) + } + } else { + handleJSConfig(fi) + } + } + + var toPostProcess []postpub.PostPublishedResource + for _, r := range h.ResourceSpec.PostProcessResources { + toPostProcess = append(toPostProcess, r) + } + + if len(toPostProcess) == 0 { + // Nothing more to do. + return nil + } + + workers := para.New(config.GetNumWorkerMultiplier()) + g, _ := workers.Start(context.Background()) + + handleFile := func(filename string) error { + content, err := afero.ReadFile(h.BaseFs.PublishFs, filename) + if err != nil { + return err + } + + k := 0 + changed := false + + for { + l := bytes.Index(content[k:], []byte(postpub.PostProcessPrefix)) + if l == -1 { + break + } + m := bytes.Index(content[k+l:], []byte(postpub.PostProcessSuffix)) + len(postpub.PostProcessSuffix) + + low, high := k+l, k+l+m + + field := content[low:high] + + forward := l + m + + for i, r := range toPostProcess { + if r == nil { + panic(fmt.Sprintf("resource %d to post process is nil", i+1)) + } + v, ok := r.GetFieldString(string(field)) + if ok { + content = append(content[:low], append([]byte(v), content[high:]...)...) + changed = true + forward = len(v) + break + } + } + + k += forward + } + + if changed { + return afero.WriteFile(h.BaseFs.PublishFs, filename, content, 0o666) + } + + return nil + } + + filenames := h.Deps.BuildState.GetFilenamesWithPostPrefix() + for _, filename := range filenames { + filename := filename + g.Run(func() error { + return handleFile(filename) + }) + } + + // Prepare for a new build. + for _, s := range h.Sites { + s.ResourceSpec.PostProcessResources = make(map[string]postpub.PostPublishedResource) + } + + return g.Wait() +} + +func (h *HugoSites) writeBuildStats() error { + if h.ResourceSpec == nil { + panic("h.ResourceSpec is nil") + } + if !h.ResourceSpec.BuildConfig().BuildStats.Enabled() { + return nil + } + + htmlElements := &publisher.HTMLElements{} + for _, s := range h.Sites { + stats := s.publisher.PublishStats() + htmlElements.Merge(stats.HTMLElements) + } + + htmlElements.Sort() + + stats := publisher.PublishStats{ + HTMLElements: *htmlElements, + } + + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + err := enc.Encode(stats) + if err != nil { + return err + } + js := buf.Bytes() + + filename := filepath.Join(h.Configs.LoadingInfo.BaseConfig.WorkingDir, files.FilenameHugoStatsJSON) + + if existingContent, err := afero.ReadFile(hugofs.Os, filename); err == nil { + // Check if the content has changed. + if bytes.Equal(existingContent, js) { + return nil + } + } + + // Make sure it's always written to the OS fs. + if err := afero.WriteFile(hugofs.Os, filename, js, 0o666); err != nil { + return err + } + + // Write to the destination as well if it's a in-memory fs. + if !hugofs.IsOsFs(h.Fs.Source) { + if err := afero.WriteFile(h.Fs.WorkingDirWritable, filename, js, 0o666); err != nil { + return err + } + } + + // This step may be followed by a post process step that may + // rebuild e.g. CSS, so clear any cache that's defined for the hugo_stats.json. + h.dynacacheGCFilenameIfNotWatchedAndDrainMatching(filename) + + return nil +} + +type pathChange struct { + // The path to the changed file. + p *paths.Path + + // If true, this is a structural change (e.g. a delete or a rename). + structural bool + + // If true, this is a directory. + isDir bool +} + +func (p pathChange) isStructuralChange() bool { + return p.structural || p.isDir +} + +func (h *HugoSites) processPartialRebuildChanges(ctx context.Context, l logg.LevelLogger, config *BuildCfg) error { + if err := h.resolveAndClearStateForIdentities(ctx, l, nil, config.WhatChanged.Drain()); err != nil { + return err + } + + if err := h.processContentAdaptersOnRebuild(ctx, config); err != nil { + return err + } + return nil +} + +// processPartialFileEvents prepares the Sites' sources for a partial rebuild. +func (h *HugoSites) processPartialFileEvents(ctx context.Context, l logg.LevelLogger, config *BuildCfg, init func(config *BuildCfg) error, events []fsnotify.Event) error { + h.Log.Trace(logg.StringFunc(func() string { + var sb strings.Builder + sb.WriteString("File events:\n") + for _, ev := range events { + sb.WriteString(ev.String()) + sb.WriteString("\n") + } + return sb.String() + })) + + // For a list of events for the different OSes, see the test output in https://github.com/bep/fsnotifyeventlister/. + events = h.fileEventsFilter(events) + events = h.fileEventsTrim(events) + eventInfos := h.fileEventsApplyInfo(events) + + logger := h.Log + + var ( + tmplAdded bool + tmplChanged bool + i18nChanged bool + needsPagesAssemble bool + ) + + changedPaths := struct { + changedFiles []*paths.Path + changedDirs []*paths.Path + deleted []*paths.Path + }{} + + removeDuplicatePaths := func(ps []*paths.Path) []*paths.Path { + seen := make(map[string]bool) + var filtered []*paths.Path + for _, p := range ps { + if !seen[p.Path()] { + seen[p.Path()] = true + filtered = append(filtered, p) + } + } + return filtered + } + + var ( + cacheBusters []func(string) bool + deletedDirs []string + addedContentPaths []*paths.Path + ) + + var ( + addedOrChangedContent []pathChange + changes []identity.Identity + ) + + for _, ev := range eventInfos { + cpss := h.BaseFs.ResolvePaths(ev.Name) + pss := make([]*paths.Path, len(cpss)) + for i, cps := range cpss { + p := cps.Path + if ev.removed && !paths.HasExt(p) { + // Assume this is a renamed/removed directory. + // For deletes, we walk up the tree to find the container (e.g. branch bundle), + // so we will catch this even if it is a file without extension. + // This avoids us walking up to the home page bundle for the common case + // of renaming root sections. + p = p + "/_index.md" + deletedDirs = append(deletedDirs, cps.Path) + } + + pss[i] = h.Configs.ContentPathParser.Parse(cps.Component, p) + if ev.added && !ev.isChangedDir && cps.Component == files.ComponentFolderContent { + addedContentPaths = append(addedContentPaths, pss[i]) + } + + // Compile cache buster. + np := glob.NormalizePath(path.Join(cps.Component, cps.Path)) + g, err := h.ResourceSpec.BuildConfig().MatchCacheBuster(h.Log, np) + if err == nil && g != nil { + cacheBusters = append(cacheBusters, g) + } + + if ev.added { + changes = append(changes, identity.StructuralChangeAdd) + } + if ev.removed { + changes = append(changes, identity.StructuralChangeRemove) + } + } + + if ev.removed { + changedPaths.deleted = append(changedPaths.deleted, pss...) + } else if ev.isChangedDir { + changedPaths.changedDirs = append(changedPaths.changedDirs, pss...) + } else { + changedPaths.changedFiles = append(changedPaths.changedFiles, pss...) + } + } + + // Find the most specific identity possible. + handleChange := func(pathInfo *paths.Path, delete, isDir bool) { + switch pathInfo.Component() { + case files.ComponentFolderContent: + logger.Println("Source changed", pathInfo.Path()) + isContentDataFile := pathInfo.IsContentData() + if !isContentDataFile { + if ids := h.pageTrees.collectAndMarkStaleIdentities(pathInfo); len(ids) > 0 { + changes = append(changes, ids...) + } + } else { + h.pageTrees.treePagesFromTemplateAdapters.DeleteAllFunc(pathInfo.Base(), + func(s string, n *pagesfromdata.PagesFromTemplate) bool { + changes = append(changes, n.DependencyManager) + + // Try to open the file to see if has been deleted. + f, err := n.GoTmplFi.Meta().Open() + if err == nil { + f.Close() + } + if err != nil { + // Remove all pages and resources below. + prefix := pathInfo.Base() + "/" + h.pageTrees.treePages.DeletePrefixAll(prefix) + h.pageTrees.resourceTrees.DeletePrefixAll(prefix) + changes = append(changes, identity.NewGlobIdentity(prefix+"*")) + } + return err != nil + }) + } + + needsPagesAssemble = true + + if config.RecentlyTouched != nil { + // Fast render mode. Adding them to the visited queue + // avoids rerendering them on navigation. + for _, id := range changes { + if p, ok := id.(page.Page); ok { + config.RecentlyTouched.Add(p.RelPermalink()) + } + } + } + + h.pageTrees.treeTaxonomyEntries.DeletePrefix("") + + if delete && !isContentDataFile { + _, ok := h.pageTrees.treePages.LongestPrefixAll(pathInfo.Base()) + if ok { + h.pageTrees.treePages.DeleteAll(pathInfo.Base()) + h.pageTrees.resourceTrees.DeleteAll(pathInfo.Base()) + if pathInfo.IsBundle() { + // Assume directory removed. + h.pageTrees.treePages.DeletePrefixAll(pathInfo.Base() + "/") + h.pageTrees.resourceTrees.DeletePrefixAll(pathInfo.Base() + "/") + } + } else { + h.pageTrees.resourceTrees.DeleteAll(pathInfo.Base()) + } + } + + addedOrChangedContent = append(addedOrChangedContent, pathChange{p: pathInfo, structural: delete, isDir: isDir}) + + case files.ComponentFolderLayouts: + tmplChanged = true + templatePath := pathInfo.Unnormalized().TrimLeadingSlash().PathNoLang() + if !h.GetTemplateStore().HasTemplate(templatePath) { + tmplAdded = true + } + + if tmplAdded { + logger.Println("Template added", pathInfo.Path()) + // A new template may require a more coarse grained build. + base := pathInfo.Base() + if strings.Contains(base, "_markup") { + // It's hard to determine the exact change set of this, + // so be very coarse grained. + changes = append(changes, identity.GenghisKhan) + } + if strings.Contains(base, "shortcodes") { + changes = append(changes, identity.NewGlobIdentity(fmt.Sprintf("shortcodes/%s*", pathInfo.BaseNameNoIdentifier()))) + } else { + changes = append(changes, pathInfo) + } + } else { + logger.Println("Template changed", pathInfo.Path()) + id := h.GetTemplateStore().GetIdentity(pathInfo.Path()) + if id != nil { + changes = append(changes, id) + } else { + changes = append(changes, pathInfo) + } + } + case files.ComponentFolderAssets: + logger.Println("Asset changed", pathInfo.Path()) + changes = append(changes, pathInfo) + case files.ComponentFolderData: + logger.Println("Data changed", pathInfo.Path()) + + // This should cover all usage of site.Data. + // Currently very coarse grained. + changes = append(changes, siteidentities.Data) + h.init.data.Reset() + case files.ComponentFolderI18n: + logger.Println("i18n changed", pathInfo.Path()) + i18nChanged = true + // It's hard to determine the exact change set of this, + // so be very coarse grained for now. + changes = append(changes, identity.GenghisKhan) + case files.ComponentFolderArchetypes: + // Ignore for now. + default: + panic(fmt.Sprintf("unknown component: %q", pathInfo.Component())) + } + } + + changedPaths.deleted = removeDuplicatePaths(changedPaths.deleted) + changedPaths.changedFiles = removeDuplicatePaths(changedPaths.changedFiles) + + h.Log.Trace(logg.StringFunc(func() string { + var sb strings.Builder + sb.WriteString("Resolved paths:\n") + sb.WriteString("Deleted:\n") + for _, p := range changedPaths.deleted { + sb.WriteString("path: " + p.Path()) + sb.WriteString("\n") + } + sb.WriteString("Changed:\n") + for _, p := range changedPaths.changedFiles { + sb.WriteString("path: " + p.Path()) + sb.WriteString("\n") + } + return sb.String() + })) + + for _, deletedDir := range deletedDirs { + prefix := deletedDir + "/" + predicate := func(id identity.Identity) bool { + // This will effectively reset all pages below this dir. + return strings.HasPrefix(paths.AddLeadingSlash(id.IdentifierBase()), prefix) + } + // Test in both directions. + changes = append(changes, identity.NewPredicateIdentity( + // Is dependent. + predicate, + // Is dependency. + predicate, + ), + ) + } + + if len(addedContentPaths) > 0 { + // These content files are new and not in use anywhere. + // To make sure that these gets listed in any site.RegularPages ranges or similar + // we could invalidate everything, but first try to collect a sample set + // from the surrounding pages. + var surroundingIDs []identity.Identity + for _, p := range addedContentPaths { + if ids := h.pageTrees.collectIdentitiesSurrounding(p.Base(), 10); len(ids) > 0 { + surroundingIDs = append(surroundingIDs, ids...) + } + } + + if len(surroundingIDs) > 0 { + changes = append(changes, surroundingIDs...) + } else { + // No surrounding pages found, so invalidate everything. + changes = append(changes, identity.GenghisKhan) + } + } + + for _, deleted := range changedPaths.deleted { + handleChange(deleted, true, false) + } + + for _, id := range changedPaths.changedFiles { + handleChange(id, false, false) + } + + for _, id := range changedPaths.changedDirs { + handleChange(id, false, true) + } + + for _, id := range changes { + if id == identity.GenghisKhan { + for i, cp := range addedOrChangedContent { + cp.structural = true + addedOrChangedContent[i] = cp + } + break + } + } + + resourceFiles := h.fileEventsContentPaths(addedOrChangedContent) + + changed := &WhatChanged{ + needsPagesAssembly: needsPagesAssemble, + } + changed.Add(changes...) + + config.WhatChanged = changed + + if err := init(config); err != nil { + return err + } + + var cacheBusterOr func(string) bool + if len(cacheBusters) > 0 { + cacheBusterOr = func(s string) bool { + for _, cb := range cacheBusters { + if cb(s) { + return true + } + } + return false + } + } + + changes2 := changed.Changes() + h.Deps.OnChangeListeners.Notify(changes2...) + + if err := h.resolveAndClearStateForIdentities(ctx, l, cacheBusterOr, changed.Drain()); err != nil { + return err + } + + if tmplChanged { + if err := loggers.TimeTrackfn(func() (logg.LevelLogger, error) { + depsFinder := identity.NewFinder(identity.FinderConfig{}) + ll := l.WithField("substep", "rebuild templates") + s := h.Sites[0] + if err := s.Deps.TemplateStore.RefreshFiles(func(fi hugofs.FileMetaInfo) bool { + pi := fi.Meta().PathInfo + for _, id := range changes2 { + if depsFinder.Contains(pi, id, -1) > 0 { + return true + } + } + return false + }); err != nil { + return ll, err + } + + return ll, nil + }); err != nil { + return err + } + } + + if i18nChanged { + if err := loggers.TimeTrackfn(func() (logg.LevelLogger, error) { + ll := l.WithField("substep", "rebuild i18n") + var prototype *deps.Deps + for i, s := range h.Sites { + if err := s.Deps.Compile(prototype); err != nil { + return ll, err + } + if i == 0 { + prototype = s.Deps + } + } + return ll, nil + }); err != nil { + return err + } + } + + if resourceFiles != nil { + if err := h.processFiles(ctx, l, config, resourceFiles...); err != nil { + return err + } + } + + if h.isRebuild() { + if err := h.processContentAdaptersOnRebuild(ctx, config); err != nil { + return err + } + } + + return nil +} + +func (h *HugoSites) LogServerAddresses() { + if h.hugoInfo.IsMultihost() { + for _, s := range h.Sites { + h.Log.Printf("Web Server is available at %s (bind address %s) %s\n", s.conf.C.BaseURL, s.conf.C.ServerInterface, s.Language().Lang) + } + } else { + s := h.Sites[0] + h.Log.Printf("Web Server is available at %s (bind address %s)\n", s.conf.C.BaseURL, s.conf.C.ServerInterface) + } +} + +func (h *HugoSites) processFull(ctx context.Context, l logg.LevelLogger, config *BuildCfg) (err error) { + if err = h.processFiles(ctx, l, config); err != nil { + err = fmt.Errorf("readAndProcessContent: %w", err) + return + } + return err +} + +func (s *Site) handleContentAdapterChanges(bi pagesfromdata.BuildInfo, buildConfig *BuildCfg) { + if !s.h.isRebuild() { + return + } + + if len(bi.ChangedIdentities) > 0 { + buildConfig.WhatChanged.Add(bi.ChangedIdentities...) + buildConfig.WhatChanged.needsPagesAssembly = true + } + + for _, p := range bi.DeletedPaths { + pp := path.Join(bi.Path.Base(), p) + if v, ok := s.pageMap.treePages.Delete(pp); ok { + buildConfig.WhatChanged.Add(v.GetIdentity()) + } + } +} + +func (h *HugoSites) processContentAdaptersOnRebuild(ctx context.Context, buildConfig *BuildCfg) error { + g := rungroup.Run[*pagesfromdata.PagesFromTemplate](ctx, rungroup.Config[*pagesfromdata.PagesFromTemplate]{ + NumWorkers: h.numWorkers, + Handle: func(ctx context.Context, p *pagesfromdata.PagesFromTemplate) error { + bi, err := p.Execute(ctx) + if err != nil { + return err + } + s := p.Site.(*Site) + s.handleContentAdapterChanges(bi, buildConfig) + return nil + }, + }) + + h.pageTrees.treePagesFromTemplateAdapters.WalkPrefixRaw(doctree.LockTypeRead, "", func(key string, p *pagesfromdata.PagesFromTemplate) (bool, error) { + if p.StaleVersion() > 0 { + g.Enqueue(p) + } + return false, nil + }) + + return g.Wait() +} + +func (s *HugoSites) processFiles(ctx context.Context, l logg.LevelLogger, buildConfig *BuildCfg, filenames ...pathChange) error { + if s.Deps == nil { + panic("nil deps on site") + } + + sourceSpec := source.NewSourceSpec(s.PathSpec, buildConfig.ContentInclusionFilter, s.BaseFs.Content.Fs) + + // For inserts, we can pick an arbitrary pageMap. + pageMap := s.Sites[0].pageMap + + c := newPagesCollector(ctx, s.h, sourceSpec, s.Log, l, pageMap, buildConfig, filenames) + + if err := c.Collect(); err != nil { + return err + } + + return nil +} diff --git a/hugolib/hugo_sites_build_errors_test.go b/hugolib/hugo_sites_build_errors_test.go new file mode 100644 index 000000000..1298d7f4f --- /dev/null +++ b/hugolib/hugo_sites_build_errors_test.go @@ -0,0 +1,644 @@ +package hugolib + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/common/herrors" +) + +type testSiteBuildErrorAsserter struct { + name string + c *qt.C +} + +func (t testSiteBuildErrorAsserter) getFileError(err error) herrors.FileError { + t.c.Assert(err, qt.Not(qt.IsNil), qt.Commentf(t.name)) + fe := herrors.UnwrapFileError(err) + t.c.Assert(fe, qt.Not(qt.IsNil)) + return fe +} + +func (t testSiteBuildErrorAsserter) assertLineNumber(lineNumber int, err error) { + t.c.Helper() + fe := t.getFileError(err) + t.c.Assert(fe.Position().LineNumber, qt.Equals, lineNumber, qt.Commentf(err.Error())) +} + +func (t testSiteBuildErrorAsserter) assertErrorMessage(e1, e2 string) { + // The error message will contain filenames with OS slashes. Normalize before compare. + e1, e2 = filepath.ToSlash(e1), filepath.ToSlash(e2) + t.c.Assert(e2, qt.Contains, e1) +} + +func TestSiteBuildErrors(t *testing.T) { + const ( + yamlcontent = "yamlcontent" + tomlcontent = "tomlcontent" + jsoncontent = "jsoncontent" + shortcode = "shortcode" + base = "base" + single = "single" + ) + + // TODO(bep) add content tests after https://github.com/gohugoio/hugo/issues/5324 + // is implemented. + + tests := []struct { + name string + fileType string + fileFixer func(content string) string + assertCreateError func(a testSiteBuildErrorAsserter, err error) + assertBuildError func(a testSiteBuildErrorAsserter, err error) + }{ + { + name: "Base template parse failed", + fileType: base, + fileFixer: func(content string) string { + return strings.Replace(content, ".Title }}", ".Title }", 1) + }, + // Base templates gets parsed at build time. + assertBuildError: func(a testSiteBuildErrorAsserter, err error) { + a.assertLineNumber(4, err) + }, + }, + { + name: "Base template execute failed", + fileType: base, + fileFixer: func(content string) string { + return strings.Replace(content, ".Title", ".Titles", 1) + }, + assertBuildError: func(a testSiteBuildErrorAsserter, err error) { + a.assertLineNumber(4, err) + }, + }, + { + name: "Single template parse failed", + fileType: single, + fileFixer: func(content string) string { + return strings.Replace(content, ".Title }}", ".Title }", 1) + }, + assertCreateError: func(a testSiteBuildErrorAsserter, err error) { + fe := a.getFileError(err) + a.c.Assert(fe.Position().LineNumber, qt.Equals, 5) + a.c.Assert(fe.Position().ColumnNumber, qt.Equals, 1) + a.assertErrorMessage("\"layouts/foo/single.html:5:1\": parse failed: template: foo/single.html:5: unexpected \"}\" in operand", fe.Error()) + }, + }, + { + name: "Single template execute failed", + fileType: single, + fileFixer: func(content string) string { + return strings.Replace(content, ".Title", ".Titles", 1) + }, + assertBuildError: func(a testSiteBuildErrorAsserter, err error) { + fe := a.getFileError(err) + a.c.Assert(fe.Position().LineNumber, qt.Equals, 5) + a.c.Assert(fe.Position().ColumnNumber, qt.Equals, 14) + a.assertErrorMessage("\"layouts/_default/single.html:5:14\": execute of template failed", fe.Error()) + }, + }, + { + name: "Single template execute failed, long keyword", + fileType: single, + fileFixer: func(content string) string { + return strings.Replace(content, ".Title", ".ThisIsAVeryLongTitle", 1) + }, + assertBuildError: func(a testSiteBuildErrorAsserter, err error) { + fe := a.getFileError(err) + a.c.Assert(fe.Position().LineNumber, qt.Equals, 5) + a.c.Assert(fe.Position().ColumnNumber, qt.Equals, 14) + a.assertErrorMessage("\"layouts/_default/single.html:5:14\": execute of template failed", fe.Error()) + }, + }, + { + name: "Shortcode parse failed", + fileType: shortcode, + fileFixer: func(content string) string { + return strings.Replace(content, ".Title }}", ".Title }", 1) + }, + assertCreateError: func(a testSiteBuildErrorAsserter, err error) { + a.assertLineNumber(4, err) + }, + }, + { + name: "Shortcode execute failed", + fileType: shortcode, + fileFixer: func(content string) string { + return strings.Replace(content, ".Title", ".Titles", 1) + }, + assertBuildError: func(a testSiteBuildErrorAsserter, err error) { + fe := a.getFileError(err) + // Make sure that it contains both the content file and template + a.assertErrorMessage(`"content/myyaml.md:7:10": failed to render shortcode "sc": failed to process shortcode: "layouts/shortcodes/sc.html:4:22": execute of template failed: template: shortcodes/sc.html:4:22: executing "shortcodes/sc.html" at <.Page.Titles>: can't evaluate field Titles in type page.Page`, fe.Error()) + a.c.Assert(fe.Position().LineNumber, qt.Equals, 7) + }, + }, + { + name: "Shortode does not exist", + fileType: yamlcontent, + fileFixer: func(content string) string { + return strings.Replace(content, "{{< sc >}}", "{{< nono >}}", 1) + }, + assertBuildError: func(a testSiteBuildErrorAsserter, err error) { + fe := a.getFileError(err) + a.c.Assert(fe.Position().LineNumber, qt.Equals, 7) + a.c.Assert(fe.Position().ColumnNumber, qt.Equals, 10) + a.assertErrorMessage(`"content/myyaml.md:7:10": failed to extract shortcode: template for shortcode "nono" not found`, fe.Error()) + }, + }, + { + name: "Invalid YAML front matter", + fileType: yamlcontent, + fileFixer: func(content string) string { + return `--- +title: "My YAML Content" +foo bar +--- +` + }, + assertBuildError: func(a testSiteBuildErrorAsserter, err error) { + a.assertLineNumber(3, err) + }, + }, + { + name: "Invalid TOML front matter", + fileType: tomlcontent, + fileFixer: func(content string) string { + return strings.Replace(content, "description = ", "description &", 1) + }, + assertBuildError: func(a testSiteBuildErrorAsserter, err error) { + fe := a.getFileError(err) + a.c.Assert(fe.Position().LineNumber, qt.Equals, 6) + }, + }, + { + name: "Invalid JSON front matter", + fileType: jsoncontent, + fileFixer: func(content string) string { + return strings.Replace(content, "\"description\":", "\"description\"", 1) + }, + assertBuildError: func(a testSiteBuildErrorAsserter, err error) { + fe := a.getFileError(err) + a.c.Assert(fe.Position().LineNumber, qt.Equals, 3) + }, + }, + { + // See https://github.com/gohugoio/hugo/issues/5327 + name: "Panic in template Execute", + fileType: single, + fileFixer: func(content string) string { + return strings.Replace(content, ".Title", ".Parent.Parent.Parent", 1) + }, + + assertBuildError: func(a testSiteBuildErrorAsserter, err error) { + a.c.Assert(err, qt.Not(qt.IsNil)) + fe := a.getFileError(err) + a.c.Assert(fe.Position().LineNumber, qt.Equals, 5) + a.c.Assert(fe.Position().ColumnNumber, qt.Equals, 21) + }, + }, + } + + for _, test := range tests { + if test.name != "Invalid JSON front matter" { + continue + } + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + c := qt.New(t) + errorAsserter := testSiteBuildErrorAsserter{ + c: c, + name: test.name, + } + + b := newTestSitesBuilder(t).WithSimpleConfigFile() + + f := func(fileType, content string) string { + if fileType != test.fileType { + return content + } + return test.fileFixer(content) + } + + b.WithTemplatesAdded("layouts/shortcodes/sc.html", f(shortcode, `SHORTCODE L1 +SHORTCODE L2 +SHORTCODE L3: +SHORTCODE L4: {{ .Page.Title }} +`)) + b.WithTemplatesAdded("layouts/_default/baseof.html", f(base, `BASEOF L1 +BASEOF L2 +BASEOF L3 +BASEOF L4{{ if .Title }}{{ end }} +{{block "main" .}}This is the main content.{{end}} +BASEOF L6 +`)) + + b.WithTemplatesAdded("layouts/_default/single.html", f(single, `{{ define "main" }} +SINGLE L2: +SINGLE L3: +SINGLE L4: +SINGLE L5: {{ .Title }} {{ .Content }} +{{ end }} +`)) + + b.WithTemplatesAdded("layouts/foo/single.html", f(single, ` +SINGLE L2: +SINGLE L3: +SINGLE L4: +SINGLE L5: {{ .Title }} {{ .Content }} +`)) + + b.WithContent("myyaml.md", f(yamlcontent, `--- +title: "The YAML" +--- + +Some content. + + {{< sc >}} + +Some more text. + +The end. + +`)) + + b.WithContent("mytoml.md", f(tomlcontent, `+++ +title = "The TOML" +p1 = "v" +p2 = "v" +p3 = "v" +description = "Descriptioon" ++++ + +Some content. + + +`)) + + b.WithContent("myjson.md", f(jsoncontent, `{ + "title": "This is a title", + "description": "This is a description." +} + +Some content. + + +`)) + + createErr := b.CreateSitesE() + if test.assertCreateError != nil { + test.assertCreateError(errorAsserter, createErr) + } else { + c.Assert(createErr, qt.IsNil) + } + + if createErr == nil { + buildErr := b.BuildE(BuildCfg{}) + if test.assertBuildError != nil { + test.assertBuildError(errorAsserter, buildErr) + } else { + c.Assert(buildErr, qt.IsNil) + } + } + }) + } +} + +// Issue 9852 +func TestErrorMinify(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +minify = true + +-- layouts/index.html -- + + + + +` + + 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) { + b := newTestSitesBuilder(t) + b.WithConfigFile("toml", ` +timeout = 5 +`) + + b.WithTemplatesAdded("_default/single.html", ` +{{ .WordCount }} +`, "shortcodes/c.html", ` +{{ range .Page.Site.RegularPages }} +{{ .WordCount }} +{{ end }} + +`) + + for i := 1; i < 100; i++ { + b.WithContent(fmt.Sprintf("page%d.md", i), `--- +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_failures_test.go b/hugolib/hugo_sites_build_failures_test.go deleted file mode 100644 index b347490cd..000000000 --- a/hugolib/hugo_sites_build_failures_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package hugolib - -import ( - "fmt" - "testing" -) - -// https://github.com/gohugoio/hugo/issues/4526 -func TestSiteBuildFailureInvalidPageMetadata(t *testing.T) { - t.Parallel() - - validContentFile := ` ---- -title = "This is good" ---- - -Some content. -` - - invalidContentFile := ` ---- -title = "PDF EPUB: Anne Bradstreet: Poems "The Prologue Summary And Analysis EBook Full Text " ---- - -Some content. -` - - var contentFiles []string - for i := 0; i <= 30; i++ { - name := fmt.Sprintf("valid%d.md", i) - contentFiles = append(contentFiles, name, validContentFile) - if i%5 == 0 { - name = fmt.Sprintf("invalid%d.md", i) - contentFiles = append(contentFiles, name, invalidContentFile) - } - } - - b := newTestSitesBuilder(t) - b.WithSimpleConfigFile().WithContent(contentFiles...) - b.CreateSites().BuildFail(BuildCfg{}) - -} diff --git a/hugolib/hugo_sites_build_test.go b/hugolib/hugo_sites_build_test.go index 1626fadcf..4c2bf452c 100644 --- a/hugolib/hugo_sites_build_test.go +++ b/hugolib/hugo_sites_build_test.go @@ -1,163 +1,54 @@ package hugolib import ( - "bytes" "fmt" - "io" + "path/filepath" "strings" "testing" - "html/template" - "os" - "path/filepath" - "time" + qt "github.com/frankban/quicktest" + "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" - "github.com/spf13/viper" - "github.com/stretchr/testify/require" ) 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) { - assert := require.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 - - require.Len(t, sites, 4) - - enSite := sites[0] - frSite := sites[1] - - assert.Equal("/en", enSite.Info.LanguagePrefix) - - if defaultInSubDir { - assert.Equal("/fr", frSite.Info.LanguagePrefix) - } else { - assert.Equal("", frSite.Info.LanguagePrefix) - } - - assert.Equal("/blog/en/foo", enSite.PathSpec.RelURL("foo", true)) - - doc1en := enSite.RegularPages[0] - doc1fr := frSite.RegularPages[0] - - enPerm := doc1en.Permalink() - enRelPerm := doc1en.RelPermalink() - assert.Equal("http://example.com/blog/en/sect/doc1-slug/", enPerm) - assert.Equal("/blog/en/sect/doc1-slug/", enRelPerm) - - 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 { - assert.Equal("http://example.com/blog/fr/sect/doc1/", frPerm) - assert.Equal("/blog/fr/sect/doc1/", frRelPerm) - - // should have a redirect on top level. - b.AssertFileContent("public/index.html", ``) - } else { - // Main language in root - assert.Equal("http://example.com/blog/sect/doc1/", frPerm) - assert.Equal("/blog/sect/doc1/", frRelPerm) - - // 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"), "List", "Bonjour") - b.AssertFileContent("public/en/tags/tag1/index.html", "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(`Another header", "

    Another header

    ", "

    The End.

    "} + + for i := 1; i <= numPages; i++ { + if i%3 != 0 { + section := "s1" + if i%10 == 0 { + section = "s2" + } + checkContent(b, fmt.Sprintf("public/%s/page%d/index.html", section, i), contentMatchers...) + } + } + + for i := 1; i <= numPages; i++ { + section := "s1" + if i%10 == 0 { + section = "s2" + } + checkContent(b, fmt.Sprintf("public/%s/page%d/index.json", section, i), contentMatchers...) + } + + checkContent(b, "public/s1/index.html", "P: s1/_index.md\nList: 10|List Content: 8132\n\n\nL1: 500 L2: 5\n\nRender 0: View: 8132\n\nRender 1: View: 8132\n\nRender 2: View: 8132\n\nRender 3: View: 8132\n\nRender 4: View: 8132\n\nEND\n") + checkContent(b, "public/s2/index.html", "P: s2/_index.md\nList: 10|List Content: 8132", "Render 4: View: 8132\n\nEND") + checkContent(b, "public/index.html", "P: _index.md\nList: 10|List Content: 8132", "4: View: 8132\n\nEND") + + // Check paginated pages + for i := 2; i <= 9; i++ { + checkContent(b, fmt.Sprintf("public/page/%d/index.html", i), fmt.Sprintf("Page: %d", i), "Content: 8132\n\n\nL1: 500 L2: 5\n\nRender 0: View: 8132", "Render 4: View: 8132\n\nEND") } } -func doTestMultiSitesBuild(t *testing.T, configTemplate, configSuffix string) { - assert := require.New(t) +func checkContent(s *sitesBuilder, filename string, matches ...string) { + s.T.Helper() + content := readWorkingDir(s.T, s.Fs, filename) + for _, match := range matches { + if !strings.Contains(content, match) { + s.Fatalf("No match for\n%q\nin content for %s\n%q\nDiff:\n%s", match, filename, content, htesting.DiffStrings(content, match)) + } + } +} - b := newMultiSiteTestBuilder(t, configSuffix, configTemplate, nil) - b.CreateSites() +func TestTranslationsFromContentToNonContent(t *testing.T) { + b := newTestSitesBuilder(t) + b.WithConfigFile("toml", ` - sites := b.H.Sites - assert.Equal(4, len(sites)) +baseURL = "http://example.com/" + +defaultContentLanguage = "en" + +[languages] +[languages.en] +weight = 10 +contentDir = "content/en" +[languages.nn] +weight = 20 +contentDir = "content/nn" + + +`) + + b.WithContent("en/mysection/_index.md", ` +--- +Title: My Section +--- + +`) + + b.WithContent("en/_index.md", ` +--- +Title: My Home +--- + +`) + + b.WithContent("en/categories/mycat/_index.md", ` +--- +Title: My MyCat +--- + +`) + + b.WithContent("en/categories/_index.md", ` +--- +Title: My categories +--- + +`) + + for _, lang := range []string{"en", "nn"} { + b.WithContent(lang+"/mysection/page.md", ` +--- +Title: My Page +categories: ["mycat"] +--- + +`) + } b.Build(BuildCfg{}) - // Check site config - for _, s := range sites { - require.True(t, s.Info.defaultContentLanguageInSubdir, s.Info.Title) - require.NotNil(t, s.disabledKinds) - } - - gp1 := b.H.GetContentPage(filepath.FromSlash("content/sect/doc1.en.md")) - require.NotNil(t, gp1) - require.Equal(t, "doc1", gp1.title) - gp2 := b.H.GetContentPage(filepath.FromSlash("content/dummysect/notfound.md")) - require.Nil(t, gp2) - - enSite := sites[0] - enSiteHome := enSite.getPage(KindHome) - require.True(t, enSiteHome.IsTranslated()) - - require.Equal(t, "en", enSite.Language.Lang) - - assert.Equal(5, len(enSite.RegularPages)) - assert.Equal(32, len(enSite.AllPages)) - - doc1en := enSite.RegularPages[0] - permalink := doc1en.Permalink() - require.Equal(t, "http://example.com/blog/en/sect/doc1-slug/", permalink, "invalid doc1.en permalink") - require.Len(t, doc1en.Translations(), 1, "doc1-en should have one translation, excluding itself") - - doc2 := enSite.RegularPages[1] - permalink = doc2.Permalink() - require.Equal(t, "http://example.com/blog/en/sect/doc2/", permalink, "invalid doc2 permalink") - - doc3 := enSite.RegularPages[2] - permalink = doc3.Permalink() - // Note that /superbob is a custom URL set in frontmatter. - // We respect that URL literally (it can be /search.json) - // and do no not do any language code prefixing. - require.Equal(t, "http://example.com/blog/superbob/", permalink, "invalid doc3 permalink") - - require.Equal(t, "/superbob", doc3.URL(), "invalid url, was specified on doc3") - b.AssertFileContent("public/superbob/index.html", "doc3|Hello|en") - require.Equal(t, doc2.Next, doc3, "doc3 should follow doc2, in .Next") - - doc1fr := doc1en.Translations()[0] - permalink = doc1fr.Permalink() - require.Equal(t, "http://example.com/blog/fr/sect/doc1/", permalink, "invalid doc1fr permalink") - - require.Equal(t, doc1en.Translations()[0], doc1fr, "doc1-en should have doc1-fr as translation") - require.Equal(t, doc1fr.Translations()[0], doc1en, "doc1-fr should have doc1-en as translation") - require.Equal(t, "fr", doc1fr.Language().Lang) - - doc4 := enSite.AllPages[4] - permalink = doc4.Permalink() - require.Equal(t, "http://example.com/blog/fr/sect/doc4/", permalink, "invalid doc4 permalink") - require.Equal(t, "/blog/fr/sect/doc4/", doc4.URL()) - - require.Len(t, doc4.Translations(), 0, "found translations for doc4") - - doc5 := enSite.AllPages[5] - permalink = doc5.Permalink() - require.Equal(t, "http://example.com/blog/fr/somewhere/else/doc5/", permalink, "invalid doc5 permalink") - - // Taxonomies and their URLs - require.Len(t, enSite.Taxonomies, 1, "should have 1 taxonomy") - tags := enSite.Taxonomies["tags"] - require.Len(t, tags, 2, "should have 2 different tags") - require.Equal(t, tags["tag1"][0].Page, doc1en, "first tag1 page should be doc1") - - frSite := sites[1] - - require.Equal(t, "fr", frSite.Language.Lang) - require.Len(t, frSite.RegularPages, 4, "should have 3 pages") - require.Len(t, frSite.AllPages, 32, "should have 32 total pages (including translations and nodes)") - - for _, frenchPage := range frSite.RegularPages { - require.Equal(t, "fr", frenchPage.Lang()) - } - - // See https://github.com/gohugoio/hugo/issues/4285 - // Before Hugo 0.33 you had to be explicit with the content path to get the correct Page, which - // isn't ideal in a multilingual setup. You want a way to get the current language version if available. - // Now you can do lookups with translation base name to get that behaviour. - // Let us test all the regular page variants: - getPageDoc1En := enSite.getPage(KindPage, filepath.ToSlash(doc1en.Path())) - getPageDoc1EnBase := enSite.getPage(KindPage, "sect/doc1") - getPageDoc1Fr := frSite.getPage(KindPage, filepath.ToSlash(doc1fr.Path())) - getPageDoc1FrBase := frSite.getPage(KindPage, "sect/doc1") - require.Equal(t, doc1en, getPageDoc1En) - require.Equal(t, doc1fr, getPageDoc1Fr) - require.Equal(t, doc1en, getPageDoc1EnBase) - require.Equal(t, doc1fr, getPageDoc1FrBase) - - // Check redirect to main language, French - b.AssertFileContent("public/index.html", "0; url=http://example.com/blog/fr") - - // check home page content (including data files rendering) - b.AssertFileContent("public/en/index.html", "Default Home Page 1", "Hello", "Hugo Rocks!") - b.AssertFileContent("public/fr/index.html", "French Home Page 1", "Bonjour", "Hugo Rocks!") - - // check single page content - b.AssertFileContent("public/fr/sect/doc1/index.html", "Single", "Shortcode: Bonjour", "LingoFrench") - b.AssertFileContent("public/en/sect/doc1-slug/index.html", "Single", "Shortcode: Hello", "LingoDefault") - - // Check node translations - homeEn := enSite.getPage(KindHome) - require.NotNil(t, homeEn) - require.Len(t, homeEn.Translations(), 3) - require.Equal(t, "fr", homeEn.Translations()[0].Lang()) - require.Equal(t, "nn", homeEn.Translations()[1].Lang()) - require.Equal(t, "På nynorsk", homeEn.Translations()[1].title) - require.Equal(t, "nb", homeEn.Translations()[2].Lang()) - require.Equal(t, "På bokmål", homeEn.Translations()[2].title, configSuffix) - require.Equal(t, "Bokmål", homeEn.Translations()[2].Language().LanguageName, configSuffix) - - sectFr := frSite.getPage(KindSection, "sect") - require.NotNil(t, sectFr) - - require.Equal(t, "fr", sectFr.Lang()) - require.Len(t, sectFr.Translations(), 1) - require.Equal(t, "en", sectFr.Translations()[0].Lang()) - require.Equal(t, "Sects", sectFr.Translations()[0].title) - - nnSite := sites[2] - require.Equal(t, "nn", nnSite.Language.Lang) - taxNn := nnSite.getPage(KindTaxonomyTerm, "lag") - require.NotNil(t, taxNn) - require.Len(t, taxNn.Translations(), 1) - require.Equal(t, "nb", taxNn.Translations()[0].Lang()) - - taxTermNn := nnSite.getPage(KindTaxonomy, "lag", "sogndal") - require.NotNil(t, taxTermNn) - require.Len(t, taxTermNn.Translations(), 1) - require.Equal(t, "nb", taxTermNn.Translations()[0].Lang()) - - // Check sitemap(s) - b.AssertFileContent("public/sitemap.xml", - "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"] - require.Len(t, enTags, 2, fmt.Sprintf("Tags in en: %v", enTags)) - require.Len(t, frTags, 2, fmt.Sprintf("Tags in fr: %v", frTags)) - require.NotNil(t, enTags["tag1"]) - require.NotNil(t, frTags["frtag1"]) - b.AssertFileContent("public/fr/plaques/frtag1/index.html", "Frtag1|Bonjour|http://example.com/blog/fr/plaques/frtag1/") - b.AssertFileContent("public/en/tags/tag1/index.html", "Tag1|Hello|http://example.com/blog/en/tags/tag1/") - - // Check Blackfriday config - require.True(t, strings.Contains(string(doc1fr.Content), "«"), string(doc1fr.Content)) - require.False(t, strings.Contains(string(doc1en.Content), "«"), string(doc1en.Content)) - require.True(t, strings.Contains(string(doc1en.Content), "“"), string(doc1en.Content)) - - // Check that the drafts etc. are not built/processed/rendered. - assertShouldNotBuild(t, b.H) - - // en and nn have custom site menus - require.Len(t, frSite.Menus, 0, "fr: "+configSuffix) - require.Len(t, enSite.Menus, 1, "en: "+configSuffix) - require.Len(t, nnSite.Menus, 1, "nn: "+configSuffix) - - require.Equal(t, "Home", enSite.Menus["main"].ByName()[0].Name) - require.Equal(t, "Heim", nnSite.Menus["main"].ByName()[0].Name) - - // Issue #1302 - require.Equal(t, template.URL(""), enSite.RegularPages[0].RSSLink()) - - // Issue #3108 - next := enSite.RegularPages[0].Next - require.NotNil(t, next) - require.Equal(t, KindPage, next.Kind) - - for { - if next == nil { - break - } - require.Equal(t, KindPage, next.Kind) - next = next.Next - } - - // Check bundles - bundleFr := frSite.getPage(KindPage, "bundles/b1/index.md") - require.NotNil(t, bundleFr) - require.Equal(t, "/blog/fr/bundles/b1/", bundleFr.RelPermalink()) - require.Equal(t, 1, len(bundleFr.Resources)) - logoFr := bundleFr.Resources.GetByPrefix("logo") - require.NotNil(t, logoFr) - require.Equal(t, "/blog/fr/bundles/b1/logo.png", logoFr.RelPermalink()) - b.AssertFileContent("public/fr/bundles/b1/logo.png", "PNG Data") - - bundleEn := enSite.getPage(KindPage, "bundles/b1/index.en.md") - require.NotNil(t, bundleEn) - require.Equal(t, "/blog/en/bundles/b1/", bundleEn.RelPermalink()) - require.Equal(t, 1, len(bundleEn.Resources)) - logoEn := bundleEn.Resources.GetByPrefix("logo") - require.NotNil(t, logoEn) - require.Equal(t, "/blog/en/bundles/b1/logo.png", logoEn.RelPermalink()) - 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)() - } - - assert := require.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] - - assert.Len(enSite.RegularPages, 5) - assert.Len(frSite.RegularPages, 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") - - contentFs := b.H.BaseFs.ContentFs - - 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) { - assert.Len(enSite.RegularPages, 4, "1 en removed") - - // Check build stats - require.Equal(t, 1, enSite.draftCount, "Draft") - require.Equal(t, 1, enSite.futureCount, "Future") - require.Equal(t, 1, enSite.expiredCount, "Expired") - require.Equal(t, 0, frSite.draftCount, "Draft") - require.Equal(t, 1, frSite.futureCount, "Future") - require.Equal(t, 1, frSite.expiredCount, "Expired") - }, - }, - { - func(t *testing.T) { - writeNewContentFile(t, contentFs, "new_en_1", "2016-07-31", "new1.en.md", -5) - writeNewContentFile(t, contentFs, "new_en_2", "1989-07-30", "new2.en.md", -10) - writeNewContentFile(t, contentFs, "new_fr_1", "2016-07-30", "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) { - assert.Len(enSite.RegularPages, 6) - assert.Len(enSite.AllPages, 34) - assert.Len(frSite.RegularPages, 5) - require.Equal(t, "new_fr_1", frSite.RegularPages[3].title) - require.Equal(t, "new_en_2", enSite.RegularPages[0].title) - require.Equal(t, "new_en_1", enSite.RegularPages[1].title) - - rendered := readDestination(t, fs, "public/en/new1/index.html") - require.True(t, strings.Contains(rendered, "new_en_1"), rendered) - }, - }, - { - func(t *testing.T) { - p := "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) { - assert.Len(enSite.RegularPages, 6) - doc1 := readDestination(t, fs, "public/en/sect/doc1-slug/index.html") - require.True(t, strings.Contains(doc1, "CHANGED"), doc1) - - }, - }, - // Rename a file - { - func(t *testing.T) { - if err := contentFs.Rename("new1.en.md", "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) { - assert.Len(enSite.RegularPages, 6, "Rename") - require.Equal(t, "new_en_1", enSite.RegularPages[1].title) - rendered := readDestination(t, fs, "public/en/new1renamed/index.html") - require.True(t, strings.Contains(rendered, "new_en_1"), rendered) - }}, - { - // 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) { - assert.Len(enSite.RegularPages, 6) - assert.Len(enSite.AllPages, 34) - assert.Len(frSite.RegularPages, 5) - doc1 := readDestination(t, fs, "public/en/sect/doc1-slug/index.html") - require.True(t, strings.Contains(doc1, "Template Changed"), doc1) - }, - }, - { - // 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) { - assert.Len(enSite.RegularPages, 6) - assert.Len(enSite.AllPages, 34) - assert.Len(frSite.RegularPages, 5) - docEn := readDestination(t, fs, "public/en/sect/doc1-slug/index.html") - require.True(t, strings.Contains(docEn, "Hello"), "No Hello") - docFr := readDestination(t, fs, "public/fr/sect/doc1/index.html") - require.True(t, strings.Contains(docFr, "Salut"), "No Salut") - - homeEn := enSite.getPage(KindHome) - require.NotNil(t, homeEn) - assert.Len(homeEn.Translations(), 3) - require.Equal(t, "fr", homeEn.Translations()[0].Lang()) - - }, - }, - // 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) { - assert.Len(enSite.RegularPages, 6) - assert.Len(enSite.AllPages, 34) - assert.Len(frSite.RegularPages, 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") - }, - }, + for _, path := range []string{ + "/", + "/mysection", + "/categories", + "/categories/mycat", } { + t.Run(path, func(t *testing.T) { + c := qt.New(t) - if this.preFunc != nil { - this.preFunc(t) - } + s1, _ := b.H.Sites[0].getPage(nil, path) + s2, _ := b.H.Sites[1].getPage(nil, path) - err := b.H.Build(BuildCfg{}, this.events...) + c.Assert(s1, qt.Not(qt.IsNil)) + c.Assert(s2, qt.Not(qt.IsNil)) - if err != nil { - t.Fatalf("[%d] Failed to rebuild sites: %s", i, err) - } + c.Assert(len(s1.Translations()), qt.Equals, 1) + c.Assert(len(s2.Translations()), qt.Equals, 1) + c.Assert(s1.Translations()[0], qt.Equals, s2) + c.Assert(s2.Translations()[0], qt.Equals, s1) - this.assertFunc(t) - } - - // Check that the drafts etc. are not built/processed/rendered. - assertShouldNotBuild(t, b.H) - -} - -func assertShouldNotBuild(t *testing.T, sites *HugoSites) { - s := sites.Sites[0] - - for _, p := range s.rawAllPages { - // No HTML when not processed - require.Equal(t, p.shouldBuild(), bytes.Contains(p.workContent, []byte("}} -# Heading 1 {#1} -Some text. -## Subheading 1.1 {#1-1} -Some more text. -# Heading 2 {#2} -Even more text. -## Subheading 2.1 {#2-1} -Lorem ipsum... -` - -var tocPageSimpleExpected = `

    ` - -var tocPageWithShortcodesInHeadings = `--- -title: tocTest -publishdate: "2000-01-01" ---- - -{{< toc >}} - -# Heading 1 {#1} - -Some text. - -## Subheading 1.1 {{< shortcode >}} {#1-1} - -Some more text. - -# Heading 2 {{% shortcode %}} {#2} - -Even more text. - -## Subheading 2.1 {#2-1} - -Lorem ipsum... -` - -var tocPageWithShortcodesInHeadingsExpected = `` - -var multiSiteTOMLConfigTemplate = ` -baseURL = "http://example.com/blog" -rssURI = "index.xml" - -paginate = 1 -disablePathToLower = true -defaultContentLanguage = "{{ .DefaultContentLanguage }}" -defaultContentLanguageInSubdir = {{ .DefaultContentLanguageInSubdir }} - -[permalinks] -other = "/somewhere/else/:filename" - -[blackfriday] -angledQuotes = true - -[Taxonomies] -tag = "tags" - -[Languages] -[Languages.en] -weight = 10 -title = "In English" -languageName = "English" -[Languages.en.blackfriday] -angledQuotes = false -[[Languages.en.menu.main]] -url = "/" -name = "Home" -weight = 0 - -[Languages.fr] -weight = 20 -title = "Le Français" -languageName = "Français" -[Languages.fr.Taxonomies] -plaque = "plaques" - -[Languages.nn] -weight = 30 -title = "På nynorsk" -languageName = "Nynorsk" -paginatePath = "side" -[Languages.nn.Taxonomies] -lag = "lag" -[[Languages.nn.menu.main]] -url = "/" -name = "Heim" -weight = 1 - -[Languages.nb] -weight = 40 -title = "På bokmål" -languageName = "Bokmål" -paginatePath = "side" -[Languages.nb.Taxonomies] -lag = "lag" -` - -var multiSiteYAMLConfigTemplate = ` -baseURL: "http://example.com/blog" -rssURI: "index.xml" - -disablePathToLower: true -paginate: 1 -defaultContentLanguage: "{{ .DefaultContentLanguage }}" -defaultContentLanguageInSubdir: {{ .DefaultContentLanguageInSubdir }} - -permalinks: - other: "/somewhere/else/:filename" - -blackfriday: - angledQuotes: true - -Taxonomies: - tag: "tags" - -Languages: - en: - weight: 10 - title: "In English" - languageName: "English" - blackfriday: - angledQuotes: false - menu: - main: - - url: "/" - name: "Home" - weight: 0 - fr: - weight: 20 - title: "Le Français" - languageName: "Français" - Taxonomies: - plaque: "plaques" - nn: - weight: 30 - title: "På nynorsk" - languageName: "Nynorsk" - paginatePath: "side" - Taxonomies: - lag: "lag" - menu: - main: - - url: "/" - name: "Heim" - weight: 1 - nb: - weight: 40 - title: "På bokmål" - languageName: "Bokmål" - paginatePath: "side" - Taxonomies: - lag: "lag" - -` - -// TODO(bep) clean move -var multiSiteJSONConfigTemplate = ` -{ - "baseURL": "http://example.com/blog", - "rssURI": "index.xml", - "paginate": 1, - "disablePathToLower": true, - "defaultContentLanguage": "{{ .DefaultContentLanguage }}", - "defaultContentLanguageInSubdir": true, - "permalinks": { - "other": "/somewhere/else/:filename" - }, - "blackfriday": { - "angledQuotes": true - }, - "Taxonomies": { - "tag": "tags" - }, - "Languages": { - "en": { - "weight": 10, - "title": "In English", - "languageName": "English", - "blackfriday": { - "angledQuotes": false - }, - "menu": { - "main": [ - { - "url": "/", - "name": "Home", - "weight": 0 - } - ] - } - }, - "fr": { - "weight": 20, - "title": "Le Français", - "languageName": "Français", - "Taxonomies": { - "plaque": "plaques" - } - }, - "nn": { - "weight": 30, - "title": "På nynorsk", - "paginatePath": "side", - "languageName": "Nynorsk", - "Taxonomies": { - "lag": "lag" - }, - "menu": { - "main": [ - { - "url": "/", - "name": "Heim", - "weight": 1 - } - ] - } - }, - "nb": { - "weight": 40, - "title": "På bokmål", - "paginatePath": "side", - "languageName": "Bokmål", - "Taxonomies": { - "lag": "lag" - } - } - } -} -` - func writeSource(t testing.TB, fs *hugofs.Fs, filename, content string) { + t.Helper() 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.Helper() + if err := afero.WriteFile(fs, filepath.FromSlash(filename), []byte(content), 0o755); err != nil { t.Fatalf("Failed to write file: %s", err) } } -func readDestination(t testing.TB, fs *hugofs.Fs, filename string) string { - return readFileFromFs(t, fs.Destination, filename) +func readWorkingDir(t testing.TB, fs *hugofs.Fs, filename string) string { + t.Helper() + return readFileFromFs(t, fs.WorkingDirReadOnly, filename) } -func destinationExists(fs *hugofs.Fs, filename string) bool { - b, err := helpers.Exists(filename, fs.Destination) +func workingDirExists(fs *hugofs.Fs, filename string) bool { + b, err := helpers.Exists(filename, fs.WorkingDirReadOnly) if err != nil { panic(err) } return b } -func readSource(t *testing.T, fs *hugofs.Fs, filename string) string { - return readFileFromFs(t, fs.Source, filename) -} - func readFileFromFs(t testing.TB, fs afero.Fs, filename string) string { + t.Helper() filename = filepath.Clean(filename) b, err := afero.ReadFile(fs, filename) if err != nil { // Print some debug info - root := strings.Split(filename, helpers.FilePathSeparator)[0] - printFs(fs, root, os.Stdout) - Fatalf(t, "Failed to read file: %s", err) + hadSlash := strings.HasPrefix(filename, helpers.FilePathSeparator) + start := 0 + if hadSlash { + start = 1 + } + end := start + 1 + + parts := strings.Split(filename, helpers.FilePathSeparator) + if parts[start] == "work" { + end++ + } + + /* + root := filepath.Join(parts[start:end]...) + if hadSlash { + root = helpers.FilePathSeparator + root + } + + helpers.PrintFs(fs, root, os.Stdout) + */ + + t.Fatalf("Failed to read file: %s", err) } return string(b) } -func printFs(fs afero.Fs, path string, w io.Writer) { - if fs == nil { - return - } - afero.Walk(fs, path, func(path string, info os.FileInfo, err error) error { - if info != nil && !info.IsDir() { - s := path - if lang, ok := info.(hugofs.LanguageAnnouncer); ok { - s = s + "\tLANG: " + lang.Lang() - } - if fp, ok := info.(hugofs.FilePather); ok { - s = s + "\tRF: " + fp.Filename() + "\tBP: " + fp.BaseDir() - } - fmt.Fprintln(w, " ", s) - } - return nil - }) -} - const testPageTemplate = `--- title: "%s" publishdate: "%s" @@ -1078,212 +360,18 @@ func newTestPage(title, date string, weight int) string { return fmt.Sprintf(testPageTemplate, title, date, weight, title) } -func writeNewContentFile(t *testing.T, fs afero.Fs, title, date, filename string, weight int) { - content := newTestPage(title, date, weight) - writeToFs(t, fs, filename, content) -} - -type multiSiteTestBuilder struct { - configData interface{} - config string - configFormat string - - *sitesBuilder -} - -func newMultiSiteTestDefaultBuilder(t testing.TB) *multiSiteTestBuilder { - return newMultiSiteTestBuilder(t, "", "", nil) -} - -func (b *multiSiteTestBuilder) WithNewConfig(config string) *multiSiteTestBuilder { - b.WithConfigTemplate(b.configData, b.configFormat, config) - return b -} - -func (b *multiSiteTestBuilder) WithNewConfigData(data interface{}) *multiSiteTestBuilder { - b.WithConfigTemplate(data, b.configFormat, b.config) - return b -} - -func newMultiSiteTestBuilder(t testing.TB, configFormat, config string, configData interface{}) *multiSiteTestBuilder { - if configData == nil { - configData = map[string]interface{}{ - "DefaultContentLanguage": "fr", - "DefaultContentLanguageInSubdir": true, - } - } - - if config == "" { - config = multiSiteTOMLConfigTemplate - } - - if configFormat == "" { - configFormat = "toml" - } - - b := newTestSitesBuilder(t).WithConfigTemplate(configData, configFormat, config) - b.WithContent("root.en.md", `--- -title: root -weight: 10000 -slug: root -publishdate: "2000-01-01" ---- -# root -`, - "sect/doc1.en.md", `--- -title: doc1 -weight: 1 -slug: doc1-slug -tags: - - tag1 -publishdate: "2000-01-01" ---- -# doc1 -*some "content"* - -{{< shortcode >}} - -{{< lingo >}} - -NOTE: slug should be used as URL -`, - "sect/doc1.fr.md", `--- -title: doc1 -weight: 1 -plaques: - - frtag1 - - frtag2 -publishdate: "2000-01-04" ---- -# doc1 -*quelque "contenu"* - -{{< shortcode >}} - -{{< lingo >}} - -NOTE: should be in the 'en' Page's 'Translations' field. -NOTE: date is after "doc3" -`, - "sect/doc2.en.md", `--- -title: doc2 -weight: 2 -publishdate: "2000-01-02" ---- -# doc2 -*some content* -NOTE: without slug, "doc2" should be used, without ".en" as URL -`, - "sect/doc3.en.md", `--- -title: doc3 -weight: 3 -publishdate: "2000-01-03" -aliases: [/en/al/alias1,/al/alias2/] -tags: - - tag2 - - tag1 -url: /superbob ---- -# doc3 -*some content* -NOTE: third 'en' doc, should trigger pagination on home page. -`, - "sect/doc4.md", `--- -title: doc4 -weight: 4 -plaques: - - frtag1 -publishdate: "2000-01-05" ---- -# doc4 -*du contenu francophone* -NOTE: should use the defaultContentLanguage and mark this doc as 'fr'. -NOTE: doesn't have any corresponding translation in 'en' -`, - "other/doc5.fr.md", `--- -title: doc5 -weight: 5 -publishdate: "2000-01-06" ---- -# doc5 -*autre contenu francophone* -NOTE: should use the "permalinks" configuration with :filename -`, - // Add some for the stats - "stats/expired.fr.md", `--- -title: expired -publishdate: "2000-01-06" -expiryDate: "2001-01-06" ---- -# Expired -`, - "stats/future.fr.md", `--- -title: future -weight: 6 -publishdate: "2100-01-06" ---- -# Future -`, - "stats/expired.en.md", `--- -title: expired -weight: 7 -publishdate: "2000-01-06" -expiryDate: "2001-01-06" ---- -# Expired -`, - "stats/future.en.md", `--- -title: future -weight: 6 -publishdate: "2100-01-06" ---- -# Future -`, - "stats/draft.en.md", `--- -title: expired -publishdate: "2000-01-06" -draft: true ---- -# Draft -`, - "stats/tax.nn.md", `--- -title: Tax NN -weight: 8 -publishdate: "2000-01-06" -weight: 1001 -lag: -- Sogndal ---- -# Tax NN -`, - "stats/tax.nb.md", `--- -title: Tax NB -weight: 8 -publishdate: "2000-01-06" -weight: 1002 -lag: -- Sogndal ---- -# Tax NB -`, - // Bundle - "bundles/b1/index.en.md", `--- -title: Bundle EN -publishdate: "2000-01-06" -weight: 2001 ---- -# Bundle Content EN -`, - "bundles/b1/index.md", `--- -title: Bundle Default -publishdate: "2000-01-06" -weight: 2002 ---- -# Bundle Content Default -`, - "bundles/b1/logo.png", ` -PNG Data +func TestRebuildOnAssetChange(t *testing.T) { + b := newTestSitesBuilder(t).Running().WithLogger(loggers.NewDefault()) + b.WithTemplatesAdded("index.html", ` +{{ (resources.Get "data.json").Content }} `) + b.WithSourceFile("assets/data.json", "orig data") - return &multiSiteTestBuilder{sitesBuilder: b, configFormat: configFormat, config: config, configData: configData} + b.Build(BuildCfg{}) + b.AssertFileContent("public/index.html", `orig data`) + + b.EditFiles("assets/data.json", "changed data") + + b.Build(BuildCfg{}) + b.AssertFileContent("public/index.html", `changed data`) } diff --git a/hugolib/hugo_sites_multihost_test.go b/hugolib/hugo_sites_multihost_test.go index 7dc2d8e1c..37f7ab927 100644 --- a/hugolib/hugo_sites_multihost_test.go +++ b/hugolib/hugo_sites_multihost_test.go @@ -3,114 +3,282 @@ package hugolib import ( "testing" - "github.com/stretchr/testify/require" + qt "github.com/frankban/quicktest" ) -func TestMultihosts(t *testing.T) { +func TestMultihost(t *testing.T) { t.Parallel() - assert := require.New(t) - - var configTemplate = ` -paginate = 1 -disablePathToLower = true + files := ` +-- hugo.toml -- defaultContentLanguage = "fr" defaultContentLanguageInSubdir = false staticDir = ["s1", "s2"] +enableRobotsTXT = true + +[pagination] +pagerSize = 1 [permalinks] other = "/somewhere/else/:filename" -[Taxonomies] +[taxonomies] tag = "tags" -[Languages] -[Languages.en] -staticDir2 = ["ens1", "ens2"] +[languages] +[languages.en] +staticDir2 = ["staticen"] baseURL = "https://example.com/docs" weight = 10 title = "In English" languageName = "English" - -[Languages.fr] -staticDir2 = ["frs1", "frs2"] +[languages.fr] +staticDir2 = ["staticfr"] baseURL = "https://example.fr" weight = 20 title = "Le Français" languageName = "Français" +-- assets/css/main.css -- +body { color: red; } +-- content/mysect/mybundle/index.md -- +--- +tags: [a, b] +title: "My Bundle fr" +--- +My Bundle +-- content/mysect/mybundle/index.en.md -- +--- +tags: [c, d] +title: "My Bundle en" +--- +My Bundle +-- content/mysect/mybundle/foo.txt -- +Foo +-- layouts/_default/list.html -- +List|{{ .Title }}|{{ .Lang }}|{{ .Permalink}}|{{ .RelPermalink }}| +-- layouts/_default/single.html -- +Single|{{ .Title }}|{{ .Lang }}|{{ .Permalink}}|{{ .RelPermalink }}| +{{ $foo := .Resources.Get "foo.txt" | fingerprint }} +Foo: {{ $foo.Permalink }}| +{{ $css := resources.Get "css/main.css" | fingerprint }} +CSS: {{ $css.Permalink }}|{{ $css.RelPermalink }}| +-- layouts/robots.txt -- +robots|{{ site.Language.Lang }} +-- layouts/404.html -- +404|{{ site.Language.Lang }} + -[Languages.nn] -staticDir2 = ["nns1", "nns2"] -baseURL = "https://example.no" -weight = 30 -title = "På nynorsk" -languageName = "Nynorsk" ` - b := newMultiSiteTestDefaultBuilder(t).WithConfigFile("toml", configTemplate) - b.CreateSites().Build(BuildCfg{}) + b := Test(t, files) - b.AssertFileContent("public/en/sect/doc1-slug/index.html", "Hello") + b.Assert(b.H.Conf.IsMultilingual(), qt.Equals, true) + b.Assert(b.H.Conf.IsMultihost(), qt.Equals, true) - s1 := b.H.Sites[0] + // helpers.PrintFs(b.H.Fs.PublishDir, "", os.Stdout) - assert.Equal([]string{"s1", "s2", "ens1", "ens2"}, s1.StaticDirs()) + // Check regular pages. + b.AssertFileContent("public/en/mysect/mybundle/index.html", "Single|My Bundle en|en|https://example.com/docs/mysect/mybundle/|") + b.AssertFileContent("public/fr/mysect/mybundle/index.html", "Single|My Bundle fr|fr|https://example.fr/mysect/mybundle/|") - s1h := s1.getPage(KindHome) - assert.True(s1h.IsTranslated()) - assert.Len(s1h.Translations(), 2) - assert.Equal("https://example.com/docs/", s1h.Permalink()) + // Check robots.txt + b.AssertFileContent("public/en/robots.txt", "robots|en") + b.AssertFileContent("public/fr/robots.txt", "robots|fr") - // For “regular multilingual” we kept the aliases pages with url in front matter - // as a literal value that we use as is. - // There is an ambiguity in the guessing. - // For multihost, we never want any content in the root. - // - // check url in front matter: - pageWithURLInFrontMatter := s1.getPage(KindPage, "sect/doc3.en.md") - assert.NotNil(pageWithURLInFrontMatter) - assert.Equal("/superbob", pageWithURLInFrontMatter.URL()) - assert.Equal("/docs/superbob/", pageWithURLInFrontMatter.RelPermalink()) - b.AssertFileContent("public/en/superbob/index.html", "doc3|Hello|en") + // Check sitemap.xml + b.AssertFileContent("public/en/sitemap.xml", "https://example.com/docs/mysect/mybundle/") + b.AssertFileContent("public/fr/sitemap.xml", "https://example.fr/mysect/mybundle/") - // check alias: - b.AssertFileContent("public/en/al/alias1/index.html", `content="0; url=https://example.com/docs/superbob/"`) - b.AssertFileContent("public/en/al/alias2/index.html", `content="0; url=https://example.com/docs/superbob/"`) + // Check 404 + b.AssertFileContent("public/en/404.html", "404|en") + b.AssertFileContent("public/fr/404.html", "404|fr") - s2 := b.H.Sites[1] - assert.Equal([]string{"s1", "s2", "frs1", "frs2"}, s2.StaticDirs()) + // Check tags. + b.AssertFileContent("public/en/tags/d/index.html", "List|D|en|https://example.com/docs/tags/d/") + b.AssertFileContent("public/fr/tags/b/index.html", "List|B|fr|https://example.fr/tags/b/") + b.AssertFileExists("public/en/tags/b/index.html", false) + b.AssertFileExists("public/fr/tags/d/index.html", false) - s2h := s2.getPage(KindHome) - assert.Equal("https://example.fr/", s2h.Permalink()) - - b.AssertFileContent("public/fr/index.html", "French Home Page") - b.AssertFileContent("public/en/index.html", "Default Home Page") - - // Check paginators - b.AssertFileContent("public/en/page/1/index.html", `refresh" content="0; url=https://example.com/docs/"`) - b.AssertFileContent("public/nn/page/1/index.html", `refresh" content="0; url=https://example.no/"`) - b.AssertFileContent("public/en/sect/page/2/index.html", "List Page 2", "Hello", "https://example.com/docs/sect/", "\"/docs/sect/page/3/") - b.AssertFileContent("public/fr/sect/page/2/index.html", "List Page 2", "Bonjour", "https://example.fr/sect/") - - // Check bundles - - bundleEn := s1.getPage(KindPage, "bundles/b1/index.en.md") - require.NotNil(t, bundleEn) - require.Equal(t, "/docs/bundles/b1/", bundleEn.RelPermalink()) - require.Equal(t, 1, len(bundleEn.Resources)) - logoEn := bundleEn.Resources.GetByPrefix("logo") - require.NotNil(t, logoEn) - require.Equal(t, "/docs/bundles/b1/logo.png", logoEn.RelPermalink()) - b.AssertFileContent("public/en/bundles/b1/logo.png", "PNG Data") - - bundleFr := s2.getPage(KindPage, "bundles/b1/index.md") - require.NotNil(t, bundleFr) - require.Equal(t, "/bundles/b1/", bundleFr.RelPermalink()) - require.Equal(t, 1, len(bundleFr.Resources)) - logoFr := bundleFr.Resources.GetByPrefix("logo") - require.NotNil(t, logoFr) - require.Equal(t, "/bundles/b1/logo.png", logoFr.RelPermalink()) - b.AssertFileContent("public/fr/bundles/b1/logo.png", "PNG Data") + // en/mysect/mybundle/foo.txt fingerprinted + b.AssertFileContent("public/en/mysect/mybundle/foo.1cbec737f863e4922cee63cc2ebbfaafcd1cff8b790d8cfd2e6a5d550b648afa.txt", "Foo") + b.AssertFileContent("public/en/mysect/mybundle/index.html", "Foo: https://example.com/docs/mysect/mybundle/foo.1cbec737f863e4922cee63cc2ebbfaafcd1cff8b790d8cfd2e6a5d550b648afa.txt|") + b.AssertFileContent("public/fr/mysect/mybundle/foo.1cbec737f863e4922cee63cc2ebbfaafcd1cff8b790d8cfd2e6a5d550b648afa.txt", "Foo") + b.AssertFileContent("public/fr/mysect/mybundle/index.html", "Foo: https://example.fr/mysect/mybundle/foo.1cbec737f863e4922cee63cc2ebbfaafcd1cff8b790d8cfd2e6a5d550b648afa.txt|") + // Assets CSS fingerprinted + b.AssertFileContent("public/en/mysect/mybundle/index.html", "CSS: https://example.fr/css/main.5de625c36355cce7c1d5408826a0b21abfb49fb6c0e1f16c945a6f2aef38200c.css|") + b.AssertFileContent("public/en/css/main.5de625c36355cce7c1d5408826a0b21abfb49fb6c0e1f16c945a6f2aef38200c.css", "body { color: red; }") + b.AssertFileContent("public/fr/mysect/mybundle/index.html", "CSS: https://example.fr/css/main.5de625c36355cce7c1d5408826a0b21abfb49fb6c0e1f16c945a6f2aef38200c.css|") + b.AssertFileContent("public/fr/css/main.5de625c36355cce7c1d5408826a0b21abfb49fb6c0e1f16c945a6f2aef38200c.css", "body { color: red; }") +} + +func TestMultihostResourcePerLanguageMultihostMinify(t *testing.T) { + t.Parallel() + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term"] +defaultContentLanguage = "en" +defaultContentLanguageInSubDir = true +[languages] +[languages.en] +baseURL = "https://example.en" +weight = 1 +contentDir = "content/en" +[languages.fr] +baseURL = "https://example.fr" +weight = 2 +contentDir = "content/fr" +-- content/en/section/mybundle/index.md -- +--- +title: "Mybundle en" +--- +-- content/fr/section/mybundle/index.md -- +--- +title: "Mybundle fr" +--- +-- content/en/section/mybundle/styles.css -- +.body { + color: english; +} +-- content/fr/section/mybundle/styles.css -- +.body { + color: french; +} +-- layouts/_default/single.html -- +{{ $data := .Resources.GetMatch "styles*" | minify }} +{{ .Lang }}: {{ $data.Content}}|{{ $data.RelPermalink }}| + +` + b := Test(t, files) + + b.AssertFileContent("public/fr/section/mybundle/index.html", + "fr: .body{color:french}|/section/mybundle/styles.min.css|", + ) + + b.AssertFileContent("public/en/section/mybundle/index.html", + "en: .body{color:english}|/section/mybundle/styles.min.css|", + ) + + b.AssertFileContent("public/en/section/mybundle/styles.min.css", ".body{color:english}") + b.AssertFileContent("public/fr/section/mybundle/styles.min.css", ".body{color:french}") +} + +func TestResourcePerLanguageIssue12163(t *testing.T) { + files := ` +-- hugo.toml -- +defaultContentLanguage = 'de' +disableKinds = ['rss','sitemap','taxonomy','term'] + +[languages.de] +baseURL = 'https://de.example.org/' +contentDir = 'content/de' +weight = 1 + +[languages.en] +baseURL = 'https://en.example.org/' +contentDir = 'content/en' +weight = 2 +-- content/de/mybundle/index.md -- +--- +title: mybundle-de +--- +-- content/de/mybundle/pixel.png -- +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg== +-- content/en/mybundle/index.md -- +--- +title: mybundle-en +--- +-- layouts/_default/single.html -- +{{ with .Resources.Get "pixel.png" }} + {{ with .Resize "2x2" }} + {{ .RelPermalink }}| + {{ end }} +{{ end }} +` + + b := Test(t, files) + + b.AssertFileExists("public/de/mybundle/index.html", true) + b.AssertFileExists("public/en/mybundle/index.html", true) + + b.AssertFileExists("public/de/mybundle/pixel.png", true) + b.AssertFileExists("public/en/mybundle/pixel.png", true) + + b.AssertFileExists("public/de/mybundle/pixel_hu_58204cbc58507d74.png", true) + // failing test below + b.AssertFileExists("public/en/mybundle/pixel_hu_58204cbc58507d74.png", true) +} + +func TestMultihostResourceOneBaseURLWithSuPath(t *testing.T) { + files := ` +-- hugo.toml -- +defaultContentLanguage = "en" +[languages] +[languages.en] +baseURL = "https://example.com/docs" +weight = 1 +contentDir = "content/en" +[languages.en.permalinks] +section = "/enpages/:slug/" +[languages.fr] +baseURL = "https://example.fr" +contentDir = "content/fr" +-- content/en/section/mybundle/index.md -- +--- +title: "Mybundle en" +--- +-- content/fr/section/mybundle/index.md -- +--- +title: "Mybundle fr" +--- +-- content/fr/section/mybundle/file1.txt -- +File 1 fr. +-- content/en/section/mybundle/file1.txt -- +File 1 en. +-- content/en/section/mybundle/file2.txt -- +File 2 en. +-- layouts/_default/single.html -- +{{ $files := .Resources.Match "file*" }} +Files: {{ range $files }}{{ .Permalink }}|{{ end }}$ + +` + + b := Test(t, files) + + b.AssertFileContent("public/en/enpages/mybundle-en/index.html", "Files: https://example.com/docs/enpages/mybundle-en/file1.txt|https://example.com/docs/enpages/mybundle-en/file2.txt|$") + b.AssertFileContent("public/fr/section/mybundle/index.html", "Files: https://example.fr/section/mybundle/file1.txt|https://example.fr/section/mybundle/file2.txt|$") + + b.AssertFileContent("public/en/enpages/mybundle-en/file1.txt", "File 1 en.") + b.AssertFileContent("public/fr/section/mybundle/file1.txt", "File 1 fr.") + b.AssertFileContent("public/en/enpages/mybundle-en/file2.txt", "File 2 en.") + b.AssertFileContent("public/fr/section/mybundle/file2.txt", "File 2 en.") +} + +func TestMultihostAllButOneLanguageDisabledIssue12288(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +defaultContentLanguage = "en" +disableLanguages = ["fr"] +#baseURL = "https://example.com" +[languages] +[languages.en] +baseURL = "https://example.en" +weight = 1 +[languages.fr] +baseURL = "https://example.fr" +weight = 2 +-- assets/css/main.css -- +body { color: red; } +-- layouts/index.html -- +{{ $css := resources.Get "css/main.css" | minify }} +CSS: {{ $css.Permalink }}|{{ $css.RelPermalink }}| +` + + b := Test(t, files) + + b.AssertFileContent("public/css/main.min.css", "body{color:red}") + b.AssertFileContent("public/index.html", "CSS: https://example.en/css/main.min.css|/css/main.min.css|") } diff --git a/hugolib/hugo_sites_test.go b/hugolib/hugo_sites_test.go new file mode 100644 index 000000000..5e1a1504c --- /dev/null +++ b/hugolib/hugo_sites_test.go @@ -0,0 +1,58 @@ +// 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 hugolib + +import "testing" + +func TestSitesAndLanguageOrder(t *testing.T) { + files := ` +-- hugo.toml -- +defaultContentLanguage = "fr" +defaultContentLanguageInSubdir = true +[languages] +[languages.en] +weight = 1 +[languages.fr] +weight = 2 +[languages.de] +weight = 3 +-- layouts/index.html -- +{{ $bundle := site.GetPage "bundle" }} +Bundle all translations: {{ range $bundle.AllTranslations }}{{ .Lang }}|{{ end }}$ +Bundle translations: {{ range $bundle.Translations }}{{ .Lang }}|{{ end }}$ +Site languages: {{ range site.Languages }}{{ .Lang }}|{{ end }}$ +Sites: {{ range site.Sites }}{{ .Language.Lang }}|{{ end }}$ +-- content/bundle/index.fr.md -- +--- +title: "Bundle Fr" +--- +-- content/bundle/index.en.md -- +--- +title: "Bundle En" +--- +-- content/bundle/index.de.md -- +--- +title: "Bundle De" +--- + + ` + b := Test(t, files) + + b.AssertFileContent("public/en/index.html", + "Bundle all translations: en|fr|de|$", + "Bundle translations: fr|de|$", + "Site languages: en|fr|de|$", + "Sites: fr|en|de|$", + ) +} diff --git a/hugolib/hugo_smoke_test.go b/hugolib/hugo_smoke_test.go new file mode 100644 index 000000000..09d57bbff --- /dev/null +++ b/hugolib/hugo_smoke_test.go @@ -0,0 +1,600 @@ +// 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 hugolib + +import ( + "fmt" + "math/rand" + "testing" + + "github.com/bep/logg" + qt "github.com/frankban/quicktest" +) + +// The most basic build test. +func TestHello(t *testing.T) { + files := ` +-- hugo.toml -- +title = "Hello" +baseURL="https://example.org" +disableKinds = ["term", "taxonomy", "section", "page"] +-- content/p1.md -- +--- +title: Page +--- +-- layouts/index.html -- +Home: {{ .Title }} +` + + b := NewIntegrationTestBuilder( + IntegrationTestConfig{ + T: t, + TxtarString: files, + // LogLevel: logg.LevelTrace, + }, + ).Build() + + b.Assert(b.H.Log.LoggCount(logg.LevelWarn), qt.Equals, 0) + b.AssertFileContent("public/index.html", `Hello`) +} + +func TestSmokeOutputFormats(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com/" +defaultContentLanguage = "en" +disableKinds = ["term", "taxonomy", "robotsTXT", "sitemap"] +[outputs] +home = ["html", "rss"] +section = ["html", "rss"] +page = ["html"] +-- content/p1.md -- +--- +title: Page +--- +Page. + +-- layouts/_default/list.html -- +List: {{ .Title }}|{{ .RelPermalink}}|{{ range .OutputFormats }}{{ .Name }}: {{ .RelPermalink }}|{{ end }}$ +-- layouts/_default/list.xml -- +List xml: {{ .Title }}|{{ .RelPermalink}}|{{ range .OutputFormats }}{{ .Name }}: {{ .RelPermalink }}|{{ end }}$ +-- layouts/_default/single.html -- +Single: {{ .Title }}|{{ .RelPermalink}}|{{ range .OutputFormats }}{{ .Name }}: {{ .RelPermalink }}|{{ end }}$ + +` + + for i := 0; i < 2; i++ { + b := Test(t, files) + b.AssertFileContent("public/index.html", `List: |/|html: /|rss: /index.xml|$`) + b.AssertFileContent("public/index.xml", `List xml: |/|html: /|rss: /index.xml|$`) + b.AssertFileContent("public/p1/index.html", `Single: Page|/p1/|html: /p1/|$`) + b.AssertFileExists("public/p1/index.xml", false) + } +} + +func TestSmoke(t *testing.T) { + t.Parallel() + + // Basic test cases. + // OK translations + // OK page collections + // OK next, prev in section + // OK GetPage + // OK Pagination + // OK RenderString with shortcode + // OK cascade + // OK site last mod, section last mod. + // OK main sections + // OK taxonomies + // OK GetTerms + // OK Resource page + // OK Resource txt + + const files = ` +-- hugo.toml -- +baseURL = "https://example.com" +title = "Smoke Site" +rssLimit = 3 +defaultContentLanguage = "en" +defaultContentLanguageInSubdir = true +enableRobotsTXT = true + +[pagination] +pagerSize = 1 + +[taxonomies] +category = 'categories' +tag = 'tags' + +[languages] +[languages.en] +weight = 1 +title = "In English" +[languages.no] +weight = 2 +title = "På norsk" + +[params] +hugo = "Rules!" + +[outputs] + home = ["html", "json", "rss"] +-- layouts/index.html -- +Home: {{ .Lang}}|{{ .Kind }}|{{ .RelPermalink }}|{{ .Title }}|{{ .Content }}|Len Resources: {{ len .Resources }}|HTML +Resources: {{ range .Resources }}{{ .ResourceType }}|{{ .RelPermalink }}|{{ .MediaType }} - {{ end }}| +Site last mod: {{ site.Lastmod.Format "2006-02-01" }}| +Home last mod: {{ .Lastmod.Format "2006-02-01" }}| +Len Translations: {{ len .Translations }}| +Len home.RegularPagesRecursive: {{ len .RegularPagesRecursive }}| +RegularPagesRecursive: {{ range .RegularPagesRecursive }}{{ .RelPermalink }}|{{ end }}@ +Len site.RegularPages: {{ len site.RegularPages }}| +Len site.Pages: {{ len site.Pages }}| +Len site.AllPages: {{ len site.AllPages }}| +GetPage: {{ with .Site.GetPage "posts/p1" }}{{ .RelPermalink }}|{{ .Title }}{{ end }}| +RenderString with shortcode: {{ .RenderString "{{% hello %}}" }}| +Paginate: {{ .Paginator.PageNumber }}/{{ .Paginator.TotalPages }}| +-- layouts/index.json -- +Home:{{ .Lang}}|{{ .Kind }}|{{ .RelPermalink }}|{{ .Title }}|{{ .Content }}|Len Resources: {{ len .Resources }}|JSON +-- layouts/_default/list.html -- +List: {{ .Lang}}|{{ .Kind }}|{{ .RelPermalink }}|{{ .Title }}|{{ .Content }}|Len Resources: {{ len .Resources }}| +Resources: {{ range .Resources }}{{ .ResourceType }}|{{ .RelPermalink }}|{{ .MediaType }} - {{ end }} +Pages Length: {{ len .Pages }} +RegularPages Length: {{ len .RegularPages }} +RegularPagesRecursive Length: {{ len .RegularPagesRecursive }} +List last mod: {{ .Lastmod.Format "2006-02-01" }} +Background: {{ .Params.background }}| +Kind: {{ .Kind }} +Type: {{ .Type }} +Paginate: {{ .Paginator.PageNumber }}/{{ .Paginator.TotalPages }}| +-- layouts/_default/single.html -- +Single: {{ .Lang}}|{{ .Kind }}|{{ .RelPermalink }}|{{ .Title }}|{{ .Content }}|Len Resources: {{ len .Resources }}|Background: {{ .Params.background }}| +Resources: {{ range .Resources }}{{ .ResourceType }}|{{ .RelPermalink }}|{{ .MediaType }}|{{ .Params }} - {{ end }} +{{ $textResource := .Resources.GetMatch "**.txt" }} +{{ with $textResource }} +Icon: {{ .Params.icon }}| +{{ $textResourceFingerprinted := . | fingerprint }} +Icon fingerprinted: {{ with $textResourceFingerprinted }}{{ .Params.icon }}|{{ .RelPermalink }}{{ end }}| +{{ end }} +NextInSection: {{ with .NextInSection }}{{ .RelPermalink }}|{{ .Title }}{{ end }}| +PrevInSection: {{ with .PrevInSection }}{{ .RelPermalink }}|{{ .Title }}{{ end }}| +GetTerms: {{ range .GetTerms "tags" }}name: {{ .Name }}, title: {{ .Title }}|{{ end }} +-- layouts/shortcodes/hello.html -- +Hello. +-- content/_index.md -- +--- +title: Home in English +--- +Home Content. +-- content/_index.no.md -- +--- +title: Hjem +cascade: + - _target: + kind: page + path: /posts/** + background: post.jpg + - _target: + kind: term + background: term.jpg +--- +Hjem Innhold. +-- content/posts/f1.txt -- +posts f1 text. +-- content/posts/sub/f1.txt -- +posts sub f1 text. +-- content/posts/p1/index.md -- ++++ +title = "Post 1" +lastMod = "2001-01-01" +tags = ["tag1"] +[[resources]] +src = '**' +[resources.params] +icon = 'enicon' ++++ +Content 1. +-- content/posts/p1/index.no.md -- ++++ +title = "Post 1 no" +lastMod = "2002-02-02" +tags = ["tag1", "tag2"] +[[resources]] +src = '**' +[resources.params] +icon = 'noicon' ++++ +Content 1 no. +-- content/posts/_index.md -- +--- +title: Posts +--- +-- content/posts/p1/f1.txt -- +posts p1 f1 text. +-- content/posts/p1/sub/ps1.md -- +--- +title: Post Sub 1 +--- +Content Sub 1. +-- content/posts/p2.md -- +--- +title: Post 2 +tags: ["tag1", "tag3"] +--- +Content 2. +-- content/posts/p2.no.md -- +--- +title: Post 2 No +--- +Content 2 No. +-- content/tags/_index.md -- +--- +title: Tags +--- +Content Tags. +-- content/tags/tag1/_index.md -- +--- +title: Tag 1 +--- +Content Tag 1. + + +` + + b := NewIntegrationTestBuilder(IntegrationTestConfig{ + T: t, + TxtarString: files, + NeedsOsFS: true, + // Verbose: true, + // LogLevel: logg.LevelTrace, + }).Build() + + b.AssertFileContent("public/en/index.html", + "Home: en|home|/en/|Home in English|

    Home Content.

    \n|HTML", + "Site last mod: 2001-01-01", + "Home last mod: 2001-01-01", + "Translations: 1|", + "Len home.RegularPagesRecursive: 2|", + "Len site.RegularPages: 2|", + "Len site.Pages: 8|", + "Len site.AllPages: 16|", + "GetPage: /en/posts/p1/|Post 1|", + "RenderString with shortcode: Hello.|", + "Paginate: 1/2|", + ) + b.AssertFileContent("public/en/page/2/index.html", "Paginate: 2/2|") + + b.AssertFileContent("public/no/index.html", + "Home: no|home|/no/|Hjem|

    Hjem Innhold.

    \n|HTML", + "Site last mod: 2002-02-02", + "Home last mod: 2002-02-02", + "Translations: 1", + "GetPage: /no/posts/p1/|Post 1 no|", + ) + + b.AssertFileContent("public/en/index.json", "Home:en|home|/en/|Home in English|

    Home Content.

    \n|JSON") + b.AssertFileContent("public/no/index.json", "Home:no|home|/no/|Hjem|

    Hjem Innhold.

    \n|JSON") + + b.AssertFileContent("public/en/posts/p1/index.html", + "Single: en|page|/en/posts/p1/|Post 1|

    Content 1.

    \n|Len Resources: 2|", + "Resources: text|/en/posts/p1/f1.txt|text/plain|map[icon:enicon] - page||application/octet-stream|map[draft:false iscjklanguage:false title:Post Sub 1] -", + "Icon: enicon", + "Icon fingerprinted: enicon|/en/posts/p1/f1.e5746577af5cbfc4f34c558051b7955a9a5a795a84f1c6ab0609cb3473a924cb.txt|", + "NextInSection: |\nPrevInSection: /en/posts/p2/|Post 2|", + "GetTerms: name: tag1, title: Tag 1|", + ) + + b.AssertFileContent("public/no/posts/p1/index.html", + "Resources: 1", + "Resources: text|/en/posts/p1/f1.txt|text/plain|map[icon:noicon] -", + "Icon: noicon", + "Icon fingerprinted: noicon|/en/posts/p1/f1.e5746577af5cbfc4f34c558051b7955a9a5a795a84f1c6ab0609cb3473a924cb.txt|", + "Background: post.jpg", + "NextInSection: |\nPrevInSection: /no/posts/p2/|Post 2 No|", + ) + + b.AssertFileContent("public/en/posts/index.html", + "List: en|section|/en/posts/|Posts||Len Resources: 2|", + "Resources: text|/en/posts/f1.txt|text/plain - text|/en/posts/sub/f1.txt|text/plain -", + "List last mod: 2001-01-01", + ) + + b.AssertFileContent("public/no/posts/index.html", + "List last mod: 2002-02-02", + ) + + b.AssertFileContent("public/en/posts/p2/index.html", "Single: en|page|/en/posts/p2/|Post 2|

    Content 2.

    \n|", + "|Len Resources: 0", + "GetTerms: name: tag1, title: Tag 1|name: tag3, title: Tag3|", + ) + b.AssertFileContent("public/no/posts/p2/index.html", "Single: no|page|/no/posts/p2/|Post 2 No|

    Content 2 No.

    \n|") + + b.AssertFileContent("public/no/categories/index.html", + "Kind: taxonomy", + "Type: categories", + ) + b.AssertFileContent("public/no/tags/index.html", + "Kind: taxonomy", + "Type: tags", + ) + + b.AssertFileContent("public/no/tags/tag1/index.html", + "Background: term.jpg", + "Kind: term", + "Type: tags", + "Paginate: 1/1|", + ) + + b.AssertFileContent("public/en/tags/tag1/index.html", + "Kind: term", + "Type: tags", + "Paginate: 1/2|", + ) +} + +// Basic tests that verifies that the different file systems work as expected. +func TestSmokeFilesystems(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +defaultContentLanguage = "en" +defaultContentLanguageInSubdir = true +[languages] +[languages.en] +title = "In English" +[languages.nn] +title = "På nynorsk" +[module] +[[module.mounts]] +source = "i18n" +target = "i18n" +[[module.mounts]] +source = "data" +target = "data" +[[module.mounts]] +source = "content/en" +target = "content" +lang = "en" +[[module.mounts]] +source = "content/nn" +target = "content" +lang = "nn" +[[module.imports]] +path = "mytheme" +-- layouts/index.html -- +i18n s1: {{ i18n "s1" }}| +i18n s2: {{ i18n "s2" }}| +data s1: {{ site.Data.d1.s1 }}| +data s2: {{ site.Data.d1.s2 }}| +title: {{ .Title }}| +-- themes/mytheme/hugo.toml -- +[[module.mounts]] +source = "i18n" +target = "i18n" +[[module.mounts]] +source = "data" +target = "data" +# i18n files both project and theme. +-- i18n/en.toml -- +[s1] +other = 's1project' +-- i18n/nn.toml -- +[s1] +other = 's1prosjekt' +-- themes/mytheme/i18n/en.toml -- +[s1] +other = 's1theme' +[s2] +other = 's2theme' +# data files both project and theme. +-- data/d1.yaml -- +s1: s1project +-- themes/mytheme/data/d1.yaml -- +s1: s1theme +s2: s2theme +# Content +-- content/en/_index.md -- +--- +title: "Home" +--- +-- content/nn/_index.md -- +--- +title: "Heim" +--- + +` + b := Test(t, files) + + b.AssertFileContent("public/en/index.html", + "i18n s1: s1project", "i18n s2: s2theme", + "data s1: s1project", "data s2: s2theme", + "title: Home", + ) + + b.AssertFileContent("public/nn/index.html", + "i18n s1: s1prosjekt", "i18n s2: s2theme", + "data s1: s1project", "data s2: s2theme", + "title: Heim", + ) +} + +// https://github.com/golang/go/issues/30286 +func TestDataRace(t *testing.T) { + const page = ` +--- +title: "The Page" +outputs: ["HTML", "JSON"] +--- + +The content. + + + ` + + b := newTestSitesBuilder(t).WithSimpleConfigFile() + for i := 1; i <= 50; i++ { + b.WithContent(fmt.Sprintf("blog/page%d.md", i), page) + } + + b.WithContent("_index.md", ` +--- +title: "The Home" +outputs: ["HTML", "JSON", "CSV", "RSS"] +--- + +The content. + + +`) + + commonTemplate := `{{ .Data.Pages }}` + + b.WithTemplatesAdded("_default/single.html", "HTML Single: "+commonTemplate) + b.WithTemplatesAdded("_default/list.html", "HTML List: "+commonTemplate) + + b.CreateSites().Build(BuildCfg{}) +} + +// This is just a test to verify that BenchmarkBaseline is working as intended. +func TestBenchmarkBaseline(t *testing.T) { + cfg := IntegrationTestConfig{ + T: t, + TxtarString: benchmarkBaselineFiles(true), + } + b := NewIntegrationTestBuilder(cfg).Build() + + b.Assert(len(b.H.Sites), qt.Equals, 4) + b.Assert(len(b.H.Sites[0].RegularPages()), qt.Equals, 161) + b.Assert(len(b.H.Sites[0].Pages()), qt.Equals, 197) + b.Assert(len(b.H.Sites[2].RegularPages()), qt.Equals, 158) + b.Assert(len(b.H.Sites[2].Pages()), qt.Equals, 194) +} + +func BenchmarkBaseline(b *testing.B) { + cfg := IntegrationTestConfig{ + T: b, + TxtarString: benchmarkBaselineFiles(false), + } + builders := make([]*IntegrationTestBuilder, b.N) + + for i := range builders { + builders[i] = NewIntegrationTestBuilder(cfg) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + builders[i].Build() + } +} + +func benchmarkBaselineFiles(leafBundles bool) string { + rnd := rand.New(rand.NewSource(32)) + + files := ` +-- config.toml -- +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 +-- layouts/_default/list.html -- +{{ .Title }} +{{ .Content }} +-- layouts/_default/single.html -- +{{ .Title }} +{{ .Content }} +-- layouts/shortcodes/myshort.html -- +{{ .Inner }} +` + + contentTemplate := ` +--- +title: "Page %d" +date: "2018-01-01" +weight: %d +--- + +## Heading 1 + +Duis nisi reprehenderit nisi cupidatat cillum aliquip ea id eu esse commodo et. + +## Heading 2 + +Aliqua labore enim et sint anim amet excepteur ea dolore. + +{{< myshort >}} +Hello, World! +{{< /myshort >}} + +Aliqua labore enim et sint anim amet excepteur ea dolore. + + +` + + for _, lang := range []string{"en", "nn", "no", "sv"} { + files += fmt.Sprintf("\n-- content/%s/_index.md --\n"+contentTemplate, lang, 1, 1, 1) + for i, root := range []string{"", "foo", "bar", "baz"} { + for j, section := range []string{"posts", "posts/funny", "posts/science", "posts/politics", "posts/world", "posts/technology", "posts/world/news", "posts/world/news/europe"} { + n := i + j + 1 + files += fmt.Sprintf("\n-- content/%s/%s/%s/_index.md --\n"+contentTemplate, lang, root, section, n, n, n) + for k := 1; k < rnd.Intn(30)+1; k++ { + n := n + k + ns := fmt.Sprintf("%d", n) + if leafBundles { + ns = fmt.Sprintf("%d/index", n) + } + file := fmt.Sprintf("\n-- content/%s/%s/%s/p%s.md --\n"+contentTemplate, lang, root, section, ns, n, n) + files += file + } + } + } + } + + return files +} diff --git a/hugolib/hugolib_integration_test.go b/hugolib/hugolib_integration_test.go new file mode 100644 index 000000000..250c7bcec --- /dev/null +++ b/hugolib/hugolib_integration_test.go @@ -0,0 +1,147 @@ +// 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 hugolib_test + +import ( + "strings" + "testing" + + "github.com/gohugoio/hugo/hugolib" +) + +// Issue 9073 +func TestPageTranslationsMap(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +baseURL = 'https://example.org/' +title = 'Issue-9073' +defaultContentLanguageInSubdir = true + +[taxonomies] +tag = 'tags' + +[languages.en] +contentDir = 'content/en' +weight = 1 +disableKinds = ['RSS','sitemap'] + +[languages.de] +contentDir = 'content/de' +weight = 2 +disableKinds = ['home', 'page', 'section', 'taxonomy', 'term','RSS','sitemap'] +-- content/de/posts/p1.md -- +--- +title: P1 +tags: ['T1'] +--- +-- content/en/posts/p1.md -- +--- +title: P1 +tags: ['T1'] +--- +-- layouts/_default/single.html -- +
      {{ range .AllTranslations }}
    • {{ .Title }}-{{ .Lang }}
    • {{ end }}
    +-- layouts/_default/list.html -- +
      {{ range .AllTranslations }}
    • {{ .Title }}-{{ .Lang }}
    • {{ end }}
    + ` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + }, + ) + b.Build() + + // Kind home + b.AssertFileContent("public/en/index.html", + "
    • Issue-9073-en
    ", + ) + // Kind section + b.AssertFileContent("public/en/posts/index.html", + "
    • Posts-en
    ", + ) + // Kind page + b.AssertFileContent("public/en/posts/p1/index.html", + "
    • P1-en
    ", + ) + // Kind taxonomy + b.AssertFileContent("public/en/tags/index.html", + "
    • Tags-en
    ", + ) + // Kind term + b.AssertFileContent("public/en/tags/t1/index.html", + "
    • T1-en
    ", + ) +} + +// Issue #11538 +func TestRenderStringBadMarkupOpt(t *testing.T) { + t.Parallel() + + files := ` +-- layouts/index.html -- +{{ $opts := dict "markup" "foo" }} +{{ "something" | .RenderString $opts }} + ` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + }, + ) + + _, err := b.BuildE() + + want := `no content renderer found for markup "foo"` + if !strings.Contains(err.Error(), want) { + t.Errorf("error msg must contain %q, error msg actually contains %q", want, err.Error()) + } +} + +// Issue #11547 +func TestTitleCaseStyleWithAutomaticSectionPages(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +titleCaseStyle = 'none' +-- content/books/book-1.md -- +--- +title: Book 1 +tags: [fiction] +--- +-- content/films/_index.md -- +--- +title: Films +--- +-- layouts/index.html -- +{{ (site.GetPage "/tags").Title }} +{{ (site.GetPage "/tags/fiction").Title }} +{{ (site.GetPage "/books").Title }} +{{ (site.GetPage "/films").Title }} + ` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + }, + ) + b.Build() + b.AssertFileContent("public/index.html", "tags\nfiction\nbooks\nFilms") +} diff --git a/hugolib/image_test.go b/hugolib/image_test.go new file mode 100644 index 000000000..09a5b841e --- /dev/null +++ b/hugolib/image_test.go @@ -0,0 +1,92 @@ +// 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 hugolib + +import ( + "testing" +) + +func TestImageResizeMultilingual(t *testing.T) { + b := newTestSitesBuilder(t).WithConfigFile("toml", ` +baseURL="https://example.org" +defaultContentLanguage = "en" + +[languages] +[languages.en] +title = "Title in English" +languageName = "English" +weight = 1 +[languages.nn] +languageName = "Nynorsk" +weight = 2 +title = "Tittel på nynorsk" +[languages.nb] +languageName = "Bokmål" +weight = 3 +title = "Tittel på bokmål" +[languages.fr] +languageName = "French" +weight = 4 +title = "French Title" + +`) + + pageContent := `--- +title: "Page" +--- +` + + b.WithContent("bundle/index.md", pageContent) + b.WithContent("bundle/index.nn.md", pageContent) + b.WithContent("bundle/index.fr.md", pageContent) + b.WithSunset("content/bundle/sunset.jpg") + b.WithSunset("assets/images/sunset.jpg") + b.WithTemplates("index.html", ` +{{ with (.Site.GetPage "bundle" ) }} +{{ $sunset := .Resources.GetMatch "sunset*" }} +{{ if $sunset }} +{{ $resized := $sunset.Resize "200x200" }} +SUNSET FOR: {{ $.Site.Language.Lang }}: {{ $resized.RelPermalink }}/{{ $resized.Width }}/Lat: {{ $resized.Exif.Lat }} +{{ end }} +{{ else }} +No bundle for {{ $.Site.Language.Lang }} +{{ end }} + +{{ $sunset2 := resources.Get "images/sunset.jpg" }} +{{ $resized2 := $sunset2.Resize "123x234" }} +SUNSET2: {{ $resized2.RelPermalink }}/{{ $resized2.Width }}/Lat: {{ $resized2.Exif.Lat }} + + +`) + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", "SUNSET FOR: en: /bundle/sunset_hu_77061c65c31d2244.jpg/200/Lat: 36.59744166666667") + b.AssertFileContent("public/fr/index.html", "SUNSET FOR: fr: /bundle/sunset_hu_77061c65c31d2244.jpg/200/Lat: 36.59744166666667") + b.AssertFileContent("public/index.html", " SUNSET2: /images/sunset_hu_b52e3343ea6a8764.jpg/123/Lat: 36.59744166666667") + b.AssertFileContent("public/nn/index.html", " SUNSET2: /images/sunset_hu_b52e3343ea6a8764.jpg/123/Lat: 36.59744166666667") + + b.AssertImage(200, 200, "public/bundle/sunset_hu_77061c65c31d2244.jpg") + + // Check the file cache + b.AssertImage(200, 200, "resources/_gen/images/bundle/sunset_hu_77061c65c31d2244.jpg") + + b.AssertFileContent("resources/_gen/images/bundle/sunset_d209dcdc6b875e26.json", + "FocalLengthIn35mmFormat|uint16", "PENTAX") + + b.AssertFileContent("resources/_gen/images/images/sunset_d209dcdc6b875e26.json", + "FocalLengthIn35mmFormat|uint16", "PENTAX") + + b.AssertNoDuplicateWrites() +} diff --git a/hugolib/integrationtest_builder.go b/hugolib/integrationtest_builder.go new file mode 100644 index 000000000..f28407fa1 --- /dev/null +++ b/hugolib/integrationtest_builder.go @@ -0,0 +1,968 @@ +package hugolib + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "math/rand" + "os" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + "sync" + "testing" + + "github.com/bep/logg" + + qt "github.com/frankban/quicktest" + "github.com/fsnotify/fsnotify" + "github.com/gohugoio/hugo/common/herrors" + "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/deps" + "github.com/gohugoio/hugo/helpers" + "github.com/gohugoio/hugo/htesting" + "github.com/gohugoio/hugo/hugofs" + "github.com/spf13/afero" + "github.com/spf13/cast" + "golang.org/x/text/unicode/norm" + "golang.org/x/tools/txtar" +) + +type TestOpt func(*IntegrationTestConfig) + +// TestOptRunning will enable running in integration tests. +func TestOptRunning() TestOpt { + return func(c *IntegrationTestConfig) { + c.Running = true + } +} + +// TestOptWatching will enable watching in integration tests. +func TestOptWatching() TestOpt { + return func(c *IntegrationTestConfig) { + c.Watching = true + } +} + +// Enable tracing in integration tests. +// THis should only be used during development and not committed to the repo. +func TestOptTrace() TestOpt { + return func(c *IntegrationTestConfig) { + c.LogLevel = logg.LevelTrace + } +} + +// TestOptDebug will enable debug logging in integration tests. +func TestOptDebug() TestOpt { + return func(c *IntegrationTestConfig) { + c.LogLevel = logg.LevelDebug + } +} + +// TestOptInfo will enable info logging in integration tests. +func TestOptInfo() TestOpt { + return func(c *IntegrationTestConfig) { + c.LogLevel = logg.LevelInfo + } +} + +// TestOptWarn will enable warn logging in integration tests. +func TestOptWarn() TestOpt { + return func(c *IntegrationTestConfig) { + c.LogLevel = logg.LevelWarn + } +} + +// TestOptOsFs will enable the real file system in integration tests. +func TestOptOsFs() TestOpt { + return func(c *IntegrationTestConfig) { + c.NeedsOsFS = true + } +} + +// TestOptWithNFDOnDarwin will normalize the Unicode filenames to NFD on Darwin. +func TestOptWithNFDOnDarwin() TestOpt { + return func(c *IntegrationTestConfig) { + c.NFDFormOnDarwin = true + } +} + +// TestOptWithOSFs enables the real file system. +func TestOptWithOSFs() TestOpt { + return func(c *IntegrationTestConfig) { + c.NeedsOsFS = true + } +} + +func TestOptWithPrintAndKeepTempDir(b bool) TestOpt { + return func(c *IntegrationTestConfig) { + c.PrintAndKeepTempDir = b + } +} + +// TestOptWithWorkingDir allows setting any config optiona as a function al option. +func TestOptWithConfig(fn func(c *IntegrationTestConfig)) TestOpt { + return func(c *IntegrationTestConfig) { + fn(c) + } +} + +// Test is a convenience method to create a new IntegrationTestBuilder from some files and run a build. +func Test(t testing.TB, files string, opts ...TestOpt) *IntegrationTestBuilder { + cfg := IntegrationTestConfig{T: t, TxtarString: files} + for _, o := range opts { + o(&cfg) + } + return NewIntegrationTestBuilder(cfg).Build() +} + +// TestE is the same as Test, but returns an error instead of failing the test. +func TestE(t testing.TB, files string, opts ...TestOpt) (*IntegrationTestBuilder, error) { + cfg := IntegrationTestConfig{T: t, TxtarString: files} + for _, o := range opts { + o(&cfg) + } + return NewIntegrationTestBuilder(cfg).BuildE() +} + +// TestRunning is a convenience method to create a new IntegrationTestBuilder from some files with Running set to true and run a build. +// Deprecated: Use Test with TestOptRunning instead. +func TestRunning(t testing.TB, files string, opts ...TestOpt) *IntegrationTestBuilder { + cfg := IntegrationTestConfig{T: t, TxtarString: files, Running: true} + for _, o := range opts { + o(&cfg) + } + return NewIntegrationTestBuilder(cfg).Build() +} + +// In most cases you should not use this function directly, but the Test or TestRunning function. +func NewIntegrationTestBuilder(conf IntegrationTestConfig) *IntegrationTestBuilder { + // Code fences. + conf.TxtarString = strings.ReplaceAll(conf.TxtarString, "§§§", "```") + // Multiline strings. + conf.TxtarString = strings.ReplaceAll(conf.TxtarString, "§§", "`") + + data := txtar.Parse([]byte(conf.TxtarString)) + + if conf.NFDFormOnDarwin { + for i, f := range data.Files { + data.Files[i].Name = norm.NFD.String(f.Name) + } + } + + c, ok := conf.T.(*qt.C) + if !ok { + c = qt.New(conf.T) + } + + if conf.NeedsOsFS { + if !filepath.IsAbs(conf.WorkingDir) { + tempDir, clean, err := htesting.CreateTempDir(hugofs.Os, "hugo-integration-test") + c.Assert(err, qt.IsNil) + conf.WorkingDir = filepath.Join(tempDir, conf.WorkingDir) + if !conf.PrintAndKeepTempDir { + c.Cleanup(clean) + } else { + fmt.Println("\nUsing WorkingDir dir:", conf.WorkingDir) + } + } + } else if conf.WorkingDir == "" { + conf.WorkingDir = helpers.FilePathSeparator + } + + return &IntegrationTestBuilder{ + Cfg: conf, + C: c, + data: data, + } +} + +// IntegrationTestBuilder is a (partial) rewrite of sitesBuilder. +// The main problem with the "old" one was that it was that the test data was often a little hidden, +// so it became hard to look at a test and determine what it should do, especially coming back to the +// test after a year or so. +type IntegrationTestBuilder struct { + *qt.C + + data *txtar.Archive + + fs *hugofs.Fs + H *HugoSites + + Cfg IntegrationTestConfig + + changedFiles []string + createdFiles []string + removedFiles []string + renamedFiles []string + renamedDirs []string + + buildCount int + GCCount int + counters *buildCounters + logBuff lockingBuffer + lastBuildLog string + + builderInit sync.Once +} + +type lockingBuffer struct { + sync.Mutex + buf bytes.Buffer +} + +func (b *lockingBuffer) String() string { + b.Lock() + defer b.Unlock() + return b.buf.String() +} + +func (b *lockingBuffer) Reset() { + b.Lock() + defer b.Unlock() + b.buf.Reset() +} + +func (b *lockingBuffer) ReadFrom(r io.Reader) (n int64, err error) { + b.Lock() + n, err = b.buf.ReadFrom(r) + b.Unlock() + return +} + +func (b *lockingBuffer) Write(p []byte) (n int, err error) { + b.Lock() + n, err = b.buf.Write(p) + b.Unlock() + return +} + +// AssertLogContains asserts that the last build log contains the given strings. +// Each string can be negated with a "! " prefix. +func (s *IntegrationTestBuilder) AssertLogContains(els ...string) { + s.Helper() + for _, el := range els { + var negate bool + el, negate = s.negate(el) + check := qt.Contains + if negate { + check = qt.Not(qt.Contains) + } + s.Assert(s.lastBuildLog, check, el) + } +} + +// AssertLogMatches asserts that the last build log matches the given regular expressions. +// The regular expressions can be negated with a "! " prefix. +func (s *IntegrationTestBuilder) AssertLogMatches(expression string) { + s.Helper() + var negate bool + expression, negate = s.negate(expression) + re := regexp.MustCompile(expression) + checker := qt.IsTrue + if negate { + checker = qt.IsFalse + } + + s.Assert(re.MatchString(s.lastBuildLog), checker, qt.Commentf(s.lastBuildLog)) +} + +func (s *IntegrationTestBuilder) AssertBuildCountData(count int) { + s.Helper() + s.Assert(s.H.init.data.InitCount(), qt.Equals, count) +} + +func (s *IntegrationTestBuilder) AssertBuildCountGitInfo(count int) { + s.Helper() + s.Assert(s.H.init.gitInfo.InitCount(), qt.Equals, count) +} + +func (s *IntegrationTestBuilder) AssertFileCount(dirname string, expected int) { + s.Helper() + fs := s.fs.WorkingDirReadOnly + count := 0 + afero.Walk(fs, dirname, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + count++ + return nil + }) + s.Assert(count, qt.Equals, expected) +} + +func (s *IntegrationTestBuilder) negate(match string) (string, bool) { + var negate bool + if strings.HasPrefix(match, "! ") { + negate = true + match = strings.TrimPrefix(match, "! ") + } + return match, negate +} + +func (s *IntegrationTestBuilder) AssertFileContent(filename string, matches ...string) { + s.Helper() + content := strings.TrimSpace(s.FileContent(filename)) + + for _, m := range matches { + cm := qt.Commentf("File: %s Match %s\nContent:\n%s", filename, m, content) + lines := strings.Split(m, "\n") + for _, match := range lines { + match = strings.TrimSpace(match) + if match == "" || strings.HasPrefix(match, "#") { + continue + } + var negate bool + match, negate = s.negate(match) + if negate { + s.Assert(content, qt.Not(qt.Contains), match, cm) + continue + } + s.Assert(content, qt.Contains, match, cm) + } + } +} + +func (s *IntegrationTestBuilder) AssertFileContentEquals(filename string, match string) { + s.Helper() + content := s.FileContent(filename) + s.Assert(content, qt.Equals, match, qt.Commentf(match)) +} + +func (s *IntegrationTestBuilder) AssertFileContentExact(filename string, matches ...string) { + s.Helper() + content := s.FileContent(filename) + for _, m := range matches { + cm := qt.Commentf("File: %s Match %s\nContent:\n%s", filename, m, content) + s.Assert(content, qt.Contains, m, cm) + } +} + +func (s *IntegrationTestBuilder) AssertNoRenderShortcodesArtifacts() { + s.Helper() + for _, p := range s.H.Pages() { + content, err := p.Content(context.Background()) + s.Assert(err, qt.IsNil) + comment := qt.Commentf("Page: %s\n%s", p.Path(), content) + s.Assert(strings.Contains(cast.ToString(content), "__hugo_ctx"), qt.IsFalse, comment) + } +} + +func (s *IntegrationTestBuilder) AssertPublishDir(matches ...string) { + s.AssertFs(s.fs.PublishDir, matches...) +} + +func (s *IntegrationTestBuilder) AssertFs(fs afero.Fs, matches ...string) { + s.Helper() + var buff bytes.Buffer + s.Assert(s.printAndCheckFs(fs, "", &buff), qt.IsNil) + printFsLines := strings.Split(buff.String(), "\n") + sort.Strings(printFsLines) + content := strings.TrimSpace((strings.Join(printFsLines, "\n"))) + for _, m := range matches { + cm := qt.Commentf("Match: %q\nIn:\n%s", m, content) + lines := strings.Split(m, "\n") + for _, match := range lines { + match = strings.TrimSpace(match) + var negate bool + if strings.HasPrefix(match, "! ") { + negate = true + match = strings.TrimPrefix(match, "! ") + } + if negate { + s.Assert(content, qt.Not(qt.Contains), match, cm) + continue + } + s.Assert(content, qt.Contains, match, cm) + } + } +} + +func (s *IntegrationTestBuilder) printAndCheckFs(fs afero.Fs, path string, w io.Writer) error { + if fs == nil { + return nil + } + + return afero.Walk(fs, path, func(path string, info os.FileInfo, err error) error { + if err != nil { + return fmt.Errorf("error: path %q: %s", path, err) + } + path = filepath.ToSlash(path) + if path == "" { + path = "." + } + if !info.IsDir() { + f, err := fs.Open(path) + if err != nil { + return fmt.Errorf("error: path %q: %s", path, err) + } + defer f.Close() + // This will panic if the file is a directory. + var buf [1]byte + io.ReadFull(f, buf[:]) + } + fmt.Fprintln(w, path, info.IsDir()) + return nil + }) +} + +func (s *IntegrationTestBuilder) AssertFileExists(filename string, b bool) { + checker := qt.IsNil + if !b { + checker = qt.IsNotNil + } + _, err := s.fs.WorkingDirReadOnly.Stat(filename) + if !herrors.IsNotExist(err) { + s.Assert(err, qt.IsNil) + } + s.Assert(err, checker) +} + +func (s *IntegrationTestBuilder) AssertIsFileError(err error) herrors.FileError { + s.Assert(err, qt.ErrorAs, new(herrors.FileError)) + return herrors.UnwrapFileError(err) +} + +func (s *IntegrationTestBuilder) AssertRenderCountContent(count int) { + s.Helper() + s.Assert(s.counters.contentRenderCounter.Load(), qt.Equals, uint64(count)) +} + +func (s *IntegrationTestBuilder) AssertRenderCountPage(count int) { + s.Helper() + s.Assert(s.counters.pageRenderCounter.Load(), qt.Equals, uint64(count)) +} + +func (s *IntegrationTestBuilder) AssertRenderCountPageBetween(from, to int) { + s.Helper() + i := int(s.counters.pageRenderCounter.Load()) + s.Assert(i >= from && i <= to, qt.IsTrue) +} + +func (s *IntegrationTestBuilder) Build() *IntegrationTestBuilder { + s.Helper() + _, err := s.BuildE() + if s.Cfg.Verbose || err != nil { + fmt.Println(s.lastBuildLog) + if s.H != nil && err == nil { + for _, s := range s.H.Sites { + m := s.pageMap + var buff bytes.Buffer + fmt.Fprintf(&buff, "PageMap for site %q\n\n", s.Language().Lang) + m.debugPrint("", 999, &buff) + fmt.Println(buff.String()) + } + } + } else if s.Cfg.LogLevel <= logg.LevelDebug { + fmt.Println(s.lastBuildLog) + } + s.Assert(err, qt.IsNil) + if s.Cfg.RunGC { + s.GCCount, err = s.H.GC() + s.Assert(err, qt.IsNil) + } + + s.Cleanup(func() { + if h := s.H; h != nil { + s.Assert(h.Close(), qt.IsNil) + } + }) + + return s +} + +func (s *IntegrationTestBuilder) BuildPartial(urls ...string) *IntegrationTestBuilder { + if _, err := s.BuildPartialE(urls...); err != nil { + s.Fatal(err) + } + return s +} + +func (s *IntegrationTestBuilder) BuildPartialE(urls ...string) (*IntegrationTestBuilder, error) { + if s.buildCount == 0 { + panic("BuildPartial can only be used after a full build") + } + if !s.Cfg.Running { + panic("BuildPartial can only be used in server mode") + } + visited := types.NewEvictingQueue[string](len(urls)) + for _, url := range urls { + visited.Add(url) + } + buildCfg := BuildCfg{RecentlyTouched: visited, PartialReRender: true} + return s, s.build(buildCfg) +} + +func (s *IntegrationTestBuilder) Close() { + s.Helper() + s.Assert(s.H.Close(), qt.IsNil) +} + +func (s *IntegrationTestBuilder) LogString() string { + return s.lastBuildLog +} + +func (s *IntegrationTestBuilder) BuildE() (*IntegrationTestBuilder, error) { + s.Helper() + if err := s.initBuilder(); err != nil { + return s, err + } + + err := s.build(s.Cfg.BuildCfg) + return s, err +} + +func (s *IntegrationTestBuilder) Init() *IntegrationTestBuilder { + if err := s.initBuilder(); err != nil { + s.Fatalf("Failed to init builder: %s", err) + } + s.lastBuildLog = s.logBuff.String() + return s +} + +type IntegrationTestDebugConfig struct { + Out io.Writer + + PrintDestinationFs bool + PrintPagemap bool + + PrefixDestinationFs string + PrefixPagemap string +} + +func (s *IntegrationTestBuilder) EditFileReplaceAll(filename, old, new string) *IntegrationTestBuilder { + return s.EditFileReplaceFunc(filename, func(s string) string { + return strings.ReplaceAll(s, old, new) + }) +} + +func (s *IntegrationTestBuilder) EditFileReplaceFunc(filename string, replacementFunc func(s string) string) *IntegrationTestBuilder { + absFilename := s.absFilename(filename) + b, err := afero.ReadFile(s.fs.Source, absFilename) + s.Assert(err, qt.IsNil) + s.changedFiles = append(s.changedFiles, absFilename) + oldContent := string(b) + s.writeSource(absFilename, replacementFunc(oldContent)) + return s +} + +func (s *IntegrationTestBuilder) EditFiles(filenameContent ...string) *IntegrationTestBuilder { + for i := 0; i < len(filenameContent); i += 2 { + filename, content := filepath.FromSlash(filenameContent[i]), filenameContent[i+1] + absFilename := s.absFilename(filename) + s.changedFiles = append(s.changedFiles, absFilename) + s.writeSource(absFilename, content) + } + return s +} + +func (s *IntegrationTestBuilder) AddFiles(filenameContent ...string) *IntegrationTestBuilder { + for i := 0; i < len(filenameContent); i += 2 { + filename, content := filepath.FromSlash(filenameContent[i]), filenameContent[i+1] + absFilename := s.absFilename(filename) + s.createdFiles = append(s.createdFiles, absFilename) + s.writeSource(absFilename, content) + } + return s +} + +func (s *IntegrationTestBuilder) RemoveFiles(filenames ...string) *IntegrationTestBuilder { + for _, filename := range filenames { + absFilename := s.absFilename(filename) + s.removedFiles = append(s.removedFiles, absFilename) + s.Assert(s.fs.Source.Remove(absFilename), qt.IsNil) + + } + + return s +} + +func (s *IntegrationTestBuilder) RenameFile(old, new string) *IntegrationTestBuilder { + absOldFilename := s.absFilename(old) + absNewFilename := s.absFilename(new) + s.renamedFiles = append(s.renamedFiles, absOldFilename) + s.createdFiles = append(s.createdFiles, absNewFilename) + s.Assert(s.fs.Source.MkdirAll(filepath.Dir(absNewFilename), 0o777), qt.IsNil) + s.Assert(s.fs.Source.Rename(absOldFilename, absNewFilename), qt.IsNil) + return s +} + +func (s *IntegrationTestBuilder) RenameDir(old, new string) *IntegrationTestBuilder { + absOldFilename := s.absFilename(old) + absNewFilename := s.absFilename(new) + s.renamedDirs = append(s.renamedDirs, absOldFilename) + s.changedFiles = append(s.changedFiles, absNewFilename) + afero.Walk(s.fs.Source, absOldFilename, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + s.createdFiles = append(s.createdFiles, strings.Replace(path, absOldFilename, absNewFilename, 1)) + return nil + }) + s.Assert(s.fs.Source.MkdirAll(filepath.Dir(absNewFilename), 0o777), qt.IsNil) + s.Assert(s.fs.Source.Rename(absOldFilename, absNewFilename), qt.IsNil) + return s +} + +func (s *IntegrationTestBuilder) FileContent(filename string) string { + s.Helper() + return s.readWorkingDir(s, s.fs, filepath.FromSlash(filename)) +} + +func (s *IntegrationTestBuilder) initBuilder() error { + var initErr error + s.builderInit.Do(func() { + var afs afero.Fs + if s.Cfg.NeedsOsFS { + afs = afero.NewOsFs() + } else { + afs = afero.NewMemMapFs() + } + + if s.Cfg.LogLevel == 0 { + s.Cfg.LogLevel = logg.LevelError + } + + isBinaryRe := regexp.MustCompile(`^(.*)(\.png|\.jpg)$`) + + const dataSourceFilenamePrefix = "sourcefilename:" + + for _, f := range s.data.Files { + filename := filepath.Join(s.Cfg.WorkingDir, f.Name) + data := bytes.TrimSuffix(f.Data, []byte("\n")) + datastr := strings.TrimSpace(string(data)) + if strings.HasPrefix(datastr, dataSourceFilenamePrefix) { + // Read from file relative to the current dir. + var err error + wd, _ := os.Getwd() + filename := filepath.Join(wd, strings.TrimSpace(strings.TrimPrefix(datastr, dataSourceFilenamePrefix))) + data, err = os.ReadFile(filename) + s.Assert(err, qt.IsNil) + } else if isBinaryRe.MatchString(filename) { + var err error + data, err = base64.StdEncoding.DecodeString(string(data)) + s.Assert(err, qt.IsNil) + + } + s.Assert(afs.MkdirAll(filepath.Dir(filename), 0o777), qt.IsNil) + s.Assert(afero.WriteFile(afs, filename, data, 0o666), qt.IsNil) + } + + configDir := "config" + if _, err := afs.Stat(filepath.Join(s.Cfg.WorkingDir, "config")); err != nil { + configDir = "" + } + + var flags config.Provider + if s.Cfg.BaseCfg != nil { + flags = s.Cfg.BaseCfg + } else { + flags = config.New() + } + + if s.Cfg.Running { + flags.Set("internal", maps.Params{ + "running": s.Cfg.Running, + "watch": s.Cfg.Running, + }) + } else if s.Cfg.Watching { + flags.Set("internal", maps.Params{ + "watch": s.Cfg.Watching, + }) + } + + if s.Cfg.WorkingDir != "" { + flags.Set("workingDir", s.Cfg.WorkingDir) + } + + var w io.Writer + if s.Cfg.LogLevel == logg.LevelTrace { + w = os.Stdout + } else { + w = &s.logBuff + } + + logger := loggers.New( + loggers.Options{ + StdOut: w, + StdErr: w, + Level: s.Cfg.LogLevel, + DistinctLevel: logg.LevelWarn, + }, + ) + + res, err := allconfig.LoadConfig( + allconfig.ConfigSourceDescriptor{ + Flags: flags, + ConfigDir: configDir, + Fs: afs, + Logger: logger, + Environ: s.Cfg.Environ, + }, + ) + if err != nil { + initErr = err + return + } + + fs := hugofs.NewFrom(afs, res.LoadingInfo.BaseConfig) + + s.Assert(err, qt.IsNil) + + depsCfg := deps.DepsCfg{Configs: res, Fs: fs, LogLevel: logger.Level(), StdErr: logger.StdErr()} + sites, err := NewHugoSites(depsCfg) + if err != nil { + initErr = err + return + } + if sites == nil { + initErr = errors.New("no sites") + return + } + + s.H = sites + s.fs = fs + + if s.Cfg.NeedsNpmInstall { + wd, _ := os.Getwd() + s.Assert(os.Chdir(s.Cfg.WorkingDir), qt.IsNil) + s.C.Cleanup(func() { os.Chdir(wd) }) + sc := security.DefaultConfig + sc.Exec.Allow, err = security.NewWhitelist("npm") + s.Assert(err, qt.IsNil) + ex := hexec.New(sc, s.Cfg.WorkingDir, loggers.NewDefault()) + command, err := ex.New("npm", "install") + s.Assert(err, qt.IsNil) + s.Assert(command.Run(), qt.IsNil) + + } + }) + + return initErr +} + +func (s *IntegrationTestBuilder) absFilename(filename string) string { + filename = filepath.FromSlash(filename) + if filepath.IsAbs(filename) { + return filename + } + if s.Cfg.WorkingDir != "" && !strings.HasPrefix(filename, s.Cfg.WorkingDir) { + filename = filepath.Join(s.Cfg.WorkingDir, filename) + } + return filename +} + +func (s *IntegrationTestBuilder) reset() { + s.changedFiles = nil + s.createdFiles = nil + s.removedFiles = nil + s.renamedFiles = nil +} + +func (s *IntegrationTestBuilder) build(cfg BuildCfg) error { + s.Helper() + defer func() { + s.reset() + s.lastBuildLog = s.logBuff.String() + s.logBuff.Reset() + }() + + changeEvents := s.changeEvents() + s.counters = &buildCounters{} + cfg.testCounters = s.counters + + s.buildCount++ + + err := s.H.Build(cfg, changeEvents...) + if err != nil { + return err + } + + return nil +} + +// We simulate the fsnotify events. +// See the test output in https://github.com/bep/fsnotifyeventlister for what events gets produced +// by the different OSes. +func (s *IntegrationTestBuilder) changeEvents() []fsnotify.Event { + var ( + events []fsnotify.Event + isLinux = runtime.GOOS == "linux" + isMacOs = runtime.GOOS == "darwin" + isWindows = runtime.GOOS == "windows" + ) + + for _, v := range s.removedFiles { + events = append(events, fsnotify.Event{ + Name: v, + Op: fsnotify.Remove, + }) + } + for _, v := range s.renamedFiles { + events = append(events, fsnotify.Event{ + Name: v, + Op: fsnotify.Rename, + }) + } + + for _, v := range s.renamedDirs { + events = append(events, fsnotify.Event{ + Name: v, + // This is what we get on MacOS. + Op: fsnotify.Remove | fsnotify.Rename, + }) + } + + for _, v := range s.changedFiles { + events = append(events, fsnotify.Event{ + Name: v, + Op: fsnotify.Write, + }) + if isLinux || isWindows { + // Duplicate write events, for some reason. + events = append(events, fsnotify.Event{ + Name: v, + Op: fsnotify.Write, + }) + } + if isMacOs { + events = append(events, fsnotify.Event{ + Name: v, + Op: fsnotify.Chmod, + }) + } + } + for _, v := range s.createdFiles { + events = append(events, fsnotify.Event{ + Name: v, + Op: fsnotify.Create, + }) + if isLinux || isWindows { + events = append(events, fsnotify.Event{ + Name: v, + Op: fsnotify.Write, + }) + } + + } + + // Shuffle events. + for i := range events { + j := rand.Intn(i + 1) + events[i], events[j] = events[j], events[i] + } + + return events +} + +func (s *IntegrationTestBuilder) readWorkingDir(t testing.TB, fs *hugofs.Fs, filename string) string { + t.Helper() + return s.readFileFromFs(t, fs.WorkingDirReadOnly, filename) +} + +func (s *IntegrationTestBuilder) readFileFromFs(t testing.TB, fs afero.Fs, filename string) string { + t.Helper() + filename = filepath.Clean(filename) + b, err := afero.ReadFile(fs, filename) + if err != nil { + // Print some debug info + hadSlash := strings.HasPrefix(filename, helpers.FilePathSeparator) + start := 0 + if hadSlash { + start = 1 + } + end := start + 1 + + parts := strings.Split(filename, helpers.FilePathSeparator) + if parts[start] == "work" { + end++ + } + + s.Assert(err, qt.IsNil) + + } + return string(b) +} + +func (s *IntegrationTestBuilder) writeSource(filename, content string) { + s.Helper() + s.writeToFs(s.fs.Source, filename, content) +} + +func (s *IntegrationTestBuilder) writeToFs(fs afero.Fs, filename, content string) { + s.Helper() + if err := afero.WriteFile(fs, filepath.FromSlash(filename), []byte(content), 0o755); err != nil { + s.Fatalf("Failed to write file: %s", err) + } +} + +type IntegrationTestConfig struct { + T testing.TB + + // The files to use on txtar format, see + // https://pkg.go.dev/golang.org/x/exp/cmd/txtar + // There are some contentions used in this test setup. + // - §§§ can be used to wrap code fences. + // - §§ can be used to wrap multiline strings. + // - filenames prefixed with sourcefilename: will be read from the file system relative to the current dir. + // - filenames with a .png or .jpg extension will be treated as binary and base64 decoded. + TxtarString string + + // COnfig to use as the base. We will also read the config from the txtar. + BaseCfg config.Provider + + // Environment variables passed to the config loader. + Environ []string + + // Whether to simulate server mode. + Running bool + + // Watch for changes. + // This is (currently) always set to true when Running is set. + // Note that the CLI for the server does allow for --watch=false, but that is not used in these test. + Watching bool + + // Will print the log buffer after the build + Verbose bool + + // The log level to use. + LogLevel logg.Level + + // Whether it needs the real file system (e.g. for js.Build tests). + NeedsOsFS bool + + // Whether to run GC after each build. + RunGC bool + + // Do not remove the temp dir after the test. + PrintAndKeepTempDir bool + + // Whether to run npm install before Build. + NeedsNpmInstall bool + + // Whether to normalize the Unicode filenames to NFD on Darwin. + NFDFormOnDarwin bool + + // The working dir to use. If not absolute, a temp dir will be created. + WorkingDir string + + // The config to pass to Build. + BuildCfg BuildCfg +} diff --git a/hugolib/language_content_dir_test.go b/hugolib/language_content_dir_test.go index 7195e8e7b..e02e118f5 100644 --- a/hugolib/language_content_dir_test.go +++ b/hugolib/language_content_dir_test.go @@ -1,4 +1,4 @@ -// Copyright 2018 The Hugo Authors. All rights reserved. +// 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. @@ -14,240 +14,45 @@ package hugolib import ( - "fmt" - "os" - "path/filepath" "testing" - - "github.com/stretchr/testify/require" ) -/* - -/en/p1.md -/nn/p1.md - -.Readdir - -- Name() => p1.en.md, p1.nn.md - -.Stat(name) - -.Open() --- real file name - - -*/ - func TestLanguageContentRoot(t *testing.T) { - t.Parallel() - assert := require.New(t) - - config := ` + files := ` +-- hugo.toml -- baseURL = "https://example.org/" - defaultContentLanguage = "en" defaultContentLanguageInSubdir = true - -contentDir = "content/main" -workingDir = "/my/project" - -[Languages] -[Languages.en] +[languages] +[languages.en] weight = 10 -title = "In English" -languageName = "English" - -[Languages.nn] +contentDir = "content/en" +[languages.nn] weight = 20 -title = "På Norsk" -languageName = "Norsk" -# This tells Hugo that all content in this directory is in the Norwegian language. -# It does not have to have the "my-page.nn.md" format. It can, but that is optional. -contentDir = "content/norsk" - -[Languages.sv] -weight = 30 -title = "På Svenska" -languageName = "Svensk" -contentDir = "content/svensk" -` - - pageTemplate := ` +contentDir = "content/nn" +-- content/en/_index.md -- --- -title: %s -slug: %s -weight: %d +title: "Home" --- - -Content. +-- content/nn/_index.md -- +--- +title: "Heim" +--- +-- content/en/myfiles/file1.txt -- +file 1 en +-- content/en/myfiles/file2.txt -- +file 2 en +-- content/nn/myfiles/file1.txt -- +file 1 nn +-- layouts/index.html -- +Title: {{ .Title }}| +Len Resources: {{ len .Resources }}| +{{ range $i, $e := .Resources }} +{{ $i }}|{{ .RelPermalink }}|{{ .Content }}| +{{ end }} ` - - pageBundleTemplate := ` ---- -title: %s -weight: %d ---- - -Content. - -` - var contentFiles []string - section := "sect" - - var contentRoot = func(lang string) string { - contentRoot := "content/main" - - switch lang { - case "nn": - contentRoot = "content/norsk" - case "sv": - contentRoot = "content/svensk" - } - return contentRoot + "/" + section - } - - for _, lang := range []string{"en", "nn", "sv"} { - for j := 1; j <= 10; j++ { - if (lang == "nn" || lang == "en") && j%4 == 0 { - // Skip 4 and 8 for nn - // We also skip it for en, but that is added to the Swedish directory below. - continue - } - - if lang == "sv" && j%5 == 0 { - // Skip 5 and 10 for sv - continue - } - - base := fmt.Sprintf("p-%s-%d", lang, j) - slug := fmt.Sprintf("%s", base) - langID := "" - - if lang == "sv" && j%4 == 0 { - // Put an English page in the Swedish content dir. - langID = ".en" - } - - if lang == "en" && j == 8 { - // This should win over the sv variant above. - langID = ".en" - } - - slug += langID - - contentRoot := contentRoot(lang) - - filename := filepath.Join(contentRoot, fmt.Sprintf("page%d%s.md", j, langID)) - contentFiles = append(contentFiles, filename, fmt.Sprintf(pageTemplate, slug, slug, j)) - } - } - - // Put common translations in all of them - for i, lang := range []string{"en", "nn", "sv"} { - contentRoot := contentRoot(lang) - - slug := fmt.Sprintf("common_%s", lang) - - filename := filepath.Join(contentRoot, "common.md") - contentFiles = append(contentFiles, filename, fmt.Sprintf(pageTemplate, slug, slug, 100+i)) - - for j, lang2 := range []string{"en", "nn", "sv"} { - filename := filepath.Join(contentRoot, fmt.Sprintf("translated_all.%s.md", lang2)) - langSlug := slug + "_translated_all_" + lang2 - contentFiles = append(contentFiles, filename, fmt.Sprintf(pageTemplate, langSlug, langSlug, 200+i+j)) - } - - for j, lang2 := range []string{"sv", "nn"} { - if lang == "en" { - continue - } - filename := filepath.Join(contentRoot, fmt.Sprintf("translated_some.%s.md", lang2)) - langSlug := slug + "_translated_some_" + lang2 - contentFiles = append(contentFiles, filename, fmt.Sprintf(pageTemplate, langSlug, langSlug, 300+i+j)) - } - } - - // Add a bundle with some images - for i, lang := range []string{"en", "nn", "sv"} { - contentRoot := contentRoot(lang) - slug := fmt.Sprintf("bundle_%s", lang) - filename := filepath.Join(contentRoot, "mybundle", "index.md") - contentFiles = append(contentFiles, filename, fmt.Sprintf(pageBundleTemplate, slug, 400+i)) - if lang == "en" { - imageFilename := filepath.Join(contentRoot, "mybundle", "logo.png") - contentFiles = append(contentFiles, imageFilename, "PNG Data") - } - imageFilename := filepath.Join(contentRoot, "mybundle", "featured.png") - contentFiles = append(contentFiles, imageFilename, fmt.Sprintf("PNG Data for %s", lang)) - - // Add some bundled pages - contentFiles = append(contentFiles, filepath.Join(contentRoot, "mybundle", "p1.md"), fmt.Sprintf(pageBundleTemplate, slug, 401+i)) - contentFiles = append(contentFiles, filepath.Join(contentRoot, "mybundle", "sub", "p1.md"), fmt.Sprintf(pageBundleTemplate, slug, 402+i)) - - } - - b := newTestSitesBuilder(t) - b.WithWorkingDir("/my/project").WithConfigFile("toml", config).WithContent(contentFiles...).CreateSites() - - _ = os.Stdout - //printFs(b.H.BaseFs.ContentFs, "/", os.Stdout) - - b.Build(BuildCfg{}) - - assert.Equal(3, len(b.H.Sites)) - - enSite := b.H.Sites[0] - nnSite := b.H.Sites[1] - svSite := b.H.Sites[2] - - //dumpPages(nnSite.RegularPages...) - assert.Equal(12, len(nnSite.RegularPages)) - assert.Equal(13, len(enSite.RegularPages)) - - assert.Equal(10, len(svSite.RegularPages)) - - for i, p := range enSite.RegularPages { - j := i + 1 - msg := fmt.Sprintf("Test %d", j) - assert.Equal("en", p.Lang(), msg) - assert.Equal("sect", p.Section()) - if j < 9 { - if j%4 == 0 { - assert.Contains(p.Title(), fmt.Sprintf("p-sv-%d.en", i+1), msg) - } else { - assert.Contains(p.Title(), "p-en", msg) - } - } - } - - // Check bundles - bundleEn := enSite.RegularPages[len(enSite.RegularPages)-1] - bundleNn := nnSite.RegularPages[len(nnSite.RegularPages)-1] - bundleSv := svSite.RegularPages[len(svSite.RegularPages)-1] - - assert.Equal("/en/sect/mybundle/", bundleEn.RelPermalink()) - assert.Equal("/sv/sect/mybundle/", bundleSv.RelPermalink()) - - assert.Equal(4, len(bundleEn.Resources)) - assert.Equal(4, len(bundleNn.Resources)) - assert.Equal(4, len(bundleSv.Resources)) - - assert.Equal("/en/sect/mybundle/logo.png", bundleEn.Resources.GetMatch("logo*").RelPermalink()) - assert.Equal("/nn/sect/mybundle/logo.png", bundleNn.Resources.GetMatch("logo*").RelPermalink()) - assert.Equal("/sv/sect/mybundle/logo.png", bundleSv.Resources.GetMatch("logo*").RelPermalink()) - - b.AssertFileContent("/my/project/public/sv/sect/mybundle/featured.png", "PNG Data for sv") - b.AssertFileContent("/my/project/public/nn/sect/mybundle/featured.png", "PNG Data for nn") - b.AssertFileContent("/my/project/public/en/sect/mybundle/featured.png", "PNG Data for en") - b.AssertFileContent("/my/project/public/en/sect/mybundle/logo.png", "PNG Data") - b.AssertFileContent("/my/project/public/sv/sect/mybundle/logo.png", "PNG Data") - b.AssertFileContent("/my/project/public/nn/sect/mybundle/logo.png", "PNG Data") - - nnSect := nnSite.getPage(KindSection, "sect") - assert.NotNil(nnSect) - assert.Equal(12, len(nnSect.Pages)) - nnHome, _ := nnSite.Info.Home() - assert.Equal("/nn/", nnHome.RelPermalink()) - + b := Test(t, files) + b.AssertFileContent("public/en/index.html", "Home", "0|/en/myfiles/file1.txt|file 1 en|\n\n1|/en/myfiles/file2.txt|file 2 en|") + b.AssertFileContent("public/nn/index.html", "Heim", "0|/nn/myfiles/file1.txt|file 1 nn|\n\n1|/en/myfiles/file2.txt|file 2 en|") } diff --git a/hugolib/language_test.go b/hugolib/language_test.go new file mode 100644 index 000000000..f7dd5b79d --- /dev/null +++ b/hugolib/language_test.go @@ -0,0 +1,144 @@ +// 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 hugolib + +import ( + "fmt" + "strings" + "testing" + + "github.com/gohugoio/hugo/htesting" + + qt "github.com/frankban/quicktest" +) + +func TestI18n(t *testing.T) { + c := qt.New(t) + + // https://github.com/gohugoio/hugo/issues/7804 + c.Run("pt-br should be case insensitive", func(c *qt.C) { + b := newTestSitesBuilder(c) + langCode := func() string { + c := "pt-br" + if htesting.RandBool() { + c = strings.ToUpper(c) + } + return c + } + + b.WithConfigFile(`toml`, fmt.Sprintf(` +baseURL = "https://example.com" +defaultContentLanguage = "%s" + +[languages] +[languages.%s] +weight = 1 +`, langCode(), langCode())) + + b.WithI18n(fmt.Sprintf("i18n/%s.toml", langCode()), `hello.one = "Hello"`) + b.WithTemplates("index.html", `Hello: {{ i18n "hello" 1 }}`) + b.WithContent("p1.md", "") + b.Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", "Hello: Hello") + }) +} + +func TestLanguageBugs(t *testing.T) { + c := qt.New(t) + + // Issue #8672 + c.Run("Config with language, menu in root only", func(c *qt.C) { + b := newTestSitesBuilder(c) + b.WithConfigFile("toml", ` +theme = "test-theme" +[[menus.foo]] +name = "foo-a" +[languages.en] + +`, + ) + + b.WithThemeConfigFile("toml", `[languages.en]`) + + b.Build(BuildCfg{}) + + menus := b.H.Sites[0].Menus() + c.Assert(menus, qt.HasLen, 1) + }) +} + +func TestLanguageNumberFormatting(t *testing.T) { + b := newTestSitesBuilder(t) + b.WithConfigFile("toml", ` +baseURL = "https://example.org" + +defaultContentLanguage = "en" +defaultContentLanguageInSubDir = true + +[languages] +[languages.en] +timeZone="UTC" +weight=10 +[languages.nn] +weight=20 + +`) + + b.WithTemplates("index.html", ` + +FormatNumber: {{ 512.5032 | lang.FormatNumber 2 }} +FormatPercent: {{ 512.5032 | lang.FormatPercent 2 }} +FormatCurrency: {{ 512.5032 | lang.FormatCurrency 2 "USD" }} +FormatAccounting: {{ 512.5032 | lang.FormatAccounting 2 "NOK" }} +FormatNumberCustom: {{ lang.FormatNumberCustom 2 12345.6789 }} + + + + +`) + b.WithContent("p1.md", "") + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/en/index.html", ` +FormatNumber: 512.50 +FormatPercent: 512.50% +FormatCurrency: $512.50 +FormatAccounting: NOK512.50 +FormatNumberCustom: 12,345.68 + +`, + ) + + b.AssertFileContent("public/nn/index.html", ` +FormatNumber: 512,50 +FormatPercent: 512,50 % +FormatCurrency: 512,50 USD +FormatAccounting: 512,50 kr +FormatNumberCustom: 12,345.68 + +`) +} + +// Issue 11993. +func TestI18nDotFile(t *testing.T) { + files := ` +-- hugo.toml --{} +baseURL = "https://example.com" +-- i18n/.keep -- +-- data/.keep -- +` + Test(t, files) +} diff --git a/hugolib/media.go b/hugolib/media.go deleted file mode 100644 index aae9a7870..000000000 --- a/hugolib/media.go +++ /dev/null @@ -1,60 +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 hugolib - -// An Image contains metadata for images + image sitemaps -// https://support.google.com/webmasters/answer/178636?hl=en -type Image struct { - - // The URL of the image. In some cases, the image URL may not be on the - // same domain as your main site. This is fine, as long as both domains - // are verified in Webmaster Tools. If, for example, you use a - // content delivery network (CDN) to host your images, make sure that the - // hosting site is verified in Webmaster Tools OR that you submit your - // sitemap using robots.txt. In addition, make sure that your robots.txt - // file doesn’t disallow the crawling of any content you want indexed. - URL string - Title string - Caption string - AltText string - - // The geographic location of the image. For example, - // Limerick, Ireland. - GeoLocation string - - // A URL to the license of the image. - License string -} - -// A Video contains metadata for videos + video sitemaps -// https://support.google.com/webmasters/answer/80471?hl=en -type Video struct { - ThumbnailLoc string - Title string - Description string - ContentLoc string - PlayerLoc string - Duration string - ExpirationDate string - Rating string - ViewCount string - PublicationDate string - FamilyFriendly string - Restriction string - GalleryLoc string - Price string - RequiresSubscription string - Uploader string - Live string -} diff --git a/hugolib/menu.go b/hugolib/menu.go deleted file mode 100644 index 81c136405..000000000 --- a/hugolib/menu.go +++ /dev/null @@ -1,224 +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 hugolib - -import ( - "html/template" - "sort" - "strings" - - "github.com/spf13/cast" -) - -// MenuEntry represents a menu item defined in either Page front matter -// or in the site config. -type MenuEntry struct { - URL string - Page *Page - Name string - Menu string - Identifier string - title string - Pre template.HTML - Post template.HTML - Weight int - Parent string - Children Menu -} - -// Menu is a collection of menu entries. -type Menu []*MenuEntry - -// Menus is a dictionary of menus. -type Menus map[string]*Menu - -// PageMenus is a dictionary of menus defined in the Pages. -type PageMenus map[string]*MenuEntry - -// HasChildren returns whether this menu item has any children. -func (m *MenuEntry) HasChildren() bool { - return m.Children != nil -} - -// KeyName returns the key used to identify this menu entry. -func (m *MenuEntry) KeyName() string { - if m.Identifier != "" { - return m.Identifier - } - return m.Name -} - -func (m *MenuEntry) hopefullyUniqueID() string { - if m.Identifier != "" { - return m.Identifier - } else if m.URL != "" { - return m.URL - } else { - return m.Name - } -} - -// 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 -// resource (URL). -func (m *MenuEntry) IsSameResource(inme *MenuEntry) bool { - return m.URL != "" && inme.URL != "" && m.URL == inme.URL -} - -func (m *MenuEntry) marshallMap(ime map[string]interface{}) { - for k, v := range ime { - loki := strings.ToLower(k) - switch loki { - case "url": - m.URL = 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 Menu) add(me *MenuEntry) Menu { - app := func(slice Menu, x ...*MenuEntry) Menu { - n := len(slice) + len(x) - if n > cap(slice) { - size := cap(slice) * 2 - if size < n { - size = n - } - new := make(Menu, size) - copy(new, slice) - slice = new - } - slice = slice[0:n] - copy(slice[n-len(x):], x) - return slice - } - - m = app(m, me) - m.Sort() - return m -} - -/* - * Implementation of a custom sorter for Menu - */ - -// A type to implement the sort interface for Menu -type menuSorter struct { - menu Menu - by menuEntryBy -} - -// Closure used in the Sort.Less method. -type menuEntryBy func(m1, m2 *MenuEntry) bool - -func (by menuEntryBy) Sort(menu Menu) { - ms := &menuSorter{ - menu: menu, - by: by, // The Sort method's receiver is the function (closure) that defines the sort order. - } - sort.Stable(ms) -} - -var defaultMenuEntrySort = func(m1, m2 *MenuEntry) bool { - if m1.Weight == m2.Weight { - if m1.Name == m2.Name { - return m1.Identifier < m2.Identifier - } - return m1.Name < m2.Name - } - - if m2.Weight == 0 { - return true - } - - if m1.Weight == 0 { - return false - } - - return m1.Weight < m2.Weight -} - -func (ms *menuSorter) Len() int { return len(ms.menu) } -func (ms *menuSorter) Swap(i, j int) { ms.menu[i], ms.menu[j] = ms.menu[j], ms.menu[i] } - -// Less is part of sort.Interface. It is implemented by calling the "by" closure in the sorter. -func (ms *menuSorter) Less(i, j int) bool { return ms.by(ms.menu[i], ms.menu[j]) } - -// Sort sorts the menu by weight, name and then by identifier. -func (m Menu) Sort() Menu { - menuEntryBy(defaultMenuEntrySort).Sort(m) - return m -} - -// Limit limits the returned menu to n entries. -func (m Menu) Limit(n int) Menu { - if len(m) > n { - return m[0:n] - } - return m -} - -// ByWeight sorts the menu by the weight defined in the menu configuration. -func (m Menu) ByWeight() Menu { - menuEntryBy(defaultMenuEntrySort).Sort(m) - return m -} - -// ByName sorts the menu by the name defined in the menu configuration. -func (m Menu) ByName() Menu { - title := func(m1, m2 *MenuEntry) bool { - return m1.Name < m2.Name - } - - menuEntryBy(title).Sort(m) - return m -} - -// 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] - } - - return m -} - -func (m *MenuEntry) Title() string { - if m.title != "" { - return m.title - } - - if m.Page != nil { - return m.Page.LinkTitle() - } - - return "" -} diff --git a/hugolib/menu_test.go b/hugolib/menu_test.go index 6a8c89b95..3be999c31 100644 --- a/hugolib/menu_test.go +++ b/hugolib/menu_test.go @@ -1,4 +1,4 @@ -// Copyright 2017 The Hugo Authors. All rights reserved. +// 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. @@ -14,13 +14,10 @@ package hugolib import ( + "fmt" "testing" - "fmt" - - "github.com/spf13/afero" - - "github.com/stretchr/testify/require" + qt "github.com/frankban/quicktest" ) const ( @@ -36,7 +33,7 @@ menu: ` ) -func TestSectionPagesMenu(t *testing.T) { +func TestMenusSectionPagesMenu(t *testing.T) { t.Parallel() siteConfig := ` @@ -45,11 +42,10 @@ title = "Section Menu" sectionPagesMenu = "sect" ` - th, h := newTestSitesFromConfig( - t, - afero.NewMemMapFs(), - siteConfig, - "layouts/partials/menu.html", + b := newTestSitesBuilder(t).WithConfigFile("toml", siteConfig) + + b.WithTemplates( + "partials/menu.html", `{{- $p := .page -}} {{- $m := .menu -}} {{ range (index $p.Site.Menus $m) -}} @@ -58,44 +54,41 @@ sectionPagesMenu = "sect" {{- if $p.HasMenuCurrent $m . }}HasMenuCurrent{{ else }}-{{ end -}}| {{- end -}} `, - "layouts/_default/single.html", + "_default/single.html", `Single|{{ .Title }} Menu Sect: {{ partial "menu.html" (dict "page" . "menu" "sect") }} Menu Main: {{ partial "menu.html" (dict "page" . "menu" "main") }}`, - "layouts/_default/list.html", "List|{{ .Title }}|{{ .Content }}", + "_default/list.html", "List|{{ .Title }}|{{ .Content }}", ) - require.Len(t, h.Sites, 1) - fs := th.Fs + b.WithContent( + "sect1/p1.md", fmt.Sprintf(menuPageTemplate, "p1", 1, "main", "atitle1", 40), + "sect1/p2.md", fmt.Sprintf(menuPageTemplate, "p2", 2, "main", "atitle2", 30), + "sect2/p3.md", fmt.Sprintf(menuPageTemplate, "p3", 3, "main", "atitle3", 20), + "sect2/p4.md", fmt.Sprintf(menuPageTemplate, "p4", 4, "main", "atitle4", 10), + "sect3/p5.md", fmt.Sprintf(menuPageTemplate, "p5", 5, "main", "atitle5", 5), + "sect1/_index.md", newTestPage("Section One", "2017-01-01", 100), + "sect5/_index.md", newTestPage("Section Five", "2017-01-01", 10), + ) - writeSource(t, fs, "content/sect1/p1.md", fmt.Sprintf(menuPageTemplate, "p1", 1, "main", "atitle1", 40)) - writeSource(t, fs, "content/sect1/p2.md", fmt.Sprintf(menuPageTemplate, "p2", 2, "main", "atitle2", 30)) - writeSource(t, fs, "content/sect2/p3.md", fmt.Sprintf(menuPageTemplate, "p3", 3, "main", "atitle3", 20)) - writeSource(t, fs, "content/sect2/p4.md", fmt.Sprintf(menuPageTemplate, "p4", 4, "main", "atitle4", 10)) - writeSource(t, fs, "content/sect3/p5.md", fmt.Sprintf(menuPageTemplate, "p5", 5, "main", "atitle5", 5)) - - writeNewContentFile(t, fs.Source, "Section One", "2017-01-01", "content/sect1/_index.md", 100) - writeNewContentFile(t, fs.Source, "Section Five", "2017-01-01", "content/sect5/_index.md", 10) - - err := h.Build(BuildCfg{}) - - require.NoError(t, err) + b.Build(BuildCfg{}) + h := b.H s := h.Sites[0] - require.Len(t, s.Menus, 2) + b.Assert(len(s.Menus()), qt.Equals, 2) - p1 := s.RegularPages[0].Menus() + p1 := s.RegularPages()[0].Menus() // There is only one menu in the page, but it is "member of" 2 - require.Len(t, p1, 1) + b.Assert(len(p1), qt.Equals, 1) - th.assertFileContent("public/sect1/p1/index.html", "Single", + b.AssertFileContent("public/sect1/p1/index.html", "Single", "Menu Sect: "+ - "/sect5/|Section Five||10|-|-|"+ - "/sect1/|Section One||100|-|HasMenuCurrent|"+ - "/sect2/|Sect2s||0|-|-|"+ - "/sect3/|Sect3s||0|-|-|", + "/sect5/|Section Five|Section Five|10|-|-|"+ + "/sect1/|Section One|Section One|100|-|HasMenuCurrent|"+ + "/sect2/|Sect2s|Sect2s|0|-|-|"+ + "/sect3/|Sect3s|Sect3s|0|-|-|", "Menu Main: "+ "/sect3/p5/|p5|atitle5|5|-|-|"+ "/sect2/p4/|p4|atitle4|10|-|-|"+ @@ -104,11 +97,604 @@ Menu Main: {{ partial "menu.html" (dict "page" . "menu" "main") }}`, "/sect1/p1/|p1|atitle1|40|IsMenuCurrent|-|", ) - th.assertFileContent("public/sect2/p3/index.html", "Single", + b.AssertFileContent("public/sect2/p3/index.html", "Single", "Menu Sect: "+ - "/sect5/|Section Five||10|-|-|"+ - "/sect1/|Section One||100|-|-|"+ - "/sect2/|Sect2s||0|-|HasMenuCurrent|"+ - "/sect3/|Sect3s||0|-|-|") - + "/sect5/|Section Five|Section Five|10|-|-|"+ + "/sect1/|Section One|Section One|100|-|-|"+ + "/sect2/|Sect2s|Sect2s|0|-|HasMenuCurrent|"+ + "/sect3/|Sect3s|Sect3s|0|-|-|") +} + +func TestMenusFrontMatter(t *testing.T) { + b := newTestSitesBuilder(t).WithSimpleConfigFile() + + b.WithTemplatesAdded("index.html", ` +Main: {{ len .Site.Menus.main }} +Other: {{ len .Site.Menus.other }} +{{ range .Site.Menus.main }} +* Main|{{ .Name }}: {{ .URL }} +{{ end }} +{{ range .Site.Menus.other }} +* Other|{{ .Name }}: {{ .URL }} +{{ end }} +`) + + // Issue #5828 + b.WithContent("blog/page1.md", ` +--- +title: "P1" +menu: main +--- + +`) + + b.WithContent("blog/page2.md", ` +--- +title: "P2" +menu: [main,other] +--- + +`) + + b.WithContent("blog/page3.md", ` +--- +title: "P3" +menu: + main: + weight: 30 +--- +`) + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", + "Main: 3", "Other: 1", + "Main|P1: /blog/page1/", + "Other|P2: /blog/page2/", + ) +} + +// https://github.com/gohugoio/hugo/issues/5849 +func TestMenusPageMultipleOutputFormats(t *testing.T) { + config := ` +baseURL = "https://example.com" + +# DAMP is similar to AMP, but not permalinkable. +[outputFormats] +[outputFormats.damp] +mediaType = "text/html" +path = "damp" + +` + + b := newTestSitesBuilder(t).WithConfigFile("toml", config) + b.WithContent("_index.md", ` +--- +Title: Home Sweet Home +outputs: [ "html", "amp" ] +menu: "main" +--- + +`) + + b.WithContent("blog/html-amp.md", ` +--- +Title: AMP and HTML +outputs: [ "html", "amp" ] +menu: "main" +--- + +`) + + b.WithContent("blog/html.md", ` +--- +Title: HTML only +outputs: [ "html" ] +menu: "main" +--- + +`) + + b.WithContent("blog/amp.md", ` +--- +Title: AMP only +outputs: [ "amp" ] +menu: "main" +--- + +`) + + b.WithTemplatesAdded("index.html", `{{ range .Site.Menus.main }}{{ .Title }}|{{ .URL }}|{{ end }}`) + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", "AMP and HTML|/blog/html-amp/|AMP only|/amp/blog/amp/|Home Sweet Home|/|HTML only|/blog/html/|") + b.AssertFileContent("public/amp/index.html", "AMP and HTML|/amp/blog/html-amp/|AMP only|/amp/blog/amp/|Home Sweet Home|/amp/|HTML only|/blog/html/|") +} + +// https://github.com/gohugoio/hugo/issues/5989 +func TestMenusPageSortByDate(t *testing.T) { + b := newTestSitesBuilder(t).WithSimpleConfigFile() + + b.WithContent("blog/a.md", ` +--- +Title: A +date: 2019-01-01 +menu: + main: + identifier: "a" + weight: 1 +--- + +`) + + b.WithContent("blog/b.md", ` +--- +Title: B +date: 2018-01-02 +menu: + main: + parent: "a" + weight: 100 +--- + +`) + + b.WithContent("blog/c.md", ` +--- +Title: C +date: 2019-01-03 +menu: + main: + parent: "a" + weight: 10 +--- + +`) + + b.WithTemplatesAdded("index.html", `{{ range .Site.Menus.main }}{{ .Title }}|Children: +{{- $children := sort .Children ".Page.Date" "desc" }}{{ range $children }}{{ .Title }}|{{ end }}{{ end }} + +`) + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", "A|Children:C|B|") +} + +// Issue #8825 +func TestMenuParamsEmptyYaml(t *testing.T) { + b := newTestSitesBuilder(t).WithConfigFile("yaml", ` + +`) + + b.WithTemplates("index.html", `{{ site.Menus }}`) + + b.WithContent("p1.md", `--- +menus: + main: + identity: journal + weight: 2 + params: +--- +`) + b.Build(BuildCfg{}) +} + +func TestMenuParams(t *testing.T) { + b := newTestSitesBuilder(t).WithConfigFile("toml", ` +[[menus.main]] +identifier = "contact" +title = "Contact Us" +url = "mailto:noreply@example.com" +weight = 300 +[menus.main.params] +foo = "foo_config" +key2 = "key2_config" +camelCase = "camelCase_config" +`) + + b.WithTemplatesAdded("index.html", ` +Main: {{ len .Site.Menus.main }} +{{ range .Site.Menus.main }} +foo: {{ .Params.foo }} +key2: {{ .Params.KEy2 }} +camelCase: {{ .Params.camelcase }} +{{ end }} +`) + + b.WithContent("_index.md", ` +--- +title: "Home" +menu: + main: + weight: 10 + params: + foo: "foo_content" + key2: "key2_content" + camelCase: "camelCase_content" +--- +`) + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", ` +Main: 2 + +foo: foo_content +key2: key2_content +camelCase: camelCase_content + +foo: foo_config +key2: key2_config +camelCase: camelCase_config +`) +} + +func TestMenusShadowMembers(t *testing.T) { + b := newTestSitesBuilder(t).WithConfigFile("toml", ` +[[menus.main]] +identifier = "contact" +pageRef = "contact" +title = "Contact Us" +url = "mailto:noreply@example.com" +weight = 1 +[[menus.main]] +pageRef = "/blog/post3" +title = "My Post 3" +url = "/blog/post3" + +`) + + commonTempl := ` +Main: {{ len .Site.Menus.main }} +{{ range .Site.Menus.main }} +{{ .Title }}|HasMenuCurrent: {{ $.HasMenuCurrent "main" . }}|Page: {{ .Page.Path }} +{{ .Title }}|IsMenuCurrent: {{ $.IsMenuCurrent "main" . }}|Page: {{ .Page.Path }} +{{ end }} +` + + b.WithTemplatesAdded("index.html", commonTempl) + b.WithTemplatesAdded("_default/single.html", commonTempl) + + b.WithContent("_index.md", ` +--- +title: "Home" +menu: + main: + weight: 10 +--- +`) + + b.WithContent("blog/_index.md", ` +--- +title: "Blog" +menu: + main: + weight: 20 +--- +`) + + b.WithContent("blog/post1.md", ` +--- +title: "My Post 1: With No Menu Defined" +--- +`) + + b.WithContent("blog/post2.md", ` +--- +title: "My Post 2: With Menu Defined" +menu: + main: + weight: 30 +--- +`) + + b.WithContent("blog/post3.md", ` +--- +title: "My Post 2: With No Menu Defined" +--- +`) + + b.WithContent("contact.md", ` +--- +title: "Contact: With No Menu Defined" +--- +`) + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", ` +Main: 5 +Home|HasMenuCurrent: false|Page: / +Blog|HasMenuCurrent: false|Page: /blog +My Post 2: With Menu Defined|HasMenuCurrent: false|Page: /blog/post2 +My Post 3|HasMenuCurrent: false|Page: /blog/post3 +Contact Us|HasMenuCurrent: false|Page: /contact +`) + + b.AssertFileContent("public/blog/post1/index.html", ` +Home|HasMenuCurrent: false|Page: / +Blog|HasMenuCurrent: true|Page: /blog +`) + + b.AssertFileContent("public/blog/post2/index.html", ` +Home|HasMenuCurrent: false|Page: / +Blog|HasMenuCurrent: true|Page: /blog +Blog|IsMenuCurrent: false|Page: /blog +`) + + b.AssertFileContent("public/blog/post3/index.html", ` +Home|HasMenuCurrent: false|Page: / +Blog|HasMenuCurrent: true|Page: /blog +`) + + b.AssertFileContent("public/contact/index.html", ` +Contact Us|HasMenuCurrent: false|Page: /contact +Contact Us|IsMenuCurrent: true|Page: /contact +Blog|HasMenuCurrent: false|Page: /blog +Blog|IsMenuCurrent: false|Page: /blog +`) +} + +// Issue 9846 +func TestMenuHasMenuCurrentSection(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +disableKinds = ['RSS','sitemap','taxonomy','term'] +[[menu.main]] +name = 'Home' +pageRef = '/' +weight = 1 + +[[menu.main]] +name = 'Tests' +pageRef = '/tests' +weight = 2 +[[menu.main]] +name = 'Test 1' +pageRef = '/tests/test-1' +parent = 'Tests' +weight = 1 + +-- content/tests/test-1.md -- +--- +title: "Test 1" +--- +-- layouts/_default/list.html -- +{{ range site.Menus.main }} +{{ .Name }}|{{ .URL }}|IsMenuCurrent = {{ $.IsMenuCurrent "main" . }}|HasMenuCurrent = {{ $.HasMenuCurrent "main" . }}| +{{ range .Children }} +{{ .Name }}|{{ .URL }}|IsMenuCurrent = {{ $.IsMenuCurrent "main" . }}|HasMenuCurrent = {{ $.HasMenuCurrent "main" . }}| +{{ end }} +{{ end }} + +{{/* Some tests for issue 9925 */}} +{{ $page := .Site.GetPage "tests/test-1" }} +{{ $section := site.GetPage "tests" }} + +Home IsAncestor Self: {{ site.Home.IsAncestor site.Home }} +Home IsDescendant Self: {{ site.Home.IsDescendant site.Home }} +Section IsAncestor Self: {{ $section.IsAncestor $section }} +Section IsDescendant Self: {{ $section.IsDescendant $section}} +Page IsAncestor Self: {{ $page.IsAncestor $page }} +Page IsDescendant Self: {{ $page.IsDescendant $page}} +` + + b := Test(t, files) + + b.AssertFileContent("public/tests/index.html", ` +Tests|/tests/|IsMenuCurrent = true|HasMenuCurrent = false +Home IsAncestor Self: false +Home IsDescendant Self: false +Section IsAncestor Self: false +Section IsDescendant Self: false +Page IsAncestor Self: false +Page IsDescendant Self: false +`) +} + +func TestMenusNewConfigSetup(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +title = "Hugo Menu Test" +[menus] +[[menus.main]] +name = "Home" +url = "/" +pre = "" +post = "" +weight = 1 +-- layouts/index.html -- +{{ range $i, $e := site.Menus.main }} +Menu Item: {{ $i }}: {{ .Pre }}{{ .Name }}{{ .Post }}|{{ .URL }}| +{{ end }} +` + + b := Test(t, files) + + b.AssertFileContent("public/index.html", ` +Menu Item: 0: Home|/| +`) +} + +// Issue #11062 +func TestMenusSubDirInBaseURL(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com/foo/" +title = "Hugo Menu Test" +[menus] +[[menus.main]] +name = "Posts" +url = "/posts" +weight = 1 +-- layouts/index.html -- +{{ range $i, $e := site.Menus.main }} +Menu Item: {{ $i }}|{{ .URL }}| +{{ end }} +` + + b := Test(t, files) + + b.AssertFileContent("public/index.html", ` +Menu Item: 0|/foo/posts| +`) +} + +func TestSectionPagesMenuMultilingualWarningIssue12306(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['section','rss','sitemap','taxonomy','term'] +defaultContentLanguageInSubdir = true +sectionPagesMenu = "main" +[languages.en] +[languages.fr] +-- layouts/_default/home.html -- +{{- range site.Menus.main -}} + {{ .Name }} +{{- end -}} +-- layouts/_default/single.html -- +{{ .Title }} +-- content/p1.en.md -- +--- +title: p1 +menu: main +--- +-- content/p1.fr.md -- +--- +title: p1 +menu: main +--- +-- content/p2.en.md -- +--- +title: p2 +menu: main +--- +` + + b := Test(t, files, TestOptWarn()) + + b.AssertFileContent("public/en/index.html", `p1p2`) + b.AssertFileContent("public/fr/index.html", `p1`) + b.AssertLogContains("! WARN") +} + +func TestSectionPagesIssue12399(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['rss','sitemap','taxonomy','term'] +capitalizeListTitles = false +pluralizeListTitles = false +sectionPagesMenu = 'main' +-- content/p1.md -- +--- +title: p1 +--- +-- content/s1/p2.md -- +--- +title: p2 +menus: main +--- +-- content/s1/p3.md -- +--- +title: p3 +--- +-- layouts/_default/list.html -- +{{ range site.Menus.main }}{{ .Name }}{{ end }} +-- layouts/_default/single.html -- +{{ .Title }} +` + + b := Test(t, files) + + b.AssertFileExists("public/index.html", true) + b.AssertFileContent("public/index.html", `p2s1`) +} + +// Issue 13161 +func TestMenuNameAndTitleFallback(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['rss','sitemap','taxonomy','term'] +[[menus.main]] +name = 'P1_ME_Name' +title = 'P1_ME_Title' +pageRef = '/p1' +weight = 10 +[[menus.main]] +pageRef = '/p2' +weight = 20 +[[menus.main]] +pageRef = '/p3' +weight = 30 +[[menus.main]] +name = 'S1_ME_Name' +title = 'S1_ME_Title' +pageRef = '/s1' +weight = 40 +[[menus.main]] +pageRef = '/s2' +weight = 50 +[[menus.main]] +pageRef = '/s3' +weight = 60 +-- content/p1.md -- +--- +title: P1_Title +--- +-- content/p2.md -- +--- +title: P2_Title +--- +-- content/p3.md -- +--- +title: P3_Title +linkTitle: P3_LinkTitle +--- +-- content/s1/_index.md -- +--- +title: S1_Title +--- +-- content/s2/_index.md -- +--- +title: S2_Title +--- +-- content/s3/_index.md -- +--- +title: S3_Title +linkTitle: S3_LinkTitle +--- +-- layouts/_default/single.html -- +{{ .Content }} +-- layouts/_default/list.html -- +{{ .Content }} +-- layouts/_default/home.html -- +{{- range site.Menus.main }} +URL: {{ .URL }}| Name: {{ .Name }}| Title: {{ .Title }}| PageRef: {{ .PageRef }}| Page.Title: {{ .Page.Title }}| Page.LinkTitle: {{ .Page.LinkTitle }}| +{{- end }} +` + + b := Test(t, files) + b.AssertFileContent("public/index.html", + `URL: /p1/| Name: P1_ME_Name| Title: P1_ME_Title| PageRef: /p1| Page.Title: P1_Title| Page.LinkTitle: P1_Title|`, + `URL: /p2/| Name: P2_Title| Title: P2_Title| PageRef: /p2| Page.Title: P2_Title| Page.LinkTitle: P2_Title|`, + `URL: /p3/| Name: P3_LinkTitle| Title: P3_Title| PageRef: /p3| Page.Title: P3_Title| Page.LinkTitle: P3_LinkTitle|`, + `URL: /s1/| Name: S1_ME_Name| Title: S1_ME_Title| PageRef: /s1| Page.Title: S1_Title| Page.LinkTitle: S1_Title|`, + `URL: /s2/| Name: S2_Title| Title: S2_Title| PageRef: /s2| Page.Title: S2_Title| Page.LinkTitle: S2_Title|`, + `URL: /s3/| Name: S3_LinkTitle| Title: S3_Title| PageRef: /s3| Page.Title: S3_Title| Page.LinkTitle: S3_LinkTitle|`, + ) } diff --git a/hugolib/minify_publisher_test.go b/hugolib/minify_publisher_test.go new file mode 100644 index 000000000..ef460efa2 --- /dev/null +++ b/hugolib/minify_publisher_test.go @@ -0,0 +1,63 @@ +// 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 hugolib + +import ( + "testing" + + "github.com/gohugoio/hugo/config" +) + +func TestMinifyPublisher(t *testing.T) { + t.Parallel() + + v := config.New() + v.Set("minify", true) + v.Set("baseURL", "https://example.org/") + + htmlTemplate := ` + + + + + HTML5 boilerplate – all you really need… + + + + + + +

    {{ .Title }}

    +

    {{ .Permalink }}

    + + + +` + + b := newTestSitesBuilder(t) + b.WithViper(v).WithTemplatesAdded("layouts/index.html", htmlTemplate) + b.CreateSites().Build(BuildCfg{}) + + // Check minification + // HTML + b.AssertFileContent("public/index.html", "") + + // RSS + b.AssertFileContent("public/index.xml", "<link>https://example.org/</link>") + + // Sitemap + b.AssertFileContent("public/sitemap.xml", "<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"yes\"?><urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\" xmlns:xhtml=\"http://www.w3.org/1999/xhtml\"><url><loc>h") +} diff --git a/hugolib/mount_filters_test.go b/hugolib/mount_filters_test.go new file mode 100644 index 000000000..16b062ec6 --- /dev/null +++ b/hugolib/mount_filters_test.go @@ -0,0 +1,117 @@ +// 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 hugolib + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/hugofs/files" + + "github.com/gohugoio/hugo/htesting" + "github.com/gohugoio/hugo/hugofs" + + qt "github.com/frankban/quicktest" +) + +func TestMountFilters(t *testing.T) { + t.Parallel() + b := newTestSitesBuilder(t) + workingDir, clean, err := htesting.CreateTempDir(hugofs.Os, "hugo-test-mountfilters") + b.Assert(err, qt.IsNil) + defer clean() + + for _, component := range files.ComponentFolders { + b.Assert(os.MkdirAll(filepath.Join(workingDir, component), 0o777), qt.IsNil) + } + b.WithWorkingDir(workingDir).WithLogger(loggers.NewDefault()) + b.WithConfigFile("toml", fmt.Sprintf(` +workingDir = %q + +[module] +[[module.mounts]] +source = 'content' +target = 'content' +excludeFiles = "/a/c/**" +[[module.mounts]] +source = 'static' +target = 'static' +[[module.mounts]] +source = 'layouts' +target = 'layouts' +excludeFiles = "/**/foo.html" +[[module.mounts]] +source = 'data' +target = 'data' +includeFiles = "/mydata/**" +[[module.mounts]] +source = 'assets' +target = 'assets' +excludeFiles = ["/**exclude.*", "/moooo.*"] +[[module.mounts]] +source = 'i18n' +target = 'i18n' +[[module.mounts]] +source = 'archetypes' +target = 'archetypes' + + +`, workingDir)) + + b.WithContent("/a/b/p1.md", "---\ntitle: Include\n---") + b.WithContent("/a/c/p2.md", "---\ntitle: Exclude\n---") + + b.WithSourceFile( + "data/mydata/b.toml", `b1='bval'`, + "data/nodata/c.toml", `c1='bval'`, + "layouts/partials/foo.html", `foo`, + "assets/exclude.txt", `foo`, + "assets/js/exclude.js", `foo`, + "assets/js/include.js", `foo`, + "assets/js/exclude.js", `foo`, + ) + + b.WithTemplatesAdded("index.html", ` + +Data: {{ site.Data }}:END + +Template: {{ templates.Exists "partials/foo.html" }}:END +Resource1: {{ resources.Get "js/include.js" }}:END +Resource2: {{ resources.Get "js/exclude.js" }}:END +Resource3: {{ resources.Get "exclude.txt" }}:END +Resources: {{ resources.Match "**.js" }} +`) + + b.Build(BuildCfg{}) + + assertExists := func(name string, shouldExist bool) { + b.Helper() + b.Assert(b.CheckExists(name), qt.Equals, shouldExist) + } + + assertExists("public/a/b/p1/index.html", true) + assertExists("public/a/c/p2/index.html", false) + + b.AssertFileContent(filepath.Join("public", "index.html"), ` +Data: map[mydata:map[b:map[b1:bval]]]:END +Template: false +Resource1: /js/include.js:END +Resource2: :END +Resource3: :END +Resources: [/js/include.js] +`) +} diff --git a/hugolib/multilingual.go b/hugolib/multilingual.go deleted file mode 100644 index a3f3828ef..000000000 --- a/hugolib/multilingual.go +++ /dev/null @@ -1,141 +0,0 @@ -// Copyright 2016-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 hugolib - -import ( - "sync" - - "sort" - - "errors" - "fmt" - - "github.com/gohugoio/hugo/config" - "github.com/gohugoio/hugo/helpers" - "github.com/spf13/cast" -) - -// Multilingual manages the all languages used in a multilingual site. -type Multilingual struct { - Languages helpers.Languages - - DefaultLang *helpers.Language - - langMap map[string]*helpers.Language - langMapInit sync.Once -} - -// Language returns the Language associated with the given string. -func (ml *Multilingual) Language(lang string) *helpers.Language { - ml.langMapInit.Do(func() { - ml.langMap = make(map[string]*helpers.Language) - for _, l := range ml.Languages { - ml.langMap[l.Lang] = l - } - }) - return ml.langMap[lang] -} - -func getLanguages(cfg config.Provider) helpers.Languages { - if cfg.IsSet("languagesSorted") { - return cfg.Get("languagesSorted").(helpers.Languages) - } - - return helpers.Languages{helpers.NewDefaultLanguage(cfg)} -} - -func newMultiLingualFromSites(cfg config.Provider, sites ...*Site) (*Multilingual, error) { - languages := make(helpers.Languages, len(sites)) - - for i, s := range sites { - if s.Language == nil { - return nil, errors.New("Missing language for site") - } - languages[i] = s.Language - } - - defaultLang := cfg.GetString("defaultContentLanguage") - - if defaultLang == "" { - defaultLang = "en" - } - - return &Multilingual{Languages: languages, DefaultLang: helpers.NewLanguage(defaultLang, cfg)}, nil - -} - -func newMultiLingualForLanguage(language *helpers.Language) *Multilingual { - languages := helpers.Languages{language} - return &Multilingual{Languages: languages, DefaultLang: language} -} -func (ml *Multilingual) enabled() bool { - return len(ml.Languages) > 1 -} - -func (s *Site) multilingualEnabled() bool { - if s.owner == nil { - return false - } - return s.owner.multilingual != nil && s.owner.multilingual.enabled() -} - -func toSortedLanguages(cfg config.Provider, l map[string]interface{}) (helpers.Languages, error) { - langs := make(helpers.Languages, len(l)) - i := 0 - - for lang, langConf := range l { - langsMap, err := cast.ToStringMapE(langConf) - - if err != nil { - return nil, fmt.Errorf("Language config is not a map: %T", langConf) - } - - language := helpers.NewLanguage(lang, cfg) - - for loki, v := range langsMap { - switch loki { - case "title": - language.Title = cast.ToString(v) - case "languagename": - language.LanguageName = cast.ToString(v) - case "weight": - language.Weight = cast.ToInt(v) - case "contentdir": - language.ContentDir = cast.ToString(v) - case "disabled": - language.Disabled = cast.ToBool(v) - case "params": - m := cast.ToStringMap(v) - // Needed for case insensitive fetching of params values - helpers.ToLowerMap(m) - for k, vv := range m { - language.SetParam(k, vv) - } - } - - // Put all into the Params map - language.SetParam(loki, v) - - // Also set it in the configuration map (for baseURL etc.) - language.Set(loki, v) - } - - langs[i] = language - i++ - } - - sort.Sort(langs) - - return langs, nil -} diff --git a/hugolib/page.go b/hugolib/page.go index 195e68084..bb3835c1e 100644 --- a/hugolib/page.go +++ b/hugolib/page.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,2036 +14,771 @@ package hugolib import ( - "bytes" - "errors" + "context" "fmt" - "reflect" - "unicode" - - "github.com/gohugoio/hugo/related" - - "github.com/bep/gitmap" - - "github.com/gohugoio/hugo/helpers" - "github.com/gohugoio/hugo/hugolib/pagemeta" - "github.com/gohugoio/hugo/resource" - - "github.com/gohugoio/hugo/output" - "github.com/gohugoio/hugo/parser" - "github.com/mitchellh/mapstructure" - - "html/template" - "io" - "path" "path/filepath" - "regexp" + "strconv" "strings" - "sync" - "time" - "unicode/utf8" + "sync/atomic" + + "github.com/gohugoio/hugo/hugofs" + "github.com/gohugoio/hugo/hugolib/doctree" + "github.com/gohugoio/hugo/hugolib/segments" + "github.com/gohugoio/hugo/identity" + "github.com/gohugoio/hugo/media" + "github.com/gohugoio/hugo/output" + "github.com/gohugoio/hugo/related" + "github.com/gohugoio/hugo/resources" + "github.com/gohugoio/hugo/tpl/tplimpl" + "github.com/spf13/afero" + + "github.com/gohugoio/hugo/markup/converter" + "github.com/gohugoio/hugo/markup/tableofcontents" + + "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/common/types" - bp "github.com/gohugoio/hugo/bufferpool" - "github.com/gohugoio/hugo/compare" "github.com/gohugoio/hugo/source" - "github.com/spf13/cast" + + "github.com/gohugoio/hugo/common/collections" + "github.com/gohugoio/hugo/common/text" + "github.com/gohugoio/hugo/resources/kinds" + "github.com/gohugoio/hugo/resources/page" + "github.com/gohugoio/hugo/resources/resource" ) var ( - cjk = regexp.MustCompile(`\p{Han}|\p{Hangul}|\p{Hiragana}|\p{Katakana}`) - - // This is all the kinds we can expect to find in .Site.Pages. - allKindsInPages = []string{KindPage, KindHome, KindSection, KindTaxonomy, KindTaxonomyTerm} - - allKinds = append(allKindsInPages, []string{kindRSS, kindSitemap, kindRobotsTXT, kind404}...) - - // Assert that it implements the Eqer interface. - _ compare.Eqer = (*Page)(nil) - _ compare.Eqer = (*PageOutput)(nil) - - // Assert that it implements the interface needed for related searches. - _ related.Document = (*Page)(nil) + _ page.Page = (*pageState)(nil) + _ collections.Grouper = (*pageState)(nil) + _ collections.Slicer = (*pageState)(nil) + _ identity.DependencyManagerScopedProvider = (*pageState)(nil) + _ contentNodeI = (*pageState)(nil) + _ pageContext = (*pageState)(nil) ) -const ( - KindPage = "page" - - // The rest are node types; home page, sections etc. - - KindHome = "home" - KindSection = "section" - KindTaxonomy = "taxonomy" - KindTaxonomyTerm = "taxonomyTerm" - - // Temporary state. - kindUnknown = "unknown" - - // The following are (currently) temporary nodes, - // i.e. nodes we create just to render in isolation. - kindRSS = "RSS" - kindSitemap = "sitemap" - kindRobotsTXT = "robotsTXT" - kind404 = "404" - - pageResourceType = "page" -) - -type Page struct { - *pageInit - - // Kind is the discriminator that identifies the different page types - // in the different page collections. This can, as an example, be used - // to to filter regular pages, find sections etc. - // Kind will, for the pages available to the templates, be one of: - // page, home, section, taxonomy and taxonomyTerm. - // It is of string type to make it easy to reason about in - // the templates. - Kind string - - // Since Hugo 0.18 we got rid of the Node type. So now all pages are ... - // pages (regular pages, home page, sections etc.). - // Sections etc. will have child pages. These were earlier placed in .Data.Pages, - // but can now be more intuitively also be fetched directly from .Pages. - // This collection will be nil for regular pages. - Pages Pages - - // Since Hugo 0.32, a Page can have resources such as images and CSS associated - // with itself. The resource will typically be placed relative to the Page, - // but templates should use the links (Permalink and RelPermalink) - // provided by the Resource object. - Resources resource.Resources - - // This is the raw front matter metadata that is going to be assigned to - // the Resources above. - resourcesMetadata []map[string]interface{} - - // translations will contain references to this page in other language - // if available. - translations Pages - - // A key that maps to translation(s) of this page. This value is fetched - // from the page front matter. - translationKey string - - // Params contains configuration defined in the params section of page frontmatter. - params map[string]interface{} - - // Content sections - Content template.HTML - Summary template.HTML - TableOfContents template.HTML - - Aliases []string - - Images []Image - Videos []Video - - Truncated bool - Draft bool - Status string - - // PageMeta contains page stats such as word count etc. - PageMeta - - // Markup contains the markup type for the content. - Markup string - - extension string - contentType string - renderable bool - - Layout string - - // For npn-renderable pages (see IsRenderable), the content itself - // is used as template and the template name is stored here. - selfLayout string - - linkTitle string - - frontmatter []byte - - // rawContent is the raw content read from the content file. - rawContent []byte - - // workContent is a copy of rawContent that may be mutated during site build. - workContent []byte - - // whether the content is in a CJK language. - isCJKLanguage bool - - shortcodeState *shortcodeHandler - - // the content stripped for HTML - plain string // TODO should be []byte - plainWords []string - - // rendering configuration - renderingConfig *helpers.BlackFriday - - // menus - pageMenus PageMenus - - Source - - Position `json:"-"` - - GitInfo *gitmap.GitInfo - - // This was added as part of getting the Nodes (taxonomies etc.) to work as - // Pages in Hugo 0.18. - // It is deliberately named similar to Section, but not exported (for now). - // We currently have only one level of section in Hugo, but the page can live - // any number of levels down the file path. - // To support taxonomies like /categories/hugo etc. we will need to keep track - // of that information in a general way. - // So, sections represents the path to the content, i.e. a content file or a - // virtual content file in the situations where a taxonomy or a section etc. - // isn't accomanied by one. - sections []string - - // Will only be set for sections and regular pages. - parent *Page - - // When we create paginator pages, we create a copy of the original, - // but keep track of it here. - origOnCopy *Page - - // Will only be set for section pages and the home page. - subSections Pages - - s *Site - - // Pulled over from old Node. TODO(bep) reorg and group (embed) - - Site *SiteInfo `json:"-"` - - title string - Description string - Keywords []string - Data map[string]interface{} - - pagemeta.PageDates - - Sitemap Sitemap - pagemeta.URLPath - frontMatterURL string - - permalink string - relPermalink string - - // relative target path without extension and any base path element from the baseURL. - // This is used to construct paths in the page resources. - relTargetPathBase string - // Is set to a forward slashed path if this is a Page resources living in a folder below its owner. - resourcePath string - - // This is enabled if it is a leaf bundle (the "index.md" type) and it is marked as headless in front matter. - // Being headless means that - // 1. The page itself is not rendered to disk - // 2. It is not available in .Site.Pages etc. - // 3. But you can get it via .Site.GetPage - headless bool - - layoutDescriptor output.LayoutDescriptor - - scratch *Scratch - - // It would be tempting to use the language set on the Site, but in they way we do - // multi-site processing, these values may differ during the initial page processing. - language *helpers.Language - - lang string - - // The output formats this page will be rendered to. - outputFormats output.Formats - - // This is the PageOutput that represents the first item in outputFormats. - // Use with care, as there are potential for inifinite loops. - mainPageOutput *PageOutput - - targetPathDescriptorPrototype *targetPathDescriptor -} - -// Sites is a convenience method to get all the Hugo sites/languages configured. -func (p *Page) Sites() SiteInfos { - infos := make(SiteInfos, len(p.s.owner.Sites)) - for i, site := range p.s.owner.Sites { - infos[i] = &site.Info +var ( + pageTypesProvider = resource.NewResourceTypesProvider(media.Builtin.OctetType, pageResourceType) + nopPageOutput = &pageOutput{ + pagePerOutputProviders: nopPagePerOutput, + MarkupProvider: page.NopPage, + ContentProvider: page.NopPage, } +) - return infos +// pageContext provides contextual information about this page, for error +// logging and similar. +type pageContext interface { + posOffset(offset int) text.Position + wrapError(err error) error + getContentConverter() converter.Converter } -// SearchKeywords implements the related.Document interface needed for fast page searches. -func (p *Page) SearchKeywords(cfg related.IndexConfig) ([]related.Keyword, error) { +type pageSiteAdapter struct { + p page.Page + s *Site +} - v, err := p.Param(cfg.Name) +func (pa pageSiteAdapter) GetPage(ref string) (page.Page, error) { + p, err := pa.s.getPage(pa.p, ref) + + if p == nil { + // The nil struct has meaning in some situations, mostly to avoid breaking + // existing sites doing $nilpage.IsDescendant($p), which will always return + // false. + p = page.NilPage + } + return p, err +} + +type pageState struct { + // Incremented for each new page created. + // Note that this will change between builds for a given Page. + pid uint64 + + // This slice will be of same length as the number of global slice of output + // formats (for all sites). + pageOutputs []*pageOutput + + // Used to determine if we can reuse content across output formats. + pageOutputTemplateVariationsState *atomic.Uint32 + + // This will be shifted out when we start to render a new output format. + pageOutputIdx int + *pageOutput + + // Common for all output formats. + *pageCommon + + resource.Staler + dependencyManager identity.Manager +} + +func (p *pageState) incrPageOutputTemplateVariation() { + p.pageOutputTemplateVariationsState.Add(1) +} + +func (p *pageState) canReusePageOutputContent() bool { + return p.pageOutputTemplateVariationsState.Load() == 1 +} + +func (p *pageState) IdentifierBase() string { + return p.Path() +} + +func (p *pageState) GetIdentity() identity.Identity { + return p +} + +func (p *pageState) ForEeachIdentity(f func(identity.Identity) bool) bool { + return f(p) +} + +func (p *pageState) GetDependencyManager() identity.Manager { + return p.dependencyManager +} + +func (p *pageState) GetDependencyManagerForScope(scope int) identity.Manager { + switch scope { + case pageDependencyScopeDefault: + return p.dependencyManagerOutput + case pageDependencyScopeGlobal: + return p.dependencyManager + default: + return identity.NopManager + } +} + +func (p *pageState) GetDependencyManagerForScopesAll() []identity.Manager { + return []identity.Manager{p.dependencyManager, p.dependencyManagerOutput} +} + +func (p *pageState) Key() string { + return "page-" + strconv.FormatUint(p.pid, 10) +} + +// RelatedKeywords implements the related.Document interface needed for fast page searches. +func (p *pageState) RelatedKeywords(cfg related.IndexConfig) ([]related.Keyword, error) { + v, found, err := page.NamedPageMetaValue(p, cfg.Name) if err != nil { return nil, err } + if !found { + return nil, nil + } + return cfg.ToKeywords(v) } -// PubDate is when this page was or will be published. -// NOTE: This is currently used for search only and is not meant to be used -// directly in templates. We need to consolidate the dates in this struct. -// TODO(bep) see https://github.com/gohugoio/hugo/issues/3854 -func (p *Page) PubDate() time.Time { - if !p.PublishDate.IsZero() { - return p.PublishDate - } - return p.Date +func (p *pageState) resetBuildState() { + // Nothing to do for now. } -func (*Page) ResourceType() string { - return pageResourceType -} - -func (p *Page) RSSLink() template.URL { - f, found := p.outputFormats.GetByName(output.RSSFormat.Name) - if !found { - return "" - } - return template.URL(newOutputFormat(p, f).Permalink()) -} - -func (p *Page) createLayoutDescriptor() output.LayoutDescriptor { - var section string - - switch p.Kind { - case KindSection: - // In Hugo 0.22 we introduce nested sections, but we still only - // use the first level to pick the correct template. This may change in - // the future. - section = p.sections[0] - case KindTaxonomy, KindTaxonomyTerm: - section = p.s.taxonomiesPluralSingular[p.sections[0]] - default: - } - - return output.LayoutDescriptor{ - Kind: p.Kind, - Type: p.Type(), - Lang: p.Lang(), - Layout: p.Layout, - Section: section, - } -} - -// pageInit lazy initializes different parts of the page. It is extracted -// into its own type so we can easily create a copy of a given page. -type pageInit struct { - languageInit sync.Once - pageMenusInit sync.Once - pageMetaInit sync.Once - pageOutputInit sync.Once - plainInit sync.Once - plainWordsInit sync.Once - renderingConfigInit sync.Once -} - -// IsNode returns whether this is an item of one of the list types in Hugo, -// i.e. not a regular content page. -func (p *Page) IsNode() bool { - return p.Kind != KindPage -} - -// IsHome returns whether this is the home page. -func (p *Page) IsHome() bool { - return p.Kind == KindHome -} - -// IsSection returns whether this is a section page. -func (p *Page) IsSection() bool { - return p.Kind == KindSection -} - -// IsPage returns whether this is a regular content page. -func (p *Page) IsPage() bool { - return p.Kind == KindPage -} - -type Source struct { - Frontmatter []byte - Content []byte - source.File -} -type PageMeta struct { - wordCount int - fuzzyWordCount int - readingTime int - Weight int -} - -type Position struct { - Prev *Page - Next *Page - PrevInSection *Page - NextInSection *Page -} - -type Pages []*Page - -func (ps Pages) String() string { - return fmt.Sprintf("Pages(%d)", len(ps)) -} - -func (ps Pages) findPagePosByFilename(filename string) int { - for i, x := range ps { - if x.Source.Filename() == filename { - return i - } - } - return -1 -} - -func (ps Pages) removeFirstIfFound(p *Page) Pages { - ii := -1 - for i, pp := range ps { - if pp == p { - ii = i - break - } - } - - if ii != -1 { - ps = append(ps[:ii], ps[ii+1:]...) - } - return ps -} - -func (ps Pages) findPagePosByFilnamePrefix(prefix string) int { - if prefix == "" { - return -1 - } - - lenDiff := -1 - currPos := -1 - prefixLen := len(prefix) - - // Find the closest match - for i, x := range ps { - if strings.HasPrefix(x.Source.Filename(), prefix) { - diff := len(x.Source.Filename()) - prefixLen - if lenDiff == -1 || diff < lenDiff { - lenDiff = diff - currPos = i - } - } - } - return currPos -} - -// findPagePos Given a page, it will find the position in Pages -// will return -1 if not found -func (ps Pages) findPagePos(page *Page) int { - for i, x := range ps { - if x.Source.Filename() == page.Source.Filename() { - return i - } - } - return -1 -} - -func (p *Page) createWorkContentCopy() { - p.workContent = make([]byte, len(p.rawContent)) - copy(p.workContent, p.rawContent) -} - -func (p *Page) Plain() string { - p.initPlain() - return p.plain -} - -func (p *Page) PlainWords() []string { - p.initPlainWords() - return p.plainWords -} - -func (p *Page) initPlain() { - p.plainInit.Do(func() { - p.plain = helpers.StripHTML(string(p.Content)) - return - }) -} - -func (p *Page) initPlainWords() { - p.plainWordsInit.Do(func() { - p.plainWords = strings.Fields(p.Plain()) - return - }) -} - -// Param is a convenience method to do lookups in Page's and Site's Params map, -// in that order. -// -// This method is also implemented on Node and SiteInfo. -func (p *Page) Param(key interface{}) (interface{}, error) { - keyStr, err := cast.ToStringE(key) - if err != nil { - return nil, err - } - - keyStr = strings.ToLower(keyStr) - result, _ := p.traverseDirect(keyStr) - if result != nil { - return result, nil - } - - keySegments := strings.Split(keyStr, ".") - if len(keySegments) == 1 { - return nil, nil - } - - return p.traverseNested(keySegments) -} - -func (p *Page) traverseDirect(key string) (interface{}, error) { - keyStr := strings.ToLower(key) - if val, ok := p.params[keyStr]; ok { - return val, nil - } - - return p.Site.Params[keyStr], nil -} - -func (p *Page) traverseNested(keySegments []string) (interface{}, error) { - result := traverse(keySegments, p.params) - if result != nil { - return result, nil - } - - result = traverse(keySegments, p.Site.Params) - if result != nil { - return result, nil - } - - // Didn't find anything, but also no problems. - return nil, nil -} - -func traverse(keys []string, m map[string]interface{}) interface{} { - // Shift first element off. - firstKey, rest := keys[0], keys[1:] - result := m[firstKey] - - // No point in continuing here. - if result == nil { - return result - } - - if len(rest) == 0 { - // That was the last key. - return result - } - - // That was not the last key. - return traverse(rest, cast.ToStringMap(result)) -} - -func (p *Page) Author() Author { - authors := p.Authors() - - for _, author := range authors { - return author - } - return Author{} -} - -func (p *Page) Authors() AuthorList { - authorKeys, ok := p.params["authors"] - if !ok { - return AuthorList{} - } - authors := authorKeys.([]string) - if len(authors) < 1 || len(p.Site.Authors) < 1 { - return AuthorList{} - } - - al := make(AuthorList) - for _, author := range authors { - a, ok := p.Site.Authors[author] - if ok { - al[author] = a - } - } - return al -} - -func (p *Page) UniqueID() string { - return p.Source.UniqueID() -} - -// for logging -func (p *Page) lineNumRawContentStart() int { - return bytes.Count(p.frontmatter, []byte("\n")) + 1 -} - -var ( - internalSummaryDivider = []byte("HUGOMORE42") -) - -// replaceDivider replaces the <!--more--> with an internal value and returns -// whether the contentis truncated or not. -// Note: The content slice will be modified if needed. -func replaceDivider(content, from, to []byte) ([]byte, bool) { - dividerIdx := bytes.Index(content, from) - if dividerIdx == -1 { - return content, false - } - - afterSummary := content[dividerIdx+len(from):] - - // If the raw content has nothing but whitespace after the summary - // marker then the page shouldn't be marked as truncated. This check - // is simplest against the raw content because different markup engines - // (rst and asciidoc in particular) add div and p elements after the - // summary marker. - truncated := bytes.IndexFunc(afterSummary, func(r rune) bool { return !unicode.IsSpace(r) }) != -1 - - content = append(content[:dividerIdx], append(to, afterSummary...)...) - - return content, truncated - -} - -// We have to replace the <!--more--> with something that survives all the -// rendering engines. -func (p *Page) replaceDivider(content []byte) []byte { - summaryDivider := helpers.SummaryDivider - // TODO(bep) handle better. - if p.Ext() == "org" || p.Markup == "org" { - summaryDivider = []byte("# more") - } - - replaced, truncated := replaceDivider(content, summaryDivider, internalSummaryDivider) - - p.Truncated = truncated - - return replaced -} - -// Returns the page as summary and main if a user defined split is provided. -func (p *Page) setUserDefinedSummaryIfProvided(rawContentCopy []byte) (*summaryContent, error) { - - sc, err := splitUserDefinedSummaryAndContent(p.Markup, rawContentCopy) - - if err != nil { - return nil, err - } - - if sc == nil { - // No divider found - return nil, nil - } - - p.Summary = helpers.BytesToHTML(sc.summary) - - return sc, nil -} - -// Make this explicit so there is no doubt about what is what. -type summaryContent struct { - summary []byte - content []byte -} - -func splitUserDefinedSummaryAndContent(markup string, c []byte) (sc *summaryContent, err error) { - defer func() { - if r := recover(); r != nil { - err = fmt.Errorf("summary split failed: %s", r) - } - }() - - c = bytes.TrimSpace(c) - startDivider := bytes.Index(c, internalSummaryDivider) - - if startDivider == -1 { - return - } - - endDivider := startDivider + len(internalSummaryDivider) - endSummary := startDivider - - var ( - startMarkup []byte - endMarkup []byte - addDiv bool +func (p *pageState) skipRender() bool { + b := p.s.conf.C.SegmentFilter.ShouldExcludeFine( + segments.SegmentMatcherFields{ + Path: p.Path(), + Kind: p.Kind(), + Lang: p.Lang(), + Output: p.pageOutput.f.Name, + }, ) - switch markup { - default: - startMarkup = []byte("<p>") - endMarkup = []byte("</p>") - case "asciidoc": - startMarkup = []byte("<div class=\"paragraph\">") - endMarkup = []byte("</div>") - case "rst": - startMarkup = []byte("<p>") - endMarkup = []byte("</p>") - addDiv = true + return b +} + +func (po *pageState) isRenderedAny() bool { + for _, o := range po.pageOutputs { + if o.isRendered() { + return true + } } + return false +} - // Find the closest end/start markup string to the divider - fromStart := -1 - fromIdx := bytes.LastIndex(c[:startDivider], startMarkup) - if fromIdx != -1 { - fromStart = startDivider - fromIdx - len(startMarkup) - } - fromEnd := bytes.Index(c[endDivider:], endMarkup) - - if fromEnd != -1 && fromEnd <= fromStart { - endSummary = startDivider + fromEnd + len(endMarkup) - } else if fromStart != -1 && fromEnd != -1 { - endSummary = startDivider - fromStart - len(startMarkup) - } - - withoutDivider := bytes.TrimSpace(append(c[:startDivider], c[endDivider:]...)) - var ( - summary []byte - ) - - if len(withoutDivider) > 0 { - summary = bytes.TrimSpace(withoutDivider[:endSummary]) - } - - if addDiv { - // For the rst - summary = append(append([]byte(nil), summary...), []byte("</div>")...) - } +func (p *pageState) isContentNodeBranch() bool { + return p.IsNode() +} +// Eq returns whether the current page equals the given page. +// This is what's invoked when doing `{{ if eq $page $otherPage }}` +func (p *pageState) Eq(other any) bool { + pp, err := unwrapPage(other) if err != nil { - return - } - - sc = &summaryContent{ - summary: summary, - content: withoutDivider, - } - - return -} - -func (p *Page) setAutoSummary() error { - var summary string - var truncated bool - if p.isCJKLanguage { - summary, truncated = p.s.ContentSpec.TruncateWordsByRune(p.PlainWords()) - } else { - summary, truncated = p.s.ContentSpec.TruncateWordsToWholeSentence(p.Plain()) - } - p.Summary = template.HTML(summary) - p.Truncated = truncated - - return nil -} - -func (p *Page) renderContent(content []byte) []byte { - return p.s.ContentSpec.RenderBytes(&helpers.RenderingContext{ - Content: content, RenderTOC: true, PageFmt: p.determineMarkupType(), - Cfg: p.Language(), - DocumentID: p.UniqueID(), DocumentName: p.Path(), - Config: p.getRenderingConfig()}) -} - -func (p *Page) getRenderingConfig() *helpers.BlackFriday { - p.renderingConfigInit.Do(func() { - bfParam := p.getParamToLower("blackfriday") - if bfParam == nil { - p.renderingConfig = p.s.ContentSpec.BlackFriday - return - } - // Create a copy so we can modify it. - bf := *p.s.ContentSpec.BlackFriday - p.renderingConfig = &bf - - if p.Language() == nil { - panic(fmt.Sprintf("nil language for %s with source lang %s", p.BaseFileName(), p.lang)) - } - - pageParam := cast.ToStringMap(bfParam) - if err := mapstructure.Decode(pageParam, &p.renderingConfig); err != nil { - p.s.Log.FATAL.Printf("Failed to get rendering config for %s:\n%s", p.BaseFileName(), err.Error()) - } - - }) - - return p.renderingConfig -} - -func (s *Site) newPage(filename string) *Page { - fi := newFileInfo( - s.SourceSpec, - s.absContentDir(), - filename, - nil, - bundleNot, - ) - return s.newPageFromFile(fi) -} - -func (s *Site) newPageFromFile(fi *fileInfo) *Page { - return &Page{ - pageInit: &pageInit{}, - Kind: kindFromFileInfo(fi), - contentType: "", - Source: Source{File: fi}, - Keywords: []string{}, Sitemap: Sitemap{Priority: -1}, - params: make(map[string]interface{}), - translations: make(Pages, 0), - sections: sectionsFromFile(fi), - Site: &s.Info, - s: s, - } -} - -func (p *Page) IsRenderable() bool { - return p.renderable -} - -func (p *Page) Type() string { - if p.contentType != "" { - return p.contentType - } - - if x := p.Section(); x != "" { - return x - } - - return "page" -} - -// Section returns the first path element below the content root. Note that -// since Hugo 0.22 we support nested sections, but this will always be the first -// element of any nested path. -func (p *Page) Section() string { - if p.Kind == KindSection || p.Kind == KindTaxonomy || p.Kind == KindTaxonomyTerm { - return p.sections[0] - } - return p.Source.Section() -} - -func (s *Site) NewPageFrom(buf io.Reader, name string) (*Page, error) { - p, err := s.NewPage(name) - if err != nil { - return p, err - } - _, err = p.ReadFrom(buf) - - return p, err -} - -func (s *Site) NewPage(name string) (*Page, error) { - if len(name) == 0 { - return nil, errors.New("Zero length page name") - } - - // Create new page - p := s.newPage(name) - p.s = s - p.Site = &s.Info - - return p, nil -} - -func (p *Page) ReadFrom(buf io.Reader) (int64, error) { - // Parse for metadata & body - if err := p.parse(buf); err != nil { - p.s.Log.ERROR.Print(err) - return 0, err - } - - return int64(len(p.rawContent)), nil -} - -func (p *Page) WordCount() int { - p.analyzePage() - return p.wordCount -} - -func (p *Page) ReadingTime() int { - p.analyzePage() - return p.readingTime -} - -func (p *Page) FuzzyWordCount() int { - p.analyzePage() - return p.fuzzyWordCount -} - -func (p *Page) analyzePage() { - p.pageMetaInit.Do(func() { - if p.isCJKLanguage { - p.wordCount = 0 - for _, word := range p.PlainWords() { - runeCount := utf8.RuneCountInString(word) - if len(word) == runeCount { - p.wordCount++ - } else { - p.wordCount += runeCount - } - } - } else { - p.wordCount = helpers.TotalWords(p.Plain()) - } - - // TODO(bep) is set in a test. Fix that. - if p.fuzzyWordCount == 0 { - p.fuzzyWordCount = (p.wordCount + 100) / 100 * 100 - } - - if p.isCJKLanguage { - p.readingTime = (p.wordCount + 500) / 501 - } else { - p.readingTime = (p.wordCount + 212) / 213 - } - }) -} - -// HasShortcode return whether the page has a shortcode with the given name. -// This method is mainly motivated with the Hugo Docs site's need for a list -// of pages with the `todo` shortcode in it. -func (p *Page) HasShortcode(name string) bool { - if p.shortcodeState == nil { return false } - return p.shortcodeState.nameSet[name] + return p == pp } -// AllTranslations returns all translations, including the current Page. -func (p *Page) AllTranslations() Pages { - return p.translations +func (p *pageState) HeadingsFiltered(context.Context) tableofcontents.Headings { + return nil +} + +type pageHeadingsFiltered struct { + *pageState + headings tableofcontents.Headings +} + +func (p *pageHeadingsFiltered) HeadingsFiltered(context.Context) tableofcontents.Headings { + return p.headings +} + +func (p *pageHeadingsFiltered) page() page.Page { + return p.pageState +} + +// For internal use by the related content feature. +func (p *pageState) ApplyFilterToHeadings(ctx context.Context, fn func(*tableofcontents.Heading) bool) related.Document { + fragments := p.pageOutput.pco.c().Fragments(ctx) + headings := fragments.Headings.FilterBy(fn) + return &pageHeadingsFiltered{ + pageState: p, + headings: headings, + } +} + +func (p *pageState) GitInfo() source.GitInfo { + return p.gitInfo +} + +func (p *pageState) CodeOwners() []string { + return p.codeowners +} + +// GetTerms gets the terms defined on this page in the given taxonomy. +// The pages returned will be ordered according to the front matter. +func (p *pageState) GetTerms(taxonomy string) page.Pages { + return p.s.pageMap.getTermsForPageInTaxonomy(p.Path(), taxonomy) +} + +func (p *pageState) MarshalJSON() ([]byte, error) { + return page.MarshalPageToJSON(p) +} + +func (p *pageState) RegularPagesRecursive() page.Pages { + switch p.Kind() { + case kinds.KindSection, kinds.KindHome: + return p.s.pageMap.getPagesInSection( + pageMapQueryPagesInSection{ + pageMapQueryPagesBelowPath: pageMapQueryPagesBelowPath{ + Path: p.Path(), + Include: pagePredicates.ShouldListLocal.And(pagePredicates.KindPage), + }, + Recursive: true, + }, + ) + default: + return p.RegularPages() + } +} + +func (p *pageState) PagesRecursive() page.Pages { + return nil +} + +func (p *pageState) RegularPages() page.Pages { + switch p.Kind() { + case kinds.KindPage: + case kinds.KindSection, kinds.KindHome, kinds.KindTaxonomy: + return p.s.pageMap.getPagesInSection( + pageMapQueryPagesInSection{ + pageMapQueryPagesBelowPath: pageMapQueryPagesBelowPath{ + Path: p.Path(), + Include: pagePredicates.ShouldListLocal.And(pagePredicates.KindPage), + }, + }, + ) + case kinds.KindTerm: + return p.s.pageMap.getPagesWithTerm( + pageMapQueryPagesBelowPath{ + Path: p.Path(), + Include: pagePredicates.ShouldListLocal.And(pagePredicates.KindPage), + }, + ) + default: + return p.s.RegularPages() + } + return nil +} + +func (p *pageState) Pages() page.Pages { + switch p.Kind() { + case kinds.KindPage: + case kinds.KindSection, kinds.KindHome: + return p.s.pageMap.getPagesInSection( + pageMapQueryPagesInSection{ + pageMapQueryPagesBelowPath: pageMapQueryPagesBelowPath{ + Path: p.Path(), + KeyPart: "page-section", + Include: pagePredicates.ShouldListLocal.And( + pagePredicates.KindPage.Or(pagePredicates.KindSection), + ), + }, + }, + ) + case kinds.KindTerm: + return p.s.pageMap.getPagesWithTerm( + pageMapQueryPagesBelowPath{ + Path: p.Path(), + }, + ) + case kinds.KindTaxonomy: + return p.s.pageMap.getPagesInSection( + pageMapQueryPagesInSection{ + pageMapQueryPagesBelowPath: pageMapQueryPagesBelowPath{ + Path: p.Path(), + KeyPart: "term", + Include: pagePredicates.ShouldListLocal.And(pagePredicates.KindTerm), + }, + Recursive: true, + }, + ) + default: + return p.s.Pages() + } + return nil +} + +// RawContent returns the un-rendered source content without +// any leading front matter. +func (p *pageState) RawContent() string { + if p.m.content.pi.itemsStep2 == nil { + return "" + } + start := p.m.content.pi.posMainContent + if start == -1 { + start = 0 + } + source, err := p.m.content.pi.contentSource(p.m.content) + if err != nil { + panic(err) + } + return string(source[start:]) +} + +func (p *pageState) Resources() resource.Resources { + return p.s.pageMap.getOrCreateResourcesForPage(p) +} + +func (p *pageState) HasShortcode(name string) bool { + if p.m.content.shortcodeState == nil { + return false + } + + return p.m.content.shortcodeState.hasName(name) +} + +func (p *pageState) Site() page.Site { + return p.sWrapped +} + +func (p *pageState) String() string { + var sb strings.Builder + if p.File() != nil { + // The forward slashes even on Windows is motivated by + // getting stable tests. + // This information is meant for getting positional information in logs, + // so the direction of the slashes should not matter. + sb.WriteString(filepath.ToSlash(p.File().Filename())) + if p.File().IsContentAdapter() { + // Also include the path. + sb.WriteString(":") + sb.WriteString(p.Path()) + } + } else { + sb.WriteString(p.Path()) + } + return sb.String() } // IsTranslated returns whether this content file is translated to // other language(s). -func (p *Page) IsTranslated() bool { - return len(p.translations) > 1 +func (p *pageState) IsTranslated() bool { + return len(p.Translations()) > 0 +} + +// TranslationKey returns the key used to identify a translation of this content. +func (p *pageState) TranslationKey() string { + if p.m.pageConfig.TranslationKey != "" { + return p.m.pageConfig.TranslationKey + } + return p.Path() +} + +// AllTranslations returns all translations, including the current Page. +func (p *pageState) AllTranslations() page.Pages { + key := p.Path() + "/" + "translations-all" + // This is called from Translations, so we need to use a different partition, cachePages2, + // to avoid potential deadlocks. + pages, err := p.s.pageMap.getOrCreatePagesFromCache(p.s.pageMap.cachePages2, key, func(string) (page.Pages, error) { + if p.m.pageConfig.TranslationKey != "" { + // translationKey set by user. + pas, _ := p.s.h.translationKeyPages.Get(p.m.pageConfig.TranslationKey) + pasc := make(page.Pages, len(pas)) + copy(pasc, pas) + page.SortByLanguage(pasc) + return pasc, nil + } + var pas page.Pages + p.s.pageMap.treePages.ForEeachInDimension(p.Path(), doctree.DimensionLanguage.Index(), + func(n contentNodeI) bool { + if n != nil { + pas = append(pas, n.(page.Page)) + } + return false + }, + ) + + pas = pagePredicates.ShouldLink.Filter(pas) + page.SortByLanguage(pas) + return pas, nil + }) + if err != nil { + panic(err) + } + + return pages } // Translations returns the translations excluding the current Page. -func (p *Page) Translations() Pages { - translations := make(Pages, 0) - for _, t := range p.translations { - if t.Lang() != p.Lang() { - translations = append(translations, t) - } - } - return translations -} - -// TranslationKey returns the key used to map language translations of this page. -// It will use the translationKey set in front matter if set, or the content path and -// filename (excluding any language code and extension), e.g. "about/index". -// The Page Kind is always prepended. -func (p *Page) TranslationKey() string { - if p.translationKey != "" { - return p.Kind + "/" + p.translationKey - } - - if p.IsNode() { - return path.Join(p.Kind, path.Join(p.sections...), p.TranslationBaseName()) - } - - return path.Join(p.Kind, filepath.ToSlash(p.Dir()), p.TranslationBaseName()) -} - -func (p *Page) LinkTitle() string { - if len(p.linkTitle) > 0 { - return p.linkTitle - } - return p.title -} - -func (p *Page) shouldBuild() bool { - return shouldBuild(p.s.BuildFuture, p.s.BuildExpired, - p.s.BuildDrafts, p.Draft, p.PublishDate, p.ExpiryDate) -} - -func shouldBuild(buildFuture bool, buildExpired bool, buildDrafts bool, Draft bool, - publishDate time.Time, expiryDate time.Time) bool { - if !(buildDrafts || !Draft) { - return false - } - if !buildFuture && !publishDate.IsZero() && publishDate.After(time.Now()) { - return false - } - if !buildExpired && !expiryDate.IsZero() && expiryDate.Before(time.Now()) { - return false - } - return true -} - -func (p *Page) IsDraft() bool { - return p.Draft -} - -func (p *Page) IsFuture() bool { - if p.PublishDate.IsZero() { - return false - } - return p.PublishDate.After(time.Now()) -} - -func (p *Page) IsExpired() bool { - if p.ExpiryDate.IsZero() { - return false - } - return p.ExpiryDate.Before(time.Now()) -} - -func (p *Page) URL() string { - - if p.IsPage() && p.URLPath.URL != "" { - // This is the url set in front matter - return p.URLPath.URL - } - // Fall back to the relative permalink. - u := p.RelPermalink() - return u -} - -// Permalink returns the absolute URL to this Page. -func (p *Page) Permalink() string { - if p.headless { - return "" - } - return p.permalink -} - -// RelPermalink gets a URL to the resource relative to the host. -func (p *Page) RelPermalink() string { - if p.headless { - return "" - } - return p.relPermalink -} - -// See resource.Resource -// This value is used, by default, in Resources.ByPrefix etc. -func (p *Page) Name() string { - if p.resourcePath != "" { - return p.resourcePath - } - return p.title -} - -func (p *Page) Title() string { - return p.title -} - -func (p *Page) Params() map[string]interface{} { - return p.params -} - -func (p *Page) subResourceTargetPathFactory(base string) string { - return path.Join(p.relTargetPathBase, base) -} - -func (p *Page) prepareForRender(cfg *BuildCfg) error { - s := p.s - - if !p.shouldRenderTo(s.rc.Format) { - // No need to prepare - return nil - } - - var shortcodeUpdate bool - if p.shortcodeState != nil { - shortcodeUpdate = p.shortcodeState.updateDelta() - } - - if !shortcodeUpdate && !cfg.whatChanged.other { - // No need to process it again. - return nil - } - - // If we got this far it means that this is either a new Page pointer - // or a template or similar has changed so wee need to do a rerendering - // of the shortcodes etc. - - // If in watch mode or if we have multiple output formats, - // we need to keep the original so we can - // potentially repeat this process on rebuild. - needsACopy := p.s.running() || len(p.outputFormats) > 1 - var workContentCopy []byte - if needsACopy { - workContentCopy = make([]byte, len(p.workContent)) - copy(workContentCopy, p.workContent) - } else { - // Just reuse the same slice. - workContentCopy = p.workContent - } - - if p.Markup == "markdown" { - tmpContent, tmpTableOfContents := helpers.ExtractTOC(workContentCopy) - p.TableOfContents = helpers.BytesToHTML(tmpTableOfContents) - workContentCopy = tmpContent - } - - var err error - if workContentCopy, err = handleShortcodes(p, workContentCopy); err != nil { - s.Log.ERROR.Printf("Failed to handle shortcodes for page %s: %s", p.BaseFileName(), err) - } - - if p.Markup != "html" { - - // Now we know enough to create a summary of the page and count some words - summaryContent, err := p.setUserDefinedSummaryIfProvided(workContentCopy) - - if err != nil { - s.Log.ERROR.Printf("Failed to set user defined summary for page %q: %s", p.Path(), err) - } else if summaryContent != nil { - workContentCopy = summaryContent.content - } - - p.Content = helpers.BytesToHTML(workContentCopy) - - if summaryContent == nil { - if err := p.setAutoSummary(); err != nil { - s.Log.ERROR.Printf("Failed to set user auto summary for page %q: %s", p.pathOrTitle(), err) +func (p *pageState) Translations() page.Pages { + key := p.Path() + "/" + "translations" + pages, err := p.s.pageMap.getOrCreatePagesFromCache(nil, key, func(string) (page.Pages, error) { + var pas page.Pages + for _, pp := range p.AllTranslations() { + if !pp.Eq(p) { + pas = append(pas, pp) } } + return pas, nil + }) + if err != nil { + panic(err) + } + return pages +} - } else { - p.Content = helpers.BytesToHTML(workContentCopy) +func (ps *pageState) initCommonProviders(pp pagePaths) error { + if ps.IsPage() { + ps.posNextPrev = &nextPrev{init: ps.s.init.prevNext} + ps.posNextPrevSection = &nextPrev{init: ps.s.init.prevNextInSection} + ps.InSectionPositioner = newPagePositionInSection(ps.posNextPrevSection) + ps.Positioner = newPagePosition(ps.posNextPrev) } - //analyze for raw stats - p.analyzePage() - - // Handle bundled pages. - for _, r := range p.Resources.ByType(pageResourceType) { - p.s.PathSpec.ProcessingStats.Incr(&p.s.PathSpec.ProcessingStats.Pages) - bp := r.(*Page) - if err := bp.prepareForRender(cfg); err != nil { - s.Log.ERROR.Printf("Failed to prepare bundled page %q for render: %s", bp.BaseFileName(), err) - } - } + ps.OutputFormatsProvider = pp + ps.targetPathDescriptor = pp.targetPathDescriptor + ps.RefProvider = newPageRef(ps) + ps.SitesProvider = ps.s return nil } -var ErrHasDraftAndPublished = errors.New("both draft and published parameters were found in page's frontmatter") - -func (p *Page) update(frontmatter map[string]interface{}) error { - if frontmatter == nil { - return errors.New("missing frontmatter data") +// Exported so it can be used in integration tests. +func (po *pageOutput) GetInternalTemplateBasePathAndDescriptor() (string, tplimpl.TemplateDescriptor) { + p := po.p + f := po.f + base := p.PathInfo().BaseReTyped(p.m.pageConfig.Type) + return base, tplimpl.TemplateDescriptor{ + Kind: p.Kind(), + Lang: p.Language().Lang, + LayoutFromUser: p.Layout(), + OutputFormat: f.Name, + MediaType: f.MediaType.Type, + IsPlainText: f.IsPlainText, } - // Needed for case insensitive fetching of params values - helpers.ToLowerMap(frontmatter) +} - var mtime time.Time - if p.Source.FileInfo() != nil { - mtime = p.Source.FileInfo().ModTime() +func (p *pageState) resolveTemplate(layouts ...string) (*tplimpl.TemplInfo, bool, error) { + dir, d := p.GetInternalTemplateBasePathAndDescriptor() + + if len(layouts) > 0 { + d.LayoutFromUser = layouts[0] + d.LayoutFromUserMustMatch = true } - var gitAuthorDate time.Time - if p.GitInfo != nil { - gitAuthorDate = p.GitInfo.AuthorDate + q := tplimpl.TemplateQuery{ + Path: dir, + Category: tplimpl.CategoryLayout, + Desc: d, } - descriptor := &pagemeta.FrontMatterDescriptor{ - Frontmatter: frontmatter, - Params: p.params, - Dates: &p.PageDates, - PageURLs: &p.URLPath, - BaseFilename: p.BaseFileName(), - ModTime: mtime, - GitAuthorDate: gitAuthorDate, + tinfo := p.s.TemplateStore.LookupPagesLayout(q) + if tinfo == nil { + return nil, false, nil } - // Handle the date separately - // TODO(bep) we need to "do more" in this area so this can be split up and - // more easily tested without the Page, but the coupling is strong. - err := p.s.frontmatterHandler.HandleDates(descriptor) - if err != nil { - p.s.Log.ERROR.Printf("Failed to handle dates for page %q: %s", p.Path(), err) + return tinfo, true, nil +} + +// Must be run after the site section tree etc. is built and ready. +func (p *pageState) initPage() error { + if _, err := p.init.Do(context.Background()); err != nil { + return err } + return nil +} - var draft, published, isCJKLanguage *bool - for k, v := range frontmatter { - loki := strings.ToLower(k) +func (p *pageState) renderResources() error { + for _, r := range p.Resources() { - if loki == "published" { // Intentionally undocumented - vv, err := cast.ToBoolE(v) - if err == nil { - published = &vv + if _, ok := r.(page.Page); ok { + if p.s.h.buildCounter.Load() == 0 { + // Pages gets rendered with the owning page but we count them here. + p.s.PathSpec.ProcessingStats.Incr(&p.s.PathSpec.ProcessingStats.Pages) } - // published may also be a date continue } - if p.s.frontmatterHandler.IsDateKey(loki) { + if resources.IsPublished(r) { continue } - switch loki { - case "title": - p.title = cast.ToString(v) - p.params[loki] = p.title - case "linktitle": - p.linkTitle = cast.ToString(v) - p.params[loki] = p.linkTitle - case "description": - p.Description = cast.ToString(v) - p.params[loki] = p.Description - case "slug": - p.Slug = cast.ToString(v) - p.params[loki] = p.Slug - case "url": - if url := cast.ToString(v); strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") { - return fmt.Errorf("Only relative URLs are supported, %v provided", url) - } - p.URLPath.URL = cast.ToString(v) - p.frontMatterURL = p.URLPath.URL - p.params[loki] = p.URLPath.URL - case "type": - p.contentType = cast.ToString(v) - p.params[loki] = p.contentType - case "extension", "ext": - p.extension = cast.ToString(v) - p.params[loki] = p.extension - case "keywords": - p.Keywords = cast.ToStringSlice(v) - p.params[loki] = p.Keywords - case "headless": - // For now, only the leaf bundles ("index.md") can be headless (i.e. produce no output). - // We may expand on this in the future, but that gets more complex pretty fast. - if p.TranslationBaseName() == "index" { - p.headless = cast.ToBool(v) - } - p.params[loki] = p.headless - case "outputs": - o := cast.ToStringSlice(v) - if len(o) > 0 { - // Output formats are exlicitly set in front matter, use those. - outFormats, err := p.s.outputFormatsConfig.GetByNames(o...) - - if err != nil { - p.s.Log.ERROR.Printf("Failed to resolve output formats: %s", err) - } else { - p.outputFormats = outFormats - p.params[loki] = outFormats - } - - } - case "draft": - draft = new(bool) - *draft = cast.ToBool(v) - case "layout": - p.Layout = cast.ToString(v) - p.params[loki] = p.Layout - case "markup": - p.Markup = cast.ToString(v) - p.params[loki] = p.Markup - case "weight": - p.Weight = cast.ToInt(v) - p.params[loki] = p.Weight - case "aliases": - p.Aliases = cast.ToStringSlice(v) - for _, alias := range p.Aliases { - if strings.HasPrefix(alias, "http://") || strings.HasPrefix(alias, "https://") { - return fmt.Errorf("Only relative aliases are supported, %v provided", alias) - } - } - p.params[loki] = p.Aliases - case "status": - p.Status = cast.ToString(v) - p.params[loki] = p.Status - case "sitemap": - p.Sitemap = parseSitemap(cast.ToStringMap(v)) - p.params[loki] = p.Sitemap - case "iscjklanguage": - isCJKLanguage = new(bool) - *isCJKLanguage = cast.ToBool(v) - case "translationkey": - p.translationKey = cast.ToString(v) - p.params[loki] = p.translationKey - case "resources": - var resources []map[string]interface{} - handled := true - - switch vv := v.(type) { - case []map[interface{}]interface{}: - for _, vvv := range vv { - resources = append(resources, cast.ToStringMap(vvv)) - } - case []map[string]interface{}: - for _, vvv := range vv { - resources = append(resources, vvv) - } - case []interface{}: - for _, vvv := range vv { - switch vvvv := vvv.(type) { - case map[interface{}]interface{}: - resources = append(resources, cast.ToStringMap(vvvv)) - case map[string]interface{}: - resources = append(resources, vvvv) - } - } - default: - handled = false - } - - if handled { - p.params[loki] = resources - p.resourcesMetadata = resources - break - } - fallthrough - - default: - // If not one of the explicit values, store in Params - switch vv := v.(type) { - case bool: - p.params[loki] = vv - case string: - p.params[loki] = vv - case int64, int32, int16, int8, int: - p.params[loki] = vv - case float64, float32: - p.params[loki] = vv - case time.Time: - p.params[loki] = vv - default: // handle array of strings as well - switch vvv := vv.(type) { - case []interface{}: - if len(vvv) > 0 { - switch vvv[0].(type) { - case map[interface{}]interface{}: // Proper parsing structured array from YAML based FrontMatter - p.params[loki] = vvv - case map[string]interface{}: // Proper parsing structured array from JSON based FrontMatter - p.params[loki] = vvv - case []interface{}: - p.params[loki] = vvv - default: - a := make([]string, len(vvv)) - for i, u := range vvv { - a[i] = cast.ToString(u) - } - - p.params[loki] = a - } - } else { - p.params[loki] = []string{} - } - default: - p.params[loki] = vv - } - } + src, ok := r.(resource.Source) + if !ok { + return fmt.Errorf("resource %T does not support resource.Source", r) } - } - if draft != nil && published != nil { - p.Draft = *draft - p.s.Log.ERROR.Printf("page %s has both draft and published settings in its frontmatter. Using draft.", p.File.Path()) - return ErrHasDraftAndPublished - } else if draft != nil { - p.Draft = *draft - } else if published != nil { - p.Draft = !*published - } - p.params["draft"] = p.Draft - - if isCJKLanguage != nil { - p.isCJKLanguage = *isCJKLanguage - } else if p.s.Cfg.GetBool("hasCJKLanguage") { - if cjk.Match(p.rawContent) { - p.isCJKLanguage = true + if err := src.Publish(); err != nil { + if !herrors.IsNotExist(err) { + p.s.Log.Errorf("Failed to publish Resource for page %q: %s", p.pathOrTitle(), err) + } } else { - p.isCJKLanguage = false + p.s.PathSpec.ProcessingStats.Incr(&p.s.PathSpec.ProcessingStats.Files) } } - p.params["iscjklanguage"] = p.isCJKLanguage return nil } -func (p *Page) GetParam(key string) interface{} { - return p.getParam(key, false) +func (p *pageState) AlternativeOutputFormats() page.OutputFormats { + f := p.outputFormat() + var o page.OutputFormats + for _, of := range p.OutputFormats() { + if of.Format.NotAlternative || of.Format.Name == f.Name { + continue + } + + o = append(o, of) + } + return o } -func (p *Page) getParamToLower(key string) interface{} { - return p.getParam(key, true) +type renderStringOpts struct { + Display string + Markup string } -func (p *Page) getParam(key string, stringToLower bool) interface{} { - v := p.params[strings.ToLower(key)] - - if v == nil { - return nil - } - - switch val := v.(type) { - case bool: - return val - case string: - if stringToLower { - return strings.ToLower(val) - } - return val - case int64, int32, int16, int8, int: - return cast.ToInt(v) - case float64, float32: - return cast.ToFloat64(v) - case time.Time: - return val - case []string: - if stringToLower { - return helpers.SliceToLower(val) - } - return v - case map[string]interface{}: // JSON and TOML - return v - case map[interface{}]interface{}: // YAML - return v - } - - p.s.Log.ERROR.Printf("GetParam(\"%s\"): Unknown type %s\n", key, reflect.TypeOf(v)) - return nil +var defaultRenderStringOpts = renderStringOpts{ + Display: "inline", + Markup: "", // Will inherit the page's value when not set. } -func (p *Page) HasMenuCurrent(menuID string, me *MenuEntry) bool { - - sectionPagesMenu := p.Site.sectionPagesMenu - - // page is labeled as "shadow-member" of the menu with the same identifier as the section - if sectionPagesMenu != "" { - section := p.Section() - - if section != "" && sectionPagesMenu == menuID && section == me.Identifier { - return true - } +func (p *pageMeta) wrapError(err error, sourceFs afero.Fs) error { + if err == nil { + panic("wrapError with nil") } - if !me.HasChildren() { - return false + if p.File() == nil { + // No more details to add. + return fmt.Errorf("%q: %w", p.Path(), err) } - menus := p.Menus() - - if m, ok := menus[menuID]; ok { - - for _, child := range me.Children { - if child.IsEqual(m) { - return true - } - if p.HasMenuCurrent(menuID, child) { - return true - } - } - - } - - if p.IsPage() { - return false - } - - // The following logic is kept from back when Hugo had both Page and Node types. - // TODO(bep) consolidate / clean - nme := MenuEntry{Page: p, Name: p.title, URL: p.URL()} - - for _, child := range me.Children { - if nme.IsSameResource(child) { - return true - } - if p.HasMenuCurrent(menuID, child) { - return true - } - } - - return false - + return hugofs.AddFileInfoToError(err, p.File().FileInfo(), sourceFs) } -func (p *Page) IsMenuCurrent(menuID string, inme *MenuEntry) bool { - - menus := p.Menus() - - if me, ok := menus[menuID]; ok { - if me.IsEqual(inme) { - return true - } - } - - if p.IsPage() { - return false - } - - // The following logic is kept from back when Hugo had both Page and Node types. - // TODO(bep) consolidate / clean - me := MenuEntry{Page: p, Name: p.title, URL: p.URL()} - - if !me.IsSameResource(inme) { - 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 - if menu, ok := (*p.Site.Menus)[menuID]; ok { - for _, menuEntry := range *menu { - if menuEntry.IsSameResource(inme) { - return true - } - - descendantFound := p.isSameAsDescendantMenu(inme, menuEntry) - if descendantFound { - return descendantFound - } - - } - } - - return false +// wrapError adds some more context to the given error if possible/needed +func (p *pageState) wrapError(err error) error { + return p.m.wrapError(err, p.s.h.SourceFs) } -func (p *Page) isSameAsDescendantMenu(inme *MenuEntry, parent *MenuEntry) bool { - if parent.HasChildren() { - for _, child := range parent.Children { - if child.IsSameResource(inme) { - return true - } - descendantFound := p.isSameAsDescendantMenu(inme, child) - if descendantFound { - return descendantFound - } - } +func (p *pageState) getPageInfoForError() string { + s := fmt.Sprintf("kind: %q, path: %q", p.Kind(), p.Path()) + if p.File() != nil { + s += fmt.Sprintf(", file: %q", p.File().Filename()) } - return false + return s } -func (p *Page) Menus() PageMenus { - p.pageMenusInit.Do(func() { - p.pageMenus = PageMenus{} - - if ms, ok := p.params["menu"]; ok { - link := p.RelPermalink() - - me := MenuEntry{Page: p, Name: p.LinkTitle(), Weight: p.Weight, URL: link} - - // Could be the name of the menu to attach it to - mname, err := cast.ToStringE(ms) - - if err == nil { - me.Menu = mname - p.pageMenus[mname] = &me - return - } - - // Could be a slice of strings - mnames, err := cast.ToStringSliceE(ms) - - if err == nil { - for _, mname := range mnames { - me.Menu = mname - p.pageMenus[mname] = &me - } - return - } - - // Could be a structured menu entry - menus, err := cast.ToStringMapE(ms) - - if err != nil { - p.s.Log.ERROR.Printf("unable to process menus for %q\n", p.title) - } - - for name, menu := range menus { - menuEntry := MenuEntry{Page: p, Name: p.LinkTitle(), URL: link, Weight: p.Weight, Menu: name} - if menu != nil { - p.s.Log.DEBUG.Printf("found menu: %q, in %q\n", name, p.title) - ime, err := cast.ToStringMapE(menu) - if err != nil { - p.s.Log.ERROR.Printf("unable to process menus for %q: %s", p.title, err) - } - - menuEntry.marshallMap(ime) - } - p.pageMenus[name] = &menuEntry - - } +func (p *pageState) getContentConverter() converter.Converter { + var err error + p.contentConverterInit.Do(func() { + if p.m.pageConfig.ContentMediaType.IsZero() { + panic("ContentMediaType not set") } + markup := p.m.pageConfig.ContentMediaType.SubType + + if markup == "html" { + // Only used for shortcode inner content. + markup = "markdown" + } + p.contentConverter, err = p.m.newContentConverter(p, markup) }) - return p.pageMenus -} - -func (p *Page) shouldRenderTo(f output.Format) bool { - _, found := p.outputFormats.GetByName(f.Name) - return found -} - -func (p *Page) determineMarkupType() string { - // Try markup explicitly set in the frontmatter - p.Markup = helpers.GuessType(p.Markup) - if p.Markup == "unknown" { - // Fall back to file extension (might also return "unknown") - p.Markup = helpers.GuessType(p.Source.Ext()) - } - - return p.Markup -} - -func (p *Page) parse(reader io.Reader) error { - psr, err := parser.ReadFrom(reader) if err != nil { + p.s.Log.Errorln("Failed to create content converter:", err) + } + return p.contentConverter +} + +func (p *pageState) errorf(err error, format string, a ...any) error { + if herrors.UnwrapFileError(err) != nil { + // More isn't always better. return err } - - p.renderable = psr.IsRenderable() - p.frontmatter = psr.FrontMatter() - p.rawContent = psr.Content() - p.lang = p.Source.File.Lang() - - meta, err := psr.Metadata() - if err != nil { - return fmt.Errorf("failed to parse page metadata for %q: %s", p.File.Path(), err) + args := append([]any{p.Language().Lang, p.pathOrTitle()}, a...) + args = append(args, err) + format = "[%s] page %q: " + format + ": %w" + if err == nil { + return fmt.Errorf(format, args...) } - if meta == nil { - // missing frontmatter equivalent to empty frontmatter - meta = map[string]interface{}{} - } - - if p.s != nil && p.s.owner != nil { - gi, enabled := p.s.owner.gitInfo.forPage(p) - if gi != nil { - p.GitInfo = gi - } else if enabled { - p.s.Log.WARN.Printf("Failed to find GitInfo for page %q", p.Path()) - } - } - - return p.update(meta) + return fmt.Errorf(format, args...) } -func (p *Page) RawContent() string { - return string(p.rawContent) +func (p *pageState) outputFormat() (f output.Format) { + if p.pageOutput == nil { + panic("no pageOutput") + } + return p.pageOutput.f } -func (p *Page) SetSourceContent(content []byte) { - p.Source.Content = content +func (p *pageState) parseError(err error, input []byte, offset int) error { + pos := posFromInput("", input, offset) + return herrors.NewFileErrorFromName(err, p.File().Filename()).UpdatePosition(pos) } -func (p *Page) SetSourceMetaData(in interface{}, mark rune) (err error) { - // See https://github.com/gohugoio/hugo/issues/2458 - defer func() { - if r := recover(); r != nil { - var ok bool - err, ok = r.(error) - if !ok { - err = fmt.Errorf("error from marshal: %v", r) - } - } - }() - - buf := bp.GetBuffer() - defer bp.PutBuffer(buf) - - err = parser.InterfaceToFrontMatter(in, mark, buf) - if err != nil { - return +func (p *pageState) pathOrTitle() string { + if p.File() != nil { + return p.File().Filename() } - _, err = buf.WriteRune('\n') - if err != nil { - return - } - - p.Source.Frontmatter = buf.Bytes() - - return -} - -func (p *Page) SafeSaveSourceAs(path string) error { - return p.saveSourceAs(path, true) -} - -func (p *Page) SaveSourceAs(path string) error { - return p.saveSourceAs(path, false) -} - -func (p *Page) saveSourceAs(path string, safe bool) error { - b := bp.GetBuffer() - defer bp.PutBuffer(b) - - b.Write(p.Source.Frontmatter) - b.Write(p.Source.Content) - - bc := make([]byte, b.Len(), b.Len()) - copy(bc, b.Bytes()) - - return p.saveSource(bc, path, safe) -} - -func (p *Page) saveSource(by []byte, inpath string, safe bool) (err error) { - if !filepath.IsAbs(inpath) { - inpath = p.s.PathSpec.AbsPathify(inpath) - } - p.s.Log.INFO.Println("creating", inpath) - if safe { - err = helpers.SafeWriteToDisk(inpath, bytes.NewReader(by), p.s.Fs.Source) - } else { - err = helpers.WriteToDisk(inpath, bytes.NewReader(by), p.s.Fs.Source) - } - if err != nil { - return - } - return nil -} - -func (p *Page) SaveSource() error { - return p.SaveSourceAs(p.FullFilePath()) -} - -func (p *Page) processShortcodes() error { - p.shortcodeState = newShortcodeHandler(p) - tmpContent, err := p.shortcodeState.extractShortcodes(string(p.workContent), p) - if err != nil { - return err - } - p.workContent = []byte(tmpContent) - - return nil - -} - -func (p *Page) FullFilePath() string { - return filepath.Join(p.Dir(), p.LogicalName()) -} - -// Pre render prepare steps - -func (p *Page) prepareLayouts() error { - // TODO(bep): Check the IsRenderable logic. - if p.Kind == KindPage { - if !p.IsRenderable() { - self := "__" + p.UniqueID() - err := p.s.TemplateHandler().AddLateTemplate(self, string(p.Content)) - if err != nil { - return err - } - p.selfLayout = self - } - } - - return nil -} - -func (p *Page) prepareData(s *Site) error { - if p.Kind != KindSection { - var pages Pages - p.Data = make(map[string]interface{}) - - switch p.Kind { - case KindPage: - case KindHome: - pages = s.RegularPages - case KindTaxonomy: - plural := p.sections[0] - term := p.sections[1] - - if s.Info.preserveTaxonomyNames { - if v, ok := s.taxonomiesOrigKey[fmt.Sprintf("%s-%s", plural, term)]; ok { - term = v - } - } - - singular := s.taxonomiesPluralSingular[plural] - taxonomy := s.Taxonomies[plural].Get(term) - - p.Data[singular] = taxonomy - p.Data["Singular"] = singular - p.Data["Plural"] = plural - p.Data["Term"] = term - pages = taxonomy.Pages() - case KindTaxonomyTerm: - plural := p.sections[0] - singular := s.taxonomiesPluralSingular[plural] - - p.Data["Singular"] = singular - p.Data["Plural"] = plural - p.Data["Terms"] = s.Taxonomies[plural] - // keep the following just for legacy reasons - p.Data["OrderedIndex"] = p.Data["Terms"] - p.Data["Index"] = p.Data["Terms"] - - // A list of all KindTaxonomy pages with matching plural - for _, p := range s.findPagesByKind(KindTaxonomy) { - if p.sections[0] == plural { - pages = append(pages, p) - } - } - } - - p.Data["Pages"] = pages - p.Pages = pages - } - - // Now we know enough to set missing dates on home page etc. - p.updatePageDates() - - return nil -} - -func (p *Page) updatePageDates() { - // TODO(bep) there is a potential issue with page sorting for home pages - // etc. without front matter dates set, but let us wrap the head around - // that in another time. - if !p.IsNode() { - return - } - - if !p.Date.IsZero() { - if p.Lastmod.IsZero() { - p.Lastmod = p.Date - } - return - } else if !p.Lastmod.IsZero() { - if p.Date.IsZero() { - p.Date = p.Lastmod - } - return - } - - // Set it to the first non Zero date in children - var foundDate, foundLastMod bool - - for _, child := range p.Pages { - if !child.Date.IsZero() { - p.Date = child.Date - foundDate = true - } - if !child.Lastmod.IsZero() { - p.Lastmod = child.Lastmod - foundLastMod = true - } - - if foundDate && foundLastMod { - break - } - } -} - -// copy creates a copy of this page with the lazy sync.Once vars reset -// so they will be evaluated again, for word count calculations etc. -func (p *Page) copy() *Page { - c := *p - c.pageInit = &pageInit{} - return &c -} - -func (p *Page) Hugo() *HugoInfo { - return hugoInfo -} - -func (p *Page) Ref(refs ...string) (string, error) { - if len(refs) == 0 { - return "", nil - } - if len(refs) > 1 { - return p.Site.Ref(refs[0], nil, refs[1]) - } - return p.Site.Ref(refs[0], nil) -} - -func (p *Page) RelRef(refs ...string) (string, error) { - if len(refs) == 0 { - return "", nil - } - if len(refs) > 1 { - return p.Site.RelRef(refs[0], nil, refs[1]) - } - return p.Site.RelRef(refs[0], nil) -} - -func (p *Page) String() string { - return fmt.Sprintf("Page(%q)", p.title) -} - -// Scratch returns the writable context associated with this Page. -func (p *Page) Scratch() *Scratch { - if p.scratch == nil { - p.scratch = newScratch() - } - return p.scratch -} - -func (p *Page) Language() *helpers.Language { - p.initLanguage() - return p.language -} - -func (p *Page) Lang() string { - // When set, Language can be different from lang in the case where there is a - // content file (doc.sv.md) with language indicator, but there is no language - // config for that language. Then the language will fall back on the site default. - if p.Language() != nil { - return p.Language().Lang - } - return p.lang -} - -func (p *Page) isNewTranslation(candidate *Page) bool { - - if p.Kind != candidate.Kind { - return false - } - - if p.Kind == KindPage || p.Kind == kindUnknown { - panic("Node type not currently supported for this op") - } - - // At this point, we know that this is a traditional Node (home page, section, taxonomy) - // It represents the same node, but different language, if the sections is the same. - if len(p.sections) != len(candidate.sections) { - return false - } - - for i := 0; i < len(p.sections); i++ { - if p.sections[i] != candidate.sections[i] { - return false - } - } - - // Finally check that it is not already added. - for _, translation := range p.translations { - if candidate == translation { - return false - } - } - - return true - -} - -func (p *Page) shouldAddLanguagePrefix() bool { - if !p.Site.IsMultiLingual() { - return false - } - - if p.s.owner.IsMultihost() { - return true - } - - if p.Lang() == "" { - return false - } - - if !p.Site.defaultContentLanguageInSubdir && p.Lang() == p.Site.multilingual.DefaultLang.Lang { - return false - } - - return true -} - -func (p *Page) initLanguage() { - p.languageInit.Do(func() { - if p.language != nil { - return - } - - ml := p.Site.multilingual - if ml == nil { - panic("Multilanguage not set") - } - if p.lang == "" { - p.lang = ml.DefaultLang.Lang - p.language = ml.DefaultLang - return - } - - language := ml.Language(p.lang) - - if language == nil { - // It can be a file named stefano.chiodino.md. - p.s.Log.WARN.Printf("Page language (if it is that) not found in multilang setup: %s.", p.lang) - language = ml.DefaultLang - } - - p.language = language - - }) -} - -func (p *Page) LanguagePrefix() string { - return p.Site.LanguagePrefix -} - -func (p *Page) addLangPathPrefixIfFlagSet(outfile string, should bool) string { - if helpers.IsAbsURL(outfile) { - return outfile - } - - if !should { - return outfile - } - - hadSlashSuffix := strings.HasSuffix(outfile, "/") - - outfile = "/" + path.Join(p.Lang(), outfile) - if hadSlashSuffix { - outfile += "/" - } - return outfile -} - -func sectionsFromFile(fi *fileInfo) []string { - dirname := fi.Dir() - dirname = strings.Trim(dirname, helpers.FilePathSeparator) - if dirname == "" { - return nil - } - parts := strings.Split(dirname, helpers.FilePathSeparator) - - if fi.bundleTp == bundleLeaf && len(parts) > 0 { - // my-section/mybundle/index.md => my-section - return parts[:len(parts)-1] - } - - return parts -} - -func kindFromFileInfo(fi *fileInfo) string { - if fi.TranslationBaseName() == "_index" { - if fi.Dir() == "" { - return KindHome - } - // Could be index for section, taxonomy, taxonomy term - // We don't know enough yet to determine which - return kindUnknown - } - return KindPage -} - -func (p *Page) setValuesForKind(s *Site) { - if p.Kind == kindUnknown { - // This is either a taxonomy list, taxonomy term or a section - nodeType := s.kindFromSections(p.sections) - - if nodeType == kindUnknown { - panic(fmt.Sprintf("Unable to determine page kind from %q", p.sections)) - } - - p.Kind = nodeType - } - - switch p.Kind { - case KindHome: - p.URLPath.URL = "/" - case KindPage: - default: - if p.URLPath.URL == "" { - p.URLPath.URL = "/" + path.Join(p.sections...) + "/" - } - } -} - -// Used in error logs. -func (p *Page) pathOrTitle() string { if p.Path() != "" { return p.Path() } - return p.title + + return p.Title() +} + +func (p *pageState) posFromInput(input []byte, offset int) text.Position { + return posFromInput(p.pathOrTitle(), input, offset) +} + +func (p *pageState) posOffset(offset int) text.Position { + return p.posFromInput(p.m.content.mustSource(), offset) +} + +// shiftToOutputFormat is serialized. The output format idx refers to the +// full set of output formats for all sites. +// This is serialized. +func (p *pageState) shiftToOutputFormat(isRenderingSite bool, idx int) error { + if err := p.initPage(); err != nil { + return err + } + + if len(p.pageOutputs) == 1 { + idx = 0 + } + + p.pageOutputIdx = idx + p.pageOutput = p.pageOutputs[idx] + if p.pageOutput == nil { + panic(fmt.Sprintf("pageOutput is nil for output idx %d", idx)) + } + + // Reset any built paginator. This will trigger when re-rendering pages in + // server mode. + if isRenderingSite && p.pageOutput.paginator != nil && p.pageOutput.paginator.current != nil { + p.pageOutput.paginator.reset() + } + + if isRenderingSite { + cp := p.pageOutput.pco + if cp == nil && p.canReusePageOutputContent() { + // Look for content to reuse. + for i := range p.pageOutputs { + if i == idx { + continue + } + po := p.pageOutputs[i] + + if po.pco != nil { + cp = po.pco + break + } + } + } + + if cp == nil { + var err error + cp, err = newPageContentOutput(p.pageOutput) + if err != nil { + return err + } + } + p.pageOutput.setContentProvider(cp) + } else { + // We attempt to assign pageContentOutputs while preparing each site + // for rendering and before rendering each site. This lets us share + // content between page outputs to conserve resources. But if a template + // unexpectedly calls a method of a ContentProvider that is not yet + // initialized, we assign a LazyContentProvider that performs the + // initialization just in time. + if lcp, ok := (p.pageOutput.ContentProvider.(*page.LazyContentProvider)); ok { + lcp.Reset() + } else { + lcp = page.NewLazyContentProvider(func() (page.OutputFormatContentProvider, error) { + cp, err := newPageContentOutput(p.pageOutput) + if err != nil { + return nil, err + } + return cp, nil + }) + p.pageOutput.contentRenderer = lcp + p.pageOutput.ContentProvider = lcp + p.pageOutput.MarkupProvider = lcp + p.pageOutput.PageRenderProvider = lcp + p.pageOutput.TableOfContentsProvider = lcp + } + } + + return nil +} + +var ( + _ page.Page = (*pageWithOrdinal)(nil) + _ collections.Order = (*pageWithOrdinal)(nil) + _ pageWrapper = (*pageWithOrdinal)(nil) +) + +type pageWithOrdinal struct { + ordinal int + *pageState +} + +func (p pageWithOrdinal) Ordinal() int { + return p.ordinal +} + +func (p pageWithOrdinal) page() page.Page { + return p.pageState +} + +type pageWithWeight0 struct { + weight0 int + *pageState +} + +func (p pageWithWeight0) Weight0() int { + return p.weight0 +} + +func (p pageWithWeight0) page() page.Page { + return p.pageState +} + +var _ types.Unwrapper = (*pageWithWeight0)(nil) + +func (p pageWithWeight0) Unwrapv() any { + return p.pageState } diff --git a/hugolib/pageCache.go b/hugolib/pageCache.go deleted file mode 100644 index 2ac584920..000000000 --- a/hugolib/pageCache.go +++ /dev/null @@ -1,138 +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 hugolib - -import ( - "sync" -) - -type pageCacheEntry struct { - in []Pages - out Pages -} - -func (entry pageCacheEntry) matches(pageLists []Pages) bool { - if len(entry.in) != len(pageLists) { - return false - } - for i, p := range pageLists { - if !fastEqualPages(p, entry.in[i]) { - return false - } - } - - return true -} - -type pageCache struct { - sync.RWMutex - m map[string][]pageCacheEntry -} - -func newPageCache() *pageCache { - return &pageCache{m: make(map[string][]pageCacheEntry)} -} - -// get/getP gets a Pages slice from the cache matching the given key and -// all the provided Pages slices. -// If none found in cache, a copy of the first slice is created. -// -// If an apply func is provided, that func is applied to the newly created copy. -// -// The getP variant' apply func takes a pointer to Pages. -// -// The cache and the execution of the apply func is protected by a RWMutex. -func (c *pageCache) get(key string, apply func(p Pages), pageLists ...Pages) (Pages, bool) { - return c.getP(key, func(p *Pages) { - if apply != nil { - apply(*p) - } - }, pageLists...) -} - -func (c *pageCache) getP(key string, apply func(p *Pages), pageLists ...Pages) (Pages, bool) { - c.RLock() - if cached, ok := c.m[key]; ok { - for _, entry := range cached { - if entry.matches(pageLists) { - c.RUnlock() - return entry.out, true - } - } - } - c.RUnlock() - - c.Lock() - defer c.Unlock() - - // double-check - if cached, ok := c.m[key]; ok { - for _, entry := range cached { - if entry.matches(pageLists) { - return entry.out, true - } - } - } - - p := pageLists[0] - pagesCopy := append(Pages(nil), p...) - - if apply != nil { - apply(&pagesCopy) - } - - entry := pageCacheEntry{in: pageLists, out: pagesCopy} - if v, ok := c.m[key]; ok { - c.m[key] = append(v, entry) - } else { - c.m[key] = []pageCacheEntry{entry} - } - - return pagesCopy, false - -} - -// "fast" as in: we do not compare every element for big slices, but that is -// good enough for our use cases. -// TODO(bep) there is a similar method in pagination.go. DRY. -func fastEqualPages(p1, p2 Pages) bool { - if p1 == nil && p2 == nil { - return true - } - - if p1 == nil || p2 == nil { - return false - } - - if p1.Len() != p2.Len() { - return false - } - - if p1.Len() == 0 { - return true - } - - step := 1 - - if len(p1) >= 50 { - step = len(p1) / 10 - } - - for i := 0; i < len(p1); i += step { - if p1[i] != p2[i] { - return false - } - } - return true -} diff --git a/hugolib/pageCache_test.go b/hugolib/pageCache_test.go deleted file mode 100644 index 52a7f4494..000000000 --- a/hugolib/pageCache_test.go +++ /dev/null @@ -1,88 +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 hugolib - -import ( - "strconv" - "sync" - "sync/atomic" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestPageCache(t *testing.T) { - t.Parallel() - c1 := newPageCache() - - changeFirst := func(p Pages) { - p[0].Description = "changed" - } - - var o1 uint64 - var o2 uint64 - - var wg sync.WaitGroup - - var l1 sync.Mutex - var l2 sync.Mutex - - var testPageSets []Pages - - s := newTestSite(t) - - for i := 0; i < 50; i++ { - testPageSets = append(testPageSets, createSortTestPages(s, i+1)) - } - - for j := 0; j < 100; j++ { - wg.Add(1) - go func() { - defer wg.Done() - for k, pages := range testPageSets { - l1.Lock() - p, c := c1.get("k1", nil, pages) - assert.Equal(t, !atomic.CompareAndSwapUint64(&o1, uint64(k), uint64(k+1)), c) - l1.Unlock() - p2, c2 := c1.get("k1", nil, p) - assert.True(t, c2) - assert.True(t, fastEqualPages(p, p2)) - assert.True(t, fastEqualPages(p, pages)) - assert.NotNil(t, p) - - l2.Lock() - p3, c3 := c1.get("k2", changeFirst, pages) - assert.Equal(t, !atomic.CompareAndSwapUint64(&o2, uint64(k), uint64(k+1)), c3) - l2.Unlock() - assert.NotNil(t, p3) - assert.Equal(t, p3[0].Description, "changed") - } - }() - } - wg.Wait() -} - -func BenchmarkPageCache(b *testing.B) { - cache := newPageCache() - pages := make(Pages, 30) - for i := 0; i < 30; i++ { - pages[i] = &Page{title: "p" + strconv.Itoa(i)} - } - key := "key" - - b.ResetTimer() - for i := 0; i < b.N; i++ { - cache.getP(key, nil, pages) - } -} diff --git a/hugolib/pageGroup.go b/hugolib/pageGroup.go deleted file mode 100644 index 8aaa1018c..000000000 --- a/hugolib/pageGroup.go +++ /dev/null @@ -1,298 +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 hugolib - -import ( - "errors" - "reflect" - "sort" - "strings" - "time" -) - -// PageGroup represents a group of pages, grouped by the key. -// The key is typically a year or similar. -type PageGroup struct { - Key interface{} - Pages -} - -type mapKeyValues []reflect.Value - -func (v mapKeyValues) Len() int { return len(v) } -func (v mapKeyValues) Swap(i, j int) { v[i], v[j] = v[j], v[i] } - -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 s.mapKeyValues[i].String() < s.mapKeyValues[j].String() -} - -func sortKeys(v []reflect.Value, order string) []reflect.Value { - if len(v) <= 1 { - return v - } - - switch v[0].Kind() { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - if order == "desc" { - sort.Sort(sort.Reverse(mapKeyByInt{v})) - } else { - sort.Sort(mapKeyByInt{v}) - } - case reflect.String: - if order == "desc" { - sort.Sort(sort.Reverse(mapKeyByStr{v})) - } else { - sort.Sort(mapKeyByStr{v}) - } - } - return v -} - -// PagesGroup represents a list of page groups. -// This is what you get when doing page grouping in the templates. -type PagesGroup []PageGroup - -// Reverse reverses the order of this list of page groups. -func (p PagesGroup) Reverse() PagesGroup { - for i, j := 0, len(p)-1; i < j; i, j = i+1, j-1 { - p[i], p[j] = p[j], p[i] - } - - return p -} - -var ( - errorType = reflect.TypeOf((*error)(nil)).Elem() - pagePtrType = reflect.TypeOf((*Page)(nil)) -) - -// 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) { - if len(p) < 1 { - return nil, nil - } - - direction := "asc" - - if len(order) > 0 && (strings.ToLower(order[0]) == "desc" || strings.ToLower(order[0]) == "rev" || strings.ToLower(order[0]) == "reverse") { - direction = "desc" - } - - var ft interface{} - m, ok := pagePtrType.MethodByName(key) - if ok { - if m.Type.NumIn() != 1 || 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") - } - if m.Type.NumOut() == 1 && m.Type.Out(0).Implements(errorType) { - return nil, errors.New(key + " is a Page method but you can't use it with GroupBy") - } - if m.Type.NumOut() == 2 && !m.Type.Out(1).Implements(errorType) { - return nil, errors.New(key + " is a Page method but you can't use it with GroupBy") - } - ft = m - } else { - ft, ok = pagePtrType.Elem().FieldByName(key) - if !ok { - return nil, errors.New(key + " is neither a field nor a method of Page") - } - } - - var tmp reflect.Value - switch e := ft.(type) { - case reflect.StructField: - tmp = reflect.MakeMap(reflect.MapOf(e.Type, reflect.SliceOf(pagePtrType))) - case reflect.Method: - tmp = reflect.MakeMap(reflect.MapOf(e.Type.Out(0), reflect.SliceOf(pagePtrType))) - } - - for _, e := range p { - ppv := reflect.ValueOf(e) - var fv reflect.Value - switch ft.(type) { - case reflect.StructField: - fv = ppv.Elem().FieldByName(key) - case reflect.Method: - fv = ppv.MethodByName(key).Call([]reflect.Value{})[0] - } - if !fv.IsValid() { - continue - } - if !tmp.MapIndex(fv).IsValid() { - tmp.SetMapIndex(fv, reflect.MakeSlice(reflect.SliceOf(pagePtrType), 0, 0)) - } - tmp.SetMapIndex(fv, reflect.Append(tmp.MapIndex(fv), ppv)) - } - - sortedKeys := sortKeys(tmp.MapKeys(), direction) - r := make([]PageGroup, len(sortedKeys)) - for i, k := range sortedKeys { - r[i] = PageGroup{Key: k.Interface(), Pages: tmp.MapIndex(k).Interface().([]*Page)} - } - - return r, nil -} - -// GroupByParam groups by the given page parameter key's value and with the given order. -// Valid values for order is asc, desc, rev and reverse. -func (p Pages) GroupByParam(key string, order ...string) (PagesGroup, error) { - if len(p) < 1 { - return nil, nil - } - - direction := "asc" - - if len(order) > 0 && (strings.ToLower(order[0]) == "desc" || strings.ToLower(order[0]) == "rev" || strings.ToLower(order[0]) == "reverse") { - direction = "desc" - } - - var tmp reflect.Value - var keyt reflect.Type - for _, e := range p { - param := e.getParamToLower(key) - if param != nil { - if _, ok := param.([]string); !ok { - keyt = reflect.TypeOf(param) - tmp = reflect.MakeMap(reflect.MapOf(keyt, reflect.SliceOf(pagePtrType))) - break - } - } - } - if !tmp.IsValid() { - return nil, errors.New("There is no such a param") - } - - for _, e := range p { - param := e.getParam(key, false) - if param == nil || reflect.TypeOf(param) != keyt { - continue - } - v := reflect.ValueOf(param) - if !tmp.MapIndex(v).IsValid() { - tmp.SetMapIndex(v, reflect.MakeSlice(reflect.SliceOf(pagePtrType), 0, 0)) - } - tmp.SetMapIndex(v, reflect.Append(tmp.MapIndex(v), reflect.ValueOf(e))) - } - - var r []PageGroup - for _, k := range sortKeys(tmp.MapKeys(), direction) { - r = append(r, PageGroup{Key: k.Interface(), Pages: tmp.MapIndex(k).Interface().([]*Page)}) - } - - return r, nil -} - -func (p Pages) groupByDateField(sorter func(p Pages) Pages, formatter func(p *Page) string, order ...string) (PagesGroup, error) { - if len(p) < 1 { - return nil, nil - } - - sp := sorter(p) - - if !(len(order) > 0 && (strings.ToLower(order[0]) == "asc" || strings.ToLower(order[0]) == "rev" || strings.ToLower(order[0]) == "reverse")) { - sp = sp.Reverse() - } - - date := formatter(sp[0]) - var r []PageGroup - r = append(r, PageGroup{Key: date, Pages: make(Pages, 0)}) - r[0].Pages = append(r[0].Pages, sp[0]) - - i := 0 - for _, e := range sp[1:] { - date = formatter(e) - if r[i].Key.(string) != date { - r = append(r, PageGroup{Key: date}) - i++ - } - r[i].Pages = append(r[i].Pages, e) - } - return r, nil -} - -// GroupByDate groups by the given page's Date 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) 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) - } - return p.groupByDateField(sorter, formatter, order...) -} - -// GroupByPublishDate groups by the given page's PublishDate 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) GroupByPublishDate(format string, order ...string) (PagesGroup, error) { - sorter := func(p Pages) Pages { - return p.ByPublishDate() - } - formatter := func(p *Page) string { - return p.PublishDate.Format(format) - } - return p.groupByDateField(sorter, formatter, order...) -} - -// GroupByExpiryDate groups by the given page's ExpireDate 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) GroupByExpiryDate(format string, order ...string) (PagesGroup, error) { - sorter := func(p Pages) Pages { - return p.ByExpiryDate() - } - formatter := func(p *Page) string { - return p.ExpiryDate.Format(format) - } - return p.groupByDateField(sorter, formatter, order...) -} - -// GroupByParamDate groups by a date set as a param on the page 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) GroupByParamDate(key string, format string, order ...string) (PagesGroup, error) { - sorter := func(p Pages) Pages { - var r Pages - for _, e := range p { - param := e.getParamToLower(key) - if param != nil { - if _, ok := param.(time.Time); ok { - r = append(r, e) - } - } - } - pdate := func(p1, p2 *Page) bool { - return p1.getParamToLower(key).(time.Time).Unix() < p2.getParamToLower(key).(time.Time).Unix() - } - pageBy(pdate).Sort(r) - return r - } - formatter := func(p *Page) string { - return p.getParamToLower(key).(time.Time).Format(format) - } - return p.groupByDateField(sorter, formatter, order...) -} diff --git a/hugolib/pageGroup_test.go b/hugolib/pageGroup_test.go deleted file mode 100644 index d17e09f8b..000000000 --- a/hugolib/pageGroup_test.go +++ /dev/null @@ -1,457 +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 hugolib - -import ( - "errors" - "path/filepath" - "reflect" - "testing" - - "github.com/spf13/cast" -) - -type pageGroupTestObject struct { - path string - weight int - date string - param string -} - -var pageGroupTestSources = []pageGroupTestObject{ - {"/section1/testpage1.md", 3, "2012-04-06", "foo"}, - {"/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"}, -} - -func preparePageGroupTestPages(t *testing.T) Pages { - s := newTestSite(t) - var pages Pages - for _, src := range pageGroupTestSources { - p, err := s.NewPage(filepath.FromSlash(src.path)) - if err != nil { - t.Fatalf("failed to prepare test page %s", src.path) - } - p.Weight = src.weight - p.Date = cast.ToTime(src.date) - p.PublishDate = cast.ToTime(src.date) - p.ExpiryDate = cast.ToTime(src.date) - p.params["custom_param"] = src.param - p.params["custom_date"] = cast.ToTime(src.date) - pages = append(pages, p) - } - return pages -} - -func TestGroupByWithFieldNameArg(t *testing.T) { - t.Parallel() - pages := preparePageGroupTestPages(t) - expect := PagesGroup{ - {Key: 1, Pages: Pages{pages[3], pages[4]}}, - {Key: 2, Pages: Pages{pages[2]}}, - {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) - } -} - -func TestGroupByWithMethodNameArg(t *testing.T) { - t.Parallel() - pages := preparePageGroupTestPages(t) - expect := PagesGroup{ - {Key: "section1", Pages: Pages{pages[0], pages[1], pages[2]}}, - {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) - } -} - -func TestGroupByWithSectionArg(t *testing.T) { - t.Parallel() - pages := preparePageGroupTestPages(t) - expect := PagesGroup{ - {Key: "section1", Pages: Pages{pages[0], pages[1], pages[2]}}, - {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 %#v, got %#v", expect, groups) - } -} - -func TestGroupByInReverseOrder(t *testing.T) { - t.Parallel() - pages := preparePageGroupTestPages(t) - expect := PagesGroup{ - {Key: 3, Pages: Pages{pages[0], pages[1]}}, - {Key: 2, Pages: Pages{pages[2]}}, - {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) - } -} - -func TestGroupByCalledWithEmptyPages(t *testing.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 TestGroupByCalledWithUnavailableKey(t *testing.T) { - t.Parallel() - pages := preparePageGroupTestPages(t) - _, err := pages.GroupBy("UnavailableKey") - if err == nil { - t.Errorf("GroupByParam should return an error but didn't") - } -} - -func (page *Page) DummyPageMethodWithArgForTest(s string) string { - return s -} - -func (page *Page) DummyPageMethodReturnThreeValueForTest() (string, string, string) { - return "foo", "bar", "baz" -} - -func (page *Page) DummyPageMethodReturnErrorOnlyForTest() error { - return errors.New("some error occurred") -} - -func (page *Page) dummyPageMethodReturnTwoValueForTest() (string, string) { - return "foo", "bar" -} - -func TestGroupByCalledWithInvalidMethod(t *testing.T) { - t.Parallel() - var err error - pages := preparePageGroupTestPages(t) - - _, err = pages.GroupBy("DummyPageMethodWithArgForTest") - if err == nil { - t.Errorf("GroupByParam should return an error but didn't") - } - - _, err = pages.GroupBy("DummyPageMethodReturnThreeValueForTest") - if err == nil { - t.Errorf("GroupByParam should return an error but didn't") - } - - _, err = pages.GroupBy("DummyPageMethodReturnErrorOnlyForTest") - if err == nil { - t.Errorf("GroupByParam should return an error but didn't") - } - - _, err = pages.GroupBy("DummyPageMethodReturnTwoValueForTest") - if err == nil { - t.Errorf("GroupByParam should return an error but didn't") - } -} - -func TestReverse(t *testing.T) { - t.Parallel() - pages := preparePageGroupTestPages(t) - - groups1, err := pages.GroupBy("Weight", "desc") - if err != nil { - t.Fatalf("Unable to make PagesGroup array: %s", err) - } - - 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) - } -} - -func TestGroupByParam(t *testing.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_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) - } -} - -func TestGroupByParamInReverseOrder(t *testing.T) { - t.Parallel() - pages := preparePageGroupTestPages(t) - expect := PagesGroup{ - {Key: "foo", Pages: Pages{pages[0], pages[2]}}, - {Key: "baz", Pages: Pages{pages[4]}}, - {Key: "bar", Pages: Pages{pages[1], pages[3]}}, - } - - 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) - } -} - -func TestGroupByParamCalledWithCapitalLetterString(t *testing.T) { - testStr := "TestString" - f := "/section1/test_capital.md" - s := newTestSite(t) - p, err := s.NewPage(filepath.FromSlash(f)) - if err != nil { - t.Fatalf("failed to prepare test page %s", f) - } - p.params["custom_param"] = testStr - pages := Pages{p} - - groups, err := pages.GroupByParam("custom_param") - if err != nil { - t.Fatalf("Unable to make PagesGroup array: %s", err) - } - if groups[0].Key != testStr { - t.Errorf("PagesGroup key is converted to a lower character string. It should be %#v, got %#v", testStr, groups[0].Key) - } -} - -func TestGroupByParamCalledWithSomeUnavailableParams(t *testing.T) { - t.Parallel() - pages := preparePageGroupTestPages(t) - delete(pages[1].params, "custom_param") - delete(pages[3].params, "custom_param") - delete(pages[4].params, "custom_param") - - expect := PagesGroup{ - {Key: "foo", Pages: Pages{pages[0], pages[2]}}, - } - - 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) - } -} - -func TestGroupByParamCalledWithEmptyPages(t *testing.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) - } -} - -func TestGroupByParamCalledWithUnavailableParam(t *testing.T) { - t.Parallel() - pages := preparePageGroupTestPages(t) - _, err := pages.GroupByParam("unavailable_param") - if err == nil { - t.Errorf("GroupByParam should return an error but didn't") - } -} - -func TestGroupByDate(t *testing.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.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) - } -} - -func TestGroupByDateInReverseOrder(t *testing.T) { - t.Parallel() - pages := preparePageGroupTestPages(t) - expect := PagesGroup{ - {Key: "2012-01", Pages: Pages{pages[1]}}, - {Key: "2012-03", Pages: Pages{pages[3]}}, - {Key: "2012-04", Pages: Pages{pages[0], pages[2], pages[4]}}, - } - - 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) - } -} - -func TestGroupByPublishDate(t *testing.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.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) - } -} - -func TestGroupByPublishDateInReverseOrder(t *testing.T) { - t.Parallel() - pages := preparePageGroupTestPages(t) - expect := PagesGroup{ - {Key: "2012-01", Pages: Pages{pages[1]}}, - {Key: "2012-03", Pages: Pages{pages[3]}}, - {Key: "2012-04", Pages: Pages{pages[0], pages[2], pages[4]}}, - } - - 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) - } -} - -func TestGroupByPublishDateWithEmptyPages(t *testing.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) - } -} - -func TestGroupByExpiryDate(t *testing.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.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) - } -} - -func TestGroupByParamDate(t *testing.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_date", "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) - } -} - -func TestGroupByParamDateInReverseOrder(t *testing.T) { - t.Parallel() - pages := preparePageGroupTestPages(t) - expect := PagesGroup{ - {Key: "2012-01", Pages: Pages{pages[1]}}, - {Key: "2012-03", Pages: Pages{pages[3]}}, - {Key: "2012-04", Pages: Pages{pages[0], pages[2], pages[4]}}, - } - - 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) - } -} - -func TestGroupByParamDateWithEmptyPages(t *testing.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) - } -} diff --git a/hugolib/pageSort.go b/hugolib/pageSort.go deleted file mode 100644 index a9477059d..000000000 --- a/hugolib/pageSort.go +++ /dev/null @@ -1,304 +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 hugolib - -import ( - "sort" - - "github.com/spf13/cast" -) - -var spc = newPageCache() - -/* - * Implementation of a custom sorter for Pages - */ - -// A pageSorter implements the sort interface for Pages -type pageSorter struct { - pages Pages - by pageBy -} - -// pageBy is a closure used in the Sort.Less method. -type pageBy func(p1, p2 *Page) bool - -// Sort stable sorts the pages given the receiver's sort order. -func (by pageBy) Sort(pages Pages) { - ps := &pageSorter{ - pages: pages, - by: by, // The Sort method's receiver is the function (closure) that defines the sort order. - } - sort.Stable(ps) -} - -// defaultPageSort is the default sort for pages in Hugo: -// Order by Weight, Date, LinkTitle and then full file path. -var defaultPageSort = func(p1, p2 *Page) bool { - if p1.Weight == p2.Weight { - if p1.Date.Unix() == p2.Date.Unix() { - if p1.LinkTitle() == p2.LinkTitle() { - return (p1.FullFilePath() < p2.FullFilePath()) - } - return (p1.LinkTitle() < p2.LinkTitle()) - } - return p1.Date.Unix() > p2.Date.Unix() - } - - if p2.Weight == 0 { - return true - } - - if p1.Weight == 0 { - return false - } - - return p1.Weight < p2.Weight -} - -var languagePageSort = func(p1, p2 *Page) bool { - if p1.Language().Weight == p2.Language().Weight { - if p1.Date.Unix() == p2.Date.Unix() { - if p1.LinkTitle() == p2.LinkTitle() { - return (p1.FullFilePath() < p2.FullFilePath()) - } - return (p1.LinkTitle() < p2.LinkTitle()) - } - return p1.Date.Unix() > p2.Date.Unix() - } - - if p2.Language().Weight == 0 { - return true - } - - if p1.Language().Weight == 0 { - return false - } - - return p1.Language().Weight < p2.Language().Weight -} - -func (ps *pageSorter) Len() int { return len(ps.pages) } -func (ps *pageSorter) Swap(i, j int) { ps.pages[i], ps.pages[j] = ps.pages[j], ps.pages[i] } - -// Less is part of sort.Interface. It is implemented by calling the "by" closure in the sorter. -func (ps *pageSorter) Less(i, j int) bool { return ps.by(ps.pages[i], ps.pages[j]) } - -// Sort sorts the pages by the default sort order defined: -// Order by Weight, Date, LinkTitle and then full file path. -func (p Pages) Sort() { - pageBy(defaultPageSort).Sort(p) -} - -// Limit limits the number of pages returned to n. -func (p Pages) Limit(n int) Pages { - if len(p) > n { - return p[0:n] - } - return p -} - -// ByWeight sorts the Pages by weight and returns a copy. -// -// Adjacent invocations on the same receiver will return a cached result. -// -// This may safely be executed in parallel. -func (p Pages) ByWeight() Pages { - key := "pageSort.ByWeight" - pages, _ := spc.get(key, pageBy(defaultPageSort).Sort, p) - return pages -} - -// ByTitle sorts the Pages by title and returns a copy. -// -// Adjacent invocations on the same receiver will return a cached result. -// -// This may safely be executed in parallel. -func (p Pages) ByTitle() Pages { - - key := "pageSort.ByTitle" - - title := func(p1, p2 *Page) bool { - return p1.title < p2.title - } - - pages, _ := spc.get(key, pageBy(title).Sort, p) - return pages -} - -// ByLinkTitle sorts the Pages by link title and returns a copy. -// -// Adjacent invocations on the same receiver will return a cached result. -// -// This may safely be executed in parallel. -func (p Pages) ByLinkTitle() Pages { - - key := "pageSort.ByLinkTitle" - - linkTitle := func(p1, p2 *Page) bool { - return p1.linkTitle < p2.linkTitle - } - - pages, _ := spc.get(key, pageBy(linkTitle).Sort, p) - - return pages -} - -// ByDate sorts the Pages by date and returns a copy. -// -// Adjacent invocations on the same receiver will return a cached result. -// -// This may safely be executed in parallel. -func (p Pages) ByDate() Pages { - - key := "pageSort.ByDate" - - date := func(p1, p2 *Page) bool { - return p1.Date.Unix() < p2.Date.Unix() - } - - pages, _ := spc.get(key, pageBy(date).Sort, p) - - return pages -} - -// ByPublishDate sorts the Pages by publish date and returns a copy. -// -// Adjacent invocations on the same receiver will return a cached result. -// -// This may safely be executed in parallel. -func (p Pages) ByPublishDate() Pages { - - key := "pageSort.ByPublishDate" - - pubDate := func(p1, p2 *Page) bool { - return p1.PublishDate.Unix() < p2.PublishDate.Unix() - } - - pages, _ := spc.get(key, pageBy(pubDate).Sort, p) - - return pages -} - -// ByExpiryDate sorts the Pages by publish date and returns a copy. -// -// Adjacent invocations on the same receiver will return a cached result. -// -// This may safely be executed in parallel. -func (p Pages) ByExpiryDate() Pages { - - key := "pageSort.ByExpiryDate" - - expDate := func(p1, p2 *Page) bool { - return p1.ExpiryDate.Unix() < p2.ExpiryDate.Unix() - } - - pages, _ := spc.get(key, pageBy(expDate).Sort, p) - - return pages -} - -// ByLastmod sorts the Pages by the last modification date and returns a copy. -// -// Adjacent invocations on the same receiver will return a cached result. -// -// This may safely be executed in parallel. -func (p Pages) ByLastmod() Pages { - - key := "pageSort.ByLastmod" - - date := func(p1, p2 *Page) bool { - return p1.Lastmod.Unix() < p2.Lastmod.Unix() - } - - pages, _ := spc.get(key, pageBy(date).Sort, p) - - return pages -} - -// ByLength sorts the Pages by length and returns a copy. -// -// Adjacent invocations on the same receiver will return a cached result. -// -// This may safely be executed in parallel. -func (p Pages) ByLength() Pages { - - key := "pageSort.ByLength" - - length := func(p1, p2 *Page) bool { - return len(p1.Content) < len(p2.Content) - } - - pages, _ := spc.get(key, pageBy(length).Sort, p) - - return pages -} - -// ByLanguage sorts the Pages by the language's Weight. -// -// Adjacent invocations on the same receiver will return a cached result. -// -// This may safely be executed in parallel. -func (p Pages) ByLanguage() Pages { - - key := "pageSort.ByLanguage" - - pages, _ := spc.get(key, pageBy(languagePageSort).Sort, p) - - return pages -} - -// Reverse reverses the order in Pages and returns a copy. -// -// Adjacent invocations on the same receiver will return a cached result. -// -// This may safely be executed in parallel. -func (p Pages) Reverse() Pages { - key := "pageSort.Reverse" - - reverseFunc := func(pages Pages) { - for i, j := 0, len(pages)-1; i < j; i, j = i+1, j-1 { - pages[i], pages[j] = pages[j], pages[i] - } - } - - pages, _ := spc.get(key, reverseFunc, p) - - return pages -} - -func (p Pages) ByParam(paramsKey interface{}) Pages { - paramsKeyStr := cast.ToString(paramsKey) - key := "pageSort.ByParam." + paramsKeyStr - - paramsKeyComparator := func(p1, p2 *Page) bool { - v1, _ := p1.Param(paramsKeyStr) - v2, _ := p2.Param(paramsKeyStr) - s1 := cast.ToString(v1) - s2 := cast.ToString(v2) - - // Sort nils last. - if s1 == "" { - return false - } else if s2 == "" { - return true - } - - return s1 < s2 - } - - pages, _ := spc.get(key, pageBy(paramsKeyComparator).Sort, p) - - return pages -} diff --git a/hugolib/pageSort_test.go b/hugolib/pageSort_test.go deleted file mode 100644 index d9c0d0761..000000000 --- a/hugolib/pageSort_test.go +++ /dev/null @@ -1,202 +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 hugolib - -import ( - "fmt" - "html/template" - "path/filepath" - "testing" - "time" - - "github.com/stretchr/testify/assert" -) - -func TestDefaultSort(t *testing.T) { - t.Parallel() - d1 := time.Now() - d2 := d1.Add(-1 * time.Hour) - d3 := d1.Add(-2 * time.Hour) - d4 := d1.Add(-3 * time.Hour) - - s := newTestSite(t) - - p := createSortTestPages(s, 4) - - // first by weight - setSortVals([4]time.Time{d1, d2, d3, d4}, [4]string{"b", "a", "c", "d"}, [4]int{4, 3, 2, 1}, p) - p.Sort() - - assert.Equal(t, 1, p[0].Weight) - - // Consider zero weight, issue #2673 - setSortVals([4]time.Time{d1, d2, d3, d4}, [4]string{"b", "a", "d", "c"}, [4]int{0, 0, 0, 1}, p) - p.Sort() - - assert.Equal(t, 1, p[0].Weight) - - // next by date - setSortVals([4]time.Time{d3, d4, d1, d2}, [4]string{"a", "b", "c", "d"}, [4]int{1, 1, 1, 1}, p) - p.Sort() - assert.Equal(t, d1, p[0].Date) - - // finally by link title - setSortVals([4]time.Time{d3, d3, d3, d3}, [4]string{"b", "c", "a", "d"}, [4]int{1, 1, 1, 1}, p) - p.Sort() - assert.Equal(t, "al", p[0].LinkTitle()) - assert.Equal(t, "bl", p[1].LinkTitle()) - assert.Equal(t, "cl", p[2].LinkTitle()) -} - -func TestSortByN(t *testing.T) { - t.Parallel() - s := newTestSite(t) - d1 := time.Now() - d2 := d1.Add(-2 * time.Hour) - d3 := d1.Add(-10 * time.Hour) - d4 := d1.Add(-20 * time.Hour) - - p := createSortTestPages(s, 4) - - for i, this := range []struct { - sortFunc func(p Pages) Pages - assertFunc func(p Pages) bool - }{ - {(Pages).ByWeight, func(p Pages) bool { return p[0].Weight == 1 }}, - {(Pages).ByTitle, func(p Pages) bool { return p[0].title == "ab" }}, - {(Pages).ByLinkTitle, func(p Pages) bool { return p[0].LinkTitle() == "abl" }}, - {(Pages).ByDate, func(p Pages) bool { return p[0].Date == d4 }}, - {(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].Content == "b_content" }}, - } { - setSortVals([4]time.Time{d1, d2, d3, d4}, [4]string{"b", "ab", "cde", "fg"}, [4]int{0, 3, 2, 1}, p) - - sorted := this.sortFunc(p) - if !this.assertFunc(sorted) { - t.Errorf("[%d] sort error", i) - } - } - -} - -func TestLimit(t *testing.T) { - t.Parallel() - s := newTestSite(t) - p := createSortTestPages(s, 10) - firstFive := p.Limit(5) - assert.Equal(t, 5, len(firstFive)) - for i := 0; i < 5; i++ { - assert.Equal(t, p[i], firstFive[i]) - } - assert.Equal(t, p, p.Limit(10)) - assert.Equal(t, p, p.Limit(11)) -} - -func TestPageSortReverse(t *testing.T) { - t.Parallel() - s := newTestSite(t) - p1 := createSortTestPages(s, 10) - assert.Equal(t, 0, p1[0].fuzzyWordCount) - assert.Equal(t, 9, p1[9].fuzzyWordCount) - p2 := p1.Reverse() - assert.Equal(t, 9, p2[0].fuzzyWordCount) - assert.Equal(t, 0, p2[9].fuzzyWordCount) - // cached - assert.True(t, fastEqualPages(p2, p1.Reverse())) -} - -func TestPageSortByParam(t *testing.T) { - t.Parallel() - var k interface{} = "arbitrarily.nested" - s := newTestSite(t) - - unsorted := createSortTestPages(s, 10) - delete(unsorted[9].params, "arbitrarily") - - firstSetValue, _ := unsorted[0].Param(k) - secondSetValue, _ := unsorted[1].Param(k) - lastSetValue, _ := unsorted[8].Param(k) - unsetValue, _ := unsorted[9].Param(k) - - assert.Equal(t, "xyz100", firstSetValue) - assert.Equal(t, "xyz99", secondSetValue) - assert.Equal(t, "xyz92", lastSetValue) - assert.Equal(t, nil, unsetValue) - - sorted := unsorted.ByParam("arbitrarily.nested") - firstSetSortedValue, _ := sorted[0].Param(k) - secondSetSortedValue, _ := sorted[1].Param(k) - lastSetSortedValue, _ := sorted[8].Param(k) - unsetSortedValue, _ := sorted[9].Param(k) - - assert.Equal(t, firstSetValue, firstSetSortedValue) - assert.Equal(t, secondSetValue, lastSetSortedValue) - assert.Equal(t, lastSetValue, secondSetSortedValue) - assert.Equal(t, unsetValue, unsetSortedValue) -} - -func BenchmarkSortByWeightAndReverse(b *testing.B) { - s := newTestSite(b) - p := createSortTestPages(s, 300) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - p = p.ByWeight().Reverse() - } -} - -func setSortVals(dates [4]time.Time, titles [4]string, weights [4]int, pages Pages) { - for i := range dates { - pages[i].Date = dates[i] - pages[i].Lastmod = dates[i] - pages[i].Weight = weights[i] - pages[i].title = titles[i] - // make sure we compare apples and ... apples ... - pages[len(dates)-1-i].linkTitle = pages[i].title + "l" - pages[len(dates)-1-i].PublishDate = dates[i] - pages[len(dates)-1-i].ExpiryDate = dates[i] - pages[len(dates)-1-i].Content = template.HTML(titles[i] + "_content") - } - lastLastMod := pages[2].Lastmod - pages[2].Lastmod = pages[1].Lastmod - pages[1].Lastmod = lastLastMod -} - -func createSortTestPages(s *Site, num int) Pages { - pages := make(Pages, num) - - for i := 0; i < num; i++ { - p := s.newPage(filepath.FromSlash(fmt.Sprintf("/x/y/p%d.md", i))) - p.params = map[string]interface{}{ - "arbitrarily": map[string]interface{}{ - "nested": ("xyz" + fmt.Sprintf("%v", 100-i)), - }, - } - - w := 5 - - if i%2 == 0 { - w = 10 - } - p.fuzzyWordCount = i - p.Weight = w - p.Description = "initial" - - pages[i] = p - } - - return pages -} diff --git a/hugolib/page__common.go b/hugolib/page__common.go new file mode 100644 index 000000000..f6f01bbe2 --- /dev/null +++ b/hugolib/page__common.go @@ -0,0 +1,113 @@ +// 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 hugolib + +import ( + "sync" + + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/compare" + "github.com/gohugoio/hugo/lazy" + "github.com/gohugoio/hugo/markup/converter" + "github.com/gohugoio/hugo/navigation" + "github.com/gohugoio/hugo/resources/page" + "github.com/gohugoio/hugo/resources/resource" + "github.com/gohugoio/hugo/source" +) + +type nextPrevProvider interface { + getNextPrev() *nextPrev +} + +func (p *pageCommon) getNextPrev() *nextPrev { + return p.posNextPrev +} + +type nextPrevInSectionProvider interface { + getNextPrevInSection() *nextPrev +} + +func (p *pageCommon) getNextPrevInSection() *nextPrev { + return p.posNextPrevSection +} + +type pageCommon struct { + s *Site + m *pageMeta + + sWrapped page.Site + + // Lazily initialized dependencies. + init *lazy.Init + + // Store holds state that survives server rebuilds. + store *maps.Scratch + + // All of these represents the common parts of a page.Page + navigation.PageMenusProvider + page.AlternativeOutputFormatsProvider + page.ChildCareProvider + page.FileProvider + page.GetPageProvider + page.GitInfoProvider + page.InSectionPositioner + page.OutputFormatsProvider + page.PageMetaProvider + page.PageMetaInternalProvider + page.Positioner + page.RawContentProvider + page.RefProvider + page.ShortcodeInfoProvider + page.SitesProvider + page.TranslationsProvider + page.TreeProvider + resource.LanguageProvider + resource.ResourceDataProvider + resource.ResourceNameTitleProvider + resource.ResourceParamsProvider + resource.ResourceTypeProvider + resource.MediaTypeProvider + resource.TranslationKeyProvider + compare.Eqer + + // Describes how paths and URLs for this page and its descendants + // should look like. + targetPathDescriptor page.TargetPathDescriptor + + // Set if feature enabled and this is in a Git repo. + gitInfo source.GitInfo + codeowners []string + + // Positional navigation + posNextPrev *nextPrev + posNextPrevSection *nextPrev + + // Menus + pageMenus *pageMenus + + // Internal use + page.RelatedDocsHandlerProvider + + contentConverterInit sync.Once + contentConverter converter.Converter +} + +func (p *pageCommon) Store() *maps.Scratch { + return p.store +} + +// See issue 13016. +func (p *pageCommon) Scratch() *maps.Scratch { + return p.Store() +} diff --git a/hugolib/page__content.go b/hugolib/page__content.go new file mode 100644 index 000000000..20abb7884 --- /dev/null +++ b/hugolib/page__content.go @@ -0,0 +1,1191 @@ +// 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 hugolib + +import ( + "context" + "errors" + "fmt" + "html/template" + "io" + "path/filepath" + "strconv" + "strings" + "unicode/utf8" + + maps0 "maps" + + "github.com/bep/logg" + "github.com/gohugoio/hugo/common/hcontext" + "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/common/hugio" + "github.com/gohugoio/hugo/common/hugo" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/common/types/hstring" + "github.com/gohugoio/hugo/helpers" + "github.com/gohugoio/hugo/markup" + "github.com/gohugoio/hugo/markup/converter" + "github.com/gohugoio/hugo/markup/goldmark/hugocontext" + "github.com/gohugoio/hugo/markup/tableofcontents" + "github.com/gohugoio/hugo/parser/metadecoders" + "github.com/gohugoio/hugo/parser/pageparser" + "github.com/gohugoio/hugo/resources" + "github.com/gohugoio/hugo/resources/page" + "github.com/gohugoio/hugo/resources/resource" + "github.com/gohugoio/hugo/tpl" + "github.com/mitchellh/mapstructure" + "github.com/spf13/cast" +) + +const ( + internalSummaryDividerBase = "HUGOMORE42" +) + +var ( + internalSummaryDividerPreString = "\n\n" + internalSummaryDividerBase + "\n\n" + internalSummaryDividerPre = []byte(internalSummaryDividerPreString) +) + +type pageContentReplacement struct { + val []byte + + source pageparser.Item +} + +func (m *pageMeta) parseFrontMatter(h *HugoSites, pid uint64) (*contentParseInfo, error) { + var ( + sourceKey string + openSource hugio.OpenReadSeekCloser + isFromContentAdapter = m.pageConfig.IsFromContentAdapter + ) + + if m.f != nil && !isFromContentAdapter { + sourceKey = filepath.ToSlash(m.f.Filename()) + if !isFromContentAdapter { + meta := m.f.FileInfo().Meta() + openSource = func() (hugio.ReadSeekCloser, error) { + r, err := meta.Open() + if err != nil { + return nil, fmt.Errorf("failed to open file %q: %w", meta.Filename, err) + } + return r, nil + } + } + } else if isFromContentAdapter { + openSource = m.pageConfig.Content.ValueAsOpenReadSeekCloser() + } + + if sourceKey == "" { + sourceKey = strconv.FormatUint(pid, 10) + } + + pi := &contentParseInfo{ + h: h, + pid: pid, + sourceKey: sourceKey, + openSource: openSource, + } + + source, err := pi.contentSource(m) + if err != nil { + return nil, err + } + + items, err := pageparser.ParseBytes( + source, + pageparser.Config{ + NoFrontMatter: isFromContentAdapter, + }, + ) + if err != nil { + return nil, err + } + + pi.itemsStep1 = items + + if isFromContentAdapter { + // No front matter. + return pi, nil + } + + if err := pi.mapFrontMatter(source); err != nil { + return nil, err + } + + return pi, nil +} + +func (m *pageMeta) newCachedContent(h *HugoSites, pi *contentParseInfo) (*cachedContent, error) { + var filename string + if m.f != nil { + filename = m.f.Filename() + } + + c := &cachedContent{ + pm: m.s.pageMap, + StaleInfo: m, + shortcodeState: newShortcodeHandler(filename, m.s), + pi: pi, + enableEmoji: m.s.conf.EnableEmoji, + scopes: maps.NewCache[string, *cachedContentScope](), + } + + source, err := c.pi.contentSource(m) + if err != nil { + return nil, err + } + + if err := c.parseContentFile(source); err != nil { + return nil, err + } + + return c, nil +} + +type cachedContent struct { + pm *pageMap + + resource.StaleInfo + + shortcodeState *shortcodeHandler + + // Parsed content. + pi *contentParseInfo + + enableEmoji bool + + scopes *maps.Cache[string, *cachedContentScope] +} + +func (c *cachedContent) getOrCreateScope(scope string, pco *pageContentOutput) *cachedContentScope { + key := scope + pco.po.f.Name + cs, _ := c.scopes.GetOrCreate(key, func() (*cachedContentScope, error) { + return &cachedContentScope{ + cachedContent: c, + pco: pco, + scope: scope, + }, nil + }) + return cs +} + +type contentParseInfo struct { + h *HugoSites + + pid uint64 + sourceKey string + + // The source bytes. + openSource hugio.OpenReadSeekCloser + + frontMatter map[string]any + + // Whether the parsed content contains a summary separator. + hasSummaryDivider bool + + // Returns the position in bytes after any front matter. + posMainContent int + + // Indicates whether we must do placeholder replacements. + hasNonMarkdownShortcode bool + + // Items from the page parser. + // These maps directly to the source + itemsStep1 pageparser.Items + + // *shortcode, pageContentReplacement or pageparser.Item + itemsStep2 []any +} + +func (p *contentParseInfo) AddBytes(item pageparser.Item) { + p.itemsStep2 = append(p.itemsStep2, item) +} + +func (p *contentParseInfo) AddReplacement(val []byte, source pageparser.Item) { + p.itemsStep2 = append(p.itemsStep2, pageContentReplacement{val: val, source: source}) +} + +func (p *contentParseInfo) AddShortcode(s *shortcode) { + p.itemsStep2 = append(p.itemsStep2, s) + if s.insertPlaceholder() { + p.hasNonMarkdownShortcode = true + } +} + +// contentToRenderForItems returns the content to be processed by Goldmark or similar. +func (pi *contentParseInfo) contentToRender(ctx context.Context, source []byte, renderedShortcodes map[string]shortcodeRenderer) ([]byte, bool, error) { + var hasVariants bool + c := make([]byte, 0, len(source)+(len(source)/10)) + + for _, it := range pi.itemsStep2 { + switch v := it.(type) { + case pageparser.Item: + c = append(c, source[v.Pos():v.Pos()+len(v.Val(source))]...) + case pageContentReplacement: + c = append(c, v.val...) + case *shortcode: + if !v.insertPlaceholder() { + // Insert the rendered shortcode. + renderedShortcode, found := renderedShortcodes[v.placeholder] + if !found { + // This should never happen. + panic(fmt.Sprintf("rendered shortcode %q not found", v.placeholder)) + } + + b, more, err := renderedShortcode.renderShortcode(ctx) + if err != nil { + return nil, false, fmt.Errorf("failed to render shortcode: %w", err) + } + hasVariants = hasVariants || more + c = append(c, []byte(b)...) + + } else { + // Insert the placeholder so we can insert the content after + // markdown processing. + c = append(c, []byte(v.placeholder)...) + } + default: + panic(fmt.Sprintf("unknown item type %T", it)) + } + } + + return c, hasVariants, nil +} + +func (c *cachedContent) IsZero() bool { + return len(c.pi.itemsStep2) == 0 +} + +func (c *cachedContent) parseContentFile(source []byte) error { + if source == nil || c.pi.openSource == nil { + return nil + } + + return c.pi.mapItemsAfterFrontMatter(source, c.shortcodeState) +} + +func (c *contentParseInfo) parseFrontMatter(it pageparser.Item, iter *pageparser.Iterator, source []byte) error { + if c.frontMatter != nil { + return nil + } + + f := pageparser.FormatFromFrontMatterType(it.Type) + var err error + c.frontMatter, err = metadecoders.Default.UnmarshalToMap(it.Val(source), f) + if err != nil { + if fe, ok := err.(herrors.FileError); ok { + pos := fe.Position() + + // Offset the starting position of front matter. + offset := iter.LineNumber(source) - 1 + if f == metadecoders.YAML { + offset -= 1 + } + pos.LineNumber += offset + + fe.UpdatePosition(pos) + fe.SetFilename("") // It will be set later. + + return fe + } else { + return err + } + } + + return nil +} + +func (rn *contentParseInfo) failMap(source []byte, err error, i pageparser.Item) error { + if fe, ok := err.(herrors.FileError); ok { + return fe + } + + pos := posFromInput("", source, i.Pos()) + + return herrors.NewFileErrorFromPos(err, pos) +} + +func (rn *contentParseInfo) mapFrontMatter(source []byte) error { + if len(rn.itemsStep1) == 0 { + return nil + } + iter := pageparser.NewIterator(rn.itemsStep1) + +Loop: + for { + it := iter.Next() + switch { + case it.IsFrontMatter(): + if err := rn.parseFrontMatter(it, iter, source); err != nil { + return err + } + next := iter.Peek() + if !next.IsDone() { + rn.posMainContent = next.Pos() + } + // Done. + break Loop + case it.IsEOF(): + break Loop + case it.IsError(): + return rn.failMap(source, it.Err, it) + default: + + } + } + + return nil +} + +func (rn *contentParseInfo) mapItemsAfterFrontMatter( + source []byte, + s *shortcodeHandler, +) error { + if len(rn.itemsStep1) == 0 { + return nil + } + + fail := func(err error, i pageparser.Item) error { + if fe, ok := err.(herrors.FileError); ok { + return fe + } + + pos := posFromInput("", source, i.Pos()) + + return herrors.NewFileErrorFromPos(err, pos) + } + + iter := pageparser.NewIterator(rn.itemsStep1) + + // the parser is guaranteed to return items in proper order or fail, so … + // … it's safe to keep some "global" state + var ordinal int + +Loop: + for { + it := iter.Next() + + switch { + case it.Type == pageparser.TypeIgnore: + case it.IsFrontMatter(): + // Ignore. + case it.Type == pageparser.TypeLeadSummaryDivider: + posBody := -1 + f := func(item pageparser.Item) bool { + if posBody == -1 && !item.IsDone() { + posBody = item.Pos() + } + + if item.IsNonWhitespace(source) { + // Done + return false + } + return true + } + iter.PeekWalk(f) + + rn.hasSummaryDivider = true + + // The content may be rendered by Goldmark or similar, + // and we need to track the summary. + rn.AddReplacement(internalSummaryDividerPre, it) + + // Handle shortcode + case it.IsLeftShortcodeDelim(): + // let extractShortcode handle left delim (will do so recursively) + iter.Backup() + + currShortcode, err := s.extractShortcode(ordinal, 0, source, iter) + if err != nil { + return fail(err, it) + } + + currShortcode.pos = it.Pos() + currShortcode.length = iter.Current().Pos() - it.Pos() + if currShortcode.placeholder == "" { + currShortcode.placeholder = createShortcodePlaceholder("s", rn.pid, currShortcode.ordinal) + } + + if currShortcode.name != "" { + s.addName(currShortcode.name) + } + + if currShortcode.params == nil { + var s []string + currShortcode.params = s + } + + currShortcode.placeholder = createShortcodePlaceholder("s", rn.pid, ordinal) + ordinal++ + s.shortcodes = append(s.shortcodes, currShortcode) + + rn.AddShortcode(currShortcode) + + case it.IsEOF(): + break Loop + case it.IsError(): + return fail(it.Err, it) + default: + rn.AddBytes(it) + } + } + + return nil +} + +func (c *cachedContent) mustSource() []byte { + source, err := c.pi.contentSource(c) + if err != nil { + panic(err) + } + return source +} + +func (c *contentParseInfo) contentSource(s resource.StaleInfo) ([]byte, error) { + key := c.sourceKey + versionv := s.StaleVersion() + + v, err := c.h.cacheContentSource.GetOrCreate(key, func(string) (*resources.StaleValue[[]byte], error) { + b, err := c.readSourceAll() + if err != nil { + return nil, err + } + + return &resources.StaleValue[[]byte]{ + Value: b, + StaleVersionFunc: func() uint32 { + return s.StaleVersion() - versionv + }, + }, nil + }) + if err != nil { + return nil, err + } + + return v.Value, nil +} + +func (c *contentParseInfo) readSourceAll() ([]byte, error) { + if c.openSource == nil { + return []byte{}, nil + } + r, err := c.openSource() + if err != nil { + return nil, err + } + defer r.Close() + + return io.ReadAll(r) +} + +type contentTableOfContents struct { + // For Goldmark we split Parse and Render. + astDoc any + + tableOfContents *tableofcontents.Fragments + tableOfContentsHTML template.HTML + + // Temporary storage of placeholders mapped to their content. + // These are shortcodes etc. Some of these will need to be replaced + // after any markup is rendered, so they share a common prefix. + contentPlaceholders map[string]shortcodeRenderer + + contentToRender []byte +} + +type contentSummary struct { + content template.HTML + contentWithoutSummary template.HTML + summary page.Summary +} + +type contentPlainPlainWords struct { + plain string + plainWords []string + + wordCount int + fuzzyWordCount int + readingTime int +} + +func (c *cachedContentScope) keyScope(ctx context.Context) string { + return hugo.GetMarkupScope(ctx) + c.pco.po.f.Name +} + +func (c *cachedContentScope) contentRendered(ctx context.Context) (contentSummary, error) { + cp := c.pco + ctx = tpl.Context.DependencyScope.Set(ctx, pageDependencyScopeGlobal) + key := c.pi.sourceKey + "/" + c.keyScope(ctx) + versionv := c.version(cp) + + v, err := c.pm.cacheContentRendered.GetOrCreate(key, func(string) (*resources.StaleValue[contentSummary], error) { + cp.po.p.s.Log.Trace(logg.StringFunc(func() string { + return fmt.Sprintln("contentRendered", key) + })) + + cp.po.p.s.h.contentRenderCounter.Add(1) + cp.contentRendered.Store(true) + po := cp.po + + ct, err := c.contentToC(ctx) + if err != nil { + return nil, err + } + + rs, err := func() (*resources.StaleValue[contentSummary], error) { + rs := &resources.StaleValue[contentSummary]{ + StaleVersionFunc: func() uint32 { + return c.version(cp) - versionv + }, + } + + if len(c.pi.itemsStep2) == 0 { + // Nothing to do. + return rs, nil + } + + var b []byte + + if ct.astDoc != nil { + // The content is parsed, but not rendered. + r, ok, err := po.contentRenderer.RenderContent(ctx, ct.contentToRender, ct.astDoc) + if err != nil { + return nil, err + } + + if !ok { + return nil, errors.New("invalid state: astDoc is set but RenderContent returned false") + } + + b = r.Bytes() + + } else { + // Copy the content to be rendered. + b = make([]byte, len(ct.contentToRender)) + copy(b, ct.contentToRender) + } + + // There are one or more replacement tokens to be replaced. + var hasShortcodeVariants bool + tokenHandler := func(ctx context.Context, token string) ([]byte, error) { + if token == tocShortcodePlaceholder { + return []byte(ct.tableOfContentsHTML), nil + } + renderer, found := ct.contentPlaceholders[token] + if found { + repl, more, err := renderer.renderShortcode(ctx) + if err != nil { + return nil, err + } + hasShortcodeVariants = hasShortcodeVariants || more + return repl, nil + } + // This should never happen. + panic(fmt.Errorf("unknown shortcode token %q (number of tokens: %d)", token, len(ct.contentPlaceholders))) + } + + b, err = expandShortcodeTokens(ctx, b, tokenHandler) + if err != nil { + return nil, err + } + if hasShortcodeVariants { + cp.po.p.incrPageOutputTemplateVariation() + } + + var result contentSummary + if c.pi.hasSummaryDivider { + s := string(b) + summarized := page.ExtractSummaryFromHTMLWithDivider(cp.po.p.m.pageConfig.ContentMediaType, s, internalSummaryDividerBase) + result.summary = page.Summary{ + Text: template.HTML(summarized.Summary()), + Type: page.SummaryTypeManual, + Truncated: summarized.Truncated(), + } + result.contentWithoutSummary = template.HTML(summarized.ContentWithoutSummary()) + result.content = template.HTML(summarized.Content()) + } else { + result.content = template.HTML(string(b)) + } + + if !c.pi.hasSummaryDivider && cp.po.p.m.pageConfig.Summary == "" { + numWords := cp.po.p.s.conf.SummaryLength + isCJKLanguage := cp.po.p.m.pageConfig.IsCJKLanguage + summary := page.ExtractSummaryFromHTML(cp.po.p.m.pageConfig.ContentMediaType, string(result.content), numWords, isCJKLanguage) + result.summary = page.Summary{ + Text: template.HTML(summary.Summary()), + Type: page.SummaryTypeAuto, + Truncated: summary.Truncated(), + } + result.contentWithoutSummary = template.HTML(summary.ContentWithoutSummary()) + } + rs.Value = result + + return rs, nil + }() + if err != nil { + return rs, cp.po.p.wrapError(err) + } + + if rs.Value.summary.IsZero() { + b, err := cp.po.contentRenderer.ParseAndRenderContent(ctx, []byte(cp.po.p.m.pageConfig.Summary), false) + if err != nil { + return nil, err + } + html := cp.po.p.s.ContentSpec.TrimShortHTML(b.Bytes(), cp.po.p.m.pageConfig.Content.Markup) + rs.Value.summary = page.Summary{ + Text: helpers.BytesToHTML(html), + Type: page.SummaryTypeFrontMatter, + } + rs.Value.contentWithoutSummary = rs.Value.content + } + + return rs, err + }) + if err != nil { + return contentSummary{}, cp.po.p.wrapError(err) + } + + return v.Value, nil +} + +func (c *cachedContentScope) mustContentToC(ctx context.Context) contentTableOfContents { + ct, err := c.contentToC(ctx) + if err != nil { + panic(err) + } + return ct +} + +type contextKey uint8 + +const ( + contextKeyContentCallback contextKey = iota +) + +var setGetContentCallbackInContext = hcontext.NewContextDispatcher[func(*pageContentOutput, contentTableOfContents)](contextKeyContentCallback) + +func (c *cachedContentScope) contentToC(ctx context.Context) (contentTableOfContents, error) { + cp := c.pco + key := c.pi.sourceKey + "/" + c.keyScope(ctx) + versionv := c.version(cp) + + v, err := c.pm.contentTableOfContents.GetOrCreate(key, func(string) (*resources.StaleValue[contentTableOfContents], error) { + source, err := c.pi.contentSource(c) + if err != nil { + return nil, err + } + + var ct contentTableOfContents + if err := cp.initRenderHooks(); err != nil { + return nil, err + } + po := cp.po + p := po.p + ct.contentPlaceholders, err = c.shortcodeState.prepareShortcodesForPage(ctx, po, false) + if err != nil { + return nil, err + } + + // Callback called from below (e.g. in .RenderString) + ctxCallback := func(cp2 *pageContentOutput, ct2 contentTableOfContents) { + cp.otherOutputs.Set(cp2.po.p.pid, cp2) + + // Merge content placeholders + maps0.Copy(ct.contentPlaceholders, ct2.contentPlaceholders) + + if p.s.conf.Internal.Watch { + for _, s := range cp2.po.p.m.content.shortcodeState.shortcodes { + cp.trackDependency(s.templ) + } + } + + // Transfer shortcode names so HasShortcode works for shortcodes from included pages. + cp.po.p.m.content.shortcodeState.transferNames(cp2.po.p.m.content.shortcodeState) + if cp2.po.p.pageOutputTemplateVariationsState.Load() > 0 { + cp.po.p.incrPageOutputTemplateVariation() + } + } + + ctx = setGetContentCallbackInContext.Set(ctx, ctxCallback) + + var hasVariants bool + ct.contentToRender, hasVariants, err = c.pi.contentToRender(ctx, source, ct.contentPlaceholders) + if err != nil { + return nil, err + } + + if hasVariants { + p.incrPageOutputTemplateVariation() + } + + isHTML := cp.po.p.m.pageConfig.ContentMediaType.IsHTML() + + if !isHTML { + createAndSetToC := func(tocProvider converter.TableOfContentsProvider) error { + cfg := p.s.ContentSpec.Converters.GetMarkupConfig() + ct.tableOfContents = tocProvider.TableOfContents() + ct.tableOfContentsHTML, err = ct.tableOfContents.ToHTML( + cfg.TableOfContents.StartLevel, + cfg.TableOfContents.EndLevel, + cfg.TableOfContents.Ordered, + ) + return err + } + + // If the converter supports doing the parsing separately, we do that. + parseResult, ok, err := po.contentRenderer.ParseContent(ctx, ct.contentToRender) + if err != nil { + return nil, err + } + if ok { + // This is Goldmark. + // Store away the parse result for later use. + createAndSetToC(parseResult) + + ct.astDoc = parseResult.Doc() + + } else { + + // This is Asciidoctor etc. + r, err := po.contentRenderer.ParseAndRenderContent(ctx, ct.contentToRender, true) + if err != nil { + return nil, err + } + + ct.contentToRender = r.Bytes() + + if tocProvider, ok := r.(converter.TableOfContentsProvider); ok { + createAndSetToC(tocProvider) + } else { + tmpContent, tmpTableOfContents := helpers.ExtractTOC(ct.contentToRender) + ct.tableOfContentsHTML = helpers.BytesToHTML(tmpTableOfContents) + ct.tableOfContents = tableofcontents.Empty + ct.contentToRender = tmpContent + } + } + } + + return &resources.StaleValue[contentTableOfContents]{ + Value: ct, + StaleVersionFunc: func() uint32 { + return c.version(cp) - versionv + }, + }, nil + }) + if err != nil { + return contentTableOfContents{}, err + } + + return v.Value, nil +} + +func (c *cachedContent) version(cp *pageContentOutput) uint32 { + // Both of these gets incremented on change. + return c.StaleVersion() + cp.contentRenderedVersion +} + +func (c *cachedContentScope) contentPlain(ctx context.Context) (contentPlainPlainWords, error) { + cp := c.pco + key := c.pi.sourceKey + "/" + c.keyScope(ctx) + + versionv := c.version(cp) + + v, err := c.pm.cacheContentPlain.GetOrCreateWitTimeout(key, cp.po.p.s.Conf.Timeout(), func(string) (*resources.StaleValue[contentPlainPlainWords], error) { + var result contentPlainPlainWords + rs := &resources.StaleValue[contentPlainPlainWords]{ + StaleVersionFunc: func() uint32 { + return c.version(cp) - versionv + }, + } + + rendered, err := c.contentRendered(ctx) + if err != nil { + return nil, err + } + + result.plain = tpl.StripHTML(string(rendered.content)) + result.plainWords = strings.Fields(result.plain) + + isCJKLanguage := cp.po.p.m.pageConfig.IsCJKLanguage + + if isCJKLanguage { + result.wordCount = 0 + for _, word := range result.plainWords { + runeCount := utf8.RuneCountInString(word) + if len(word) == runeCount { + result.wordCount++ + } else { + result.wordCount += runeCount + } + } + } else { + result.wordCount = helpers.TotalWords(result.plain) + } + + // TODO(bep) is set in a test. Fix that. + if result.fuzzyWordCount == 0 { + result.fuzzyWordCount = (result.wordCount + 100) / 100 * 100 + } + + if isCJKLanguage { + result.readingTime = (result.wordCount + 500) / 501 + } else { + result.readingTime = (result.wordCount + 212) / 213 + } + + rs.Value = result + + return rs, nil + }) + if err != nil { + if herrors.IsTimeoutError(err) { + err = fmt.Errorf("timed out rendering the page content. Extend the `timeout` limit in your Hugo config file: %w", err) + } + return contentPlainPlainWords{}, err + } + return v.Value, nil +} + +type cachedContentScope struct { + *cachedContent + pco *pageContentOutput + scope string +} + +func (c *cachedContentScope) prepareContext(ctx context.Context) context.Context { + // A regular page's shortcode etc. may be rendered by e.g. the home page, + // so we need to track any changes to this content's page. + ctx = tpl.Context.DependencyManagerScopedProvider.Set(ctx, c.pco.po.p) + + // The markup scope is recursive, so if already set to a non zero value, preserve that value. + if s := hugo.GetMarkupScope(ctx); s != "" || s == c.scope { + return ctx + } + return hugo.SetMarkupScope(ctx, c.scope) +} + +func (c *cachedContentScope) Render(ctx context.Context) (page.Content, error) { + return c, nil +} + +func (c *cachedContentScope) Content(ctx context.Context) (template.HTML, error) { + ctx = c.prepareContext(ctx) + cr, err := c.contentRendered(ctx) + if err != nil { + return "", err + } + return cr.content, nil +} + +func (c *cachedContentScope) ContentWithoutSummary(ctx context.Context) (template.HTML, error) { + ctx = c.prepareContext(ctx) + cr, err := c.contentRendered(ctx) + if err != nil { + return "", err + } + return cr.contentWithoutSummary, nil +} + +func (c *cachedContentScope) Summary(ctx context.Context) (page.Summary, error) { + ctx = c.prepareContext(ctx) + rendered, err := c.contentRendered(ctx) + return rendered.summary, err +} + +func (c *cachedContentScope) RenderString(ctx context.Context, args ...any) (template.HTML, error) { + ctx = c.prepareContext(ctx) + + if len(args) < 1 || len(args) > 2 { + return "", errors.New("want 1 or 2 arguments") + } + + pco := c.pco + + var contentToRender string + opts := defaultRenderStringOpts + sidx := 1 + + if len(args) == 1 { + sidx = 0 + } else { + m, ok := args[0].(map[string]any) + if !ok { + return "", errors.New("first argument must be a map") + } + + if err := mapstructure.WeakDecode(m, &opts); err != nil { + return "", fmt.Errorf("failed to decode options: %w", err) + } + if opts.Markup != "" { + opts.Markup = markup.ResolveMarkup(opts.Markup) + } + } + + contentToRenderv := args[sidx] + + if _, ok := contentToRenderv.(hstring.HTML); ok { + // This content is already rendered, this is potentially + // a infinite recursion. + return "", errors.New("text is already rendered, repeating it may cause infinite recursion") + } + + var err error + contentToRender, err = cast.ToStringE(contentToRenderv) + if err != nil { + return "", err + } + + if err = pco.initRenderHooks(); err != nil { + return "", err + } + + conv := pco.po.p.getContentConverter() + + if opts.Markup != "" && opts.Markup != pco.po.p.m.pageConfig.ContentMediaType.SubType { + var err error + conv, err = pco.po.p.m.newContentConverter(pco.po.p, opts.Markup) + if err != nil { + return "", pco.po.p.wrapError(err) + } + } + + var rendered []byte + + parseInfo := &contentParseInfo{ + h: pco.po.p.s.h, + pid: pco.po.p.pid, + } + + if pageparser.HasShortcode(contentToRender) { + contentToRenderb := []byte(contentToRender) + // String contains a shortcode. + parseInfo.itemsStep1, err = pageparser.ParseBytes(contentToRenderb, pageparser.Config{ + NoFrontMatter: true, + NoSummaryDivider: true, + }) + if err != nil { + return "", err + } + + s := newShortcodeHandler(pco.po.p.pathOrTitle(), pco.po.p.s) + if err := parseInfo.mapItemsAfterFrontMatter(contentToRenderb, s); err != nil { + return "", err + } + + placeholders, err := s.prepareShortcodesForPage(ctx, pco.po, true) + if err != nil { + return "", err + } + + contentToRender, hasVariants, err := parseInfo.contentToRender(ctx, contentToRenderb, placeholders) + if err != nil { + return "", err + } + if hasVariants { + pco.po.p.incrPageOutputTemplateVariation() + } + b, err := pco.renderContentWithConverter(ctx, conv, contentToRender, false) + if err != nil { + return "", pco.po.p.wrapError(err) + } + rendered = b.Bytes() + + if parseInfo.hasNonMarkdownShortcode { + var hasShortcodeVariants bool + + tokenHandler := func(ctx context.Context, token string) ([]byte, error) { + if token == tocShortcodePlaceholder { + toc, err := c.contentToC(ctx) + if err != nil { + return nil, err + } + // The Page's TableOfContents was accessed in a shortcode. + return []byte(toc.tableOfContentsHTML), nil + } + renderer, found := placeholders[token] + if found { + repl, more, err := renderer.renderShortcode(ctx) + if err != nil { + return nil, err + } + hasShortcodeVariants = hasShortcodeVariants || more + return repl, nil + } + // This should not happen. + return nil, fmt.Errorf("unknown shortcode token %q", token) + } + + rendered, err = expandShortcodeTokens(ctx, rendered, tokenHandler) + if err != nil { + return "", err + } + if hasShortcodeVariants { + pco.po.p.incrPageOutputTemplateVariation() + } + } + + // We need a consolidated view in $page.HasShortcode + pco.po.p.m.content.shortcodeState.transferNames(s) + + } else { + c, err := pco.renderContentWithConverter(ctx, conv, []byte(contentToRender), false) + if err != nil { + return "", pco.po.p.wrapError(err) + } + + rendered = c.Bytes() + } + + if opts.Display == "inline" { + markup := pco.po.p.m.pageConfig.Content.Markup + if opts.Markup != "" { + markup = pco.po.p.s.ContentSpec.ResolveMarkup(opts.Markup) + } + rendered = pco.po.p.s.ContentSpec.TrimShortHTML(rendered, markup) + } + + return template.HTML(string(rendered)), nil +} + +func (c *cachedContentScope) RenderShortcodes(ctx context.Context) (template.HTML, error) { + ctx = c.prepareContext(ctx) + + pco := c.pco + content := pco.po.p.m.content + + source, err := content.pi.contentSource(content) + if err != nil { + return "", err + } + ct, err := c.contentToC(ctx) + if err != nil { + return "", err + } + + var insertPlaceholders bool + var hasVariants bool + cb := setGetContentCallbackInContext.Get(ctx) + if cb != nil { + insertPlaceholders = true + } + cc := make([]byte, 0, len(source)+(len(source)/10)) + for _, it := range content.pi.itemsStep2 { + switch v := it.(type) { + case pageparser.Item: + cc = append(cc, source[v.Pos():v.Pos()+len(v.Val(source))]...) + case pageContentReplacement: + // Ignore. + case *shortcode: + if !insertPlaceholders || !v.insertPlaceholder() { + // Insert the rendered shortcode. + renderedShortcode, found := ct.contentPlaceholders[v.placeholder] + if !found { + // This should never happen. + panic(fmt.Sprintf("rendered shortcode %q not found", v.placeholder)) + } + + b, more, err := renderedShortcode.renderShortcode(ctx) + if err != nil { + return "", fmt.Errorf("failed to render shortcode: %w", err) + } + hasVariants = hasVariants || more + cc = append(cc, []byte(b)...) + + } else { + // Insert the placeholder so we can insert the content after + // markdown processing. + cc = append(cc, []byte(v.placeholder)...) + } + default: + panic(fmt.Sprintf("unknown item type %T", it)) + } + } + + if hasVariants { + pco.po.p.incrPageOutputTemplateVariation() + } + + if cb != nil { + cb(pco, ct) + } + + if tpl.Context.IsInGoldmark.Get(ctx) { + // This content will be parsed and rendered by Goldmark. + // Wrap it in a special Hugo markup to assign the correct Page from + // the stack. + return template.HTML(hugocontext.Wrap(cc, pco.po.p.pid)), nil + } + + return helpers.BytesToHTML(cc), nil +} + +func (c *cachedContentScope) Plain(ctx context.Context) string { + ctx = c.prepareContext(ctx) + return c.mustContentPlain(ctx).plain +} + +func (c *cachedContentScope) PlainWords(ctx context.Context) []string { + ctx = c.prepareContext(ctx) + return c.mustContentPlain(ctx).plainWords +} + +func (c *cachedContentScope) WordCount(ctx context.Context) int { + ctx = c.prepareContext(ctx) + return c.mustContentPlain(ctx).wordCount +} + +func (c *cachedContentScope) FuzzyWordCount(ctx context.Context) int { + ctx = c.prepareContext(ctx) + return c.mustContentPlain(ctx).fuzzyWordCount +} + +func (c *cachedContentScope) ReadingTime(ctx context.Context) int { + ctx = c.prepareContext(ctx) + return c.mustContentPlain(ctx).readingTime +} + +func (c *cachedContentScope) Len(ctx context.Context) int { + ctx = c.prepareContext(ctx) + return len(c.mustContentRendered(ctx).content) +} + +func (c *cachedContentScope) Fragments(ctx context.Context) *tableofcontents.Fragments { + ctx = c.prepareContext(ctx) + toc := c.mustContentToC(ctx).tableOfContents + if toc == nil { + return nil + } + return toc +} + +func (c *cachedContentScope) fragmentsHTML(ctx context.Context) template.HTML { + ctx = c.prepareContext(ctx) + return c.mustContentToC(ctx).tableOfContentsHTML +} + +func (c *cachedContentScope) mustContentPlain(ctx context.Context) contentPlainPlainWords { + r, err := c.contentPlain(ctx) + if err != nil { + c.pco.fail(err) + } + return r +} + +func (c *cachedContentScope) mustContentRendered(ctx context.Context) contentSummary { + r, err := c.contentRendered(ctx) + if err != nil { + c.pco.fail(err) + } + return r +} diff --git a/hugolib/page__data.go b/hugolib/page__data.go new file mode 100644 index 000000000..256d8a97f --- /dev/null +++ b/hugolib/page__data.go @@ -0,0 +1,63 @@ +// 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 hugolib + +import ( + "strings" + "sync" + + "github.com/gohugoio/hugo/resources/kinds" + "github.com/gohugoio/hugo/resources/page" +) + +type pageData struct { + *pageState + + dataInit sync.Once + data page.Data +} + +func (p *pageData) Data() any { + p.dataInit.Do(func() { + p.data = make(page.Data) + + if p.Kind() == kinds.KindPage { + return + } + + switch p.Kind() { + case kinds.KindTerm: + path := p.Path() + name := p.s.pageMap.cfg.getTaxonomyConfig(path) + term := p.s.Taxonomies()[name.plural].Get(strings.TrimPrefix(path, name.pluralTreeKey)) + p.data[name.singular] = term + p.data["Singular"] = name.singular + p.data["Plural"] = name.plural + p.data["Term"] = p.m.term + case kinds.KindTaxonomy: + viewCfg := p.s.pageMap.cfg.getTaxonomyConfig(p.Path()) + p.data["Singular"] = viewCfg.singular + p.data["Plural"] = viewCfg.plural + p.data["Terms"] = p.s.Taxonomies()[viewCfg.plural] + // keep the following just for legacy reasons + p.data["OrderedIndex"] = p.data["Terms"] + p.data["Index"] = p.data["Terms"] + } + + // Assign the function to the map to make sure it is lazily initialized + p.data["pages"] = p.Pages + }) + + return p.data +} diff --git a/hugolib/page__fragments_test.go b/hugolib/page__fragments_test.go new file mode 100644 index 000000000..c30fa829e --- /dev/null +++ b/hugolib/page__fragments_test.go @@ -0,0 +1,110 @@ +// 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 hugolib + +import "testing" + +// #10794 +func TestFragmentsAndToCCrossSiteAccess(t *testing.T) { + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ["taxonomy", "term", "home"] +defaultContentLanguage = "en" +defaultContentLanguageInSubdir = true +[languages] +[languages.en] +weight = 1 +[languages.fr] +weight = 2 +-- content/p1.en.md -- +--- +title: "P1" +outputs: ["HTML", "JSON"] +--- + +## Heading 1 EN + +-- content/p1.fr.md -- +--- +title: "P1" +outputs: ["HTML", "JSON"] +--- + +## Heading 1 FR +-- layouts/_default/single.html -- +HTML +-- layouts/_default/single.json -- +{{ $secondSite := index .Sites 1 }} +{{ $p1 := $secondSite.GetPage "p1" }} +ToC: {{ $p1.TableOfContents }} +Fragments : {{ $p1.Fragments.Identifiers }} + + + + +` + + b := NewIntegrationTestBuilder( + IntegrationTestConfig{ + TxtarString: files, + T: t, + }, + ).Build() + + b.AssertFileContent("public/en/p1/index.html", "HTML") + b.AssertFileContent("public/en/p1/index.json", "ToC: <nav id=\"TableOfContents\">\n <ul>\n <li><a href=\"#heading-1-fr\">Heading 1 FR</a></li>\n </ul>\n</nav>\nFragments : [heading-1-fr]") +} + +// Issue #10866 +func TestTableOfContentsWithIncludedMarkdownFile(t *testing.T) { + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ["taxonomy", "term", "home"] +-- content/p1.md -- +--- +title: "P1" +--- + +## Heading P1 1 +{{% include "p2" %}} + +-- content/p2.md -- +--- +title: "P2" +--- + +### Heading P2 1 +### Heading P2 2 + +-- layouts/shortcodes/include.html -- +{{ with site.GetPage (.Get 0) }}{{ .RawContent }}{{ end }} +-- layouts/_default/single.html -- +Fragments: {{ .Fragments.Identifiers }}| + + + +` + + b := NewIntegrationTestBuilder( + IntegrationTestConfig{ + TxtarString: files, + T: t, + }, + ).Build() + + b.AssertFileContent("public/p1/index.html", "Fragments: [heading-p1-1 heading-p2-1 heading-p2-2]|") + b.AssertFileContent("public/p2/index.html", "Fragments: [heading-p2-1 heading-p2-2]|") +} diff --git a/hugolib/page__menus.go b/hugolib/page__menus.go new file mode 100644 index 000000000..1666036ce --- /dev/null +++ b/hugolib/page__menus.go @@ -0,0 +1,85 @@ +// 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 hugolib + +import ( + "context" + "sync" + + "github.com/gohugoio/hugo/navigation" +) + +type pageMenus struct { + p *pageState + + q navigation.MenuQueryProvider + + pmInit sync.Once + pm navigation.PageMenus +} + +func (p *pageMenus) HasMenuCurrent(menuID string, me *navigation.MenuEntry) bool { + p.p.s.init.menus.Do(context.Background()) + p.init() + return p.q.HasMenuCurrent(menuID, me) +} + +func (p *pageMenus) IsMenuCurrent(menuID string, inme *navigation.MenuEntry) bool { + p.p.s.init.menus.Do(context.Background()) + p.init() + return p.q.IsMenuCurrent(menuID, inme) +} + +func (p *pageMenus) Menus() navigation.PageMenus { + // There is a reverse dependency here. initMenus will, once, build the + // site menus and update any relevant page. + p.p.s.init.menus.Do(context.Background()) + + return p.menus() +} + +func (p *pageMenus) menus() navigation.PageMenus { + p.init() + return p.pm +} + +func (p *pageMenus) init() { + p.pmInit.Do(func() { + p.q = navigation.NewMenuQueryProvider( + p, + p.p.s, + p.p, + ) + + params := p.p.Params() + + var menus any + var ok bool + + if p.p.m.pageConfig.Menus != nil { + menus = p.p.m.pageConfig.Menus + } else { + menus, ok = params["menus"] + if !ok { + menus = params["menu"] + } + } + + var err error + p.pm, err = navigation.PageMenusFromPage(menus, p.p) + if err != nil { + p.p.s.Log.Errorln(p.p.wrapError(err)) + } + }) +} diff --git a/hugolib/page__meta.go b/hugolib/page__meta.go new file mode 100644 index 000000000..1af489f18 --- /dev/null +++ b/hugolib/page__meta.go @@ -0,0 +1,948 @@ +// 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 hugolib + +import ( + "context" + "fmt" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/bep/logg" + "github.com/gobuffalo/flect" + "github.com/gohugoio/hugo/langs" + "github.com/gohugoio/hugo/markup/converter" + xmaps "golang.org/x/exp/maps" + + "github.com/gohugoio/hugo/source" + + "github.com/gohugoio/hugo/common/hashing" + "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/config" + "github.com/gohugoio/hugo/helpers" + + "github.com/gohugoio/hugo/output" + "github.com/gohugoio/hugo/resources/kinds" + "github.com/gohugoio/hugo/resources/page" + "github.com/gohugoio/hugo/resources/page/pagemeta" + "github.com/gohugoio/hugo/resources/resource" + "github.com/spf13/cast" +) + +var cjkRe = regexp.MustCompile(`\p{Han}|\p{Hangul}|\p{Hiragana}|\p{Katakana}`) + +type pageMeta struct { + term string // Set for kind == KindTerm. + singular string // Set for kind == KindTerm and kind == KindTaxonomy. + + resource.Staler + *pageMetaParams + + // Set for standalone pages, e.g. robotsTXT. + standaloneOutputFormat output.Format + + resourcePath string // Set for bundled pages; path relative to its bundle root. + bundled bool // Set if this page is bundled inside another. + + pathInfo *paths.Path // Always set. This the canonical path to the Page. + f *source.File + + content *cachedContent // The source and the parsed page content. + + s *Site // The site this page belongs to. +} + +// Prepare for a rebuild of the data passed in from front matter. +func (m *pageMeta) setMetaPostPrepareRebuild() { + params := xmaps.Clone(m.paramsOriginal) + m.pageMetaParams.pageConfig = pagemeta.ClonePageConfigForRebuild(m.pageMetaParams.pageConfig, params) +} + +type pageMetaParams struct { + setMetaPostCount int + setMetaPostCascadeChanged bool + + pageConfig *pagemeta.PageConfig + + // These are only set in watch mode. + datesOriginal pagemeta.Dates + paramsOriginal map[string]any // contains the original params as defined in the front matter. + cascadeOriginal *maps.Ordered[page.PageMatcher, page.PageMatcherParamsConfig] // contains the original cascade as defined in the front matter. +} + +func (m *pageMetaParams) init(preserveOriginal bool) { + if preserveOriginal { + if m.pageConfig.IsFromContentAdapter { + m.paramsOriginal = xmaps.Clone(m.pageConfig.ContentAdapterData) + } else { + m.paramsOriginal = xmaps.Clone(m.pageConfig.Params) + } + m.cascadeOriginal = m.pageConfig.CascadeCompiled.Clone() + } +} + +func (p *pageMeta) Aliases() []string { + return p.pageConfig.Aliases +} + +func (p *pageMeta) BundleType() string { + switch p.pathInfo.Type() { + case paths.TypeLeaf: + return "leaf" + case paths.TypeBranch: + return "branch" + default: + return "" + } +} + +func (p *pageMeta) Date() time.Time { + return p.pageConfig.Dates.Date +} + +func (p *pageMeta) PublishDate() time.Time { + return p.pageConfig.Dates.PublishDate +} + +func (p *pageMeta) Lastmod() time.Time { + return p.pageConfig.Dates.Lastmod +} + +func (p *pageMeta) ExpiryDate() time.Time { + return p.pageConfig.Dates.ExpiryDate +} + +func (p *pageMeta) Description() string { + return p.pageConfig.Description +} + +func (p *pageMeta) Lang() string { + return p.s.Lang() +} + +func (p *pageMeta) Draft() bool { + return p.pageConfig.Draft +} + +func (p *pageMeta) File() *source.File { + return p.f +} + +func (p *pageMeta) IsHome() bool { + return p.Kind() == kinds.KindHome +} + +func (p *pageMeta) Keywords() []string { + return p.pageConfig.Keywords +} + +func (p *pageMeta) Kind() string { + return p.pageConfig.Kind +} + +func (p *pageMeta) Layout() string { + return p.pageConfig.Layout +} + +func (p *pageMeta) LinkTitle() string { + if p.pageConfig.LinkTitle != "" { + return p.pageConfig.LinkTitle + } + + return p.Title() +} + +func (p *pageMeta) Name() string { + if p.resourcePath != "" { + return p.resourcePath + } + if p.pageConfig.Kind == kinds.KindTerm { + return p.pathInfo.Unnormalized().BaseNameNoIdentifier() + } + return p.Title() +} + +func (p *pageMeta) IsNode() bool { + return !p.IsPage() +} + +func (p *pageMeta) IsPage() bool { + return p.Kind() == kinds.KindPage +} + +// Param is a convenience method to do lookups in Page's and Site's Params map, +// in that order. +// +// This method is also implemented on SiteInfo. +// TODO(bep) interface +func (p *pageMeta) Param(key any) (any, error) { + return resource.Param(p, p.s.Params(), key) +} + +func (p *pageMeta) Params() maps.Params { + return p.pageConfig.Params +} + +func (p *pageMeta) Path() string { + return p.pathInfo.Base() +} + +func (p *pageMeta) PathInfo() *paths.Path { + return p.pathInfo +} + +func (p *pageMeta) IsSection() bool { + return p.Kind() == kinds.KindSection +} + +func (p *pageMeta) Section() string { + return p.pathInfo.Section() +} + +func (p *pageMeta) Sitemap() config.SitemapConfig { + return p.pageConfig.Sitemap +} + +func (p *pageMeta) Title() string { + return p.pageConfig.Title +} + +const defaultContentType = "page" + +func (p *pageMeta) Type() string { + if p.pageConfig.Type != "" { + return p.pageConfig.Type + } + + if sect := p.Section(); sect != "" { + return sect + } + + return defaultContentType +} + +func (p *pageMeta) Weight() int { + return p.pageConfig.Weight +} + +func (p *pageMeta) setMetaPre(pi *contentParseInfo, logger loggers.Logger, conf config.AllProvider) error { + frontmatter := pi.frontMatter + + if frontmatter != nil { + pcfg := p.pageConfig + // Needed for case insensitive fetching of params values + maps.PrepareParams(frontmatter) + pcfg.Params = frontmatter + // Check for any cascade define on itself. + if cv, found := frontmatter["cascade"]; found { + var err error + cascade, err := page.DecodeCascade(logger, true, cv) + if err != nil { + return err + } + pcfg.CascadeCompiled = cascade + } + + // Look for path, lang and kind, all of which values we need early on. + if v, found := frontmatter["path"]; found { + pcfg.Path = paths.ToSlashPreserveLeading(cast.ToString(v)) + pcfg.Params["path"] = pcfg.Path + } + if v, found := frontmatter["lang"]; found { + lang := strings.ToLower(cast.ToString(v)) + if _, ok := conf.PathParser().LanguageIndex[lang]; ok { + pcfg.Lang = lang + pcfg.Params["lang"] = pcfg.Lang + } + } + if v, found := frontmatter["kind"]; found { + s := cast.ToString(v) + if s != "" { + pcfg.Kind = kinds.GetKindMain(s) + if pcfg.Kind == "" { + return fmt.Errorf("unknown kind %q in front matter", s) + } + pcfg.Params["kind"] = pcfg.Kind + } + } + } else if p.pageMetaParams.pageConfig.Params == nil { + p.pageConfig.Params = make(maps.Params) + } + + p.pageMetaParams.init(conf.Watching()) + + return nil +} + +func (ps *pageState) setMetaPost(cascade *maps.Ordered[page.PageMatcher, page.PageMatcherParamsConfig]) error { + ps.m.setMetaPostCount++ + var cascadeHashPre uint64 + if ps.m.setMetaPostCount > 1 { + cascadeHashPre = hashing.HashUint64(ps.m.pageConfig.CascadeCompiled) + ps.m.pageConfig.CascadeCompiled = ps.m.cascadeOriginal.Clone() + + } + + // Apply cascades first so they can be overridden later. + if cascade != nil { + if ps.m.pageConfig.CascadeCompiled != nil { + cascade.Range(func(k page.PageMatcher, v page.PageMatcherParamsConfig) bool { + vv, found := ps.m.pageConfig.CascadeCompiled.Get(k) + if !found { + ps.m.pageConfig.CascadeCompiled.Set(k, v) + } else { + // Merge + for ck, cv := range v.Params { + if _, found := vv.Params[ck]; !found { + vv.Params[ck] = cv + } + } + for ck, cv := range v.Fields { + if _, found := vv.Fields[ck]; !found { + vv.Fields[ck] = cv + } + } + } + return true + }) + cascade = ps.m.pageConfig.CascadeCompiled + } else { + ps.m.pageConfig.CascadeCompiled = cascade + } + } + + if cascade == nil { + cascade = ps.m.pageConfig.CascadeCompiled + } + + if ps.m.setMetaPostCount > 1 { + ps.m.setMetaPostCascadeChanged = cascadeHashPre != hashing.HashUint64(ps.m.pageConfig.CascadeCompiled) + if !ps.m.setMetaPostCascadeChanged { + + // No changes, restore any value that may be changed by aggregation. + ps.m.pageConfig.Dates = ps.m.datesOriginal + return nil + } + ps.m.setMetaPostPrepareRebuild() + + } + + // Cascade is also applied to itself. + var err error + cascade.Range(func(k page.PageMatcher, v page.PageMatcherParamsConfig) bool { + if !k.Matches(ps) { + return true + } + for kk, vv := range v.Params { + if _, found := ps.m.pageConfig.Params[kk]; !found { + ps.m.pageConfig.Params[kk] = vv + } + } + + for kk, vv := range v.Fields { + if ps.m.pageConfig.IsFromContentAdapter { + if _, found := ps.m.pageConfig.ContentAdapterData[kk]; !found { + ps.m.pageConfig.ContentAdapterData[kk] = vv + } + } else { + if _, found := ps.m.pageConfig.Params[kk]; !found { + ps.m.pageConfig.Params[kk] = vv + } + } + } + return true + }) + + if err != nil { + return err + } + + if err := ps.setMetaPostParams(); err != nil { + return err + } + + if err := ps.m.applyDefaultValues(); err != nil { + return err + } + + // Store away any original values that may be changed from aggregation. + ps.m.datesOriginal = ps.m.pageConfig.Dates + + return nil +} + +func (p *pageState) setMetaPostParams() error { + pm := p.m + var mtime time.Time + var contentBaseName string + var ext string + var isContentAdapter bool + if p.File() != nil { + isContentAdapter = p.File().IsContentAdapter() + contentBaseName = p.File().ContentBaseName() + if p.File().FileInfo() != nil { + mtime = p.File().FileInfo().ModTime() + } + if !isContentAdapter { + ext = p.File().Ext() + } + } + + var gitAuthorDate time.Time + if !p.gitInfo.IsZero() { + gitAuthorDate = p.gitInfo.AuthorDate + } + + descriptor := &pagemeta.FrontMatterDescriptor{ + PageConfig: pm.pageConfig, + BaseFilename: contentBaseName, + ModTime: mtime, + GitAuthorDate: gitAuthorDate, + Location: langs.GetLocation(pm.s.Language()), + PathOrTitle: p.pathOrTitle(), + } + + if isContentAdapter { + if err := pm.pageConfig.Compile(ext, p.s.Log, p.s.conf.OutputFormats.Config, p.s.conf.MediaTypes.Config); err != nil { + return err + } + } + + // Handle the date separately + // TODO(bep) we need to "do more" in this area so this can be split up and + // more easily tested without the Page, but the coupling is strong. + err := pm.s.frontmatterHandler.HandleDates(descriptor) + if err != nil { + p.s.Log.Errorf("Failed to handle dates for page %q: %s", p.pathOrTitle(), err) + } + + if isContentAdapter { + // Done. + return nil + } + + var buildConfig any + var isNewBuildKeyword bool + if v, ok := pm.pageConfig.Params["_build"]; ok { + hugo.Deprecate("The \"_build\" front matter key", "Use \"build\" instead. See https://gohugo.io/content-management/build-options.", "0.145.0") + buildConfig = v + } else { + buildConfig = pm.pageConfig.Params["build"] + isNewBuildKeyword = true + } + pm.pageConfig.Build, err = pagemeta.DecodeBuildConfig(buildConfig) + if err != nil { + var msgDetail string + if isNewBuildKeyword { + msgDetail = `. We renamed the _build keyword to build in Hugo 0.123.0. We recommend putting user defined params in the params section, e.g.: +--- +title: "My Title" +params: + build: "My Build" +--- +´ + +` + } + return fmt.Errorf("failed to decode build config in front matter: %s%s", err, msgDetail) + } + + var sitemapSet bool + + pcfg := pm.pageConfig + params := pcfg.Params + if params == nil { + panic("params not set for " + p.Title()) + } + + var draft, published, isCJKLanguage *bool + var userParams map[string]any + for k, v := range pcfg.Params { + loki := strings.ToLower(k) + + if loki == "params" { + vv, err := maps.ToStringMapE(v) + if err != nil { + return err + } + userParams = vv + delete(pcfg.Params, k) + continue + } + + if loki == "published" { // Intentionally undocumented + vv, err := cast.ToBoolE(v) + if err == nil { + published = &vv + } + // published may also be a date + continue + } + + if pm.s.frontmatterHandler.IsDateKey(loki) { + continue + } + + if loki == "path" || loki == "kind" || loki == "lang" { + // See issue 12484. + hugo.DeprecateLevelMin(loki+" in front matter", "", "v0.144.0", logg.LevelWarn) + } + + switch loki { + case "title": + pcfg.Title = cast.ToString(v) + params[loki] = pcfg.Title + case "linktitle": + pcfg.LinkTitle = cast.ToString(v) + params[loki] = pcfg.LinkTitle + case "summary": + pcfg.Summary = cast.ToString(v) + params[loki] = pcfg.Summary + case "description": + pcfg.Description = cast.ToString(v) + params[loki] = pcfg.Description + case "slug": + // Don't start or end with a - + pcfg.Slug = strings.Trim(cast.ToString(v), "-") + params[loki] = pm.Slug() + case "url": + url := cast.ToString(v) + if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") { + return fmt.Errorf("URLs with protocol (http*) not supported: %q. In page %q", url, p.pathOrTitle()) + } + pcfg.URL = url + params[loki] = url + case "type": + pcfg.Type = cast.ToString(v) + params[loki] = pcfg.Type + case "keywords": + pcfg.Keywords = cast.ToStringSlice(v) + params[loki] = pcfg.Keywords + case "headless": + // Legacy setting for leaf bundles. + // This is since Hugo 0.63 handled in a more general way for all + // pages. + isHeadless := cast.ToBool(v) + params[loki] = isHeadless + if isHeadless { + pm.pageConfig.Build.List = pagemeta.Never + pm.pageConfig.Build.Render = pagemeta.Never + } + case "outputs": + o := cast.ToStringSlice(v) + // lower case names: + for i, s := range o { + o[i] = strings.ToLower(s) + } + pm.pageConfig.Outputs = o + case "draft": + draft = new(bool) + *draft = cast.ToBool(v) + case "layout": + pcfg.Layout = cast.ToString(v) + params[loki] = pcfg.Layout + case "markup": + pcfg.Content.Markup = cast.ToString(v) + params[loki] = pcfg.Content.Markup + case "weight": + pcfg.Weight = cast.ToInt(v) + params[loki] = pcfg.Weight + case "aliases": + pcfg.Aliases = cast.ToStringSlice(v) + for i, alias := range pcfg.Aliases { + if strings.HasPrefix(alias, "http://") || strings.HasPrefix(alias, "https://") { + return fmt.Errorf("http* aliases not supported: %q", alias) + } + pcfg.Aliases[i] = filepath.ToSlash(alias) + } + params[loki] = pcfg.Aliases + case "sitemap": + pcfg.Sitemap, err = config.DecodeSitemap(p.s.conf.Sitemap, maps.ToStringMap(v)) + if err != nil { + return fmt.Errorf("failed to decode sitemap config in front matter: %s", err) + } + sitemapSet = true + case "iscjklanguage": + isCJKLanguage = new(bool) + *isCJKLanguage = cast.ToBool(v) + case "translationkey": + pcfg.TranslationKey = cast.ToString(v) + params[loki] = pcfg.TranslationKey + case "resources": + var resources []map[string]any + handled := true + + switch vv := v.(type) { + case []map[any]any: + for _, vvv := range vv { + resources = append(resources, maps.ToStringMap(vvv)) + } + case []map[string]any: + resources = append(resources, vv...) + case []any: + for _, vvv := range vv { + switch vvvv := vvv.(type) { + case map[any]any: + resources = append(resources, maps.ToStringMap(vvvv)) + case map[string]any: + resources = append(resources, vvvv) + } + } + default: + handled = false + } + + if handled { + pcfg.ResourcesMeta = resources + break + } + fallthrough + default: + // If not one of the explicit values, store in Params + switch vv := v.(type) { + case []any: + if len(vv) > 0 { + allStrings := true + for _, vvv := range vv { + if _, ok := vvv.(string); !ok { + allStrings = false + break + } + } + if allStrings { + // We need tags, keywords etc. to be []string, not []interface{}. + a := make([]string, len(vv)) + for i, u := range vv { + a[i] = cast.ToString(u) + } + params[loki] = a + } else { + params[loki] = vv + } + } else { + params[loki] = []string{} + } + + default: + params[loki] = vv + } + } + } + + for k, v := range userParams { + params[strings.ToLower(k)] = v + } + + if !sitemapSet { + pcfg.Sitemap = p.s.conf.Sitemap + } + + if draft != nil && published != nil { + pcfg.Draft = *draft + p.m.s.Log.Warnf("page %q has both draft and published settings in its frontmatter. Using draft.", p.File().Filename()) + } else if draft != nil { + pcfg.Draft = *draft + } else if published != nil { + pcfg.Draft = !*published + } + params["draft"] = pcfg.Draft + + if isCJKLanguage != nil { + pcfg.IsCJKLanguage = *isCJKLanguage + } else if p.s.conf.HasCJKLanguage && p.m.content.pi.openSource != nil { + if cjkRe.Match(p.m.content.mustSource()) { + pcfg.IsCJKLanguage = true + } else { + pcfg.IsCJKLanguage = false + } + } + + params["iscjklanguage"] = pcfg.IsCJKLanguage + + if err := pcfg.Validate(false); err != nil { + return err + } + + if err := pcfg.Compile(ext, p.s.Log, p.s.conf.OutputFormats.Config, p.s.conf.MediaTypes.Config); err != nil { + return err + } + + return nil +} + +// shouldList returns whether this page should be included in the list of pages. +// global indicates site.Pages etc. +func (p *pageMeta) shouldList(global bool) bool { + if p.isStandalone() { + // Never list 404, sitemap and similar. + return false + } + + switch p.pageConfig.Build.List { + case pagemeta.Always: + return true + case pagemeta.Never: + return false + case pagemeta.ListLocally: + return !global + } + return false +} + +func (p *pageMeta) shouldListAny() bool { + return p.shouldList(true) || p.shouldList(false) +} + +func (p *pageMeta) isStandalone() bool { + return !p.standaloneOutputFormat.IsZero() +} + +func (p *pageMeta) shouldBeCheckedForMenuDefinitions() bool { + if !p.shouldList(false) { + return false + } + + return p.pageConfig.Kind == kinds.KindHome || p.pageConfig.Kind == kinds.KindSection || p.pageConfig.Kind == kinds.KindPage +} + +func (p *pageMeta) noRender() bool { + return p.pageConfig.Build.Render != pagemeta.Always +} + +func (p *pageMeta) noLink() bool { + return p.pageConfig.Build.Render == pagemeta.Never +} + +func (p *pageMeta) applyDefaultValues() error { + if p.pageConfig.Build.IsZero() { + p.pageConfig.Build, _ = pagemeta.DecodeBuildConfig(nil) + } + + if !p.s.conf.IsKindEnabled(p.Kind()) { + (&p.pageConfig.Build).Disable() + } + + if p.pageConfig.Content.Markup == "" { + if p.File() != nil { + // Fall back to file extension + p.pageConfig.Content.Markup = p.s.ContentSpec.ResolveMarkup(p.File().Ext()) + } + if p.pageConfig.Content.Markup == "" { + p.pageConfig.Content.Markup = "markdown" + } + } + + if p.pageConfig.Title == "" && p.f == nil { + switch p.Kind() { + case kinds.KindHome: + p.pageConfig.Title = p.s.Title() + case kinds.KindSection: + sectionName := p.pathInfo.Unnormalized().BaseNameNoIdentifier() + if p.s.conf.PluralizeListTitles { + sectionName = flect.Pluralize(sectionName) + } + if p.s.conf.CapitalizeListTitles { + sectionName = p.s.conf.C.CreateTitle(sectionName) + } + p.pageConfig.Title = sectionName + case kinds.KindTerm: + if p.term != "" { + if p.s.conf.CapitalizeListTitles { + p.pageConfig.Title = p.s.conf.C.CreateTitle(p.term) + } else { + p.pageConfig.Title = p.term + } + } else { + panic("term not set") + } + case kinds.KindTaxonomy: + if p.s.conf.CapitalizeListTitles { + p.pageConfig.Title = strings.Replace(p.s.conf.C.CreateTitle(p.pathInfo.Unnormalized().BaseNameNoIdentifier()), "-", " ", -1) + } else { + p.pageConfig.Title = strings.Replace(p.pathInfo.Unnormalized().BaseNameNoIdentifier(), "-", " ", -1) + } + case kinds.KindStatus404: + p.pageConfig.Title = "404 Page not found" + } + } + + return nil +} + +func (p *pageMeta) newContentConverter(ps *pageState, markup string) (converter.Converter, error) { + if ps == nil { + panic("no Page provided") + } + cp := p.s.ContentSpec.Converters.Get(markup) + if cp == nil { + return converter.NopConverter, fmt.Errorf("no content renderer found for markup %q, page: %s", markup, ps.getPageInfoForError()) + } + + var id string + var filename string + var path string + if p.f != nil { + id = p.f.UniqueID() + filename = p.f.Filename() + path = p.f.Path() + } else { + path = p.Path() + } + + doc := newPageForRenderHook(ps) + + documentLookup := func(id uint64) any { + if id == ps.pid { + // This prevents infinite recursion in some cases. + return doc + } + if v, ok := ps.pageOutput.pco.otherOutputs.Get(id); ok { + return v.po.p + } + return nil + } + + cpp, err := cp.New( + converter.DocumentContext{ + Document: doc, + DocumentLookup: documentLookup, + DocumentID: id, + DocumentName: path, + Filename: filename, + }, + ) + if err != nil { + return converter.NopConverter, err + } + + return cpp, nil +} + +// The output formats this page will be rendered to. +func (m *pageMeta) outputFormats() output.Formats { + if len(m.pageConfig.ConfiguredOutputFormats) > 0 { + return m.pageConfig.ConfiguredOutputFormats + } + return m.s.conf.C.KindOutputFormats[m.Kind()] +} + +func (p *pageMeta) Slug() string { + return p.pageConfig.Slug +} + +func getParam(m resource.ResourceParamsProvider, key string, stringToLower bool) any { + v := m.Params()[strings.ToLower(key)] + + if v == nil { + return nil + } + + switch val := v.(type) { + case bool: + return val + case string: + if stringToLower { + return strings.ToLower(val) + } + return val + case int64, int32, int16, int8, int: + return cast.ToInt(v) + case float64, float32: + return cast.ToFloat64(v) + case time.Time: + return val + case []string: + if stringToLower { + return helpers.SliceToLower(val) + } + return v + default: + return v + } +} + +func getParamToLower(m resource.ResourceParamsProvider, key string) any { + return getParam(m, key, true) +} + +func (ps *pageState) initLazyProviders() error { + ps.init.Add(func(ctx context.Context) (any, error) { + pp, err := newPagePaths(ps) + if err != nil { + return nil, err + } + + var outputFormatsForPage output.Formats + var renderFormats output.Formats + + if ps.m.standaloneOutputFormat.IsZero() { + outputFormatsForPage = ps.m.outputFormats() + renderFormats = ps.s.h.renderFormats + } else { + // One of the fixed output format pages, e.g. 404. + outputFormatsForPage = output.Formats{ps.m.standaloneOutputFormat} + renderFormats = outputFormatsForPage + } + + // Prepare output formats for all sites. + // We do this even if this page does not get rendered on + // its own. It may be referenced via one of the site collections etc. + // it will then need an output format. + ps.pageOutputs = make([]*pageOutput, len(renderFormats)) + created := make(map[string]*pageOutput) + shouldRenderPage := !ps.m.noRender() + + for i, f := range renderFormats { + + if po, found := created[f.Name]; found { + ps.pageOutputs[i] = po + continue + } + + render := shouldRenderPage + if render { + _, render = outputFormatsForPage.GetByName(f.Name) + } + + po := newPageOutput(ps, pp, f, render) + + // Create a content provider for the first, + // we may be able to reuse it. + if i == 0 { + contentProvider, err := newPageContentOutput(po) + if err != nil { + return nil, err + } + po.setContentProvider(contentProvider) + } + + ps.pageOutputs[i] = po + created[f.Name] = po + + } + + if err := ps.initCommonProviders(pp); err != nil { + return nil, err + } + + return nil, nil + }) + + return nil +} diff --git a/hugolib/page__meta_test.go b/hugolib/page__meta_test.go new file mode 100644 index 000000000..58d29464d --- /dev/null +++ b/hugolib/page__meta_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 hugolib + +import ( + "strings" + "testing" +) + +// Issue 9793 +// Issue 12115 +func TestListTitles(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['home','rss','sitemap'] +capitalizeListTitles = true +pluralizeListTitles = true +[taxonomies] +tag = 'tags' +-- content/section-1/page-1.md -- +--- +title: page-1 +tags: 'tag-a' +--- +-- layouts/_default/list.html -- +{{ .Title }} +-- layouts/_default/single.html -- +{{ .Title }} + ` + + b := Test(t, files) + + b.AssertFileContent("public/section-1/index.html", "Section-1s") + b.AssertFileContent("public/tags/index.html", "Tags") + b.AssertFileContent("public/tags/tag-a/index.html", "Tag-A") + + files = strings.Replace(files, "true", "false", -1) + + b = Test(t, files) + + b.AssertFileContent("public/section-1/index.html", "section-1") + b.AssertFileContent("public/tags/index.html", "tags") + b.AssertFileContent("public/tags/tag-a/index.html", "tag-a") +} + +func TestDraftNonDefaultContentLanguage(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +defaultContentLanguage = "en" +[languages] +[languages.en] +weight = 1 +[languages.nn] +weight = 2 +-- content/p1.md -- +-- content/p2.nn.md -- +--- +title: "p2" +draft: true +--- +-- layouts/_default/single.html -- +{{ .Title }} +` + b := Test(t, files) + + b.AssertFileExists("public/nn/p2/index.html", false) +} diff --git a/hugolib/page__new.go b/hugolib/page__new.go new file mode 100644 index 000000000..80115cc72 --- /dev/null +++ b/hugolib/page__new.go @@ -0,0 +1,265 @@ +// 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 hugolib + +import ( + "fmt" + "strings" + "sync/atomic" + + "github.com/gohugoio/hugo/hugofs/files" + "github.com/gohugoio/hugo/resources" + + "github.com/gohugoio/hugo/common/constants" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/common/paths" + + "github.com/gohugoio/hugo/lazy" + + "github.com/gohugoio/hugo/resources/kinds" + "github.com/gohugoio/hugo/resources/page" + "github.com/gohugoio/hugo/resources/page/pagemeta" +) + +var pageIDCounter atomic.Uint64 + +func (h *HugoSites) newPage(m *pageMeta) (*pageState, *paths.Path, error) { + p, pth, err := h.doNewPage(m) + if err != nil { + // Make sure that any partially created page part is marked as stale. + m.MarkStale() + } + + if p != nil && pth != nil && p.IsHome() && pth.IsLeafBundle() { + msg := "Using %s in your content's root directory is usually incorrect for your home page. " + msg += "You should use %s instead. If you don't rename this file, your home page will be " + msg += "treated as a leaf bundle, meaning it won't be able to have any child pages or sections." + h.Log.Warnidf(constants.WarnHomePageIsLeafBundle, msg, pth.PathNoLeadingSlash(), strings.ReplaceAll(pth.PathNoLeadingSlash(), "index", "_index")) + } + + return p, pth, err +} + +func (h *HugoSites) doNewPage(m *pageMeta) (*pageState, *paths.Path, error) { + m.Staler = &resources.AtomicStaler{} + if m.pageMetaParams == nil { + m.pageMetaParams = &pageMetaParams{ + pageConfig: &pagemeta.PageConfig{}, + } + } + if m.pageConfig.Params == nil { + m.pageConfig.Params = maps.Params{} + } + + pid := pageIDCounter.Add(1) + pi, err := m.parseFrontMatter(h, pid) + if err != nil { + return nil, nil, err + } + + if err := m.setMetaPre(pi, h.Log, h.Conf); err != nil { + return nil, nil, m.wrapError(err, h.BaseFs.SourceFs) + } + pcfg := m.pageConfig + if pcfg.Lang != "" { + if h.Conf.IsLangDisabled(pcfg.Lang) { + return nil, nil, nil + } + } + + if pcfg.Path != "" { + s := m.pageConfig.Path + // Paths from content adapters should never have any extension. + if pcfg.IsFromContentAdapter || !paths.HasExt(s) { + var ( + isBranch bool + isBranchSet bool + ext string = m.pageConfig.ContentMediaType.FirstSuffix.Suffix + ) + if pcfg.Kind != "" { + isBranch = kinds.IsBranch(pcfg.Kind) + isBranchSet = true + } + + if !pcfg.IsFromContentAdapter { + if m.pathInfo != nil { + if !isBranchSet { + isBranch = m.pathInfo.IsBranchBundle() + } + if m.pathInfo.Ext() != "" { + ext = m.pathInfo.Ext() + } + } else if m.f != nil { + pi := m.f.FileInfo().Meta().PathInfo + if !isBranchSet { + isBranch = pi.IsBranchBundle() + } + if pi.Ext() != "" { + ext = pi.Ext() + } + } + } + + if isBranch { + s += "/_index." + ext + } else { + s += "/index." + ext + } + + } + m.pathInfo = h.Conf.PathParser().Parse(files.ComponentFolderContent, s) + } else if m.pathInfo == nil { + if m.f != nil { + m.pathInfo = m.f.FileInfo().Meta().PathInfo + } + + if m.pathInfo == nil { + panic(fmt.Sprintf("missing pathInfo in %v", m)) + } + } + + ps, err := func() (*pageState, error) { + if m.s == nil { + // Identify the Site/language to associate this Page with. + var lang string + if pcfg.Lang != "" { + lang = pcfg.Lang + } else if m.f != nil { + meta := m.f.FileInfo().Meta() + lang = meta.Lang + } else { + lang = m.pathInfo.Lang() + } + + m.s = h.resolveSite(lang) + + if m.s == nil { + return nil, fmt.Errorf("no site found for language %q", lang) + } + } + + var tc viewName + // Identify Page Kind. + if m.pageConfig.Kind == "" { + m.pageConfig.Kind = kinds.KindSection + if m.pathInfo.Base() == "/" { + m.pageConfig.Kind = kinds.KindHome + } else if m.pathInfo.IsBranchBundle() { + // A section, taxonomy or term. + tc = m.s.pageMap.cfg.getTaxonomyConfig(m.Path()) + if !tc.IsZero() { + // Either a taxonomy or a term. + if tc.pluralTreeKey == m.Path() { + m.pageConfig.Kind = kinds.KindTaxonomy + } else { + m.pageConfig.Kind = kinds.KindTerm + } + } + } else if m.f != nil { + m.pageConfig.Kind = kinds.KindPage + } + } + + if m.pageConfig.Kind == kinds.KindTerm || m.pageConfig.Kind == kinds.KindTaxonomy { + if tc.IsZero() { + tc = m.s.pageMap.cfg.getTaxonomyConfig(m.Path()) + } + if tc.IsZero() { + return nil, fmt.Errorf("no taxonomy configuration found for %q", m.Path()) + } + m.singular = tc.singular + if m.pageConfig.Kind == kinds.KindTerm { + m.term = paths.TrimLeading(strings.TrimPrefix(m.pathInfo.Unnormalized().Base(), tc.pluralTreeKey)) + } + } + + if m.pageConfig.Kind == kinds.KindPage && !m.s.conf.IsKindEnabled(m.pageConfig.Kind) { + return nil, nil + } + + // Parse the rest of the page content. + m.content, err = m.newCachedContent(h, pi) + if err != nil { + return nil, m.wrapError(err, h.SourceFs) + } + + ps := &pageState{ + pid: pid, + pageOutput: nopPageOutput, + pageOutputTemplateVariationsState: &atomic.Uint32{}, + Staler: m, + dependencyManager: m.s.Conf.NewIdentityManager(m.Path()), + pageCommon: &pageCommon{ + FileProvider: m, + store: maps.NewScratch(), + Positioner: page.NopPage, + InSectionPositioner: page.NopPage, + ResourceNameTitleProvider: m, + ResourceParamsProvider: m, + PageMetaProvider: m, + PageMetaInternalProvider: m, + OutputFormatsProvider: page.NopPage, + ResourceTypeProvider: pageTypesProvider, + MediaTypeProvider: pageTypesProvider, + RefProvider: page.NopPage, + ShortcodeInfoProvider: page.NopPage, + LanguageProvider: m.s, + + RelatedDocsHandlerProvider: m.s, + init: lazy.New(), + m: m, + s: m.s, + sWrapped: page.WrapSite(m.s), + }, + } + + if m.f != nil { + gi, err := m.s.h.gitInfoForPage(ps) + if err != nil { + return nil, fmt.Errorf("failed to load Git data: %w", err) + } + ps.gitInfo = gi + owners, err := m.s.h.codeownersForPage(ps) + if err != nil { + return nil, fmt.Errorf("failed to load CODEOWNERS: %w", err) + } + ps.codeowners = owners + } + + ps.pageMenus = &pageMenus{p: ps} + ps.PageMenusProvider = ps.pageMenus + ps.GetPageProvider = pageSiteAdapter{s: m.s, p: ps} + ps.GitInfoProvider = ps + ps.TranslationsProvider = ps + ps.ResourceDataProvider = &pageData{pageState: ps} + ps.RawContentProvider = ps + ps.ChildCareProvider = ps + ps.TreeProvider = pageTree{p: ps} + ps.Eqer = ps + ps.TranslationKeyProvider = ps + ps.ShortcodeInfoProvider = ps + ps.AlternativeOutputFormatsProvider = ps + + if err := ps.initLazyProviders(); err != nil { + return nil, ps.wrapError(err) + } + return ps, nil + }() + + if ps == nil { + return nil, nil, err + } + + return ps, ps.PathInfo(), err +} diff --git a/hugolib/page__output.go b/hugolib/page__output.go new file mode 100644 index 000000000..b8086bb48 --- /dev/null +++ b/hugolib/page__output.go @@ -0,0 +1,149 @@ +// 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 hugolib + +import ( + "fmt" + + "github.com/gohugoio/hugo/identity" + "github.com/gohugoio/hugo/output" + "github.com/gohugoio/hugo/resources/page" + "github.com/gohugoio/hugo/resources/resource" +) + +func newPageOutput( + ps *pageState, + pp pagePaths, + f output.Format, + render bool, +) *pageOutput { + var targetPathsProvider targetPathsHolder + var linksProvider resource.ResourceLinksProvider + + ft, found := pp.targetPaths[f.Name] + if !found { + // Link to the main output format + ft = pp.targetPaths[pp.firstOutputFormat.Format.Name] + } + targetPathsProvider = ft + linksProvider = ft + + var paginatorProvider page.PaginatorProvider + var pag *pagePaginator + + if render && ps.IsNode() { + pag = newPagePaginator(ps) + paginatorProvider = pag + } else { + paginatorProvider = page.PaginatorNotSupportedFunc(func() error { + return fmt.Errorf("pagination not supported for this page: %s", ps.getPageInfoForError()) + }) + } + + providers := struct { + page.PaginatorProvider + resource.ResourceLinksProvider + targetPather + }{ + paginatorProvider, + linksProvider, + targetPathsProvider, + } + + po := &pageOutput{ + p: ps, + f: f, + pagePerOutputProviders: providers, + MarkupProvider: page.NopPage, + ContentProvider: page.NopPage, + PageRenderProvider: page.NopPage, + TableOfContentsProvider: page.NopPage, + render: render, + paginator: pag, + dependencyManagerOutput: ps.s.Conf.NewIdentityManager((ps.Path() + "/" + f.Name)), + } + + return po +} + +// We create a pageOutput for every output format combination, even if this +// particular page isn't configured to be rendered to that format. +type pageOutput struct { + p *pageState + + // Set if this page isn't configured to be rendered to this format. + render bool + + f output.Format + + // Only set if render is set. + // Note that this will be lazily initialized, so only used if actually + // used in template(s). + paginator *pagePaginator + + // These interface provides the functionality that is specific for this + // output format. + contentRenderer page.ContentRenderer + pagePerOutputProviders + page.MarkupProvider + page.ContentProvider + page.PageRenderProvider + page.TableOfContentsProvider + page.RenderShortcodesProvider + + // May be nil. + pco *pageContentOutput + + dependencyManagerOutput identity.Manager + + renderState int // Reset when it needs to be rendered again. + renderOnce bool // To make sure we at least try to render it once. +} + +func (po *pageOutput) incrRenderState() { + po.renderState++ + po.renderOnce = true +} + +// isRendered reports whether this output format or its content has been rendered. +func (po *pageOutput) isRendered() bool { + if po.renderState > 0 { + return true + } + if po.pco != nil && po.pco.contentRendered.Load() { + return true + } + return false +} + +func (po *pageOutput) IdentifierBase() string { + return po.p.f.Name +} + +func (po *pageOutput) GetDependencyManager() identity.Manager { + return po.dependencyManagerOutput +} + +func (p *pageOutput) setContentProvider(cp *pageContentOutput) { + if cp == nil { + return + } + p.contentRenderer = cp + p.ContentProvider = cp + p.MarkupProvider = cp + p.PageRenderProvider = cp + p.TableOfContentsProvider = cp + p.RenderShortcodesProvider = cp + p.pco = cp +} diff --git a/hugolib/page__paginator.go b/hugolib/page__paginator.go new file mode 100644 index 000000000..b6a778a21 --- /dev/null +++ b/hugolib/page__paginator.go @@ -0,0 +1,112 @@ +// 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 hugolib + +import ( + "sync" + + "github.com/gohugoio/hugo/resources/kinds" + "github.com/gohugoio/hugo/resources/page" +) + +func newPagePaginator(source *pageState) *pagePaginator { + return &pagePaginator{ + source: source, + pagePaginatorInit: &pagePaginatorInit{}, + } +} + +type pagePaginator struct { + *pagePaginatorInit + source *pageState +} + +type pagePaginatorInit struct { + init sync.Once + current *page.Pager +} + +// reset resets the paginator to allow for a rebuild. +func (p *pagePaginator) reset() { + p.pagePaginatorInit = &pagePaginatorInit{} +} + +func (p *pagePaginator) Paginate(seq any, options ...any) (*page.Pager, error) { + var initErr error + p.init.Do(func() { + pagerSize, err := page.ResolvePagerSize(p.source.s.Conf, options...) + if err != nil { + initErr = err + return + } + + pd := p.source.targetPathDescriptor + pd.Type = p.source.outputFormat() + paginator, err := page.Paginate(pd, seq, pagerSize) + if err != nil { + initErr = err + return + } + + p.current = paginator.Pagers()[0] + }) + + if initErr != nil { + return nil, initErr + } + + return p.current, nil +} + +func (p *pagePaginator) Paginator(options ...any) (*page.Pager, error) { + var initErr error + p.init.Do(func() { + pagerSize, err := page.ResolvePagerSize(p.source.s.Conf, options...) + if err != nil { + initErr = err + return + } + + pd := p.source.targetPathDescriptor + pd.Type = p.source.outputFormat() + + var pages page.Pages + + switch p.source.Kind() { + case kinds.KindHome: + // From Hugo 0.57 we made home.Pages() work like any other + // section. To avoid the default paginators for the home page + // changing in the wild, we make this a special case. + pages = p.source.s.RegularPages() + case kinds.KindTerm, kinds.KindTaxonomy: + pages = p.source.Pages() + default: + pages = p.source.RegularPages() + } + + paginator, err := page.Paginate(pd, pages, pagerSize) + if err != nil { + initErr = err + return + } + + p.current = paginator.Pagers()[0] + }) + + if initErr != nil { + return nil, initErr + } + + return p.current, nil +} diff --git a/hugolib/page__paths.go b/hugolib/page__paths.go new file mode 100644 index 000000000..62206cb15 --- /dev/null +++ b/hugolib/page__paths.go @@ -0,0 +1,181 @@ +// 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 hugolib + +import ( + "net/url" + "strings" + + "github.com/gohugoio/hugo/output" + + "github.com/gohugoio/hugo/resources/kinds" + "github.com/gohugoio/hugo/resources/page" +) + +func newPagePaths(ps *pageState) (pagePaths, error) { + s := ps.s + pm := ps.m + + targetPathDescriptor, err := createTargetPathDescriptor(ps) + if err != nil { + return pagePaths{}, err + } + + var outputFormats output.Formats + + if ps.m.isStandalone() { + outputFormats = output.Formats{ps.m.standaloneOutputFormat} + } else { + outputFormats = pm.outputFormats() + if len(outputFormats) == 0 { + return pagePaths{}, nil + } + + if pm.noRender() { + outputFormats = outputFormats[:1] + } + } + + pageOutputFormats := make(page.OutputFormats, len(outputFormats)) + targets := make(map[string]targetPathsHolder) + + for i, f := range outputFormats { + desc := targetPathDescriptor + desc.Type = f + paths := page.CreateTargetPaths(desc) + + var relPermalink, permalink string + + // If a page is headless or bundled in another, + // it will not get published on its own and it will have no links. + // We also check the build options if it's set to not render or have + // a link. + if !pm.noLink() && !pm.bundled { + relPermalink = paths.RelPermalink(s.PathSpec) + permalink = paths.PermalinkForOutputFormat(s.PathSpec, f) + } + + pageOutputFormats[i] = page.NewOutputFormat(relPermalink, permalink, len(outputFormats) == 1, f) + + // Use the main format for permalinks, usually HTML. + permalinksIndex := 0 + if f.Permalinkable { + // Unless it's permalinkable. + permalinksIndex = i + } + + targets[f.Name] = targetPathsHolder{ + relURL: relPermalink, + paths: paths, + OutputFormat: pageOutputFormats[permalinksIndex], + } + + } + + var out page.OutputFormats + if !pm.noLink() { + out = pageOutputFormats + } + + return pagePaths{ + outputFormats: out, + firstOutputFormat: pageOutputFormats[0], + targetPaths: targets, + targetPathDescriptor: targetPathDescriptor, + }, nil +} + +type pagePaths struct { + outputFormats page.OutputFormats + firstOutputFormat page.OutputFormat + + targetPaths map[string]targetPathsHolder + targetPathDescriptor page.TargetPathDescriptor +} + +func (l pagePaths) OutputFormats() page.OutputFormats { + return l.outputFormats +} + +func createTargetPathDescriptor(p *pageState) (page.TargetPathDescriptor, error) { + s := p.s + d := s.Deps + pm := p.m + alwaysInSubDir := p.Kind() == kinds.KindSitemap + + pageInfoPage := p.PathInfo() + pageInfoCurrentSection := p.CurrentSection().PathInfo() + if p.s.Conf.DisablePathToLower() { + pageInfoPage = pageInfoPage.Unnormalized() + pageInfoCurrentSection = pageInfoCurrentSection.Unnormalized() + } + + desc := page.TargetPathDescriptor{ + PathSpec: d.PathSpec, + Kind: p.Kind(), + Path: pageInfoPage, + Section: pageInfoCurrentSection, + UglyURLs: s.h.Conf.IsUglyURLs(p.Section()), + ForcePrefix: s.h.Conf.IsMultihost() || alwaysInSubDir, + URL: pm.pageConfig.URL, + } + + if pm.Slug() != "" { + desc.BaseName = pm.Slug() + } else if pm.isStandalone() && pm.standaloneOutputFormat.BaseName != "" { + desc.BaseName = pm.standaloneOutputFormat.BaseName + } else { + desc.BaseName = pageInfoPage.BaseNameNoIdentifier() + } + + desc.PrefixFilePath = s.getLanguageTargetPathLang(alwaysInSubDir) + desc.PrefixLink = s.getLanguagePermalinkLang(alwaysInSubDir) + + if desc.URL != "" && strings.IndexByte(desc.URL, ':') >= 0 { + // Attempt to parse and expand an url + opath, err := d.ResourceSpec.Permalinks.ExpandPattern(desc.URL, p) + if err != nil { + return desc, err + } + + if opath != "" { + opath, _ = url.QueryUnescape(opath) + desc.URL = opath + } + } + + opath, err := d.ResourceSpec.Permalinks.Expand(p.Section(), p) + if err != nil { + return desc, err + } + + if opath != "" { + opath, _ = url.QueryUnescape(opath) + if strings.HasSuffix(opath, "//") { + // When rewriting the _index of the section the permalink config is applied to, + // we get double slashes at the end sometimes; clear them up here + opath = strings.TrimSuffix(opath, "/") + } + + desc.ExpandedPermalink = opath + + if p.File() != nil { + s.Log.Debugf("Set expanded permalink path for %s %s to %#v", p.Kind(), p.File().Path(), opath) + } else { + s.Log.Debugf("Set expanded permalink path for %s in %v to %#v", p.Kind(), desc.Section.Path(), opath) + } + } + + return desc, nil +} diff --git a/hugolib/page__per_output.go b/hugolib/page__per_output.go new file mode 100644 index 000000000..1f7f3411e --- /dev/null +++ b/hugolib/page__per_output.go @@ -0,0 +1,493 @@ +// 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 hugolib + +import ( + "bytes" + "context" + "errors" + "fmt" + "html/template" + "sync" + "sync/atomic" + + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/common/text" + "github.com/gohugoio/hugo/identity" + "github.com/gohugoio/hugo/tpl/tplimpl" + "github.com/spf13/cast" + + "github.com/gohugoio/hugo/markup/converter/hooks" + "github.com/gohugoio/hugo/markup/tableofcontents" + + "github.com/gohugoio/hugo/markup/converter" + + bp "github.com/gohugoio/hugo/bufferpool" + + "github.com/gohugoio/hugo/output" + "github.com/gohugoio/hugo/resources/page" + "github.com/gohugoio/hugo/resources/resource" +) + +var ( + nopTargetPath = targetPathsHolder{} + nopPagePerOutput = struct { + resource.ResourceLinksProvider + page.ContentProvider + page.PageRenderProvider + page.PaginatorProvider + page.TableOfContentsProvider + page.AlternativeOutputFormatsProvider + + targetPather + }{ + page.NopPage, + page.NopPage, + page.NopPage, + page.NopPage, + page.NopPage, + page.NopPage, + nopTargetPath, + } +) + +func newPageContentOutput(po *pageOutput) (*pageContentOutput, error) { + cp := &pageContentOutput{ + po: po, + renderHooks: &renderHooks{}, + otherOutputs: maps.NewCache[uint64, *pageContentOutput](), + } + return cp, nil +} + +type renderHooks struct { + getRenderer hooks.GetRendererFunc + init sync.Once +} + +// pageContentOutput represents the Page content for a given output format. +type pageContentOutput struct { + po *pageOutput + + // Other pages involved in rendering of this page, + // typically included with .RenderShortcodes. + otherOutputs *maps.Cache[uint64, *pageContentOutput] + + contentRenderedVersion uint32 // Incremented on reset. + contentRendered atomic.Bool // Set on content render. + + // Renders Markdown hooks. + renderHooks *renderHooks +} + +func (pco *pageContentOutput) trackDependency(idp identity.IdentityProvider) { + pco.po.p.dependencyManagerOutput.AddIdentity(idp.GetIdentity()) +} + +func (pco *pageContentOutput) Reset() { + if pco == nil { + return + } + pco.contentRenderedVersion++ + pco.contentRendered.Store(false) + pco.renderHooks = &renderHooks{} +} + +func (pco *pageContentOutput) Render(ctx context.Context, layout ...string) (template.HTML, error) { + if len(layout) == 0 { + return "", errors.New("no layout given") + } + templ, found, err := pco.po.p.resolveTemplate(layout...) + if err != nil { + return "", pco.po.p.wrapError(err) + } + + if !found { + return "", nil + } + + // Make sure to send the *pageState and not the *pageContentOutput to the template. + res, err := executeToString(ctx, pco.po.p.s.GetTemplateStore(), templ, pco.po.p) + if err != nil { + return "", pco.po.p.wrapError(fmt.Errorf("failed to execute template %s: %w", templ.Name(), err)) + } + return template.HTML(res), nil +} + +func (pco *pageContentOutput) Fragments(ctx context.Context) *tableofcontents.Fragments { + return pco.c().Fragments(ctx) +} + +func (pco *pageContentOutput) RenderShortcodes(ctx context.Context) (template.HTML, error) { + return pco.c().RenderShortcodes(ctx) +} + +func (pco *pageContentOutput) Markup(opts ...any) page.Markup { + if len(opts) > 1 { + panic("too many arguments, expected 0 or 1") + } + var scope string + if len(opts) == 1 { + scope = cast.ToString(opts[0]) + } + return pco.po.p.m.content.getOrCreateScope(scope, pco) +} + +func (pco *pageContentOutput) c() page.Markup { + return pco.po.p.m.content.getOrCreateScope("", pco) +} + +func (pco *pageContentOutput) Content(ctx context.Context) (any, error) { + r, err := pco.c().Render(ctx) + if err != nil { + return nil, err + } + return r.Content(ctx) +} + +func (pco *pageContentOutput) ContentWithoutSummary(ctx context.Context) (template.HTML, error) { + r, err := pco.c().Render(ctx) + if err != nil { + return "", err + } + return r.ContentWithoutSummary(ctx) +} + +func (pco *pageContentOutput) TableOfContents(ctx context.Context) template.HTML { + return pco.c().(*cachedContentScope).fragmentsHTML(ctx) +} + +func (pco *pageContentOutput) Len(ctx context.Context) int { + return pco.mustRender(ctx).Len(ctx) +} + +func (pco *pageContentOutput) mustRender(ctx context.Context) page.Content { + c, err := pco.c().Render(ctx) + if err != nil { + pco.fail(err) + } + return c +} + +func (pco *pageContentOutput) fail(err error) { + pco.po.p.s.h.FatalError(pco.po.p.wrapError(err)) +} + +func (pco *pageContentOutput) Plain(ctx context.Context) string { + return pco.mustRender(ctx).Plain(ctx) +} + +func (pco *pageContentOutput) PlainWords(ctx context.Context) []string { + return pco.mustRender(ctx).PlainWords(ctx) +} + +func (pco *pageContentOutput) ReadingTime(ctx context.Context) int { + return pco.mustRender(ctx).ReadingTime(ctx) +} + +func (pco *pageContentOutput) WordCount(ctx context.Context) int { + return pco.mustRender(ctx).WordCount(ctx) +} + +func (pco *pageContentOutput) FuzzyWordCount(ctx context.Context) int { + return pco.mustRender(ctx).FuzzyWordCount(ctx) +} + +func (pco *pageContentOutput) Summary(ctx context.Context) template.HTML { + summary, err := pco.mustRender(ctx).Summary(ctx) + if err != nil { + pco.fail(err) + } + return summary.Text +} + +func (pco *pageContentOutput) Truncated(ctx context.Context) bool { + summary, err := pco.mustRender(ctx).Summary(ctx) + if err != nil { + pco.fail(err) + } + return summary.Truncated +} + +func (pco *pageContentOutput) RenderString(ctx context.Context, args ...any) (template.HTML, error) { + return pco.c().RenderString(ctx, args...) +} + +func (pco *pageContentOutput) initRenderHooks() error { + if pco == nil { + return nil + } + + pco.renderHooks.init.Do(func() { + if pco.po.p.pageOutputTemplateVariationsState.Load() == 0 { + pco.po.p.pageOutputTemplateVariationsState.Store(1) + } + + type cacheKey struct { + tp hooks.RendererType + id any + f output.Format + } + + renderCache := make(map[cacheKey]any) + var renderCacheMu sync.Mutex + + resolvePosition := func(ctx any) text.Position { + source := pco.po.p.m.content.mustSource() + var offset int + + switch v := ctx.(type) { + case hooks.PositionerSourceTargetProvider: + offset = bytes.Index(source, v.PositionerSourceTarget()) + } + + pos := pco.po.p.posFromInput(source, offset) + + if pos.LineNumber > 0 { + // Move up to the code fence delimiter. + // This is in line with how we report on shortcodes. + pos.LineNumber = pos.LineNumber - 1 + } + + return pos + } + + pco.renderHooks.getRenderer = func(tp hooks.RendererType, id any) any { + renderCacheMu.Lock() + defer renderCacheMu.Unlock() + + key := cacheKey{tp: tp, id: id, f: pco.po.f} + if r, ok := renderCache[key]; ok { + return r + } + + // Inherit the descriptor from the page/current output format. + // This allows for fine-grained control of the template used for + // rendering of e.g. links. + base, layoutDescriptor := pco.po.p.GetInternalTemplateBasePathAndDescriptor() + + switch tp { + case hooks.LinkRendererType: + layoutDescriptor.Variant1 = "link" + case hooks.ImageRendererType: + layoutDescriptor.Variant1 = "image" + case hooks.HeadingRendererType: + layoutDescriptor.Variant1 = "heading" + case hooks.PassthroughRendererType: + layoutDescriptor.Variant1 = "passthrough" + if id != nil { + layoutDescriptor.Variant2 = id.(string) + } + case hooks.BlockquoteRendererType: + layoutDescriptor.Variant1 = "blockquote" + if id != nil { + layoutDescriptor.Variant2 = id.(string) + } + case hooks.TableRendererType: + layoutDescriptor.Variant1 = "table" + case hooks.CodeBlockRendererType: + layoutDescriptor.Variant1 = "codeblock" + if id != nil { + layoutDescriptor.Variant2 = id.(string) + } + } + + renderHookConfig := pco.po.p.s.conf.Markup.Goldmark.RenderHooks + var ignoreInternal bool + switch layoutDescriptor.Variant1 { + case "link": + ignoreInternal = !renderHookConfig.Link.IsEnableDefault() + case "image": + ignoreInternal = !renderHookConfig.Image.IsEnableDefault() + } + + candidates := pco.po.p.s.renderFormats + var numCandidatesFound int + consider := func(candidate *tplimpl.TemplInfo) bool { + if layoutDescriptor.Variant1 != candidate.D.Variant1 { + return false + } + + if layoutDescriptor.Variant2 != "" && candidate.D.Variant2 != "" && layoutDescriptor.Variant2 != candidate.D.Variant2 { + return false + } + + if ignoreInternal && candidate.SubCategory() == tplimpl.SubCategoryEmbedded { + // Don't consider the internal hook templates. + return false + } + + if pco.po.p.pageOutputTemplateVariationsState.Load() > 1 { + return true + } + + if candidate.D.OutputFormat == "" { + numCandidatesFound++ + } else if _, found := candidates.GetByName(candidate.D.OutputFormat); found { + numCandidatesFound++ + } + + return true + } + + getHookTemplate := func() (*tplimpl.TemplInfo, bool) { + q := tplimpl.TemplateQuery{ + Path: base, + Category: tplimpl.CategoryMarkup, + Desc: layoutDescriptor, + Consider: consider, + } + + v := pco.po.p.s.TemplateStore.LookupPagesLayout(q) + return v, v != nil + } + + templ, found1 := getHookTemplate() + if found1 && templ == nil { + panic("found1 is true, but templ is nil") + } + + if !found1 && layoutDescriptor.OutputFormat == pco.po.p.s.conf.DefaultOutputFormat { + numCandidatesFound++ + } + + if numCandidatesFound > 1 { + // More than one output format candidate found for this hook temoplate, + // so we cannot reuse the same rendered content. + pco.po.p.incrPageOutputTemplateVariation() + } + + if !found1 { + if tp == hooks.CodeBlockRendererType { + // No user provided template for code blocks, so we use the native Go version -- which is also faster. + r := pco.po.p.s.ContentSpec.Converters.GetHighlighter() + renderCache[key] = r + return r + } + return nil + } + + r := hookRendererTemplate{ + templateHandler: pco.po.p.s.GetTemplateStore(), + templ: templ, + resolvePosition: resolvePosition, + } + renderCache[key] = r + return r + } + }) + + return nil +} + +func (pco *pageContentOutput) getContentConverter() (converter.Converter, error) { + if err := pco.initRenderHooks(); err != nil { + return nil, err + } + return pco.po.p.getContentConverter(), nil +} + +func (cp *pageContentOutput) ParseAndRenderContent(ctx context.Context, content []byte, renderTOC bool) (converter.ResultRender, error) { + c, err := cp.getContentConverter() + if err != nil { + return nil, err + } + return cp.renderContentWithConverter(ctx, c, content, renderTOC) +} + +func (pco *pageContentOutput) ParseContent(ctx context.Context, content []byte) (converter.ResultParse, bool, error) { + c, err := pco.getContentConverter() + if err != nil { + return nil, false, err + } + p, ok := c.(converter.ParseRenderer) + if !ok { + return nil, ok, nil + } + rctx := converter.RenderContext{ + Ctx: ctx, + Src: content, + RenderTOC: true, + GetRenderer: pco.renderHooks.getRenderer, + } + r, err := p.Parse(rctx) + return r, ok, err +} + +func (pco *pageContentOutput) RenderContent(ctx context.Context, content []byte, doc any) (converter.ResultRender, bool, error) { + c, err := pco.getContentConverter() + if err != nil { + return nil, false, err + } + p, ok := c.(converter.ParseRenderer) + if !ok { + return nil, ok, nil + } + rctx := converter.RenderContext{ + Ctx: ctx, + Src: content, + RenderTOC: true, + GetRenderer: pco.renderHooks.getRenderer, + } + r, err := p.Render(rctx, doc) + return r, ok, err +} + +func (pco *pageContentOutput) renderContentWithConverter(ctx context.Context, c converter.Converter, content []byte, renderTOC bool) (converter.ResultRender, error) { + r, err := c.Convert( + converter.RenderContext{ + Ctx: ctx, + Src: content, + RenderTOC: renderTOC, + GetRenderer: pco.renderHooks.getRenderer, + }) + return r, err +} + +// these will be shifted out when rendering a given output format. +type pagePerOutputProviders interface { + targetPather + page.PaginatorProvider + resource.ResourceLinksProvider +} + +type targetPather interface { + targetPaths() page.TargetPaths + getRelURL() string +} + +type targetPathsHolder struct { + // relURL is usually the same as OutputFormat.RelPermalink, but can be different + // for non-permalinkable output formats. These shares RelPermalink with the main (first) output format. + relURL string + paths page.TargetPaths + page.OutputFormat +} + +func (t targetPathsHolder) getRelURL() string { + return t.relURL +} + +func (t targetPathsHolder) targetPaths() page.TargetPaths { + return t.paths +} + +func executeToString(ctx context.Context, h *tplimpl.TemplateStore, templ *tplimpl.TemplInfo, data any) (string, error) { + b := bp.GetBuffer() + defer bp.PutBuffer(b) + if err := h.ExecuteWithContext(ctx, templ, b, data); err != nil { + return "", err + } + return b.String(), nil +} diff --git a/hugolib/page__position.go b/hugolib/page__position.go new file mode 100644 index 000000000..d55ebeb07 --- /dev/null +++ b/hugolib/page__position.go @@ -0,0 +1,82 @@ +// 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 hugolib + +import ( + "context" + + "github.com/gohugoio/hugo/common/hugo" + "github.com/gohugoio/hugo/lazy" + "github.com/gohugoio/hugo/resources/page" +) + +func newPagePosition(n *nextPrev) pagePosition { + return pagePosition{nextPrev: n} +} + +func newPagePositionInSection(n *nextPrev) pagePositionInSection { + return pagePositionInSection{nextPrev: n} +} + +type nextPrev struct { + init *lazy.Init + prevPage page.Page + nextPage page.Page +} + +func (n *nextPrev) next() page.Page { + n.init.Do(context.Background()) + return n.nextPage +} + +func (n *nextPrev) prev() page.Page { + n.init.Do(context.Background()) + return n.prevPage +} + +type pagePosition struct { + *nextPrev +} + +func (p pagePosition) Next() page.Page { + return p.next() +} + +// Deprecated: Use Next instead. +func (p pagePosition) NextPage() page.Page { + hugo.Deprecate(".Page.NextPage", "Use .Page.Next instead.", "v0.123.0") + return p.Next() +} + +func (p pagePosition) Prev() page.Page { + return p.prev() +} + +// Deprecated: Use Prev instead. +func (p pagePosition) PrevPage() page.Page { + hugo.Deprecate(".Page.PrevPage", "Use .Page.Prev instead.", "v0.123.0") + return p.Prev() +} + +type pagePositionInSection struct { + *nextPrev +} + +func (p pagePositionInSection) NextInSection() page.Page { + return p.next() +} + +func (p pagePositionInSection) PrevInSection() page.Page { + return p.prev() +} diff --git a/hugolib/page__ref.go b/hugolib/page__ref.go new file mode 100644 index 000000000..e55a8a3e4 --- /dev/null +++ b/hugolib/page__ref.go @@ -0,0 +1,114 @@ +// 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 hugolib + +import ( + "fmt" + + "github.com/gohugoio/hugo/common/text" + + "github.com/mitchellh/mapstructure" +) + +func newPageRef(p *pageState) pageRef { + return pageRef{p: p} +} + +type pageRef struct { + p *pageState +} + +func (p pageRef) Ref(argsm map[string]any) (string, error) { + return p.ref(argsm, p.p) +} + +func (p pageRef) RefFrom(argsm map[string]any, source any) (string, error) { + return p.ref(argsm, source) +} + +func (p pageRef) RelRef(argsm map[string]any) (string, error) { + return p.relRef(argsm, p.p) +} + +func (p pageRef) RelRefFrom(argsm map[string]any, source any) (string, error) { + return p.relRef(argsm, source) +} + +func (p pageRef) decodeRefArgs(args map[string]any) (refArgs, *Site, error) { + var ra refArgs + err := mapstructure.WeakDecode(args, &ra) + if err != nil { + return ra, nil, nil + } + + s := p.p.s + + if ra.Lang != "" && ra.Lang != p.p.s.Language().Lang { + // Find correct site + found := false + for _, ss := range p.p.s.h.Sites { + if ss.Lang() == ra.Lang { + found = true + s = ss + } + } + + if !found { + p.p.s.siteRefLinker.logNotFound(ra.Path, fmt.Sprintf("no site found with lang %q", ra.Lang), nil, text.Position{}) + return ra, nil, nil + } + } + + return ra, s, nil +} + +func (p pageRef) ref(argsm map[string]any, source any) (string, error) { + args, s, err := p.decodeRefArgs(argsm) + if err != nil { + return "", fmt.Errorf("invalid arguments to Ref: %w", err) + } + + if s == nil { + return p.p.s.siteRefLinker.notFoundURL, nil + } + + if args.Path == "" { + return "", nil + } + + return s.refLink(args.Path, source, false, args.OutputFormat) +} + +func (p pageRef) relRef(argsm map[string]any, source any) (string, error) { + args, s, err := p.decodeRefArgs(argsm) + if err != nil { + return "", fmt.Errorf("invalid arguments to Ref: %w", err) + } + + if s == nil { + return p.p.s.siteRefLinker.notFoundURL, nil + } + + if args.Path == "" { + return "", nil + } + + return s.refLink(args.Path, source, true, args.OutputFormat) +} + +type refArgs struct { + Path string + Lang string + OutputFormat string +} diff --git a/hugolib/page__tree.go b/hugolib/page__tree.go new file mode 100644 index 000000000..cccfb8904 --- /dev/null +++ b/hugolib/page__tree.go @@ -0,0 +1,203 @@ +// 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 hugolib + +import ( + "context" + "fmt" + "strings" + + "github.com/gohugoio/hugo/common/paths" + "github.com/gohugoio/hugo/common/types" + "github.com/gohugoio/hugo/hugolib/doctree" + "github.com/gohugoio/hugo/resources/kinds" + "github.com/gohugoio/hugo/resources/page" +) + +// pageTree holds the treen navigational method for a Page. +type pageTree struct { + p *pageState +} + +func (pt pageTree) IsAncestor(other any) bool { + n, ok := other.(contentNodeI) + if !ok { + return false + } + + if n.Path() == pt.p.Path() { + return false + } + + return strings.HasPrefix(n.Path(), paths.AddTrailingSlash(pt.p.Path())) +} + +func (pt pageTree) IsDescendant(other any) bool { + n, ok := other.(contentNodeI) + if !ok { + return false + } + + if n.Path() == pt.p.Path() { + return false + } + + return strings.HasPrefix(pt.p.Path(), paths.AddTrailingSlash(n.Path())) +} + +func (pt pageTree) CurrentSection() page.Page { + if kinds.IsBranch(pt.p.Kind()) { + return pt.p + } + + dir := pt.p.m.pathInfo.Dir() + if dir == "/" { + return pt.p.s.home + } + + _, n := pt.p.s.pageMap.treePages.LongestPrefix(dir, true, func(n contentNodeI) bool { return n.isContentNodeBranch() }) + if n != nil { + return n.(page.Page) + } + + panic(fmt.Sprintf("CurrentSection not found for %q in lang %s", pt.p.Path(), pt.p.Lang())) +} + +func (pt pageTree) FirstSection() page.Page { + s := pt.p.m.pathInfo.Dir() + if s == "/" { + return pt.p.s.home + } + + for { + k, n := pt.p.s.pageMap.treePages.LongestPrefix(s, true, func(n contentNodeI) bool { return n.isContentNodeBranch() }) + if n == nil { + return nil + } + + // /blog + if strings.Count(k, "/") < 2 { + return n.(page.Page) + } + + if s == "" { + return nil + } + + s = paths.Dir(s) + + } +} + +func (pt pageTree) InSection(other any) bool { + if pt.p == nil || types.IsNil(other) { + return false + } + + p, ok := other.(page.Page) + if !ok { + return false + } + + return pt.CurrentSection() == p.CurrentSection() +} + +func (pt pageTree) Parent() page.Page { + if pt.p.IsHome() { + return nil + } + + dir := pt.p.m.pathInfo.ContainerDir() + + if dir == "" { + return pt.p.s.home + } + + for { + _, n := pt.p.s.pageMap.treePages.LongestPrefix(dir, true, nil) + if n == nil { + return pt.p.s.home + } + if pt.p.m.bundled || n.isContentNodeBranch() { + return n.(page.Page) + } + dir = paths.Dir(dir) + } +} + +func (pt pageTree) Ancestors() page.Pages { + var ancestors page.Pages + parent := pt.Parent() + for parent != nil { + ancestors = append(ancestors, parent) + parent = parent.Parent() + } + return ancestors +} + +func (pt pageTree) Sections() page.Pages { + var ( + pages page.Pages + currentBranchPrefix string + s = pt.p.Path() + prefix = paths.AddTrailingSlash(s) + tree = pt.p.s.pageMap.treePages + ) + + w := &doctree.NodeShiftTreeWalker[contentNodeI]{ + Tree: tree, + Prefix: prefix, + } + w.Handle = func(ss string, n contentNodeI, match doctree.DimensionFlag) (bool, error) { + if !n.isContentNodeBranch() { + return false, nil + } + if currentBranchPrefix == "" || !strings.HasPrefix(ss, currentBranchPrefix) { + if p, ok := n.(*pageState); ok && p.IsSection() && p.m.shouldList(false) && p.Parent() == pt.p { + pages = append(pages, p) + } else { + w.SkipPrefix(ss + "/") + } + } + currentBranchPrefix = ss + "/" + return false, nil + } + + if err := w.Walk(context.Background()); err != nil { + panic(err) + } + + page.SortByDefault(pages) + return pages +} + +func (pt pageTree) Page() page.Page { + return pt.p +} + +func (p pageTree) SectionsEntries() []string { + sp := p.SectionsPath() + if sp == "/" { + return nil + } + entries := strings.Split(sp[1:], "/") + if len(entries) == 0 { + return nil + } + return entries +} + +func (p pageTree) SectionsPath() string { + return p.CurrentSection().Path() +} diff --git a/hugolib/page_bundler.go b/hugolib/page_bundler.go deleted file mode 100644 index e55e0a92b..000000000 --- a/hugolib/page_bundler.go +++ /dev/null @@ -1,208 +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 hugolib - -import ( - "fmt" - "math" - "runtime" - - // Use this until errgroup gets ported to context - // See https://github.com/golang/go/issues/19781 - "golang.org/x/net/context" - "golang.org/x/sync/errgroup" -) - -type siteContentProcessor struct { - site *Site - - handleContent contentHandler - - ctx context.Context - - // The input file bundles. - fileBundlesChan chan *bundleDir - - // The input file singles. - fileSinglesChan chan *fileInfo - - // These assets should be just copied to destination. - fileAssetsChan chan []pathLangFile - - numWorkers int - - // The output Pages - pagesChan chan *Page - - // Used for partial rebuilds (aka. live reload) - // Will signal replacement of pages in the site collection. - partialBuild bool -} - -func (s *siteContentProcessor) processBundle(b *bundleDir) { - select { - case s.fileBundlesChan <- b: - case <-s.ctx.Done(): - } -} - -func (s *siteContentProcessor) processSingle(fi *fileInfo) { - select { - case s.fileSinglesChan <- fi: - case <-s.ctx.Done(): - } -} - -func (s *siteContentProcessor) processAssets(assets []pathLangFile) { - select { - case s.fileAssetsChan <- assets: - case <-s.ctx.Done(): - } -} - -func newSiteContentProcessor(ctx context.Context, partialBuild bool, s *Site) *siteContentProcessor { - numWorkers := 12 - if n := runtime.NumCPU() * 3; n > numWorkers { - numWorkers = n - } - - numWorkers = int(math.Ceil(float64(numWorkers) / float64(len(s.owner.Sites)))) - - return &siteContentProcessor{ - ctx: ctx, - partialBuild: partialBuild, - site: s, - handleContent: newHandlerChain(s), - fileBundlesChan: make(chan *bundleDir, numWorkers), - fileSinglesChan: make(chan *fileInfo, numWorkers), - fileAssetsChan: make(chan []pathLangFile, numWorkers), - numWorkers: numWorkers, - pagesChan: make(chan *Page, numWorkers), - } -} - -func (s *siteContentProcessor) closeInput() { - close(s.fileSinglesChan) - close(s.fileBundlesChan) - close(s.fileAssetsChan) -} - -func (s *siteContentProcessor) process(ctx context.Context) error { - g1, ctx := errgroup.WithContext(ctx) - g2, ctx := errgroup.WithContext(ctx) - - // There can be only one of these per site. - g1.Go(func() error { - for p := range s.pagesChan { - if p.s != s.site { - panic(fmt.Sprintf("invalid page site: %v vs %v", p.s, s)) - } - - if s.partialBuild { - s.site.replacePage(p) - } else { - s.site.addPage(p) - } - } - return nil - }) - - for i := 0; i < s.numWorkers; i++ { - g2.Go(func() error { - for { - select { - case f, ok := <-s.fileSinglesChan: - if !ok { - return nil - } - err := s.readAndConvertContentFile(f) - if err != nil { - return err - } - case <-ctx.Done(): - return ctx.Err() - } - } - }) - - g2.Go(func() error { - for { - select { - case files, ok := <-s.fileAssetsChan: - if !ok { - return nil - } - for _, file := range files { - f, err := s.site.BaseFs.ContentFs.Open(file.Filename()) - if err != nil { - return fmt.Errorf("failed to open assets file: %s", err) - } - err = s.site.publish(&s.site.PathSpec.ProcessingStats.Files, file.Path(), f) - f.Close() - if err != nil { - return err - } - } - - case <-ctx.Done(): - return ctx.Err() - } - } - }) - - g2.Go(func() error { - for { - select { - case bundle, ok := <-s.fileBundlesChan: - if !ok { - return nil - } - err := s.readAndConvertContentBundle(bundle) - if err != nil { - return err - } - case <-ctx.Done(): - return ctx.Err() - } - } - }) - } - - err := g2.Wait() - - close(s.pagesChan) - - if err != nil { - return err - } - - if err := g1.Wait(); err != nil { - return err - } - - s.site.rawAllPages.Sort() - - return nil - -} - -func (s *siteContentProcessor) readAndConvertContentFile(file *fileInfo) error { - ctx := &handlerContext{source: file, pages: s.pagesChan} - return s.handleContent(ctx).err -} - -func (s *siteContentProcessor) readAndConvertContentBundle(bundle *bundleDir) error { - ctx := &handlerContext{bundle: bundle, pages: s.pagesChan} - return s.handleContent(ctx).err -} diff --git a/hugolib/page_bundler_capture.go b/hugolib/page_bundler_capture.go deleted file mode 100644 index 255a8efda..000000000 --- a/hugolib/page_bundler_capture.go +++ /dev/null @@ -1,741 +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 hugolib - -import ( - "errors" - "fmt" - "os" - "path" - "path/filepath" - "runtime" - "strings" - "sync" - - "github.com/spf13/afero" - - "github.com/gohugoio/hugo/hugofs" - - "github.com/gohugoio/hugo/helpers" - - "golang.org/x/sync/errgroup" - - "github.com/gohugoio/hugo/source" - jww "github.com/spf13/jwalterweatherman" -) - -var errSkipCyclicDir = errors.New("skip potential cyclic dir") - -type capturer struct { - // To prevent symbolic link cycles: Visit same folder only once. - seen map[string]bool - seenMu sync.Mutex - - handler captureResultHandler - - sourceSpec *source.SourceSpec - fs afero.Fs - logger *jww.Notepad - - // Filenames limits the content to process to a list of filenames/directories. - // This is used for partial building in server mode. - filenames []string - - // Used to determine how to handle content changes in server mode. - contentChanges *contentChangeMap - - // Semaphore used to throttle the concurrent sub directory handling. - sem chan bool -} - -func newCapturer( - logger *jww.Notepad, - sourceSpec *source.SourceSpec, - handler captureResultHandler, - contentChanges *contentChangeMap, - filenames ...string) *capturer { - - numWorkers := 4 - if n := runtime.NumCPU(); n > numWorkers { - numWorkers = n - } - - c := &capturer{ - sem: make(chan bool, numWorkers), - handler: handler, - sourceSpec: sourceSpec, - fs: sourceSpec.Fs, - logger: logger, - contentChanges: contentChanges, - seen: make(map[string]bool), - filenames: filenames} - - return c -} - -// Captured files and bundles ready to be processed will be passed on to -// these channels. -type captureResultHandler interface { - handleSingles(fis ...*fileInfo) - handleCopyFiles(fis ...pathLangFile) - captureBundlesHandler -} - -type captureBundlesHandler interface { - handleBundles(b *bundleDirs) -} - -type captureResultHandlerChain struct { - handlers []captureBundlesHandler -} - -func (c *captureResultHandlerChain) handleSingles(fis ...*fileInfo) { - for _, h := range c.handlers { - if hh, ok := h.(captureResultHandler); ok { - hh.handleSingles(fis...) - } - } -} -func (c *captureResultHandlerChain) handleBundles(b *bundleDirs) { - for _, h := range c.handlers { - h.handleBundles(b) - } -} - -func (c *captureResultHandlerChain) handleCopyFiles(files ...pathLangFile) { - for _, h := range c.handlers { - if hh, ok := h.(captureResultHandler); ok { - hh.handleCopyFiles(files...) - } - } -} - -func (c *capturer) capturePartial(filenames ...string) error { - handled := make(map[string]bool) - - for _, filename := range filenames { - dir, resolvedFilename, tp := c.contentChanges.resolveAndRemove(filename) - if handled[resolvedFilename] { - continue - } - - handled[resolvedFilename] = true - - switch tp { - case bundleLeaf: - if err := c.handleDir(resolvedFilename); err != nil { - return err - } - case bundleBranch: - if err := c.handleBranchDir(resolvedFilename); err != nil { - return err - } - default: - fi, err := c.resolveRealPath(resolvedFilename) - if os.IsNotExist(err) { - // File has been deleted. - continue - } - - // Just in case the owning dir is a new symlink -- this will - // create the proper mapping for it. - c.resolveRealPath(dir) - - f, active := c.newFileInfo(fi, tp) - if active { - c.copyOrHandleSingle(f) - } - } - } - - return nil -} - -func (c *capturer) capture() error { - if len(c.filenames) > 0 { - return c.capturePartial(c.filenames...) - } - - err := c.handleDir(helpers.FilePathSeparator) - if err != nil { - return err - } - - return nil -} - -func (c *capturer) handleNestedDir(dirname string) error { - select { - case c.sem <- true: - var g errgroup.Group - - g.Go(func() error { - defer func() { - <-c.sem - }() - return c.handleDir(dirname) - }) - return g.Wait() - default: - // For deeply nested file trees, waiting for a semaphore wil deadlock. - return c.handleDir(dirname) - } -} - -// This handles a bundle branch and its resources only. This is used -// in server mode on changes. If this dir does not (anymore) represent a bundle -// branch, the handling is upgraded to the full handleDir method. -func (c *capturer) handleBranchDir(dirname string) error { - files, err := c.readDir(dirname) - if err != nil { - - return err - } - - var ( - dirType bundleDirType - ) - - for _, fi := range files { - if !fi.IsDir() { - tp, _ := classifyBundledFile(fi.RealName()) - if dirType == bundleNot { - dirType = tp - } - - if dirType == bundleLeaf { - return c.handleDir(dirname) - } - } - } - - if dirType != bundleBranch { - return c.handleDir(dirname) - } - - dirs := newBundleDirs(bundleBranch, c) - - var secondPass []*fileInfo - - // Handle potential bundle headers first. - for _, fi := range files { - if fi.IsDir() { - continue - } - - tp, isContent := classifyBundledFile(fi.RealName()) - - f, active := c.newFileInfo(fi, tp) - - if !active { - continue - } - - if !f.isOwner() { - if !isContent { - // This is a partial update -- we only care about the files that - // is in this bundle. - secondPass = append(secondPass, f) - } - continue - } - dirs.addBundleHeader(f) - } - - for _, f := range secondPass { - dirs.addBundleFiles(f) - } - - c.handler.handleBundles(dirs) - - return nil - -} - -func (c *capturer) handleDir(dirname string) error { - - files, err := c.readDir(dirname) - if err != nil { - return err - } - - type dirState int - - const ( - dirStateDefault dirState = iota - - dirStateAssetsOnly - dirStateSinglesOnly - ) - - var ( - fileBundleTypes = make([]bundleDirType, len(files)) - - // Start with the assumption that this dir contains only non-content assets (images etc.) - // If that is still true after we had a first look at the list of files, we - // can just copy the files to destination. We will still have to look at the - // sub-folders for potential bundles. - state = dirStateAssetsOnly - - // Start with the assumption that this dir is not a bundle. - // A directory is a bundle if it contains a index content file, - // e.g. index.md (a leaf bundle) or a _index.md (a branch bundle). - bundleType = bundleNot - ) - - /* First check for any content files. - - If there are none, then this is a assets folder only (images etc.) - and we can just plainly copy them to - destination. - - If this is a section with no image etc. or similar, we can just handle it - as it was a single content file. - */ - var hasNonContent, isBranch bool - - for i, fi := range files { - if !fi.IsDir() { - tp, isContent := classifyBundledFile(fi.RealName()) - - fileBundleTypes[i] = tp - if !isBranch { - isBranch = tp == bundleBranch - } - - if isContent { - // This is not a assets-only folder. - state = dirStateDefault - } else { - hasNonContent = true - } - } - } - - if isBranch && !hasNonContent { - // This is a section or similar with no need for any bundle handling. - state = dirStateSinglesOnly - } - - if state > dirStateDefault { - return c.handleNonBundle(dirname, files, state == dirStateSinglesOnly) - } - - var fileInfos = make([]*fileInfo, 0, len(files)) - - for i, fi := range files { - - currentType := bundleNot - - if !fi.IsDir() { - currentType = fileBundleTypes[i] - if bundleType == bundleNot && currentType != bundleNot { - bundleType = currentType - } - } - - if bundleType == bundleNot && currentType != bundleNot { - bundleType = currentType - } - - f, active := c.newFileInfo(fi, currentType) - - if !active { - continue - } - - fileInfos = append(fileInfos, f) - } - - var todo []*fileInfo - - if bundleType != bundleLeaf { - for _, fi := range fileInfos { - if fi.FileInfo().IsDir() { - // Handle potential nested bundles. - if err := c.handleNestedDir(fi.Path()); err != nil { - return err - } - } else if bundleType == bundleNot || (!fi.isOwner() && fi.isContentFile()) { - // Not in a bundle. - c.copyOrHandleSingle(fi) - } else { - // This is a section folder or similar with non-content files in it. - todo = append(todo, fi) - } - } - } else { - todo = fileInfos - } - - if len(todo) == 0 { - return nil - } - - dirs, err := c.createBundleDirs(todo, bundleType) - if err != nil { - return err - } - - // Send the bundle to the next step in the processor chain. - c.handler.handleBundles(dirs) - - return nil -} - -func (c *capturer) handleNonBundle( - dirname string, - fileInfos pathLangFileFis, - singlesOnly bool) error { - - for _, fi := range fileInfos { - if fi.IsDir() { - if err := c.handleNestedDir(fi.Filename()); err != nil { - return err - } - } else { - if singlesOnly { - f, active := c.newFileInfo(fi, bundleNot) - if !active { - continue - } - c.handler.handleSingles(f) - } else { - c.handler.handleCopyFiles(fi) - } - } - } - - return nil -} - -func (c *capturer) copyOrHandleSingle(fi *fileInfo) { - if fi.isContentFile() { - c.handler.handleSingles(fi) - } else { - // These do not currently need any further processing. - c.handler.handleCopyFiles(fi) - } -} - -func (c *capturer) createBundleDirs(fileInfos []*fileInfo, bundleType bundleDirType) (*bundleDirs, error) { - dirs := newBundleDirs(bundleType, c) - - for _, fi := range fileInfos { - if fi.FileInfo().IsDir() { - var collector func(fis ...*fileInfo) - - if bundleType == bundleBranch { - // All files in the current directory are part of this bundle. - // Trying to include sub folders in these bundles are filled with ambiguity. - collector = func(fis ...*fileInfo) { - for _, fi := range fis { - c.copyOrHandleSingle(fi) - } - } - } else { - // All nested files and directories are part of this bundle. - collector = func(fis ...*fileInfo) { - fileInfos = append(fileInfos, fis...) - } - } - err := c.collectFiles(fi.Path(), collector) - if err != nil { - return nil, err - } - - } else if fi.isOwner() { - // There can be more than one language, so: - // 1. Content files must be attached to its language's bundle. - // 2. Other files must be attached to all languages. - // 3. Every content file needs a bundle header. - dirs.addBundleHeader(fi) - } - } - - for _, fi := range fileInfos { - if fi.FileInfo().IsDir() || fi.isOwner() { - continue - } - - if fi.isContentFile() { - if bundleType != bundleBranch { - dirs.addBundleContentFile(fi) - } - } else { - dirs.addBundleFiles(fi) - } - } - - return dirs, nil -} - -func (c *capturer) collectFiles(dirname string, handleFiles func(fis ...*fileInfo)) error { - - filesInDir, err := c.readDir(dirname) - if err != nil { - return err - } - - for _, fi := range filesInDir { - if fi.IsDir() { - err := c.collectFiles(fi.Filename(), handleFiles) - if err != nil { - return err - } - } else { - f, active := c.newFileInfo(fi, bundleNot) - if active { - handleFiles(f) - } - } - } - - return nil -} - -func (c *capturer) readDir(dirname string) (pathLangFileFis, error) { - if c.sourceSpec.IgnoreFile(dirname) { - return nil, nil - } - - dir, err := c.fs.Open(dirname) - if err != nil { - return nil, fmt.Errorf("readDir: %s", err) - } - defer dir.Close() - fis, err := dir.Readdir(-1) - if err != nil { - return nil, err - } - - pfis := make(pathLangFileFis, 0, len(fis)) - - for _, fi := range fis { - fip := fi.(pathLangFileFi) - - if !c.sourceSpec.IgnoreFile(fip.Filename()) { - - err := c.resolveRealPathIn(fip) - - if err != nil { - // It may have been deleted in the meantime. - if err == errSkipCyclicDir || os.IsNotExist(err) { - continue - } - return nil, err - } - - pfis = append(pfis, fip) - } - } - - return pfis, nil -} - -func (c *capturer) newFileInfo(fi pathLangFileFi, tp bundleDirType) (*fileInfo, bool) { - f := newFileInfo(c.sourceSpec, "", "", fi, tp) - return f, !f.disabled -} - -type pathLangFile interface { - hugofs.LanguageAnnouncer - hugofs.FilePather -} - -type pathLangFileFi interface { - os.FileInfo - pathLangFile -} - -type pathLangFileFis []pathLangFileFi - -type bundleDirs struct { - tp bundleDirType - // Maps languages to bundles. - bundles map[string]*bundleDir - - // Keeps track of language overrides for non-content files, e.g. logo.en.png. - langOverrides map[string]bool - - c *capturer -} - -func newBundleDirs(tp bundleDirType, c *capturer) *bundleDirs { - return &bundleDirs{tp: tp, bundles: make(map[string]*bundleDir), langOverrides: make(map[string]bool), c: c} -} - -type bundleDir struct { - tp bundleDirType - fi *fileInfo - - resources map[string]*fileInfo -} - -func (b bundleDir) clone() *bundleDir { - b.resources = make(map[string]*fileInfo) - fic := *b.fi - b.fi = &fic - return &b -} - -func newBundleDir(fi *fileInfo, bundleType bundleDirType) *bundleDir { - return &bundleDir{fi: fi, tp: bundleType, resources: make(map[string]*fileInfo)} -} - -func (b *bundleDirs) addBundleContentFile(fi *fileInfo) { - dir, found := b.bundles[fi.Lang()] - if !found { - // Every bundled content file needs a bundle header. - // If one does not exist in its language, we pick the default - // language version, or a random one if that doesn't exist, either. - tl := b.c.sourceSpec.DefaultContentLanguage - ldir, found := b.bundles[tl] - if !found { - // Just pick one. - for _, v := range b.bundles { - ldir = v - break - } - } - - if ldir == nil { - panic(fmt.Sprintf("bundle not found for file %q", fi.Filename())) - } - - dir = ldir.clone() - dir.fi.overriddenLang = fi.Lang() - b.bundles[fi.Lang()] = dir - } - - dir.resources[fi.Path()] = fi -} - -func (b *bundleDirs) addBundleFiles(fi *fileInfo) { - dir := filepath.ToSlash(fi.Dir()) - p := dir + fi.TranslationBaseName() + "." + fi.Ext() - for lang, bdir := range b.bundles { - key := path.Join(lang, p) - - // Given mypage.de.md (German translation) and mypage.md we pick the most - // specific for that language. - if fi.Lang() == lang || !b.langOverrides[key] { - bdir.resources[key] = fi - } - b.langOverrides[key] = true - } -} - -func (b *bundleDirs) addBundleHeader(fi *fileInfo) { - b.bundles[fi.Lang()] = newBundleDir(fi, b.tp) -} - -func (c *capturer) isSeen(dirname string) bool { - c.seenMu.Lock() - defer c.seenMu.Unlock() - seen := c.seen[dirname] - c.seen[dirname] = true - if seen { - c.logger.WARN.Printf("Content dir %q already processed; skipped to avoid infinite recursion.", dirname) - return true - - } - return false -} - -func (c *capturer) resolveRealPath(path string) (pathLangFileFi, error) { - fileInfo, err := c.lstatIfPossible(path) - if err != nil { - return nil, err - } - return fileInfo, c.resolveRealPathIn(fileInfo) -} - -func (c *capturer) resolveRealPathIn(fileInfo pathLangFileFi) error { - - basePath := fileInfo.BaseDir() - path := fileInfo.Filename() - - realPath := path - - if fileInfo.Mode()&os.ModeSymlink == os.ModeSymlink { - link, err := filepath.EvalSymlinks(path) - if err != nil { - return fmt.Errorf("Cannot read symbolic link %q, error was: %s", path, err) - } - - // This is a file on the outside of any base fs, so we have to use the os package. - sfi, err := os.Stat(link) - if err != nil { - return fmt.Errorf("Cannot stat %q, error was: %s", link, err) - } - - // TODO(bep) improve all of this. - if a, ok := fileInfo.(*hugofs.LanguageFileInfo); ok { - a.FileInfo = sfi - } - - realPath = link - - if realPath != path && sfi.IsDir() && c.isSeen(realPath) { - // Avoid cyclic symlinks. - // Note that this may prevent some uses that isn't cyclic and also - // potential useful, but this implementation is both robust and simple: - // We stop at the first directory that we have seen before, e.g. - // /content/blog will only be processed once. - return errSkipCyclicDir - } - - if c.contentChanges != nil { - // Keep track of symbolic links in watch mode. - var from, to string - if sfi.IsDir() { - from = realPath - to = path - - if !strings.HasSuffix(to, helpers.FilePathSeparator) { - to = to + helpers.FilePathSeparator - } - if !strings.HasSuffix(from, helpers.FilePathSeparator) { - from = from + helpers.FilePathSeparator - } - - if !strings.HasSuffix(basePath, helpers.FilePathSeparator) { - basePath = basePath + helpers.FilePathSeparator - } - - if strings.HasPrefix(from, basePath) { - // With symbolic links inside /content we need to keep - // a reference to both. This may be confusing with --navigateToChanged - // but the user has chosen this him or herself. - c.contentChanges.addSymbolicLinkMapping(from, from) - } - - } else { - from = realPath - to = path - } - - c.contentChanges.addSymbolicLinkMapping(from, to) - } - } - - return nil -} - -func (c *capturer) lstatIfPossible(path string) (pathLangFileFi, error) { - fi, err := helpers.LstatIfPossible(c.fs, path) - if err != nil { - return nil, err - } - return fi.(pathLangFileFi), nil -} diff --git a/hugolib/page_bundler_capture_test.go b/hugolib/page_bundler_capture_test.go deleted file mode 100644 index c07383797..000000000 --- a/hugolib/page_bundler_capture_test.go +++ /dev/null @@ -1,278 +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 hugolib - -import ( - "fmt" - "os" - "path" - "path/filepath" - "sort" - - jww "github.com/spf13/jwalterweatherman" - - "runtime" - "strings" - "sync" - "testing" - - "github.com/gohugoio/hugo/helpers" - "github.com/gohugoio/hugo/source" - "github.com/stretchr/testify/require" -) - -type storeFilenames struct { - sync.Mutex - filenames []string - copyNames []string - dirKeys []string -} - -func (s *storeFilenames) handleSingles(fis ...*fileInfo) { - s.Lock() - defer s.Unlock() - for _, fi := range fis { - s.filenames = append(s.filenames, filepath.ToSlash(fi.Filename())) - } -} - -func (s *storeFilenames) handleBundles(d *bundleDirs) { - s.Lock() - defer s.Unlock() - var keys []string - for _, b := range d.bundles { - res := make([]string, len(b.resources)) - i := 0 - for _, r := range b.resources { - res[i] = path.Join(r.Lang(), filepath.ToSlash(r.Filename())) - i++ - } - sort.Strings(res) - keys = append(keys, path.Join("__bundle", b.fi.Lang(), filepath.ToSlash(b.fi.Filename()), "resources", strings.Join(res, "|"))) - } - s.dirKeys = append(s.dirKeys, keys...) -} - -func (s *storeFilenames) handleCopyFiles(files ...pathLangFile) { - s.Lock() - defer s.Unlock() - for _, file := range files { - s.copyNames = append(s.copyNames, filepath.ToSlash(file.Filename())) - } -} - -func (s *storeFilenames) sortedStr() string { - s.Lock() - defer s.Unlock() - sort.Strings(s.filenames) - sort.Strings(s.dirKeys) - sort.Strings(s.copyNames) - return "\nF:\n" + strings.Join(s.filenames, "\n") + "\nD:\n" + strings.Join(s.dirKeys, "\n") + - "\nC:\n" + strings.Join(s.copyNames, "\n") + "\n" -} - -func TestPageBundlerCaptureSymlinks(t *testing.T) { - if runtime.GOOS == "windows" && os.Getenv("CI") == "" { - t.Skip("Skip TestPageBundlerCaptureSymlinks as os.Symlink needs administrator rights on Windows") - } - - assert := require.New(t) - ps, workDir := newTestBundleSymbolicSources(t) - sourceSpec := source.NewSourceSpec(ps, ps.BaseFs.ContentFs) - - fileStore := &storeFilenames{} - logger := newErrorLogger() - c := newCapturer(logger, sourceSpec, fileStore, nil) - - assert.NoError(c.capture()) - - // Symlink back to content skipped to prevent infinite recursion. - assert.Equal(uint64(3), logger.LogCountForLevelsGreaterThanorEqualTo(jww.LevelWarn)) - - expected := ` -F: -/base/a/page_s.md -/base/a/regular.md -/base/symbolic1/s1.md -/base/symbolic1/s2.md -/base/symbolic3/circus/a/page_s.md -/base/symbolic3/circus/a/regular.md -D: -__bundle/en/base/symbolic2/a1/index.md/resources/en/base/symbolic2/a1/logo.png|en/base/symbolic2/a1/page.md -C: -/base/symbolic3/s1.png -/base/symbolic3/s2.png -` - - got := strings.Replace(fileStore.sortedStr(), filepath.ToSlash(workDir), "", -1) - got = strings.Replace(got, "//", "/", -1) - - if expected != got { - diff := helpers.DiffStringSlices(strings.Fields(expected), strings.Fields(got)) - t.Log(got) - t.Fatalf("Failed:\n%s", diff) - } -} - -func TestPageBundlerCaptureBasic(t *testing.T) { - t.Parallel() - - assert := require.New(t) - fs, cfg := newTestBundleSources(t) - assert.NoError(loadDefaultSettingsFor(cfg)) - assert.NoError(loadLanguageSettings(cfg, nil)) - ps, err := helpers.NewPathSpec(fs, cfg) - assert.NoError(err) - - sourceSpec := source.NewSourceSpec(ps, ps.BaseFs.ContentFs) - - fileStore := &storeFilenames{} - - c := newCapturer(newErrorLogger(), sourceSpec, fileStore, nil) - - assert.NoError(c.capture()) - - printFs(fs.Source, "", os.Stdout) - - expected := ` -F: -/work/base/_1.md -/work/base/a/1.md -/work/base/a/2.md -/work/base/assets/pages/mypage.md -D: -__bundle/en/work/base/_index.md/resources/en/work/base/_1.png -__bundle/en/work/base/a/b/index.md/resources/en/work/base/a/b/ab1.md -__bundle/en/work/base/b/my-bundle/index.md/resources/en/work/base/b/my-bundle/1.md|en/work/base/b/my-bundle/2.md|en/work/base/b/my-bundle/c/logo.png|en/work/base/b/my-bundle/custom-mime.bep|en/work/base/b/my-bundle/sunset1.jpg|en/work/base/b/my-bundle/sunset2.jpg -__bundle/en/work/base/c/bundle/index.md/resources/en/work/base/c/bundle/logo-은행.png -__bundle/en/work/base/root/index.md/resources/en/work/base/root/1.md|en/work/base/root/c/logo.png -C: -/work/base/assets/pic1.png -/work/base/assets/pic2.png -/work/base/images/hugo-logo.png -` - - got := fileStore.sortedStr() - - if expected != got { - diff := helpers.DiffStringSlices(strings.Fields(expected), strings.Fields(got)) - t.Log(got) - t.Fatalf("Failed:\n%s", diff) - } -} - -func TestPageBundlerCaptureMultilingual(t *testing.T) { - t.Parallel() - - assert := require.New(t) - fs, cfg := newTestBundleSourcesMultilingual(t) - assert.NoError(loadDefaultSettingsFor(cfg)) - assert.NoError(loadLanguageSettings(cfg, nil)) - - ps, err := helpers.NewPathSpec(fs, cfg) - assert.NoError(err) - - sourceSpec := source.NewSourceSpec(ps, ps.BaseFs.ContentFs) - fileStore := &storeFilenames{} - c := newCapturer(newErrorLogger(), sourceSpec, fileStore, nil) - - assert.NoError(c.capture()) - - expected := ` -F: -/work/base/1s/mypage.md -/work/base/1s/mypage.nn.md -/work/base/bb/_1.md -/work/base/bb/_1.nn.md -/work/base/bb/en.md -/work/base/bc/page.md -/work/base/bc/page.nn.md -/work/base/be/_index.md -/work/base/be/page.md -/work/base/be/page.nn.md -D: -__bundle/en/work/base/bb/_index.md/resources/en/work/base/bb/a.png|en/work/base/bb/b.png|nn/work/base/bb/c.nn.png -__bundle/en/work/base/bc/_index.md/resources/en/work/base/bc/logo-bc.png -__bundle/en/work/base/bd/index.md/resources/en/work/base/bd/page.md -__bundle/en/work/base/bf/my-bf-bundle/index.md/resources/en/work/base/bf/my-bf-bundle/page.md -__bundle/en/work/base/lb/index.md/resources/en/work/base/lb/1.md|en/work/base/lb/2.md|en/work/base/lb/c/d/deep.png|en/work/base/lb/c/logo.png|en/work/base/lb/c/one.png|en/work/base/lb/c/page.md -__bundle/nn/work/base/bb/_index.nn.md/resources/en/work/base/bb/a.png|nn/work/base/bb/b.nn.png|nn/work/base/bb/c.nn.png -__bundle/nn/work/base/bd/index.md/resources/nn/work/base/bd/page.nn.md -__bundle/nn/work/base/bf/my-bf-bundle/index.nn.md/resources -__bundle/nn/work/base/lb/index.nn.md/resources/en/work/base/lb/c/d/deep.png|en/work/base/lb/c/one.png|nn/work/base/lb/2.nn.md|nn/work/base/lb/c/logo.nn.png -C: -/work/base/1s/mylogo.png -/work/base/bb/b/d.nn.png -` - - got := fileStore.sortedStr() - - if expected != got { - diff := helpers.DiffStringSlices(strings.Fields(expected), strings.Fields(got)) - t.Log(got) - t.Fatalf("Failed:\n%s", strings.Join(diff, "\n")) - } - -} - -type noOpFileStore int - -func (noOpFileStore) handleSingles(fis ...*fileInfo) {} -func (noOpFileStore) handleBundles(b *bundleDirs) {} -func (noOpFileStore) handleCopyFiles(files ...pathLangFile) {} - -func BenchmarkPageBundlerCapture(b *testing.B) { - capturers := make([]*capturer, b.N) - - for i := 0; i < b.N; i++ { - cfg, fs := newTestCfg() - ps, _ := helpers.NewPathSpec(fs, cfg) - sourceSpec := source.NewSourceSpec(ps, fs.Source) - - base := fmt.Sprintf("base%d", i) - for j := 1; j <= 5; j++ { - js := fmt.Sprintf("j%d", j) - writeSource(b, fs, filepath.Join(base, js, "index.md"), "content") - writeSource(b, fs, filepath.Join(base, js, "logo1.png"), "content") - writeSource(b, fs, filepath.Join(base, js, "sub", "logo2.png"), "content") - writeSource(b, fs, filepath.Join(base, js, "section", "_index.md"), "content") - writeSource(b, fs, filepath.Join(base, js, "section", "logo.png"), "content") - writeSource(b, fs, filepath.Join(base, js, "section", "sub", "logo.png"), "content") - - for k := 1; k <= 5; k++ { - ks := fmt.Sprintf("k%d", k) - writeSource(b, fs, filepath.Join(base, js, ks, "logo1.png"), "content") - writeSource(b, fs, filepath.Join(base, js, "section", ks, "logo.png"), "content") - } - } - - for i := 1; i <= 5; i++ { - writeSource(b, fs, filepath.Join(base, "assetsonly", fmt.Sprintf("image%d.png", i)), "image") - } - - for i := 1; i <= 5; i++ { - writeSource(b, fs, filepath.Join(base, "contentonly", fmt.Sprintf("c%d.md", i)), "content") - } - - capturers[i] = newCapturer(newErrorLogger(), sourceSpec, new(noOpFileStore), nil, base) - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - err := capturers[i].capture() - if err != nil { - b.Fatal(err) - } - } -} diff --git a/hugolib/page_bundler_handlers.go b/hugolib/page_bundler_handlers.go deleted file mode 100644 index 477f336fc..000000000 --- a/hugolib/page_bundler_handlers.go +++ /dev/null @@ -1,349 +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 hugolib - -import ( - "errors" - "fmt" - "path/filepath" - "sort" - - "strings" - - "github.com/gohugoio/hugo/helpers" - "github.com/gohugoio/hugo/resource" -) - -var ( - // This should be the only list of valid extensions for content files. - contentFileExtensions = []string{ - "html", "htm", - "mdown", "markdown", "md", - "asciidoc", "adoc", "ad", - "rest", "rst", - "mmark", - "org", - "pandoc", "pdc"} - - contentFileExtensionsSet map[string]bool -) - -func init() { - contentFileExtensionsSet = make(map[string]bool) - for _, ext := range contentFileExtensions { - contentFileExtensionsSet[ext] = true - } -} - -func newHandlerChain(s *Site) contentHandler { - c := &contentHandlers{s: s} - - contentFlow := c.parsePage(c.processFirstMatch( - // Handles all files with a content file extension. See above. - c.handlePageContent(), - - // Every HTML file without front matter will be passed on to this handler. - c.handleHTMLContent(), - )) - - c.rootHandler = c.processFirstMatch( - contentFlow, - - // Creates a file resource (image, CSS etc.) if there is a parent - // page set on the current context. - c.createResource(), - - // Everything that isn't handled above, will just be copied - // to destination. - c.copyFile(), - ) - - return c.rootHandler - -} - -type contentHandlers struct { - s *Site - rootHandler contentHandler -} - -func (c *contentHandlers) processFirstMatch(handlers ...contentHandler) func(ctx *handlerContext) handlerResult { - return func(ctx *handlerContext) handlerResult { - for _, h := range handlers { - res := h(ctx) - if res.handled || res.err != nil { - return res - } - } - return handlerResult{err: errors.New("no matching handler found")} - } -} - -type handlerContext struct { - // These are the pages stored in Site. - pages chan<- *Page - - doNotAddToSiteCollections bool - - currentPage *Page - parentPage *Page - - bundle *bundleDir - - source *fileInfo - - // Relative path to the target. - target string -} - -func (c *handlerContext) ext() string { - if c.currentPage != nil { - if c.currentPage.Markup != "" { - return c.currentPage.Markup - } - return c.currentPage.Ext() - } - - if c.bundle != nil { - return c.bundle.fi.Ext() - } else { - return c.source.Ext() - } -} - -func (c *handlerContext) targetPath() string { - if c.target != "" { - return c.target - } - - return c.source.Filename() -} - -func (c *handlerContext) file() *fileInfo { - if c.bundle != nil { - return c.bundle.fi - } - - return c.source -} - -// Create a copy with the current context as its parent. -func (c handlerContext) childCtx(fi *fileInfo) *handlerContext { - if c.currentPage == nil { - panic("Need a Page to create a child context") - } - - c.target = strings.TrimPrefix(fi.Path(), c.bundle.fi.Dir()) - c.source = fi - - c.doNotAddToSiteCollections = c.bundle != nil && c.bundle.tp != bundleBranch - - c.bundle = nil - - c.parentPage = c.currentPage - c.currentPage = nil - - return &c -} - -func (c *handlerContext) supports(exts ...string) bool { - ext := c.ext() - for _, s := range exts { - if s == ext { - return true - } - } - - return false -} - -func (c *handlerContext) isContentFile() bool { - return contentFileExtensionsSet[c.ext()] -} - -type ( - handlerResult struct { - err error - handled bool - resource resource.Resource - } - - contentHandler func(ctx *handlerContext) handlerResult -) - -var ( - notHandled handlerResult -) - -func (c *contentHandlers) parsePage(h contentHandler) contentHandler { - return func(ctx *handlerContext) handlerResult { - if !ctx.isContentFile() { - return notHandled - } - - result := handlerResult{handled: true} - fi := ctx.file() - - f, err := fi.Open() - if err != nil { - return handlerResult{err: fmt.Errorf("(%s) failed to open content file: %s", fi.Filename(), err)} - } - defer f.Close() - - p := c.s.newPageFromFile(fi) - - _, err = p.ReadFrom(f) - if err != nil { - return handlerResult{err: err} - } - - if !p.shouldBuild() { - if !ctx.doNotAddToSiteCollections { - ctx.pages <- p - } - return result - } - - ctx.currentPage = p - - if ctx.bundle != nil { - // Add the bundled files - for _, fi := range ctx.bundle.resources { - childCtx := ctx.childCtx(fi) - res := c.rootHandler(childCtx) - if res.err != nil { - return res - } - if res.resource != nil { - if pageResource, ok := res.resource.(*Page); ok { - pageResource.resourcePath = filepath.ToSlash(childCtx.target) - } - p.Resources = append(p.Resources, res.resource) - } - } - - sort.SliceStable(p.Resources, func(i, j int) bool { - if p.Resources[i].ResourceType() < p.Resources[j].ResourceType() { - return true - } - - p1, ok1 := p.Resources[i].(*Page) - p2, ok2 := p.Resources[j].(*Page) - - if ok1 != ok2 { - return ok2 - } - - if ok1 { - return defaultPageSort(p1, p2) - } - - return p.Resources[i].RelPermalink() < p.Resources[j].RelPermalink() - }) - - // Assign metadata from front matter if set - if len(p.resourcesMetadata) > 0 { - resource.AssignMetadata(p.resourcesMetadata, p.Resources...) - } - - } - - return h(ctx) - } -} - -func (c *contentHandlers) handlePageContent() contentHandler { - return func(ctx *handlerContext) handlerResult { - if ctx.supports("html", "htm") { - return notHandled - } - - p := ctx.currentPage - - // Work on a copy of the raw content from now on. - p.createWorkContentCopy() - - if err := p.processShortcodes(); err != nil { - p.s.Log.ERROR.Println(err) - } - - if c.s.Cfg.GetBool("enableEmoji") { - p.workContent = helpers.Emojify(p.workContent) - } - - p.workContent = p.replaceDivider(p.workContent) - p.workContent = p.renderContent(p.workContent) - - if !ctx.doNotAddToSiteCollections { - ctx.pages <- p - } - - return handlerResult{handled: true, resource: p} - } -} - -func (c *contentHandlers) handleHTMLContent() contentHandler { - return func(ctx *handlerContext) handlerResult { - if !ctx.supports("html", "htm") { - return notHandled - } - - p := ctx.currentPage - - p.createWorkContentCopy() - - if err := p.processShortcodes(); err != nil { - p.s.Log.ERROR.Println(err) - } - - if !ctx.doNotAddToSiteCollections { - ctx.pages <- p - } - - return handlerResult{handled: true, resource: p} - } -} - -func (c *contentHandlers) createResource() contentHandler { - return func(ctx *handlerContext) handlerResult { - if ctx.parentPage == nil { - return notHandled - } - - resource, err := c.s.resourceSpec.NewResourceFromFilename( - ctx.parentPage.subResourceTargetPathFactory, - ctx.source.Filename(), ctx.target) - - return handlerResult{err: err, handled: true, resource: resource} - } -} - -func (c *contentHandlers) copyFile() contentHandler { - return func(ctx *handlerContext) handlerResult { - f, err := c.s.BaseFs.ContentFs.Open(ctx.source.Filename()) - if err != nil { - err := fmt.Errorf("failed to open file in copyFile: %s", err) - return handlerResult{err: err} - } - - target := ctx.targetPath() - - defer f.Close() - if err := c.s.publish(&c.s.PathSpec.ProcessingStats.Files, target, f); err != nil { - return handlerResult{err: err} - } - - return handlerResult{handled: true} - } -} diff --git a/hugolib/page_bundler_test.go b/hugolib/page_bundler_test.go deleted file mode 100644 index 34e4ef6e4..000000000 --- a/hugolib/page_bundler_test.go +++ /dev/null @@ -1,733 +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 hugolib - -import ( - "io/ioutil" - "os" - "runtime" - "strings" - "testing" - - "github.com/gohugoio/hugo/helpers" - - "io" - - "github.com/spf13/afero" - - "github.com/gohugoio/hugo/media" - - "path/filepath" - - "fmt" - - "github.com/gohugoio/hugo/deps" - "github.com/gohugoio/hugo/hugofs" - "github.com/gohugoio/hugo/resource" - "github.com/spf13/viper" - - "github.com/stretchr/testify/require" -) - -func TestPageBundlerSiteRegular(t *testing.T) { - t.Parallel() - - for _, ugly := range []bool{false, true} { - t.Run(fmt.Sprintf("ugly=%t", ugly), - func(t *testing.T) { - - assert := require.New(t) - fs, cfg := newTestBundleSources(t) - assert.NoError(loadDefaultSettingsFor(cfg)) - assert.NoError(loadLanguageSettings(cfg, nil)) - - cfg.Set("permalinks", map[string]string{ - "a": ":sections/:filename", - "b": ":year/:slug/", - "c": ":sections/:slug", - "": ":filename/", - }) - - cfg.Set("outputFormats", map[string]interface{}{ - "CUSTOMO": map[string]interface{}{ - "mediaType": media.HTMLType, - "baseName": "cindex", - "path": "cpath", - }, - }) - - cfg.Set("outputs", map[string]interface{}{ - "home": []string{"HTML", "CUSTOMO"}, - "page": []string{"HTML", "CUSTOMO"}, - "section": []string{"HTML", "CUSTOMO"}, - }) - - cfg.Set("uglyURLs", ugly) - - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{}) - - th := testHelper{s.Cfg, s.Fs, t} - - assert.Len(s.RegularPages, 8) - - singlePage := s.getPage(KindPage, "a/1.md") - - assert.NotNil(singlePage) - assert.Equal(singlePage, s.getPage("page", "a/1")) - assert.Equal(singlePage, s.getPage("page", "1")) - - assert.Contains(singlePage.Content, "TheContent") - - if ugly { - assert.Equal("/a/1.html", singlePage.RelPermalink()) - th.assertFileContent(filepath.FromSlash("/work/public/a/1.html"), "TheContent") - - } else { - assert.Equal("/a/1/", singlePage.RelPermalink()) - th.assertFileContent(filepath.FromSlash("/work/public/a/1/index.html"), "TheContent") - } - - th.assertFileContent(filepath.FromSlash("/work/public/images/hugo-logo.png"), "content") - - // This should be just copied to destination. - th.assertFileContent(filepath.FromSlash("/work/public/assets/pic1.png"), "content") - - leafBundle1 := s.getPage(KindPage, "b/my-bundle/index.md") - assert.NotNil(leafBundle1) - assert.Equal("b", leafBundle1.Section()) - assert.NotNil(s.getPage(KindSection, "b")) - - // This is a root bundle and should live in the "home section" - // See https://github.com/gohugoio/hugo/issues/4332 - rootBundle := s.getPage(KindPage, "root") - assert.NotNil(rootBundle) - assert.True(rootBundle.Parent().IsHome()) - if ugly { - assert.Equal("/root.html", rootBundle.RelPermalink()) - } else { - assert.Equal("/root/", rootBundle.RelPermalink()) - } - - leafBundle2 := s.getPage(KindPage, "a/b/index.md") - assert.NotNil(leafBundle2) - unicodeBundle := s.getPage(KindPage, "c/bundle/index.md") - assert.NotNil(unicodeBundle) - - pageResources := leafBundle1.Resources.ByType(pageResourceType) - assert.Len(pageResources, 2) - firstPage := pageResources[0].(*Page) - secondPage := pageResources[1].(*Page) - assert.Equal(filepath.FromSlash("b/my-bundle/1.md"), firstPage.pathOrTitle(), secondPage.pathOrTitle()) - assert.Contains(firstPage.Content, "TheContent") - assert.Equal(6, len(leafBundle1.Resources)) - - assert.Equal(firstPage, pageResources.GetByPrefix("1")) - assert.Equal(secondPage, pageResources.GetByPrefix("2")) - assert.Nil(pageResources.GetByPrefix("doesnotexist")) - - imageResources := leafBundle1.Resources.ByType("image") - assert.Equal(3, len(imageResources)) - image := imageResources[0] - - altFormat := leafBundle1.OutputFormats().Get("CUSTOMO") - assert.NotNil(altFormat) - - assert.Equal(filepath.FromSlash("/work/base/b/my-bundle/c/logo.png"), image.(resource.Source).AbsSourceFilename()) - assert.Equal("https://example.com/2017/pageslug/c/logo.png", image.Permalink()) - - printFs(th.Fs.Destination, "", os.Stdout) - th.assertFileContent(filepath.FromSlash("/work/public/2017/pageslug/c/logo.png"), "content") - th.assertFileContent(filepath.FromSlash("/work/public/cpath/2017/pageslug/c/logo.png"), "content") - - // Custom media type defined in site config. - assert.Len(leafBundle1.Resources.ByType("bepsays"), 1) - - if ugly { - assert.Equal("/2017/pageslug.html", leafBundle1.RelPermalink()) - th.assertFileContent(filepath.FromSlash("/work/public/2017/pageslug.html"), - "TheContent", - "Sunset RelPermalink: /2017/pageslug/sunset1.jpg", - "Thumb Width: 123", - "Thumb Name: my-sunset-1", - "Short Sunset RelPermalink: /2017/pageslug/sunset2.jpg", - "Short Thumb Width: 56", - "1: Image Title: Sunset Galore 1", - "1: Image Params: map[myparam:My Sunny Param]", - "2: Image Title: Sunset Galore 2", - "2: Image Params: map[myparam:My Sunny Param]", - "1: Image myParam: Lower: My Sunny Param Caps: My Sunny Param", - ) - th.assertFileContent(filepath.FromSlash("/work/public/cpath/2017/pageslug.html"), "TheContent") - - assert.Equal("/a/b.html", leafBundle2.RelPermalink()) - - // 은행 - assert.Equal("/c/%EC%9D%80%ED%96%89.html", unicodeBundle.RelPermalink()) - th.assertFileContent(filepath.FromSlash("/work/public/c/은행.html"), "Content for 은행") - th.assertFileContent(filepath.FromSlash("/work/public/c/은행/logo-은행.png"), "은행 PNG") - - } else { - assert.Equal("/2017/pageslug/", leafBundle1.RelPermalink()) - th.assertFileContent(filepath.FromSlash("/work/public/2017/pageslug/index.html"), "TheContent") - th.assertFileContent(filepath.FromSlash("/work/public/cpath/2017/pageslug/cindex.html"), "TheContent") - th.assertFileContent(filepath.FromSlash("/work/public/2017/pageslug/index.html"), "Single Title") - th.assertFileContent(filepath.FromSlash("/work/public/root/index.html"), "Single Title") - - assert.Equal("/a/b/", leafBundle2.RelPermalink()) - - } - - }) - } - -} - -func TestPageBundlerSiteMultilingual(t *testing.T) { - t.Parallel() - - for _, ugly := range []bool{false, true} { - t.Run(fmt.Sprintf("ugly=%t", ugly), - func(t *testing.T) { - - assert := require.New(t) - fs, cfg := newTestBundleSourcesMultilingual(t) - cfg.Set("uglyURLs", ugly) - - assert.NoError(loadDefaultSettingsFor(cfg)) - assert.NoError(loadLanguageSettings(cfg, nil)) - sites, err := NewHugoSites(deps.DepsCfg{Fs: fs, Cfg: cfg}) - assert.NoError(err) - assert.Equal(2, len(sites.Sites)) - - assert.NoError(sites.Build(BuildCfg{})) - - s := sites.Sites[0] - - assert.Equal(8, len(s.RegularPages)) - assert.Equal(16, len(s.Pages)) - assert.Equal(31, len(s.AllPages)) - - bundleWithSubPath := s.getPage(KindPage, "lb/index") - assert.NotNil(bundleWithSubPath) - - // See https://github.com/gohugoio/hugo/issues/4312 - // Before that issue: - // A bundle in a/b/index.en.md - // a/b/index.en.md => OK - // a/b/index => OK - // index.en.md => ambigous, but OK. - // With bundles, the file name has little meaning, the folder it lives in does. So this should also work: - // a/b - // and probably also just b (aka "my-bundle") - // These may also be translated, so we also need to test that. - // "bf", "my-bf-bundle", "index.md + nn - bfBundle := s.getPage(KindPage, "bf/my-bf-bundle/index") - assert.NotNil(bfBundle) - assert.Equal("en", bfBundle.Lang()) - assert.Equal(bfBundle, s.getPage(KindPage, "bf/my-bf-bundle/index.md")) - assert.Equal(bfBundle, s.getPage(KindPage, "bf/my-bf-bundle")) - assert.Equal(bfBundle, s.getPage(KindPage, "my-bf-bundle")) - - nnSite := sites.Sites[1] - assert.Equal(7, len(nnSite.RegularPages)) - - bfBundleNN := nnSite.getPage(KindPage, "bf/my-bf-bundle/index") - assert.NotNil(bfBundleNN) - assert.Equal("nn", bfBundleNN.Lang()) - assert.Equal(bfBundleNN, nnSite.getPage(KindPage, "bf/my-bf-bundle/index.nn.md")) - assert.Equal(bfBundleNN, nnSite.getPage(KindPage, "bf/my-bf-bundle")) - assert.Equal(bfBundleNN, nnSite.getPage(KindPage, "my-bf-bundle")) - - // See https://github.com/gohugoio/hugo/issues/4295 - // Every resource should have its Name prefixed with its base folder. - cBundleResources := bundleWithSubPath.Resources.ByPrefix("c/") - assert.Equal(4, len(cBundleResources)) - bundlePage := bundleWithSubPath.Resources.GetByPrefix("c/page") - assert.NotNil(bundlePage) - assert.IsType(&Page{}, bundlePage) - - }) - } -} - -func TestMultilingualDisableDefaultLanguage(t *testing.T) { - t.Parallel() - - assert := require.New(t) - _, cfg := newTestBundleSourcesMultilingual(t) - - cfg.Set("disableLanguages", []string{"en"}) - - err := loadDefaultSettingsFor(cfg) - assert.NoError(err) - err = loadLanguageSettings(cfg, nil) - assert.Error(err) - assert.Contains(err.Error(), "cannot disable default language") -} - -func TestMultilingualDisableLanguage(t *testing.T) { - t.Parallel() - - assert := require.New(t) - fs, cfg := newTestBundleSourcesMultilingual(t) - cfg.Set("disableLanguages", []string{"nn"}) - - assert.NoError(loadDefaultSettingsFor(cfg)) - assert.NoError(loadLanguageSettings(cfg, nil)) - - sites, err := NewHugoSites(deps.DepsCfg{Fs: fs, Cfg: cfg}) - assert.NoError(err) - assert.Equal(1, len(sites.Sites)) - - assert.NoError(sites.Build(BuildCfg{})) - - s := sites.Sites[0] - - assert.Equal(8, len(s.RegularPages)) - assert.Equal(16, len(s.Pages)) - // No nn pages - assert.Equal(16, len(s.AllPages)) - for _, p := range s.rawAllPages { - assert.True(p.Lang() != "nn") - } - for _, p := range s.AllPages { - assert.True(p.Lang() != "nn") - } - -} - -func TestPageBundlerSiteWitSymbolicLinksInContent(t *testing.T) { - if runtime.GOOS == "windows" && os.Getenv("CI") == "" { - t.Skip("Skip TestPageBundlerSiteWitSymbolicLinksInContent as os.Symlink needs administrator rights on Windows") - } - - assert := require.New(t) - ps, workDir := newTestBundleSymbolicSources(t) - cfg := ps.Cfg - fs := ps.Fs - - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg, Logger: newErrorLogger()}, BuildCfg{}) - - th := testHelper{s.Cfg, s.Fs, t} - - assert.Equal(7, len(s.RegularPages)) - a1Bundle := s.getPage(KindPage, "symbolic2/a1/index.md") - assert.NotNil(a1Bundle) - assert.Equal(2, len(a1Bundle.Resources)) - assert.Equal(1, len(a1Bundle.Resources.ByType(pageResourceType))) - - th.assertFileContent(filepath.FromSlash(workDir+"/public/a/page/index.html"), "TheContent") - th.assertFileContent(filepath.FromSlash(workDir+"/public/symbolic1/s1/index.html"), "TheContent") - th.assertFileContent(filepath.FromSlash(workDir+"/public/symbolic2/a1/index.html"), "TheContent") - -} - -func TestPageBundlerHeadless(t *testing.T) { - t.Parallel() - - cfg, fs := newTestCfg() - assert := require.New(t) - - workDir := "/work" - cfg.Set("workingDir", workDir) - cfg.Set("contentDir", "base") - cfg.Set("baseURL", "https://example.com") - - pageContent := `--- -title: "Bundle Galore" -slug: s1 -date: 2017-01-23 ---- - -TheContent. - -{{< myShort >}} -` - - writeSource(t, fs, filepath.Join(workDir, "layouts", "_default", "single.html"), "single {{ .Content }}") - writeSource(t, fs, filepath.Join(workDir, "layouts", "_default", "list.html"), "list") - writeSource(t, fs, filepath.Join(workDir, "layouts", "shortcodes", "myShort.html"), "SHORTCODE") - - writeSource(t, fs, filepath.Join(workDir, "base", "a", "index.md"), pageContent) - writeSource(t, fs, filepath.Join(workDir, "base", "a", "l1.png"), "PNG image") - writeSource(t, fs, filepath.Join(workDir, "base", "a", "l2.png"), "PNG image") - - writeSource(t, fs, filepath.Join(workDir, "base", "b", "index.md"), `--- -title: "Headless Bundle in Topless Bar" -slug: s2 -headless: true -date: 2017-01-23 ---- - -TheContent. -HEADLESS {{< myShort >}} -`) - writeSource(t, fs, filepath.Join(workDir, "base", "b", "l1.png"), "PNG image") - writeSource(t, fs, filepath.Join(workDir, "base", "b", "l2.png"), "PNG image") - writeSource(t, fs, filepath.Join(workDir, "base", "b", "p1.md"), pageContent) - - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{}) - - assert.Equal(1, len(s.RegularPages)) - assert.Equal(1, len(s.headlessPages)) - - regular := s.getPage(KindPage, "a/index") - assert.Equal("/a/s1/", regular.RelPermalink()) - - headless := s.getPage(KindPage, "b/index") - assert.NotNil(headless) - assert.True(headless.headless) - assert.Equal("Headless Bundle in Topless Bar", headless.Title()) - assert.Equal("", headless.RelPermalink()) - assert.Equal("", headless.Permalink()) - assert.Contains(headless.Content, "HEADLESS SHORTCODE") - - headlessResources := headless.Resources - assert.Equal(3, len(headlessResources)) - assert.Equal(2, len(headlessResources.Match("l*"))) - pageResource := headlessResources.GetMatch("p*") - assert.NotNil(pageResource) - assert.IsType(&Page{}, pageResource) - p := pageResource.(*Page) - assert.Contains(p.Content, "SHORTCODE") - assert.Equal("p1.md", p.Name()) - - th := testHelper{s.Cfg, s.Fs, t} - - th.assertFileContent(filepath.FromSlash(workDir+"/public/a/s1/index.html"), "TheContent") - th.assertFileContent(filepath.FromSlash(workDir+"/public/a/s1/l1.png"), "PNG") - - th.assertFileNotExist(workDir + "/public/b/s2/index.html") - // But the bundled resources needs to be published - th.assertFileContent(filepath.FromSlash(workDir+"/public/b/s2/l1.png"), "PNG") - -} - -func newTestBundleSources(t *testing.T) (*hugofs.Fs, *viper.Viper) { - cfg, fs := newTestCfg() - assert := require.New(t) - - workDir := "/work" - cfg.Set("workingDir", workDir) - cfg.Set("contentDir", "base") - cfg.Set("baseURL", "https://example.com") - cfg.Set("mediaTypes", map[string]interface{}{ - "text/bepsays": map[string]interface{}{ - "suffix": "bep", - }, - }) - - pageContent := `--- -title: "Bundle Galore" -slug: pageslug -date: 2017-10-09 ---- - -TheContent. -` - - pageWithImageShortcodeAndResourceMetadataContent := `--- -title: "Bundle Galore" -slug: pageslug -date: 2017-10-09 -resources: -- src: "*.jpg" - name: "my-sunset-:counter" - title: "Sunset Galore :counter" - params: - myParam: "My Sunny Param" ---- - -TheContent. - -{{< myShort >}} -` - - pageContentNoSlug := `--- -title: "Bundle Galore #2" -date: 2017-10-09 ---- - -TheContent. -` - - singleLayout := ` -Single Title: {{ .Title }} -Content: {{ .Content }} -{{ $sunset := .Resources.GetByPrefix "my-sunset-1" }} -{{ with $sunset }} -Sunset RelPermalink: {{ .RelPermalink }} -{{ $thumb := .Fill "123x123" }} -Thumb Width: {{ $thumb.Width }} -Thumb Name: {{ $thumb.Name }} -Thumb Title: {{ $thumb.Title }} -Thumb RelPermalink: {{ $thumb.RelPermalink }} -{{ end }} -{{ range $i, $e := .Resources.ByType "image" }} -{{ $i }}: Image Title: {{ .Title }} -{{ $i }}: Image Name: {{ .Name }} -{{ $i }}: Image Params: {{ printf "%v" .Params }} -{{ $i }}: Image myParam: Lower: {{ .Params.myparam }} Caps: {{ .Params.MYPARAM }} -{{ end }} -` - - myShort := ` -{{ $sunset := .Page.Resources.GetByPrefix "my-sunset-2" }} -{{ with $sunset }} -Short Sunset RelPermalink: {{ .RelPermalink }} -{{ $thumb := .Fill "56x56" }} -Short Thumb Width: {{ $thumb.Width }} -{{ end }} -` - - listLayout := `{{ .Title }}|{{ .Content }}` - - writeSource(t, fs, filepath.Join(workDir, "layouts", "_default", "single.html"), singleLayout) - writeSource(t, fs, filepath.Join(workDir, "layouts", "_default", "list.html"), listLayout) - writeSource(t, fs, filepath.Join(workDir, "layouts", "shortcodes", "myShort.html"), myShort) - - writeSource(t, fs, filepath.Join(workDir, "base", "_index.md"), pageContent) - writeSource(t, fs, filepath.Join(workDir, "base", "_1.md"), pageContent) - writeSource(t, fs, filepath.Join(workDir, "base", "_1.png"), pageContent) - - writeSource(t, fs, filepath.Join(workDir, "base", "images", "hugo-logo.png"), "content") - writeSource(t, fs, filepath.Join(workDir, "base", "a", "2.md"), pageContent) - writeSource(t, fs, filepath.Join(workDir, "base", "a", "1.md"), pageContent) - - writeSource(t, fs, filepath.Join(workDir, "base", "a", "b", "index.md"), pageContentNoSlug) - writeSource(t, fs, filepath.Join(workDir, "base", "a", "b", "ab1.md"), pageContentNoSlug) - - // Mostly plain static assets in a folder with a page in a sub folder thrown in. - writeSource(t, fs, filepath.Join(workDir, "base", "assets", "pic1.png"), "content") - writeSource(t, fs, filepath.Join(workDir, "base", "assets", "pic2.png"), "content") - writeSource(t, fs, filepath.Join(workDir, "base", "assets", "pages", "mypage.md"), pageContent) - - // Bundle - writeSource(t, fs, filepath.Join(workDir, "base", "b", "my-bundle", "index.md"), pageWithImageShortcodeAndResourceMetadataContent) - writeSource(t, fs, filepath.Join(workDir, "base", "b", "my-bundle", "1.md"), pageContent) - writeSource(t, fs, filepath.Join(workDir, "base", "b", "my-bundle", "2.md"), pageContent) - writeSource(t, fs, filepath.Join(workDir, "base", "b", "my-bundle", "custom-mime.bep"), "bepsays") - writeSource(t, fs, filepath.Join(workDir, "base", "b", "my-bundle", "c", "logo.png"), "content") - - // Bundle with 은행 slug - // See https://github.com/gohugoio/hugo/issues/4241 - writeSource(t, fs, filepath.Join(workDir, "base", "c", "bundle", "index.md"), `--- -title: "은행 은행" -slug: 은행 -date: 2017-10-09 ---- - -Content for 은행. -`) - - // Bundle in root - writeSource(t, fs, filepath.Join(workDir, "base", "root", "index.md"), pageWithImageShortcodeAndResourceMetadataContent) - writeSource(t, fs, filepath.Join(workDir, "base", "root", "1.md"), pageContent) - writeSource(t, fs, filepath.Join(workDir, "base", "root", "c", "logo.png"), "content") - - writeSource(t, fs, filepath.Join(workDir, "base", "c", "bundle", "logo-은행.png"), "은행 PNG") - - // Write a real image into one of the bundle above. - src, err := os.Open("testdata/sunset.jpg") - assert.NoError(err) - - // We need 2 to test https://github.com/gohugoio/hugo/issues/4202 - out, err := fs.Source.Create(filepath.Join(workDir, "base", "b", "my-bundle", "sunset1.jpg")) - assert.NoError(err) - out2, err := fs.Source.Create(filepath.Join(workDir, "base", "b", "my-bundle", "sunset2.jpg")) - assert.NoError(err) - - _, err = io.Copy(out, src) - out.Close() - src.Seek(0, 0) - _, err = io.Copy(out2, src) - out2.Close() - src.Close() - assert.NoError(err) - - return fs, cfg - -} - -func newTestBundleSourcesMultilingual(t *testing.T) (*hugofs.Fs, *viper.Viper) { - cfg, fs := newTestCfg() - - workDir := "/work" - cfg.Set("workingDir", workDir) - cfg.Set("contentDir", "base") - cfg.Set("baseURL", "https://example.com") - cfg.Set("defaultContentLanguage", "en") - - langConfig := map[string]interface{}{ - "en": map[string]interface{}{ - "weight": 1, - "languageName": "English", - }, - "nn": map[string]interface{}{ - "weight": 2, - "languageName": "Nynorsk", - }, - } - - cfg.Set("languages", langConfig) - - pageContent := `--- -slug: pageslug -date: 2017-10-09 ---- - -TheContent. -` - - layout := `{{ .Title }}|{{ .Content }}|Lang: {{ .Site.Language.Lang }}` - - writeSource(t, fs, filepath.Join(workDir, "layouts", "_default", "single.html"), layout) - writeSource(t, fs, filepath.Join(workDir, "layouts", "_default", "list.html"), layout) - - writeSource(t, fs, filepath.Join(workDir, "base", "1s", "mypage.md"), pageContent) - writeSource(t, fs, filepath.Join(workDir, "base", "1s", "mypage.nn.md"), pageContent) - writeSource(t, fs, filepath.Join(workDir, "base", "1s", "mylogo.png"), "content") - - writeSource(t, fs, filepath.Join(workDir, "base", "bb", "_index.md"), pageContent) - writeSource(t, fs, filepath.Join(workDir, "base", "bb", "_index.nn.md"), pageContent) - writeSource(t, fs, filepath.Join(workDir, "base", "bb", "en.md"), pageContent) - writeSource(t, fs, filepath.Join(workDir, "base", "bb", "_1.md"), pageContent) - writeSource(t, fs, filepath.Join(workDir, "base", "bb", "_1.nn.md"), pageContent) - writeSource(t, fs, filepath.Join(workDir, "base", "bb", "a.png"), "content") - writeSource(t, fs, filepath.Join(workDir, "base", "bb", "b.png"), "content") - writeSource(t, fs, filepath.Join(workDir, "base", "bb", "b.nn.png"), "content") - writeSource(t, fs, filepath.Join(workDir, "base", "bb", "c.nn.png"), "content") - writeSource(t, fs, filepath.Join(workDir, "base", "bb", "b", "d.nn.png"), "content") - - writeSource(t, fs, filepath.Join(workDir, "base", "bc", "_index.md"), pageContent) - writeSource(t, fs, filepath.Join(workDir, "base", "bc", "page.md"), pageContent) - writeSource(t, fs, filepath.Join(workDir, "base", "bc", "logo-bc.png"), pageContent) - writeSource(t, fs, filepath.Join(workDir, "base", "bc", "page.nn.md"), pageContent) - - writeSource(t, fs, filepath.Join(workDir, "base", "bd", "index.md"), pageContent) - writeSource(t, fs, filepath.Join(workDir, "base", "bd", "page.md"), pageContent) - writeSource(t, fs, filepath.Join(workDir, "base", "bd", "page.nn.md"), pageContent) - - writeSource(t, fs, filepath.Join(workDir, "base", "be", "_index.md"), pageContent) - writeSource(t, fs, filepath.Join(workDir, "base", "be", "page.md"), pageContent) - writeSource(t, fs, filepath.Join(workDir, "base", "be", "page.nn.md"), pageContent) - - // Bundle leaf, multilingual - writeSource(t, fs, filepath.Join(workDir, "base", "lb", "index.md"), pageContent) - writeSource(t, fs, filepath.Join(workDir, "base", "lb", "index.nn.md"), pageContent) - writeSource(t, fs, filepath.Join(workDir, "base", "lb", "1.md"), pageContent) - writeSource(t, fs, filepath.Join(workDir, "base", "lb", "2.md"), pageContent) - writeSource(t, fs, filepath.Join(workDir, "base", "lb", "2.nn.md"), pageContent) - writeSource(t, fs, filepath.Join(workDir, "base", "lb", "c", "page.md"), pageContent) - writeSource(t, fs, filepath.Join(workDir, "base", "lb", "c", "logo.png"), "content") - writeSource(t, fs, filepath.Join(workDir, "base", "lb", "c", "logo.nn.png"), "content") - writeSource(t, fs, filepath.Join(workDir, "base", "lb", "c", "one.png"), "content") - writeSource(t, fs, filepath.Join(workDir, "base", "lb", "c", "d", "deep.png"), "content") - - //Translated bundle in some sensible sub path. - writeSource(t, fs, filepath.Join(workDir, "base", "bf", "my-bf-bundle", "index.md"), pageContent) - writeSource(t, fs, filepath.Join(workDir, "base", "bf", "my-bf-bundle", "index.nn.md"), pageContent) - writeSource(t, fs, filepath.Join(workDir, "base", "bf", "my-bf-bundle", "page.md"), pageContent) - - return fs, cfg -} - -func newTestBundleSymbolicSources(t *testing.T) (*helpers.PathSpec, string) { - assert := require.New(t) - // We need to use the OS fs for this. - cfg := viper.New() - fs := hugofs.NewFrom(hugofs.Os, cfg) - fs.Destination = &afero.MemMapFs{} - loadDefaultSettingsFor(cfg) - - workDir, err := ioutil.TempDir("", "hugosym") - - if runtime.GOOS == "darwin" && !strings.HasPrefix(workDir, "/private") { - // To get the entry folder in line with the rest. This its a little bit - // mysterious, but so be it. - workDir = "/private" + workDir - } - - contentDir := "base" - cfg.Set("workingDir", workDir) - cfg.Set("contentDir", contentDir) - cfg.Set("baseURL", "https://example.com") - - if err := loadLanguageSettings(cfg, nil); err != nil { - t.Fatal(err) - } - - layout := `{{ .Title }}|{{ .Content }}` - pageContent := `--- -slug: %s -date: 2017-10-09 ---- - -TheContent. -` - - fs.Source.MkdirAll(filepath.Join(workDir, "layouts", "_default"), 0777) - fs.Source.MkdirAll(filepath.Join(workDir, contentDir), 0777) - fs.Source.MkdirAll(filepath.Join(workDir, contentDir, "a"), 0777) - for i := 1; i <= 3; i++ { - fs.Source.MkdirAll(filepath.Join(workDir, fmt.Sprintf("symcontent%d", i)), 0777) - - } - fs.Source.MkdirAll(filepath.Join(workDir, "symcontent2", "a1"), 0777) - - writeSource(t, fs, filepath.Join(workDir, "layouts", "_default", "single.html"), layout) - writeSource(t, fs, filepath.Join(workDir, "layouts", "_default", "list.html"), layout) - - writeSource(t, fs, filepath.Join(workDir, contentDir, "a", "regular.md"), fmt.Sprintf(pageContent, "a1")) - - // Regular files inside symlinked folder. - writeSource(t, fs, filepath.Join(workDir, "symcontent1", "s1.md"), fmt.Sprintf(pageContent, "s1")) - writeSource(t, fs, filepath.Join(workDir, "symcontent1", "s2.md"), fmt.Sprintf(pageContent, "s2")) - - // A bundle - writeSource(t, fs, filepath.Join(workDir, "symcontent2", "a1", "index.md"), fmt.Sprintf(pageContent, "")) - writeSource(t, fs, filepath.Join(workDir, "symcontent2", "a1", "page.md"), fmt.Sprintf(pageContent, "page")) - writeSource(t, fs, filepath.Join(workDir, "symcontent2", "a1", "logo.png"), "image") - - // Assets - writeSource(t, fs, filepath.Join(workDir, "symcontent3", "s1.png"), "image") - writeSource(t, fs, filepath.Join(workDir, "symcontent3", "s2.png"), "image") - - wd, _ := os.Getwd() - defer func() { - os.Chdir(wd) - }() - // Symlinked sections inside content. - os.Chdir(filepath.Join(workDir, contentDir)) - for i := 1; i <= 3; i++ { - assert.NoError(os.Symlink(filepath.FromSlash(fmt.Sprintf(("../symcontent%d"), i)), fmt.Sprintf("symbolic%d", i))) - } - - os.Chdir(filepath.Join(workDir, contentDir, "a")) - - // Create a symlink to one single content file - assert.NoError(os.Symlink(filepath.FromSlash("../../symcontent2/a1/page.md"), "page_s.md")) - - os.Chdir(filepath.FromSlash("../../symcontent3")) - - // Create a circular symlink. Will print some warnings. - assert.NoError(os.Symlink(filepath.Join("..", contentDir), filepath.FromSlash("circus"))) - - os.Chdir(workDir) - assert.NoError(err) - - ps, _ := helpers.NewPathSpec(fs, cfg) - - return ps, workDir -} diff --git a/hugolib/page_collections.go b/hugolib/page_collections.go deleted file mode 100644 index 74f7d608c..000000000 --- a/hugolib/page_collections.go +++ /dev/null @@ -1,225 +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 hugolib - -import ( - "path" - "path/filepath" - "strings" - - "github.com/gohugoio/hugo/cache" - "github.com/gohugoio/hugo/helpers" -) - -// PageCollections contains the page collections for a site. -type PageCollections struct { - // Includes only pages of all types, and only pages in the current language. - Pages Pages - - // Includes all pages in all languages, including the current one. - // Includes pages of all types. - AllPages Pages - - // A convenience cache for the traditional index types, taxonomies, home page etc. - // This is for the current language only. - indexPages Pages - - // A convenience cache for the regular pages. - // This is for the current language only. - RegularPages Pages - - // A convenience cache for the all the regular pages. - AllRegularPages Pages - - // Includes absolute all pages (of all types), including drafts etc. - rawAllPages Pages - - // Includes headless bundles, i.e. bundles that produce no output for its content page. - headlessPages Pages - - pageCache *cache.PartitionedLazyCache -} - -func (c *PageCollections) refreshPageCaches() { - c.indexPages = c.findPagesByKindNotIn(KindPage, c.Pages) - c.RegularPages = c.findPagesByKindIn(KindPage, c.Pages) - c.AllRegularPages = c.findPagesByKindIn(KindPage, c.AllPages) - - var s *Site - - if len(c.Pages) > 0 { - s = c.Pages[0].s - } - - cacheLoader := func(kind string) func() (map[string]interface{}, error) { - return func() (map[string]interface{}, error) { - cache := make(map[string]interface{}) - switch kind { - case KindPage: - // Note that we deliberately use the pages from all sites - // in this cache, as we intend to use this in the ref and relref - // shortcodes. If the user says "sect/doc1.en.md", he/she knows - // what he/she is looking for. - for _, pageCollection := range []Pages{c.AllRegularPages, c.headlessPages} { - for _, p := range pageCollection { - cache[filepath.ToSlash(p.Source.Path())] = p - - if s != nil && p.s == s { - // Ref/Relref supports this potentially ambiguous lookup. - cache[p.Source.LogicalName()] = p - - translasionBaseName := p.Source.TranslationBaseName() - dir := filepath.ToSlash(strings.TrimSuffix(p.Dir(), helpers.FilePathSeparator)) - - if translasionBaseName == "index" { - _, name := path.Split(dir) - cache[name] = p - cache[dir] = p - } else { - // Again, ambigous - cache[translasionBaseName] = p - } - - // We need a way to get to the current language version. - pathWithNoExtensions := path.Join(dir, translasionBaseName) - cache[pathWithNoExtensions] = p - } - } - - } - default: - for _, p := range c.indexPages { - key := path.Join(p.sections...) - cache[key] = p - } - } - - return cache, nil - } - } - - partitions := make([]cache.Partition, len(allKindsInPages)) - - for i, kind := range allKindsInPages { - partitions[i] = cache.Partition{Key: kind, Load: cacheLoader(kind)} - } - - c.pageCache = cache.NewPartitionedLazyCache(partitions...) -} - -func newPageCollections() *PageCollections { - return &PageCollections{} -} - -func newPageCollectionsFromPages(pages Pages) *PageCollections { - return &PageCollections{rawAllPages: pages} -} - -func (c *PageCollections) getPage(typ string, sections ...string) *Page { - var key string - if len(sections) == 1 { - key = filepath.ToSlash(sections[0]) - } else { - key = path.Join(sections...) - } - - p, _ := c.pageCache.Get(typ, key) - if p == nil { - return nil - } - return p.(*Page) - -} - -func (*PageCollections) findPagesByKindIn(kind string, inPages Pages) Pages { - var pages Pages - for _, p := range inPages { - if p.Kind == kind { - pages = append(pages, p) - } - } - return pages -} - -func (*PageCollections) findFirstPageByKindIn(kind string, inPages Pages) *Page { - for _, p := range inPages { - if p.Kind == kind { - return p - } - } - return nil -} - -func (*PageCollections) findPagesByKindNotIn(kind string, inPages Pages) Pages { - var pages Pages - for _, p := range inPages { - if p.Kind != kind { - pages = append(pages, p) - } - } - return pages -} - -func (c *PageCollections) findPagesByKind(kind string) Pages { - return c.findPagesByKindIn(kind, c.Pages) -} - -func (c *PageCollections) addPage(page *Page) { - c.rawAllPages = append(c.rawAllPages, page) -} - -func (c *PageCollections) removePageFilename(filename string) { - if i := c.rawAllPages.findPagePosByFilename(filename); i >= 0 { - c.clearResourceCacheForPage(c.rawAllPages[i]) - c.rawAllPages = append(c.rawAllPages[:i], c.rawAllPages[i+1:]...) - } - -} - -func (c *PageCollections) removePage(page *Page) { - if i := c.rawAllPages.findPagePos(page); i >= 0 { - c.clearResourceCacheForPage(c.rawAllPages[i]) - c.rawAllPages = append(c.rawAllPages[:i], c.rawAllPages[i+1:]...) - } - -} - -func (c *PageCollections) findPagesByShortcode(shortcode string) Pages { - var pages Pages - - for _, p := range c.rawAllPages { - if p.shortcodeState != nil { - if _, ok := p.shortcodeState.nameSet[shortcode]; ok { - pages = append(pages, p) - } - } - } - return pages -} - -func (c *PageCollections) replacePage(page *Page) { - // will find existing page that matches filepath and remove it - c.removePage(page) - c.addPage(page) -} - -func (c *PageCollections) clearResourceCacheForPage(page *Page) { - if len(page.Resources) > 0 { - first := page.Resources[0] - dir := path.Dir(first.RelPermalink()) - dir = strings.TrimPrefix(dir, page.LanguagePrefix()) - // This is done to keep the memory usage in check when doing live reloads. - page.s.resourceSpec.DeleteCacheByPrefix(dir) - } -} diff --git a/hugolib/page_collections_test.go b/hugolib/page_collections_test.go deleted file mode 100644 index c6f4a4a26..000000000 --- a/hugolib/page_collections_test.go +++ /dev/null @@ -1,140 +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 hugolib - -import ( - "fmt" - "math/rand" - "path" - "path/filepath" - "testing" - "time" - - "github.com/gohugoio/hugo/deps" - "github.com/stretchr/testify/require" -) - -const pageCollectionsPageTemplate = `--- -title: "%s" -categories: -- Hugo ---- -# Doc -` - -func BenchmarkGetPage(b *testing.B) { - var ( - cfg, fs = newTestCfg() - r = rand.New(rand.NewSource(time.Now().UnixNano())) - ) - - for i := 0; i < 10; i++ { - for j := 0; j < 100; j++ { - writeSource(b, fs, filepath.Join("content", fmt.Sprintf("sect%d", i), fmt.Sprintf("page%d.md", j)), "CONTENT") - } - } - - s := buildSingleSite(b, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{SkipRender: true}) - - pagePaths := make([]string, b.N) - - for i := 0; i < b.N; i++ { - pagePaths[i] = fmt.Sprintf("sect%d", r.Intn(10)) - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - home := s.getPage(KindHome) - if home == nil { - b.Fatal("Home is nil") - } - - p := s.getPage(KindSection, pagePaths[i]) - if p == nil { - b.Fatal("Section is nil") - } - - } -} - -func BenchmarkGetPageRegular(b *testing.B) { - var ( - cfg, fs = newTestCfg() - r = rand.New(rand.NewSource(time.Now().UnixNano())) - ) - - for i := 0; i < 10; i++ { - for j := 0; j < 100; j++ { - content := fmt.Sprintf(pageCollectionsPageTemplate, fmt.Sprintf("Title%d_%d", i, j)) - writeSource(b, fs, filepath.Join("content", fmt.Sprintf("sect%d", i), fmt.Sprintf("page%d.md", j)), content) - } - } - - s := buildSingleSite(b, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{SkipRender: true}) - - pagePaths := make([]string, b.N) - - for i := 0; i < b.N; i++ { - pagePaths[i] = path.Join(fmt.Sprintf("sect%d", r.Intn(10)), fmt.Sprintf("page%d.md", r.Intn(100))) - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - page := s.getPage(KindPage, pagePaths[i]) - require.NotNil(b, page) - } -} - -func TestGetPage(t *testing.T) { - - var ( - assert = require.New(t) - cfg, fs = newTestCfg() - ) - - for i := 0; i < 10; i++ { - for j := 0; j < 10; j++ { - content := fmt.Sprintf(pageCollectionsPageTemplate, fmt.Sprintf("Title%d_%d", i, j)) - writeSource(t, fs, filepath.Join("content", fmt.Sprintf("sect%d", i), fmt.Sprintf("page%d.md", j)), content) - } - } - - content := fmt.Sprintf(pageCollectionsPageTemplate, "UniqueBase") - writeSource(t, fs, filepath.Join("content", "sect3", "unique.md"), content) - - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{SkipRender: true}) - - tests := []struct { - kind string - path []string - expectedTitle string - }{ - {KindHome, []string{}, ""}, - {KindSection, []string{"sect3"}, "Sect3s"}, - {KindPage, []string{"sect3", "page1.md"}, "Title3_1"}, - {KindPage, []string{"sect4/page2.md"}, "Title4_2"}, - {KindPage, []string{filepath.FromSlash("sect5/page3.md")}, "Title5_3"}, - // Ref/Relref supports this potentially ambiguous lookup. - {KindPage, []string{"unique.md"}, "UniqueBase"}, - } - - for i, test := range tests { - errorMsg := fmt.Sprintf("Test %d", i) - page := s.getPage(test.kind, test.path...) - assert.NotNil(page, errorMsg) - assert.Equal(test.kind, page.Kind, errorMsg) - assert.Equal(test.expectedTitle, page.title) - } - -} diff --git a/hugolib/page_kinds.go b/hugolib/page_kinds.go new file mode 100644 index 000000000..9bdf689d7 --- /dev/null +++ b/hugolib/page_kinds.go @@ -0,0 +1,18 @@ +// 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 hugolib + +const ( + pageResourceType = "page" +) diff --git a/hugolib/page_output.go b/hugolib/page_output.go deleted file mode 100644 index 0a51e72f7..000000000 --- a/hugolib/page_output.go +++ /dev/null @@ -1,322 +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 hugolib - -import ( - "fmt" - "html/template" - "os" - "strings" - "sync" - - "github.com/gohugoio/hugo/resource" - - "github.com/gohugoio/hugo/media" - - "github.com/gohugoio/hugo/helpers" - "github.com/gohugoio/hugo/output" -) - -// PageOutput represents one of potentially many output formats of a given -// Page. -type PageOutput struct { - *Page - - // Pagination - paginator *Pager - paginatorInit sync.Once - - // Page output specific resources - resources resource.Resources - resourcesInit sync.Once - - // Keep this to create URL/path variations, i.e. paginators. - targetPathDescriptor targetPathDescriptor - - outputFormat output.Format -} - -func (p *PageOutput) targetPath(addends ...string) (string, error) { - tp, err := p.createTargetPath(p.outputFormat, false, addends...) - if err != nil { - return "", err - } - return tp, nil -} - -func newPageOutput(p *Page, createCopy bool, f output.Format) (*PageOutput, error) { - // TODO(bep) This is only needed for tests and we should get rid of it. - if p.targetPathDescriptorPrototype == nil { - if err := p.initPaths(); err != nil { - return nil, err - } - } - - if createCopy { - p = p.copy() - } - - td, err := p.createTargetPathDescriptor(f) - - if err != nil { - return nil, err - } - - return &PageOutput{ - Page: p, - outputFormat: f, - targetPathDescriptor: td, - }, nil -} - -// copy creates a copy of this PageOutput with the lazy sync.Once vars reset -// so they will be evaluated again, for word count calculations etc. -func (p *PageOutput) copyWithFormat(f output.Format) (*PageOutput, error) { - c, err := newPageOutput(p.Page, true, f) - if err != nil { - return nil, err - } - c.paginator = p.paginator - return c, nil -} - -func (p *PageOutput) copy() (*PageOutput, error) { - return p.copyWithFormat(p.outputFormat) -} - -func (p *PageOutput) layouts(layouts ...string) ([]string, error) { - if len(layouts) == 0 && p.selfLayout != "" { - return []string{p.selfLayout}, nil - } - - layoutDescriptor := p.layoutDescriptor - - if len(layouts) > 0 { - layoutDescriptor.Layout = layouts[0] - layoutDescriptor.LayoutOverride = true - } - - return p.s.layoutHandler.For( - layoutDescriptor, - p.outputFormat) -} - -func (p *PageOutput) Render(layout ...string) template.HTML { - l, err := p.layouts(layout...) - if err != nil { - helpers.DistinctErrorLog.Printf("in .Render: Failed to resolve layout %q for page %q", layout, p.pathOrTitle()) - return "" - } - - for _, layout := range l { - templ := p.s.Tmpl.Lookup(layout) - if templ == nil { - // This is legacy from when we had only one output format and - // HTML templates only. Some have references to layouts without suffix. - // We default to good old HTML. - templ = p.s.Tmpl.Lookup(layout + ".html") - } - if templ != nil { - res, err := templ.ExecuteToString(p) - if err != nil { - helpers.DistinctErrorLog.Printf("in .Render: Failed to execute template %q: %s", layout, err) - return template.HTML("") - } - return template.HTML(res) - } - } - - return "" - -} - -func (p *Page) Render(layout ...string) template.HTML { - p.pageOutputInit.Do(func() { - if p.mainPageOutput != nil { - return - } - // If Render is called in a range loop, the page output isn't available. - // So, create one. - outFormat := p.outputFormats[0] - pageOutput, err := newPageOutput(p, true, outFormat) - - if err != nil { - p.s.Log.ERROR.Printf("Failed to create output page for type %q for page %q: %s", outFormat.Name, p.pathOrTitle(), err) - return - } - - p.mainPageOutput = pageOutput - - }) - - return p.mainPageOutput.Render(layout...) -} - -// OutputFormats holds a list of the relevant output formats for a given resource. -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. - // 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". - // As an example, the AMP output format will, by default, return "amphtml". - // - // See: - // https://www.ampproject.org/docs/guides/deploy/discovery - // - // Most other output formats will have "alternate" as value for this. - Rel string - - // It may be tempting to export this, but let us hold on to that horse for a while. - f output.Format - - p *Page -} - -// Name returns this OutputFormat's name, i.e. HTML, AMP, JSON etc. -func (o OutputFormat) Name() string { - return o.f.Name -} - -// MediaType returns this OutputFormat's MediaType (MIME type). -func (o OutputFormat) MediaType() media.Type { - return o.f.MediaType -} - -// OutputFormats gives the output formats for this Page. -func (p *Page) OutputFormats() OutputFormats { - var o OutputFormats - for _, f := range p.outputFormats { - o = append(o, newOutputFormat(p, f)) - } - return o -} - -func newOutputFormat(p *Page, f output.Format) *OutputFormat { - rel := f.Rel - isCanonical := len(p.outputFormats) == 1 - if isCanonical { - rel = "canonical" - } - return &OutputFormat{Rel: rel, f: f, p: p} -} - -// AlternativeOutputFormats gives the alternative output formats for this PageOutput. -// 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. -func (p *PageOutput) AlternativeOutputFormats() (OutputFormats, error) { - var o OutputFormats - for _, of := range p.OutputFormats() { - if of.f.NotAlternative || of.f == p.outputFormat { - continue - } - o = append(o, of) - } - return o, nil -} - -// deleteResource removes the resource from this PageOutput and the Page. They will -// always be of the same length, but may contain different elements. -func (p *PageOutput) deleteResource(i int) { - p.resources = append(p.resources[:i], p.resources[i+1:]...) - p.Page.Resources = append(p.Page.Resources[:i], p.Page.Resources[i+1:]...) - -} - -func (p *PageOutput) Resources() resource.Resources { - p.resourcesInit.Do(func() { - // If the current out shares the same path as the main page output, we reuse - // the resource set. For the "amp" use case, we need to clone them with new - // base folder. - ff := p.outputFormats[0] - if p.outputFormat.Path == ff.Path { - p.resources = p.Page.Resources - return - } - - // Clone it with new base. - resources := make(resource.Resources, len(p.Page.Resources)) - - for i, r := range p.Page.Resources { - if c, ok := r.(resource.Cloner); ok { - // Clone the same resource with a new target. - resources[i] = c.WithNewBase(p.outputFormat.Path) - } else { - resources[i] = r - } - } - - p.resources = resources - }) - - return p.resources -} - -func (p *PageOutput) renderResources() error { - - for i, r := range p.Resources() { - src, ok := r.(resource.Source) - if !ok { - // Pages gets rendered with the owning page. - continue - } - - if err := src.Publish(); err != nil { - if os.IsNotExist(err) { - // The resource has been deleted from the file system. - // This should be extremely rare, but can happen on live reload in server - // mode when the same resource is member of different page bundles. - p.deleteResource(i) - } else { - p.s.Log.ERROR.Printf("Failed to publish %q for page %q: %s", src.AbsSourceFilename(), p.pathOrTitle(), err) - } - } else { - p.s.PathSpec.ProcessingStats.Incr(&p.s.PathSpec.ProcessingStats.Files) - } - } - return nil -} - -// AlternativeOutputFormats is only available on the top level rendering -// entry point, and not inside range loops on the Page collections. -// This method is just here to inform users of that restriction. -func (p *Page) AlternativeOutputFormats() (OutputFormats, error) { - return nil, fmt.Errorf("AlternativeOutputFormats only available from the top level template context for page %q", p.Path()) -} - -// Get gets a OutputFormat given its name, i.e. json, html etc. -// It returns nil if not found. -func (o OutputFormats) Get(name string) *OutputFormat { - for _, f := range o { - if strings.EqualFold(f.f.Name, name) { - return f - } - } - return nil -} - -// Permalink returns the absolute permalink to this output format. -func (o *OutputFormat) Permalink() string { - rel := o.p.createRelativePermalinkForOutputFormat(o.f) - perm, _ := o.p.s.permalinkForOutputFormat(rel, o.f) - return perm -} - -// RelPermalink returns the relative permalink to this output format. -func (o *OutputFormat) RelPermalink() string { - rel := o.p.createRelativePermalinkForOutputFormat(o.f) - return o.p.s.PathSpec.PrependBasePath(rel) -} diff --git a/hugolib/page_paths.go b/hugolib/page_paths.go deleted file mode 100644 index 4d64f4c14..000000000 --- a/hugolib/page_paths.go +++ /dev/null @@ -1,308 +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 hugolib - -import ( - "fmt" - "path/filepath" - - "net/url" - "strings" - - "github.com/gohugoio/hugo/helpers" - "github.com/gohugoio/hugo/output" -) - -// targetPathDescriptor describes how a file path for a given resource -// should look like on the file system. The same descriptor is then later used to -// create both the permalinks and the relative links, paginator URLs etc. -// -// 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. -// -// Page.createTargetPathDescriptor is the Page adapter. -// -type targetPathDescriptor struct { - PathSpec *helpers.PathSpec - - Type output.Format - Kind string - - Sections []string - - // For regular content pages this is either - // 1) the Slug, if set, - // 2) the file base name (TranslationBaseName). - BaseName string - - // Source directory. - Dir string - - // Language prefix, set if multilingual and if page should be placed in its - // language subdir. - LangPrefix string - - // Whether this is a multihost multilingual setup. - IsMultihost bool - - // URL from front matter if set. Will override any Slug etc. - URL string - - // Used to create paginator links. - Addends string - - // The expanded permalink if defined for the section, ready to use. - ExpandedPermalink string - - // Some types cannot have uglyURLs, even if globally enabled, RSS being one example. - UglyURLs bool -} - -// createTargetPathDescriptor adapts a Page and the given output.Format into -// a targetPathDescriptor. This descriptor can then be used to create paths -// and URLs for this Page. -func (p *Page) createTargetPathDescriptor(t output.Format) (targetPathDescriptor, error) { - if p.targetPathDescriptorPrototype == nil { - panic(fmt.Sprintf("Must run initTargetPathDescriptor() for page %q, kind %q", p.title, p.Kind)) - } - d := *p.targetPathDescriptorPrototype - d.Type = t - return d, nil -} - -func (p *Page) initTargetPathDescriptor() error { - d := &targetPathDescriptor{ - PathSpec: p.s.PathSpec, - Kind: p.Kind, - Sections: p.sections, - UglyURLs: p.s.Info.uglyURLs(p), - Dir: filepath.ToSlash(p.Source.Dir()), - URL: p.frontMatterURL, - IsMultihost: p.s.owner.IsMultihost(), - } - - if p.Slug != "" { - d.BaseName = p.Slug - } else { - d.BaseName = p.TranslationBaseName() - } - - if p.shouldAddLanguagePrefix() { - d.LangPrefix = p.Lang() - } - - // Expand only KindPage and KindTaxonomy; don't expand other Kinds of Pages - // like KindSection or KindTaxonomyTerm because they are "shallower" and - // the permalink configuration values are likely to be redundant, e.g. - // naively expanding /category/:slug/ would give /category/categories/ for - // the "categories" KindTaxonomyTerm. - if p.Kind == KindPage || p.Kind == KindTaxonomy { - if override, ok := p.Site.Permalinks[p.Section()]; ok { - opath, err := override.Expand(p) - if err != nil { - return err - } - - opath, _ = url.QueryUnescape(opath) - opath = filepath.FromSlash(opath) - d.ExpandedPermalink = opath - } - } - - p.targetPathDescriptorPrototype = d - return nil - -} - -func (p *Page) initURLs() error { - if len(p.outputFormats) == 0 { - p.outputFormats = p.s.outputFormats[p.Kind] - } - target := filepath.ToSlash(p.createRelativeTargetPath()) - rel := p.s.PathSpec.URLizeFilename(target) - - var err error - f := p.outputFormats[0] - p.permalink, err = p.s.permalinkForOutputFormat(rel, f) - if err != nil { - return err - } - - p.relTargetPathBase = strings.TrimSuffix(target, f.MediaType.FullSuffix()) - p.relPermalink = p.s.PathSpec.PrependBasePath(rel) - p.layoutDescriptor = p.createLayoutDescriptor() - return nil -} - -func (p *Page) initPaths() error { - if err := p.initTargetPathDescriptor(); err != nil { - return err - } - if err := p.initURLs(); err != nil { - return err - } - return nil -} - -// createTargetPath creates the target filename for this Page for the given -// output.Format. Some additional URL parts can also be provided, the typical -// use case being pagination. -func (p *Page) createTargetPath(t output.Format, noLangPrefix bool, addends ...string) (string, error) { - d, err := p.createTargetPathDescriptor(t) - if err != nil { - return "", nil - } - - if noLangPrefix { - d.LangPrefix = "" - } - - if len(addends) > 0 { - d.Addends = filepath.Join(addends...) - } - - return createTargetPath(d), nil -} - -func createTargetPath(d targetPathDescriptor) string { - - pagePath := helpers.FilePathSeparator - - // 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 - - if d.ExpandedPermalink == "" && d.BaseName != "" && d.BaseName == d.Type.BaseName { - isUgly = true - } - - if d.Kind != KindPage && d.URL == "" && len(d.Sections) > 0 { - if d.ExpandedPermalink != "" { - pagePath = filepath.Join(pagePath, d.ExpandedPermalink) - } else { - pagePath = filepath.Join(d.Sections...) - } - needsBase = false - } - - if d.Type.Path != "" { - pagePath = filepath.Join(pagePath, d.Type.Path) - } - - if d.Kind != KindHome && d.URL != "" { - if d.IsMultihost && d.LangPrefix != "" && !strings.HasPrefix(d.URL, "/"+d.LangPrefix) { - pagePath = filepath.Join(d.LangPrefix, pagePath, d.URL) - } else { - pagePath = filepath.Join(pagePath, d.URL) - } - - if d.Addends != "" { - pagePath = filepath.Join(pagePath, d.Addends) - } - - if strings.HasSuffix(d.URL, "/") || !strings.Contains(d.URL, ".") { - pagePath = filepath.Join(pagePath, d.Type.BaseName+d.Type.MediaType.FullSuffix()) - } - - } else if d.Kind == KindPage { - if d.ExpandedPermalink != "" { - pagePath = filepath.Join(pagePath, d.ExpandedPermalink) - - } else { - if d.Dir != "" { - pagePath = filepath.Join(pagePath, d.Dir) - } - if d.BaseName != "" { - pagePath = filepath.Join(pagePath, d.BaseName) - } - } - - if d.Addends != "" { - pagePath = filepath.Join(pagePath, d.Addends) - } - - if isUgly { - pagePath += d.Type.MediaType.Delimiter + d.Type.MediaType.Suffix - } else { - pagePath = filepath.Join(pagePath, d.Type.BaseName+d.Type.MediaType.FullSuffix()) - } - - if d.LangPrefix != "" { - pagePath = filepath.Join(d.LangPrefix, pagePath) - } - } else { - if d.Addends != "" { - pagePath = filepath.Join(pagePath, d.Addends) - } - - needsBase = needsBase && d.Addends == "" - - // No permalink expansion etc. for node type pages (for now) - base := "" - - if needsBase || !isUgly { - base = helpers.FilePathSeparator + d.Type.BaseName - } - - pagePath += base + d.Type.MediaType.FullSuffix() - - if d.LangPrefix != "" { - pagePath = filepath.Join(d.LangPrefix, pagePath) - } - } - - pagePath = filepath.Join(helpers.FilePathSeparator, pagePath) - - // Note: MakePathSanitized will lower case the path if - // disablePathToLower isn't set. - return d.PathSpec.MakePathSanitized(pagePath) -} - -func (p *Page) createRelativeTargetPath() string { - - if len(p.outputFormats) == 0 { - if p.Kind == kindUnknown { - panic(fmt.Sprintf("Page %q has unknown kind", p.title)) - } - panic(fmt.Sprintf("Page %q missing output format(s)", p.title)) - } - - // Choose the main output format. In most cases, this will be HTML. - f := p.outputFormats[0] - - return p.createRelativeTargetPathForOutputFormat(f) - -} - -func (p *Page) createRelativePermalinkForOutputFormat(f output.Format) string { - return p.s.PathSpec.URLizeFilename(p.createRelativeTargetPathForOutputFormat(f)) -} - -func (p *Page) createRelativeTargetPathForOutputFormat(f output.Format) string { - tp, err := p.createTargetPath(f, p.s.owner.IsMultihost()) - - if err != nil { - p.s.Log.ERROR.Printf("Failed to create permalink for page %q: %s", p.FullFilePath(), err) - return "" - } - - // For /index.json etc. we must use the full path. - if strings.HasSuffix(f.BaseFilename(), "html") { - tp = strings.TrimSuffix(tp, f.BaseFilename()) - } - - return tp -} diff --git a/hugolib/page_paths_test.go b/hugolib/page_paths_test.go deleted file mode 100644 index 149505ee4..000000000 --- a/hugolib/page_paths_test.go +++ /dev/null @@ -1,194 +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 hugolib - -import ( - "path/filepath" - "strings" - "testing" - - "github.com/gohugoio/hugo/media" - - "fmt" - - "github.com/gohugoio/hugo/output" -) - -func TestPageTargetPath(t *testing.T) { - - pathSpec := newTestDefaultPathSpec() - - noExtNoDelimMediaType := media.TextType - noExtNoDelimMediaType.Suffix = "" - noExtNoDelimMediaType.Delimiter = "" - - // Netlify style _redirects - noExtDelimFormat := output.Format{ - Name: "NER", - MediaType: noExtNoDelimMediaType, - BaseName: "_redirects", - } - - for _, multiHost := range []bool{false, true} { - for _, langPrefix := range []string{"", "no"} { - for _, uglyURLs := range []bool{false, true} { - t.Run(fmt.Sprintf("multihost=%t,langPrefix=%q,uglyURLs=%t", multiHost, langPrefix, uglyURLs), - func(t *testing.T) { - - tests := []struct { - name string - d targetPathDescriptor - expected string - }{ - {"JSON home", targetPathDescriptor{Kind: KindHome, Type: output.JSONFormat}, "/index.json"}, - {"AMP home", targetPathDescriptor{Kind: KindHome, Type: output.AMPFormat}, "/amp/index.html"}, - {"HTML home", targetPathDescriptor{Kind: KindHome, BaseName: "_index", Type: output.HTMLFormat}, "/index.html"}, - {"Netlify redirects", targetPathDescriptor{Kind: KindHome, BaseName: "_index", Type: noExtDelimFormat}, "/_redirects"}, - {"HTML section list", targetPathDescriptor{ - Kind: KindSection, - Sections: []string{"sect1"}, - BaseName: "_index", - Type: output.HTMLFormat}, "/sect1/index.html"}, - {"HTML taxonomy list", targetPathDescriptor{ - Kind: KindTaxonomy, - Sections: []string{"tags", "hugo"}, - BaseName: "_index", - Type: output.HTMLFormat}, "/tags/hugo/index.html"}, - {"HTML taxonomy term", targetPathDescriptor{ - Kind: KindTaxonomy, - Sections: []string{"tags"}, - BaseName: "_index", - Type: output.HTMLFormat}, "/tags/index.html"}, - { - "HTML page", targetPathDescriptor{ - Kind: KindPage, - Dir: "/a/b", - BaseName: "mypage", - Sections: []string{"a"}, - Type: output.HTMLFormat}, "/a/b/mypage/index.html"}, - - { - "HTML page with index as base", targetPathDescriptor{ - Kind: KindPage, - Dir: "/a/b", - BaseName: "index", - Sections: []string{"a"}, - Type: output.HTMLFormat}, "/a/b/index.html"}, - - { - "HTML page with special chars", targetPathDescriptor{ - Kind: KindPage, - Dir: "/a/b", - BaseName: "My Page!", - Type: output.HTMLFormat}, "/a/b/My-Page/index.html"}, - {"RSS home", targetPathDescriptor{Kind: kindRSS, Type: output.RSSFormat}, "/index.xml"}, - {"RSS section list", targetPathDescriptor{ - Kind: kindRSS, - Sections: []string{"sect1"}, - Type: output.RSSFormat}, "/sect1/index.xml"}, - { - "AMP page", targetPathDescriptor{ - Kind: KindPage, - Dir: "/a/b/c", - BaseName: "myamp", - Type: output.AMPFormat}, "/amp/a/b/c/myamp/index.html"}, - { - "AMP page with URL with suffix", targetPathDescriptor{ - Kind: KindPage, - Dir: "/sect/", - BaseName: "mypage", - URL: "/some/other/url.xhtml", - Type: output.HTMLFormat}, "/some/other/url.xhtml"}, - { - "JSON page with URL without suffix", targetPathDescriptor{ - Kind: KindPage, - Dir: "/sect/", - BaseName: "mypage", - URL: "/some/other/path/", - Type: output.JSONFormat}, "/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}, "/some/other/path/index.json"}, - { - "HTML page with expanded permalink", targetPathDescriptor{ - Kind: KindPage, - Dir: "/a/b", - BaseName: "mypage", - ExpandedPermalink: "/2017/10/my-title", - Type: output.HTMLFormat}, "/2017/10/my-title/index.html"}, - { - "Paginated HTML home", targetPathDescriptor{ - Kind: KindHome, - BaseName: "_index", - Type: output.HTMLFormat, - Addends: "page/3"}, "/page/3/index.html"}, - { - "Paginated Taxonomy list", targetPathDescriptor{ - Kind: KindTaxonomy, - BaseName: "_index", - Sections: []string{"tags", "hugo"}, - Type: output.HTMLFormat, - Addends: "page/3"}, "/tags/hugo/page/3/index.html"}, - { - "Regular page with addend", targetPathDescriptor{ - Kind: KindPage, - Dir: "/a/b", - BaseName: "mypage", - Addends: "c/d/e", - Type: output.HTMLFormat}, "/a/b/mypage/c/d/e/index.html"}, - } - - for i, test := range tests { - test.d.PathSpec = pathSpec - test.d.UglyURLs = uglyURLs - test.d.LangPrefix = langPrefix - test.d.IsMultihost = multiHost - 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 (!strings.HasPrefix(expected, "/index") || test.d.Addends != "") && test.d.URL == "" && isUgly { - expected = strings.Replace(expected, - "/"+test.d.Type.BaseName+"."+test.d.Type.MediaType.Suffix, - "."+test.d.Type.MediaType.Suffix, -1) - } - - if test.d.LangPrefix != "" && !(test.d.Kind == KindPage && test.d.URL != "") { - expected = "/" + test.d.LangPrefix + expected - } else if multiHost && test.d.LangPrefix != "" && test.d.URL != "" { - expected = "/" + test.d.LangPrefix + expected - } - - expected = filepath.FromSlash(expected) - - pagePath := createTargetPath(test.d) - - if pagePath != expected { - t.Fatalf("[%d] [%s] targetPath expected %q, got: %q", i, test.name, expected, pagePath) - } - } - }) - } - } - } -} diff --git a/hugolib/page_permalink_test.go b/hugolib/page_permalink_test.go index 6f899efae..d8fd99d79 100644 --- a/hugolib/page_permalink_test.go +++ b/hugolib/page_permalink_test.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Hugo Authors. All rights reserved. +// 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. @@ -16,12 +16,11 @@ package hugolib import ( "fmt" "html/template" - "path/filepath" "testing" - "github.com/stretchr/testify/require" + qt "github.com/frankban/quicktest" - "github.com/gohugoio/hugo/deps" + "github.com/gohugoio/hugo/config" ) func TestPermalink(t *testing.T) { @@ -55,46 +54,108 @@ func TestPermalink(t *testing.T) { {"x/y/z/boofar.md", "http://barnew/boo/", "booslug", "", false, false, "http://barnew/boo/x/y/z/booslug/", "/boo/x/y/z/booslug/"}, {"x/y/z/boofar.md", "http://barnew/boo/", "booslug", "", true, true, "http://barnew/boo/x/y/z/booslug.html", "/x/y/z/booslug.html"}, {"x/y/z/boofar.md", "http://barnew/boo", "booslug", "", true, true, "http://barnew/boo/x/y/z/booslug.html", "/x/y/z/booslug.html"}, + // Issue #4666 + {"x/y/z/boo-makeindex.md", "http://barnew/boo", "", "", true, true, "http://barnew/boo/x/y/z/boo-makeindex.html", "/x/y/z/boo-makeindex.html"}, // test URL overrides {"x/y/z/boofar.md", "", "", "/z/y/q/", false, false, "/z/y/q/", "/z/y/q/"}, + // test URL override with expands + {"x/y/z/boofar.md", "", "test", "/z/:slug/", false, false, "/z/test/", "/z/test/"}, } for i, test := range tests { + i := i + test := test + t.Run(fmt.Sprintf("%s-%d", test.file, i), func(t *testing.T) { + t.Parallel() + c := qt.New(t) + cfg := config.New() + cfg.Set("uglyURLs", test.uglyURLs) + cfg.Set("canonifyURLs", test.canonifyURLs) - cfg, fs := newTestCfg() - - cfg.Set("uglyURLs", test.uglyURLs) - cfg.Set("canonifyURLs", test.canonifyURLs) - cfg.Set("baseURL", test.base) - - pageContent := fmt.Sprintf(`--- + files := fmt.Sprintf(` +-- hugo.toml -- +baseURL = %q +-- content/%s -- +--- title: Page slug: %q -url: %q +url: %q +output: ["HTML"] --- -Content -`, test.slug, test.url) +`, test.base, test.file, test.slug, test.url) - writeSource(t, fs, filepath.Join("content", filepath.FromSlash(test.file)), pageContent) + if i > 0 { + t.Skip() + } - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{SkipRender: true}) - require.Len(t, s.RegularPages, 1) + b := NewIntegrationTestBuilder( + IntegrationTestConfig{ + T: t, + TxtarString: files, + BaseCfg: cfg, + }, + ) - p := s.RegularPages[0] + b.Build() + s := b.H.Sites[0] + c.Assert(len(s.RegularPages()), qt.Equals, 1) + p := s.RegularPages()[0] + u := p.Permalink() - u := p.Permalink() + expected := test.expectedAbs + if u != expected { + t.Fatalf("[%d] Expected abs url: %s, got: %s", i, expected, u) + } - expected := test.expectedAbs - if u != expected { - t.Fatalf("[%d] Expected abs url: %s, got: %s", i, expected, u) - } + u = p.RelPermalink() - u = p.RelPermalink() - - expected = test.expectedRel - if u != expected { - t.Errorf("[%d] Expected rel url: %s, got: %s", i, expected, u) - } + expected = test.expectedRel + if u != expected { + t.Errorf("[%d] Expected rel url: %s, got: %s", i, expected, u) + } + }) } } + +func TestRelativeURLInFrontMatter(t *testing.T) { + config := ` +baseURL = "https://example.com" +defaultContentLanguage = "en" +defaultContentLanguageInSubdir = false + +[Languages] +[Languages.en] +weight = 10 +contentDir = "content/en" +[Languages.nn] +weight = 20 +contentDir = "content/nn" + +` + + pageTempl := `--- +title: "A page" +url: %q +--- + +Some content. +` + + b := newTestSitesBuilder(t).WithConfigFile("toml", config) + b.WithContent("content/en/blog/page1.md", fmt.Sprintf(pageTempl, "myblog/p1/")) + b.WithContent("content/en/blog/page2.md", fmt.Sprintf(pageTempl, "../../../../../myblog/p2/")) + b.WithContent("content/en/blog/page3.md", fmt.Sprintf(pageTempl, "../myblog/../myblog/p3/")) + b.WithContent("content/en/blog/_index.md", fmt.Sprintf(pageTempl, "this-is-my-english-blog")) + b.WithContent("content/nn/blog/page1.md", fmt.Sprintf(pageTempl, "myblog/p1/")) + b.WithContent("content/nn/blog/_index.md", fmt.Sprintf(pageTempl, "this-is-my-blog")) + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/nn/myblog/p1/index.html", "Single: A page|Hello|nn|RelPermalink: /nn/myblog/p1/|") + b.AssertFileContent("public/nn/this-is-my-blog/index.html", "List Page 1|A page|Hello|https://example.com/nn/this-is-my-blog/|") + b.AssertFileContent("public/this-is-my-english-blog/index.html", "List Page 1|A page|Hello|https://example.com/this-is-my-english-blog/|") + b.AssertFileContent("public/myblog/p1/index.html", "Single: A page|Hello|en|RelPermalink: /myblog/p1/|Permalink: https://example.com/myblog/p1/|") + b.AssertFileContent("public/myblog/p2/index.html", "Single: A page|Hello|en|RelPermalink: /myblog/p2/|Permalink: https://example.com/myblog/p2/|") + b.AssertFileContent("public/myblog/p3/index.html", "Single: A page|Hello|en|RelPermalink: /myblog/p3/|Permalink: https://example.com/myblog/p3/|") +} diff --git a/hugolib/page_resource.go b/hugolib/page_resource.go deleted file mode 100644 index 808a692da..000000000 --- a/hugolib/page_resource.go +++ /dev/null @@ -1,23 +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 hugolib - -import ( - "github.com/gohugoio/hugo/resource" -) - -var ( - _ resource.Resource = (*Page)(nil) - _ resource.Resource = (*PageOutput)(nil) -) diff --git a/hugolib/page_taxonomy_test.go b/hugolib/page_taxonomy_test.go deleted file mode 100644 index ed1d2565d..000000000 --- a/hugolib/page_taxonomy_test.go +++ /dev/null @@ -1,96 +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 hugolib - -import ( - "reflect" - "strings" - "testing" -) - -var pageYamlWithTaxonomiesA = `--- -tags: ['a', 'B', 'c'] -categories: 'd' ---- -YAML frontmatter with tags and categories taxonomy.` - -var pageYamlWithTaxonomiesB = `--- -tags: - - "a" - - "B" - - "c" -categories: 'd' ---- -YAML frontmatter with tags and categories taxonomy.` - -var pageYamlWithTaxonomiesC = `--- -tags: 'E' -categories: 'd' ---- -YAML frontmatter with tags and categories taxonomy.` - -var pageJSONWithTaxonomies = `{ - "categories": "D", - "tags": [ - "a", - "b", - "c" - ] -} -JSON Front Matter with tags and categories` - -var pageTomlWithTaxonomies = `+++ -tags = [ "a", "B", "c" ] -categories = "d" -+++ -TOML Front Matter with tags and categories` - -func TestParseTaxonomies(t *testing.T) { - t.Parallel() - for _, test := range []string{pageTomlWithTaxonomies, - pageJSONWithTaxonomies, - pageYamlWithTaxonomiesA, - pageYamlWithTaxonomiesB, - pageYamlWithTaxonomiesC, - } { - - s := newTestSite(t) - p, _ := s.NewPage("page/with/taxonomy") - _, err := p.ReadFrom(strings.NewReader(test)) - if err != nil { - t.Fatalf("Failed parsing %q: %s", test, err) - } - - param := p.getParamToLower("tags") - - if params, ok := param.([]string); ok { - expected := []string{"a", "b", "c"} - if !reflect.DeepEqual(params, expected) { - t.Errorf("Expected %s: got: %s", expected, params) - } - } else if params, ok := param.(string); ok { - expected := "e" - if params != expected { - t.Errorf("Expected %s: got: %s", expected, params) - } - } - - param = p.getParamToLower("categories") - singleparam := param.(string) - - if singleparam != "d" { - t.Fatalf("Expected: d, got: %s", singleparam) - } - } -} diff --git a/hugolib/page_test.go b/hugolib/page_test.go index 2b679c842..1da67e58f 100644 --- a/hugolib/page_test.go +++ b/hugolib/page_test.go @@ -1,4 +1,4 @@ -// Copyright 2018 The Hugo Authors. All rights reserved. +// 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. @@ -14,87 +14,46 @@ package hugolib import ( - "bytes" + "context" "fmt" "html/template" - "os" - "path/filepath" - "reflect" - "sort" "strings" "testing" "time" - "github.com/gohugoio/hugo/hugofs" - "github.com/spf13/afero" + "github.com/bep/clocks" + "github.com/gohugoio/hugo/markup/asciidocext" + "github.com/gohugoio/hugo/markup/rst" + "github.com/gohugoio/hugo/tpl" - "github.com/spf13/viper" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/common/hashing" + "github.com/gohugoio/hugo/common/htime" + "github.com/gohugoio/hugo/common/loggers" + + "github.com/gohugoio/hugo/resources/page" + "github.com/gohugoio/hugo/resources/resource" + + qt "github.com/frankban/quicktest" "github.com/gohugoio/hugo/deps" - "github.com/gohugoio/hugo/helpers" - "github.com/spf13/cast" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -var emptyPage = "" - const ( - homePage = "---\ntitle: Home\n---\nHome Page Content\n" - simplePage = "---\ntitle: Simple\n---\nSimple Page\n" - renderNoFrontmatter = "<!doctype><html><head></head><body>This is a test</body></html>" - contentNoFrontmatter = "Page without front matter.\n" - contentWithCommentedFrontmatter = "<!--\n+++\ntitle = \"Network configuration\"\ndescription = \"Docker networking\"\nkeywords = [\"network\"]\n[menu.main]\nparent= \"smn_administrate\"\n+++\n-->\n\n# Network configuration\n\n##\nSummary" - contentWithCommentedTextFrontmatter = "<!--[metaData]>\n+++\ntitle = \"Network configuration\"\ndescription = \"Docker networking\"\nkeywords = [\"network\"]\n[menu.main]\nparent= \"smn_administrate\"\n+++\n<![end-metadata]-->\n\n# Network configuration\n\n##\nSummary" - contentWithCommentedLongFrontmatter = "<!--[metaData123456789012345678901234567890]>\n+++\ntitle = \"Network configuration\"\ndescription = \"Docker networking\"\nkeywords = [\"network\"]\n[menu.main]\nparent= \"smn_administrate\"\n+++\n<![end-metadata]-->\n\n# Network configuration\n\n##\nSummary" - contentWithCommentedLong2Frontmatter = "<!--[metaData]>\n+++\ntitle = \"Network configuration\"\ndescription = \"Docker networking\"\nkeywords = [\"network\"]\n[menu.main]\nparent= \"smn_administrate\"\n+++\n<![end-metadata123456789012345678901234567890]-->\n\n# Network configuration\n\n##\nSummary" - invalidFrontmatterShortDelim = ` --- -title: Short delim start + homePage = "---\ntitle: Home\n---\nHome Page Content\n" + simplePage = "---\ntitle: Simple\n---\nSimple Page\n" + + simplePageRFC3339Date = "---\ntitle: RFC3339 Date\ndate: \"2013-05-17T16:59:30Z\"\n---\nrfc3339 content" + + simplePageWithoutSummaryDelimiter = `--- +title: SimpleWithoutSummaryDelimiter --- -Short Delim -` +[Lorem ipsum](https://lipsum.com/) dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. - invalidFrontmatterShortDelimEnding = ` ---- -title: Short delim ending --- -Short Delim -` +Additional text. - invalidFrontmatterLadingWs = ` - - --- -title: Leading WS ---- -Leading -` - - simplePageJSON = ` -{ -"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-" -} - -Content of the file goes Here -` - - simplePageRFC3339Date = "---\ntitle: RFC3339 Date\ndate: \"2013-05-17T16:59:30Z\"\n---\nrfc3339 content" - simplePageJSONMultiple = ` -{ - "title": "foobar", - "customData": { "foo": "bar" }, - "date": "2012-08-06" -} -Some text +Further text. ` simplePageWithSummaryDelimiter = `--- @@ -104,6 +63,16 @@ Summary Next Line <!--more--> Some more text +` + + simplePageWithSummaryParameter = `--- +title: SimpleWithSummaryParameter +summary: "Page with summary parameter and [a link](http://www.example.com/)" +--- + +Some text. + +Some more text. ` simplePageWithSummaryDelimiterAndMarkdownThatCrossesBorder = `--- @@ -121,12 +90,6 @@ Summary Next Line. {{<figure src="/not/real" >}}. More text here. Some more text -` - - simplePageWithEmbeddedScript = `--- -title: Simple ---- -<script type='text/javascript'>alert('the script tags are still there, right?');</script> ` simplePageWithSummaryDelimiterSameLine = `--- @@ -135,14 +98,6 @@ title: Simple Summary Same Line<!--more--> Some more text -` - - simplePageWithSummaryDelimiterOnlySummary = `--- -title: Simple ---- -Summary text - -<!--more--> ` simplePageWithAllCJKRunes = `--- @@ -280,16 +235,6 @@ the cylinder and strike me down. ## BB ### BBB "You're a great Granser," he cried delightedly, "always making believe them little marks mean something." -` - - simplePageWithAdditionalExtension = `+++ -[blackfriday] - extensions = ["hardLineBreak"] -+++ -first line. -second line. - -fourth line. ` simplePageWithURL = `--- @@ -334,154 +279,16 @@ date: '2013-10-15T06:16:13' UTF8 Page With Date` ) -var pageWithVariousFrontmatterTypes = `+++ -a_string = "bar" -an_integer = 1 -a_float = 1.3 -a_bool = false -a_date = 1979-05-27T07:32:00Z - -[a_table] -a_key = "a_value" -+++ -Front Matter with various frontmatter types` - -var pageWithCalendarYAMLFrontmatter = `--- -type: calendar -weeks: - - - start: "Jan 5" - days: - - activity: class - room: EN1000 - - activity: lab - - activity: class - - activity: lab - - activity: class - - - start: "Jan 12" - days: - - activity: class - - activity: lab - - activity: class - - activity: lab - - activity: exam ---- - -Hi. -` - -var pageWithCalendarJSONFrontmatter = `{ - "type": "calendar", - "weeks": [ - { - "start": "Jan 5", - "days": [ - { "activity": "class", "room": "EN1000" }, - { "activity": "lab" }, - { "activity": "class" }, - { "activity": "lab" }, - { "activity": "class" } - ] - }, - { - "start": "Jan 12", - "days": [ - { "activity": "class" }, - { "activity": "lab" }, - { "activity": "class" }, - { "activity": "lab" }, - { "activity": "exam" } - ] - } - ] -} - -Hi. -` - -var pageWithCalendarTOMLFrontmatter = `+++ -type = "calendar" - -[[weeks]] -start = "Jan 5" - -[[weeks.days]] -activity = "class" -room = "EN1000" - -[[weeks.days]] -activity = "lab" - -[[weeks.days]] -activity = "class" - -[[weeks.days]] -activity = "lab" - -[[weeks.days]] -activity = "class" - -[[weeks]] -start = "Jan 12" - -[[weeks.days]] -activity = "class" - -[[weeks.days]] -activity = "lab" - -[[weeks.days]] -activity = "class" - -[[weeks.days]] -activity = "lab" - -[[weeks.days]] -activity = "exam" -+++ - -Hi. -` - -func checkError(t *testing.T, err error, expected string) { - if err == nil { - t.Fatalf("err is nil. Expected: %s", expected) - } - if err.Error() != expected { - t.Errorf("err.Error() returned: '%s'. Expected: '%s'", err.Error(), expected) +func checkPageTitle(t *testing.T, page page.Page, title string) { + if page.Title() != title { + t.Fatalf("Page title is: %s. Expected %s", page.Title(), title) } } -func TestDegenerateEmptyPageZeroLengthName(t *testing.T) { - t.Parallel() - s := newTestSite(t) - _, err := s.NewPage("") - if err == nil { - t.Fatalf("A zero length page name must return an error") - } - - checkError(t, err, "Zero length page name") -} - -func TestDegenerateEmptyPage(t *testing.T) { - t.Parallel() - s := newTestSite(t) - _, err := s.NewPageFrom(strings.NewReader(emptyPage), "test") - if err != nil { - t.Fatalf("Empty files should not trigger an error. Should be able to touch a file while watching without erroring out.") - } -} - -func checkPageTitle(t *testing.T, page *Page, title string) { - if page.title != title { - t.Fatalf("Page title is: %s. Expected %s", page.title, title) - } -} - -func checkPageContent(t *testing.T, page *Page, content string, msg ...interface{}) { - a := normalizeContent(content) - b := normalizeContent(string(page.Content)) +func checkPageContent(t *testing.T, page page.Page, expected string, msg ...any) { + t.Helper() + a := normalizeContent(expected) + b := normalizeContent(content(page)) if a != b { t.Fatalf("Page content is:\n%q\nExpected:\n%q (%q)", b, a, msg) } @@ -498,42 +305,31 @@ func normalizeContent(c string) string { return strings.TrimSpace(norm) } -func checkPageTOC(t *testing.T, page *Page, toc string) { - if page.TableOfContents != template.HTML(toc) { - t.Fatalf("Page TableOfContents is: %q.\nExpected %q", page.TableOfContents, toc) +func checkPageTOC(t *testing.T, page page.Page, toc string) { + t.Helper() + if page.TableOfContents(context.Background()) != template.HTML(toc) { + t.Fatalf("Page TableOfContents is:\n%q.\nExpected %q", page.TableOfContents(context.Background()), toc) } } -func checkPageSummary(t *testing.T, page *Page, summary string, msg ...interface{}) { - a := normalizeContent(string(page.Summary)) +func checkPageSummary(t *testing.T, page page.Page, summary string, msg ...any) { + s := string(page.Summary(context.Background())) + a := normalizeContent(s) b := normalizeContent(summary) if a != b { t.Fatalf("Page summary is:\n%q.\nExpected\n%q (%q)", a, b, msg) } } -func checkPageType(t *testing.T, page *Page, pageType string) { +func checkPageType(t *testing.T, page page.Page, pageType string) { if page.Type() != pageType { t.Fatalf("Page type is: %s. Expected: %s", page.Type(), pageType) } } -func checkPageDate(t *testing.T, page *Page, time time.Time) { - if page.Date != time { - t.Fatalf("Page date is: %s. Expected: %s", page.Date, time) - } -} - -func checkTruncation(t *testing.T, page *Page, shouldBe bool, msg string) { - if page.Summary == "" { - t.Fatal("page has no summary, can not check truncation") - } - if page.Truncated != shouldBe { - if shouldBe { - t.Fatalf("page wasn't truncated: %s", msg) - } else { - t.Fatalf("page was truncated: %s", msg) - } +func checkPageDate(t *testing.T, page page.Page, time time.Time) { + if page.Date() != time { + t.Fatalf("Page date is: %s. Expected: %s", page.Date(), time) } } @@ -543,7 +339,7 @@ func normalizeExpected(ext, str string) string { default: return str case "html": - return strings.Trim(helpers.StripHTML(str), " ") + return strings.Trim(tpl.StripHTML(str), " ") case "ad": paragraphs := strings.Split(str, "</p>") expected := "" @@ -553,24 +349,26 @@ func normalizeExpected(ext, str string) string { } expected += fmt.Sprintf("<div class=\"paragraph\">\n%s</p></div>\n", para) } + return expected case "rst": + if str == "" { + return "<div class=\"document\"></div>" + } return fmt.Sprintf("<div class=\"document\">\n\n\n%s</div>", str) } } func testAllMarkdownEnginesForPages(t *testing.T, - assertFunc func(t *testing.T, ext string, pages Pages), settings map[string]interface{}, pageSources ...string) { - + assertFunc func(t *testing.T, ext string, pages page.Pages), settings map[string]any, pageSources ...string, +) { engines := []struct { ext string shouldExecute func() bool }{ {"md", func() bool { return true }}, - {"mmark", func() bool { return true }}, - {"ad", func() bool { return helpers.HasAsciidoc() }}, - // TODO(bep) figure a way to include this without too much work.{"html", func() bool { return true }}, - {"rst", func() bool { return helpers.HasRst() }}, + {"ad", func() bool { return asciidocext.Supports() }}, + {"rst", func() bool { return rst.Supports() }}, } for _, e := range engines { @@ -578,135 +376,51 @@ func testAllMarkdownEnginesForPages(t *testing.T, continue } - cfg, fs := newTestCfg() - - if settings != nil { + t.Run(e.ext, func(t *testing.T) { + cfg := config.New() for k, v := range settings { cfg.Set(k, v) } - } - contentDir := "content" + if s := cfg.GetString("contentDir"); s != "" && s != "content" { + panic("contentDir must be set to 'content' for this test") + } - if s := cfg.GetString("contentDir"); s != "" { - contentDir = s - } + files := ` +-- hugo.toml -- +[security] +[security.exec] +allow = ['^python$', '^rst2html.*', '^asciidoctor$'] +` - var fileSourcePairs []string + for i, source := range pageSources { + files += fmt.Sprintf("-- content/p%d.%s --\n%s\n", i, e.ext, source) + } + homePath := fmt.Sprintf("_index.%s", e.ext) + files += fmt.Sprintf("-- content/%s --\n%s\n", homePath, homePage) - for i, source := range pageSources { - fileSourcePairs = append(fileSourcePairs, fmt.Sprintf("p%d.%s", i, e.ext), source) - } + b := NewIntegrationTestBuilder( + IntegrationTestConfig{ + T: t, + TxtarString: files, + NeedsOsFS: true, + BaseCfg: cfg, + }, + ).Build() - for i := 0; i < len(fileSourcePairs); i += 2 { - writeSource(t, fs, filepath.Join(contentDir, fileSourcePairs[i]), fileSourcePairs[i+1]) - } + s := b.H.Sites[0] - // Add a content page for the home page - homePath := fmt.Sprintf("_index.%s", e.ext) - writeSource(t, fs, filepath.Join(contentDir, homePath), homePage) + b.Assert(len(s.RegularPages()), qt.Equals, len(pageSources)) - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{SkipRender: true}) + assertFunc(t, e.ext, s.RegularPages()) - require.Len(t, s.RegularPages, len(pageSources)) - - assertFunc(t, e.ext, s.RegularPages) - - home, err := s.Info.Home() - require.NoError(t, err) - require.NotNil(t, home) - require.Equal(t, homePath, home.Path()) - require.Contains(t, home.Content, "Home Page Content") + home := s.Home() + b.Assert(home, qt.Not(qt.IsNil)) + b.Assert(home.File().Path(), qt.Equals, homePath) + b.Assert(content(home), qt.Contains, "Home Page Content") + }) } - -} - -func TestCreateNewPage(t *testing.T) { - t.Parallel() - assertFunc := func(t *testing.T, ext string, pages Pages) { - p := pages[0] - - // issue #2290: Path is relative to the content dir and will continue to be so. - require.Equal(t, filepath.FromSlash(fmt.Sprintf("p0.%s", ext)), p.Path()) - assert.False(t, p.IsHome()) - checkPageTitle(t, p, "Simple") - checkPageContent(t, p, normalizeExpected(ext, "<p>Simple Page</p>\n")) - checkPageSummary(t, p, "Simple Page") - checkPageType(t, p, "page") - checkTruncation(t, p, false, "simple short page") - } - - settings := map[string]interface{}{ - "contentDir": "mycontent", - } - - testAllMarkdownEnginesForPages(t, assertFunc, settings, simplePage) -} - -func TestSplitSummaryAndContent(t *testing.T) { - t.Parallel() - for i, this := range []struct { - markup string - content string - expectedSummary string - expectedContent string - }{ - {"markdown", `<p>Summary Same LineHUGOMORE42</p> - -<p>Some more text</p>`, "<p>Summary Same Line</p>", "<p>Summary Same Line</p>\n\n<p>Some more text</p>"}, - {"asciidoc", `<div class="paragraph"><p>sn</p></div><div class="paragraph"><p>HUGOMORE42Some more text</p></div>`, - "<div class=\"paragraph\"><p>sn</p></div>", - "<div class=\"paragraph\"><p>sn</p></div><div class=\"paragraph\"><p>Some more text</p></div>"}, - {"rst", - "<div class=\"document\"><p>Summary Next Line</p><p>HUGOMORE42Some more text</p></div>", - "<div class=\"document\"><p>Summary Next Line</p></div>", - "<div class=\"document\"><p>Summary Next Line</p><p>Some more text</p></div>"}, - {"markdown", "<p>a</p><p>b</p><p>HUGOMORE42c</p>", "<p>a</p><p>b</p>", "<p>a</p><p>b</p><p>c</p>"}, - {"markdown", "<p>a</p><p>b</p><p>cHUGOMORE42</p>", "<p>a</p><p>b</p><p>c</p>", "<p>a</p><p>b</p><p>c</p>"}, - {"markdown", "<p>a</p><p>bHUGOMORE42</p><p>c</p>", "<p>a</p><p>b</p>", "<p>a</p><p>b</p><p>c</p>"}, - {"markdown", "<p>aHUGOMORE42</p><p>b</p><p>c</p>", "<p>a</p>", "<p>a</p><p>b</p><p>c</p>"}, - {"markdown", " HUGOMORE42 ", "", ""}, - {"markdown", "HUGOMORE42", "", ""}, - {"markdown", "<p>HUGOMORE42", "<p>", "<p>"}, - {"markdown", "HUGOMORE42<p>", "", "<p>"}, - {"markdown", "\n\n<p>HUGOMORE42</p>\n", "<p></p>", "<p></p>"}, - // Issue #2586 - // Note: Hugo will not split mid-sentence but will look for the closest - // paragraph end marker. This may be a change from Hugo 0.16, but it makes sense. - {"markdown", `<p>this is an example HUGOMORE42of the issue.</p>`, - "<p>this is an example of the issue.</p>", - "<p>this is an example of the issue.</p>"}, - // Issue: #2538 - {"markdown", fmt.Sprintf(` <p class="lead">%s</p>HUGOMORE42<p>%s</p> -`, - strings.Repeat("A", 10), strings.Repeat("B", 31)), - fmt.Sprintf(`<p class="lead">%s</p>`, strings.Repeat("A", 10)), - fmt.Sprintf(`<p class="lead">%s</p><p>%s</p>`, strings.Repeat("A", 10), strings.Repeat("B", 31)), - }, - } { - - sc, err := splitUserDefinedSummaryAndContent(this.markup, []byte(this.content)) - - require.NoError(t, err) - require.NotNil(t, sc, fmt.Sprintf("[%d] Nil %s", i, this.markup)) - require.Equal(t, this.expectedSummary, string(sc.summary), fmt.Sprintf("[%d] Summary markup %s", i, this.markup)) - require.Equal(t, this.expectedContent, string(sc.content), fmt.Sprintf("[%d] Content markup %s", i, this.markup)) - } -} - -func TestPageWithDelimiter(t *testing.T) { - t.Parallel() - assertFunc := func(t *testing.T, ext string, pages Pages) { - p := pages[0] - checkPageTitle(t, p, "Simple") - checkPageContent(t, p, normalizeExpected(ext, "<p>Summary Next Line</p>\n\n<p>Some more text</p>\n"), ext) - checkPageSummary(t, p, normalizeExpected(ext, "<p>Summary Next Line</p>"), ext) - checkPageType(t, p, "page") - checkTruncation(t, p, true, "page with summary delimiter") - } - - testAllMarkdownEnginesForPages(t, assertFunc, nil, simplePageWithSummaryDelimiter) } // Issue #1076 @@ -714,27 +428,212 @@ func TestPageWithDelimiterForMarkdownThatCrossesBorder(t *testing.T) { t.Parallel() cfg, fs := newTestCfg() + c := qt.New(t) + configs, err := loadTestConfigFromProvider(cfg) + c.Assert(err, qt.IsNil) + writeSource(t, fs, filepath.Join("content", "simple.md"), simplePageWithSummaryDelimiterAndMarkdownThatCrossesBorder) - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{SkipRender: true}) + s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Configs: configs}, BuildCfg{SkipRender: true}) - require.Len(t, s.RegularPages, 1) + c.Assert(len(s.RegularPages()), qt.Equals, 1) - p := s.RegularPages[0] + p := s.RegularPages()[0] - if p.Summary != template.HTML("<p>The <a href=\"http://gohugo.io/\">best static site generator</a>.<sup class=\"footnote-ref\" id=\"fnref:1\"><a href=\"#fn:1\">1</a></sup>\n</p>") { - t.Fatalf("Got summary:\n%q", p.Summary) + if p.Summary(context.Background()) != template.HTML( + "<p>The <a href=\"http://gohugo.io/\">best static site generator</a>.<sup id=\"fnref:1\"><a href=\"#fn:1\" class=\"footnote-ref\" role=\"doc-noteref\">1</a></sup></p>") { + t.Fatalf("Got summary:\n%q", p.Summary(context.Background())) } - if p.Content != template.HTML("<p>The <a href=\"http://gohugo.io/\">best static site generator</a>.<sup class=\"footnote-ref\" id=\"fnref:1\"><a href=\"#fn:1\">1</a></sup>\n</p>\n<div class=\"footnotes\">\n\n<hr />\n\n<ol>\n<li id=\"fn:1\">Many people say so.\n <a class=\"footnote-return\" href=\"#fnref:1\"><sup>[return]</sup></a></li>\n</ol>\n</div>") { - t.Fatalf("Got content:\n%q", p.Content) + cnt := content(p) + if cnt != "<p>The <a href=\"http://gohugo.io/\">best static site generator</a>.<sup id=\"fnref:1\"><a href=\"#fn:1\" class=\"footnote-ref\" role=\"doc-noteref\">1</a></sup></p>\n<div class=\"footnotes\" role=\"doc-endnotes\">\n<hr>\n<ol>\n<li id=\"fn:1\">\n<p>Many people say so. <a href=\"#fnref:1\" class=\"footnote-backref\" role=\"doc-backlink\">↩︎</a></p>\n</li>\n</ol>\n</div>" { + t.Fatalf("Got content:\n%q", cnt) } } +func TestPageDatesTerms(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "http://example.com/" +-- content/p1.md -- +--- +title: p1 +date: 2022-01-15 +lastMod: 2022-01-16 +tags: ["a", "b"] +categories: ["c", "d"] +--- +p1 +-- content/p2.md -- +--- +title: p2 +date: 2017-01-16 +lastMod: 2017-01-17 +tags: ["a", "c"] +categories: ["c", "e"] +--- +p2 +-- layouts/_default/list.html -- +{{ .Title }}|Date: {{ .Date.Format "2006-01-02" }}|Lastmod: {{ .Lastmod.Format "2006-01-02" }}| + +` + b := Test(t, files) + + b.AssertFileContent("public/categories/index.html", "Categories|Date: 2022-01-15|Lastmod: 2022-01-16|") + b.AssertFileContent("public/categories/c/index.html", "C|Date: 2022-01-15|Lastmod: 2022-01-16|") + b.AssertFileContent("public/categories/e/index.html", "E|Date: 2017-01-16|Lastmod: 2017-01-17|") + b.AssertFileContent("public/tags/index.html", "Tags|Date: 2022-01-15|Lastmod: 2022-01-16|") + b.AssertFileContent("public/tags/a/index.html", "A|Date: 2022-01-15|Lastmod: 2022-01-16|") + b.AssertFileContent("public/tags/c/index.html", "C|Date: 2017-01-16|Lastmod: 2017-01-17|") +} + +func TestPageDatesAllKinds(t *testing.T) { + t.Parallel() + + pageContent := ` +--- +title: Page +date: 2017-01-15 +tags: ["hugo"] +categories: ["cool stuff"] +--- +` + + b := newTestSitesBuilder(t) + b.WithSimpleConfigFile().WithContent("page.md", pageContent) + b.WithContent("blog/page.md", pageContent) + + b.CreateSites().Build(BuildCfg{}) + + b.Assert(len(b.H.Sites), qt.Equals, 1) + s := b.H.Sites[0] + + checkDate := func(t time.Time, msg string) { + b.Helper() + b.Assert(t.Year(), qt.Equals, 2017, qt.Commentf(msg)) + } + + checkDated := func(d resource.Dated, msg string) { + b.Helper() + checkDate(d.Date(), "date: "+msg) + checkDate(d.Lastmod(), "lastmod: "+msg) + } + for _, p := range s.Pages() { + checkDated(p, p.Kind()) + } + checkDate(s.Lastmod(), "site") +} + +func TestPageDatesSections(t *testing.T) { + t.Parallel() + + b := newTestSitesBuilder(t) + b.WithSimpleConfigFile().WithContent("no-index/page.md", ` +--- +title: Page +date: 2017-01-15 +--- +`, "with-index-no-date/_index.md", `--- +title: No Date +--- + +`, + // https://github.com/gohugoio/hugo/issues/5854 + "with-index-date/_index.md", `--- +title: Date +date: 2018-01-15 +--- + +`, "with-index-date/p1.md", `--- +title: Date +date: 2018-01-15 +--- + +`, "with-index-date/p1.md", `--- +title: Date +date: 2018-01-15 +--- + +`) + + for i := 1; i <= 20; i++ { + b.WithContent(fmt.Sprintf("main-section/p%d.md", i), `--- +title: Date +date: 2012-01-12 +--- + +`) + } + + b.CreateSites().Build(BuildCfg{}) + + b.Assert(len(b.H.Sites), qt.Equals, 1) + s := b.H.Sites[0] + + checkDate := func(p page.Page, year int) { + b.Assert(p.Date().Year(), qt.Equals, year) + b.Assert(p.Lastmod().Year(), qt.Equals, year) + } + + checkDate(s.getPageOldVersion("/"), 2018) + checkDate(s.getPageOldVersion("/no-index"), 2017) + b.Assert(s.getPageOldVersion("/with-index-no-date").Date().IsZero(), qt.Equals, true) + checkDate(s.getPageOldVersion("/with-index-date"), 2018) + + b.Assert(s.Site().Lastmod().Year(), qt.Equals, 2018) +} + +func TestPageSummary(t *testing.T) { + t.Parallel() + assertFunc := func(t *testing.T, ext string, pages page.Pages) { + p := pages[0] + checkPageTitle(t, p, "SimpleWithoutSummaryDelimiter") + // Source is not Asciidoctor- or RST-compatible so don't test them + if ext != "ad" && ext != "rst" { + checkPageContent(t, p, normalizeExpected(ext, "<p><a href=\"https://lipsum.com/\">Lorem ipsum</a> dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>\n\n<p>Additional text.</p>\n\n<p>Further text.</p>\n"), ext) + checkPageSummary(t, p, normalizeExpected(ext, "<p><a href=\"https://lipsum.com/\">Lorem ipsum</a> dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p><p>Additional text.</p>"), ext) + } + checkPageType(t, p, "page") + } + + testAllMarkdownEnginesForPages(t, assertFunc, nil, simplePageWithoutSummaryDelimiter) +} + +func TestPageWithDelimiter(t *testing.T) { + t.Parallel() + assertFunc := func(t *testing.T, ext string, pages page.Pages) { + p := pages[0] + checkPageTitle(t, p, "Simple") + checkPageContent(t, p, normalizeExpected(ext, "<p>Summary Next Line</p>\n\n<p>Some more text</p>\n"), ext) + checkPageSummary(t, p, normalizeExpected(ext, "<p>Summary Next Line</p>"), ext) + checkPageType(t, p, "page") + } + + testAllMarkdownEnginesForPages(t, assertFunc, nil, simplePageWithSummaryDelimiter) +} + +func TestPageWithSummaryParameter(t *testing.T) { + t.Parallel() + assertFunc := func(t *testing.T, ext string, pages page.Pages) { + p := pages[0] + checkPageTitle(t, p, "SimpleWithSummaryParameter") + checkPageContent(t, p, normalizeExpected(ext, "<p>Some text.</p>\n\n<p>Some more text.</p>\n"), ext) + // Summary is not Asciidoctor- or RST-compatible so don't test them + if ext != "ad" && ext != "rst" { + checkPageSummary(t, p, normalizeExpected(ext, "Page with summary parameter and <a href=\"http://www.example.com/\">a link</a>"), ext) + } + checkPageType(t, p, "page") + } + + testAllMarkdownEnginesForPages(t, assertFunc, nil, simplePageWithSummaryParameter) +} + // Issue #3854 // Also see https://github.com/gohugoio/hugo/issues/3977 func TestPageWithDateFields(t *testing.T) { - assert := require.New(t) + c := qt.New(t) pageWithDate := `--- title: P%d weight: %d @@ -742,8 +641,8 @@ weight: %d --- Simple Page With Some Date` - hasDate := func(p *Page) bool { - return p.Date.Year() == 2017 + hasDate := func(p page.Page) bool { + return p.Date().Year() == 2017 } datePage := func(field string, weight int) string { @@ -751,12 +650,11 @@ Simple Page With Some Date` } t.Parallel() - assertFunc := func(t *testing.T, ext string, pages Pages) { - assert.True(len(pages) > 0) + assertFunc := func(t *testing.T, ext string, pages page.Pages) { + c.Assert(len(pages) > 0, qt.Equals, true) for _, p := range pages { - assert.True(hasDate(p)) + c.Assert(hasDate(p), qt.Equals, true) } - } fields := []string{"date", "publishdate", "pubdate", "published"} @@ -768,116 +666,152 @@ Simple Page With Some Date` testAllMarkdownEnginesForPages(t, assertFunc, nil, pageContents...) } -// Issue #2601 func TestPageRawContent(t *testing.T) { - t.Parallel() - cfg, fs := newTestCfg() - - writeSource(t, fs, filepath.Join("content", "raw.md"), `--- -title: Raw + files := ` +-- hugo.toml -- +-- content/basic.md -- --- -**Raw**`) +title: "basic" +--- +**basic** +-- content/empty.md -- +--- +title: "empty" +--- +-- layouts/_default/single.html -- +|{{ .RawContent }}| +` - writeSource(t, fs, filepath.Join("layouts", "_default", "single.html"), `{{ .RawContent }}`) + b := Test(t, files) - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{SkipRender: true}) - - require.Len(t, s.RegularPages, 1) - p := s.RegularPages[0] - - require.Contains(t, p.RawContent(), "**Raw**") - -} - -func TestPageWithShortCodeInSummary(t *testing.T) { - t.Parallel() - assertFunc := func(t *testing.T, ext string, pages Pages) { - p := pages[0] - checkPageTitle(t, p, "Simple") - checkPageContent(t, p, normalizeExpected(ext, "<p>Summary Next Line. \n<figure>\n \n <img src=\"/not/real\" />\n \n \n</figure>\n.\nMore text here.</p>\n\n<p>Some more text</p>\n")) - checkPageSummary(t, p, "Summary Next Line. . More text here. Some more text") - checkPageType(t, p, "page") - } - - testAllMarkdownEnginesForPages(t, assertFunc, nil, simplePageWithShortcodeInSummary) -} - -func TestPageWithEmbeddedScriptTag(t *testing.T) { - t.Parallel() - assertFunc := func(t *testing.T, ext string, pages Pages) { - p := pages[0] - if ext == "ad" || ext == "rst" { - // TOD(bep) - return - } - checkPageContent(t, p, "<script type='text/javascript'>alert('the script tags are still there, right?');</script>\n", ext) - } - - testAllMarkdownEnginesForPages(t, assertFunc, nil, simplePageWithEmbeddedScript) -} - -func TestPageWithAdditionalExtension(t *testing.T) { - t.Parallel() - cfg, fs := newTestCfg() - - writeSource(t, fs, filepath.Join("content", "simple.md"), simplePageWithAdditionalExtension) - - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{SkipRender: true}) - - require.Len(t, s.RegularPages, 1) - - p := s.RegularPages[0] - - checkPageContent(t, p, "<p>first line.<br />\nsecond line.</p>\n\n<p>fourth line.</p>\n") + b.AssertFileContent("public/basic/index.html", "|**basic**|") + b.AssertFileContent("public/empty/index.html", "! title") } func TestTableOfContents(t *testing.T) { - + c := qt.New(t) cfg, fs := newTestCfg() + configs, err := loadTestConfigFromProvider(cfg) + c.Assert(err, qt.IsNil) writeSource(t, fs, filepath.Join("content", "tocpage.md"), pageWithToC) - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{SkipRender: true}) + s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Configs: configs}, BuildCfg{SkipRender: true}) - require.Len(t, s.RegularPages, 1) + c.Assert(len(s.RegularPages()), qt.Equals, 1) - p := s.RegularPages[0] + p := s.RegularPages()[0] - checkPageContent(t, p, "\n\n<p>For some moments the old man did not reply. He stood with bowed head, buried in deep thought. But at last he spoke.</p>\n\n<h2 id=\"aa\">AA</h2>\n\n<p>I have no idea, of course, how long it took me to reach the limit of the plain,\nbut at last I entered the foothills, following a pretty little canyon upward\ntoward the mountains. Beside me frolicked a laughing brooklet, hurrying upon\nits noisy way down to the silent sea. In its quieter pools I discovered many\nsmall fish, of four-or five-pound weight I should imagine. In appearance,\nexcept as to size and color, they were not unlike the whale of our own seas. As\nI watched them playing about I discovered, not only that they suckled their\nyoung, but that at intervals they rose to the surface to breathe as well as to\nfeed upon certain grasses and a strange, scarlet lichen which grew upon the\nrocks just above the water line.</p>\n\n<h3 id=\"aaa\">AAA</h3>\n\n<p>I remember I felt an extraordinary persuasion that I was being played with,\nthat presently, when I was upon the very verge of safety, this mysterious\ndeath–as swift as the passage of light–would leap after me from the pit about\nthe cylinder and strike me down. ## BB</p>\n\n<h3 id=\"bbb\">BBB</h3>\n\n<p>“You’re a great Granser,” he cried delightedly, “always making believe them little marks mean something.”</p>\n") - checkPageTOC(t, p, "<nav id=\"TableOfContents\">\n<ul>\n<li>\n<ul>\n<li><a href=\"#aa\">AA</a>\n<ul>\n<li><a href=\"#aaa\">AAA</a></li>\n<li><a href=\"#bbb\">BBB</a></li>\n</ul></li>\n</ul></li>\n</ul>\n</nav>") + checkPageContent(t, p, "<p>For some moments the old man did not reply. He stood with bowed head, buried in deep thought. But at last he spoke.</p><h2 id=\"aa\">AA</h2> <p>I have no idea, of course, how long it took me to reach the limit of the plain, but at last I entered the foothills, following a pretty little canyon upward toward the mountains. Beside me frolicked a laughing brooklet, hurrying upon its noisy way down to the silent sea. In its quieter pools I discovered many small fish, of four-or five-pound weight I should imagine. In appearance, except as to size and color, they were not unlike the whale of our own seas. As I watched them playing about I discovered, not only that they suckled their young, but that at intervals they rose to the surface to breathe as well as to feed upon certain grasses and a strange, scarlet lichen which grew upon the rocks just above the water line.</p><h3 id=\"aaa\">AAA</h3> <p>I remember I felt an extraordinary persuasion that I was being played with, that presently, when I was upon the very verge of safety, this mysterious death–as swift as the passage of light–would leap after me from the pit about the cylinder and strike me down. ## BB</p><h3 id=\"bbb\">BBB</h3> <p>“You’re a great Granser,” he cried delightedly, “always making believe them little marks mean something.”</p>") + checkPageTOC(t, p, "<nav id=\"TableOfContents\">\n <ul>\n <li><a href=\"#aa\">AA</a>\n <ul>\n <li><a href=\"#aaa\">AAA</a></li>\n <li><a href=\"#bbb\">BBB</a></li>\n </ul>\n </li>\n </ul>\n</nav>") } func TestPageWithMoreTag(t *testing.T) { t.Parallel() - assertFunc := func(t *testing.T, ext string, pages Pages) { + assertFunc := func(t *testing.T, ext string, pages page.Pages) { p := pages[0] checkPageTitle(t, p, "Simple") checkPageContent(t, p, normalizeExpected(ext, "<p>Summary Same Line</p>\n\n<p>Some more text</p>\n")) checkPageSummary(t, p, normalizeExpected(ext, "<p>Summary Same Line</p>")) checkPageType(t, p, "page") - } testAllMarkdownEnginesForPages(t, assertFunc, nil, simplePageWithSummaryDelimiterSameLine) } -func TestPageWithMoreTagOnlySummary(t *testing.T) { +func TestSummaryInFrontMatter(t *testing.T) { + t.Parallel() + Test(t, ` +-- hugo.toml -- +-- content/simple.md -- +--- +title: Simple +summary: "Front **matter** summary" +--- +Simple Page +-- layouts/_default/single.html -- +Summary: {{ .Summary }}|Truncated: {{ .Truncated }}| - assertFunc := func(t *testing.T, ext string, pages Pages) { - p := pages[0] - checkTruncation(t, p, false, "page with summary delimiter at end") - } +`).AssertFileContent("public/simple/index.html", "Summary: Front <strong>matter</strong> summary|", "Truncated: false") +} - testAllMarkdownEnginesForPages(t, assertFunc, nil, simplePageWithSummaryDelimiterOnlySummary) +func TestSummaryManualSplit(t *testing.T) { + t.Parallel() + Test(t, ` +-- hugo.toml -- +-- content/simple.md -- +--- +title: Simple +--- +This is **summary**. +<!--more--> +This is **content**. +-- layouts/_default/single.html -- +Summary: {{ .Summary }}|Truncated: {{ .Truncated }}| +Content: {{ .Content }}| + +`).AssertFileContent("public/simple/index.html", + "Summary: <p>This is <strong>summary</strong>.</p>|", + "Truncated: true|", + "Content: <p>This is <strong>summary</strong>.</p>\n<p>This is <strong>content</strong>.</p>|", + ) +} + +func TestSummaryManualSplitHTML(t *testing.T) { + t.Parallel() + Test(t, ` +-- hugo.toml -- +-- content/simple.html -- +--- +title: Simple +--- +<div> +This is <b>summary</b>. +</div> +<!--more--> +<div> +This is <b>content</b>. +</div> +-- layouts/_default/single.html -- +Summary: {{ .Summary }}|Truncated: {{ .Truncated }}| +Content: {{ .Content }}| + +`).AssertFileContent("public/simple/index.html", "Summary: <div>\nThis is <b>summary</b>.\n</div>\n|Truncated: true|\nContent: \n\n<div>\nThis is <b>content</b>.\n</div>|") +} + +func TestSummaryAuto(t *testing.T) { + t.Parallel() + Test(t, ` +-- hugo.toml -- +summaryLength = 10 +-- content/simple.md -- +--- +title: Simple +--- +This is **summary**. +This is **more summary**. +This is *even more summary**. +This is **more summary**. + +This is **content**. +-- layouts/_default/single.html -- +Summary: {{ .Summary }}|Truncated: {{ .Truncated }}| +Content: {{ .Content }}| + +`).AssertFileContent("public/simple/index.html", + "Summary: <p>This is <strong>summary</strong>.\nThis is <strong>more summary</strong>.\nThis is <em>even more summary</em>*.\nThis is <strong>more summary</strong>.</p>|", + "Truncated: true|", + "Content: <p>This is <strong>summary</strong>.") } // #2973 func TestSummaryWithHTMLTagsOnNextLine(t *testing.T) { - - assertFunc := func(t *testing.T, ext string, pages Pages) { + assertFunc := func(t *testing.T, ext string, pages page.Pages) { + c := qt.New(t) p := pages[0] - require.Contains(t, p.Summary, "Happy new year everyone!") - require.NotContains(t, p.Summary, "User interface") + s := string(p.Summary(context.Background())) + c.Assert(s, qt.Contains, "Happy new year everyone!") + c.Assert(s, qt.Not(qt.Contains), "User interface") } testAllMarkdownEnginesForPages(t, assertFunc, nil, `--- @@ -894,56 +828,271 @@ Here is the last report for commits in the year 2016. It covers hrev50718-hrev50 `) } +// Issue 9383 +func TestRenderStringForRegularPageTranslations(t *testing.T) { + c := qt.New(t) + b := newTestSitesBuilder(t) + b.WithLogger(loggers.NewDefault()) + + b.WithConfigFile("toml", + `baseurl = "https://example.org/" +title = "My Site" + +defaultContentLanguage = "ru" +defaultContentLanguageInSubdir = true + +[languages.ru] +contentDir = 'content/ru' +weight = 1 + +[languages.en] +weight = 2 +contentDir = 'content/en' + +[outputs] +home = ["HTML", "JSON"]`) + + b.WithTemplates("index.html", ` +{{- range .Site.Home.Translations -}} + <p>{{- .RenderString "foo" -}}</p> +{{- end -}} +{{- range .Site.Home.AllTranslations -}} + <p>{{- .RenderString "bar" -}}</p> +{{- end -}} +`, "_default/single.html", + `{{ .Content }}`, + "index.json", + `{"Title": "My Site"}`, + ) + + b.WithContent( + "ru/a.md", + "", + "en/a.md", + "", + ) + + err := b.BuildE(BuildCfg{}) + c.Assert(err, qt.Equals, nil) + + b.AssertFileContent("public/ru/index.html", ` +<p>foo</p> +<p>foo</p> +<p>bar</p> +<p>bar</p> +`) + + b.AssertFileContent("public/en/index.html", ` +<p>foo</p> +<p>foo</p> +<p>bar</p> +<p>bar</p> +`) +} + +// Issue 8919 +func TestContentProviderWithCustomOutputFormat(t *testing.T) { + b := newTestSitesBuilder(t) + b.WithLogger(loggers.NewDefault()) + b.WithConfigFile("toml", `baseURL = 'http://example.org/' +title = 'My New Hugo Site' + +timeout = 600000 # ten minutes in case we want to pause and debug + +defaultContentLanguage = "en" + +[languages] + [languages.en] + title = "Repro" + languageName = "English" + contentDir = "content/en" + + [languages.zh_CN] + title = "Repro" + languageName = "简体中文" + contentDir = "content/zh_CN" + +[outputFormats] + [outputFormats.metadata] + baseName = "metadata" + mediaType = "text/html" + isPlainText = true + notAlternative = true + +[outputs] + home = ["HTML", "metadata"]`) + + b.WithTemplates("home.metadata.html", `<h2>Translations metadata</h2> +<ul> +{{ $p := .Page }} +{{ range $p.Translations}} +<li>Title: {{ .Title }}, {{ .Summary }}</li> +<li>Content: {{ .Content }}</li> +<li>Plain: {{ .Plain }}</li> +<li>PlainWords: {{ .PlainWords }}</li> +<li>Summary: {{ .Summary }}</li> +<li>Truncated: {{ .Truncated }}</li> +<li>FuzzyWordCount: {{ .FuzzyWordCount }}</li> +<li>ReadingTime: {{ .ReadingTime }}</li> +<li>Len: {{ .Len }}</li> +{{ end }} +</ul>`) + + b.WithTemplates("_default/baseof.html", `<html> + +<body> + {{ block "main" . }}{{ end }} +</body> + +</html>`) + + b.WithTemplates("_default/home.html", `{{ define "main" }} +<h2>Translations</h2> +<ul> +{{ $p := .Page }} +{{ range $p.Translations}} +<li>Title: {{ .Title }}, {{ .Summary }}</li> +<li>Content: {{ .Content }}</li> +<li>Plain: {{ .Plain }}</li> +<li>PlainWords: {{ .PlainWords }}</li> +<li>Summary: {{ .Summary }}</li> +<li>Truncated: {{ .Truncated }}</li> +<li>FuzzyWordCount: {{ .FuzzyWordCount }}</li> +<li>ReadingTime: {{ .ReadingTime }}</li> +<li>Len: {{ .Len }}</li> +{{ end }} +</ul> +{{ end }}`) + + b.WithContent("en/_index.md", `--- +title: Title (en) +summary: Summary (en) +--- + +Here is some content. +`) + + b.WithContent("zh_CN/_index.md", `--- +title: Title (zh) +summary: Summary (zh) +--- + +这是一些内容 +`) + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", `<html> + +<body> + +<h2>Translations</h2> +<ul> + + +<li>Title: Title (zh), Summary (zh)</li> +<li>Content: <p>这是一些内容</p> +</li> +<li>Plain: 这是一些内容 +</li> +<li>PlainWords: [这是一些内容]</li> +<li>Summary: Summary (zh)</li> +<li>Truncated: false</li> +<li>FuzzyWordCount: 100</li> +<li>ReadingTime: 1</li> +<li>Len: 26</li> + +</ul> + +</body> + +</html>`) + b.AssertFileContent("public/metadata.html", `<h2>Translations metadata</h2> +<ul> + + +<li>Title: Title (zh), Summary (zh)</li> +<li>Content: <p>这是一些内容</p> +</li> +<li>Plain: 这是一些内容 +</li> +<li>PlainWords: [这是一些内容]</li> +<li>Summary: Summary (zh)</li> +<li>Truncated: false</li> +<li>FuzzyWordCount: 100</li> +<li>ReadingTime: 1</li> +<li>Len: 26</li> + +</ul>`) + b.AssertFileContent("public/zh_cn/index.html", `<html> + +<body> + +<h2>Translations</h2> +<ul> + + +<li>Title: Title (en), Summary (en)</li> +<li>Content: <p>Here is some content.</p> +</li> +<li>Plain: Here is some content. +</li> +<li>PlainWords: [Here is some content.]</li> +<li>Summary: Summary (en)</li> +<li>Truncated: false</li> +<li>FuzzyWordCount: 100</li> +<li>ReadingTime: 1</li> +<li>Len: 29</li> + +</ul> + +</body> + +</html>`) + b.AssertFileContent("public/zh_cn/metadata.html", `<h2>Translations metadata</h2> +<ul> + + +<li>Title: Title (en), Summary (en)</li> +<li>Content: <p>Here is some content.</p> +</li> +<li>Plain: Here is some content. +</li> +<li>PlainWords: [Here is some content.]</li> +<li>Summary: Summary (en)</li> +<li>Truncated: false</li> +<li>FuzzyWordCount: 100</li> +<li>ReadingTime: 1</li> +<li>Len: 29</li> + +</ul>`) +} + func TestPageWithDate(t *testing.T) { t.Parallel() + c := qt.New(t) cfg, fs := newTestCfg() + configs, err := loadTestConfigFromProvider(cfg) + c.Assert(err, qt.IsNil) writeSource(t, fs, filepath.Join("content", "simple.md"), simplePageRFC3339Date) - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{SkipRender: true}) + s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Configs: configs}, BuildCfg{SkipRender: true}) - require.Len(t, s.RegularPages, 1) + c.Assert(len(s.RegularPages()), qt.Equals, 1) - p := s.RegularPages[0] + p := s.RegularPages()[0] d, _ := time.Parse(time.RFC3339, "2013-05-17T16:59:30Z") checkPageDate(t, p, d) } -func TestPageWithLastmodFromGitInfo(t *testing.T) { - assrt := require.New(t) - - // We need to use the OS fs for this. - cfg := viper.New() - fs := hugofs.NewFrom(hugofs.Os, cfg) - fs.Destination = &afero.MemMapFs{} - - cfg.Set("frontmatter", map[string]interface{}{ - "lastmod": []string{":git", "lastmod"}, - }) - - cfg.Set("enableGitInfo", true) - - assrt.NoError(loadDefaultSettingsFor(cfg)) - - wd, err := os.Getwd() - assrt.NoError(err) - cfg.Set("workingDir", filepath.Join(wd, "testsite")) - - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{SkipRender: true}) - - assrt.Len(s.RegularPages, 1) - - // 2018-03-11 is the Git author date for testsite/content/first-post.md - assrt.Equal("2018-03-11", s.RegularPages[0].Lastmod.Format("2006-01-02")) -} - func TestPageWithFrontMatterConfig(t *testing.T) { - t.Parallel() - for _, dateHandler := range []string{":filename", ":fileModTime"} { + dateHandler := dateHandler t.Run(fmt.Sprintf("dateHandler=%q", dateHandler), func(t *testing.T) { - assrt := require.New(t) + t.Parallel() + c := qt.New(t) cfg, fs := newTestCfg() pageTemplate := ` @@ -956,9 +1105,11 @@ lastMod: 2018-02-28 Content ` - cfg.Set("frontmatter", map[string]interface{}{ + cfg.Set("frontmatter", map[string]any{ "date": []string{dateHandler, "date"}, }) + configs, err := loadTestConfigFromProvider(cfg) + c.Assert(err, qt.IsNil) c1 := filepath.Join("content", "section", "2012-02-21-noslug.md") c2 := filepath.Join("content", "section", "2012-02-22-slug.md") @@ -967,47 +1118,48 @@ Content writeSource(t, fs, c2, fmt.Sprintf(pageTemplate, 2, "slug: aslug")) c1fi, err := fs.Source.Stat(c1) - assrt.NoError(err) + c.Assert(err, qt.IsNil) c2fi, err := fs.Source.Stat(c2) - assrt.NoError(err) + c.Assert(err, qt.IsNil) - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{SkipRender: true}) + b := newTestSitesBuilderFromDepsCfg(t, deps.DepsCfg{Fs: fs, Configs: configs}).WithNothingAdded() + b.Build(BuildCfg{SkipRender: true}) - assrt.Len(s.RegularPages, 2) + s := b.H.Sites[0] + c.Assert(len(s.RegularPages()), qt.Equals, 2) - noSlug := s.RegularPages[0] - slug := s.RegularPages[1] + noSlug := s.RegularPages()[0] + slug := s.RegularPages()[1] - assrt.Equal(28, noSlug.Lastmod.Day()) + c.Assert(noSlug.Lastmod().Day(), qt.Equals, 28) switch strings.ToLower(dateHandler) { case ":filename": - assrt.False(noSlug.Date.IsZero()) - assrt.False(slug.Date.IsZero()) - assrt.Equal(2012, noSlug.Date.Year()) - assrt.Equal(2012, slug.Date.Year()) - assrt.Equal("noslug", noSlug.Slug) - assrt.Equal("aslug", slug.Slug) + c.Assert(noSlug.Date().IsZero(), qt.Equals, false) + c.Assert(slug.Date().IsZero(), qt.Equals, false) + c.Assert(noSlug.Date().Year(), qt.Equals, 2012) + c.Assert(slug.Date().Year(), qt.Equals, 2012) + c.Assert(noSlug.Slug(), qt.Equals, "noslug") + c.Assert(slug.Slug(), qt.Equals, "aslug") case ":filemodtime": - assrt.Equal(c1fi.ModTime().Year(), noSlug.Date.Year()) - assrt.Equal(c2fi.ModTime().Year(), slug.Date.Year()) + c.Assert(noSlug.Date().Year(), qt.Equals, c1fi.ModTime().Year()) + c.Assert(slug.Date().Year(), qt.Equals, c2fi.ModTime().Year()) fallthrough default: - assrt.Equal("", noSlug.Slug) - assrt.Equal("aslug", slug.Slug) + c.Assert(noSlug.Slug(), qt.Equals, "") + c.Assert(slug.Slug(), qt.Equals, "aslug") } }) } - } func TestWordCountWithAllCJKRunesWithoutHasCJKLanguage(t *testing.T) { t.Parallel() - assertFunc := func(t *testing.T, ext string, pages Pages) { + assertFunc := func(t *testing.T, ext string, pages page.Pages) { p := pages[0] - if p.WordCount() != 8 { - t.Fatalf("[%s] incorrect word count for content '%s'. expected %v, got %v", ext, p.plain, 8, p.WordCount()) + if p.WordCount(context.Background()) != 8 { + t.Fatalf("[%s] incorrect word count. expected %v, got %v", ext, 8, p.WordCount(context.Background())) } } @@ -1016,12 +1168,12 @@ func TestWordCountWithAllCJKRunesWithoutHasCJKLanguage(t *testing.T) { func TestWordCountWithAllCJKRunesHasCJKLanguage(t *testing.T) { t.Parallel() - settings := map[string]interface{}{"hasCJKLanguage": true} + settings := map[string]any{"hasCJKLanguage": true} - assertFunc := func(t *testing.T, ext string, pages Pages) { + assertFunc := func(t *testing.T, ext string, pages page.Pages) { p := pages[0] - if p.WordCount() != 15 { - t.Fatalf("[%s] incorrect word count for content '%s'. expected %v, got %v", ext, p.plain, 15, p.WordCount()) + if p.WordCount(context.Background()) != 15 { + t.Fatalf("[%s] incorrect word count, expected %v, got %v", ext, 15, p.WordCount(context.Background())) } } testAllMarkdownEnginesForPages(t, assertFunc, settings, simplePageWithAllCJKRunes) @@ -1029,17 +1181,12 @@ func TestWordCountWithAllCJKRunesHasCJKLanguage(t *testing.T) { func TestWordCountWithMainEnglishWithCJKRunes(t *testing.T) { t.Parallel() - settings := map[string]interface{}{"hasCJKLanguage": true} + settings := map[string]any{"hasCJKLanguage": true} - assertFunc := func(t *testing.T, ext string, pages Pages) { + assertFunc := func(t *testing.T, ext string, pages page.Pages) { p := pages[0] - if p.WordCount() != 74 { - t.Fatalf("[%s] incorrect word count for content '%s'. expected %v, got %v", ext, p.plain, 74, p.WordCount()) - } - - if p.Summary != simplePageWithMainEnglishWithCJKRunesSummary { - t.Fatalf("[%s] incorrect Summary for content '%s'. expected %v, got %v", ext, p.plain, - simplePageWithMainEnglishWithCJKRunesSummary, p.Summary) + if p.WordCount(context.Background()) != 74 { + t.Fatalf("[%s] incorrect word count, expected %v, got %v", ext, 74, p.WordCount(context.Background())) } } @@ -1048,256 +1195,43 @@ func TestWordCountWithMainEnglishWithCJKRunes(t *testing.T) { func TestWordCountWithIsCJKLanguageFalse(t *testing.T) { t.Parallel() - settings := map[string]interface{}{ + settings := map[string]any{ "hasCJKLanguage": true, } - assertFunc := func(t *testing.T, ext string, pages Pages) { + assertFunc := func(t *testing.T, ext string, pages page.Pages) { p := pages[0] - if p.WordCount() != 75 { - t.Fatalf("[%s] incorrect word count for content '%s'. expected %v, got %v", ext, p.plain, 74, p.WordCount()) - } - - if p.Summary != simplePageWithIsCJKLanguageFalseSummary { - t.Fatalf("[%s] incorrect Summary for content '%s'. expected %v, got %v", ext, p.plain, - simplePageWithIsCJKLanguageFalseSummary, p.Summary) + if p.WordCount(context.Background()) != 75 { + t.Fatalf("[%s] incorrect word count for content '%s'. expected %v, got %v", ext, p.Plain(context.Background()), 74, p.WordCount(context.Background())) } } testAllMarkdownEnginesForPages(t, assertFunc, settings, simplePageWithIsCJKLanguageFalse) - } func TestWordCount(t *testing.T) { t.Parallel() - assertFunc := func(t *testing.T, ext string, pages Pages) { + assertFunc := func(t *testing.T, ext string, pages page.Pages) { p := pages[0] - if p.WordCount() != 483 { - t.Fatalf("[%s] incorrect word count. expected %v, got %v", ext, 483, p.WordCount()) + if p.WordCount(context.Background()) != 483 { + t.Fatalf("[%s] incorrect word count. expected %v, got %v", ext, 483, p.WordCount(context.Background())) } - if p.FuzzyWordCount() != 500 { - t.Fatalf("[%s] incorrect word count. expected %v, got %v", ext, 500, p.WordCount()) + if p.FuzzyWordCount(context.Background()) != 500 { + t.Fatalf("[%s] incorrect word count. expected %v, got %v", ext, 500, p.FuzzyWordCount(context.Background())) } - if p.ReadingTime() != 3 { - t.Fatalf("[%s] incorrect min read. expected %v, got %v", ext, 3, p.ReadingTime()) + if p.ReadingTime(context.Background()) != 3 { + t.Fatalf("[%s] incorrect min read. expected %v, got %v", ext, 3, p.ReadingTime(context.Background())) } - - checkTruncation(t, p, true, "long page") } testAllMarkdownEnginesForPages(t, assertFunc, nil, simplePageWithLongContent) } -func TestCreatePage(t *testing.T) { - t.Parallel() - var tests = []struct { - r string - }{ - {simplePageJSON}, - {simplePageJSONMultiple}, - //{strings.NewReader(SIMPLE_PAGE_JSON_COMPACT)}, - } - - for i, test := range tests { - s := newTestSite(t) - p, _ := s.NewPage("page") - if _, err := p.ReadFrom(strings.NewReader(test.r)); err != nil { - t.Fatalf("[%d] Unable to parse page: %s", i, err) - } - } -} - -func TestDegenerateInvalidFrontMatterShortDelim(t *testing.T) { - t.Parallel() - var tests = []struct { - r string - err string - }{ - {invalidFrontmatterShortDelimEnding, "unable to read frontmatter at filepos 45: EOF"}, - } - for _, test := range tests { - s := newTestSite(t) - p, _ := s.NewPage("invalid/front/matter/short/delim") - _, err := p.ReadFrom(strings.NewReader(test.r)) - checkError(t, err, test.err) - } -} - -func TestShouldRenderContent(t *testing.T) { - t.Parallel() - var tests = []struct { - text string - render bool - }{ - {contentNoFrontmatter, true}, - // TODO how to deal with malformed frontmatter. In this case it'll be rendered as markdown. - {invalidFrontmatterShortDelim, true}, - {renderNoFrontmatter, false}, - {contentWithCommentedFrontmatter, true}, - {contentWithCommentedTextFrontmatter, true}, - {contentWithCommentedLongFrontmatter, false}, - {contentWithCommentedLong2Frontmatter, true}, - } - - for _, test := range tests { - s := newTestSite(t) - p, _ := s.NewPage("render/front/matter") - _, err := p.ReadFrom(strings.NewReader(test.text)) - p = pageMust(p, err) - if p.IsRenderable() != test.render { - t.Errorf("expected p.IsRenderable() == %t, got %t", test.render, p.IsRenderable()) - } - } -} - -// Issue #768 -func TestCalendarParamsVariants(t *testing.T) { - t.Parallel() - s := newTestSite(t) - pageJSON, _ := s.NewPage("test/fileJSON.md") - _, _ = pageJSON.ReadFrom(strings.NewReader(pageWithCalendarJSONFrontmatter)) - - pageYAML, _ := s.NewPage("test/fileYAML.md") - _, _ = pageYAML.ReadFrom(strings.NewReader(pageWithCalendarYAMLFrontmatter)) - - pageTOML, _ := s.NewPage("test/fileTOML.md") - _, _ = pageTOML.ReadFrom(strings.NewReader(pageWithCalendarTOMLFrontmatter)) - - assert.True(t, compareObjects(pageJSON.params, pageYAML.params)) - assert.True(t, compareObjects(pageJSON.params, pageTOML.params)) - -} - -func TestDifferentFrontMatterVarTypes(t *testing.T) { - t.Parallel() - s := newTestSite(t) - page, _ := s.NewPage("test/file1.md") - _, _ = page.ReadFrom(strings.NewReader(pageWithVariousFrontmatterTypes)) - - dateval, _ := time.Parse(time.RFC3339, "1979-05-27T07:32:00Z") - if page.getParamToLower("a_string") != "bar" { - t.Errorf("frontmatter not handling strings correctly should be %s, got: %s", "bar", page.getParamToLower("a_string")) - } - if page.getParamToLower("an_integer") != 1 { - t.Errorf("frontmatter not handling ints correctly should be %s, got: %s", "1", page.getParamToLower("an_integer")) - } - if page.getParamToLower("a_float") != 1.3 { - t.Errorf("frontmatter not handling floats correctly should be %f, got: %s", 1.3, page.getParamToLower("a_float")) - } - if page.getParamToLower("a_bool") != false { - t.Errorf("frontmatter not handling bools correctly should be %t, got: %s", false, page.getParamToLower("a_bool")) - } - if page.getParamToLower("a_date") != dateval { - t.Errorf("frontmatter not handling dates correctly should be %s, got: %s", dateval, page.getParamToLower("a_date")) - } - param := page.getParamToLower("a_table") - if param == nil { - t.Errorf("frontmatter not handling tables correctly should be type of %v, got: type of %v", reflect.TypeOf(page.params["a_table"]), reflect.TypeOf(param)) - } - if cast.ToStringMap(param)["a_key"] != "a_value" { - t.Errorf("frontmatter not handling values inside a table correctly should be %s, got: %s", "a_value", cast.ToStringMap(page.params["a_table"])["a_key"]) - } -} - -func TestDegenerateInvalidFrontMatterLeadingWhitespace(t *testing.T) { - t.Parallel() - s := newTestSite(t) - p, _ := s.NewPage("invalid/front/matter/leading/ws") - _, err := p.ReadFrom(strings.NewReader(invalidFrontmatterLadingWs)) - if err != nil { - t.Fatalf("Unable to parse front matter given leading whitespace: %s", err) - } -} - -func TestSectionEvaluation(t *testing.T) { - t.Parallel() - s := newTestSite(t) - page, _ := s.NewPage(filepath.FromSlash("blue/file1.md")) - page.ReadFrom(strings.NewReader(simplePage)) - if page.Section() != "blue" { - t.Errorf("Section should be %s, got: %s", "blue", page.Section()) - } -} - -func TestSliceToLower(t *testing.T) { - t.Parallel() - tests := []struct { - value []string - expected []string - }{ - {[]string{"a", "b", "c"}, []string{"a", "b", "c"}}, - {[]string{"a", "B", "c"}, []string{"a", "b", "c"}}, - {[]string{"A", "B", "C"}, []string{"a", "b", "c"}}, - } - - for _, test := range tests { - res := helpers.SliceToLower(test.value) - for i, val := range res { - if val != test.expected[i] { - t.Errorf("Case mismatch. Expected %s, got %s", test.expected[i], res[i]) - } - } - } -} - -func TestReplaceDivider(t *testing.T) { - t.Parallel() - - tests := []struct { - content string - from string - to string - expectedContent string - expectedTruncated bool - }{ - {"none", "a", "b", "none", false}, - {"summary <!--more--> content", "<!--more-->", "HUGO", "summary HUGO content", true}, - {"summary\n\ndivider", "divider", "HUGO", "summary\n\nHUGO", false}, - {"summary\n\ndivider\n\r", "divider", "HUGO", "summary\n\nHUGO\n\r", false}, - } - - for i, test := range tests { - replaced, truncated := replaceDivider([]byte(test.content), []byte(test.from), []byte(test.to)) - - if truncated != test.expectedTruncated { - t.Fatalf("[%d] Expected truncated to be %t, was %t", i, test.expectedTruncated, truncated) - } - - if string(replaced) != test.expectedContent { - t.Fatalf("[%d] Expected content to be %q, was %q", i, test.expectedContent, replaced) - } - } -} - -func BenchmarkReplaceDivider(b *testing.B) { - divider := "HUGO_DIVIDER" - from, to := []byte(divider), []byte("HUGO_REPLACED") - - withDivider := make([][]byte, b.N) - noDivider := make([][]byte, b.N) - - for i := 0; i < b.N; i++ { - withDivider[i] = []byte(strings.Repeat("Summary ", 5) + "\n" + divider + "\n" + strings.Repeat("Word ", 300)) - noDivider[i] = []byte(strings.Repeat("Word ", 300)) - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, t1 := replaceDivider(withDivider[i], from, to) - _, t2 := replaceDivider(noDivider[i], from, to) - if !t1 { - b.Fatal("Should be truncated") - } - if t2 { - b.Fatal("Should not be truncated") - } - } -} - func TestPagePaths(t *testing.T) { t.Parallel() + c := qt.New(t) siteParmalinksSetting := map[string]string{ "post": ":year/:month/:day/:title/", @@ -1321,6 +1255,8 @@ func TestPagePaths(t *testing.T) { for _, test := range tests { cfg, fs := newTestCfg() + configs, err := loadTestConfigFromProvider(cfg) + c.Assert(err, qt.IsNil) if test.hasPermalink { cfg.Set("permalinks", siteParmalinksSetting) @@ -1328,284 +1264,274 @@ func TestPagePaths(t *testing.T) { writeSource(t, fs, filepath.Join("content", filepath.FromSlash(test.path)), test.content) - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{SkipRender: true}) - require.Len(t, s.RegularPages, 1) + s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Configs: configs}, BuildCfg{SkipRender: true}) + c.Assert(len(s.RegularPages()), qt.Equals, 1) } } -var pageWithDraftAndPublished = `--- -title: broken -published: false -draft: true ---- -some content -` - -func TestDraftAndPublishedFrontMatterError(t *testing.T) { - t.Parallel() - s := newTestSite(t) - _, err := s.NewPageFrom(strings.NewReader(pageWithDraftAndPublished), "content/post/broken.md") - if err != ErrHasDraftAndPublished { - t.Errorf("expected ErrHasDraftAndPublished, was %#v", err) - } -} - -var pagesWithPublishedFalse = `--- -title: okay -published: false ---- -some content -` -var pageWithPublishedTrue = `--- -title: okay -published: true ---- -some content -` - -func TestPublishedFrontMatter(t *testing.T) { - t.Parallel() - s := newTestSite(t) - p, err := s.NewPageFrom(strings.NewReader(pagesWithPublishedFalse), "content/post/broken.md") - if err != nil { - t.Fatalf("err during parse: %s", err) - } - if !p.Draft { - t.Errorf("expected true, got %t", p.Draft) - } - p, err = s.NewPageFrom(strings.NewReader(pageWithPublishedTrue), "content/post/broken.md") - if err != nil { - t.Fatalf("err during parse: %s", err) - } - if p.Draft { - t.Errorf("expected false, got %t", p.Draft) - } -} - -var pagesDraftTemplate = []string{`--- -title: "okay" -draft: %t ---- -some content -`, - `+++ -title = "okay" -draft = %t -+++ - -some content -`, -} - -func TestDraft(t *testing.T) { - t.Parallel() - s := newTestSite(t) - for _, draft := range []bool{true, false} { - for i, templ := range pagesDraftTemplate { - pageContent := fmt.Sprintf(templ, draft) - p, err := s.NewPageFrom(strings.NewReader(pageContent), "content/post/broken.md") - if err != nil { - t.Fatalf("err during parse: %s", err) - } - if p.Draft != draft { - t.Errorf("[%d] expected %t, got %t", i, draft, p.Draft) - } - } - } -} - -var pagesParamsTemplate = []string{`+++ -title = "okay" -draft = false -tags = [ "hugo", "web" ] -social= [ - [ "a", "#" ], - [ "b", "#" ], -] -+++ -some content -`, - `--- -title: "okay" -draft: false -tags: - - hugo - - web -social: - - - a - - "#" - - - b - - "#" ---- -some content -`, - `{ - "title": "okay", - "draft": false, - "tags": [ "hugo", "web" ], - "social": [ - [ "a", "#" ], - [ "b", "#" ] - ] -} -some content -`, -} - -func TestPageParams(t *testing.T) { - t.Parallel() - s := newTestSite(t) - wantedMap := map[string]interface{}{ - "tags": []string{"hugo", "web"}, - // Issue #2752 - "social": []interface{}{ - []interface{}{"a", "#"}, - []interface{}{"b", "#"}, - }, - } - - for i, c := range pagesParamsTemplate { - p, err := s.NewPageFrom(strings.NewReader(c), "content/post/params.md") - require.NoError(t, err, "err during parse", "#%d", i) - for key := range wantedMap { - assert.Equal(t, wantedMap[key], p.params[key], "#%d", key) - } - } -} - -func TestTraverse(t *testing.T) { - exampleParams := `--- -rating: "5 stars" -tags: - - hugo - - web -social: - twitter: "@jxxf" - facebook: "https://example.com" ----` - t.Parallel() - s := newTestSite(t) - p, _ := s.NewPageFrom(strings.NewReader(exampleParams), "content/post/params.md") - - topLevelKeyValue, _ := p.Param("rating") - assert.Equal(t, "5 stars", topLevelKeyValue) - - nestedStringKeyValue, _ := p.Param("social.twitter") - assert.Equal(t, "@jxxf", nestedStringKeyValue) - - nonexistentKeyValue, _ := p.Param("doesn't.exist") - assert.Nil(t, nonexistentKeyValue) -} - -func TestPageSimpleMethods(t *testing.T) { - t.Parallel() - s := newTestSite(t) - for i, this := range []struct { - assertFunc func(p *Page) bool - }{ - {func(p *Page) bool { return !p.IsNode() }}, - {func(p *Page) bool { return p.IsPage() }}, - {func(p *Page) bool { return p.Plain() == "Do Be Do Be Do" }}, - {func(p *Page) bool { return strings.Join(p.PlainWords(), " ") == "Do Be Do Be Do" }}, - } { - - p, _ := s.NewPage("Test") - p.Content = "<h1>Do Be Do Be Do</h1>" - if !this.assertFunc(p) { - t.Errorf("[%d] Page method error", i) - } - } -} - -func TestIndexPageSimpleMethods(t *testing.T) { - s := newTestSite(t) - t.Parallel() - for i, this := range []struct { - assertFunc func(n *Page) bool - }{ - {func(n *Page) bool { return n.IsNode() }}, - {func(n *Page) bool { return !n.IsPage() }}, - {func(n *Page) bool { return n.Scratch() != nil }}, - {func(n *Page) bool { return n.Hugo() != nil }}, - } { - - n := s.newHomePage() - - if !this.assertFunc(n) { - t.Errorf("[%d] Node method error", i) - } - } -} - -func TestKind(t *testing.T) { - t.Parallel() - // Add tests for these constants to make sure they don't change - require.Equal(t, "page", KindPage) - require.Equal(t, "home", KindHome) - require.Equal(t, "section", KindSection) - require.Equal(t, "taxonomy", KindTaxonomy) - require.Equal(t, "taxonomyTerm", KindTaxonomyTerm) - -} - func TestTranslationKey(t *testing.T) { + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term"] +defaultContentLanguage = "en" +defaultContentLanguageInSubdir = true +[languages] +[languages.en] +weight = 1 +[languages.nn] +weight = 2 +-- content/sect/p1.en.md -- +--- +translationkey: "adfasdf" +title: "p1 en" +--- +-- content/sect/p1.nn.md -- +--- +translationkey: "adfasdf" +title: "p1 nn" +--- +-- layouts/_default/single.html -- +Title: {{ .Title }}|TranslationKey: {{ .TranslationKey }}| +Translations: {{ range .Translations }}{{ .Language.Lang }}|{{ end }}| +AllTranslations: {{ range .AllTranslations }}{{ .Language.Lang }}|{{ end }}| + +` + b := Test(t, files) + b.AssertFileContent("public/en/sect/p1/index.html", + "TranslationKey: adfasdf|", + "AllTranslations: en|nn||", + "Translations: nn||", + ) + + b.AssertFileContent("public/nn/sect/p1/index.html", + "TranslationKey: adfasdf|", + "Translations: en||", + "AllTranslations: en|nn||", + ) +} + +func TestTranslationKeyTermPages(t *testing.T) { t.Parallel() - assert := require.New(t) - cfg, fs := newTestCfg() - writeSource(t, fs, filepath.Join("content", filepath.FromSlash("sect/simple.no.md")), "---\ntitle: \"A1\"\ntranslationKey: \"k1\"\n---\nContent\n") - writeSource(t, fs, filepath.Join("content", filepath.FromSlash("sect/simple.en.md")), "---\ntitle: \"A2\"\n---\nContent\n") + files := ` +-- hugo.toml -- +disableKinds = ['home','rss','section','sitemap','taxonomy'] +defaultContentLanguage = 'en' +defaultContentLanguageInSubdir = true +[languages.en] +weight = 1 +[languages.pt] +weight = 2 +[taxonomies] +category = 'categories' +-- layouts/_default/list.html -- +{{ .IsTranslated }}|{{ range .Translations }}{{ .RelPermalink }}|{{ end }} +-- layouts/_default/single.html -- +{{ .Title }}| +-- content/p1.en.md -- +--- +title: p1 (en) +categories: [music] +--- +-- content/p1.pt.md -- +--- +title: p1 (pt) +categories: [música] +--- +-- content/categories/music/_index.en.md -- +--- +title: music +translationKey: foo +--- +-- content/categories/música/_index.pt.md -- +--- +title: música +translationKey: foo +--- +` - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{SkipRender: true}) + b := Test(t, files) + b.AssertFileContent("public/en/categories/music/index.html", "true|/pt/categories/m%C3%BAsica/|") + b.AssertFileContent("public/pt/categories/música/index.html", "true|/en/categories/music/|") +} - require.Len(t, s.RegularPages, 2) +// Issue #11540. +func TestTranslationKeyResourceSharing(t *testing.T) { + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term"] +defaultContentLanguage = "en" +defaultContentLanguageInSubdir = true +[languages] +[languages.en] +weight = 1 +[languages.nn] +weight = 2 +-- content/sect/mybundle_en/index.en.md -- +--- +translationkey: "adfasdf" +title: "mybundle en" +--- +-- content/sect/mybundle_en/f1.txt -- +f1.en +-- content/sect/mybundle_en/f2.txt -- +f2.en +-- content/sect/mybundle_nn/index.nn.md -- +--- +translationkey: "adfasdf" +title: "mybundle nn" +--- +-- content/sect/mybundle_nn/f2.nn.txt -- +f2.nn +-- layouts/_default/single.html -- +Title: {{ .Title }}|TranslationKey: {{ .TranslationKey }}| +Resources: {{ range .Resources }}{{ .RelPermalink }}|{{ .Content }}|{{ end }}| - home, _ := s.Info.Home() - assert.NotNil(home) - assert.Equal("home", home.TranslationKey()) - assert.Equal("page/k1", s.RegularPages[0].TranslationKey()) - p2 := s.RegularPages[1] - - assert.Equal("page/sect/simple", p2.TranslationKey()) +` + b := Test(t, files) + b.AssertFileContent("public/en/sect/mybundle_en/index.html", + "TranslationKey: adfasdf|", + "Resources: /en/sect/mybundle_en/f1.txt|f1.en|/en/sect/mybundle_en/f2.txt|f2.en||", + ) + b.AssertFileContent("public/nn/sect/mybundle_nn/index.html", + "TranslationKey: adfasdf|", + "Title: mybundle nn|TranslationKey: adfasdf|\nResources: /en/sect/mybundle_en/f1.txt|f1.en|/nn/sect/mybundle_nn/f2.nn.txt|f2.nn||", + ) } func TestChompBOM(t *testing.T) { t.Parallel() + c := qt.New(t) const utf8BOM = "\xef\xbb\xbf" cfg, fs := newTestCfg() + configs, err := loadTestConfigFromProvider(cfg) + c.Assert(err, qt.IsNil) writeSource(t, fs, filepath.Join("content", "simple.md"), utf8BOM+simplePage) - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{SkipRender: true}) + s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Configs: configs}, BuildCfg{SkipRender: true}) - require.Len(t, s.RegularPages, 1) + c.Assert(len(s.RegularPages()), qt.Equals, 1) - p := s.RegularPages[0] + p := s.RegularPages()[0] checkPageTitle(t, p, "Simple") } -// TODO(bep) this may be useful for other tests. -func compareObjects(a interface{}, b interface{}) bool { - aStr := strings.Split(fmt.Sprintf("%v", a), "") - sort.Strings(aStr) +// https://github.com/gohugoio/hugo/issues/5381 +func TestPageManualSummary(t *testing.T) { + b := newTestSitesBuilder(t) + b.WithSimpleConfigFile() - bStr := strings.Split(fmt.Sprintf("%v", b), "") - sort.Strings(bStr) + b.WithContent("page-md-shortcode.md", `--- +title: "Hugo" +--- +This is a {{< sc >}}. +<!--more--> +Content. +`) - return strings.Join(aStr, "") == strings.Join(bStr, "") + // https://github.com/gohugoio/hugo/issues/5464 + b.WithContent("page-md-only-shortcode.md", `--- +title: "Hugo" +--- +{{< sc >}} +<!--more--> +{{< sc >}} +`) + + b.WithContent("page-md-shortcode-same-line.md", `--- +title: "Hugo" +--- +This is a {{< sc >}}<!--more-->Same line. +`) + + b.WithContent("page-md-shortcode-same-line-after.md", `--- +title: "Hugo" +--- +Summary<!--more-->{{< sc >}} +`) + + b.WithContent("page-org-shortcode.org", `#+TITLE: T1 +#+AUTHOR: A1 +#+DESCRIPTION: D1 +This is a {{< sc >}}. +# more +Content. +`) + + b.WithContent("page-org-variant1.org", `#+TITLE: T1 +Summary. + +# more + +Content. +`) + + b.WithTemplatesAdded("layouts/shortcodes/sc.html", "a shortcode") + b.WithTemplatesAdded("layouts/_default/single.html", ` +SUMMARY:{{ .Summary }}:END +-------------------------- +CONTENT:{{ .Content }} +`) + + b.CreateSites().Build(BuildCfg{}) + + b.AssertFileContent("public/page-md-shortcode/index.html", + "SUMMARY:<p>This is a a shortcode.</p>:END", + "CONTENT:<p>This is a a shortcode.</p>\n\n<p>Content.</p>\n", + ) + + b.AssertFileContent("public/page-md-shortcode-same-line/index.html", + "SUMMARY:<p>This is a a shortcode</p>:END", + "CONTENT:<p>This is a a shortcode</p>\n\n<p>Same line.</p>\n", + ) + + b.AssertFileContent("public/page-md-shortcode-same-line-after/index.html", + "SUMMARY:<p>Summary</p>:END", + "CONTENT:<p>Summary</p>\n\na shortcode", + ) + + b.AssertFileContent("public/page-org-shortcode/index.html", + "SUMMARY:<p>\nThis is a a shortcode.\n</p>:END", + "CONTENT:<p>\nThis is a a shortcode.\n</p>\n<p>\nContent.\t\n</p>\n", + ) + b.AssertFileContent("public/page-org-variant1/index.html", + "SUMMARY:<p>\nSummary.\n</p>:END", + "CONTENT:<p>\nSummary.\n</p>\n<p>\nContent.\t\n</p>\n", + ) + + b.AssertFileContent("public/page-md-only-shortcode/index.html", + "SUMMARY:a shortcode:END", + "CONTENT:a shortcode\n\na shortcode\n", + ) +} + +func TestHomePageWithNoTitle(t *testing.T) { + b := newTestSitesBuilder(t).WithConfigFile("toml", ` +title = "Site Title" +`) + b.WithTemplatesAdded("index.html", "Title|{{ with .Title }}{{ . }}{{ end }}|") + b.WithContent("_index.md", `--- +description: "No title for you!" +--- + +Content. +`) + + b.Build(BuildCfg{}) + b.AssertFileContent("public/index.html", "Title||") } func TestShouldBuild(t *testing.T) { - t.Parallel() - var past = time.Date(2009, 11, 17, 20, 34, 58, 651387237, time.UTC) - var future = time.Date(2037, 11, 17, 20, 34, 58, 651387237, time.UTC) - var zero = time.Time{} + past := time.Date(2009, 11, 17, 20, 34, 58, 651387237, time.UTC) + future := time.Date(2037, 11, 17, 20, 34, 58, 651387237, time.UTC) + zero := time.Time{} - var publishSettings = []struct { + publishSettings := []struct { buildFuture bool buildExpired bool buildDrafts bool @@ -1646,102 +1572,431 @@ func TestShouldBuild(t *testing.T) { } } -// "dot" in path: #1885 and #2110 -// disablePathToLower regression: #3374 -func TestPathIssues(t *testing.T) { - t.Parallel() - for _, disablePathToLower := range []bool{false, true} { - for _, uglyURLs := range []bool{false, true} { - t.Run(fmt.Sprintf("disablePathToLower=%t,uglyURLs=%t", disablePathToLower, uglyURLs), func(t *testing.T) { +func TestShouldBuildWithClock(t *testing.T) { + htime.Clock = clocks.Start(time.Date(2021, 11, 17, 20, 34, 58, 651387237, time.UTC)) + t.Cleanup(func() { htime.Clock = clocks.System() }) + past := time.Date(2009, 11, 17, 20, 34, 58, 651387237, time.UTC) + future := time.Date(2037, 11, 17, 20, 34, 58, 651387237, time.UTC) + zero := time.Time{} - cfg, fs := newTestCfg() - th := testHelper{cfg, fs, t} + publishSettings := []struct { + buildFuture bool + buildExpired bool + buildDrafts bool + draft bool + publishDate time.Time + expiryDate time.Time + out bool + }{ + // publishDate and expiryDate + {false, false, false, false, zero, zero, true}, + {false, false, false, false, zero, future, true}, + {false, false, false, false, past, zero, true}, + {false, false, false, false, past, future, true}, + {false, false, false, false, past, past, false}, + {false, false, false, false, future, future, false}, + {false, false, false, false, future, past, false}, - cfg.Set("permalinks", map[string]string{ - "post": ":section/:title", - }) + // buildFuture and buildExpired + {false, true, false, false, past, past, true}, + {true, true, false, false, past, past, true}, + {true, false, false, false, past, past, false}, + {true, false, false, false, future, future, true}, + {true, true, false, false, future, future, true}, + {false, true, false, false, future, past, false}, - cfg.Set("uglyURLs", uglyURLs) - cfg.Set("disablePathToLower", disablePathToLower) - cfg.Set("paginate", 1) + // buildDrafts and draft + {true, true, false, true, past, future, false}, + {true, true, true, true, past, future, true}, + {true, true, true, true, past, future, true}, + } - writeSource(t, fs, filepath.Join("layouts", "_default", "single.html"), "<html><body>{{.Content}}</body></html>") - writeSource(t, fs, filepath.Join("layouts", "_default", "list.html"), - "<html><body>P{{.Paginator.PageNumber}}|URL: {{.Paginator.URL}}|{{ if .Paginator.HasNext }}Next: {{.Paginator.Next.URL }}{{ end }}</body></html>") - - for i := 0; i < 3; i++ { - writeSource(t, fs, filepath.Join("content", "post", fmt.Sprintf("doc%d.md", i)), - fmt.Sprintf(`--- -title: "test%d.dot" -tags: -- ".net" ---- -# doc1 -*some content*`, i)) - } - - writeSource(t, fs, filepath.Join("content", "Blog", "Blog1.md"), - fmt.Sprintf(`--- -title: "testBlog" -tags: -- "Blog" ---- -# doc1 -*some blog content*`)) - - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{}) - - require.Len(t, s.RegularPages, 4) - - pathFunc := func(s string) string { - if uglyURLs { - return strings.Replace(s, "/index.html", ".html", 1) - } - return s - } - - blog := "blog" - - if disablePathToLower { - blog = "Blog" - } - - th.assertFileContent(pathFunc("public/"+blog+"/"+blog+"1/index.html"), "some blog content") - - th.assertFileContent(pathFunc("public/post/test0.dot/index.html"), "some content") - - if uglyURLs { - th.assertFileContent("public/post/page/1.html", `canonical" href="/post.html"/`) - th.assertFileContent("public/post.html", `<body>P1|URL: /post.html|Next: /post/page/2.html</body>`) - th.assertFileContent("public/post/page/2.html", `<body>P2|URL: /post/page/2.html|Next: /post/page/3.html</body>`) - } else { - th.assertFileContent("public/post/page/1/index.html", `canonical" href="/post/"/`) - th.assertFileContent("public/post/index.html", `<body>P1|URL: /post/|Next: /post/page/2/</body>`) - th.assertFileContent("public/post/page/2/index.html", `<body>P2|URL: /post/page/2/|Next: /post/page/3/</body>`) - th.assertFileContent("public/tags/.net/index.html", `<body>P1|URL: /tags/.net/|Next: /tags/.net/page/2/</body>`) - - } - - p := s.RegularPages[0] - if uglyURLs { - require.Equal(t, "/post/test0.dot.html", p.RelPermalink()) - } else { - require.Equal(t, "/post/test0.dot/", p.RelPermalink()) - } - - }) + for _, ps := range publishSettings { + s := shouldBuild(ps.buildFuture, ps.buildExpired, ps.buildDrafts, ps.draft, + ps.publishDate, ps.expiryDate) + if s != ps.out { + t.Errorf("AssertShouldBuildWithClock unexpected output with params: %+v", ps) } } } -func BenchmarkParsePage(b *testing.B) { - s := newTestSite(b) - f, _ := os.Open("testdata/redis.cn.md") - var buf bytes.Buffer - buf.ReadFrom(f) - b.ResetTimer() - for i := 0; i < b.N; i++ { - page, _ := s.NewPage("bench") - page.ReadFrom(bytes.NewReader(buf.Bytes())) - } +// See https://github.com/gohugoio/hugo/issues/9171 +// We redefined disablePathToLower in v0.121.0. +func TestPagePathDisablePathToLower(t *testing.T) { + files := ` +-- hugo.toml -- +baseURL = "http://example.com" +disablePathToLower = true +[permalinks] +sect2 = "/:section/:filename/" +sect3 = "/:section/:title/" +-- content/sect/p1.md -- +--- +title: "Page1" +--- +p1. +-- content/sect/p2.md -- +--- +title: "Page2" +slug: "PaGe2" +--- +p2. +-- content/sect2/PaGe3.md -- +--- +title: "Page3" +--- +-- content/seCt3/p4.md -- +--- +title: "Pag.E4" +slug: "PaGe4" +--- +p4. +-- layouts/_default/single.html -- +Single: {{ .Title}}|{{ .RelPermalink }}|{{ .Path }}| +` + b := Test(t, files) + b.AssertFileContent("public/sect/p1/index.html", "Single: Page1|/sect/p1/|/sect/p1") + b.AssertFileContent("public/sect/PaGe2/index.html", "Single: Page2|/sect/PaGe2/|/sect/p2") + b.AssertFileContent("public/sect2/PaGe3/index.html", "Single: Page3|/sect2/PaGe3/|/sect2/page3|") + b.AssertFileContent("public/sect3/Pag.E4/index.html", "Single: Pag.E4|/sect3/Pag.E4/|/sect3/p4|") +} + +func TestScratch(t *testing.T) { + t.Parallel() + + b := newTestSitesBuilder(t) + b.WithSimpleConfigFile().WithTemplatesAdded("index.html", ` +{{ .Scratch.Set "b" "bv" }} +B: {{ .Scratch.Get "b" }} +`, + "shortcodes/scratch.html", ` +{{ .Scratch.Set "c" "cv" }} +C: {{ .Scratch.Get "c" }} +`, + ) + + b.WithContentAdded("scratchme.md", ` +--- +title: Scratch Me! +--- + +{{< scratch >}} +`) + b.Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", "B: bv") + b.AssertFileContent("public/scratchme/index.html", "C: cv") +} + +// Issue 13016. +func TestScratchAliasToStore(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "page", "section"] +disableLiveReload = true +-- layouts/index.html -- +{{ .Scratch.Set "a" "b" }} +{{ .Store.Set "c" "d" }} +.Scratch eq .Store: {{ eq .Scratch .Store }} +a: {{ .Store.Get "a" }} +c: {{ .Scratch.Get "c" }} + +` + + b := Test(t, files) + + b.AssertFileContent("public/index.html", + ".Scratch eq .Store: true", + "a: b", + "c: d", + ) +} + +func TestPageParam(t *testing.T) { + t.Parallel() + + b := newTestSitesBuilder(t).WithConfigFile("toml", ` + +baseURL = "https://example.org" + +[params] +[params.author] + name = "Kurt Vonnegut" + +`) + b.WithTemplatesAdded("index.html", ` + +{{ $withParam := .Site.GetPage "withparam" }} +{{ $noParam := .Site.GetPage "noparam" }} +{{ $withStringParam := .Site.GetPage "withstringparam" }} + +Author page: {{ $withParam.Param "author.name" }} +Author name page string: {{ $withStringParam.Param "author.name" }}| +Author page string: {{ $withStringParam.Param "author" }}| +Author site config: {{ $noParam.Param "author.name" }} + +`, + ) + + b.WithContent("withparam.md", ` ++++ +title = "With Param!" +[author] + name = "Ernest Miller Hemingway" + ++++ + +`, + + "noparam.md", ` +--- +title: "No Param!" +--- +`, "withstringparam.md", ` ++++ +title = "With string Param!" +author = "Jo Nesbø" + ++++ + +`) + b.Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", + "Author page: Ernest Miller Hemingway", + "Author name page string: Kurt Vonnegut|", + "Author page string: Jo Nesbø|", + "Author site config: Kurt Vonnegut") +} + +func TestGoldmark(t *testing.T) { + t.Parallel() + + b := newTestSitesBuilder(t).WithConfigFile("toml", ` +baseURL = "https://example.org" + +[markup] +defaultMarkdownHandler="goldmark" +[markup.goldmark] +[markup.goldmark.renderer] +unsafe = false +[markup.highlight] +noClasses=false + + +`) + b.WithTemplatesAdded("_default/single.html", ` +Title: {{ .Title }} +ToC: {{ .TableOfContents }} +Content: {{ .Content }} + +`, "shortcodes/t.html", `T-SHORT`, "shortcodes/s.html", `## Code +{{ .Inner }} +`) + + content := ` ++++ +title = "A Page!" ++++ + +## Shortcode {{% t %}} in header + +## Code Fense in Shortcode + +{{% s %}} +$$$bash {hl_lines=[1]} +SHORT +$$$ +{{% /s %}} + +## Code Fence + +$$$bash {hl_lines=[1]} +MARKDOWN +$$$ + +Link with URL as text + +[https://google.com](https://google.com) + + +` + content = strings.ReplaceAll(content, "$$$", "```") + + b.WithContent("page.md", content) + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/page/index.html", + `<nav id="TableOfContents"> +<li><a href="#shortcode-t-short-in-header">Shortcode T-SHORT in header</a></li> +<code class="language-bash" data-lang="bash"><span class="line hl"><span class="cl">SHORT +<code class="language-bash" data-lang="bash"><span class="line hl"><span class="cl">MARKDOWN +<p><a href="https://google.com">https://google.com</a></p> +`) +} + +func TestPageHashString(t *testing.T) { + files := ` +-- config.toml -- +baseURL = "https://example.org" +[languages] +[languages.en] +weight = 1 +title = "English" +[languages.no] +weight = 2 +title = "Norsk" +-- content/p1.md -- +--- +title: "p1" +--- +-- content/p2.md -- +--- +title: "p2" +--- +` + + b := NewIntegrationTestBuilder(IntegrationTestConfig{ + T: t, + TxtarString: files, + }).Build() + + p1 := b.H.Sites[0].RegularPages()[0] + p2 := b.H.Sites[0].RegularPages()[1] + sites := p1.Sites() + + b.Assert(p1, qt.Not(qt.Equals), p2) + + b.Assert(hashing.HashString(p1), qt.Not(qt.Equals), hashing.HashString(p2)) + b.Assert(hashing.HashString(sites[0]), qt.Not(qt.Equals), hashing.HashString(sites[1])) +} + +// Issue #11243 +func TestRenderWithoutArgument(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +-- layouts/index.html -- +{{ .Render }} +` + + b, err := NewIntegrationTestBuilder( + IntegrationTestConfig{ + T: t, + TxtarString: files, + }, + ).BuildE() + + b.Assert(err, qt.IsNotNil) +} + +// Issue #13021 +func TestAllStores(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "page", "section"] +disableLiveReload = true +-- content/_index.md -- +--- +title: "Home" +--- +{{< s >}} +-- layouts/shortcodes/s.html -- +{{ if not (.Store.Get "Shortcode") }}{{ .Store.Set "Shortcode" (printf "sh-%s" $.Page.Title) }}{{ end }} +Shortcode: {{ .Store.Get "Shortcode" }}| +-- layouts/index.html -- +{{ .Content }} +{{ if not (.Store.Get "Page") }}{{ .Store.Set "Page" (printf "p-%s" $.Title) }}{{ end }} +{{ if not (hugo.Store.Get "Hugo") }}{{ hugo.Store.Set "Hugo" (printf "h-%s" $.Title) }}{{ end }} +{{ if not (site.Store.Get "Site") }}{{ site.Store.Set "Site" (printf "s-%s" $.Title) }}{{ end }} +Page: {{ .Store.Get "Page" }}| +Hugo: {{ hugo.Store.Get "Hugo" }}| +Site: {{ site.Store.Get "Site" }}| +` + + b := TestRunning(t, files) + + b.AssertFileContent("public/index.html", + ` +Shortcode: sh-Home| +Page: p-Home| +Site: s-Home| +Hugo: h-Home| +`, + ) + + b.EditFileReplaceAll("content/_index.md", "Home", "Homer").Build() + + b.AssertFileContent("public/index.html", + ` +Shortcode: sh-Homer| +Page: p-Homer| +Site: s-Home| +Hugo: h-Home| +`, + ) +} + +// See #12484 +func TestPageFrontMatterDeprecatePathKindLang(t *testing.T) { + // This cannot be parallel as it depends on output from the global logger. + + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "home", "section"] +-- content/p1.md -- +--- +title: "p1" +kind: "page" +lang: "en" +path: "mypath" +--- +-- layouts/_default/single.html -- +Title: {{ .Title }} +` + b := Test(t, files, TestOptWarn()) + b.AssertFileContent("public/mypath/index.html", "p1") + b.AssertLogContains( + "deprecated: kind in front matter was deprecated", + "deprecated: lang in front matter was deprecated", + "deprecated: path in front matter was deprecated", + ) +} + +// Issue 13538 +func TestHomePageIsLeafBundle(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +defaultContentLanguage = 'de' +defaultContentLanguageInSubdir = true +[languages.de] +weight = 1 +[languages.en] +weight = 2 +-- layouts/all.html -- +{{ .Title }} +-- content/index.de.md -- +--- +title: home de +--- +-- content/index.en.org -- +--- +title: home en +--- +` + + b := Test(t, files, TestOptWarn()) + + b.AssertFileContent("public/de/index.html", "home de") + b.AssertFileContent("public/en/index.html", "home en") + b.AssertLogContains("Using index.de.md in your content's root directory is usually incorrect for your home page. You should use _index.de.md instead.") + b.AssertLogContains("Using index.en.org in your content's root directory is usually incorrect for your home page. You should use _index.en.org instead.") } diff --git a/hugolib/page_time_integration_test.go b/hugolib/page_time_integration_test.go deleted file mode 100644 index 1bf83bdca..000000000 --- a/hugolib/page_time_integration_test.go +++ /dev/null @@ -1,183 +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 hugolib - -import ( - "fmt" - "os" - "strings" - "sync" - "testing" - "time" - - "github.com/spf13/cast" -) - -const ( - pageWithInvalidDate = `--- -date: 2010-05-02_15:29:31+08:00 ---- -Page With Invalid Date (replace T with _ for RFC 3339)` - - pageWithDateRFC3339 = `--- -date: 2010-05-02T15:29:31+08:00 ---- -Page With Date RFC3339` - - pageWithDateRFC3339NoT = `--- -date: 2010-05-02 15:29:31+08:00 ---- -Page With Date RFC3339_NO_T` - - pageWithRFC1123 = `--- -date: Sun, 02 May 2010 15:29:31 PST ---- -Page With Date RFC1123` - - pageWithDateRFC1123Z = `--- -date: Sun, 02 May 2010 15:29:31 +0800 ---- -Page With Date RFC1123Z` - - pageWithDateRFC822 = `--- -date: 02 May 10 15:29 PST ---- -Page With Date RFC822` - - pageWithDateRFC822Z = `--- -date: 02 May 10 15:29 +0800 ---- -Page With Date RFC822Z` - - pageWithDateANSIC = `--- -date: Sun May 2 15:29:31 2010 ---- -Page With Date ANSIC` - - pageWithDateUnixDate = `--- -date: Sun May 2 15:29:31 PST 2010 ---- -Page With Date UnixDate` - - pageWithDateRubyDate = `--- -date: Sun May 02 15:29:31 +0800 2010 ---- -Page With Date RubyDate` - - pageWithDateHugoYearNumeric = `--- -date: 2010-05-02 ---- -Page With Date HugoYearNumeric` - - pageWithDateHugoYear = `--- -date: 02 May 2010 ---- -Page With Date HugoYear` - - pageWithDateHugoLong = `--- -date: 02 May 2010 15:29 PST ---- -Page With Date HugoLong` -) - -func TestDegenerateDateFrontMatter(t *testing.T) { - t.Parallel() - s := newTestSite(t) - p, _ := s.NewPageFrom(strings.NewReader(pageWithInvalidDate), "page/with/invalid/date") - if p.Date != *new(time.Time) { - t.Fatalf("Date should be set to time.Time zero value. Got: %s", p.Date) - } -} - -func TestParsingDateInFrontMatter(t *testing.T) { - t.Parallel() - s := newTestSite(t) - tests := []struct { - buf string - dt string - }{ - {pageWithDateRFC3339, "2010-05-02T15:29:31+08:00"}, - {pageWithDateRFC3339NoT, "2010-05-02T15:29:31+08:00"}, - {pageWithDateRFC1123Z, "2010-05-02T15:29:31+08:00"}, - {pageWithDateRFC822Z, "2010-05-02T15:29:00+08:00"}, - {pageWithDateANSIC, "2010-05-02T15:29:31Z"}, - {pageWithDateRubyDate, "2010-05-02T15:29:31+08:00"}, - {pageWithDateHugoYearNumeric, "2010-05-02T00:00:00Z"}, - {pageWithDateHugoYear, "2010-05-02T00:00:00Z"}, - } - - tzShortCodeTests := []struct { - buf string - dt string - }{ - {pageWithRFC1123, "2010-05-02T15:29:31-08:00"}, - {pageWithDateRFC822, "2010-05-02T15:29:00-08:00Z"}, - {pageWithDateUnixDate, "2010-05-02T15:29:31-08:00"}, - {pageWithDateHugoLong, "2010-05-02T15:21:00+08:00"}, - } - - if _, err := time.LoadLocation("PST"); err == nil { - tests = append(tests, tzShortCodeTests...) - } else { - fmt.Fprintf(os.Stderr, "Skipping shortname timezone tests.\n") - } - - for _, test := range tests { - dt, e := time.Parse(time.RFC3339, test.dt) - if e != nil { - t.Fatalf("Unable to parse date time (RFC3339) for running the test: %s", e) - } - p, err := s.NewPageFrom(strings.NewReader(test.buf), "page/with/date") - if err != nil { - t.Fatalf("Expected to be able to parse page.") - } - if !dt.Equal(p.Date) { - t.Errorf("Date does not equal frontmatter:\n%s\nExpecting: %s\n Got: %s. Diff: %s\n internal: %#v\n %#v", test.buf, dt, p.Date, dt.Sub(p.Date), dt, p.Date) - } - } -} - -// Temp test https://github.com/gohugoio/hugo/issues/3059 -func TestParsingDateParallel(t *testing.T) { - t.Parallel() - - var wg sync.WaitGroup - - for j := 0; j < 100; j++ { - wg.Add(1) - go func() { - defer wg.Done() - for j := 0; j < 100; j++ { - dateStr := "2010-05-02 15:29:31 +08:00" - - dt, err := time.Parse("2006-01-02 15:04:05 -07:00", dateStr) - if err != nil { - t.Fatal(err) - } - - if dt.Year() != 2010 { - t.Fatal("time.Parse: Invalid date:", dt) - } - - dt2 := cast.ToTime(dateStr) - - if dt2.Year() != 2010 { - t.Fatal("cast.ToTime: Invalid date:", dt2.Year()) - } - } - }() - } - wg.Wait() - -} diff --git a/hugolib/page_unwrap.go b/hugolib/page_unwrap.go new file mode 100644 index 000000000..c22ff2174 --- /dev/null +++ b/hugolib/page_unwrap.go @@ -0,0 +1,53 @@ +// 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 hugolib + +import ( + "fmt" + + "github.com/gohugoio/hugo/common/types" + "github.com/gohugoio/hugo/resources/page" +) + +// Wraps a Page. +type pageWrapper interface { + page() page.Page +} + +// unwrapPage is used in equality checks and similar. +func unwrapPage(in any) (page.Page, error) { + switch v := in.(type) { + case *pageState: + return v, nil + case pageWrapper: + return v.page(), nil + case types.Unwrapper: + return unwrapPage(v.Unwrapv()) + case page.Page: + return v, nil + case nil: + return nil, nil + default: + return nil, fmt.Errorf("unwrapPage: %T not supported", in) + } +} + +func mustUnwrapPage(in any) page.Page { + p, err := unwrapPage(in) + if err != nil { + panic(err) + } + + return p +} diff --git a/hugolib/page_unwrap_test.go b/hugolib/page_unwrap_test.go new file mode 100644 index 000000000..2d9b5e17f --- /dev/null +++ b/hugolib/page_unwrap_test.go @@ -0,0 +1,38 @@ +// 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 hugolib + +import ( + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/resources/page" +) + +func TestUnwrapPage(t *testing.T) { + c := qt.New(t) + + p := &pageState{} + + c.Assert(mustUnwrap(newPageForShortcode(p)), qt.Equals, p) + c.Assert(mustUnwrap(newPageForRenderHook(p)), qt.Equals, p) +} + +func mustUnwrap(v any) page.Page { + p, err := unwrapPage(v) + if err != nil { + panic(err) + } + return p +} diff --git a/hugolib/pagebundler_test.go b/hugolib/pagebundler_test.go new file mode 100644 index 000000000..e5521412b --- /dev/null +++ b/hugolib/pagebundler_test.go @@ -0,0 +1,959 @@ +// 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 hugolib + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/gohugoio/hugo/common/hashing" + "github.com/gohugoio/hugo/common/loggers" + + "github.com/gohugoio/hugo/config" + + "github.com/gohugoio/hugo/hugofs" + + "github.com/gohugoio/hugo/resources/kinds" + "github.com/gohugoio/hugo/resources/page" + + "github.com/gohugoio/hugo/htesting" + + "github.com/gohugoio/hugo/deps" + + qt "github.com/frankban/quicktest" +) + +func TestPageBundlerBundleInRoot(t *testing.T) { + t.Parallel() + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ["taxonomy", "term"] +-- content/root/index.md -- +--- +title: "Root" +--- +-- layouts/_default/single.html -- +Basic: {{ .Title }}|{{ .Kind }}|{{ .BundleType }}|{{ .RelPermalink }}| +Tree: Section: {{ .Section }}|CurrentSection: {{ .CurrentSection.RelPermalink }}|Parent: {{ .Parent.RelPermalink }}|FirstSection: {{ .FirstSection.RelPermalink }} +` + b := Test(t, files) + + b.AssertFileContent("public/root/index.html", + "Basic: Root|page|leaf|/root/|", + "Tree: Section: |CurrentSection: /|Parent: /|FirstSection: /", + ) +} + +func TestPageBundlerShortcodeInBundledPage(t *testing.T) { + t.Parallel() + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ["taxonomy", "term"] +-- content/section/mybundle/index.md -- +--- +title: "Mybundle" +--- +-- content/section/mybundle/p1.md -- +--- +title: "P1" +--- + +P1 content. + +{{< myShort >}} + +-- layouts/_default/single.html -- +Bundled page: {{ .RelPermalink}}|{{ with .Resources.Get "p1.md" }}Title: {{ .Title }}|Content: {{ .Content }}{{ end }}| +-- layouts/shortcodes/myShort.html -- +MyShort. + +` + b := Test(t, files) + + b.AssertFileContent("public/section/mybundle/index.html", + "Bundled page: /section/mybundle/|Title: P1|Content: <p>P1 content.</p>\nMyShort.", + ) +} + +func TestPageBundlerResourceMultipleOutputFormatsWithDifferentPaths(t *testing.T) { + t.Parallel() + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ["taxonomy", "term"] +[outputformats] +[outputformats.cpath] +mediaType = "text/html" +path = "cpath" +-- content/section/mybundle/index.md -- +--- +title: "My Bundle" +outputs: ["html", "cpath"] +--- +-- content/section/mybundle/hello.txt -- +Hello. +-- content/section/mybundle/p1.md -- +--- +title: "P1" +--- +P1. + +{{< hello >}} + +-- layouts/shortcodes/hello.html -- +Hello HTML. +-- layouts/_default/single.html -- +Basic: {{ .Title }}|{{ .Kind }}|{{ .BundleType }}|{{ .RelPermalink }}| +Resources: {{ range .Resources }}RelPermalink: {{ .RelPermalink }}|Content: {{ .Content }}|{{ end }}| +-- layouts/shortcodes/hello.cpath -- +Hello CPATH. +-- layouts/_default/single.cpath -- +Basic: {{ .Title }}|{{ .Kind }}|{{ .BundleType }}|{{ .RelPermalink }}| +Resources: {{ range .Resources }}RelPermalink: {{ .RelPermalink }}|Content: {{ .Content }}|{{ end }}| +` + + b := Test(t, files) + + b.AssertFileContent("public/section/mybundle/index.html", + "Basic: My Bundle|page|leaf|/section/mybundle/|", + "Resources: RelPermalink: |Content: <p>P1.</p>\nHello HTML.\n|RelPermalink: /section/mybundle/hello.txt|Content: Hello.||", + ) + + b.AssertFileContent("public/cpath/section/mybundle/index.html", "Basic: My Bundle|page|leaf|/section/mybundle/|\nResources: RelPermalink: |Content: <p>P1.</p>\nHello CPATH.\n|RelPermalink: /section/mybundle/hello.txt|Content: Hello.||") +} + +func TestPageBundlerMultilingualTextResource(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ["taxonomy", "term"] +defaultContentLanguage = "en" +defaultContentLanguageInSubdir = true +[languages] +[languages.en] +weight = 1 +[languages.en.permalinks] +"/" = "/enpages/:slug/" +[languages.nn] +weight = 2 +-- content/mybundle/index.md -- +--- +title: "My Bundle" +--- +-- content/mybundle/index.nn.md -- +--- +title: "My Bundle NN" +--- +-- content/mybundle/f1.txt -- +F1 +-- content/mybundle/f2.txt -- +F2 +-- content/mybundle/f2.nn.txt -- +F2 nn. +-- layouts/_default/single.html -- +{{ .Title }}|{{ .RelPermalink }}|{{ .Lang }}| +Resources: {{ range .Resources }}RelPermalink: {{ .RelPermalink }}|Content: {{ .Content }}|{{ end }}| + +` + b := Test(t, files) + + b.AssertFileContent("public/en/enpages/my-bundle/index.html", "My Bundle|/en/enpages/my-bundle/|en|\nResources: RelPermalink: /en/enpages/my-bundle/f1.txt|Content: F1|RelPermalink: /en/enpages/my-bundle/f2.txt|Content: F2||") + b.AssertFileContent("public/nn/mybundle/index.html", "My Bundle NN|/nn/mybundle/|nn|\nResources: RelPermalink: /en/enpages/my-bundle/f1.txt|Content: F1|RelPermalink: /nn/mybundle/f2.nn.txt|Content: F2 nn.||") +} + +func TestMultilingualDisableLanguage(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ["taxonomy", "term"] +defaultContentLanguage = "en" +defaultContentLanguageInSubdir = true +[languages] +[languages.en] +weight = 1 +[languages.nn] +weight = 2 +disabled = true +-- content/mysect/_index.md -- +--- +title: "My Sect En" +--- +-- content/mysect/p1/index.md -- +--- +title: "P1" +--- +P1 +-- content/mysect/_index.nn.md -- +--- +title: "My Sect Nn" +--- +-- content/mysect/p1/index.nn.md -- +--- +title: "P1nn" +--- +P1nn +-- layouts/index.html -- +Len RegularPages: {{ len .Site.RegularPages }}|RegularPages: {{ range site.RegularPages }}{{ .RelPermalink }}: {{ .Title }}|{{ end }}| +Len Pages: {{ len .Site.Pages }}| +Len Sites: {{ len .Site.Sites }}| +-- layouts/_default/single.html -- +{{ .Title }}|{{ .Content }}|{{ .Lang }}| + +` + b := Test(t, files) + + b.AssertFileContent("public/en/index.html", "Len RegularPages: 1|") + b.AssertFileContent("public/en/mysect/p1/index.html", "P1|<p>P1</p>\n|en|") + b.AssertFileExists("public/public/nn/mysect/p1/index.html", false) + b.Assert(len(b.H.Sites), qt.Equals, 1) +} + +func TestPageBundlerHeadless(t *testing.T) { + t.Parallel() + + cfg, fs := newTestCfg() + c := qt.New(t) + + workDir := "/work" + cfg.Set("workingDir", workDir) + cfg.Set("contentDir", "base") + cfg.Set("baseURL", "https://example.com") + configs, err := loadTestConfigFromProvider(cfg) + c.Assert(err, qt.IsNil) + + pageContent := `--- +title: "Bundle Galore" +slug: s1 +date: 2017-01-23 +--- + +TheContent. + +{{< myShort >}} +` + + writeSource(t, fs, filepath.Join(workDir, "layouts", "_default", "single.html"), "single {{ .Content }}") + writeSource(t, fs, filepath.Join(workDir, "layouts", "_default", "list.html"), "list") + writeSource(t, fs, filepath.Join(workDir, "layouts", "shortcodes", "myShort.html"), "SHORTCODE") + + writeSource(t, fs, filepath.Join(workDir, "base", "a", "index.md"), pageContent) + writeSource(t, fs, filepath.Join(workDir, "base", "a", "l1.png"), "PNG image") + writeSource(t, fs, filepath.Join(workDir, "base", "a", "l2.png"), "PNG image") + + writeSource(t, fs, filepath.Join(workDir, "base", "b", "index.md"), `--- +title: "Headless Bundle in Topless Bar" +slug: s2 +headless: true +date: 2017-01-23 +--- + +TheContent. +HEADLESS {{< myShort >}} +`) + writeSource(t, fs, filepath.Join(workDir, "base", "b", "l1.png"), "PNG image") + writeSource(t, fs, filepath.Join(workDir, "base", "b", "l2.png"), "PNG image") + writeSource(t, fs, filepath.Join(workDir, "base", "b", "p1.md"), pageContent) + + s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Configs: configs}, BuildCfg{}) + + c.Assert(len(s.RegularPages()), qt.Equals, 1) + + regular := s.getPageOldVersion(kinds.KindPage, "a/index") + c.Assert(regular.RelPermalink(), qt.Equals, "/s1/") + + headless := s.getPageOldVersion(kinds.KindPage, "b/index") + c.Assert(headless, qt.Not(qt.IsNil)) + c.Assert(headless.Title(), qt.Equals, "Headless Bundle in Topless Bar") + c.Assert(headless.RelPermalink(), qt.Equals, "") + c.Assert(headless.Permalink(), qt.Equals, "") + c.Assert(content(headless), qt.Contains, "HEADLESS SHORTCODE") + + headlessResources := headless.Resources() + c.Assert(len(headlessResources), qt.Equals, 3) + res := headlessResources.Match("l*") + c.Assert(len(res), qt.Equals, 2) + pageResource := headlessResources.GetMatch("p*") + c.Assert(pageResource, qt.Not(qt.IsNil)) + p := pageResource.(page.Page) + c.Assert(content(p), qt.Contains, "SHORTCODE") + c.Assert(p.Name(), qt.Equals, "p1.md") + + th := newTestHelper(s.conf, s.Fs, t) + + th.assertFileContent(filepath.FromSlash("public/s1/index.html"), "TheContent") + th.assertFileContent(filepath.FromSlash("public/s1/l1.png"), "PNG") + + th.assertFileNotExist("public/s2/index.html") + // But the bundled resources needs to be published + th.assertFileContent(filepath.FromSlash("public/s2/l1.png"), "PNG") + + // No headless bundles here, please. + // https://github.com/gohugoio/hugo/issues/6492 + c.Assert(s.RegularPages(), qt.HasLen, 1) + c.Assert(s.Pages(), qt.HasLen, 4) + c.Assert(s.home.RegularPages(), qt.HasLen, 1) + c.Assert(s.home.Pages(), qt.HasLen, 1) +} + +func TestPageBundlerHeadlessIssue6552(t *testing.T) { + t.Parallel() + + b := newTestSitesBuilder(t) + b.WithContent("headless/h1/index.md", ` +--- +title: My Headless Bundle1 +headless: true +--- +`, "headless/h1/p1.md", ` +--- +title: P1 +--- +`, "headless/h2/index.md", ` +--- +title: My Headless Bundle2 +headless: true +--- +`) + + b.WithTemplatesAdded("index.html", ` +{{ $headless1 := .Site.GetPage "headless/h1" }} +{{ $headless2 := .Site.GetPage "headless/h2" }} + +HEADLESS1: {{ $headless1.Title }}|{{ $headless1.RelPermalink }}|{{ len $headless1.Resources }}| +HEADLESS2: {{ $headless2.Title }}{{ $headless2.RelPermalink }}|{{ len $headless2.Resources }}| + +`) + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", ` +HEADLESS1: My Headless Bundle1||1| +HEADLESS2: My Headless Bundle2|0| +`) +} + +func TestMultiSiteBundles(t *testing.T) { + c := qt.New(t) + b := newTestSitesBuilder(t) + b.WithConfigFile("toml", ` + +baseURL = "http://example.com/" + +defaultContentLanguage = "en" + +[languages] +[languages.en] +weight = 10 +contentDir = "content/en" +[languages.nn] +weight = 20 +contentDir = "content/nn" + + +`) + + b.WithContent("en/mybundle/index.md", ` +--- +headless: true +--- + +`) + + b.WithContent("nn/mybundle/index.md", ` +--- +headless: true +--- + +`) + + b.WithContent("en/mybundle/data.yaml", `data en`) + b.WithContent("en/mybundle/forms.yaml", `forms en`) + b.WithContent("nn/mybundle/data.yaml", `data nn`) + + b.WithContent("en/_index.md", ` +--- +Title: Home +--- + +Home content. + +`) + + b.WithContent("en/section-not-bundle/_index.md", ` +--- +Title: Section Page +--- + +Section content. + +`) + + b.WithContent("en/section-not-bundle/single.md", ` +--- +Title: Section Single +Date: 2018-02-01 +--- + +Single content. + +`) + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/nn/mybundle/data.yaml", "data nn") + b.AssertFileContent("public/mybundle/data.yaml", "data en") + b.AssertFileContent("public/mybundle/forms.yaml", "forms en") + + c.Assert(b.CheckExists("public/nn/nn/mybundle/data.yaml"), qt.Equals, false) + c.Assert(b.CheckExists("public/en/mybundle/data.yaml"), qt.Equals, false) + + homeEn := b.H.Sites[0].home + c.Assert(homeEn, qt.Not(qt.IsNil)) + c.Assert(homeEn.Date().Year(), qt.Equals, 2018) + + b.AssertFileContent("public/section-not-bundle/index.html", "Section Page", "Content: <p>Section content.</p>") + b.AssertFileContent("public/section-not-bundle/single/index.html", "Section Single", "|<p>Single content.</p>") +} + +func TestBundledResourcesMultilingualDuplicateResourceFiles(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com/" +[markup] +[markup.goldmark] +duplicateResourceFiles = true +[languages] +[languages.en] +weight = 1 +[languages.en.permalinks] +"/" = "/enpages/:slug/" +[languages.nn] +weight = 2 +[languages.nn.permalinks] +"/" = "/nnpages/:slug/" +-- content/mybundle/index.md -- +--- +title: "My Bundle" +--- +{{< getresource "f1.txt" >}} +{{< getresource "f2.txt" >}} +-- content/mybundle/index.nn.md -- +--- +title: "My Bundle NN" +--- +{{< getresource "f1.txt" >}} +f2.nn.txt is the original name. +{{< getresource "f2.nn.txt" >}} +{{< getresource "f2.txt" >}} +{{< getresource "sub/f3.txt" >}} +-- content/mybundle/f1.txt -- +F1 en. +-- content/mybundle/sub/f3.txt -- +F1 en. +-- content/mybundle/f2.txt -- +F2 en. +-- content/mybundle/f2.nn.txt -- +F2 nn. +-- layouts/shortcodes/getresource.html -- +{{ $r := .Page.Resources.Get (.Get 0)}} +Resource: {{ (.Get 0) }}|{{ with $r }}{{ .RelPermalink }}|{{ .Content }}|{{ else }}Not found.{{ end}} +-- layouts/_default/single.html -- +{{ .Title }}|{{ .RelPermalink }}|{{ .Lang }}|{{ .Content }}| +` + b := Test(t, files) + + // helpers.PrintFs(b.H.Fs.PublishDir, "", os.Stdout) + b.AssertFileContent("public/nn/nnpages/my-bundle-nn/index.html", ` +My Bundle NN +Resource: f1.txt|/nn/nnpages/my-bundle-nn/f1.txt| +Resource: f2.txt|/nn/nnpages/my-bundle-nn/f2.nn.txt|F2 nn.| +Resource: f2.nn.txt|/nn/nnpages/my-bundle-nn/f2.nn.txt|F2 nn.| +Resource: sub/f3.txt|/nn/nnpages/my-bundle-nn/sub/f3.txt|F1 en.| +`) + + b.AssertFileContent("public/enpages/my-bundle/f2.txt", "F2 en.") + b.AssertFileContent("public/nn/nnpages/my-bundle-nn/f2.nn.txt", "F2 nn") + + b.AssertFileContent("public/enpages/my-bundle/index.html", ` +Resource: f1.txt|/enpages/my-bundle/f1.txt|F1 en.| +Resource: f2.txt|/enpages/my-bundle/f2.txt|F2 en.| +`) + b.AssertFileContent("public/enpages/my-bundle/f1.txt", "F1 en.") + + // Should be duplicated to the nn bundle. + b.AssertFileContent("public/nn/nnpages/my-bundle-nn/f1.txt", "F1 en.") +} + +// https://github.com/gohugoio/hugo/issues/5858 +func TestBundledResourcesWhenMultipleOutputFormats(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.org" +disableKinds = ["taxonomy", "term"] +disableLiveReload = true +[outputs] +# This looks odd, but it triggers the behavior in #5858 +# The total output formats list gets sorted, so CSS before HTML. +home = [ "CSS" ] +-- content/mybundle/index.md -- +--- +title: Page +--- +-- content/mybundle/data.json -- +MyData +-- layouts/_default/single.html -- +{{ range .Resources }} +{{ .ResourceType }}|{{ .Title }}| +{{ end }} +` + + b := TestRunning(t, files) + + b.AssertFileContent("public/mybundle/data.json", "MyData") + + b.EditFileReplaceAll("content/mybundle/data.json", "MyData", "My changed data").Build() + + b.AssertFileContent("public/mybundle/data.json", "My changed data") +} + +// https://github.com/gohugoio/hugo/issues/5858 + +// https://github.com/gohugoio/hugo/issues/4870 +func TestBundleSlug(t *testing.T) { + t.Parallel() + c := qt.New(t) + + const pageTemplate = `--- +title: Title +slug: %s +--- +` + + b := newTestSitesBuilder(t) + + b.WithTemplatesAdded("index.html", `{{ range .Site.RegularPages }}|{{ .RelPermalink }}{{ end }}|`) + b.WithSimpleConfigFile(). + WithContent("about/services1/misc.md", fmt.Sprintf(pageTemplate, "this-is-the-slug")). + WithContent("about/services2/misc/index.md", fmt.Sprintf(pageTemplate, "this-is-another-slug")) + + b.CreateSites().Build(BuildCfg{}) + + b.AssertHome( + "|/about/services1/this-is-the-slug/|/", + "|/about/services2/this-is-another-slug/|") + + c.Assert(b.CheckExists("public/about/services1/this-is-the-slug/index.html"), qt.Equals, true) + c.Assert(b.CheckExists("public/about/services2/this-is-another-slug/index.html"), qt.Equals, true) +} + +// See #11663 +func TestPageBundlerPartialTranslations(t *testing.T) { + t.Parallel() + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ["taxonomy", "term"] +defaultContentLanguage = "en" +defaultContentLanguageInSubDir = true +[languages] +[languages.nn] +weight = 2 +[languages.en] +weight = 1 +-- content/section/mybundle/index.md -- +--- +title: "Mybundle" +--- +-- content/section/mybundle/bundledpage.md -- +--- +title: "Bundled page en" +--- +-- content/section/mybundle/bundledpage.nn.md -- +--- +title: "Bundled page nn" +--- + +-- layouts/_default/single.html -- +Bundled page: {{ .RelPermalink}}|Len resources: {{ len .Resources }}| + + +` + b := Test(t, files) + + b.AssertFileContent("public/en/section/mybundle/index.html", + "Bundled page: /en/section/mybundle/|Len resources: 1|", + ) + + b.AssertFileExists("public/nn/section/mybundle/index.html", false) +} + +// #6208 +func TestBundleIndexInSubFolder(t *testing.T) { + config := ` +baseURL = "https://example.com" + +` + + const pageContent = `--- +title: %q +--- +` + createPage := func(s string) string { + return fmt.Sprintf(pageContent, s) + } + + b := newTestSitesBuilder(t).WithConfigFile("toml", config) + b.WithLogger(loggers.NewDefault()) + + b.WithTemplates("_default/single.html", `{{ range .Resources }} +{{ .ResourceType }}|{{ .Title }}| +{{ end }} + + +`) + + b.WithContent("bundle/index.md", createPage("bundle index")) + b.WithContent("bundle/p1.md", createPage("bundle p1")) + b.WithContent("bundle/sub/p2.md", createPage("bundle sub p2")) + b.WithContent("bundle/sub/index.md", createPage("bundle sub index")) + b.WithContent("bundle/sub/data.json", "data") + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/bundle/index.html", ` + application|sub/data.json| + page|bundle p1| + page|bundle sub index| + page|bundle sub p2| +`) +} + +func TestBundleTransformMany(t *testing.T) { + b := newTestSitesBuilder(t).WithSimpleConfigFile().Running() + + for i := 1; i <= 50; i++ { + b.WithContent(fmt.Sprintf("bundle%d/index.md", i), fmt.Sprintf(` +--- +title: "Page" +weight: %d +--- + +`, i)) + b.WithSourceFile(fmt.Sprintf("content/bundle%d/data.yaml", i), fmt.Sprintf(`data: v%d`, i)) + b.WithSourceFile(fmt.Sprintf("content/bundle%d/data.json", i), fmt.Sprintf(`{ "data": "v%d" }`, i)) + b.WithSourceFile(fmt.Sprintf("assets/data%d/data.yaml", i), fmt.Sprintf(`vdata: v%d`, i)) + + } + + b.WithTemplatesAdded("_default/single.html", ` +{{ $bundleYaml := .Resources.GetMatch "*.yaml" }} +{{ $bundleJSON := .Resources.GetMatch "*.json" }} +{{ $assetsYaml := resources.GetMatch (printf "data%d/*.yaml" .Weight) }} +{{ $data1 := $bundleYaml | transform.Unmarshal }} +{{ $data2 := $assetsYaml | transform.Unmarshal }} +{{ $bundleFingerprinted := $bundleYaml | fingerprint "md5" }} +{{ $assetsFingerprinted := $assetsYaml | fingerprint "md5" }} +{{ $jsonMin := $bundleJSON | minify }} +{{ $jsonMinMin := $jsonMin | minify }} +{{ $jsonMinMinMin := $jsonMinMin | minify }} + +data content unmarshaled: {{ $data1.data }} +data assets content unmarshaled: {{ $data2.vdata }} +bundle fingerprinted: {{ $bundleFingerprinted.RelPermalink }} +assets fingerprinted: {{ $assetsFingerprinted.RelPermalink }} + +bundle min min min: {{ $jsonMinMinMin.RelPermalink }} +bundle min min key: {{ $jsonMinMin.Key }} + +`) + + for range 3 { + + b.Build(BuildCfg{}) + + for i := 1; i <= 50; i++ { + index := fmt.Sprintf("public/bundle%d/index.html", i) + b.AssertFileContent(fmt.Sprintf("public/bundle%d/data.yaml", i), fmt.Sprintf("data: v%d", i)) + b.AssertFileContent(index, fmt.Sprintf("data content unmarshaled: v%d", i)) + b.AssertFileContent(index, fmt.Sprintf("data assets content unmarshaled: v%d", i)) + + md5Asset := hashing.MD5FromStringHexEncoded(fmt.Sprintf(`vdata: v%d`, i)) + b.AssertFileContent(index, fmt.Sprintf("assets fingerprinted: /data%d/data.%s.yaml", i, md5Asset)) + + // The original is not used, make sure it's not published. + b.Assert(b.CheckExists(fmt.Sprintf("public/data%d/data.yaml", i)), qt.Equals, false) + + md5Bundle := hashing.MD5FromStringHexEncoded(fmt.Sprintf(`data: v%d`, i)) + b.AssertFileContent(index, fmt.Sprintf("bundle fingerprinted: /bundle%d/data.%s.yaml", i, md5Bundle)) + + b.AssertFileContent(index, + fmt.Sprintf("bundle min min min: /bundle%d/data.min.min.min.json", i), + fmt.Sprintf("bundle min min key: /bundle%d/data.min.min.json", i), + ) + b.Assert(b.CheckExists(fmt.Sprintf("public/bundle%d/data.min.min.min.json", i)), qt.Equals, true) + b.Assert(b.CheckExists(fmt.Sprintf("public/bundle%d/data.min.json", i)), qt.Equals, false) + b.Assert(b.CheckExists(fmt.Sprintf("public/bundle%d/data.min.min.json", i)), qt.Equals, false) + + } + + b.EditFiles("assets/data/foo.yaml", "FOO") + + } +} + +func TestPageBundlerHome(t *testing.T) { + t.Parallel() + c := qt.New(t) + + workDir, clean, err := htesting.CreateTempDir(hugofs.Os, "hugo-bundler-home") + c.Assert(err, qt.IsNil) + + cfg := config.New() + cfg.Set("workingDir", workDir) + cfg.Set("publishDir", "public") + fs := hugofs.NewFromOld(hugofs.Os, cfg) + + os.MkdirAll(filepath.Join(workDir, "content"), 0o777) + + defer clean() + + b := newTestSitesBuilder(t) + b.Fs = fs + + b.WithWorkingDir(workDir).WithViper(cfg) + + b.WithContent("_index.md", "---\ntitle: Home\n---\n![Alt text](image.jpg)") + b.WithSourceFile("content/data.json", "DATA") + + b.WithTemplates("index.html", `Title: {{ .Title }}|First Resource: {{ index .Resources 0 }}|Content: {{ .Content }}`) + b.WithTemplates("_default/_markup/render-image.html", `Hook Len Page Resources {{ len .Page.Resources }}`) + + b.Build(BuildCfg{}) + b.AssertFileContent("public/index.html", ` +Title: Home|First Resource: data.json|Content: <p>Hook Len Page Resources 1</p> +`) +} + +func TestHTMLFilesIsue11999(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "rss", "sitemap", "robotsTXT", "404"] +[permalinks] +posts = "/myposts/:slugorfilename" +-- content/posts/markdown-without-frontmatter.md -- +-- content/posts/html-without-frontmatter.html -- +<html>hello</html> +-- content/posts/html-with-frontmatter.html -- +--- +title: "HTML with frontmatter" +--- +<html>hello</html> +-- content/posts/html-with-commented-out-frontmatter.html -- +<!-- +--- +title: "HTML with commented out frontmatter" +--- +--> +<html>hello</html> +-- content/posts/markdown-with-frontmatter.md -- +--- +title: "Markdown" +--- +-- content/posts/mybundle/index.md -- +--- +title: My Bundle +--- +-- content/posts/mybundle/data.txt -- +Data.txt +-- content/posts/mybundle/html-in-bundle-without-frontmatter.html -- +<html>hell</html> +-- content/posts/mybundle/html-in-bundle-with-frontmatter.html -- +--- +title: Hello +--- +<html>hello</html> +-- content/posts/mybundle/html-in-bundle-with-commented-out-frontmatter.html -- +<!-- +--- +title: "HTML with commented out frontmatter" +--- +--> +<html>hello</html> +-- layouts/index.html -- +{{ range site.RegularPages }}{{ .RelPermalink }}|{{ end }}$ +-- layouts/_default/single.html -- +{{ .Title }}|{{ .RelPermalink }}Resources: {{ range .Resources }}{{ .Name }}|{{ end }}$ + +` + b := Test(t, files) + + b.AssertFileContent("public/index.html", "/myposts/html-with-commented-out-frontmatter/|/myposts/html-without-frontmatter/|/myposts/markdown-without-frontmatter/|/myposts/html-with-frontmatter/|/myposts/markdown-with-frontmatter/|/myposts/mybundle/|$") + + b.AssertFileContent("public/myposts/mybundle/index.html", + "My Bundle|/myposts/mybundle/Resources: html-in-bundle-with-commented-out-frontmatter.html|html-in-bundle-without-frontmatter.html|html-in-bundle-with-frontmatter.html|data.txt|$") + + b.AssertPublishDir(` +index.html +myposts/html-with-commented-out-frontmatter +myposts/html-with-commented-out-frontmatter/index.html +myposts/html-with-frontmatter +myposts/html-with-frontmatter/index.html +myposts/html-without-frontmatter +myposts/html-without-frontmatter/index.html +myposts/markdown-with-frontmatter +myposts/markdown-with-frontmatter/index.html +myposts/markdown-without-frontmatter +myposts/markdown-without-frontmatter/index.html +myposts/mybundle/data.txt +myposts/mybundle/index.html +! myposts/mybundle/html-in-bundle-with-frontmatter.html +`) +} + +func TestBundleDuplicatePagesAndResources(t *testing.T) { + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ["taxonomy", "term"] +-- content/mysection/mybundle/index.md -- +-- content/mysection/mybundle/index.html -- +-- content/mysection/mybundle/p1.md -- +-- content/mysection/mybundle/p1.html -- +-- content/mysection/mybundle/foo/p1.html -- +-- content/mysection/mybundle/data.txt -- +Data txt. +-- content/mysection/mybundle/data.en.txt -- +Data en txt. +-- content/mysection/mybundle/data.json -- +Data JSON. +-- content/mysection/_index.md -- +-- content/mysection/_index.html -- +-- content/mysection/sectiondata.json -- +Secion data JSON. +-- content/mysection/sectiondata.txt -- +Section data TXT. +-- content/mysection/p2.md -- +-- content/mysection/p2.html -- +-- content/mysection/foo/p2.md -- +-- layouts/_default/single.html -- +Single:{{ .Title }}|{{ .Path }}|File LogicalName: {{ with .File }}{{ .LogicalName }}{{ end }}||{{ .RelPermalink }}|{{ .Kind }}|Resources: {{ range .Resources}}{{ .Name }}: {{ .Content }}|{{ end }}$ +-- layouts/_default/list.html -- +List: {{ .Title }}|{{ .Path }}|File LogicalName: {{ with .File }}{{ .LogicalName }}{{ end }}|{{ .RelPermalink }}|{{ .Kind }}|Resources: {{ range .Resources}}{{ .Name }}: {{ .Content }}|{{ end }}$ +RegularPages: {{ range .RegularPages }}{{ .RelPermalink }}|File LogicalName: {{ with .File }}{{ .LogicalName }}|{{ end }}{{ end }}$ +` + + b := Test(t, files) + + // Note that the sort order gives us the most specific data file for the en language (the data.en.json). + b.AssertFileContent("public/mysection/mybundle/index.html", `Single:|/mysection/mybundle|File LogicalName: index.md||/mysection/mybundle/|page|Resources: data.en.txt: Data en txt.|data.json: Data JSON.|foo/p1.html: |p1.html: |p1.md: |$`) + b.AssertFileContent("public/mysection/index.html", + "List: |/mysection|File LogicalName: _index.md|/mysection/|section|Resources: sectiondata.json: Secion data JSON.|sectiondata.txt: Section data TXT.|$", + "RegularPages: /mysection/foo/p2/|File LogicalName: p2.md|/mysection/mybundle/|File LogicalName: index.md|/mysection/p2/|File LogicalName: p2.md|$") +} + +func TestBundleResourcesGetMatchOriginalName(t *testing.T) { + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +-- content/mybundle/index.md -- +-- content/mybundle/f1.en.txt -- +F1. +-- layouts/_default/single.html -- +GetMatch: {{ with .Resources.GetMatch "f1.en.*" }}{{ .Name }}: {{ .Content }}|{{ end }} +Match: {{ range .Resources.Match "f1.En.*" }}{{ .Name }}: {{ .Content }}|{{ end }} +` + + b := Test(t, files) + + b.AssertFileContent("public/mybundle/index.html", "GetMatch: f1.en.txt: F1.|", "Match: f1.en.txt: F1.|") +} + +func TestBundleResourcesWhenLanguageVariantIsDraft(t *testing.T) { + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +defaultContentLanguage = "en" +[languages] +[languages.en] +weight = 1 +[languages.nn] +weight = 2 +-- content/mybundle/index.en.md -- +-- content/mybundle/index.nn.md -- +--- +draft: true +--- +-- content/mybundle/f1.en.txt -- +F1. +-- layouts/_default/single.html -- +GetMatch: {{ with .Resources.GetMatch "f1.*" }}{{ .Name }}: {{ .Content }}|{{ end }}$ +` + + b := Test(t, files) + + b.AssertFileContent("public/mybundle/index.html", "GetMatch: f1.en.txt: F1.|") +} + +func TestBundleBranchIssue12320(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['rss','sitemap','taxonomy','term'] +defaultContentLanguage = 'en' +defaultContentLanguageInSubdir = true +[languages.en] +baseURL = "https://en.example.org/" +contentDir = "content/en" +[languages.fr] +baseURL = "https://fr.example.org/" +contentDir = "content/fr" +-- content/en/s1/p1.md -- +--- +title: p1 +--- +-- content/en/s1/p1.txt -- +--- +p1.txt +--- +-- layouts/_default/single.html -- +{{ .Title }}| +-- layouts/_default/list.html -- +{{ .Title }}| +` + + b := Test(t, files) + + b.AssertFileExists("public/en/s1/index.html", true) + b.AssertFileExists("public/en/s1/p1/index.html", true) + b.AssertFileExists("public/en/s1/p1.txt", true) + + b.AssertFileExists("public/fr/s1/index.html", false) + b.AssertFileExists("public/fr/s1/p1/index.html", false) + b.AssertFileExists("public/fr/s1/p1.txt", false) // failing test +} diff --git a/hugolib/pagecollections.go b/hugolib/pagecollections.go new file mode 100644 index 000000000..f1038deff --- /dev/null +++ b/hugolib/pagecollections.go @@ -0,0 +1,256 @@ +// 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 hugolib + +import ( + "fmt" + "path" + "path/filepath" + "strings" + + "github.com/gohugoio/hugo/hugofs" + "github.com/gohugoio/hugo/hugofs/files" + + "github.com/gohugoio/hugo/common/paths" + + "github.com/gohugoio/hugo/resources/kinds" + "github.com/gohugoio/hugo/resources/page" +) + +// pageFinder provides ways to find a Page in a Site. +type pageFinder struct { + pageMap *pageMap +} + +func newPageFinder(m *pageMap) *pageFinder { + if m == nil { + panic("must provide a pageMap") + } + c := &pageFinder{pageMap: m} + return c +} + +// getPageRef resolves a Page from ref/relRef, with a slightly more comprehensive +// search path than getPage. +func (c *pageFinder) getPageRef(context page.Page, ref string) (page.Page, error) { + n, err := c.getContentNode(context, true, ref) + if err != nil { + return nil, err + } + + if p, ok := n.(page.Page); ok { + return p, nil + } + return nil, nil +} + +func (c *pageFinder) getPage(context page.Page, ref string) (page.Page, error) { + n, err := c.getContentNode(context, false, ref) + if err != nil { + return nil, err + } + if p, ok := n.(page.Page); ok { + return p, nil + } + return nil, nil +} + +// Only used in tests. +func (c *pageFinder) getPageOldVersion(kind string, sections ...string) page.Page { + refs := append([]string{kind}, path.Join(sections...)) + p, _ := c.getPageForRefs(refs...) + return p +} + +// This is an adapter func for the old API with Kind as first argument. +// This is invoked when you do .Site.GetPage. We drop the Kind and fails +// if there are more than 2 arguments, which would be ambiguous. +func (c *pageFinder) getPageForRefs(ref ...string) (page.Page, error) { + var refs []string + for _, r := range ref { + // A common construct in the wild is + // .Site.GetPage "home" "" or + // .Site.GetPage "home" "/" + if r != "" && r != "/" { + refs = append(refs, r) + } + } + + var key string + + if len(refs) > 2 { + // This was allowed in Hugo <= 0.44, but we cannot support this with the + // new API. This should be the most unusual case. + return nil, fmt.Errorf(`too many arguments to .Site.GetPage: %v. Use lookups on the form {{ .Site.GetPage "/posts/mypage-md" }}`, ref) + } + + if len(refs) == 0 || refs[0] == kinds.KindHome { + key = "/" + } else if len(refs) == 1 { + if len(ref) == 2 && refs[0] == kinds.KindSection { + // This is an old style reference to the "Home Page section". + // Typically fetched via {{ .Site.GetPage "section" .Section }} + // See https://github.com/gohugoio/hugo/issues/4989 + key = "/" + } else { + key = refs[0] + } + } else { + key = refs[1] + } + + return c.getPage(nil, key) +} + +const defaultContentExt = ".md" + +func (c *pageFinder) getContentNode(context page.Page, isReflink bool, ref string) (contentNodeI, error) { + ref = paths.ToSlashTrimTrailing(ref) + inRef := ref + if ref == "" { + ref = "/" + } + + if paths.HasExt(ref) { + return c.getContentNodeForRef(context, isReflink, true, inRef, ref) + } + + // We are always looking for a content file and having an extension greatly simplifies the code that follows, + // even in the case where the extension does not match this one. + if ref == "/" { + if n, err := c.getContentNodeForRef(context, isReflink, false, inRef, "/_index"+defaultContentExt); n != nil || err != nil { + return n, err + } + } else if strings.HasSuffix(ref, "/index") { + if n, err := c.getContentNodeForRef(context, isReflink, false, inRef, ref+"/index"+defaultContentExt); n != nil || err != nil { + return n, err + } + if n, err := c.getContentNodeForRef(context, isReflink, false, inRef, ref+defaultContentExt); n != nil || err != nil { + return n, err + } + } else { + if n, err := c.getContentNodeForRef(context, isReflink, false, inRef, ref+defaultContentExt); n != nil || err != nil { + return n, err + } + } + + return nil, nil +} + +func (c *pageFinder) getContentNodeForRef(context page.Page, isReflink, hadExtension bool, inRef, ref string) (contentNodeI, error) { + s := c.pageMap.s + contentPathParser := s.Conf.PathParser() + + if context != nil && !strings.HasPrefix(ref, "/") { + // Try the page-relative path first. + // Branch pages: /mysection, "./mypage" => /mysection/mypage + // Regular pages: /mysection/mypage.md, Path=/mysection/mypage, "./someotherpage" => /mysection/mypage/../someotherpage + // Regular leaf bundles: /mysection/mypage/index.md, Path=/mysection/mypage, "./someotherpage" => /mysection/mypage/../someotherpage + // Given the above, for regular pages we use the containing folder. + var baseDir string + if pi := context.PathInfo(); pi != nil { + if pi.IsBranchBundle() || (hadExtension && strings.HasPrefix(ref, "../")) { + baseDir = pi.Dir() + } else { + baseDir = pi.ContainerDir() + } + } + + rel := path.Join(baseDir, ref) + + relPath, _ := contentPathParser.ParseBaseAndBaseNameNoIdentifier(files.ComponentFolderContent, rel) + + n, err := c.getContentNodeFromPath(relPath, ref) + if n != nil || err != nil { + return n, err + } + + if hadExtension && context.File() != nil { + if n, err := c.getContentNodeFromRefReverseLookup(inRef, context.File().FileInfo()); n != nil || err != nil { + return n, err + } + } + + } + + if strings.HasPrefix(ref, ".") { + // Page relative, no need to look further. + return nil, nil + } + + relPath, nameNoIdentifier := contentPathParser.ParseBaseAndBaseNameNoIdentifier(files.ComponentFolderContent, ref) + + n, err := c.getContentNodeFromPath(relPath, ref) + + if n != nil || err != nil { + return n, err + } + + if hadExtension && s.home != nil && s.home.File() != nil { + if n, err := c.getContentNodeFromRefReverseLookup(inRef, s.home.File().FileInfo()); n != nil || err != nil { + return n, err + } + } + + var doSimpleLookup bool + if isReflink || context == nil { + slashCount := strings.Count(inRef, "/") + doSimpleLookup = slashCount == 0 + } + + if !doSimpleLookup { + return nil, nil + } + + n = c.pageMap.pageReverseIndex.Get(nameNoIdentifier) + if n == ambiguousContentNode { + return nil, fmt.Errorf("page reference %q is ambiguous", inRef) + } + + return n, nil +} + +func (c *pageFinder) getContentNodeFromRefReverseLookup(ref string, fi hugofs.FileMetaInfo) (contentNodeI, error) { + s := c.pageMap.s + meta := fi.Meta() + dir := meta.Filename + if !fi.IsDir() { + dir = filepath.Dir(meta.Filename) + } + + realFilename := filepath.Join(dir, ref) + + pcs, err := s.BaseFs.Content.ReverseLookup(realFilename, true) + if err != nil { + return nil, err + } + + // There may be multiple matches, but we will only use the first one. + for _, pc := range pcs { + pi := s.Conf.PathParser().Parse(pc.Component, pc.Path) + if n := c.pageMap.treePages.Get(pi.Base()); n != nil { + return n, nil + } + } + return nil, nil +} + +func (c *pageFinder) getContentNodeFromPath(s string, ref string) (contentNodeI, error) { + n := c.pageMap.treePages.Get(s) + if n != nil { + return n, nil + } + + return nil, nil +} diff --git a/hugolib/pagecollections_test.go b/hugolib/pagecollections_test.go new file mode 100644 index 000000000..10c973b7e --- /dev/null +++ b/hugolib/pagecollections_test.go @@ -0,0 +1,757 @@ +// 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 hugolib + +import ( + "fmt" + "math/rand" + "path" + "path/filepath" + "testing" + "time" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/resources/kinds" + "github.com/gohugoio/hugo/resources/page" + + "github.com/gohugoio/hugo/deps" +) + +const pageCollectionsPageTemplate = `--- +title: "%s" +categories: +- Hugo +--- +# Doc +` + +func BenchmarkGetPage(b *testing.B) { + var ( + cfg, fs = newTestCfg() + r = rand.New(rand.NewSource(time.Now().UnixNano())) + ) + + configs, err := loadTestConfigFromProvider(cfg) + if err != nil { + b.Fatal(err) + } + + for i := range 10 { + for j := range 100 { + writeSource(b, fs, filepath.Join("content", fmt.Sprintf("sect%d", i), fmt.Sprintf("page%d.md", j)), "CONTENT") + } + } + + s := buildSingleSite(b, deps.DepsCfg{Fs: fs, Configs: configs}, BuildCfg{SkipRender: true}) + + pagePaths := make([]string, b.N) + + for i := 0; i < b.N; i++ { + pagePaths[i] = fmt.Sprintf("sect%d", r.Intn(10)) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + home, _ := s.getPage(nil, "/") + if home == nil { + b.Fatal("Home is nil") + } + + p, _ := s.getPage(nil, pagePaths[i]) + if p == nil { + b.Fatal("Section is nil") + } + + } +} + +func createGetPageRegularBenchmarkSite(t testing.TB) *Site { + var ( + c = qt.New(t) + cfg, fs = newTestCfg() + ) + + configs, err := loadTestConfigFromProvider(cfg) + if err != nil { + t.Fatal(err) + } + + pc := func(title string) string { + return fmt.Sprintf(pageCollectionsPageTemplate, title) + } + + for i := range 10 { + for j := range 100 { + content := pc(fmt.Sprintf("Title%d_%d", i, j)) + writeSource(c, fs, filepath.Join("content", fmt.Sprintf("sect%d", i), fmt.Sprintf("page%d.md", j)), content) + } + } + + return buildSingleSite(c, deps.DepsCfg{Fs: fs, Configs: configs}, BuildCfg{SkipRender: true}) +} + +func TestBenchmarkGetPageRegular(t *testing.T) { + c := qt.New(t) + s := createGetPageRegularBenchmarkSite(t) + + for i := range 10 { + pp := path.Join("/", fmt.Sprintf("sect%d", i), fmt.Sprintf("page%d.md", i)) + page, _ := s.getPage(nil, pp) + c.Assert(page, qt.Not(qt.IsNil), qt.Commentf(pp)) + } +} + +func BenchmarkGetPageRegular(b *testing.B) { + r := rand.New(rand.NewSource(time.Now().UnixNano())) + + b.Run("From root", func(b *testing.B) { + s := createGetPageRegularBenchmarkSite(b) + c := qt.New(b) + + pagePaths := make([]string, b.N) + + for i := 0; i < b.N; i++ { + pagePaths[i] = path.Join(fmt.Sprintf("/sect%d", r.Intn(10)), fmt.Sprintf("page%d.md", r.Intn(100))) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + page, _ := s.getPage(nil, pagePaths[i]) + c.Assert(page, qt.Not(qt.IsNil)) + } + }) + + b.Run("Page relative", func(b *testing.B) { + s := createGetPageRegularBenchmarkSite(b) + c := qt.New(b) + allPages := s.RegularPages() + + pagePaths := make([]string, b.N) + pages := make([]page.Page, b.N) + + for i := 0; i < b.N; i++ { + pagePaths[i] = fmt.Sprintf("page%d.md", r.Intn(100)) + pages[i] = allPages[r.Intn(len(allPages)/3)] + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + page, _ := s.getPage(pages[i], pagePaths[i]) + c.Assert(page, qt.Not(qt.IsNil)) + } + }) +} + +type getPageTest struct { + name string + kind string + context page.Page + pathVariants []string + expectedTitle string +} + +func (t *getPageTest) check(p page.Page, err error, errorMsg string, c *qt.C) { + c.Helper() + errorComment := qt.Commentf(errorMsg) + switch t.kind { + case "Ambiguous": + c.Assert(err, qt.Not(qt.IsNil)) + c.Assert(p, qt.IsNil, errorComment) + case "NoPage": + c.Assert(err, qt.IsNil) + c.Assert(p, qt.IsNil, errorComment) + default: + c.Assert(err, qt.IsNil, errorComment) + c.Assert(p, qt.Not(qt.IsNil), errorComment) + c.Assert(p.Kind(), qt.Equals, t.kind, errorComment) + c.Assert(p.Title(), qt.Equals, t.expectedTitle, errorComment) + } +} + +func TestGetPage(t *testing.T) { + var ( + cfg, fs = newTestCfg() + c = qt.New(t) + ) + + configs, err := loadTestConfigFromProvider(cfg) + c.Assert(err, qt.IsNil) + + pc := func(title string) string { + return fmt.Sprintf(pageCollectionsPageTemplate, title) + } + + for i := range 10 { + for j := range 10 { + content := pc(fmt.Sprintf("Title%d_%d", i, j)) + writeSource(t, fs, filepath.Join("content", fmt.Sprintf("sect%d", i), fmt.Sprintf("page%d.md", j)), content) + } + } + + content := pc("home page") + writeSource(t, fs, filepath.Join("content", "_index.md"), content) + + content = pc("about page") + writeSource(t, fs, filepath.Join("content", "about.md"), content) + + content = pc("section 3") + writeSource(t, fs, filepath.Join("content", "sect3", "_index.md"), content) + + writeSource(t, fs, filepath.Join("content", "sect3", "unique.md"), pc("UniqueBase")) + writeSource(t, fs, filepath.Join("content", "sect3", "Unique2.md"), pc("UniqueBase2")) + + content = pc("another sect7") + writeSource(t, fs, filepath.Join("content", "sect3", "sect7", "_index.md"), content) + + content = pc("deep page") + writeSource(t, fs, filepath.Join("content", "sect3", "subsect", "deep.md"), content) + + // Bundle variants + writeSource(t, fs, filepath.Join("content", "sect3", "b1", "index.md"), pc("b1 bundle")) + writeSource(t, fs, filepath.Join("content", "sect3", "index", "index.md"), pc("index bundle")) + + writeSource(t, fs, filepath.Join("content", "section_bundle_overlap", "_index.md"), pc("index overlap section")) + writeSource(t, fs, filepath.Join("content", "section_bundle_overlap_bundle", "index.md"), pc("index overlap bundle")) + + s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Configs: configs}, BuildCfg{SkipRender: true}) + + sec3, err := s.getPage(nil, "/sect3") + c.Assert(err, qt.IsNil) + c.Assert(sec3, qt.Not(qt.IsNil)) + + tests := []getPageTest{ + // legacy content root relative paths + {"Root relative, no slash, home", kinds.KindHome, nil, []string{""}, "home page"}, + {"Root relative, no slash, root page", kinds.KindPage, nil, []string{"about.md", "ABOUT.md"}, "about page"}, + {"Root relative, no slash, section", kinds.KindSection, nil, []string{"sect3"}, "section 3"}, + {"Root relative, no slash, section page", kinds.KindPage, nil, []string{"sect3/page1.md"}, "Title3_1"}, + {"Root relative, no slash, sub section", kinds.KindSection, nil, []string{"sect3/sect7"}, "another sect7"}, + {"Root relative, no slash, nested page", kinds.KindPage, nil, []string{"sect3/subsect/deep.md"}, "deep page"}, + {"Root relative, no slash, OS slashes", kinds.KindPage, nil, []string{filepath.FromSlash("sect5/page3.md")}, "Title5_3"}, + + {"Short ref, unique", kinds.KindPage, nil, []string{"unique.md", "unique"}, "UniqueBase"}, + {"Short ref, unique, upper case", kinds.KindPage, nil, []string{"Unique2.md", "unique2.md", "unique2"}, "UniqueBase2"}, + {"Short ref, ambiguous", "Ambiguous", nil, []string{"page1.md"}, ""}, + + // ISSUE: This is an ambiguous ref, but because we have to support the legacy + // content root relative paths without a leading slash, the lookup + // returns /sect7. This undermines ambiguity detection, but we have no choice. + //{"Ambiguous", nil, []string{"sect7"}, ""}, + {"Section, ambiguous", kinds.KindSection, nil, []string{"sect7"}, "Sect7s"}, + + {"Absolute, home", kinds.KindHome, nil, []string{"/", ""}, "home page"}, + {"Absolute, page", kinds.KindPage, nil, []string{"/about.md", "/about"}, "about page"}, + {"Absolute, sect", kinds.KindSection, nil, []string{"/sect3"}, "section 3"}, + {"Absolute, page in subsection", kinds.KindPage, nil, []string{"/sect3/page1.md", "/Sect3/Page1.md"}, "Title3_1"}, + {"Absolute, section, subsection with same name", kinds.KindSection, nil, []string{"/sect3/sect7"}, "another sect7"}, + {"Absolute, page, deep", kinds.KindPage, nil, []string{"/sect3/subsect/deep.md"}, "deep page"}, + {"Absolute, page, OS slashes", kinds.KindPage, nil, []string{filepath.FromSlash("/sect5/page3.md")}, "Title5_3"}, // test OS-specific path + {"Absolute, unique", kinds.KindPage, nil, []string{"/sect3/unique.md"}, "UniqueBase"}, + {"Absolute, unique, case", kinds.KindPage, nil, []string{"/sect3/Unique2.md", "/sect3/unique2.md", "/sect3/unique2", "/sect3/Unique2"}, "UniqueBase2"}, + // next test depends on this page existing + // {"NoPage", nil, []string{"/unique.md"}, ""}, // ISSUE #4969: this is resolving to /sect3/unique.md + {"Absolute, missing page", "NoPage", nil, []string{"/missing-page.md"}, ""}, + {"Absolute, missing section", "NoPage", nil, []string{"/missing-section"}, ""}, + + // relative paths + {"Dot relative, home", kinds.KindHome, sec3, []string{".."}, "home page"}, + {"Dot relative, home, slash", kinds.KindHome, sec3, []string{"../"}, "home page"}, + {"Dot relative about", kinds.KindPage, sec3, []string{"../about.md"}, "about page"}, + {"Dot", kinds.KindSection, sec3, []string{"."}, "section 3"}, + {"Dot slash", kinds.KindSection, sec3, []string{"./"}, "section 3"}, + {"Page relative, no dot", kinds.KindPage, sec3, []string{"page1.md"}, "Title3_1"}, + {"Page relative, dot", kinds.KindPage, sec3, []string{"./page1.md"}, "Title3_1"}, + {"Up and down another section", kinds.KindPage, sec3, []string{"../sect4/page2.md"}, "Title4_2"}, + {"Rel sect7", kinds.KindSection, sec3, []string{"sect7"}, "another sect7"}, + {"Rel sect7 dot", kinds.KindSection, sec3, []string{"./sect7"}, "another sect7"}, + {"Dot deep", kinds.KindPage, sec3, []string{"./subsect/deep.md"}, "deep page"}, + {"Dot dot inner", kinds.KindPage, sec3, []string{"./subsect/../../sect7/page9.md"}, "Title7_9"}, + {"Dot OS slash", kinds.KindPage, sec3, []string{filepath.FromSlash("../sect5/page3.md")}, "Title5_3"}, // test OS-specific path + {"Dot unique", kinds.KindPage, sec3, []string{"./unique.md"}, "UniqueBase"}, + {"Dot sect", "NoPage", sec3, []string{"./sect2"}, ""}, + //{"NoPage", sec3, []string{"sect2"}, ""}, // ISSUE: /sect3 page relative query is resolving to /sect2 + + {"Abs, ignore context, home", kinds.KindHome, sec3, []string{"/"}, "home page"}, + {"Abs, ignore context, about", kinds.KindPage, sec3, []string{"/about.md"}, "about page"}, + {"Abs, ignore context, page in section", kinds.KindPage, sec3, []string{"/sect4/page2.md"}, "Title4_2"}, + {"Abs, ignore context, page subsect deep", kinds.KindPage, sec3, []string{"/sect3/subsect/deep.md"}, "deep page"}, // next test depends on this page existing + {"Abs, ignore context, page deep", "NoPage", sec3, []string{"/subsect/deep.md"}, ""}, + + // Taxonomies + {"Taxonomy term", kinds.KindTaxonomy, nil, []string{"categories"}, "Categories"}, + {"Taxonomy", kinds.KindTerm, nil, []string{"categories/hugo", "categories/Hugo"}, "Hugo"}, + + // Bundle variants + {"Bundle regular", kinds.KindPage, nil, []string{"sect3/b1", "sect3/b1/index.md", "sect3/b1/index.en.md"}, "b1 bundle"}, + {"Bundle index name", kinds.KindPage, nil, []string{"sect3/index/index.md", "sect3/index"}, "index bundle"}, + + // https://github.com/gohugoio/hugo/issues/7301 + {"Section and bundle overlap", kinds.KindPage, nil, []string{"section_bundle_overlap_bundle"}, "index overlap bundle"}, + } + + for _, test := range tests { + c.Run(test.name, func(c *qt.C) { + errorMsg := fmt.Sprintf("Test case %v %v -> %s", test.context, test.pathVariants, test.expectedTitle) + + // test legacy public Site.GetPage (which does not support page context relative queries) + if test.context == nil { + for _, ref := range test.pathVariants { + args := append([]string{test.kind}, ref) + page, err := s.GetPage(args...) + test.check(page, err, errorMsg, c) + } + } + + // test new internal Site.getPage + for _, ref := range test.pathVariants { + page2, err := s.getPage(test.context, ref) + test.check(page2, err, errorMsg, c) + } + }) + } +} + +// #11664 +func TestGetPageIndexIndex(t *testing.T) { + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term"] +-- content/mysect/index/index.md -- +--- +title: "Mysect Index" +--- +-- layouts/index.html -- +GetPage 1: {{ with site.GetPage "mysect/index/index.md" }}{{ .Title }}|{{ .RelPermalink }}|{{ .Path }}{{ end }}| +GetPage 2: {{ with site.GetPage "mysect/index" }}{{ .Title }}|{{ .RelPermalink }}|{{ .Path }}{{ end }}| +` + + b := Test(t, files) + b.AssertFileContent("public/index.html", + "GetPage 1: Mysect Index|/mysect/index/|/mysect/index|", + "GetPage 2: Mysect Index|/mysect/index/|/mysect/index|", + ) +} + +// https://github.com/gohugoio/hugo/issues/6034 +func TestGetPageRelative(t *testing.T) { + b := newTestSitesBuilder(t) + for i, section := range []string{"what", "where", "who"} { + isDraft := i == 2 + b.WithContent( + section+"/_index.md", fmt.Sprintf("---title: %s\n---", section), + section+"/members.md", fmt.Sprintf("---title: members %s\ndraft: %t\n---", section, isDraft), + ) + } + + b.WithTemplates("_default/list.html", ` +{{ with .GetPage "members.md" }} + Members: {{ .Title }} +{{ else }} +NOT FOUND +{{ end }} +`) + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/what/index.html", `Members: members what`) + b.AssertFileContent("public/where/index.html", `Members: members where`) + b.AssertFileContent("public/who/index.html", `NOT FOUND`) +} + +func TestGetPageIssue11883(t *testing.T) { + files := ` +-- hugo.toml -- +-- p1/index.md -- +--- +title: p1 +--- +-- p1/p1.xyz -- +xyz. +-- layouts/index.html -- +Home. {{ with .Page.GetPage "p1.xyz" }}{{ else }}OK 1{{ end }} {{ with .Site.GetPage "p1.xyz" }}{{ else }}OK 2{{ end }} +` + + b := Test(t, files) + b.AssertFileContent("public/index.html", "Home. OK 1 OK 2") +} + +func TestGetPageIssue12120(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['home','rss','section','sitemap','taxonomy','term'] +-- content/s1/p1/index.md -- +--- +title: p1 +layout: p1 +--- +-- content/s1/p2.md -- +--- +title: p2 +layout: p2 +--- +-- layouts/_default/p1.html -- +{{ (.GetPage "p2.md").Title }}| +-- layouts/_default/p2.html -- +{{ (.GetPage "p1").Title }}| +` + + b := Test(t, files) + b.AssertFileContent("public/s1/p1/index.html", "p2") // failing test + b.AssertFileContent("public/s1/p2/index.html", "p1") +} + +func TestGetPageNewsVsTagsNewsIssue12638(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['rss','section','sitemap'] +[taxonomies] + tag = "tags" +-- content/p1.md -- +--- +title: p1 +tags: [news] +--- +-- layouts/index.html -- +/tags/news: {{ with .Site.GetPage "/tags/news" }}{{ .Title }}{{ end }}| +news: {{ with .Site.GetPage "news" }}{{ .Title }}{{ end }}| +/news: {{ with .Site.GetPage "/news" }}{{ .Title }}{{ end }}| + +` + + b := Test(t, files) + + b.AssertFileContent("public/index.html", + "/tags/news: News|", + "news: News|", + "/news: |", + ) +} + +func TestGetPageBundleToRegular(t *testing.T) { + files := ` +-- hugo.toml -- +-- content/s1/p1/index.md -- +--- +title: p1 +--- +-- content/s1/p2.md -- +--- +title: p2 +--- +-- layouts/_default/single.html -- +{{ with .GetPage "p2" }} + OK: {{ .LinkTitle }} +{{ else }} + Unable to get p2. +{{ end }} +` + + b := Test(t, files) + b.AssertFileContent("public/s1/p1/index.html", "OK: p2") + b.AssertFileContent("public/s1/p2/index.html", "OK: p2") +} + +func TestPageGetPageVariations(t *testing.T) { + files := ` +-- hugo.toml -- +-- content/s1/_index.md -- +--- +title: s1 section +--- +-- content/s1/p1/index.md -- +--- +title: p1 +--- +-- content/s1/p2.md -- +--- +title: p2 +--- +-- content/s2/p3/index.md -- +--- +title: p3 +--- +-- content/p2.md -- +--- +title: p2_root +--- +-- layouts/index.html -- +/s1: {{ with .GetPage "/s1" }}{{ .Title }}{{ end }}| +/s1/: {{ with .GetPage "/s1/" }}{{ .Title }}{{ end }}| +/s1/p2.md: {{ with .GetPage "/s1/p2.md" }}{{ .Title }}{{ end }}| +/s1/p2: {{ with .GetPage "/s1/p2" }}{{ .Title }}{{ end }}| +/s1/p1/index.md: {{ with .GetPage "/s1/p1/index.md" }}{{ .Title }}{{ end }}| +/s1/p1: {{ with .GetPage "/s1/p1" }}{{ .Title }}{{ end }}| +-- layouts/_default/single.html -- +../p2: {{ with .GetPage "../p2" }}{{ .Title }}{{ end }}| +../p2.md: {{ with .GetPage "../p2.md" }}{{ .Title }}{{ end }}| +p1/index.md: {{ with .GetPage "p1/index.md" }}{{ .Title }}{{ end }}| +../s2/p3/index.md: {{ with .GetPage "../s2/p3/index.md" }}{{ .Title }}{{ end }}| +` + + b := Test(t, files) + + b.AssertFileContent("public/index.html", ` +/s1: s1 section| +/s1/: s1 section| +/s1/p2.md: p2| +/s1/p2: p2| +/s1/p1/index.md: p1| +/s1/p1: p1| +`) + + b.AssertFileContent("public/s1/p1/index.html", ` +../p2: p2_root| +../p2.md: p2| + +`) + + b.AssertFileContent("public/s1/p2/index.html", ` +../p2: p2_root| +../p2.md: p2_root| +p1/index.md: p1| +../s2/p3/index.md: p3| + +`) +} + +func TestPageGetPageMountsReverseLookup(t *testing.T) { + tempDir := t.TempDir() + + files := ` +-- README.md -- +--- +title: README +--- +-- blog/b1.md -- +--- +title: b1 +--- +{{< ref "../docs/d1.md" >}} +-- blog/b2/index.md -- +--- +title: b2 +--- +{{< ref "../../docs/d1.md" >}} +-- docs/d1.md -- +--- +title: d1 +--- +-- hugo.toml -- +baseURL = "https://example.com/" +[module] +[[module.mounts]] +source = "layouts" +target = "layouts" +[[module.mounts]] +source = "README.md" +target = "content/_index.md" +[[module.mounts]] +source = "blog" +target = "content/posts" +[[module.mounts]] +source = "docs" +target = "content/mydocs" +-- layouts/shortcodes/ref.html -- +{{ $ref := .Get 0 }} +.Page.GetPage({{ $ref }}).Title: {{ with .Page.GetPage $ref }}{{ .Title }}{{ end }}| +-- layouts/index.html -- +Home. +/blog/b1.md: {{ with .GetPage "/blog/b1.md" }}{{ .Title }}{{ end }}| +/blog/b2/index.md: {{ with .GetPage "/blog/b2/index.md" }}{{ .Title }}{{ end }}| +/docs/d1.md: {{ with .GetPage "/docs/d1.md" }}{{ .Title }}{{ end }}| +/README.md: {{ with .GetPage "/README.md" }}{{ .Title }}{{ end }}| +-- layouts/_default/single.html -- +Single. +/README.md: {{ with .GetPage "/README.md" }}{{ .Title }}{{ end }}| +{{ .Content }} + + +` + b := Test(t, files, TestOptWithConfig(func(cfg *IntegrationTestConfig) { cfg.WorkingDir = tempDir })) + + b.AssertFileContent("public/index.html", + ` +/blog/b1.md: b1| +/blog/b2/index.md: b2| +/docs/d1.md: d1| +/README.md: README +`, + ) + + b.AssertFileContent("public/mydocs/d1/index.html", `README.md: README|`) + + b.AssertFileContent("public/posts/b1/index.html", `.Page.GetPage(../docs/d1.md).Title: d1|`) + b.AssertFileContent("public/posts/b2/index.html", `.Page.GetPage(../../docs/d1.md).Title: d1|`) +} + +// https://github.com/gohugoio/hugo/issues/7016 +func TestGetPageMultilingual(t *testing.T) { + b := newTestSitesBuilder(t) + + b.WithConfigFile("yaml", ` +baseURL: "http://example.org/" +languageCode: "en-us" +defaultContentLanguage: ru +title: "My New Hugo Site" +uglyurls: true + +languages: + ru: {} + en: {} +`) + + b.WithContent( + "docs/1.md", "\n---title: p1\n---", + "news/1.md", "\n---title: p1\n---", + "news/1.en.md", "\n---title: p1en\n---", + "news/about/1.md", "\n---title: about1\n---", + "news/about/1.en.md", "\n---title: about1en\n---", + ) + + b.WithTemplates("index.html", ` +{{ with site.GetPage "docs/1" }} + Docs p1: {{ .Title }} +{{ else }} +NOT FOUND +{{ end }} +`) + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", `Docs p1: p1`) + b.AssertFileContent("public/en/index.html", `NOT FOUND`) +} + +func TestRegularPagesRecursive(t *testing.T) { + b := newTestSitesBuilder(t) + + b.WithConfigFile("yaml", ` +baseURL: "http://example.org/" +title: "My New Hugo Site" + +`) + + b.WithContent( + "docs/1.md", "\n---title: docs1\n---", + "docs/sect1/_index.md", "\n---title: docs_sect1\n---", + "docs/sect1/ps1.md", "\n---title: docs_sect1_ps1\n---", + "docs/sect1/ps2.md", "\n---title: docs_sect1_ps2\n---", + "docs/sect1/sect1_s2/_index.md", "\n---title: docs_sect1_s2\n---", + "docs/sect1/sect1_s2/ps2_1.md", "\n---title: docs_sect1_s2_1\n---", + "docs/sect2/_index.md", "\n---title: docs_sect2\n---", + "docs/sect2/ps1.md", "\n---title: docs_sect2_ps1\n---", + "docs/sect2/ps2.md", "\n---title: docs_sect2_ps2\n---", + "news/1.md", "\n---title: news1\n---", + ) + + b.WithTemplates("index.html", ` +{{ $sect1 := site.GetPage "sect1" }} + +Sect1 RegularPagesRecursive: {{ range $sect1.RegularPagesRecursive }}{{ .Kind }}:{{ .RelPermalink}}|{{ end }}|End. + +`) + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", ` +Sect1 RegularPagesRecursive: page:/docs/sect1/ps1/|page:/docs/sect1/ps2/|page:/docs/sect1/sect1_s2/ps2_1/||End. + + +`) +} + +func TestRegularPagesRecursiveHome(t *testing.T) { + files := ` +-- hugo.toml -- +-- content/p1.md -- +-- content/post/p2.md -- +-- layouts/index.html -- +RegularPagesRecursive: {{ range .RegularPagesRecursive }}{{ .Kind }}:{{ .RelPermalink}}|{{ end }}|End. +` + + b := NewIntegrationTestBuilder( + IntegrationTestConfig{ + T: t, + TxtarString: files, + }).Build() + + b.AssertFileContent("public/index.html", `RegularPagesRecursive: page:/p1/|page:/post/p2/||End.`) +} + +// Issue #12169. +func TestPagesSimilarSectionNames(t *testing.T) { + files := ` +-- hugo.toml -- +-- content/draftsection/_index.md -- +--- +draft: true +--- +-- content/draftsection/sub/_index.md --got +-- content/draftsection/sub/d1.md -- +-- content/s1/_index.md -- +-- content/s1/p1.md -- +-- content/s1-foo/_index.md -- +-- content/s1-foo/p2.md -- +-- content/s1-foo/s2/_index.md -- +-- content/s1-foo/s2/p3.md -- +-- content/s1-foo/s2-foo/_index.md -- +-- content/s1-foo/s2-foo/p4.md -- +-- layouts/_default/list.html -- +{{ .RelPermalink }}: Pages: {{ range .Pages }}{{ .RelPermalink }}|{{ end }}$ + +` + b := Test(t, files) + + b.AssertFileContent("public/index.html", "/: Pages: /s1-foo/|/s1/|$") + b.AssertFileContent("public/s1/index.html", "/s1/: Pages: /s1/p1/|$") + b.AssertFileContent("public/s1-foo/index.html", "/s1-foo/: Pages: /s1-foo/p2/|/s1-foo/s2-foo/|/s1-foo/s2/|$") + b.AssertFileContent("public/s1-foo/s2/index.html", "/s1-foo/s2/: Pages: /s1-foo/s2/p3/|$") +} + +func TestGetPageContentAdapterBaseIssue12561(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['rss','section','sitemap','taxonomy','term'] +-- layouts/index.html -- +Test A: {{ (site.GetPage "/s1/p1").Title }} +Test B: {{ (site.GetPage "p1").Title }} +Test C: {{ (site.GetPage "/s2/p2").Title }} +Test D: {{ (site.GetPage "p2").Title }} +-- layouts/_default/single.html -- +{{ .Title }} +-- content/s1/p1.md -- +--- +title: p1 +--- +-- content/s2/_content.gotmpl -- +{{ .AddPage (dict "path" "p2" "title" "p2") }} +` + + b := Test(t, files) + + b.AssertFileExists("public/s1/p1/index.html", true) + b.AssertFileExists("public/s2/p2/index.html", true) + b.AssertFileContent("public/index.html", + "Test A: p1", + "Test B: p1", + "Test C: p2", + "Test D: p2", // fails + ) +} diff --git a/hugolib/pagemeta/page_frontmatter.go b/hugolib/pagemeta/page_frontmatter.go deleted file mode 100644 index 8bfc4e837..000000000 --- a/hugolib/pagemeta/page_frontmatter.go +++ /dev/null @@ -1,430 +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 pagemeta - -import ( - "io/ioutil" - "log" - "os" - "strings" - "time" - - "github.com/gohugoio/hugo/helpers" - - "github.com/gohugoio/hugo/config" - "github.com/spf13/cast" - jww "github.com/spf13/jwalterweatherman" -) - -// FrontMatterHandler maps front matter into Page fields and .Params. -// Note that we currently have only extracted the date logic. -type FrontMatterHandler struct { - fmConfig frontmatterConfig - - dateHandler frontMatterFieldHandler - lastModHandler frontMatterFieldHandler - publishDateHandler frontMatterFieldHandler - expiryDateHandler frontMatterFieldHandler - - // A map of all date keys configured, including any custom. - allDateKeys map[string]bool - - logger *jww.Notepad -} - -// 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, e.g. page.md. - BaseFilename 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. - - // This is the Page's params. - Params map[string]interface{} - - // This is the Page's dates. - Dates *PageDates - - // This is the Page's Slug etc. - PageURLs *URLPath -} - -var ( - dateFieldAliases = map[string][]string{ - fmDate: []string{}, - fmLastmod: []string{"modified"}, - fmPubDate: []string{"pubdate", "published"}, - fmExpiryDate: []string{"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 f.dateHandler == nil { - panic("missing date handler") - } - - if _, err := f.dateHandler(d); err != nil { - return err - } - - if _, err := f.lastModHandler(d); err != nil { - return err - } - - if _, err := f.publishDateHandler(d); err != nil { - return err - } - - if _, err := f.expiryDateHandler(d); err != nil { - return err - } - - return nil -} - -// IsDateKey returns whether the given front matter key is considered a date by the current -// configuration. -func (f FrontMatterHandler) IsDateKey(key string) bool { - return f.allDateKeys[key] -} - -// 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) - - 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]) - if err != nil { - return time.Time{}, "" - } - - // Be a little lenient with the format here. - slug := strings.Trim(withoutExt[10:], " -_") - - return d, slug -} - -type frontMatterFieldHandler func(d *FrontMatterDescriptor) (bool, error) - -func (f FrontMatterHandler) newChainedFrontMatterFieldHandler(handlers ...frontMatterFieldHandler) frontMatterFieldHandler { - return func(d *FrontMatterDescriptor) (bool, error) { - for _, h := range handlers { - // First successful handler wins. - success, err := h(d) - if err != nil { - f.logger.ERROR.Println(err) - } else if success { - return true, nil - } - } - return false, nil - } -} - -type frontmatterConfig struct { - date []string - lastmod []string - publishDate []string - expiryDate []string -} - -const ( - // These are all the date handler identifiers - // All identifiers not starting with a ":" maps to a front matter parameter. - fmDate = "date" - fmPubDate = "publishdate" - fmLastmod = "lastmod" - fmExpiryDate = "expirydate" - - // Gets date from filename, e.g 218-02-22-mypage.md - fmFilename = ":filename" - - // Gets date from file OS mod time. - fmModTime = ":filemodtime" - - // Gets date from Git - fmGitAuthorDate = ":git" -) - -// 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 newFrontmatterConfig(cfg config.Provider) (frontmatterConfig, error) { - c := newDefaultFrontmatterConfig() - defaultConfig := c - - if cfg.IsSet("frontmatter") { - fm := cfg.GetStringMap("frontmatter") - if fm != nil { - for k, v := range fm { - loki := strings.ToLower(k) - switch loki { - case fmDate: - c.date = toLowerSlice(v) - case fmPubDate: - c.publishDate = toLowerSlice(v) - case fmLastmod: - c.lastmod = toLowerSlice(v) - case fmExpiryDate: - c.expiryDate = toLowerSlice(v) - } - } - } - } - - expander := func(c, d []string) []string { - out := expandDefaultValues(c, d) - out = addDateFieldAliases(out) - 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) - - return c, nil -} - -func addDateFieldAliases(values []string) []string { - var complete []string - - for _, v := range values { - complete = append(complete, v) - if aliases, found := dateFieldAliases[v]; found { - complete = append(complete, aliases...) - } - } - return helpers.UniqueStrings(complete) -} - -func expandDefaultValues(values []string, defaults []string) []string { - var out []string - for _, v := range values { - if v == ":default" { - out = append(out, defaults...) - } else { - out = append(out, v) - } - } - return out -} - -func toLowerSlice(in interface{}) []string { - out := cast.ToStringSlice(in) - for i := 0; i < len(out); i++ { - out[i] = strings.ToLower(out[i]) - } - - return out -} - -// NewFrontmatterHandler creates a new FrontMatterHandler with the given logger and configuration. -// If no logger is provided, one will be created. -func NewFrontmatterHandler(logger *jww.Notepad, cfg config.Provider) (FrontMatterHandler, error) { - - if logger == nil { - logger = jww.NewNotepad(jww.LevelWarn, jww.LevelWarn, os.Stdout, ioutil.Discard, "", log.Ldate|log.Ltime) - } - - frontMatterConfig, err := newFrontmatterConfig(cfg) - if err != nil { - return FrontMatterHandler{}, err - } - - allDateKeys := make(map[string]bool) - addKeys := func(vals []string) { - for _, k := range vals { - if !strings.HasPrefix(k, ":") { - allDateKeys[k] = true - } - } - } - - addKeys(frontMatterConfig.date) - addKeys(frontMatterConfig.expiryDate) - addKeys(frontMatterConfig.lastmod) - addKeys(frontMatterConfig.publishDate) - - f := FrontMatterHandler{logger: logger, fmConfig: frontMatterConfig, allDateKeys: allDateKeys} - - if err := f.createHandlers(); err != nil { - return f, err - } - - return f, nil -} - -func (f *FrontMatterHandler) createHandlers() error { - var err error - - if f.dateHandler, err = f.createDateHandler(f.fmConfig.date, - func(d *FrontMatterDescriptor, t time.Time) { - d.Dates.Date = t - setParamIfNotSet(fmDate, t, d) - }); err != nil { - return err - } - - if f.lastModHandler, err = f.createDateHandler(f.fmConfig.lastmod, - func(d *FrontMatterDescriptor, t time.Time) { - setParamIfNotSet(fmLastmod, t, d) - d.Dates.Lastmod = t - }); err != nil { - return err - } - - if f.publishDateHandler, err = f.createDateHandler(f.fmConfig.publishDate, - func(d *FrontMatterDescriptor, t time.Time) { - setParamIfNotSet(fmPubDate, t, d) - d.Dates.PublishDate = t - }); err != nil { - return err - } - - if f.expiryDateHandler, err = f.createDateHandler(f.fmConfig.expiryDate, - func(d *FrontMatterDescriptor, t time.Time) { - setParamIfNotSet(fmExpiryDate, t, d) - d.Dates.ExpiryDate = t - }); err != nil { - return err - } - - return nil -} - -func setParamIfNotSet(key string, value interface{}, d *FrontMatterDescriptor) { - if _, found := d.Params[key]; found { - return - } - d.Params[key] = value -} - -func (f FrontMatterHandler) createDateHandler(identifiers []string, setter func(d *FrontMatterDescriptor, t time.Time)) (frontMatterFieldHandler, error) { - var h *frontmatterFieldHandlers - var handlers []frontMatterFieldHandler - - for _, identifier := range identifiers { - switch identifier { - case fmFilename: - handlers = append(handlers, h.newDateFilenameHandler(setter)) - case fmModTime: - handlers = append(handlers, h.newDateModTimeHandler(setter)) - case fmGitAuthorDate: - handlers = append(handlers, h.newDateGitAuthorDateHandler(setter)) - default: - handlers = append(handlers, h.newDateFieldHandler(identifier, setter)) - } - } - - 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] - - if !found { - return false, nil - } - - date, err := cast.ToTimeE(v) - if err != nil { - return false, nil - } - - // 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) - if date.IsZero() { - return false, nil - } - - setter(d, date) - - if _, found := d.Frontmatter["slug"]; !found { - // Use slug from filename - d.PageURLs.Slug = slug - } - - return true, nil - } -} - -func (f *frontmatterFieldHandlers) newDateModTimeHandler(setter func(d *FrontMatterDescriptor, t time.Time)) frontMatterFieldHandler { - return func(d *FrontMatterDescriptor) (bool, error) { - if d.ModTime.IsZero() { - return false, nil - } - setter(d, d.ModTime) - return true, nil - } -} - -func (f *frontmatterFieldHandlers) newDateGitAuthorDateHandler(setter func(d *FrontMatterDescriptor, t time.Time)) frontMatterFieldHandler { - return func(d *FrontMatterDescriptor) (bool, error) { - if d.GitAuthorDate.IsZero() { - return false, nil - } - setter(d, d.GitAuthorDate) - return true, nil - } -} diff --git a/hugolib/pagemeta/page_frontmatter_test.go b/hugolib/pagemeta/page_frontmatter_test.go deleted file mode 100644 index 03f4c2f84..000000000 --- a/hugolib/pagemeta/page_frontmatter_test.go +++ /dev/null @@ -1,261 +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 pagemeta - -import ( - "fmt" - "strings" - "testing" - "time" - - "github.com/spf13/viper" - - "github.com/stretchr/testify/require" -) - -func TestDateAndSlugFromBaseFilename(t *testing.T) { - - t.Parallel() - - assert := require.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 i, test := range tests { - expectedDate, err := time.Parse("2006-01-02", test.date) - assert.NoError(err) - - errMsg := fmt.Sprintf("Test %d", i) - gotDate, gotSlug := dateAndSlugFromBaseFilename(test.name) - - assert.Equal(expectedDate, gotDate, errMsg) - assert.Equal(test.slug, gotSlug, errMsg) - - } -} - -func newTestFd() *FrontMatterDescriptor { - return &FrontMatterDescriptor{ - Frontmatter: make(map[string]interface{}), - Params: make(map[string]interface{}), - Dates: &PageDates{}, - PageURLs: &URLPath{}, - } -} - -func TestFrontMatterNewConfig(t *testing.T) { - assert := require.New(t) - - cfg := viper.New() - - cfg.Set("frontmatter", map[string]interface{}{ - "date": []string{"publishDate", "LastMod"}, - "Lastmod": []string{"publishDate"}, - "expiryDate": []string{"lastMod"}, - "publishDate": []string{"date"}, - }) - - fc, err := newFrontmatterConfig(cfg) - assert.NoError(err) - assert.Equal([]string{"publishdate", "pubdate", "published", "lastmod", "modified"}, fc.date) - assert.Equal([]string{"publishdate", "pubdate", "published"}, fc.lastmod) - assert.Equal([]string{"lastmod", "modified"}, fc.expiryDate) - assert.Equal([]string{"date"}, fc.publishDate) - - // Default - cfg = viper.New() - fc, err = newFrontmatterConfig(cfg) - assert.NoError(err) - assert.Equal([]string{"date", "publishdate", "pubdate", "published", "lastmod", "modified"}, fc.date) - assert.Equal([]string{":git", "lastmod", "modified", "date", "publishdate", "pubdate", "published"}, fc.lastmod) - assert.Equal([]string{"expirydate", "unpublishdate"}, fc.expiryDate) - assert.Equal([]string{"publishdate", "pubdate", "published", "date"}, fc.publishDate) - - // :default keyword - cfg.Set("frontmatter", map[string]interface{}{ - "date": []string{"d1", ":default"}, - "lastmod": []string{"d2", ":default"}, - "expiryDate": []string{"d3", ":default"}, - "publishDate": []string{"d4", ":default"}, - }) - fc, err = newFrontmatterConfig(cfg) - assert.NoError(err) - assert.Equal([]string{"d1", "date", "publishdate", "pubdate", "published", "lastmod", "modified"}, fc.date) - assert.Equal([]string{"d2", ":git", "lastmod", "modified", "date", "publishdate", "pubdate", "published"}, fc.lastmod) - assert.Equal([]string{"d3", "expirydate", "unpublishdate"}, fc.expiryDate) - assert.Equal([]string{"d4", "publishdate", "pubdate", "published", "date"}, fc.publishDate) - -} - -func TestFrontMatterDatesHandlers(t *testing.T) { - assert := require.New(t) - - for _, handlerID := range []string{":filename", ":fileModTime", ":git"} { - - cfg := viper.New() - - cfg.Set("frontmatter", map[string]interface{}{ - "date": []string{handlerID, "date"}, - }) - - handler, err := NewFrontmatterHandler(nil, cfg) - assert.NoError(err) - - d1, _ := time.Parse("2006-01-02", "2018-02-01") - d2, _ := time.Parse("2006-01-02", "2018-02-02") - - d := newTestFd() - switch strings.ToLower(handlerID) { - case ":filename": - d.BaseFilename = "2018-02-01-page.md" - case ":filemodtime": - d.ModTime = d1 - case ":git": - d.GitAuthorDate = d1 - } - d.Frontmatter["date"] = d2 - assert.NoError(handler.HandleDates(d)) - assert.Equal(d1, d.Dates.Date) - assert.Equal(d2, d.Params["date"]) - - d = newTestFd() - d.Frontmatter["date"] = d2 - assert.NoError(handler.HandleDates(d)) - assert.Equal(d2, d.Dates.Date) - assert.Equal(d2, d.Params["date"]) - - } -} - -func TestFrontMatterDatesCustomConfig(t *testing.T) { - t.Parallel() - - assert := require.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) - assert.NoError(err) - - testDate, err := time.Parse("2006-01-02", "2018-02-01") - assert.NoError(err) - - 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 - - assert.NoError(handler.HandleDates(d)) - - assert.Equal(1, d.Dates.Date.Day()) - assert.Equal(4, d.Dates.Lastmod.Day()) - assert.Equal(4, d.Dates.PublishDate.Day()) - assert.Equal(5, d.Dates.ExpiryDate.Day()) - - assert.Equal(d.Dates.Date, d.Params["date"]) - assert.Equal(d.Dates.Date, d.Params["mydate"]) - assert.Equal(d.Dates.PublishDate, d.Params["publishdate"]) - assert.Equal(d.Dates.ExpiryDate, d.Params["expirydate"]) - - assert.False(handler.IsDateKey("date")) // This looks odd, but is configured like this. - assert.True(handler.IsDateKey("mydate")) - assert.True(handler.IsDateKey("publishdate")) - assert.True(handler.IsDateKey("pubdate")) - -} - -func TestFrontMatterDatesDefaultKeyword(t *testing.T) { - t.Parallel() - - assert := require.New(t) - - cfg := viper.New() - - cfg.Set("frontmatter", map[string]interface{}{ - "date": []string{"mydate", ":default"}, - "publishdate": []string{":default", "mypubdate"}, - }) - - handler, err := NewFrontmatterHandler(nil, cfg) - assert.NoError(err) - - 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) - - assert.NoError(handler.HandleDates(d)) - - assert.Equal(1, d.Dates.Date.Day()) - assert.Equal(2, d.Dates.Lastmod.Day()) - assert.Equal(4, d.Dates.PublishDate.Day()) - assert.True(d.Dates.ExpiryDate.IsZero()) - -} - -func TestExpandDefaultValues(t *testing.T) { - assert := require.New(t) - assert.Equal([]string{"a", "b", "c", "d"}, expandDefaultValues([]string{"a", ":default", "d"}, []string{"b", "c"})) - assert.Equal([]string{"a", "b", "c"}, expandDefaultValues([]string{"a", "b", "c"}, []string{"a", "b", "c"})) - assert.Equal([]string{"b", "c", "a", "b", "c", "d"}, expandDefaultValues([]string{":default", "a", ":default", "d"}, []string{"b", "c"})) - -} - -func TestFrontMatterDateFieldHandler(t *testing.T) { - t.Parallel() - - assert := require.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.Date = t }) - - handled, err := h(fd) - assert.True(handled) - assert.NoError(err) - assert.Equal(d, fd.Dates.Date) -} diff --git a/hugolib/pagemeta/pagemeta.go b/hugolib/pagemeta/pagemeta.go deleted file mode 100644 index 93dc9a12f..000000000 --- a/hugolib/pagemeta/pagemeta.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 pagemeta - -import ( - "time" -) - -type URLPath struct { - URL string - Permalink string - Slug string - Section string -} - -type PageDates struct { - Date time.Time - Lastmod time.Time - PublishDate time.Time - ExpiryDate time.Time -} diff --git a/hugolib/pagesPrevNext.go b/hugolib/pagesPrevNext.go deleted file mode 100644 index 947a49b85..000000000 --- a/hugolib/pagesPrevNext.go +++ /dev/null @@ -1,42 +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 hugolib - -// Prev returns the previous page reletive to the given page. -func (p Pages) Prev(cur *Page) *Page { - for x, c := range p { - if c.Eq(cur) { - if x == 0 { - // TODO(bep) consider return nil here to get it line with the other Prevs - return p[len(p)-1] - } - return p[x-1] - } - } - return nil -} - -// Next returns the next page reletive to the given page. -func (p Pages) Next(cur *Page) *Page { - for x, c := range p { - if c.Eq(cur) { - if x < len(p)-1 { - return p[x+1] - } - // TODO(bep) consider return nil here to get it line with the other Nexts - return p[0] - } - } - return nil -} diff --git a/hugolib/pagesPrevNext_test.go b/hugolib/pagesPrevNext_test.go deleted file mode 100644 index 5945d8fe5..000000000 --- a/hugolib/pagesPrevNext_test.go +++ /dev/null @@ -1,86 +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 hugolib - -import ( - "testing" - - "github.com/spf13/cast" - "github.com/stretchr/testify/assert" -) - -type pagePNTestObject struct { - path string - weight int - date string -} - -var pagePNTestSources = []pagePNTestObject{ - {"/section1/testpage1.md", 5, "2012-04-06"}, - {"/section1/testpage2.md", 4, "2012-01-01"}, - {"/section1/testpage3.md", 3, "2012-04-06"}, - {"/section2/testpage4.md", 2, "2012-03-02"}, - {"/section2/testpage5.md", 1, "2012-04-06"}, -} - -func TestPrev(t *testing.T) { - t.Parallel() - pages := preparePageGroupTestPages(t) - assert.Equal(t, pages.Prev(pages[0]), pages[4]) - assert.Equal(t, pages.Prev(pages[1]), pages[0]) - assert.Equal(t, pages.Prev(pages[4]), pages[3]) -} - -func TestNext(t *testing.T) { - t.Parallel() - pages := preparePageGroupTestPages(t) - assert.Equal(t, pages.Next(pages[0]), pages[1]) - assert.Equal(t, pages.Next(pages[1]), pages[2]) - assert.Equal(t, pages.Next(pages[4]), pages[0]) -} - -func prepareWeightedPagesPrevNext(t *testing.T) WeightedPages { - s := newTestSite(t) - w := WeightedPages{} - - for _, src := range pagePNTestSources { - p, err := s.NewPage(src.path) - if err != nil { - t.Fatalf("failed to prepare test page %s", src.path) - } - p.Weight = src.weight - p.Date = cast.ToTime(src.date) - p.PublishDate = cast.ToTime(src.date) - w = append(w, WeightedPage{p.Weight, p}) - } - - w.Sort() - return w -} - -func TestWeightedPagesPrev(t *testing.T) { - t.Parallel() - w := prepareWeightedPagesPrevNext(t) - assert.Equal(t, w.Prev(w[0].Page), w[4].Page) - assert.Equal(t, w.Prev(w[1].Page), w[0].Page) - assert.Equal(t, w.Prev(w[4].Page), w[3].Page) -} - -func TestWeightedPagesNext(t *testing.T) { - t.Parallel() - w := prepareWeightedPagesPrevNext(t) - assert.Equal(t, w.Next(w[0].Page), w[1].Page) - assert.Equal(t, w.Next(w[1].Page), w[2].Page) - assert.Equal(t, w.Next(w[4].Page), w[0].Page) -} diff --git a/hugolib/pages_capture.go b/hugolib/pages_capture.go new file mode 100644 index 000000000..50900e585 --- /dev/null +++ b/hugolib/pages_capture.go @@ -0,0 +1,425 @@ +// 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 hugolib + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/bep/logg" + "github.com/gohugoio/hugo/common/hstrings" + "github.com/gohugoio/hugo/common/paths" + "github.com/gohugoio/hugo/common/rungroup" + "github.com/spf13/afero" + + "github.com/gohugoio/hugo/source" + + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/hugofs" +) + +func newPagesCollector( + ctx context.Context, + h *HugoSites, + sp *source.SourceSpec, + logger loggers.Logger, + infoLogger logg.LevelLogger, + m *pageMap, + buildConfig *BuildCfg, + ids []pathChange, +) *pagesCollector { + return &pagesCollector{ + ctx: ctx, + h: h, + fs: sp.BaseFs.Content.Fs, + m: m, + sp: sp, + logger: logger, + infoLogger: infoLogger, + buildConfig: buildConfig, + ids: ids, + seenDirs: make(map[string]bool), + } +} + +type pagesCollector struct { + ctx context.Context + h *HugoSites + sp *source.SourceSpec + logger loggers.Logger + infoLogger logg.LevelLogger + + m *pageMap + + fs afero.Fs + + buildConfig *BuildCfg + + // List of paths that have changed. Used in partial builds. + ids []pathChange + seenDirs map[string]bool + + g rungroup.Group[hugofs.FileMetaInfo] +} + +// Collect collects content by walking the file system and storing +// it in the content tree. +// It may be restricted by filenames set on the collector (partial build). +func (c *pagesCollector) Collect() (collectErr error) { + var ( + numWorkers = c.h.numWorkers + numFilesProcessedTotal atomic.Uint64 + numPagesProcessedTotal atomic.Uint64 + numResourcesProcessed atomic.Uint64 + numFilesProcessedLast uint64 + fileBatchTimer = time.Now() + fileBatchTimerMu sync.Mutex + ) + + l := c.infoLogger.WithField("substep", "collect") + + logFilesProcessed := func(force bool) { + fileBatchTimerMu.Lock() + if force || time.Since(fileBatchTimer) > 3*time.Second { + numFilesProcessedBatch := numFilesProcessedTotal.Load() - numFilesProcessedLast + numFilesProcessedLast = numFilesProcessedTotal.Load() + loggers.TimeTrackf(l, fileBatchTimer, + logg.Fields{ + logg.Field{Name: "files", Value: numFilesProcessedBatch}, + logg.Field{Name: "files_total", Value: numFilesProcessedTotal.Load()}, + logg.Field{Name: "pages_total", Value: numPagesProcessedTotal.Load()}, + logg.Field{Name: "resources_total", Value: numResourcesProcessed.Load()}, + }, + "", + ) + fileBatchTimer = time.Now() + } + fileBatchTimerMu.Unlock() + } + + defer func() { + logFilesProcessed(true) + }() + + c.g = rungroup.Run[hugofs.FileMetaInfo](c.ctx, rungroup.Config[hugofs.FileMetaInfo]{ + NumWorkers: numWorkers, + Handle: func(ctx context.Context, fi hugofs.FileMetaInfo) error { + numPages, numResources, err := c.m.AddFi(fi, c.buildConfig) + if err != nil { + return hugofs.AddFileInfoToError(err, fi, c.fs) + } + numFilesProcessedTotal.Add(1) + numPagesProcessedTotal.Add(numPages) + numResourcesProcessed.Add(numResources) + if numFilesProcessedTotal.Load()%1000 == 0 { + logFilesProcessed(false) + } + return nil + }, + }) + + if c.ids == nil { + // Collect everything. + collectErr = c.collectDir(nil, false, nil) + } else { + for _, s := range c.h.Sites { + s.pageMap.cfg.isRebuild = true + } + + var hasStructuralChange bool + for _, id := range c.ids { + if id.isStructuralChange() { + hasStructuralChange = true + break + } + } + + for _, id := range c.ids { + if id.p.IsLeafBundle() { + collectErr = c.collectDir( + id.p, + false, + func(fim hugofs.FileMetaInfo) bool { + if hasStructuralChange { + return true + } + fimp := fim.Meta().PathInfo + if fimp == nil { + return true + } + + return fimp.Path() == id.p.Path() + }, + ) + } else if id.p.IsBranchBundle() { + collectErr = c.collectDir( + id.p, + false, + func(fim hugofs.FileMetaInfo) bool { + if fim.IsDir() { + return id.isStructuralChange() + } + fimp := fim.Meta().PathInfo + if fimp == nil { + return false + } + + return strings.HasPrefix(fimp.Path(), paths.AddTrailingSlash(id.p.Dir())) + }, + ) + } else { + // We always start from a directory. + collectErr = c.collectDir(id.p, id.isDir, func(fim hugofs.FileMetaInfo) bool { + if id.isStructuralChange() { + if id.isDir && fim.Meta().PathInfo.IsLeafBundle() { + return strings.HasPrefix(fim.Meta().PathInfo.Path(), paths.AddTrailingSlash(id.p.Path())) + } + + return id.p.Dir() == fim.Meta().PathInfo.Dir() + } + + if fim.Meta().PathInfo.IsLeafBundle() && id.p.Type() == paths.TypeContentSingle { + return id.p.Dir() == fim.Meta().PathInfo.Dir() + } + + return id.p.Path() == fim.Meta().PathInfo.Path() + }) + } + + if collectErr != nil { + break + } + } + + } + + werr := c.g.Wait() + if collectErr == nil { + collectErr = werr + } + + return +} + +func (c *pagesCollector) collectDir(dirPath *paths.Path, isDir bool, inFilter func(fim hugofs.FileMetaInfo) bool) error { + var dpath string + if dirPath != nil { + if isDir { + dpath = filepath.FromSlash(dirPath.Unnormalized().Path()) + } else { + dpath = filepath.FromSlash(dirPath.Unnormalized().Dir()) + } + } + + if c.seenDirs[dpath] { + return nil + } + c.seenDirs[dpath] = true + + root, err := c.fs.Stat(dpath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + rootm := root.(hugofs.FileMetaInfo) + + if err := c.collectDirDir(dpath, rootm, inFilter); err != nil { + return err + } + + return nil +} + +func (c *pagesCollector) collectDirDir(path string, root hugofs.FileMetaInfo, inFilter func(fim hugofs.FileMetaInfo) bool) error { + filter := func(fim hugofs.FileMetaInfo) bool { + if inFilter != nil { + return inFilter(fim) + } + return true + } + + preHook := func(dir hugofs.FileMetaInfo, path string, readdir []hugofs.FileMetaInfo) ([]hugofs.FileMetaInfo, error) { + filtered := readdir[:0] + for _, fi := range readdir { + if filter(fi) { + filtered = append(filtered, fi) + } + } + readdir = filtered + if len(readdir) == 0 { + return nil, nil + } + + n := 0 + for _, fi := range readdir { + if fi.Meta().PathInfo.IsContentData() { + // _content.json + // These are not part of any bundle, so just add them directly and remove them from the readdir slice. + if err := c.g.Enqueue(fi); err != nil { + return nil, err + } + } else { + readdir[n] = fi + n++ + } + } + readdir = readdir[:n] + + // Pick the first regular file. + var first hugofs.FileMetaInfo + for _, fi := range readdir { + if fi.IsDir() { + continue + } + first = fi + break + } + + if first == nil { + // Only dirs, keep walking. + return readdir, nil + } + + // Any bundle file will always be first. + firstPi := first.Meta().PathInfo + + if firstPi == nil { + panic(fmt.Sprintf("collectDirDir: no path info for %q", first.Meta().Filename)) + } + + if firstPi.IsLeafBundle() { + if err := c.handleBundleLeaf(dir, first, path, readdir); err != nil { + return nil, err + } + return nil, filepath.SkipDir + } + + seen := map[hstrings.Strings2]hugofs.FileMetaInfo{} + for _, fi := range readdir { + if fi.IsDir() { + continue + } + + pi := fi.Meta().PathInfo + meta := fi.Meta() + + // Filter out duplicate page or resource. + // These would eventually have been filtered out as duplicates when + // inserting them into the document store, + // but doing it here will preserve a consistent ordering. + baseLang := hstrings.Strings2{pi.Base(), meta.Lang} + if fi2, ok := seen[baseLang]; ok { + if c.h.Configs.Base.PrintPathWarnings && !c.h.isRebuild() { + c.logger.Warnf("Duplicate content path: %q file: %q file: %q", pi.Base(), fi2.Meta().Filename, meta.Filename) + } + continue + } + seen[baseLang] = fi + + if pi == nil { + panic(fmt.Sprintf("no path info for %q", meta.Filename)) + } + + if meta.Lang == "" { + panic("lang not set") + } + + if err := c.g.Enqueue(fi); err != nil { + return nil, err + } + } + + // Keep walking. + return readdir, nil + } + + var postHook hugofs.WalkHook + + wfn := func(path string, fi hugofs.FileMetaInfo) error { + return nil + } + + w := hugofs.NewWalkway( + hugofs.WalkwayConfig{ + Logger: c.logger, + Root: path, + Info: root, + Fs: c.fs, + IgnoreFile: c.h.SourceSpec.IgnoreFile, + PathParser: c.h.Conf.PathParser(), + HookPre: preHook, + HookPost: postHook, + WalkFn: wfn, + }) + + return w.Walk() +} + +func (c *pagesCollector) handleBundleLeaf(dir, bundle hugofs.FileMetaInfo, inPath string, readdir []hugofs.FileMetaInfo) error { + bundlePi := bundle.Meta().PathInfo + seen := map[hstrings.Strings2]bool{} + + walk := func(path string, info hugofs.FileMetaInfo) error { + if info.IsDir() { + return nil + } + + pi := info.Meta().PathInfo + + if info != bundle { + // Everything inside a leaf bundle is a Resource, + // even the content pages. + // Note that we do allow index.md as page resources, but not in the bundle root. + if !pi.IsLeafBundle() || pi.Dir() != bundlePi.Dir() { + paths.ModifyPathBundleTypeResource(pi) + } + } + + // Filter out duplicate page or resource. + // These would eventually have been filtered out as duplicates when + // inserting them into the document store, + // but doing it here will preserve a consistent ordering. + baseLang := hstrings.Strings2{pi.Base(), info.Meta().Lang} + if seen[baseLang] { + return nil + } + seen[baseLang] = true + + return c.g.Enqueue(info) + } + + // Start a new walker from the given path. + w := hugofs.NewWalkway( + hugofs.WalkwayConfig{ + Root: inPath, + Fs: c.fs, + Logger: c.logger, + Info: dir, + DirEntries: readdir, + IgnoreFile: c.h.SourceSpec.IgnoreFile, + PathParser: c.h.Conf.PathParser(), + WalkFn: walk, + }) + + return w.Walk() +} diff --git a/hugolib/pages_language_merge_test.go b/hugolib/pages_language_merge_test.go index 3b55a6288..ba1ed83de 100644 --- a/hugolib/pages_language_merge_test.go +++ b/hugolib/pages_language_merge_test.go @@ -1,4 +1,4 @@ -// Copyright 2018 The Hugo Authors. All rights reserved. +// 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. @@ -17,12 +17,15 @@ import ( "fmt" "testing" - "github.com/stretchr/testify/require" + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/resources/resource" ) +// TODO(bep) move and rewrite in resource/page. + func TestMergeLanguages(t *testing.T) { t.Parallel() - assert := require.New(t) + c := qt.New(t) b := newTestSiteForLanguageMerge(t, 30) b.CreateSites() @@ -35,38 +38,53 @@ func TestMergeLanguages(t *testing.T) { frSite := h.Sites[1] nnSite := h.Sites[2] - assert.Equal(30, len(enSite.RegularPages)) - assert.Equal(6, len(frSite.RegularPages)) - assert.Equal(11, len(nnSite.RegularPages)) + c.Assert(len(enSite.RegularPages()), qt.Equals, 31) + c.Assert(len(frSite.RegularPages()), qt.Equals, 6) + c.Assert(len(nnSite.RegularPages()), qt.Equals, 12) - for i := 0; i < 2; i++ { - mergedNN := nnSite.RegularPages.MergeByLanguage(enSite.RegularPages) - assert.Equal(30, len(mergedNN)) - for i := 1; i <= 30; i++ { + for range 2 { + mergedNN := nnSite.RegularPages().MergeByLanguage(enSite.RegularPages()) + c.Assert(len(mergedNN), qt.Equals, 31) + for i := 1; i <= 31; i++ { expectedLang := "en" - if i == 2 || i%3 == 0 { + if i == 2 || i%3 == 0 || i == 31 { expectedLang = "nn" } p := mergedNN[i-1] - assert.Equal(expectedLang, p.Lang(), fmt.Sprintf("Test %d", i)) + c.Assert(p.Language().Lang, qt.Equals, expectedLang) } } - mergedFR := frSite.RegularPages.MergeByLanguage(enSite.RegularPages) - assert.Equal(30, len(mergedFR)) - for i := 1; i <= 30; i++ { + mergedFR := frSite.RegularPages().MergeByLanguage(enSite.RegularPages()) + c.Assert(len(mergedFR), qt.Equals, 31) + for i := 1; i <= 31; i++ { expectedLang := "en" if i%5 == 0 { expectedLang = "fr" } p := mergedFR[i-1] - assert.Equal(expectedLang, p.Lang(), fmt.Sprintf("Test %d", i)) + c.Assert(p.Language().Lang, qt.Equals, expectedLang) } - firstNN := nnSite.RegularPages[0] - assert.Equal(4, len(firstNN.Sites())) - assert.Equal("en", firstNN.Sites().First().Language.Lang) + firstNN := nnSite.RegularPages()[0] + c.Assert(len(firstNN.Sites()), qt.Equals, 4) + c.Assert(firstNN.Sites().Default().Language().Lang, qt.Equals, "en") + nnBundle := nnSite.getPageOldVersion("page", "bundle") + enBundle := enSite.getPageOldVersion("page", "bundle") + + c.Assert(len(enBundle.Resources()), qt.Equals, 6) + c.Assert(len(nnBundle.Resources()), qt.Equals, 2) + + var ri any = nnBundle.Resources() + + // This looks less ugly in the templates ... + mergedNNResources := ri.(resource.ResourcesLanguageMerger).MergeByLanguage(enBundle.Resources()) + c.Assert(len(mergedNNResources), qt.Equals, 6) + + unchanged, err := nnSite.RegularPages().MergeByLanguageInterface(nil) + c.Assert(err, qt.IsNil) + c.Assert(unchanged, deepEqualsPages, nnSite.RegularPages()) } func TestMergeLanguagesTemplate(t *testing.T) { @@ -76,13 +94,23 @@ func TestMergeLanguagesTemplate(t *testing.T) { b.WithTemplates("home.html", ` {{ $pages := .Site.RegularPages }} {{ .Scratch.Set "pages" $pages }} -{{ if eq .Lang "nn" }}: {{ $enSite := index .Sites 0 }} {{ $frSite := index .Sites 1 }} +{{ if eq .Language.Lang "nn" }}: +{{ $nnBundle := .Site.GetPage "page" "bundle" }} +{{ $enBundle := $enSite.GetPage "page" "bundle" }} {{ .Scratch.Set "pages" ($pages | lang.Merge $frSite.RegularPages| lang.Merge $enSite.RegularPages) }} +{{ .Scratch.Set "pages2" (sort ($nnBundle.Resources | lang.Merge $enBundle.Resources) "Title") }} {{ end }} {{ $pages := .Scratch.Get "pages" }} -{{ range $i, $p := $pages }}{{ add $i 1 }}: {{ .Path }} {{ .Lang }} | {{ end }} +{{ $pages2 := .Scratch.Get "pages2" }} +Pages1: {{ range $i, $p := $pages }}{{ add $i 1 }}: {{ .File.Path }} {{ .Language.Lang }} | {{ end }} +Pages2: {{ range $i, $p := $pages2 }}{{ add $i 1 }}: {{ .Title }} {{ .Language.Lang }} | {{ end }} +{{ $nil := resources.Get "asdfasdfasdf" }} +Pages3: {{ $frSite.RegularPages | lang.Merge $nil }} +Pages4: {{ $nil | lang.Merge $frSite.RegularPages }} + + `, "shortcodes/shortcode.html", "MyShort", "shortcodes/lingo.html", "MyLingo", @@ -91,7 +119,12 @@ func TestMergeLanguagesTemplate(t *testing.T) { b.CreateSites() b.Build(BuildCfg{}) - b.AssertFileContent("public/nn/index.html", "p1.md en | 2: p2.nn.md nn | 3: p3.nn.md nn | 4: p4.md en | 5: p5.fr.md fr | 6: p6.nn.md nn | 7: p7.md en | 8: p8.md en | 9: p9.nn.md nn | 10: p10.fr.md fr | 11: p11.md en | 12: p12.nn.md nn | 13: p13.md en | 14: p14.md en | 15: p15.nn.md nn") + b.AssertFileContent("public/nn/index.html", "Pages1: 1: p1.md en | 2: p2.nn.md nn | 3: p3.nn.md nn | 4: p4.md en | 5: p5.fr.md fr | 6: p6.nn.md nn | 7: p7.md en | 8: p8.md en | 9: p9.nn.md nn | 10: p10.fr.md fr | 11: p11.md en | 12: p12.nn.md nn | 13: p13.md en | 14: p14.md en | 15: p15.nn.md nn") + b.AssertFileContent("public/nn/index.html", "Pages2: 1: doc100 en | 2: doc101 nn | 3: doc102 nn | 4: doc103 en | 5: doc104 en | 6: doc105 en") + b.AssertFileContent("public/nn/index.html", ` +Pages3: Pages(3) +Pages4: Pages(3) + `) } func newTestSiteForLanguageMerge(t testing.TB, count int) *sitesBuilder { @@ -126,6 +159,18 @@ date: "2018-02-28" } } + // See https://github.com/gohugoio/hugo/issues/4644 + // Add a bundles + j := 100 + contentPairs = append(contentPairs, []string{"bundle/index.md", fmt.Sprintf(contentTemplate, j, j)}...) + for i := range 6 { + contentPairs = append(contentPairs, []string{fmt.Sprintf("bundle/pb%d.md", i), fmt.Sprintf(contentTemplate, i+j, i+j)}...) + } + contentPairs = append(contentPairs, []string{"bundle/index.nn.md", fmt.Sprintf(contentTemplate, j, j)}...) + for i := 1; i < 3; i++ { + contentPairs = append(contentPairs, []string{fmt.Sprintf("bundle/pb%d.nn.md", i), fmt.Sprintf(contentTemplate, i+j, i+j)}...) + } + builder.WithContent(contentPairs...) return builder } @@ -133,7 +178,8 @@ date: "2018-02-28" func BenchmarkMergeByLanguage(b *testing.B) { const count = 100 - builder := newTestSiteForLanguageMerge(b, count) + // newTestSiteForLanguageMerge creates count+1 pages. + builder := newTestSiteForLanguageMerge(b, count-1) builder.CreateSites() builder.Build(BuildCfg{SkipRender: true}) h := builder.H @@ -142,7 +188,7 @@ func BenchmarkMergeByLanguage(b *testing.B) { nnSite := h.Sites[2] for i := 0; i < b.N; i++ { - merged := nnSite.RegularPages.MergeByLanguage(enSite.RegularPages) + merged := nnSite.RegularPages().MergeByLanguage(enSite.RegularPages()) if len(merged) != count { b.Fatal("Count mismatch") } diff --git a/hugolib/pages_related.go b/hugolib/pages_related.go deleted file mode 100644 index 03abf0a58..000000000 --- a/hugolib/pages_related.go +++ /dev/null @@ -1,191 +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 hugolib - -import ( - "sync" - - "github.com/gohugoio/hugo/common/types" - "github.com/gohugoio/hugo/related" - "github.com/spf13/cast" -) - -var ( - // Assert that Pages and PageGroup implements the PageGenealogist interface. - _ PageGenealogist = (Pages)(nil) - _ PageGenealogist = PageGroup{} -) - -// A PageGenealogist finds related pages in a page collection. This interface is implemented -// by Pages and PageGroup, which makes it available as `{{ .RegularPages.Related . }}` etc. -type PageGenealogist interface { - - // Template example: - // {{ $related := .RegularPages.Related . }} - Related(doc related.Document) (Pages, error) - - // Template example: - // {{ $related := .RegularPages.RelatedIndices . "tags" "date" }} - RelatedIndices(doc related.Document, indices ...interface{}) (Pages, error) - - // Template example: - // {{ $related := .RegularPages.RelatedTo ( keyVals "tags" "hugo", "rocks") ( keyVals "date" .Date ) }} - RelatedTo(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) { - page, err := unwrapPage(doc) - if err != nil { - return nil, err - } - - result, err := p.searchDoc(page) - if err != nil { - return nil, err - } - - return result.removeFirstIfFound(page), 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) { - page, err := unwrapPage(doc) - if err != nil { - return nil, err - } - - indicesStr, err := cast.ToStringSliceE(indices) - if err != nil { - return nil, err - } - - result, err := p.searchDoc(page, indicesStr...) - if err != nil { - return nil, err - } - - return result.removeFirstIfFound(page), nil - -} - -// RelatedTo searches the given indices with the corresponding values. -func (p Pages) RelatedTo(args ...types.KeyValues) (Pages, error) { - if len(p) == 0 { - return nil, nil - } - - return p.search(args...) - -} - -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) withInvertedIndex(search func(idx *related.InvertedIndex) ([]related.Document, error)) (Pages, error) { - if len(p) == 0 { - return nil, nil - } - - cache := p[0].s.relatedDocsHandler - - searchIndex, err := cache.getOrCreateIndex(p) - if err != nil { - return nil, err - } - - result, err := search(searchIndex) - if err != nil { - return nil, err - } - - if len(result) > 0 { - mp := make(Pages, len(result)) - for i, match := range result { - mp[i] = match.(*Page) - } - return mp, nil - } - - return nil, nil -} - -type cachedPostingList struct { - p Pages - - postingList *related.InvertedIndex -} - -type relatedDocsHandler struct { - // This is configured in site or langugage config. - cfg related.Config - - postingLists []*cachedPostingList - mu sync.RWMutex -} - -func newSearchIndexHandler(cfg related.Config) *relatedDocsHandler { - return &relatedDocsHandler{cfg: cfg} -} - -// This assumes that a lock has been acquired. -func (s *relatedDocsHandler) getIndex(p Pages) *related.InvertedIndex { - for _, ci := range s.postingLists { - if fastEqualPages(p, ci.p) { - return ci.postingList - } - } - return nil -} - -func (s *relatedDocsHandler) getOrCreateIndex(p Pages) (*related.InvertedIndex, error) { - s.mu.RLock() - cachedIndex := s.getIndex(p) - if cachedIndex != nil { - s.mu.RUnlock() - return cachedIndex, nil - } - s.mu.RUnlock() - - s.mu.Lock() - defer s.mu.Unlock() - - if cachedIndex := s.getIndex(p); cachedIndex != nil { - return cachedIndex, nil - } - - searchIndex := related.NewInvertedIndex(s.cfg) - - for _, page := range p { - if err := searchIndex.Add(page); err != nil { - return nil, err - } - } - - s.postingLists = append(s.postingLists, &cachedPostingList{p: p, postingList: searchIndex}) - - return searchIndex, nil -} diff --git a/hugolib/pages_related_test.go b/hugolib/pages_related_test.go deleted file mode 100644 index ed8d9df9d..000000000 --- a/hugolib/pages_related_test.go +++ /dev/null @@ -1,75 +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 hugolib - -import ( - "fmt" - "path/filepath" - "testing" - - "github.com/gohugoio/hugo/common/types" - "github.com/gohugoio/hugo/deps" - - "github.com/stretchr/testify/require" -) - -func TestRelated(t *testing.T) { - assert := require.New(t) - - t.Parallel() - - var ( - cfg, fs = newTestCfg() - //th = testHelper{cfg, fs, t} - ) - - pageTmpl := `--- -title: Page %d -keywords: [%s] -date: %s ---- - -Content -` - - writeSource(t, fs, filepath.Join("content", "page1.md"), fmt.Sprintf(pageTmpl, 1, "hugo, says", "2017-01-03")) - writeSource(t, fs, filepath.Join("content", "page2.md"), fmt.Sprintf(pageTmpl, 2, "hugo, rocks", "2017-01-02")) - writeSource(t, fs, filepath.Join("content", "page3.md"), fmt.Sprintf(pageTmpl, 3, "bep, says", "2017-01-01")) - - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{SkipRender: true}) - assert.Len(s.RegularPages, 3) - - result, err := s.RegularPages.RelatedTo(types.NewKeyValuesStrings("keywords", "hugo", "rocks")) - - assert.NoError(err) - assert.Len(result, 2) - assert.Equal("Page 2", result[0].title) - assert.Equal("Page 1", result[1].title) - - result, err = s.RegularPages.Related(s.RegularPages[0]) - assert.Len(result, 2) - assert.Equal("Page 2", result[0].title) - assert.Equal("Page 3", result[1].title) - - result, err = s.RegularPages.RelatedIndices(s.RegularPages[0], "keywords") - assert.Len(result, 2) - assert.Equal("Page 2", result[0].title) - assert.Equal("Page 3", result[1].title) - - result, err = s.RegularPages.RelatedTo(types.NewKeyValuesStrings("keywords", "bep", "rocks")) - assert.NoError(err) - assert.Len(result, 2) - assert.Equal("Page 2", result[0].title) - assert.Equal("Page 3", result[1].title) -} diff --git a/hugolib/pages_test.go b/hugolib/pages_test.go new file mode 100644 index 000000000..30e9e59d2 --- /dev/null +++ b/hugolib/pages_test.go @@ -0,0 +1,119 @@ +package hugolib + +import ( + "fmt" + "math/rand" + "testing" + + "github.com/gohugoio/hugo/resources/page" + + qt "github.com/frankban/quicktest" +) + +func newPagesPrevNextTestSite(t testing.TB, numPages int) *sitesBuilder { + categories := []string{"blue", "green", "red", "orange", "indigo", "amber", "lime"} + cat1, cat2 := categories[rand.Intn(len(categories))], categories[rand.Intn(len(categories))] + categoriesSlice := fmt.Sprintf("[%q,%q]", cat1, cat2) + pageTemplate := ` +--- +title: "Page %d" +weight: %d +categories: %s +--- + +` + b := newTestSitesBuilder(t) + + for i := 1; i <= numPages; i++ { + b.WithContent(fmt.Sprintf("page%d.md", i), fmt.Sprintf(pageTemplate, i, rand.Intn(numPages), categoriesSlice)) + } + + return b +} + +func TestPagesPrevNext(t *testing.T) { + b := newPagesPrevNextTestSite(t, 100) + b.Build(BuildCfg{SkipRender: true}) + + pages := b.H.Sites[0].RegularPages() + + b.Assert(pages, qt.HasLen, 100) + + for _, p := range pages { + msg := qt.Commentf("w=%d", p.Weight()) + b.Assert(pages.Next(p), qt.Equals, p.Next(), msg) + b.Assert(pages.Prev(p), qt.Equals, p.Prev(), msg) + } +} + +func BenchmarkPagesPrevNext(b *testing.B) { + type Variant struct { + name string + preparePages func(pages page.Pages) page.Pages + run func(p page.Page, pages page.Pages) + } + + shufflePages := func(pages page.Pages) page.Pages { + rand.Shuffle(len(pages), func(i, j int) { pages[i], pages[j] = pages[j], pages[i] }) + return pages + } + + for _, variant := range []Variant{ + {".Next", nil, func(p page.Page, pages page.Pages) { p.Next() }}, + {".Prev", nil, func(p page.Page, pages page.Pages) { p.Prev() }}, + {"Pages.Next", nil, func(p page.Page, pages page.Pages) { pages.Next(p) }}, + {"Pages.Prev", nil, func(p page.Page, pages page.Pages) { pages.Prev(p) }}, + {"Pages.Shuffled.Next", shufflePages, func(p page.Page, pages page.Pages) { pages.Next(p) }}, + {"Pages.Shuffled.Prev", shufflePages, func(p page.Page, pages page.Pages) { pages.Prev(p) }}, + {"Pages.ByTitle.Next", func(pages page.Pages) page.Pages { return pages.ByTitle() }, func(p page.Page, pages page.Pages) { pages.Next(p) }}, + } { + for _, numPages := range []int{300, 5000} { + b.Run(fmt.Sprintf("%s-pages-%d", variant.name, numPages), func(b *testing.B) { + b.StopTimer() + builder := newPagesPrevNextTestSite(b, numPages) + builder.Build(BuildCfg{SkipRender: true}) + pages := builder.H.Sites[0].RegularPages() + if variant.preparePages != nil { + pages = variant.preparePages(pages) + } + b.StartTimer() + for i := 0; i < b.N; i++ { + p := pages[rand.Intn(len(pages))] + variant.run(p, pages) + } + }) + } + } +} + +func BenchmarkPagePageCollections(b *testing.B) { + type Variant struct { + name string + run func(p page.Page) + } + + for _, variant := range []Variant{ + {".Pages", func(p page.Page) { p.Pages() }}, + {".RegularPages", func(p page.Page) { p.RegularPages() }}, + {".RegularPagesRecursive", func(p page.Page) { p.RegularPagesRecursive() }}, + } { + for _, numPages := range []int{300, 5000} { + b.Run(fmt.Sprintf("%s-%d", variant.name, numPages), func(b *testing.B) { + b.StopTimer() + builder := newPagesPrevNextTestSite(b, numPages) + builder.Build(BuildCfg{SkipRender: true}) + var pages page.Pages + for _, p := range builder.H.Sites[0].Pages() { + if !p.IsPage() { + pages = append(pages, p) + } + } + b.StartTimer() + for i := 0; i < b.N; i++ { + p := pages[rand.Intn(len(pages))] + variant.run(p) + } + }) + } + } +} diff --git a/hugolib/pagesfromdata/pagesfromgotmpl.go b/hugolib/pagesfromdata/pagesfromgotmpl.go new file mode 100644 index 000000000..72909a40b --- /dev/null +++ b/hugolib/pagesfromdata/pagesfromgotmpl.go @@ -0,0 +1,340 @@ +// 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 pagesfromdata + +import ( + "context" + "fmt" + "io" + "path/filepath" + + "github.com/gohugoio/hugo/common/hashing" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/common/paths" + "github.com/gohugoio/hugo/helpers" + "github.com/gohugoio/hugo/hugofs" + "github.com/gohugoio/hugo/identity" + "github.com/gohugoio/hugo/resources/page" + "github.com/gohugoio/hugo/resources/page/pagemeta" + "github.com/gohugoio/hugo/resources/resource" + "github.com/gohugoio/hugo/tpl" + "github.com/gohugoio/hugo/tpl/tplimpl" + "github.com/mitchellh/mapstructure" + "github.com/spf13/cast" +) + +type PagesFromDataTemplateContext interface { + // AddPage adds a new page to the site. + // The first return value will always be an empty string. + AddPage(any) (string, error) + + // AddResource adds a new resource to the site. + // The first return value will always be an empty string. + AddResource(any) (string, error) + + // The site to which the pages will be added. + Site() page.Site + + // The same template may be executed multiple times for multiple languages. + // The Store can be used to store state between these invocations. + Store() *maps.Scratch + + // By default, the template will be executed for the language + // defined by the _content.gotmpl file (e.g. its mount definition). + // This method can be used to activate the template for all languages. + // The return value will always be an empty string. + EnableAllLanguages() string +} + +var _ PagesFromDataTemplateContext = (*pagesFromDataTemplateContext)(nil) + +type pagesFromDataTemplateContext struct { + p *PagesFromTemplate +} + +func (p *pagesFromDataTemplateContext) toPathMap(v any) (string, map[string]any, error) { + m, err := maps.ToStringMapE(v) + if err != nil { + return "", nil, err + } + pathv, ok := m["path"] + if !ok { + return "", nil, fmt.Errorf("path not set") + } + path, err := cast.ToStringE(pathv) + if err != nil || path == "" { + return "", nil, fmt.Errorf("invalid path %q", path) + } + return path, m, nil +} + +func (p *pagesFromDataTemplateContext) AddPage(v any) (string, error) { + path, m, err := p.toPathMap(v) + if err != nil { + return "", err + } + + if !p.p.buildState.checkHasChangedAndSetSourceInfo(path, m) { + return "", nil + } + + pd := pagemeta.DefaultPageConfig + pd.IsFromContentAdapter = true + pd.ContentAdapterData = m + + // The rest will be handled after the cascade is calculated and applied. + if err := mapstructure.WeakDecode(pd.ContentAdapterData, &pd.PageConfigEarly); err != nil { + err = fmt.Errorf("failed to decode page map: %w", err) + return "", err + } + + if err := pd.Validate(true); err != nil { + return "", err + } + + p.p.buildState.NumPagesAdded++ + + return "", p.p.HandlePage(p.p, &pd) +} + +func (p *pagesFromDataTemplateContext) AddResource(v any) (string, error) { + path, m, err := p.toPathMap(v) + if err != nil { + return "", err + } + + if !p.p.buildState.checkHasChangedAndSetSourceInfo(path, m) { + return "", nil + } + + var rd pagemeta.ResourceConfig + if err := mapstructure.WeakDecode(m, &rd); err != nil { + return "", err + } + + p.p.buildState.NumResourcesAdded++ + + if err := rd.Validate(); err != nil { + return "", err + } + + return "", p.p.HandleResource(p.p, &rd) +} + +func (p *pagesFromDataTemplateContext) Site() page.Site { + return p.p.Site +} + +func (p *pagesFromDataTemplateContext) Store() *maps.Scratch { + return p.p.store +} + +func (p *pagesFromDataTemplateContext) EnableAllLanguages() string { + p.p.buildState.EnableAllLanguages = true + return "" +} + +func NewPagesFromTemplate(opts PagesFromTemplateOptions) *PagesFromTemplate { + return &PagesFromTemplate{ + PagesFromTemplateOptions: opts, + PagesFromTemplateDeps: opts.DepsFromSite(opts.Site), + buildState: &BuildState{ + sourceInfosCurrent: maps.NewCache[string, *sourceInfo](), + }, + store: maps.NewScratch(), + } +} + +type PagesFromTemplateOptions struct { + Site page.Site + DepsFromSite func(page.Site) PagesFromTemplateDeps + + DependencyManager identity.Manager + + Watching bool + + HandlePage func(pt *PagesFromTemplate, p *pagemeta.PageConfig) error + HandleResource func(pt *PagesFromTemplate, p *pagemeta.ResourceConfig) error + + GoTmplFi hugofs.FileMetaInfo +} + +type PagesFromTemplateDeps struct { + TemplateStore *tplimpl.TemplateStore +} + +var _ resource.Staler = (*PagesFromTemplate)(nil) + +type PagesFromTemplate struct { + PagesFromTemplateOptions + PagesFromTemplateDeps + buildState *BuildState + store *maps.Scratch +} + +func (b *PagesFromTemplate) AddChange(id identity.Identity) { + b.buildState.ChangedIdentities = append(b.buildState.ChangedIdentities, id) +} + +func (b *PagesFromTemplate) MarkStale() { + b.buildState.StaleVersion++ +} + +func (b *PagesFromTemplate) StaleVersion() uint32 { + return b.buildState.StaleVersion +} + +type BuildInfo struct { + NumPagesAdded uint64 + NumResourcesAdded uint64 + EnableAllLanguages bool + ChangedIdentities []identity.Identity + DeletedPaths []string + Path *paths.Path +} + +type BuildState struct { + StaleVersion uint32 + + EnableAllLanguages bool + + // Paths deleted in the current build. + DeletedPaths []string + + // Changed identities in the current build. + ChangedIdentities []identity.Identity + + NumPagesAdded uint64 + NumResourcesAdded uint64 + + sourceInfosCurrent *maps.Cache[string, *sourceInfo] + sourceInfosPrevious *maps.Cache[string, *sourceInfo] +} + +func (b *BuildState) hash(v any) uint64 { + return hashing.HashUint64(v) +} + +func (b *BuildState) checkHasChangedAndSetSourceInfo(changedPath string, v any) bool { + h := b.hash(v) + si, found := b.sourceInfosPrevious.Get(changedPath) + if found { + b.sourceInfosCurrent.Set(changedPath, si) + if si.hash == h { + return false + } + } else { + si = &sourceInfo{} + b.sourceInfosCurrent.Set(changedPath, si) + } + si.hash = h + return true +} + +func (b *BuildState) resolveDeletedPaths() { + if b.sourceInfosPrevious == nil { + b.DeletedPaths = nil + return + } + var paths []string + b.sourceInfosPrevious.ForEeach(func(k string, _ *sourceInfo) bool { + if _, found := b.sourceInfosCurrent.Get(k); !found { + paths = append(paths, k) + } + return true + }) + + b.DeletedPaths = paths +} + +func (b *BuildState) PrepareNextBuild() { + b.sourceInfosPrevious = b.sourceInfosCurrent + b.sourceInfosCurrent = maps.NewCache[string, *sourceInfo]() + b.StaleVersion = 0 + b.DeletedPaths = nil + b.ChangedIdentities = nil + b.NumPagesAdded = 0 + b.NumResourcesAdded = 0 +} + +type sourceInfo struct { + hash uint64 +} + +func (p PagesFromTemplate) CloneForSite(s page.Site) *PagesFromTemplate { + // We deliberately make them share the same DependencyManager and Store. + p.PagesFromTemplateOptions.Site = s + p.PagesFromTemplateDeps = p.PagesFromTemplateOptions.DepsFromSite(s) + p.buildState = &BuildState{ + sourceInfosCurrent: maps.NewCache[string, *sourceInfo](), + } + return &p +} + +func (p PagesFromTemplate) CloneForGoTmpl(fi hugofs.FileMetaInfo) *PagesFromTemplate { + p.PagesFromTemplateOptions.GoTmplFi = fi + return &p +} + +func (p *PagesFromTemplate) GetDependencyManagerForScope(scope int) identity.Manager { + return p.DependencyManager +} + +func (p *PagesFromTemplate) GetDependencyManagerForScopesAll() []identity.Manager { + return []identity.Manager{p.DependencyManager} +} + +func (p *PagesFromTemplate) Execute(ctx context.Context) (BuildInfo, error) { + defer func() { + p.buildState.PrepareNextBuild() + }() + + f, err := p.GoTmplFi.Meta().Open() + if err != nil { + return BuildInfo{}, err + } + defer f.Close() + + tmpl, err := p.TemplateStore.TextParse(filepath.ToSlash(p.GoTmplFi.Meta().Filename), helpers.ReaderToString(f)) + if err != nil { + return BuildInfo{}, err + } + + data := &pagesFromDataTemplateContext{ + p: p, + } + + ctx = tpl.Context.DependencyManagerScopedProvider.Set(ctx, p) + + if err := p.TemplateStore.ExecuteWithContext(ctx, tmpl, io.Discard, data); err != nil { + return BuildInfo{}, err + } + + if p.Watching { + p.buildState.resolveDeletedPaths() + } + + bi := BuildInfo{ + NumPagesAdded: p.buildState.NumPagesAdded, + NumResourcesAdded: p.buildState.NumResourcesAdded, + EnableAllLanguages: p.buildState.EnableAllLanguages, + ChangedIdentities: p.buildState.ChangedIdentities, + DeletedPaths: p.buildState.DeletedPaths, + Path: p.GoTmplFi.Meta().PathInfo, + } + + return bi, nil +} + +////////////// diff --git a/hugolib/pagesfromdata/pagesfromgotmpl_integration_test.go b/hugolib/pagesfromdata/pagesfromgotmpl_integration_test.go new file mode 100644 index 000000000..db06fb4a4 --- /dev/null +++ b/hugolib/pagesfromdata/pagesfromgotmpl_integration_test.go @@ -0,0 +1,917 @@ +// Copyright 2025 The Hugo Authors. All 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 pagesfromdata_test + +import ( + "fmt" + "strings" + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/hugolib" + "github.com/gohugoio/hugo/markup/asciidocext" + "github.com/gohugoio/hugo/markup/pandoc" + "github.com/gohugoio/hugo/markup/rst" + "github.com/gohugoio/hugo/related" +) + +const filesPagesFromDataTempleBasic = ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "rss", "sitemap"] +baseURL = "https://example.com" +disableLiveReload = true +-- assets/a/pixel.png -- +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg== +-- assets/mydata.yaml -- +p1: "p1" +draft: false +-- layouts/partials/get-value.html -- +{{ $val := "p1" }} +{{ return $val }} +-- layouts/_default/baseof.html -- +Baseof: +{{ block "main" . }}{{ end }} +-- layouts/_default/single.html -- +{{ define "main" }} +Single: {{ .Title }}|{{ .Content }}|Params: {{ .Params.param1 }}|Path: {{ .Path }}| +Dates: Date: {{ .Date.Format "2006-01-02" }}|Lastmod: {{ .Lastmod.Format "2006-01-02" }}|PublishDate: {{ .PublishDate.Format "2006-01-02" }}|ExpiryDate: {{ .ExpiryDate.Format "2006-01-02" }}| +Len Resources: {{ .Resources | len }} +Resources: {{ range .Resources }}RelPermalink: {{ .RelPermalink }}|Name: {{ .Name }}|Title: {{ .Title }}|Params: {{ .Params }}|{{ end }}$ +{{ with .Resources.Get "featured.png" }} +Featured Image: {{ .RelPermalink }}|{{ .Name }}| +{{ with .Resize "10x10" }} +Resized Featured Image: {{ .RelPermalink }}|{{ .Width }}| +{{ end}} +{{ end }} +{{ end }} +-- layouts/_default/list.html -- +List: {{ .Title }}|{{ .Content }}| +RegularPagesRecursive: {{ range .RegularPagesRecursive }}{{ .Title }}:{{ .Path }}|{{ end }}$ +Sections: {{ range .Sections }}{{ .Title }}:{{ .Path }}|{{ end }}$ +-- content/docs/pfile.md -- +--- +title: "pfile" +date: 2023-03-01 +--- +Pfile Content +-- content/docs/_content.gotmpl -- +{{ $pixel := resources.Get "a/pixel.png" }} +{{ $dataResource := resources.Get "mydata.yaml" }} +{{ $data := $dataResource | transform.Unmarshal }} +{{ $pd := $data.p1 }} +{{ $pp := partial "get-value.html" }} +{{ $title := printf "%s:%s" $pd $pp }} +{{ $date := "2023-03-01" | time.AsTime }} +{{ $dates := dict "date" $date }} +{{ $keywords := slice "foo" "Bar"}} +{{ $contentMarkdown := dict "value" "**Hello World**" "mediaType" "text/markdown" }} +{{ $contentMarkdownDefault := dict "value" "**Hello World Default**" }} +{{ $contentHTML := dict "value" "<b>Hello World!</b> No **markdown** here." "mediaType" "text/html" }} +{{ $.AddPage (dict "kind" "page" "path" "P1" "title" $title "dates" $dates "keywords" $keywords "content" $contentMarkdown "params" (dict "param1" "param1v" ) ) }} +{{ $.AddPage (dict "kind" "page" "path" "p2" "title" "p2title" "dates" $dates "content" $contentHTML ) }} +{{ $.AddPage (dict "kind" "page" "path" "p3" "title" "p3title" "dates" $dates "content" $contentMarkdownDefault "draft" false ) }} +{{ $.AddPage (dict "kind" "page" "path" "p4" "title" "p4title" "dates" $dates "content" $contentMarkdownDefault "draft" $data.draft ) }} +ADD_MORE_PLACEHOLDER + + +{{ $resourceContent := dict "value" $dataResource }} +{{ $.AddResource (dict "path" "p1/data1.yaml" "content" $resourceContent) }} +{{ $.AddResource (dict "path" "p1/mytext.txt" "content" (dict "value" "some text") "name" "textresource" "title" "My Text Resource" "params" (dict "param1" "param1v") )}} +{{ $.AddResource (dict "path" "p1/sub/mytex2.txt" "content" (dict "value" "some text") "title" "My Text Sub Resource" ) }} +{{ $.AddResource (dict "path" "P1/Sub/MyMixCaseText2.txt" "content" (dict "value" "some text") "title" "My Text Sub Mixed Case Path Resource" ) }} +{{ $.AddResource (dict "path" "p1/sub/data1.yaml" "content" $resourceContent "title" "Sub data") }} +{{ $resourceParams := dict "data2ParaM1" "data2Param1v" }} +{{ $.AddResource (dict "path" "p1/data2.yaml" "name" "data2.yaml" "title" "My data 2" "params" $resourceParams "content" $resourceContent) }} +{{ $.AddResource (dict "path" "p1/featuredimage.png" "name" "featured.png" "title" "My Featured Image" "params" $resourceParams "content" (dict "value" $pixel ))}} +` + +func TestPagesFromGoTmplMisc(t *testing.T) { + t.Parallel() + b := hugolib.Test(t, filesPagesFromDataTempleBasic, hugolib.TestOptWarn()) + b.AssertLogContains("! WARN") + b.AssertPublishDir(` +docs/p1/mytext.txt +docs/p1/sub/mytex2.tx +docs/p1/sub/mymixcasetext2.txt + `) + + // Page from markdown file. + b.AssertFileContent("public/docs/pfile/index.html", "Dates: Date: 2023-03-01|Lastmod: 2023-03-01|PublishDate: 2023-03-01|ExpiryDate: 0001-01-01|") + // Pages from gotmpl. + b.AssertFileContent("public/docs/p1/index.html", + "Single: p1:p1|", + "Path: /docs/p1|", + "<strong>Hello World</strong>", + "Params: param1v|", + "Len Resources: 7", + "RelPermalink: /mydata.yaml|Name: data1.yaml|Title: data1.yaml|Params: map[]|", + "RelPermalink: /mydata.yaml|Name: data2.yaml|Title: My data 2|Params: map[data2param1:data2Param1v]|", + "RelPermalink: /a/pixel.png|Name: featured.png|Title: My Featured Image|Params: map[data2param1:data2Param1v]|", + "RelPermalink: /docs/p1/sub/mytex2.txt|Name: sub/mytex2.txt|", + "RelPermalink: /docs/p1/sub/mymixcasetext2.txt|Name: sub/mymixcasetext2.txt|", + "RelPermalink: /mydata.yaml|Name: sub/data1.yaml|Title: Sub data|Params: map[]|", + "Featured Image: /a/pixel.png|featured.png|", + "Resized Featured Image: /a/pixel_hu_a32b3e361d55df1.png|10|", + // Resource from string + "RelPermalink: /docs/p1/mytext.txt|Name: textresource|Title: My Text Resource|Params: map[param1:param1v]|", + // Dates + "Dates: Date: 2023-03-01|Lastmod: 2023-03-01|PublishDate: 2023-03-01|ExpiryDate: 0001-01-01|", + ) + b.AssertFileContent("public/docs/p2/index.html", "Single: p2title|", "<b>Hello World!</b> No **markdown** here.") + b.AssertFileContent("public/docs/p3/index.html", "<strong>Hello World Default</strong>") +} + +func TestPagesFromGoTmplAsciidocAndSimilar(t *testing.T) { + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "rss", "sitemap"] +baseURL = "https://example.com" +[security] +[security.exec] +allow = ['asciidoctor', 'pandoc','rst2html', 'python'] +-- layouts/_default/single.html -- +|Content: {{ .Content }}|Title: {{ .Title }}|Path: {{ .Path }}| +-- content/docs/_content.gotmpl -- +{{ $.AddPage (dict "path" "asciidoc" "content" (dict "value" "Mark my words, #automation is essential#." "mediaType" "text/asciidoc" )) }} +{{ $.AddPage (dict "path" "pandoc" "content" (dict "value" "This ~~is deleted text.~~" "mediaType" "text/pandoc" )) }} +{{ $.AddPage (dict "path" "rst" "content" (dict "value" "This is *bold*." "mediaType" "text/rst" )) }} +{{ $.AddPage (dict "path" "org" "content" (dict "value" "the ability to use +strikethrough+ is a plus" "mediaType" "text/org" )) }} +{{ $.AddPage (dict "path" "nocontent" "title" "No Content" ) }} + + ` + + b := hugolib.Test(t, files) + + if asciidocext.Supports() { + b.AssertFileContent("public/docs/asciidoc/index.html", + "Mark my words, <mark>automation is essential</mark>", + "Path: /docs/asciidoc|", + ) + } + if pandoc.Supports() { + b.AssertFileContent("public/docs/pandoc/index.html", + "This <del>is deleted text.</del>", + "Path: /docs/pandoc|", + ) + } + + if rst.Supports() { + b.AssertFileContent("public/docs/rst/index.html", + "This is <em>bold</em>", + "Path: /docs/rst|", + ) + } + + b.AssertFileContent("public/docs/org/index.html", + "the ability to use <del>strikethrough</del> is a plus", + "Path: /docs/org|", + ) + + b.AssertFileContent("public/docs/nocontent/index.html", "|Content: |Title: No Content|Path: /docs/nocontent|") +} + +func TestPagesFromGoTmplAddPageErrors(t *testing.T) { + filesTemplate := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "rss", "sitemap"] +baseURL = "https://example.com" +-- content/docs/_content.gotmpl -- +{{ $.AddPage DICT }} +` + + t.Run("AddPage, missing Path", func(t *testing.T) { + files := strings.ReplaceAll(filesTemplate, "DICT", `(dict "kind" "page" "title" "p1")`) + b, err := hugolib.TestE(t, files) + b.Assert(err, qt.IsNotNil) + b.Assert(err.Error(), qt.Contains, "_content.gotmpl:1:4") + b.Assert(err.Error(), qt.Contains, "error calling AddPage: path not set") + }) + + t.Run("AddPage, path starting with slash", func(t *testing.T) { + files := strings.ReplaceAll(filesTemplate, "DICT", `(dict "kind" "page" "title" "p1" "path" "/foo")`) + b, err := hugolib.TestE(t, files) + b.Assert(err, qt.IsNotNil) + b.Assert(err.Error(), qt.Contains, `path "/foo" must not start with a /`) + }) + + t.Run("AddPage, lang set", func(t *testing.T) { + files := strings.ReplaceAll(filesTemplate, "DICT", `(dict "kind" "page" "path" "p1" "lang" "en")`) + b, err := hugolib.TestE(t, files) + b.Assert(err, qt.IsNotNil) + b.Assert(err.Error(), qt.Contains, "_content.gotmpl:1:4") + b.Assert(err.Error(), qt.Contains, "error calling AddPage: lang must not be set") + }) + + t.Run("Site methods not ready", func(t *testing.T) { + filesTemplate := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "rss", "sitemap"] +baseURL = "https://example.com" +-- content/docs/_content.gotmpl -- +{{ .Site.METHOD }} +` + + for _, method := range []string{"RegularPages", "Pages", "AllPages", "AllRegularPages", "Home", "Sections", "GetPage", "Menus", "MainSections", "Taxonomies"} { + t.Run(method, func(t *testing.T) { + files := strings.ReplaceAll(filesTemplate, "METHOD", method) + b, err := hugolib.TestE(t, files) + b.Assert(err, qt.IsNotNil) + b.Assert(err.Error(), qt.Contains, fmt.Sprintf("error calling %s: this method cannot be called before the site is fully initialized", method)) + }) + } + }) +} + +func TestPagesFromGoTmplAddResourceErrors(t *testing.T) { + filesTemplate := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "rss", "sitemap"] +baseURL = "https://example.com" +-- content/docs/_content.gotmpl -- +{{ $.AddResource DICT }} +` + + t.Run("missing Path", func(t *testing.T) { + files := strings.ReplaceAll(filesTemplate, "DICT", `(dict "name" "r1")`) + b, err := hugolib.TestE(t, files) + b.Assert(err, qt.IsNotNil) + b.Assert(err.Error(), qt.Contains, "error calling AddResource: path not set") + }) +} + +func TestPagesFromGoTmplEditGoTmpl(t *testing.T) { + t.Parallel() + b := hugolib.TestRunning(t, filesPagesFromDataTempleBasic) + b.EditFileReplaceAll("content/docs/_content.gotmpl", `"title" "p2title"`, `"title" "p2titleedited"`).Build() + b.AssertFileContent("public/docs/p2/index.html", "Single: p2titleedited|") + b.AssertFileContent("public/docs/index.html", "p2titleedited") +} + +func TestPagesFromGoTmplEditDataResource(t *testing.T) { + t.Parallel() + b := hugolib.TestRunning(t, filesPagesFromDataTempleBasic) + b.AssertRenderCountPage(7) + b.EditFileReplaceAll("assets/mydata.yaml", "p1: \"p1\"", "p1: \"p1edited\"").Build() + b.AssertFileContent("public/docs/p1/index.html", "Single: p1edited:p1|") + b.AssertFileContent("public/docs/index.html", "p1edited") + b.AssertRenderCountPage(3) +} + +func TestPagesFromGoTmplEditPartial(t *testing.T) { + t.Parallel() + b := hugolib.TestRunning(t, filesPagesFromDataTempleBasic) + b.EditFileReplaceAll("layouts/partials/get-value.html", "p1", "p1edited").Build() + b.AssertFileContent("public/docs/p1/index.html", "Single: p1:p1edited|") + b.AssertFileContent("public/docs/index.html", "p1edited") +} + +func TestPagesFromGoTmplRemovePage(t *testing.T) { + t.Parallel() + b := hugolib.TestRunning(t, filesPagesFromDataTempleBasic) + b.EditFileReplaceAll("content/docs/_content.gotmpl", `{{ $.AddPage (dict "kind" "page" "path" "p2" "title" "p2title" "dates" $dates "content" $contentHTML ) }}`, "").Build() + b.AssertFileContent("public/index.html", "RegularPagesRecursive: p1:p1:/docs/p1|p3title:/docs/p3|p4title:/docs/p4|pfile:/docs/pfile|$") +} + +func TestPagesFromGoTmplAddPage(t *testing.T) { + t.Parallel() + b := hugolib.TestRunning(t, filesPagesFromDataTempleBasic) + b.EditFileReplaceAll("content/docs/_content.gotmpl", "ADD_MORE_PLACEHOLDER", `{{ $.AddPage (dict "kind" "page" "path" "page_added" "title" "page_added_title" "dates" $dates "content" $contentHTML ) }}`).Build() + b.AssertFileExists("public/docs/page_added/index.html", true) + b.AssertFileContent("public/index.html", "RegularPagesRecursive: p1:p1:/docs/p1|p2title:/docs/p2|p3title:/docs/p3|p4title:/docs/p4|page_added_title:/docs/page_added|pfile:/docs/pfile|$") +} + +func TestPagesFromGoTmplDraftPage(t *testing.T) { + t.Parallel() + b := hugolib.TestRunning(t, filesPagesFromDataTempleBasic) + b.EditFileReplaceAll("content/docs/_content.gotmpl", `"draft" false`, `"draft" true`).Build() + b.AssertFileContent("public/index.html", "RegularPagesRecursive: p1:p1:/docs/p1|p2title:/docs/p2|p4title:/docs/p4|pfile:/docs/pfile|$") +} + +func TestPagesFromGoTmplDraftFlagFromResource(t *testing.T) { + t.Parallel() + b := hugolib.TestRunning(t, filesPagesFromDataTempleBasic) + b.EditFileReplaceAll("assets/mydata.yaml", `draft: false`, `draft: true`).Build() + b.AssertFileContent("public/index.html", "RegularPagesRecursive: p1:p1:/docs/p1|p2title:/docs/p2|p3title:/docs/p3|pfile:/docs/pfile|$") + b.EditFileReplaceAll("assets/mydata.yaml", `draft: true`, `draft: false`).Build() + b.AssertFileContent("public/index.html", "RegularPagesRecursive: p1:p1:/docs/p1|p2title:/docs/p2|p3title:/docs/p3|p4title:/docs/p4|pfile:/docs/pfile|$") +} + +func TestPagesFromGoTmplMovePage(t *testing.T) { + t.Parallel() + b := hugolib.TestRunning(t, filesPagesFromDataTempleBasic) + b.AssertFileContent("public/index.html", "RegularPagesRecursive: p1:p1:/docs/p1|p2title:/docs/p2|p3title:/docs/p3|p4title:/docs/p4|pfile:/docs/pfile|$") + b.EditFileReplaceAll("content/docs/_content.gotmpl", `"path" "p2"`, `"path" "p2moved"`).Build() + b.AssertFileContent("public/index.html", "RegularPagesRecursive: p1:p1:/docs/p1|p2title:/docs/p2moved|p3title:/docs/p3|p4title:/docs/p4|pfile:/docs/pfile|$") +} + +func TestPagesFromGoTmplRemoveGoTmpl(t *testing.T) { + t.Parallel() + b := hugolib.TestRunning(t, filesPagesFromDataTempleBasic) + b.AssertFileContent("public/index.html", + "RegularPagesRecursive: p1:p1:/docs/p1|p2title:/docs/p2|p3title:/docs/p3|p4title:/docs/p4|pfile:/docs/pfile|$", + "Sections: Docs:/docs|", + ) + b.AssertFileContent("public/docs/index.html", "RegularPagesRecursive: p1:p1:/docs/p1|p2title:/docs/p2|p3title:/docs/p3|p4title:/docs/p4|pfile:/docs/pfile|$") + b.RemoveFiles("content/docs/_content.gotmpl").Build() + // One regular page left. + b.AssertFileContent("public/index.html", + "RegularPagesRecursive: pfile:/docs/pfile|$", + "Sections: Docs:/docs|", + ) + b.AssertFileContent("public/docs/index.html", "RegularPagesRecursive: pfile:/docs/pfile|$") +} + +// Issue #13443. +func TestPagesFromGoRelatedKeywords(t *testing.T) { + t.Parallel() + b := hugolib.Test(t, filesPagesFromDataTempleBasic) + + p1 := b.H.Sites[0].RegularPages()[0] + icfg := related.IndexConfig{ + Name: "keywords", + } + k, err := p1.RelatedKeywords(icfg) + b.Assert(err, qt.IsNil) + b.Assert(k, qt.DeepEquals, icfg.StringsToKeywords("foo", "Bar")) + icfg.Name = "title" + k, err = p1.RelatedKeywords(icfg) + b.Assert(err, qt.IsNil) + b.Assert(k, qt.DeepEquals, icfg.StringsToKeywords("p1:p1")) +} + +func TestPagesFromGoTmplLanguagePerFile(t *testing.T) { + filesTemplate := ` +-- hugo.toml -- +defaultContentLanguage = "en" +defaultContentLanguageInSubdir = true +[languages] +[languages.en] +weight = 1 +title = "Title" +[languages.fr] +weight = 2 +title = "Titre" +disabled = DISABLE +-- layouts/_default/single.html -- +Single: {{ .Title }}|{{ .Content }}| +-- content/docs/_content.gotmpl -- +{{ $.AddPage (dict "kind" "page" "path" "p1" "title" "Title" ) }} +-- content/docs/_content.fr.gotmpl -- +{{ $.AddPage (dict "kind" "page" "path" "p1" "title" "Titre" ) }} +` + + for _, disable := range []bool{false, true} { + t.Run(fmt.Sprintf("disable=%t", disable), func(t *testing.T) { + b := hugolib.Test(t, strings.ReplaceAll(filesTemplate, "DISABLE", fmt.Sprintf("%t", disable))) + b.AssertFileContent("public/en/docs/p1/index.html", "Single: Title||") + b.AssertFileExists("public/fr/docs/p1/index.html", !disable) + if !disable { + b.AssertFileContent("public/fr/docs/p1/index.html", "Single: Titre||") + } + }) + } +} + +func TestPagesFromGoTmplDefaultPageSort(t *testing.T) { + t.Parallel() + files := ` +-- hugo.toml -- +defaultContentLanguage = "en" +-- layouts/index.html -- +{{ range site.RegularPages }}{{ .RelPermalink }}|{{ end}} +-- content/_content.gotmpl -- +{{ $.AddPage (dict "kind" "page" "path" "docs/_p22" "title" "A" ) }} +{{ $.AddPage (dict "kind" "page" "path" "docs/p12" "title" "A" ) }} +{{ $.AddPage (dict "kind" "page" "path" "docs/_p12" "title" "A" ) }} +-- content/docs/_content.gotmpl -- +{{ $.AddPage (dict "kind" "page" "path" "_p21" "title" "A" ) }} +{{ $.AddPage (dict "kind" "page" "path" "p11" "title" "A" ) }} +{{ $.AddPage (dict "kind" "page" "path" "_p11" "title" "A" ) }} +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", "/docs/_p11/|/docs/_p12/|/docs/_p21/|/docs/_p22/|/docs/p11/|/docs/p12/|") +} + +func TestPagesFromGoTmplEnableAllLanguages(t *testing.T) { + t.Parallel() + + filesTemplate := ` +-- hugo.toml -- +defaultContentLanguage = "en" +defaultContentLanguageInSubdir = true +[languages] +[languages.en] +weight = 1 +title = "Title" +[languages.fr] +title = "Titre" +weight = 2 +disabled = DISABLE +-- i18n/en.yaml -- +title: Title +-- i18n/fr.yaml -- +title: Titre +-- content/docs/_content.gotmpl -- +{{ .EnableAllLanguages }} +{{ $titleFromStore := .Store.Get "title" }} +{{ if not $titleFromStore }} + {{ $titleFromStore = "notfound"}} + {{ .Store.Set "title" site.Title }} +{{ end }} +{{ $title := printf "%s:%s:%s" site.Title (i18n "title") $titleFromStore }} +{{ $.AddPage (dict "kind" "page" "path" "p1" "title" $title ) }} +-- layouts/_default/single.html -- +Single: {{ .Title }}|{{ .Content }}| + +` + + for _, disable := range []bool{false, true} { + t.Run(fmt.Sprintf("disable=%t", disable), func(t *testing.T) { + b := hugolib.Test(t, strings.ReplaceAll(filesTemplate, "DISABLE", fmt.Sprintf("%t", disable))) + b.AssertFileExists("public/fr/docs/p1/index.html", !disable) + if !disable { + b.AssertFileContent("public/en/docs/p1/index.html", "Single: Title:Title:notfound||") + b.AssertFileContent("public/fr/docs/p1/index.html", "Single: Titre:Titre:Title||") + } + }) + } +} + +func TestPagesFromGoTmplMarkdownify(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "rss", "sitemap"] +baseURL = "https://example.com" +-- layouts/_default/single.html -- +|Content: {{ .Content }}|Title: {{ .Title }}|Path: {{ .Path }}| +-- content/docs/_content.gotmpl -- +{{ $content := "**Hello World**" | markdownify }} +{{ $.AddPage (dict "path" "p1" "content" (dict "value" $content "mediaType" "text/html" )) }} +` + + b, err := hugolib.TestE(t, files) + + // This currently fails. We should fix this, but that is not a trivial task, so do it later. + b.Assert(err, qt.IsNotNil) + b.Assert(err.Error(), qt.Contains, "error calling markdownify: this method cannot be called before the site is fully initialized") +} + +func TestPagesFromGoTmplResourceWithoutExtensionWithMediaTypeProvided(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "rss", "sitemap"] +baseURL = "https://example.com" +-- layouts/_default/single.html -- +|Content: {{ .Content }}|Title: {{ .Title }}|Path: {{ .Path }}| +{{ range .Resources }} +|RelPermalink: {{ .RelPermalink }}|Name: {{ .Name }}|Title: {{ .Title }}|Params: {{ .Params }}|MediaType: {{ .MediaType }}| +{{ end }} +-- content/docs/_content.gotmpl -- +{{ $.AddPage (dict "path" "p1" "content" (dict "value" "**Hello World**" "mediaType" "text/markdown" )) }} +{{ $.AddResource (dict "path" "p1/myresource" "content" (dict "value" "abcde" "mediaType" "text/plain" )) }} +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/docs/p1/index.html", "RelPermalink: /docs/p1/myresource|Name: myresource|Title: myresource|Params: map[]|MediaType: text/plain|") +} + +func TestPagesFromGoTmplCascade(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "rss", "sitemap"] +baseURL = "https://example.com" +-- layouts/_default/single.html -- +|Content: {{ .Content }}|Title: {{ .Title }}|Path: {{ .Path }}|Params: {{ .Params }}| +-- content/_content.gotmpl -- +{{ $cascade := dict "params" (dict "cascadeparam1" "cascadeparam1value" ) }} +{{ $.AddPage (dict "path" "docs" "kind" "section" "cascade" $cascade ) }} +{{ $.AddPage (dict "path" "docs/p1" "content" (dict "value" "**Hello World**" "mediaType" "text/markdown" )) }} + +` + b := hugolib.Test(t, files) + + b.AssertFileContent("public/docs/p1/index.html", "|Path: /docs/p1|Params: map[cascadeparam1:cascadeparam1value") +} + +func TestPagesFromGoBuildOptions(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "rss", "sitemap"] +baseURL = "https://example.com" +-- layouts/_default/single.html -- +|Content: {{ .Content }}|Title: {{ .Title }}|Path: {{ .Path }}|Params: {{ .Params }}| +-- content/_content.gotmpl -- +{{ $.AddPage (dict "path" "docs/p1" "content" (dict "value" "**Hello World**" "mediaType" "text/markdown" )) }} +{{ $never := dict "list" "never" "publishResources" false "render" "never" }} +{{ $.AddPage (dict "path" "docs/p2" "content" (dict "value" "**Hello World**" "mediaType" "text/markdown" ) "build" $never ) }} + + +` + b := hugolib.Test(t, files) + + b.AssertFileExists("public/docs/p1/index.html", true) + b.AssertFileExists("public/docs/p2/index.html", false) +} + +func TestPagesFromGoPathsWithDotsIssue12493(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['home','section','rss','sitemap','taxonomy','term'] +-- content/_content.gotmpl -- +{{ .AddPage (dict "path" "s-1.2.3/p-4.5.6" "title" "p-4.5.6") }} +-- layouts/_default/single.html -- +{{ .Title }} +` + + b := hugolib.Test(t, files) + + b.AssertFileExists("public/s-1.2.3/p-4.5.6/index.html", true) +} + +func TestPagesFromGoParamsIssue12497(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['home','section','rss','sitemap','taxonomy','term'] +-- content/_content.gotmpl -- +{{ .AddPage (dict "path" "p1" "title" "p1" "params" (dict "paraM1" "param1v" )) }} +{{ .AddResource (dict "path" "p1/data1.yaml" "content" (dict "value" "data1" ) "params" (dict "paraM1" "param1v" )) }} +-- layouts/_default/single.html -- +{{ .Title }}|{{ .Params.paraM1 }} +{{ range .Resources }} +{{ .Name }}|{{ .Params.paraM1 }} +{{ end }} +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/p1/index.html", + "p1|param1v", + "data1.yaml|param1v", + ) +} + +func TestPagesFromGoTmplPathWarningsPathPage(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ['home','section','rss','sitemap','taxonomy','term'] +printPathWarnings = true +-- content/_content.gotmpl -- +{{ .AddPage (dict "path" "p1" "title" "p1" ) }} +{{ .AddPage (dict "path" "p2" "title" "p2" ) }} +-- content/p1.md -- +--- +title: "p1" +--- +-- layouts/_default/single.html -- +{{ .Title }}| +` + + b := hugolib.Test(t, files, hugolib.TestOptWarn()) + + b.AssertFileContent("public/p1/index.html", "p1|") + + b.AssertLogContains("Duplicate content path") + + files = strings.ReplaceAll(files, `"path" "p1"`, `"path" "p1new"`) + + b = hugolib.Test(t, files, hugolib.TestOptWarn()) + + b.AssertLogContains("! WARN") +} + +func TestPagesFromGoTmplPathWarningsPathResource(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ['home','section','rss','sitemap','taxonomy','term'] +printPathWarnings = true +-- content/_content.gotmpl -- +{{ .AddResource (dict "path" "p1/data1.yaml" "content" (dict "value" "data1" ) ) }} +{{ .AddResource (dict "path" "p1/data2.yaml" "content" (dict "value" "data2" ) ) }} + +-- content/p1/index.md -- +--- +title: "p1" +--- +-- content/p1/data1.yaml -- +value: data1 +-- layouts/_default/single.html -- +{{ .Title }}| +` + + b := hugolib.Test(t, files, hugolib.TestOptWarn()) + + b.AssertFileContent("public/p1/index.html", "p1|") + + b.AssertLogContains("Duplicate resource path") + + files = strings.ReplaceAll(files, `"path" "p1/data1.yaml"`, `"path" "p1/data1new.yaml"`) + + b = hugolib.Test(t, files, hugolib.TestOptWarn()) + + b.AssertLogContains("! WARN") +} + +func TestPagesFromGoTmplShortcodeNoPreceddingCharacterIssue12544(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['home','rss','section','sitemap','taxonomy','term'] +-- content/_content.gotmpl -- +{{ $content := dict "mediaType" "text/html" "value" "x{{< sc >}}" }} +{{ .AddPage (dict "content" $content "path" "a") }} + +{{ $content := dict "mediaType" "text/html" "value" "{{< sc >}}" }} +{{ .AddPage (dict "content" $content "path" "b") }} +-- layouts/_default/single.html -- +|{{ .Content }}| +-- layouts/shortcodes/sc.html -- +foo +{{- /**/ -}} +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/a/index.html", "|xfoo|") + b.AssertFileContent("public/b/index.html", "|foo|") // fails +} + +func TestPagesFromGoTmplMenus(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['rss','section','sitemap','taxonomy','term'] + +[menus] +[[menus.main]] +name = "Main" +[[menus.footer]] +name = "Footer" +-- content/_content.gotmpl -- +{{ .AddPage (dict "path" "p1" "title" "p1" "menus" "main" ) }} +{{ .AddPage (dict "path" "p2" "title" "p2" "menus" (slice "main" "footer")) }} +-- layouts/index.html -- +Main: {{ range index site.Menus.main }}{{ .Name }}|{{ end }}| +Footer: {{ range index site.Menus.footer }}{{ .Name }}|{{ end }}| + +` + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", + "Main: Main|p1|p2||", + "Footer: Footer|p2||", + ) +} + +// Issue 13384. +func TestPagesFromGoTmplMenusMap(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['rss','section','sitemap','taxonomy','term'] +-- content/_content.gotmpl -- +{{ $menu1 := dict + "parent" "main-page" + "identifier" "id1" +}} +{{ $menu2 := dict + "parent" "main-page" + "identifier" "id2" +}} +{{ $menus := dict "m1" $menu1 "m2" $menu2 }} +{{ .AddPage (dict "path" "p1" "title" "p1" "menus" $menus ) }} + +-- layouts/index.html -- +Menus: {{ range $k, $v := site.Menus }}{{ $k }}|{{ end }} + +` + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", "Menus: m1|m2|") +} + +func TestPagesFromGoTmplMore(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['home','rss','section','sitemap','taxonomy','term'] +[markup.goldmark.renderer] +unsafe = true +-- content/s1/_content.gotmpl -- +{{ $page := dict + "content" (dict "mediaType" "text/markdown" "value" "aaa <!--more--> bbb") + "title" "p1" + "path" "p1" + }} + {{ .AddPage $page }} +-- layouts/_default/single.html -- +summary: {{ .Summary }}|content: {{ .Content}} +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/s1/p1/index.html", + "<p>aaa</p>|content: <p>aaa</p>\n<p>bbb</p>", + ) +} + +// Issue 13063. +func TestPagesFromGoTmplTermIsEmpty(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ['section', 'home', 'rss','sitemap'] +printPathWarnings = true +[taxonomies] +tag = "tags" +-- content/mypost.md -- +--- +title: "My Post" +tags: ["mytag"] +--- +-- content/tags/_content.gotmpl -- +{{ .AddPage (dict "path" "mothertag" "title" "My title" "kind" "term") }} +-- +-- layouts/_default/taxonomy.html -- +Terms: {{ range .Data.Terms.ByCount }}{{ .Name }}: {{ .Count }}|{{ end }}§s +-- layouts/_default/single.html -- +Single. +` + + b := hugolib.Test(t, files, hugolib.TestOptWarn()) + + b.AssertFileContent("public/tags/index.html", "Terms: mytag: 1|§s") +} + +func TestContentAdapterOutputsIssue13689(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['home','rss','section','sitemap','taxonomy','term'] +[outputs] +page = ['html','json'] +-- layouts/page.html -- +html: {{ .Title }} +-- layouts/page.json -- +json: {{ .Title }} +-- content/p1.md -- +--- +title: p1 +--- +-- content/p2.md -- +--- +title: p2 +outputs: + - html +--- +-- content/_content.gotmpl -- +{{ $page := dict "path" "p3" "title" "p3" }} +{{ $.AddPage $page }} + +{{ $page := dict "path" "p4" "title" "p4" "outputs" (slice "html") }} +{{ $.AddPage $page }} +` + + b := hugolib.Test(t, files) + + b.AssertFileExists("public/p1/index.html", true) + b.AssertFileExists("public/p1/index.json", true) + b.AssertFileExists("public/p2/index.html", true) + b.AssertFileExists("public/p2/index.json", false) + b.AssertFileExists("public/p3/index.html", true) + b.AssertFileExists("public/p3/index.json", true) + b.AssertFileExists("public/p4/index.html", true) + b.AssertFileExists("public/p4/index.json", false) // currently returns true +} + +func TestContentAdapterOutputsIssue13692(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['page','home','sitemap','taxonomy','term'] +[[cascade]] +outputs = ['html','json'] +[cascade.target] +path = '{/s2,/s4}' +-- layouts/section.html -- +html: {{ .Title }} +-- layouts/section.json -- +json: {{ .Title }} +-- content/s1/_index.md -- +--- +title: s1 +--- +-- content/s2/_index.md -- +--- +title: s2 +--- +-- content/_content.gotmpl -- +{{ $page := dict "path" "s3" "title" "s3" "kind" "section" }} +{{ $.AddPage $page }} + +{{ $page := dict "path" "s4" "title" "s4" "kind" "section" }} +{{ $.AddPage $page }} + +{{ $page := dict "path" "s5" "title" "s5" "kind" "section" "outputs" (slice "html") }} + {{ $.AddPage $page }} +` + + b := hugolib.Test(t, files) + + b.AssertFileExists("public/s1/index.html", true) + b.AssertFileExists("public/s1/index.json", false) + b.AssertFileExists("public/s1/index.xml", true) + + b.AssertFileExists("public/s2/index.html", true) + b.AssertFileExists("public/s2/index.json", true) + b.AssertFileExists("public/s2/index.xml", false) + + b.AssertFileExists("public/s3/index.html", true) + b.AssertFileExists("public/s3/index.json", false) + b.AssertFileExists("public/s3/index.xml", true) + + b.AssertFileExists("public/s4/index.html", true) + b.AssertFileExists("public/s4/index.json", true) + b.AssertFileExists("public/s4/index.xml", false) + + b.AssertFileExists("public/s5/index.html", true) + b.AssertFileExists("public/s5/index.json", false) + b.AssertFileExists("public/s5/index.xml", false) +} + +func TestContentAdapterCascadeBasic(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableLiveReload = true +-- content/_index.md -- +--- +cascade: + - title: foo + target: + path: "**" +--- +-- layouts/all.html -- +Title: {{ .Title }}|Content: {{ .Content }}| +-- content/_content.gotmpl -- +{{ $content := dict + "mediaType" "text/markdown" + "value" "The _Hunchback of Notre Dame_ was written by Victor Hugo." +}} + +{{ $page := dict "path" "s1" "kind" "page" }} +{{ $.AddPage $page }} + {{ $page := dict "path" "s2" "kind" "page" "title" "bar" "content" $content }} +{{ $.AddPage $page }} + +` + + b := hugolib.TestRunning(t, files) + + b.AssertFileContent("public/s1/index.html", "Title: foo|") + b.AssertFileContent("public/s2/index.html", "Title: bar|", "Content: <p>The <em>Hunchback of Notre Dame</em> was written by Victor Hugo.</p>") + + b.EditFileReplaceAll("content/_index.md", "foo", "baz").Build() + + b.AssertFileContent("public/s1/index.html", "Title: baz|") +} diff --git a/hugolib/pagesfromdata/pagesfromgotmpl_test.go b/hugolib/pagesfromdata/pagesfromgotmpl_test.go new file mode 100644 index 000000000..c60b56dbf --- /dev/null +++ b/hugolib/pagesfromdata/pagesfromgotmpl_test.go @@ -0,0 +1,32 @@ +// 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 pagesfromdata + +import "testing" + +func BenchmarkHash(b *testing.B) { + m := map[string]any{ + "foo": "bar", + "bar": "foo", + "stringSlice": []any{"a", "b", "c"}, + "intSlice": []any{1, 2, 3}, + "largeText": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec a diam lectus. Sed sit amet ipsum mauris. Maecenas congue ligula ac quam viverra nec consectetur ante hendrerit.", + } + + bs := BuildState{} + + for i := 0; i < b.N; i++ { + bs.hash(m) + } +} diff --git a/hugolib/pagination.go b/hugolib/pagination.go deleted file mode 100644 index 86113271b..000000000 --- a/hugolib/pagination.go +++ /dev/null @@ -1,545 +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 hugolib - -import ( - "errors" - "fmt" - "html/template" - "math" - "reflect" - "strings" - - "github.com/gohugoio/hugo/config" - - "github.com/spf13/cast" -) - -// Pager represents one of the elements in a paginator. -// The number, starting on 1, represents its place. -type Pager struct { - number int - *paginator -} - -func (p Pager) String() string { - return fmt.Sprintf("Pager %d", p.number) -} - -type paginatedElement interface { - Len() int -} - -// Len returns the number of pages in the list. -func (p Pages) Len() int { - return len(p) -} - -// Len returns the number of pages in the page group. -func (psg PagesGroup) Len() int { - l := 0 - for _, pg := range psg { - l += len(pg.Pages) - } - return l -} - -type pagers []*Pager - -var ( - paginatorEmptyPages Pages - paginatorEmptyPageGroups PagesGroup -) - -type paginator struct { - paginatedElements []paginatedElement - pagers - paginationURLFactory - total int - size int - source interface{} - options []interface{} -} - -type paginationURLFactory func(int) string - -// PageNumber returns the current page's number in the pager sequence. -func (p *Pager) PageNumber() int { - return p.number -} - -// URL returns the URL to the current page. -func (p *Pager) URL() template.HTML { - return template.HTML(p.paginationURLFactory(p.PageNumber())) -} - -// Pages returns the Pages on this page. -// Note: If this return a non-empty result, then PageGroups() will return empty. -func (p *Pager) Pages() Pages { - if len(p.paginatedElements) == 0 { - return paginatorEmptyPages - } - - if pages, ok := p.element().(Pages); ok { - return pages - } - - return paginatorEmptyPages -} - -// PageGroups return Page groups for this page. -// Note: If this return non-empty result, then Pages() will return empty. -func (p *Pager) PageGroups() PagesGroup { - if len(p.paginatedElements) == 0 { - return paginatorEmptyPageGroups - } - - if groups, ok := p.element().(PagesGroup); ok { - return groups - } - - return paginatorEmptyPageGroups -} - -func (p *Pager) element() paginatedElement { - if len(p.paginatedElements) == 0 { - return paginatorEmptyPages - } - return p.paginatedElements[p.PageNumber()-1] -} - -// 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 - } - return nil, nil - } - - // must be PagesGroup - // this construction looks clumsy, but ... - // ... it is the difference between 99.5% and 100% test coverage :-) - groups := p.element().(PagesGroup) - - i := 0 - for _, v := range groups { - for _, page := range v.Pages { - if i == index { - return page, nil - } - i++ - } - } - return nil, nil -} - -// NumberOfElements gets the number of elements on this page. -func (p *Pager) NumberOfElements() int { - return p.element().Len() -} - -// HasPrev tests whether there are page(s) before the current. -func (p *Pager) HasPrev() bool { - return p.PageNumber() > 1 -} - -// Prev returns the pager for the previous page. -func (p *Pager) Prev() *Pager { - if !p.HasPrev() { - return nil - } - return p.pagers[p.PageNumber()-2] -} - -// HasNext tests whether there are page(s) after the current. -func (p *Pager) HasNext() bool { - return p.PageNumber() < len(p.paginatedElements) -} - -// Next returns the pager for the next page. -func (p *Pager) Next() *Pager { - if !p.HasNext() { - return nil - } - return p.pagers[p.PageNumber()] -} - -// First returns the pager for the first page. -func (p *Pager) First() *Pager { - return p.pagers[0] -} - -// Last returns the pager for the last page. -func (p *Pager) Last() *Pager { - return p.pagers[len(p.pagers)-1] -} - -// Pagers returns a list of pagers that can be used to build a pagination menu. -func (p *paginator) Pagers() pagers { - return p.pagers -} - -// PageSize returns the size of each paginator page. -func (p *paginator) PageSize() int { - return p.size -} - -// TotalPages returns the number of pages in the paginator. -func (p *paginator) TotalPages() int { - return len(p.paginatedElements) -} - -// TotalNumberOfElements returns the number of elements on all pages in this paginator. -func (p *paginator) TotalNumberOfElements() int { - return p.total -} - -func splitPages(pages Pages, size int) []paginatedElement { - var split []paginatedElement - for low, j := 0, len(pages); low < j; low += size { - high := int(math.Min(float64(low+size), float64(len(pages)))) - split = append(split, pages[low:high]) - } - - return split -} - -func splitPageGroups(pageGroups PagesGroup, size int) []paginatedElement { - - type keyPage struct { - key interface{} - page *Page - } - - var ( - split []paginatedElement - flattened []keyPage - ) - - for _, g := range pageGroups { - for _, p := range g.Pages { - flattened = append(flattened, keyPage{g.Key, p}) - } - } - - numPages := len(flattened) - - for low, j := 0, numPages; low < j; low += size { - high := int(math.Min(float64(low+size), float64(numPages))) - - var ( - pg PagesGroup - key interface{} - groupIndex = -1 - ) - - for k := low; k < high; k++ { - kp := flattened[k] - if key == nil || key != kp.key { - key = kp.key - pg = append(pg, PageGroup{Key: key}) - groupIndex++ - } - pg[groupIndex].Pages = append(pg[groupIndex].Pages, kp.page) - } - split = append(split, pg) - } - - return split -} - -// Paginator get this Page's main output's paginator. -func (p *Page) Paginator(options ...interface{}) (*Pager, error) { - return p.mainPageOutput.Paginator(options...) -} - -// Paginator gets this PageOutput's paginator if it's already created. -// If it's not, one will be created with all pages in Data["Pages"]. -func (p *PageOutput) Paginator(options ...interface{}) (*Pager, error) { - if !p.IsNode() { - return nil, fmt.Errorf("Paginators not supported for pages of type %q (%q)", p.Kind, p.title) - } - pagerSize, err := resolvePagerSize(p.s.Cfg, options...) - - if err != nil { - return nil, err - } - - var initError error - - p.paginatorInit.Do(func() { - if p.paginator != nil { - return - } - - pathDescriptor := p.targetPathDescriptor - if p.s.owner.IsMultihost() { - pathDescriptor.LangPrefix = "" - } - pagers, err := paginatePages(pathDescriptor, p.Data["Pages"], pagerSize) - - if err != nil { - initError = err - } - - if len(pagers) > 0 { - // the rest of the nodes will be created later - p.paginator = pagers[0] - p.paginator.source = "paginator" - p.paginator.options = options - } - - }) - - if initError != nil { - return nil, initError - } - - return p.paginator, nil -} - -// Paginate invokes this Page's main output's Paginate method. -func (p *Page) Paginate(seq interface{}, options ...interface{}) (*Pager, error) { - return p.mainPageOutput.Paginate(seq, options...) -} - -// Paginate gets this PageOutput's paginator if it's already created. -// If it's not, one will be created with the qiven sequence. -// Note that repeated calls will return the same result, even if the sequence is different. -func (p *PageOutput) Paginate(seq interface{}, options ...interface{}) (*Pager, error) { - if !p.IsNode() { - return nil, fmt.Errorf("Paginators not supported for pages of type %q (%q)", p.Kind, p.title) - } - - pagerSize, err := resolvePagerSize(p.s.Cfg, options...) - - if err != nil { - return nil, err - } - - var initError error - - p.paginatorInit.Do(func() { - if p.paginator != nil { - return - } - - pathDescriptor := p.targetPathDescriptor - if p.s.owner.IsMultihost() { - pathDescriptor.LangPrefix = "" - } - pagers, err := paginatePages(pathDescriptor, seq, pagerSize) - - if err != nil { - initError = err - } - - if len(pagers) > 0 { - // the rest of the nodes will be created later - p.paginator = pagers[0] - p.paginator.source = seq - p.paginator.options = options - } - - }) - - if initError != nil { - return nil, initError - } - - if p.paginator.source == "paginator" { - return nil, errors.New("a Paginator was previously built for this Node without filters; look for earlier .Paginator usage") - } - - if !reflect.DeepEqual(options, p.paginator.options) || !probablyEqualPageLists(p.paginator.source, seq) { - return nil, errors.New("invoked multiple times with different arguments") - } - - return p.paginator, nil -} - -func resolvePagerSize(cfg config.Provider, options ...interface{}) (int, error) { - if len(options) == 0 { - return cfg.GetInt("paginate"), nil - } - - if len(options) > 1 { - return -1, errors.New("too many arguments, 'pager size' is currently the only option") - } - - pas, err := cast.ToIntE(options[0]) - - if err != nil || pas <= 0 { - return -1, errors.New(("'pager size' must be a positive integer")) - } - - return pas, nil -} - -func paginatePages(td targetPathDescriptor, seq interface{}, pagerSize int) (pagers, error) { - - if pagerSize <= 0 { - return nil, errors.New("'paginate' configuration setting must be positive to paginate") - } - - urlFactory := newPaginationURLFactory(td) - - var paginator *paginator - - if groups, ok := seq.(PagesGroup); ok { - paginator, _ = newPaginatorFromPageGroups(groups, pagerSize, urlFactory) - } else { - pages, err := toPages(seq) - if err != nil { - return nil, err - } - paginator, _ = newPaginatorFromPages(pages, pagerSize, urlFactory) - } - - pagers := paginator.Pagers() - - return pagers, nil -} - -func toPages(seq interface{}) (Pages, error) { - if seq == nil { - return Pages{}, nil - } - - switch seq.(type) { - case Pages: - return seq.(Pages), nil - case *Pages: - return *(seq.(*Pages)), nil - case WeightedPages: - return (seq.(WeightedPages)).Pages(), nil - case PageGroup: - return (seq.(PageGroup)).Pages, nil - default: - return nil, fmt.Errorf("unsupported type in paginate, got %T", seq) - } -} - -// probablyEqual checks page lists for probable equality. -// 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 { - - if a1 == nil || a2 == nil { - return a1 == a2 - } - - t1 := reflect.TypeOf(a1) - t2 := reflect.TypeOf(a2) - - if t1 != t2 { - return false - } - - if g1, ok := a1.(PagesGroup); ok { - g2 := a2.(PagesGroup) - if len(g1) != len(g2) { - return false - } - if len(g1) == 0 { - return true - } - if g1.Len() != g2.Len() { - return false - } - - return g1[0].Pages[0] == g2[0].Pages[0] - } - - p1, err1 := toPages(a1) - p2, err2 := toPages(a2) - - // probably the same wrong type - if err1 != nil && err2 != nil { - return true - } - - if len(p1) != len(p2) { - return false - } - - if len(p1) == 0 { - return true - } - - return p1[0] == p2[0] -} - -func newPaginatorFromPages(pages Pages, size int, urlFactory paginationURLFactory) (*paginator, error) { - - if size <= 0 { - return nil, errors.New("Paginator size must be positive") - } - - split := splitPages(pages, size) - - return newPaginator(split, len(pages), size, urlFactory) -} - -func newPaginatorFromPageGroups(pageGroups PagesGroup, size int, urlFactory paginationURLFactory) (*paginator, error) { - - if size <= 0 { - return nil, errors.New("Paginator size must be positive") - } - - split := splitPageGroups(pageGroups, size) - - return newPaginator(split, pageGroups.Len(), size, urlFactory) -} - -func newPaginator(elements []paginatedElement, total, size int, urlFactory paginationURLFactory) (*paginator, error) { - p := &paginator{total: total, paginatedElements: elements, size: size, paginationURLFactory: urlFactory} - - var ps pagers - - if len(elements) > 0 { - ps = make(pagers, len(elements)) - for i := range p.paginatedElements { - ps[i] = &Pager{number: (i + 1), paginator: p} - } - } else { - ps = make(pagers, 1) - ps[0] = &Pager{number: 1, paginator: p} - } - - p.pagers = ps - - return p, nil -} - -func newPaginationURLFactory(d targetPathDescriptor) paginationURLFactory { - - return func(page int) string { - pathDescriptor := d - var rel string - if page > 1 { - rel = fmt.Sprintf("/%s/%d/", d.PathSpec.PaginatePath(), page) - pathDescriptor.Addends = rel - } - - targetPath := createTargetPath(pathDescriptor) - targetPath = strings.TrimSuffix(targetPath, d.Type.BaseFilename()) - link := d.PathSpec.PrependBasePath(targetPath) - // Note: The targetPath is massaged with MakePathSanitized - return d.PathSpec.URLizeFilename(link) - } -} diff --git a/hugolib/pagination_test.go b/hugolib/pagination_test.go deleted file mode 100644 index edfac3f3e..000000000 --- a/hugolib/pagination_test.go +++ /dev/null @@ -1,579 +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 hugolib - -import ( - "fmt" - "html/template" - "path/filepath" - "strings" - "testing" - - "github.com/gohugoio/hugo/deps" - "github.com/gohugoio/hugo/output" - "github.com/stretchr/testify/require" -) - -func TestSplitPages(t *testing.T) { - t.Parallel() - s := newTestSite(t) - - pages := createTestPages(s, 21) - chunks := splitPages(pages, 5) - require.Equal(t, 5, len(chunks)) - - for i := 0; i < 4; i++ { - require.Equal(t, 5, chunks[i].Len()) - } - - lastChunk := chunks[4] - require.Equal(t, 1, lastChunk.Len()) - -} - -func TestSplitPageGroups(t *testing.T) { - t.Parallel() - s := newTestSite(t) - pages := createTestPages(s, 21) - groups, _ := pages.GroupBy("Weight", "desc") - chunks := splitPageGroups(groups, 5) - require.Equal(t, 5, len(chunks)) - - firstChunk := chunks[0] - - // alternate weight 5 and 10 - if groups, ok := firstChunk.(PagesGroup); ok { - require.Equal(t, 5, groups.Len()) - for _, pg := range groups { - // first group 10 in weight - require.Equal(t, 10, pg.Key) - for _, p := range pg.Pages { - require.True(t, p.fuzzyWordCount%2 == 0) // magic test - } - } - } else { - t.Fatal("Excepted PageGroup") - } - - lastChunk := chunks[4] - - if groups, ok := lastChunk.(PagesGroup); ok { - require.Equal(t, 1, groups.Len()) - for _, pg := range groups { - // last should have 5 in weight - require.Equal(t, 5, pg.Key) - for _, p := range pg.Pages { - require.True(t, p.fuzzyWordCount%2 != 0) // magic test - } - } - } else { - t.Fatal("Excepted PageGroup") - } - -} - -func TestPager(t *testing.T) { - t.Parallel() - s := newTestSite(t) - pages := createTestPages(s, 21) - groups, _ := pages.GroupBy("Weight", "desc") - - urlFactory := func(page int) string { - return fmt.Sprintf("page/%d/", page) - } - - _, err := newPaginatorFromPages(pages, -1, urlFactory) - require.NotNil(t, err) - - _, err = newPaginatorFromPageGroups(groups, -1, urlFactory) - require.NotNil(t, err) - - pag, err := newPaginatorFromPages(pages, 5, urlFactory) - require.Nil(t, err) - doTestPages(t, pag) - first := pag.Pagers()[0].First() - require.Equal(t, "Pager 1", first.String()) - require.NotEmpty(t, first.Pages()) - require.Empty(t, first.PageGroups()) - - pag, err = newPaginatorFromPageGroups(groups, 5, urlFactory) - require.Nil(t, err) - doTestPages(t, pag) - first = pag.Pagers()[0].First() - require.NotEmpty(t, first.PageGroups()) - require.Empty(t, first.Pages()) - -} - -func doTestPages(t *testing.T, paginator *paginator) { - - paginatorPages := paginator.Pagers() - - require.Equal(t, 5, len(paginatorPages)) - require.Equal(t, 21, paginator.TotalNumberOfElements()) - require.Equal(t, 5, paginator.PageSize()) - require.Equal(t, 5, paginator.TotalPages()) - - first := paginatorPages[0] - require.Equal(t, template.HTML("page/1/"), first.URL()) - require.Equal(t, first, first.First()) - require.True(t, first.HasNext()) - require.Equal(t, paginatorPages[1], first.Next()) - require.False(t, first.HasPrev()) - require.Nil(t, first.Prev()) - require.Equal(t, 5, first.NumberOfElements()) - require.Equal(t, 1, first.PageNumber()) - - third := paginatorPages[2] - require.True(t, third.HasNext()) - require.True(t, third.HasPrev()) - require.Equal(t, paginatorPages[1], third.Prev()) - - last := paginatorPages[4] - require.Equal(t, template.HTML("page/5/"), last.URL()) - require.Equal(t, last, last.Last()) - require.False(t, last.HasNext()) - require.Nil(t, last.Next()) - require.True(t, last.HasPrev()) - require.Equal(t, 1, last.NumberOfElements()) - require.Equal(t, 5, last.PageNumber()) -} - -func TestPagerNoPages(t *testing.T) { - t.Parallel() - s := newTestSite(t) - pages := createTestPages(s, 0) - groups, _ := pages.GroupBy("Weight", "desc") - - urlFactory := func(page int) string { - return fmt.Sprintf("page/%d/", page) - } - - paginator, _ := newPaginatorFromPages(pages, 5, urlFactory) - doTestPagerNoPages(t, paginator) - - first := paginator.Pagers()[0].First() - require.Empty(t, first.PageGroups()) - require.Empty(t, first.Pages()) - - paginator, _ = newPaginatorFromPageGroups(groups, 5, urlFactory) - doTestPagerNoPages(t, paginator) - - first = paginator.Pagers()[0].First() - require.Empty(t, first.PageGroups()) - require.Empty(t, first.Pages()) - -} - -func doTestPagerNoPages(t *testing.T, paginator *paginator) { - paginatorPages := paginator.Pagers() - - require.Equal(t, 1, len(paginatorPages)) - require.Equal(t, 0, paginator.TotalNumberOfElements()) - require.Equal(t, 5, paginator.PageSize()) - require.Equal(t, 0, paginator.TotalPages()) - - // pageOne should be nothing but the first - pageOne := paginatorPages[0] - require.NotNil(t, pageOne.First()) - require.False(t, pageOne.HasNext()) - require.False(t, pageOne.HasPrev()) - require.Nil(t, pageOne.Next()) - require.Equal(t, 1, len(pageOne.Pagers())) - require.Equal(t, 0, pageOne.Pages().Len()) - require.Equal(t, 0, pageOne.NumberOfElements()) - require.Equal(t, 0, pageOne.TotalNumberOfElements()) - require.Equal(t, 0, pageOne.TotalPages()) - require.Equal(t, 1, pageOne.PageNumber()) - require.Equal(t, 5, pageOne.PageSize()) - -} - -func TestPaginationURLFactory(t *testing.T) { - t.Parallel() - cfg, fs := newTestCfg() - cfg.Set("paginatePath", "zoo") - - for _, uglyURLs := range []bool{false, true} { - for _, canonifyURLs := range []bool{false, true} { - t.Run(fmt.Sprintf("uglyURLs=%t,canonifyURLs=%t", uglyURLs, canonifyURLs), func(t *testing.T) { - - tests := []struct { - name string - d targetPathDescriptor - baseURL string - page int - expected string - }{ - {"HTML home page 32", - targetPathDescriptor{Kind: KindHome, Type: output.HTMLFormat}, "http://example.com/", 32, "/zoo/32/"}, - {"JSON home page 42", - targetPathDescriptor{Kind: KindHome, Type: output.JSONFormat}, "http://example.com/", 42, "/zoo/42/"}, - // Issue #1252 - {"BaseURL with sub path", - targetPathDescriptor{Kind: KindHome, Type: output.HTMLFormat}, "http://example.com/sub/", 999, "/sub/zoo/999/"}, - } - - for _, test := range tests { - d := test.d - cfg.Set("baseURL", test.baseURL) - cfg.Set("canonifyURLs", canonifyURLs) - cfg.Set("uglyURLs", uglyURLs) - d.UglyURLs = uglyURLs - - expected := test.expected - - if canonifyURLs { - expected = strings.Replace(expected, "/sub", "", 1) - } - - if uglyURLs { - expected = expected[:len(expected)-1] + "." + test.d.Type.MediaType.Suffix - } - - pathSpec := newTestPathSpec(fs, cfg) - d.PathSpec = pathSpec - - factory := newPaginationURLFactory(d) - - got := factory(test.page) - - require.Equal(t, expected, got) - - } - }) - } - } -} - -func TestPaginator(t *testing.T) { - t.Parallel() - for _, useViper := range []bool{false, true} { - doTestPaginator(t, useViper) - } -} - -func doTestPaginator(t *testing.T, useViper bool) { - - cfg, fs := newTestCfg() - - pagerSize := 5 - if useViper { - cfg.Set("paginate", pagerSize) - } else { - cfg.Set("paginate", -1) - } - - s, err := NewSiteForCfg(deps.DepsCfg{Cfg: cfg, Fs: fs}) - require.NoError(t, err) - - pages := createTestPages(s, 12) - n1, _ := newPageOutput(s.newHomePage(), false, output.HTMLFormat) - n2, _ := newPageOutput(s.newHomePage(), false, output.HTMLFormat) - n1.Data["Pages"] = pages - - var paginator1 *Pager - - if useViper { - paginator1, err = n1.Paginator() - } else { - paginator1, err = n1.Paginator(pagerSize) - } - - require.Nil(t, err) - require.NotNil(t, paginator1) - require.Equal(t, 3, paginator1.TotalPages()) - require.Equal(t, 12, paginator1.TotalNumberOfElements()) - - n2.paginator = paginator1.Next() - paginator2, err := n2.Paginator() - require.Nil(t, err) - require.Equal(t, paginator2, paginator1.Next()) - - n1.Data["Pages"] = createTestPages(s, 1) - samePaginator, _ := n1.Paginator() - require.Equal(t, paginator1, samePaginator) - - pp, _ := s.NewPage("test") - p, _ := newPageOutput(pp, false, output.HTMLFormat) - - _, err = p.Paginator() - require.NotNil(t, err) -} - -func TestPaginatorWithNegativePaginate(t *testing.T) { - t.Parallel() - s := newTestSite(t, "paginate", -1) - n1, _ := newPageOutput(s.newHomePage(), false, output.HTMLFormat) - _, err := n1.Paginator() - require.Error(t, err) -} - -func TestPaginate(t *testing.T) { - t.Parallel() - for _, useViper := range []bool{false, true} { - doTestPaginate(t, useViper) - } -} - -func TestPaginatorURL(t *testing.T) { - t.Parallel() - cfg, fs := newTestCfg() - - cfg.Set("paginate", 2) - cfg.Set("paginatePath", "testing") - - for i := 0; i < 10; i++ { - // Issue #2177, do not double encode URLs - writeSource(t, fs, filepath.Join("content", "阅读", fmt.Sprintf("page%d.md", (i+1))), - fmt.Sprintf(`--- -title: Page%d ---- -Conten%d -`, (i+1), i+1)) - - } - writeSource(t, fs, filepath.Join("layouts", "_default", "single.html"), "<html><body>{{.Content}}</body></html>") - writeSource(t, fs, filepath.Join("layouts", "_default", "list.html"), - ` -<html><body> -Count: {{ .Paginator.TotalNumberOfElements }} -Pages: {{ .Paginator.TotalPages }} -{{ range .Paginator.Pagers -}} - {{ .PageNumber }}: {{ .URL }} -{{ end }} -</body></html>`) - - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{}) - - th := testHelper{s.Cfg, s.Fs, t} - - th.assertFileContent(filepath.Join("public", "阅读", "testing", "2", "index.html"), "2: /%E9%98%85%E8%AF%BB/testing/2/") - -} - -func doTestPaginate(t *testing.T, useViper bool) { - pagerSize := 5 - - var ( - s *Site - err error - ) - - if useViper { - s = newTestSite(t, "paginate", pagerSize) - } else { - s = newTestSite(t, "paginate", -1) - } - - pages := createTestPages(s, 6) - n1, _ := newPageOutput(s.newHomePage(), false, output.HTMLFormat) - n2, _ := newPageOutput(s.newHomePage(), false, output.HTMLFormat) - - var paginator1, paginator2 *Pager - - if useViper { - paginator1, err = n1.Paginate(pages) - } else { - paginator1, err = n1.Paginate(pages, pagerSize) - } - - require.Nil(t, err) - require.NotNil(t, paginator1) - require.Equal(t, 2, paginator1.TotalPages()) - require.Equal(t, 6, paginator1.TotalNumberOfElements()) - - n2.paginator = paginator1.Next() - if useViper { - paginator2, err = n2.Paginate(pages) - } else { - paginator2, err = n2.Paginate(pages, pagerSize) - } - require.Nil(t, err) - require.Equal(t, paginator2, paginator1.Next()) - - pp, err := s.NewPage("test") - p, _ := newPageOutput(pp, false, output.HTMLFormat) - - _, err = p.Paginate(pages) - require.NotNil(t, err) -} - -func TestInvalidOptions(t *testing.T) { - t.Parallel() - s := newTestSite(t) - n1, _ := newPageOutput(s.newHomePage(), false, output.HTMLFormat) - - _, err := n1.Paginate(createTestPages(s, 1), 1, 2) - require.NotNil(t, err) - _, err = n1.Paginator(1, 2) - require.NotNil(t, err) - _, err = n1.Paginator(-1) - require.NotNil(t, err) -} - -func TestPaginateWithNegativePaginate(t *testing.T) { - t.Parallel() - cfg, fs := newTestCfg() - cfg.Set("paginate", -1) - - s, err := NewSiteForCfg(deps.DepsCfg{Cfg: cfg, Fs: fs}) - require.NoError(t, err) - - n, _ := newPageOutput(s.newHomePage(), false, output.HTMLFormat) - - _, err = n.Paginate(createTestPages(s, 2)) - require.NotNil(t, err) -} - -func TestPaginatePages(t *testing.T) { - t.Parallel() - s := newTestSite(t) - - groups, _ := createTestPages(s, 31).GroupBy("Weight", "desc") - pd := targetPathDescriptor{Kind: KindHome, Type: output.HTMLFormat, PathSpec: s.PathSpec, Addends: "t"} - - for i, seq := range []interface{}{createTestPages(s, 11), groups, WeightedPages{}, PageGroup{}, &Pages{}} { - v, err := paginatePages(pd, seq, 11) - require.NotNil(t, v, "Val %d", i) - require.Nil(t, err, "Err %d", i) - } - _, err := paginatePages(pd, Site{}, 11) - require.NotNil(t, err) - -} - -// Issue #993 -func TestPaginatorFollowedByPaginateShouldFail(t *testing.T) { - t.Parallel() - s := newTestSite(t, "paginate", 10) - n1, _ := newPageOutput(s.newHomePage(), false, output.HTMLFormat) - n2, _ := newPageOutput(s.newHomePage(), false, output.HTMLFormat) - - _, err := n1.Paginator() - require.Nil(t, err) - _, err = n1.Paginate(createTestPages(s, 2)) - require.NotNil(t, err) - - _, err = n2.Paginate(createTestPages(s, 2)) - require.Nil(t, err) - -} - -func TestPaginateFollowedByDifferentPaginateShouldFail(t *testing.T) { - t.Parallel() - s := newTestSite(t, "paginate", 10) - - n1, _ := newPageOutput(s.newHomePage(), false, output.HTMLFormat) - n2, _ := newPageOutput(s.newHomePage(), false, output.HTMLFormat) - - p1 := createTestPages(s, 2) - p2 := createTestPages(s, 10) - - _, err := n1.Paginate(p1) - require.Nil(t, err) - - _, err = n1.Paginate(p1) - require.Nil(t, err) - - _, err = n1.Paginate(p2) - require.NotNil(t, err) - - _, err = n2.Paginate(p2) - require.Nil(t, err) -} - -func TestProbablyEqualPageLists(t *testing.T) { - t.Parallel() - s := newTestSite(t) - fivePages := createTestPages(s, 5) - zeroPages := createTestPages(s, 0) - zeroPagesByWeight, _ := createTestPages(s, 0).GroupBy("Weight", "asc") - fivePagesByWeight, _ := createTestPages(s, 5).GroupBy("Weight", "asc") - ninePagesByWeight, _ := createTestPages(s, 9).GroupBy("Weight", "asc") - - for i, this := range []struct { - v1 interface{} - v2 interface{} - expect bool - }{ - {nil, nil, true}, - {"a", "b", true}, - {"a", fivePages, false}, - {fivePages, "a", false}, - {fivePages, createTestPages(s, 2), false}, - {fivePages, fivePages, true}, - {zeroPages, zeroPages, true}, - {fivePagesByWeight, fivePagesByWeight, true}, - {zeroPagesByWeight, fivePagesByWeight, false}, - {zeroPagesByWeight, zeroPagesByWeight, true}, - {fivePagesByWeight, fivePages, false}, - {fivePagesByWeight, ninePagesByWeight, false}, - } { - result := probablyEqualPageLists(this.v1, this.v2) - - if result != this.expect { - t.Errorf("[%d] got %t but expected %t", i, result, this.expect) - - } - } -} - -func TestPage(t *testing.T) { - t.Parallel() - urlFactory := func(page int) string { - return fmt.Sprintf("page/%d/", page) - } - - s := newTestSite(t) - - fivePages := createTestPages(s, 7) - fivePagesFuzzyWordCount, _ := createTestPages(s, 7).GroupBy("FuzzyWordCount", "asc") - - p1, _ := newPaginatorFromPages(fivePages, 2, urlFactory) - p2, _ := newPaginatorFromPageGroups(fivePagesFuzzyWordCount, 2, urlFactory) - - f1 := p1.pagers[0].First() - f2 := p2.pagers[0].First() - - page11, _ := f1.page(1) - page1Nil, _ := f1.page(3) - - page21, _ := f2.page(1) - page2Nil, _ := f2.page(3) - - require.Equal(t, 3, page11.fuzzyWordCount) - require.Nil(t, page1Nil) - - require.Equal(t, 3, page21.fuzzyWordCount) - require.Nil(t, page2Nil) -} - -func createTestPages(s *Site, num int) Pages { - pages := make(Pages, num) - - for i := 0; i < num; i++ { - p := s.newPage(filepath.FromSlash(fmt.Sprintf("/x/y/z/p%d.md", i))) - w := 5 - if i%2 == 0 { - w = 10 - } - p.fuzzyWordCount = i + 2 - p.Weight = w - pages[i] = p - - } - - return pages -} diff --git a/hugolib/paginator_test.go b/hugolib/paginator_test.go new file mode 100644 index 000000000..2fb87956f --- /dev/null +++ b/hugolib/paginator_test.go @@ -0,0 +1,194 @@ +// 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 hugolib + +import ( + "fmt" + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestPaginator(t *testing.T) { + configFile := ` +baseURL = "https://example.com/foo/" + +[pagination] +pagerSize = 3 +path = "thepage" + +[languages.en] +weight = 1 +contentDir = "content/en" + +[languages.nn] +weight = 2 +contentDir = "content/nn" + +` + b := newTestSitesBuilder(t).WithConfigFile("toml", configFile) + var content []string + for i := range 9 { + for _, contentDir := range []string{"content/en", "content/nn"} { + content = append(content, fmt.Sprintf(contentDir+"/blog/page%d.md", i), fmt.Sprintf(`--- +title: Page %d +--- + +Content. +`, i)) + } + } + + b.WithContent(content...) + + pagTemplate := ` +{{ $pag := $.Paginator }} +Total: {{ $pag.TotalPages }} +First: {{ $pag.First.URL }} +Page Number: {{ $pag.PageNumber }} +URL: {{ $pag.URL }} +{{ with $pag.Next }}Next: {{ .URL }}{{ end }} +{{ with $pag.Prev }}Prev: {{ .URL }}{{ end }} +{{ range $i, $e := $pag.Pagers }} +{{ printf "%d: %d/%d %t" $i $pag.PageNumber .PageNumber (eq . $pag) -}} +{{ end }} +` + + b.WithTemplatesAdded("index.html", pagTemplate) + b.WithTemplatesAdded("index.xml", pagTemplate) + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", + "Page Number: 1", + "0: 1/1 true") + + b.AssertFileContent("public/thepage/2/index.html", + "Total: 3", + "Page Number: 2", + "URL: /foo/thepage/2/", + "Next: /foo/thepage/3/", + "Prev: /foo/", + "1: 2/2 true", + ) + + b.AssertFileContent("public/index.xml", + "Page Number: 1", + "0: 1/1 true") + b.AssertFileContent("public/thepage/2/index.xml", + "Page Number: 2", + "1: 2/2 true") + + b.AssertFileContent("public/nn/index.html", + "Page Number: 1", + "0: 1/1 true") + + b.AssertFileContent("public/nn/index.xml", + "Page Number: 1", + "0: 1/1 true") +} + +// Issue 6023 +func TestPaginateWithSort(t *testing.T) { + files := ` +-- hugo.toml -- +-- content/a/a.md -- +-- content/z/b.md -- +-- content/x/b.md -- +-- content/x/a.md -- +-- layouts/home.html -- +Paginate: {{ range (.Paginate (sort .Site.RegularPages ".File.Filename" "desc")).Pages }}|{{ .Path }}{{ end }} +` + b := Test(t, files) + + b.AssertFileContent("public/index.html", "Paginate: |/z/b|/x/b|/x/a|/a/a") +} + +// https://github.com/gohugoio/hugo/issues/6797 +func TestPaginateOutputFormat(t *testing.T) { + b := newTestSitesBuilder(t).WithSimpleConfigFile() + b.WithContent("_index.md", `--- +title: "Home" +cascade: + outputs: + - JSON +---`) + + for i := range 22 { + b.WithContent(fmt.Sprintf("p%d.md", i+1), fmt.Sprintf(`--- +title: "Page" +weight: %d +---`, i+1)) + } + + b.WithTemplatesAdded("index.json", `JSON: {{ .Paginator.TotalNumberOfElements }}: {{ range .Paginator.Pages }}|{{ .RelPermalink }}{{ end }}:DONE`) + b.Build(BuildCfg{}) + + b.AssertFileContent("public/index.json", + `JSON: 22 +|/p1/index.json|/p2/index.json| +`) + + // This looks odd, so are most bugs. + b.Assert(b.CheckExists("public/page/1/index.json/index.html"), qt.Equals, false) + b.Assert(b.CheckExists("public/page/1/index.json"), qt.Equals, false) + b.AssertFileContent("public/page/2/index.json", `JSON: 22: |/p11/index.json|/p12/index.json`) +} + +// Issue 10802 +func TestPaginatorEmptyPageGroups(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +baseURL = "https://example.com/" +-- content/p1.md -- +-- content/p2.md -- +-- layouts/index.html -- +{{ $empty := site.RegularPages | complement site.RegularPages }} +Len: {{ len $empty }}: Type: {{ printf "%T" $empty }} +{{ $pgs := $empty.GroupByPublishDate "January 2006" }} +{{ $pag := .Paginate $pgs }} +Len Pag: {{ len $pag.Pages }} +` + b := Test(t, files) + + b.AssertFileContent("public/index.html", "Len: 0", "Len Pag: 0") +} + +func TestPaginatorNodePagesOnly(t *testing.T) { + files := ` +-- hugo.toml -- +[pagination] +pagerSize = 1 +-- content/p1.md -- +-- layouts/_default/single.html -- +Paginator: {{ .Paginator }} +` + b, err := TestE(t, files) + b.Assert(err, qt.IsNotNil) + b.Assert(err.Error(), qt.Contains, `error calling Paginator: pagination not supported for this page: kind: "page"`) +} + +func TestNilPointerErrorMessage(t *testing.T) { + files := ` +-- hugo.toml -- +-- content/p1.md -- +-- layouts/_default/single.html -- +Home Filename: {{ site.Home.File.Filename }} +` + b, err := TestE(t, files) + b.Assert(err, qt.IsNotNil) + b.Assert(err.Error(), qt.Contains, `single.html:1:22: executing "single.html" – File is nil; wrap it in if or with: {{ with site.Home.File }}{{ .Filename }}{{ end }}`) +} diff --git a/hugolib/params_test.go b/hugolib/params_test.go new file mode 100644 index 000000000..7f7566024 --- /dev/null +++ b/hugolib/params_test.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 hugolib + +import ( + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestFrontMatterParamsInItsOwnSection(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.org/" +-- content/_index.md -- ++++ +title = "Home" +[[cascade]] +background = 'yosemite.jpg' +[cascade.params] +a = "home-a" +b = "home-b" +[cascade._target] +kind = 'page' ++++ +-- content/p1.md -- +--- +title: "P1" +summary: "frontmatter.summary" +params: + a: "p1-a" + summary: "params.summary" +--- +-- layouts/_default/single.html -- +Params: {{ range $k, $v := .Params }}{{ $k }}: {{ $v }}|{{ end }}$ +Summary: {{ .Summary }}| +` + + b := Test(t, files) + + b.AssertFileContent("public/p1/index.html", + "Params: a: p1-a|b: home-b|background: yosemite.jpg|draft: false|iscjklanguage: false|summary: params.summary|title: P1|$", + "Summary: frontmatter.summary|", + ) +} + +func TestFrontMatterParamsPath(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.org/" +disableKinds = ["taxonomy", "term"] + +-- content/p1.md -- +--- +title: "P1" +date: 2019-08-07 +path: "/a/b/c" +slug: "s1" +--- +-- content/mysection/_index.md -- +--- +title: "My Section" +date: 2022-08-07 +path: "/a/b" +--- +-- layouts/index.html -- +RegularPages: {{ range site.RegularPages }}{{ .Path }}|{{ .RelPermalink }}|{{ .Title }}|{{ .Date.Format "2006-02-01" }}| Slug: {{ .Params.slug }}|{{ end }}$ +Sections: {{ range site.Sections }}{{ .Path }}|{{ .RelPermalink }}|{{ .Title }}|{{ .Date.Format "2006-02-01" }}| Slug: {{ .Params.slug }}|{{ end }}$ +{{ $ab := site.GetPage "a/b" }} +a/b pages: {{ range $ab.RegularPages }}{{ .Path }}|{{ .RelPermalink }}|{{ end }}$ +` + + b := Test(t, files) + + b.AssertFileContent("public/index.html", + "RegularPages: /a/b/c|/a/b/s1/|P1|2019-07-08| Slug: s1|$", + "Sections: /a|/a/|As", + "a/b pages: /a/b/c|/a/b/s1/|$", + ) +} + +func TestFrontMatterParamsLangNoCascade(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.org/" +disableKinds = ["taxonomy", "term"] +defaultContentLanguage = "en" +defaultContentLanguageInSubdir = true +[languages] +[languages.en] +weight = 1 +[languages.nn] +weight = 2 +-- content/_index.md -- ++++ +[[cascade]] +background = 'yosemite.jpg' +lang = 'nn' ++++ + +` + + b, err := TestE(t, files) + b.Assert(err, qt.IsNotNil) +} + +// Issue 11970. +func TestFrontMatterBuildIsHugoKeyword(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.org/" +-- content/p1.md -- +--- +title: "P1" +build: "foo" +--- +-- layouts/_default/single.html -- +Params: {{ range $k, $v := .Params }}{{ $k }}: {{ $v }}|{{ end }}$ +` + b, err := TestE(t, files) + + b.Assert(err, qt.IsNotNil) + b.Assert(err.Error(), qt.Contains, "We renamed the _build keyword") +} diff --git a/hugolib/path_separators_test.go b/hugolib/path_separators_test.go deleted file mode 100644 index 3a73869ad..000000000 --- a/hugolib/path_separators_test.go +++ /dev/null @@ -1,38 +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 hugolib - -import ( - "path/filepath" - "strings" - "testing" -) - -var simplePageYAML = `--- -contenttype: "" ---- -Sample Text -` - -func TestDegenerateMissingFolderInPageFilename(t *testing.T) { - t.Parallel() - s := newTestSite(t) - p, err := s.NewPageFrom(strings.NewReader(simplePageYAML), filepath.Join("foobar")) - if err != nil { - t.Fatalf("Error in NewPageFrom") - } - if p.Section() != "" { - t.Fatalf("No section should be set for a file path: foobar") - } -} diff --git a/hugolib/paths/paths.go b/hugolib/paths/paths.go new file mode 100644 index 000000000..60ec873f9 --- /dev/null +++ b/hugolib/paths/paths.go @@ -0,0 +1,133 @@ +// 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 paths + +import ( + "path/filepath" + "strings" + + hpaths "github.com/gohugoio/hugo/common/paths" + + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/modules" + + "github.com/gohugoio/hugo/hugofs" +) + +var FilePathSeparator = string(filepath.Separator) + +type Paths struct { + Fs *hugofs.Fs + Cfg config.AllProvider + + // Directories to store Resource related artifacts. + AbsResourcesDir string + + AbsPublishDir string + + // When in multihost mode, this returns a list of base paths below PublishDir + // for each language. + MultihostTargetBasePaths []string +} + +func New(fs *hugofs.Fs, cfg config.AllProvider) (*Paths, error) { + bcfg := cfg.BaseConfig() + publishDir := bcfg.PublishDir + if publishDir == "" { + panic("publishDir not set") + } + + absPublishDir := hpaths.AbsPathify(bcfg.WorkingDir, publishDir) + if !strings.HasSuffix(absPublishDir, FilePathSeparator) { + absPublishDir += FilePathSeparator + } + // If root, remove the second '/' + if absPublishDir == "//" { + absPublishDir = FilePathSeparator + } + absResourcesDir := hpaths.AbsPathify(bcfg.WorkingDir, cfg.Dirs().ResourceDir) + if !strings.HasSuffix(absResourcesDir, FilePathSeparator) { + absResourcesDir += FilePathSeparator + } + if absResourcesDir == "//" { + absResourcesDir = FilePathSeparator + } + + var multihostTargetBasePaths []string + if cfg.IsMultihost() && len(cfg.Languages()) > 1 { + for _, l := range cfg.Languages() { + multihostTargetBasePaths = append(multihostTargetBasePaths, hpaths.ToSlashPreserveLeading(l.Lang)) + } + } + + p := &Paths{ + Fs: fs, + Cfg: cfg, + AbsResourcesDir: absResourcesDir, + AbsPublishDir: absPublishDir, + MultihostTargetBasePaths: multihostTargetBasePaths, + } + + return p, nil +} + +func (p *Paths) AllModules() modules.Modules { + return p.Cfg.GetConfigSection("allModules").(modules.Modules) +} + +// GetBasePath returns any path element in baseURL if needed. +// The path returned will have a leading, but no trailing slash. +func (p *Paths) GetBasePath(isRelativeURL bool) string { + if isRelativeURL && p.Cfg.CanonifyURLs() { + // The baseURL will be prepended later. + return "" + } + return p.Cfg.BaseURL().BasePathNoTrailingSlash +} + +func (p *Paths) Lang() string { + if p == nil || p.Cfg.Language() == nil { + return "" + } + return p.Cfg.Language().Lang +} + +func (p *Paths) GetTargetLanguageBasePath() string { + if p.Cfg.IsMultihost() { + // In a multihost configuration all assets will be published below the language code. + return p.Lang() + } + return p.GetLanguagePrefix() +} + +func (p *Paths) GetLanguagePrefix() string { + return p.Cfg.LanguagePrefix() +} + +// AbsPathify creates an absolute path if given a relative path. If already +// absolute, the path is just cleaned. +func (p *Paths) AbsPathify(inPath string) string { + return hpaths.AbsPathify(p.Cfg.BaseConfig().WorkingDir, inPath) +} + +// RelPathify trims any WorkingDir prefix from the given filename. If +// the filename is not considered to be absolute, the path is just cleaned. +func (p *Paths) RelPathify(filename string) string { + filename = filepath.Clean(filename) + if !filepath.IsAbs(filename) { + return filename + } + + return strings.TrimPrefix(strings.TrimPrefix(filename, p.Cfg.BaseConfig().WorkingDir), FilePathSeparator) +} diff --git a/hugolib/permalinker.go b/hugolib/permalinker.go index 5e7a13a02..aeaa673f7 100644 --- a/hugolib/permalinker.go +++ b/hugolib/permalinker.go @@ -1,4 +1,4 @@ -// Copyright 2017-present The Hugo Authors. All rights reserved. +// 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. @@ -13,10 +13,7 @@ package hugolib -var ( - _ Permalinker = (*Page)(nil) - _ Permalinker = (*OutputFormat)(nil) -) +var _ Permalinker = (*pageState)(nil) // Permalinker provides permalinks of both the relative and absolute kind. type Permalinker interface { diff --git a/hugolib/permalinks.go b/hugolib/permalinks.go deleted file mode 100644 index 7640db6c1..000000000 --- a/hugolib/permalinks.go +++ /dev/null @@ -1,217 +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 hugolib - -import ( - "errors" - "fmt" - "path" - "path/filepath" - "regexp" - "strconv" - "strings" - - "github.com/gohugoio/hugo/helpers" -) - -// pathPattern represents a string which builds up a URL from attributes -type pathPattern string - -// pageToPermaAttribute is the type of a function which, given a page and a tag -// can return a string to go in that position in the page (or an error) -type pageToPermaAttribute func(*Page, string) (string, error) - -// PermalinkOverrides maps a section name to a PathPattern -type PermalinkOverrides map[string]pathPattern - -// 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. -var knownPermalinkAttributes map[string]pageToPermaAttribute - -var attributeRegexp *regexp.Regexp - -// validate determines if a PathPattern is well-formed -func (pp pathPattern) validate() bool { - fragments := strings.Split(string(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 := strings.ToLower(match[0][1:]) - if _, ok := knownPermalinkAttributes[k]; !ok { - return false - } - } - } - return true -} - -type permalinkExpandError struct { - pattern pathPattern - section string - err error -} - -func (pee *permalinkExpandError) Error() string { - return fmt.Sprintf("error expanding %q section %q: %s", string(pee.pattern), pee.section, pee.err) -} - -var ( - errPermalinkIllFormed = errors.New("permalink ill-formed") - errPermalinkAttributeUnknown = errors.New("permalink attribute not recognised") -) - -// Expand on a PathPattern takes a Page and returns the fully expanded Permalink -// or an error explaining the failure. -func (pp pathPattern) Expand(p *Page) (string, error) { - if !pp.validate() { - return "", &permalinkExpandError{pattern: pp, section: "<all>", err: errPermalinkIllFormed} - } - sections := strings.Split(string(pp), "/") - for i, field := range sections { - if len(field) == 0 { - continue - } - - matches := attributeRegexp.FindAllStringSubmatch(field, -1) - - if matches == nil { - continue - } - - newField := field - - for _, match := range matches { - attr := match[0][1:] - callback, ok := knownPermalinkAttributes[attr] - - if !ok { - return "", &permalinkExpandError{pattern: pp, section: strconv.Itoa(i), err: errPermalinkAttributeUnknown} - } - - newAttr, err := callback(p, attr) - - if err != nil { - return "", &permalinkExpandError{pattern: pp, section: strconv.Itoa(i), err: err} - } - - newField = strings.Replace(newField, match[0], newAttr, 1) - } - - sections[i] = newField - } - return strings.Join(sections, "/"), nil -} - -func pageToPermalinkDate(p *Page, dateField string) (string, error) { - // a Page contains a Node which provides a field Date, time.Time - switch dateField { - case "year": - return strconv.Itoa(p.Date.Year()), nil - case "month": - return fmt.Sprintf("%02d", int(p.Date.Month())), nil - case "monthname": - return p.Date.Month().String(), nil - case "day": - return fmt.Sprintf("%02d", p.Date.Day()), nil - case "weekday": - return strconv.Itoa(int(p.Date.Weekday())), nil - case "weekdayname": - return p.Date.Weekday().String(), nil - case "yearday": - return strconv.Itoa(p.Date.YearDay()), nil - } - //TODO: support classic strftime escapes too - // (and pass those through despite not being in the map) - panic("coding error: should not be here") -} - -// pageToPermalinkTitle returns the URL-safe form of the title -func pageToPermalinkTitle(p *Page, _ string) (string, error) { - // Page contains Node which has Title - // (also contains URLPath which has Slug, sometimes) - return p.s.PathSpec.URLize(p.title), nil -} - -// pageToPermalinkFilename returns the URL-safe form of the filename -func pageToPermalinkFilename(p *Page, _ string) (string, error) { - name := p.File.TranslationBaseName() - 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) - } - - return p.s.PathSpec.URLize(name), nil -} - -// if the page has a slug, return the slug, else return the title -func pageToPermalinkSlugElseTitle(p *Page, a string) (string, error) { - if p.Slug != "" { - // Don't start or end with a - - // TODO(bep) this doesn't look good... Set the Slug once. - if strings.HasPrefix(p.Slug, "-") { - p.Slug = p.Slug[1:len(p.Slug)] - } - - if strings.HasSuffix(p.Slug, "-") { - p.Slug = p.Slug[0 : len(p.Slug)-1] - } - return p.s.PathSpec.URLize(p.Slug), nil - } - return pageToPermalinkTitle(p, a) -} - -func pageToPermalinkSection(p *Page, _ string) (string, error) { - // Page contains Node contains URLPath which has Section - return p.Section(), nil -} - -func pageToPermalinkSections(p *Page, _ string) (string, error) { - // TODO(bep) we have some superflous URLize in this file, but let's - // deal with that later. - return path.Join(p.CurrentSection().sections...), nil -} - -func init() { - knownPermalinkAttributes = map[string]pageToPermaAttribute{ - "year": pageToPermalinkDate, - "month": pageToPermalinkDate, - "monthname": pageToPermalinkDate, - "day": pageToPermalinkDate, - "weekday": pageToPermalinkDate, - "weekdayname": pageToPermalinkDate, - "yearday": pageToPermalinkDate, - "section": pageToPermalinkSection, - "sections": pageToPermalinkSections, - "title": pageToPermalinkTitle, - "slug": pageToPermalinkSlugElseTitle, - "filename": pageToPermalinkFilename, - } - - attributeRegexp = regexp.MustCompile(`:\w+`) -} diff --git a/hugolib/permalinks_test.go b/hugolib/permalinks_test.go deleted file mode 100644 index 7a4bf78c2..000000000 --- a/hugolib/permalinks_test.go +++ /dev/null @@ -1,94 +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 hugolib - -import ( - "strings" - "testing" -) - -// testdataPermalinks is used by a couple of tests; the expandsTo content is -// subject to the data in SIMPLE_PAGE_JSON. -var testdataPermalinks = []struct { - spec string - valid bool - expandsTo string -}{ - //{"/:year/:month/:title/", true, "/2012/04/spf13-vim-3.0-release-and-new-website/"}, - //{"/:title", true, "/spf13-vim-3.0-release-and-new-website"}, - //{":title", true, "spf13-vim-3.0-release-and-new-website"}, - //{"/blog/:year/:yearday/:title", true, "/blog/2012/97/spf13-vim-3.0-release-and-new-website"}, - {"/:year-:month-:title", true, "/2012-04-spf13-vim-3.0-release-and-new-website"}, - {"/blog/:year-:month-:title", true, "/blog/2012-04-spf13-vim-3.0-release-and-new-website"}, - {"/blog-:year-:month-:title", true, "/blog-2012-04-spf13-vim-3.0-release-and-new-website"}, - //{"/blog/:fred", false, ""}, - //{"/:year//:title", false, ""}, - //{ - //"/:section/:year/:month/:day/:weekdayname/:yearday/:title", - //true, - //"/blue/2012/04/06/Friday/97/spf13-vim-3.0-release-and-new-website", - //}, - //{ - //"/:weekday/:weekdayname/:month/:monthname", - //true, - //"/5/Friday/04/April", - //}, - //{ - //"/:slug/:title", - //true, - //"/spf13-vim-3-0-release-and-new-website/spf13-vim-3.0-release-and-new-website", - //}, -} - -func TestPermalinkValidation(t *testing.T) { - t.Parallel() - for _, item := range testdataPermalinks { - pp := pathPattern(item.spec) - have := pp.validate() - if have == item.valid { - continue - } - var howBad string - if have { - howBad = "validates but should not have" - } else { - howBad = "should have validated but did not" - } - t.Errorf("permlink spec %q %s", item.spec, howBad) - } -} - -func TestPermalinkExpansion(t *testing.T) { - t.Parallel() - s := newTestSite(t) - page, err := s.NewPageFrom(strings.NewReader(simplePageJSON), "blue/test-page.md") - - if err != nil { - t.Fatalf("failed before we began, could not parse SIMPLE_PAGE_JSON: %s", err) - } - for _, item := range testdataPermalinks { - if !item.valid { - continue - } - pp := pathPattern(item.spec) - result, err := pp.Expand(page) - if err != nil { - t.Errorf("failed to expand page: %s", err) - continue - } - if result != item.expandsTo { - t.Errorf("expansion mismatch!\n\tExpected: %q\n\tReceived: %q", item.expandsTo, result) - } - } -} diff --git a/hugolib/prune_resources.go b/hugolib/prune_resources.go index e9d2bf96e..50868e872 100644 --- a/hugolib/prune_resources.go +++ b/hugolib/prune_resources.go @@ -1,4 +1,4 @@ -// Copyright 2017-present The Hugo Authors. All rights reserved. +// 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. @@ -13,74 +13,7 @@ package hugolib -import ( - "fmt" - "io" - "os" - "strings" - - "github.com/spf13/afero" -) - -// GC requires a build first. +// GC requires a build first and must run on it's own. It is not thread safe. func (h *HugoSites) GC() (int, error) { - s := h.Sites[0] - fs := h.PathSpec.BaseFs.ResourcesFs - - imageCacheDir := s.resourceSpec.GenImagePath - if len(imageCacheDir) < 10 { - panic("invalid image cache") - } - - isInUse := func(filename string) bool { - key := strings.TrimPrefix(filename, imageCacheDir) - for _, site := range h.Sites { - if site.resourceSpec.IsInCache(key) { - return true - } - } - - return false - } - - counter := 0 - - err := afero.Walk(fs, imageCacheDir, func(path string, info os.FileInfo, err error) error { - if info == nil { - return nil - } - - if !strings.HasPrefix(path, imageCacheDir) { - return fmt.Errorf("Invalid state, walk outside of resource dir: %q", path) - } - - if info.IsDir() { - f, err := fs.Open(path) - if err != nil { - return nil - } - defer f.Close() - _, err = f.Readdirnames(1) - if err == io.EOF { - // Empty dir. - s.Fs.Source.Remove(path) - } - - return nil - } - - inUse := isInUse(path) - if !inUse { - err := fs.Remove(path) - if err != nil && !os.IsNotExist(err) { - s.Log.ERROR.Printf("Failed to remove %q: %s", path, err) - } else { - counter++ - } - } - return nil - }) - - return counter, err - + return h.Deps.ResourceSpec.FileCaches.Prune() } diff --git a/hugolib/rebuild_test.go b/hugolib/rebuild_test.go new file mode 100644 index 000000000..d4a15fb5b --- /dev/null +++ b/hugolib/rebuild_test.go @@ -0,0 +1,1968 @@ +package hugolib + +import ( + "fmt" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/fortytw2/leaktest" + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/common/types" + "github.com/gohugoio/hugo/htesting" + "github.com/gohugoio/hugo/markup/asciidocext" + "github.com/gohugoio/hugo/resources/resource_transformers/tocss/dartsass" + "github.com/gohugoio/hugo/resources/resource_transformers/tocss/scss" +) + +const rebuildFilesSimple = ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ["term", "taxonomy", "sitemap", "robotstxt", "404"] +disableLiveReload = true +[outputFormats] + [outputFormats.rss] + weight = 10 + [outputFormats.html] + weight = 20 +[outputs] +home = ["rss", "html"] +section = ["html"] +page = ["html"] +-- content/mysection/_index.md -- +--- +title: "My Section" +--- +-- content/mysection/mysectionbundle/index.md -- +--- +title: "My Section Bundle" +--- +My Section Bundle Content. +-- content/mysection/mysectionbundle/mysectionbundletext.txt -- +My Section Bundle Text 2 Content. +-- content/mysection/mysectionbundle/mysectionbundlecontent.md -- +--- +title: "My Section Bundle Content" +--- +My Section Bundle Content Content. +-- content/mysection/_index.md -- +--- +title: "My Section" +--- +-- content/mysection/mysectiontext.txt -- +Content. +-- content/_index.md -- +--- +title: "Home" +--- +Home Content. +-- content/hometext.txt -- +Home Text Content. +-- content/myothersection/myothersectionpage.md -- +--- +title: "myothersectionpage" +--- +myothersectionpage Content. +-- content/mythirdsection/mythirdsectionpage.md -- +--- +title: "mythirdsectionpage" +--- +mythirdsectionpage Content. +{{< myshortcodetext >}} +§§§ myothertext +foo +§§§ +-- assets/mytext.txt -- +Assets My Text. +-- assets/myshortcodetext.txt -- +Assets My Shortcode Text. +-- assets/myothertext.txt -- +Assets My Other Text. +-- layouts/_default/single.html -- +Single: {{ .Title }}|{{ .Content }}$ +Resources: {{ range $i, $e := .Resources }}{{ $i }}:{{ .RelPermalink }}|{{ .Content }}|{{ end }}$ +Len Resources: {{ len .Resources }}| +-- layouts/_default/list.html -- +List: {{ .Title }}|{{ .Content }}$ +Len Resources: {{ len .Resources }}| +Resources: {{ range $i, $e := .Resources }}{{ $i }}:{{ .RelPermalink }}|{{ .Content }}|{{ end }}$ +-- layouts/shortcodes/foo.html -- +Foo. +-- layouts/shortcodes/myshortcodetext.html -- +{{ warnf "mytext %s" now}} +{{ $r := resources.Get "myshortcodetext.txt" }} +My Shortcode Text: {{ $r.Content }}|{{ $r.Permalink }}| +-- layouts/_default/_markup/render-codeblock-myothertext.html -- +{{ $r := resources.Get "myothertext.txt" }} +My Other Text: {{ $r.Content }}|{{ $r.Permalink }}| + +` + +func TestRebuildEditLeafBundleHeaderOnly(t *testing.T) { + t.Parallel() + for i := 0; i < 3; i++ { + b := TestRunning(t, rebuildFilesSimple) + b.AssertFileContent("public/mysection/mysectionbundle/index.html", + "My Section Bundle Content Content.") + b.EditFileReplaceAll("content/mysection/mysectionbundle/index.md", "My Section Bundle Content.", "My Section Bundle Content Edited.").Build() + b.AssertFileContent("public/mysection/mysectionbundle/index.html", + "My Section Bundle Content Edited.") + b.AssertRenderCountPage(2) // home (rss) + bundle. + b.AssertRenderCountContent(1) + } +} + +func TestRebuildEditTextFileInLeafBundle(t *testing.T) { + b := TestRunning(t, rebuildFilesSimple) + b.AssertFileContent("public/mysection/mysectionbundle/index.html", + "Resources: 0:/mysection/mysectionbundle/mysectionbundletext.txt|My Section Bundle Text 2 Content.|1:|<p>My Section Bundle Content Content.</p>\n|$") + + b.EditFileReplaceAll("content/mysection/mysectionbundle/mysectionbundletext.txt", "Content.", "Content Edited.").Build() + b.AssertFileContent("public/mysection/mysectionbundle/index.html", + "Text 2 Content Edited") + b.AssertRenderCountPage(1) + b.AssertRenderCountContent(0) +} + +func TestRebuildEditTextFileInShortcode(t *testing.T) { + t.Parallel() + for range 3 { + b := TestRunning(t, rebuildFilesSimple) + b.AssertFileContent("public/mythirdsection/mythirdsectionpage/index.html", + "Text: Assets My Shortcode Text.") + b.EditFileReplaceAll("assets/myshortcodetext.txt", "My Shortcode Text", "My Shortcode Text Edited").Build() + fmt.Println(b.LogString()) + b.AssertFileContent("public/mythirdsection/mythirdsectionpage/index.html", + "Text: Assets My Shortcode Text Edited.") + + } +} + +func TestRebuildEditTextFileInHook(t *testing.T) { + t.Parallel() + for range 3 { + b := TestRunning(t, rebuildFilesSimple) + b.AssertFileContent("public/mythirdsection/mythirdsectionpage/index.html", + "Text: Assets My Other Text.") + b.AssertFileContent("public/myothertext.txt", "Assets My Other Text.") + b.EditFileReplaceAll("assets/myothertext.txt", "My Other Text", "My Other Text Edited").Build() + b.AssertFileContent("public/mythirdsection/mythirdsectionpage/index.html", + "Text: Assets My Other Text Edited.") + + } +} + +func TestRebuiEditUnmarshaledYamlFileInLeafBundle(t *testing.T) { + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableLiveReload = true +disableKinds = ["taxonomy", "term", "sitemap", "robotsTXT", "404", "rss"] +-- content/mybundle/index.md -- +-- content/mybundle/mydata.yml -- +foo: bar +-- layouts/_default/single.html -- +MyData: {{ .Resources.Get "mydata.yml" | transform.Unmarshal }}| +` + b := TestRunning(t, files) + + b.AssertFileContent("public/mybundle/index.html", "MyData: map[foo:bar]") + + b.EditFileReplaceAll("content/mybundle/mydata.yml", "bar", "bar edited").Build() + + b.AssertFileContent("public/mybundle/index.html", "MyData: map[foo:bar edited]") +} + +func TestRebuildEditTextFileInHomeBundle(t *testing.T) { + b := TestRunning(t, rebuildFilesSimple) + b.AssertFileContent("public/index.html", "Home Content.") + b.AssertFileContent("public/index.html", "Home Text Content.") + + b.EditFileReplaceAll("content/hometext.txt", "Content.", "Content Edited.").Build() + b.AssertFileContent("public/index.html", "Home Content.") + b.AssertFileContent("public/index.html", "Home Text Content Edited.") + b.AssertRenderCountPage(1) + b.AssertRenderCountContent(0) +} + +func TestRebuildEditTextFileInBranchBundle(t *testing.T) { + b := TestRunning(t, rebuildFilesSimple) + b.AssertFileContent("public/mysection/index.html", "My Section", "0:/mysection/mysectiontext.txt|Content.|") + + b.EditFileReplaceAll("content/mysection/mysectiontext.txt", "Content.", "Content Edited.").Build() + b.AssertFileContent("public/mysection/index.html", "My Section", "0:/mysection/mysectiontext.txt|Content Edited.|") + b.AssertRenderCountPage(1) + b.AssertRenderCountContent(0) +} + +func testRebuildBothWatchingAndRunning(t *testing.T, files string, withB func(b *IntegrationTestBuilder)) { + t.Helper() + for _, opt := range []TestOpt{TestOptWatching(), TestOptRunning()} { + b := Test(t, files, opt) + withB(b) + } +} + +func TestRebuildRenameTextFileInLeafBundle(t *testing.T) { + testRebuildBothWatchingAndRunning(t, rebuildFilesSimple, func(b *IntegrationTestBuilder) { + b.AssertFileContent("public/mysection/mysectionbundle/index.html", "My Section Bundle Text 2 Content.", "Len Resources: 2|") + + b.RenameFile("content/mysection/mysectionbundle/mysectionbundletext.txt", "content/mysection/mysectionbundle/mysectionbundletext2.txt").Build() + b.AssertFileContent("public/mysection/mysectionbundle/index.html", "mysectionbundletext2", "My Section Bundle Text 2 Content.", "Len Resources: 2|") + b.AssertRenderCountPage(8) + b.AssertRenderCountContent(9) + }) +} + +func TestRebuilEditContentFileInLeafBundle(t *testing.T) { + b := TestRunning(t, rebuildFilesSimple) + b.AssertFileContent("public/mysection/mysectionbundle/index.html", "My Section Bundle Content Content.") + b.EditFileReplaceAll("content/mysection/mysectionbundle/mysectionbundlecontent.md", "Content Content.", "Content Content Edited.").Build() + b.AssertFileContent("public/mysection/mysectionbundle/index.html", "My Section Bundle Content Content Edited.") +} + +func TestRebuilEditContentFileThenAnother(t *testing.T) { + b := TestRunning(t, rebuildFilesSimple) + b.EditFileReplaceAll("content/mysection/mysectionbundle/mysectionbundlecontent.md", "Content Content.", "Content Content Edited.").Build() + b.AssertFileContent("public/mysection/mysectionbundle/index.html", "My Section Bundle Content Content Edited.") + b.AssertRenderCountPage(1) + b.AssertRenderCountContent(2) + + b.EditFileReplaceAll("content/myothersection/myothersectionpage.md", "myothersectionpage Content.", "myothersectionpage Content Edited.").Build() + b.AssertFileContent("public/myothersection/myothersectionpage/index.html", "myothersectionpage Content Edited") + b.AssertRenderCountPage(2) + b.AssertRenderCountContent(2) +} + +func TestRebuildRenameTextFileInBranchBundle(t *testing.T) { + b := TestRunning(t, rebuildFilesSimple) + b.AssertFileContent("public/mysection/index.html", "My Section") + + b.RenameFile("content/mysection/mysectiontext.txt", "content/mysection/mysectiontext2.txt").Build() + b.AssertFileContent("public/mysection/index.html", "mysectiontext2", "My Section") + b.AssertRenderCountPage(3) + b.AssertRenderCountContent(2) +} + +func TestRebuildRenameTextFileInHomeBundle(t *testing.T) { + b := TestRunning(t, rebuildFilesSimple) + b.AssertFileContent("public/index.html", "Home Text Content.") + + b.RenameFile("content/hometext.txt", "content/hometext2.txt").Build() + b.AssertFileContent("public/index.html", "hometext2", "Home Text Content.") + b.AssertRenderCountPage(5) +} + +func TestRebuildRenameDirectoryWithLeafBundle(t *testing.T) { + b := TestRunning(t, rebuildFilesSimple) + b.RenameDir("content/mysection/mysectionbundle", "content/mysection/mysectionbundlerenamed").Build() + b.AssertFileContent("public/mysection/mysectionbundlerenamed/index.html", "My Section Bundle") + b.AssertRenderCountPage(2) +} + +func TestRebuildRenameDirectoryWithBranchBundle(t *testing.T) { + b := TestRunning(t, rebuildFilesSimple) + b.RenameDir("content/mysection", "content/mysectionrenamed").Build() + b.AssertFileContent("public/mysectionrenamed/index.html", "My Section") + b.AssertFileContent("public/mysectionrenamed/mysectionbundle/index.html", "My Section Bundle") + b.AssertFileContent("public/mysectionrenamed/mysectionbundle/mysectionbundletext.txt", "My Section Bundle Text 2 Content.") + b.AssertRenderCountPage(5) +} + +func TestRebuildRenameDirectoryWithRegularPageUsedInHome(t *testing.T) { + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableLiveReload = true +-- content/foo/p1.md -- +--- +title: "P1" +--- +-- layouts/index.html -- +Pages: {{ range .Site.RegularPages }}{{ .RelPermalink }}|{{ end }}$ +` + b := TestRunning(t, files) + + b.AssertFileContent("public/index.html", "Pages: /foo/p1/|$") + + b.RenameDir("content/foo", "content/bar").Build() + + b.AssertFileContent("public/index.html", "Pages: /bar/p1/|$") +} + +func TestRebuildAddRegularFileRegularPageUsedInHomeMultilingual(t *testing.T) { + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableLiveReload = true +[languages] +[languages.en] +weight = 1 +[languages.nn] +weight = 2 +[languages.fr] +weight = 3 +[languages.a] +weight = 4 +[languages.b] +weight = 5 +[languages.c] +weight = 6 +[languages.d] +weight = 7 +[languages.e] +weight = 8 +[languages.f] +weight = 9 +[languages.g] +weight = 10 +[languages.h] +weight = 11 +[languages.i] +weight = 12 +[languages.j] +weight = 13 +-- content/foo/_index.md -- +-- content/foo/data.txt -- +-- content/foo/p1.md -- +-- content/foo/p1.nn.md -- +-- content/foo/p1.fr.md -- +-- content/foo/p1.a.md -- +-- content/foo/p1.b.md -- +-- content/foo/p1.c.md -- +-- content/foo/p1.d.md -- +-- content/foo/p1.e.md -- +-- content/foo/p1.f.md -- +-- content/foo/p1.g.md -- +-- content/foo/p1.h.md -- +-- content/foo/p1.i.md -- +-- content/foo/p1.j.md -- +-- layouts/index.html -- +RegularPages: {{ range .Site.RegularPages }}{{ .RelPermalink }}|{{ end }}$ +` + b := TestRunning(t, files) + + b.AssertFileContent("public/index.html", "RegularPages: /foo/p1/|$") + b.AssertFileContent("public/nn/index.html", "RegularPages: /nn/foo/p1/|$") + b.AssertFileContent("public/i/index.html", "RegularPages: /i/foo/p1/|$") + + b.AddFiles("content/foo/p2.md", ``).Build() + + b.AssertFileContent("public/index.html", "RegularPages: /foo/p1/|/foo/p2/|$") + b.AssertFileContent("public/fr/index.html", "RegularPages: /fr/foo/p1/|$") + + b.AddFiles("content/foo/p2.fr.md", ``).Build() + b.AssertFileContent("public/fr/index.html", "RegularPages: /fr/foo/p1/|/fr/foo/p2/|$") + + b.AddFiles("content/foo/p2.i.md", ``).Build() + b.AssertFileContent("public/i/index.html", "RegularPages: /i/foo/p1/|/i/foo/p2/|$") +} + +func TestRebuildRenameDirectoryWithBranchBundleFastRender(t *testing.T) { + recentlyVisited := types.NewEvictingQueue[string](10).Add("/a/b/c/") + b := TestRunning(t, rebuildFilesSimple, func(cfg *IntegrationTestConfig) { cfg.BuildCfg = BuildCfg{RecentlyTouched: recentlyVisited} }) + b.RenameDir("content/mysection", "content/mysectionrenamed").Build() + b.AssertFileContent("public/mysectionrenamed/index.html", "My Section") + b.AssertFileContent("public/mysectionrenamed/mysectionbundle/index.html", "My Section Bundle") + b.AssertFileContent("public/mysectionrenamed/mysectionbundle/mysectionbundletext.txt", "My Section Bundle Text 2 Content.") + b.AssertRenderCountPage(5) +} + +func TestRebuilErrorRecovery(t *testing.T) { + b := TestRunning(t, rebuildFilesSimple) + _, err := b.EditFileReplaceAll("content/mysection/mysectionbundle/index.md", "My Section Bundle Content.", "My Section Bundle Content\n\n\n\n{{< foo }}.").BuildE() + + b.Assert(err, qt.Not(qt.IsNil)) + b.Assert(err.Error(), qt.Contains, filepath.FromSlash(`"/content/mysection/mysectionbundle/index.md:8:9": unrecognized character`)) + + // Fix the error + b.EditFileReplaceAll("content/mysection/mysectionbundle/index.md", "{{< foo }}", "{{< foo >}}").Build() +} + +func TestRebuildAddPageListPagesInHome(t *testing.T) { + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableLiveReload = true +-- content/asection/s1.md -- +-- content/p1.md -- +--- +title: "P1" +weight: 1 +--- +-- layouts/_default/single.html -- +Single: {{ .Title }}|{{ .Content }}| +-- layouts/index.html -- +Pages: {{ range .RegularPages }}{{ .RelPermalink }}|{{ end }}$ +` + + b := TestRunning(t, files) + b.AssertFileContent("public/index.html", "Pages: /p1/|$") + b.AddFiles("content/p2.md", ``).Build() + b.AssertFileContent("public/index.html", "Pages: /p1/|/p2/|$") +} + +func TestRebuildAddPageWithSpaceListPagesInHome(t *testing.T) { + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableLiveReload = true +-- content/asection/s1.md -- +-- content/p1.md -- +--- +title: "P1" +weight: 1 +--- +-- layouts/_default/single.html -- +Single: {{ .Title }}|{{ .Content }}| +-- layouts/index.html -- +Pages: {{ range .RegularPages }}{{ .RelPermalink }}|{{ end }}$ +` + + b := TestRunning(t, files) + b.AssertFileContent("public/index.html", "Pages: /p1/|$") + b.AddFiles("content/test test/index.md", ``).Build() + b.AssertFileContent("public/index.html", "Pages: /p1/|/test-test/|$") +} + +func TestRebuildScopedToOutputFormat(t *testing.T) { + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ["term", "taxonomy", "sitemap", "robotstxt", "404"] +disableLiveReload = true +-- content/p1.md -- +--- +title: "P1" +outputs: ["html", "json"] +--- +P1 Content. + +{{< myshort >}} +-- layouts/_default/single.html -- +Single HTML: {{ .Title }}|{{ .Content }}| +-- layouts/_default/single.json -- +Single JSON: {{ .Title }}|{{ .Content }}| +-- layouts/shortcodes/myshort.html -- +My short. +` + b := Test(t, files, TestOptRunning()) + b.AssertRenderCountPage(3) + b.AssertRenderCountContent(1) + b.AssertFileContent("public/p1/index.html", "Single HTML: P1|<p>P1 Content.</p>\n") + b.AssertFileContent("public/p1/index.json", "Single JSON: P1|<p>P1 Content.</p>\n") + b.EditFileReplaceAll("layouts/_default/single.html", "Single HTML", "Single HTML Edited").Build() + b.AssertFileContent("public/p1/index.html", "Single HTML Edited: P1|<p>P1 Content.</p>\n") + b.AssertRenderCountPage(1) + + // Edit shortcode. Note that this is reused across all output formats. + b.EditFileReplaceAll("layouts/shortcodes/myshort.html", "My short", "My short edited").Build() + b.AssertFileContent("public/p1/index.html", "My short edited") + b.AssertFileContent("public/p1/index.json", "My short edited") + b.AssertRenderCountPage(3) // rss (uses .Content) + 2 single pages. +} + +func TestRebuildBaseof(t *testing.T) { + files := ` +-- hugo.toml -- +title = "Hugo Site" +baseURL = "https://example.com" +disableKinds = ["term", "taxonomy"] +disableLiveReload = true +-- layouts/_default/baseof.html -- +Baseof: {{ .Title }}| +{{ block "main" . }}default{{ end }} +-- layouts/index.html -- +{{ define "main" }} +Home: {{ .Title }}|{{ .Content }}| +{{ end }} +` + testRebuildBothWatchingAndRunning(t, files, func(b *IntegrationTestBuilder) { + b.AssertFileContent("public/index.html", "Baseof: Hugo Site|", "Home: Hugo Site||") + b.EditFileReplaceFunc("layouts/_default/baseof.html", func(s string) string { + return strings.Replace(s, "Baseof", "Baseof Edited", 1) + }).Build() + b.AssertFileContent("public/index.html", "Baseof Edited: Hugo Site|", "Home: Hugo Site||") + }) +} + +func TestRebuildSingle(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +title = "Hugo Site" +baseURL = "https://example.com" +disableKinds = ["term", "taxonomy", "sitemap", "robotstxt", "404"] +disableLiveReload = true +-- content/p1.md -- +--- +title: "P1" +--- +P1 Content. +-- layouts/index.html -- +Home. +-- layouts/single.html -- +Single: {{ .Title }}|{{ .Content }}| +{{ with (templates.Defer (dict "key" "global")) }} +Defer. +{{ end }} +` + b := Test(t, files, TestOptRunning()) + b.AssertFileContent("public/p1/index.html", "Single: P1|", "Defer.") + b.AssertRenderCountPage(3) + b.AssertRenderCountContent(1) + b.EditFileReplaceFunc("layouts/single.html", func(s string) string { + s = strings.Replace(s, "Single", "Single Edited", 1) + s = strings.Replace(s, "Defer.", "Defer Edited.", 1) + return s + }).Build() + b.AssertFileContent("public/p1/index.html", "Single Edited: P1|", "Defer Edited.") + b.AssertRenderCountPage(1) + b.AssertRenderCountContent(0) +} + +func TestRebuildSingleWithBaseofEditSingle(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +title = "Hugo Site" +baseURL = "https://example.com" +disableKinds = ["term", "taxonomy"] +disableLiveReload = true +-- content/p1.md -- +--- +title: "P1" +--- +P1 Content. +[foo](/foo) +-- layouts/_default/baseof.html -- +Baseof: {{ .Title }}| +{{ block "main" . }}default{{ end }} +{{ with (templates.Defer (dict "foo" "bar")) }} +Defer. +{{ end }} +-- layouts/index.html -- +Home. +-- layouts/_default/single.html -- +{{ define "main" }} +Single: {{ .Title }}|{{ .Content }}| +{{ end }} +` + b := Test(t, files, TestOptRunning()) + b.AssertFileContent("public/p1/index.html", "Single: P1|") + b.EditFileReplaceFunc("layouts/_default/single.html", func(s string) string { + return strings.Replace(s, "Single", "Single Edited", 1) + }).Build() + b.AssertFileContent("public/p1/index.html", "Single Edited") +} + +func TestRebuildSingleWithBaseofEditBaseof(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +title = "Hugo Site" +baseURL = "https://example.com" +disableKinds = ["term", "taxonomy"] +disableLiveReload = true +-- content/p1.md -- +--- +title: "P1" +--- +P1 Content. +[foo](/foo) +-- layouts/_default/baseof.html -- +Baseof: {{ .Title }}| +{{ block "main" . }}default{{ end }} +{{ with (templates.Defer (dict "foo" "bar")) }} +Defer. +{{ end }} +-- layouts/index.html -- +Home. +-- layouts/_default/single.html -- +{{ define "main" }} +Single: {{ .Title }}|{{ .Content }}| +{{ end }} +` + b := Test(t, files, TestOptRunning()) + b.AssertFileContent("public/p1/index.html", "Single: P1|") + fmt.Println("===============") + b.EditFileReplaceAll("layouts/_default/baseof.html", "Baseof", "Baseof Edited").Build() + b.AssertFileContent("public/p1/index.html", "Baseof Edited") +} + +func TestRebuildWithDeferEditRenderHook(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +title = "Hugo Site" +baseURL = "https://example.com" +disableKinds = ["term", "taxonomy"] +disableLiveReload = true +-- content/p1.md -- +--- +title: "P1" +--- +P1 Content. +[foo](/foo) +-- layouts/_default/baseof.html -- +Baseof: {{ .Title }}| +{{ block "main" . }}default{{ end }} + {{ with (templates.Defer (dict "foo" "bar")) }} +Defer. +{{ end }} +-- layouts/single.html -- +{{ define "main" }} +Single: {{ .Title }}|{{ .Content }}| +{{ end }} +-- layouts/_default/_markup/render-link.html -- +Render Link. +` + b := Test(t, files, TestOptRunning()) + // Edit render hook. + b.EditFileReplaceAll("layouts/_default/_markup/render-link.html", "Render Link", "Render Link Edited").Build() + + b.AssertFileContent("public/p1/index.html", "Render Link Edited") +} + +func TestRebuildFromString(t *testing.T) { + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ["term", "taxonomy", "sitemap", "robotstxt", "404"] +disableLiveReload = true +-- content/p1.md -- +--- +title: "P1" +layout: "l1" +--- +P1 Content. +-- content/p2.md -- +--- +title: "P2" +layout: "l2" +--- +P2 Content. +-- assets/mytext.txt -- +My Text +-- layouts/_default/l1.html -- +{{ $r := partial "get-resource.html" . }} +L1: {{ .Title }}|{{ .Content }}|R: {{ $r.Content }}| +-- layouts/_default/l2.html -- +L2. +-- layouts/partials/get-resource.html -- +{{ $mytext := resources.Get "mytext.txt" }} +{{ $txt := printf "Text: %s" $mytext.Content }} +{{ $r := resources.FromString "r.txt" $txt }} +{{ return $r }} + +` + b := TestRunning(t, files) + + b.AssertFileContent("public/p1/index.html", "L1: P1|<p>P1 Content.</p>\n|R: Text: My Text|") + + b.EditFileReplaceAll("assets/mytext.txt", "My Text", "My Text Edited").Build() + + b.AssertFileContent("public/p1/index.html", "L1: P1|<p>P1 Content.</p>\n|R: Text: My Text Edited|") + + b.AssertRenderCountPage(1) +} + +func TestRebuildDeeplyNestedLink(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com/" +disableKinds = ["term", "taxonomy", "sitemap", "robotstxt", "404"] +disableLiveReload = true +-- content/s/p1.md -- +--- +title: "P1" +--- +-- content/s/p2.md -- +--- +title: "P2" +--- +-- content/s/p3.md -- +--- +title: "P3" +--- +-- content/s/p4.md -- +--- +title: "P4" +--- +-- content/s/p5.md -- +--- +title: "P5" +--- +-- content/s/p6.md -- +--- +title: "P6" +--- +-- content/s/p7.md -- +--- +title: "P7" +--- +-- layouts/_default/list.html -- +List. +-- layouts/_default/single.html -- +Single. +-- layouts/_default/single.html -- +Next: {{ with .PrevInSection }}{{ .Title }}{{ end }}| +Prev: {{ with .NextInSection }}{{ .Title }}{{ end }}| + + +` + + b := TestRunning(t, files) + + b.AssertFileContent("public/s/p1/index.html", "Next: P2|") + b.EditFileReplaceAll("content/s/p7.md", "P7", "P7 Edited").Build() + b.AssertFileContent("public/s/p6/index.html", "Next: P7 Edited|") +} + +func TestRebuildVariations(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 !htesting.IsCI() { + defer leaktest.CheckTimeout(t, 10*time.Second)() + } + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ["term", "taxonomy"] +disableLiveReload = true +defaultContentLanguage = "nn" +[pagination] +pagerSize = 20 +[security] +enableInlineShortcodes = true +[languages] +[languages.en] +weight = 1 +[languages.nn] +weight = 2 +-- content/mysect/p1/index.md -- +--- +title: "P1" +--- +P1 Content. +{{< include "mysect/p2" >}} +§§§go { page="mysect/p3" } +hello +§§§ + +{{< foo.inline >}}Foo{{< /foo.inline >}} +-- content/mysect/p2/index.md -- +--- +title: "P2" +--- +P2 Content. +-- content/mysect/p3/index.md -- +--- +title: "P3" +--- +P3 Content. +-- content/mysect/sub/_index.md -- +-- content/mysect/sub/p4/index.md -- +--- +title: "P4" +--- +P4 Content. +-- content/mysect/sub/p5/index.md -- +--- +title: "P5" +lastMod: 2019-03-02 +--- +P5 Content. +-- content/myothersect/_index.md -- +--- +cascade: +- _target: + cascadeparam: "cascadevalue" +--- +-- content/myothersect/sub/_index.md -- +-- content/myothersect/sub/p6/index.md -- +--- +title: "P6" +--- +P6 Content. +-- content/translations/p7.en.md -- +--- +title: "P7 EN" +--- +P7 EN Content. +-- content/translations/p7.nn.md -- +--- +title: "P7 NN" +--- +P7 NN Content. +-- layouts/index.html -- +Home: {{ .Title }}|{{ .Content }}| +RegularPages: {{ range .RegularPages }}{{ .RelPermalink }}|{{ end }}$ +Len RegularPagesRecursive: {{ len .RegularPagesRecursive }} +Site.Lastmod: {{ .Site.Lastmod.Format "2006-01-02" }}| +Paginate: {{ range (.Paginate .Site.RegularPages).Pages }}{{ .RelPermalink }}|{{ .Title }}|{{ end }}$ +-- layouts/_default/single.html -- +Single: {{ .Title }}|{{ .Content }}| +Single Partial Cached: {{ partialCached "pcached" . }}| +Page.Lastmod: {{ .Lastmod.Format "2006-01-02" }}| +Cascade param: {{ .Params.cascadeparam }}| +-- layouts/_default/list.html -- +List: {{ .Title }}|{{ .Content }}| +RegularPages: {{ range .RegularPages }}{{ .Title }}|{{ end }}$ +Len RegularPagesRecursive: {{ len .RegularPagesRecursive }} +RegularPagesRecursive: {{ range .RegularPagesRecursive }}{{ .RelPermalink }}|{{ end }}$ +List Partial P1: {{ partial "p1" . }}| +Page.Lastmod: {{ .Lastmod.Format "2006-01-02" }}| +Cascade param: {{ .Params.cascadeparam }}| +-- layouts/partials/p1.html -- +Partial P1. +-- layouts/partials/pcached.html -- +Partial Pcached. +-- layouts/shortcodes/include.html -- +{{ $p := site.GetPage (.Get 0)}} +{{ with $p }} +Shortcode Include: {{ .Title }}| +{{ end }} +Shortcode .Page.Title: {{ .Page.Title }}| +Shortcode Partial P1: {{ partial "p1" . }}| +-- layouts/_default/_markup/render-codeblock.html -- +{{ $p := site.GetPage (.Attributes.page)}} +{{ with $p }} +Codeblock Include: {{ .Title }}| +{{ end }} + + + +` + + b := NewIntegrationTestBuilder( + IntegrationTestConfig{ + T: t, + TxtarString: files, + Running: true, + BuildCfg: BuildCfg{ + testCounters: &buildCounters{}, + }, + // Verbose: true, + // LogLevel: logg.LevelTrace, + }, + ).Build() + + // When running the server, this is done on shutdown. + // Do this here to satisfy the leak detector above. + defer func() { + b.Assert(b.H.Close(), qt.IsNil) + }() + + contentRenderCount := b.counters.contentRenderCounter.Load() + pageRenderCount := b.counters.pageRenderCounter.Load() + + b.Assert(contentRenderCount > 0, qt.IsTrue) + b.Assert(pageRenderCount > 0, qt.IsTrue) + + // Test cases: + // - Edit content file direct + // - Edit content file transitive shortcode + // - Edit content file transitive render hook + // - Rename one language version of a content file + // - Delete content file, check site.RegularPages and section.RegularPagesRecursive (length) + // - Add content file (see above). + // - Edit shortcode + // - Edit inline shortcode + // - Edit render hook + // - Edit partial used in template + // - Edit partial used in shortcode + // - Edit partial cached. + // - Edit lastMod date in content file, check site.Lastmod. + editFile := func(filename string, replacementFunc func(s string) string) { + b.EditFileReplaceFunc(filename, replacementFunc).Build() + b.Assert(b.counters.contentRenderCounter.Load() < contentRenderCount, qt.IsTrue, qt.Commentf("count %d < %d", b.counters.contentRenderCounter.Load(), contentRenderCount)) + b.Assert(b.counters.pageRenderCounter.Load() < pageRenderCount, qt.IsTrue, qt.Commentf("count %d < %d", b.counters.pageRenderCounter.Load(), pageRenderCount)) + } + + b.AssertFileContent("public/index.html", "RegularPages: $", "Len RegularPagesRecursive: 7", "Site.Lastmod: 2019-03-02") + + b.AssertFileContent("public/mysect/p1/index.html", + "Single: P1|<p>P1 Content.", + "Shortcode Include: P2|", + "Codeblock Include: P3|") + + editFile("content/mysect/p1/index.md", func(s string) string { + return strings.ReplaceAll(s, "P1", "P1 Edited") + }) + + b.AssertFileContent("public/mysect/p1/index.html", "Single: P1 Edited|<p>P1 Edited Content.") + b.AssertFileContent("public/index.html", "RegularPages: $", "Len RegularPagesRecursive: 7", "Paginate: /mysect/sub/p5/|P5|/mysect/p1/|P1 Edited") + b.AssertFileContent("public/mysect/index.html", "RegularPages: P1 Edited|P2|P3|$", "Len RegularPagesRecursive: 5") + + // p2 is included in p1 via shortcode. + editFile("content/mysect/p2/index.md", func(s string) string { + return strings.ReplaceAll(s, "P2", "P2 Edited") + }) + + b.AssertFileContent("public/mysect/p1/index.html", "Shortcode Include: P2 Edited|") + + // p3 is included in p1 via codeblock hook. + editFile("content/mysect/p3/index.md", func(s string) string { + return strings.ReplaceAll(s, "P3", "P3 Edited") + }) + + b.AssertFileContent("public/mysect/p1/index.html", "Codeblock Include: P3 Edited|") + + // Remove a content file in a nested section. + b.RemoveFiles("content/mysect/sub/p4/index.md").Build() + b.AssertFileContent("public/mysect/index.html", "RegularPages: P1 Edited|P2 Edited|P3 Edited", "Len RegularPagesRecursive: 4") + b.AssertFileContent("public/mysect/sub/index.html", "RegularPages: P5|$", "RegularPagesRecursive: 1") + + // Rename one of the translations. + b.AssertFileContent("public/translations/index.html", "RegularPagesRecursive: /translations/p7/") + b.AssertFileContent("public/en/translations/index.html", "RegularPagesRecursive: /en/translations/p7/") + b.RenameFile("content/translations/p7.nn.md", "content/translations/p7rename.nn.md").Build() + b.AssertFileContent("public/translations/index.html", "RegularPagesRecursive: /translations/p7rename/") + b.AssertFileContent("public/en/translations/index.html", "RegularPagesRecursive: /en/translations/p7/") + + // Edit shortcode + editFile("layouts/shortcodes/include.html", func(s string) string { + return s + "\nShortcode Include Edited." + }) + b.AssertFileContent("public/mysect/p1/index.html", "Shortcode Include Edited.") + + // Edit render hook + editFile("layouts/_default/_markup/render-codeblock.html", func(s string) string { + return s + "\nCodeblock Include Edited." + }) + b.AssertFileContent("public/mysect/p1/index.html", "Codeblock Include Edited.") + + // Edit partial p1 + editFile("layouts/partials/p1.html", func(s string) string { + return strings.Replace(s, "Partial P1", "Partial P1 Edited", 1) + }) + b.AssertFileContent("public/mysect/index.html", "List Partial P1: Partial P1 Edited.") + b.AssertFileContent("public/mysect/p1/index.html", "Shortcode Partial P1: Partial P1 Edited.") + + // Edit partial cached. + editFile("layouts/partials/pcached.html", func(s string) string { + return strings.Replace(s, "Partial Pcached", "Partial Pcached Edited", 1) + }) + b.AssertFileContent("public/mysect/p1/index.html", "Pcached Edited.") + + // Edit lastMod date in content file, check site.Lastmod. + editFile("content/mysect/sub/p5/index.md", func(s string) string { + return strings.Replace(s, "2019-03-02", "2020-03-10", 1) + }) + b.AssertFileContent("public/index.html", "Site.Lastmod: 2020-03-10|") + b.AssertFileContent("public/mysect/index.html", "Page.Lastmod: 2020-03-10|") + + // Adjust the date back a few days. + editFile("content/mysect/sub/p5/index.md", func(s string) string { + return strings.Replace(s, "2020-03-10", "2019-03-08", 1) + }) + b.AssertFileContent("public/mysect/index.html", "Page.Lastmod: 2019-03-08|") + b.AssertFileContent("public/index.html", "Site.Lastmod: 2019-03-08|") + + // Check cascade mods. + b.AssertFileContent("public/myothersect/index.html", "Cascade param: cascadevalue|") + b.AssertFileContent("public/myothersect/sub/index.html", "Cascade param: cascadevalue|") + b.AssertFileContent("public/myothersect/sub/p6/index.html", "Cascade param: cascadevalue|") + + editFile("content/myothersect/_index.md", func(s string) string { + return strings.Replace(s, "cascadevalue", "cascadevalue edited", 1) + }) + b.AssertFileContent("public/myothersect/index.html", "Cascade param: cascadevalue edited|") + b.AssertFileContent("public/myothersect/sub/p6/index.html", "Cascade param: cascadevalue edited|") + + // Repurpose the cascadeparam to set the title. + editFile("content/myothersect/_index.md", func(s string) string { + return strings.Replace(s, "cascadeparam:", "title:", 1) + }) + b.AssertFileContent("public/myothersect/sub/index.html", "Cascade param: |", "List: cascadevalue edited|") + + // Revert it. + editFile("content/myothersect/_index.md", func(s string) string { + return strings.Replace(s, "title:", "cascadeparam:", 1) + }) + b.AssertFileContent("public/myothersect/sub/index.html", "Cascade param: cascadevalue edited|", "List: |") +} + +func TestRebuildVariationsJSNoneFingerprinted(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com/" +disableKinds = ["term", "taxonomy", "sitemap", "robotsTXT", "404", "rss"] +disableLiveReload = true +-- content/p1/index.md -- +--- +title: "P1" +--- +P1. +-- content/p2/index.md -- +--- +title: "P2" +--- +P2. +-- content/p3/index.md -- +--- +title: "P3" +--- +P3. +-- content/p4/index.md -- +--- +title: "P4" +--- +P4. +-- assets/main.css -- +body { + background: red; +} +-- layouts/default/list.html -- +List. +-- layouts/_default/single.html -- +Single. +{{ $css := resources.Get "main.css" | minify }} +RelPermalink: {{ $css.RelPermalink }}| + +` + + b := TestRunning(t, files) + + b.AssertFileContent("public/p1/index.html", "RelPermalink: /main.min.css|") + b.AssertFileContent("public/main.min.css", "body{background:red}") + + b.EditFileReplaceAll("assets/main.css", "red", "blue") + b.RemoveFiles("content/p2/index.md") + b.RemoveFiles("content/p3/index.md") + b.Build() + + b.AssertFileContent("public/main.min.css", "body{background:blue}") +} + +func TestRebuildVariationsJSInNestedCachedPartialFingerprinted(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com/" +disableKinds = ["term", "taxonomy", "sitemap", "robotsTXT", "404", "rss"] +disableLiveReload = true +-- content/p1/index.md -- +--- +title: "P1" +--- +P1. +-- content/p2/index.md -- +--- +title: "P2" +--- +P2. +-- content/p3/index.md -- +--- +title: "P3" +--- +P3. +-- content/p4/index.md -- +--- +title: "P4" +--- +P4. +-- assets/js/main.js -- +console.log("Hello"); +-- layouts/_default/list.html -- +List. {{ partial "head.html" . }}$ +-- layouts/_default/single.html -- +Single. {{ partial "head.html" . }}$ +-- layouts/partials/head.html -- +{{ partialCached "js.html" . }}$ +-- layouts/partials/js.html -- +{{ $js := resources.Get "js/main.js" | js.Build | fingerprint }} +RelPermalink: {{ $js.RelPermalink }}| +` + + b := TestRunning(t, files, TestOptOsFs()) + + b.AssertFileContent("public/p1/index.html", "/js/main.712a50b59d0f0dedb4e3606eaa3860b1f1a5305f6c42da30a2985e473ba314eb.js") + b.AssertFileContent("public/index.html", "/js/main.712a50b59d0f0dedb4e3606eaa3860b1f1a5305f6c42da30a2985e473ba314eb.js") + + b.EditFileReplaceAll("assets/js/main.js", "Hello", "Hello is Edited").Build() + + for i := 1; i < 5; i++ { + b.AssertFileContent(fmt.Sprintf("public/p%d/index.html", i), "/js/main.6535698cec9a21875f40ae03e96f30c4bee41a01e979224761e270b9034b2424.js") + } + + b.AssertFileContent("public/index.html", "/js/main.6535698cec9a21875f40ae03e96f30c4bee41a01e979224761e270b9034b2424.js") +} + +func TestRebuildVariationsJSInNestedPartialFingerprintedInBase(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com/" +disableKinds = ["term", "taxonomy", "sitemap", "robotsTXT", "404", "rss"] +disableLiveReload = true +-- assets/js/main.js -- +console.log("Hello"); +-- layouts/_default/baseof.html -- +Base. {{ partial "common/head.html" . }}$ +{{ block "main" . }}default{{ end }} +-- layouts/_default/list.html -- +{{ define "main" }}main{{ end }} +-- layouts/partials/common/head.html -- +{{ partial "myfiles/js.html" . }}$ +-- layouts/partials/myfiles/js.html -- +{{ $js := resources.Get "js/main.js" | js.Build | fingerprint }} +RelPermalink: {{ $js.RelPermalink }}| +` + + b := TestRunning(t, files, TestOptOsFs()) + + b.AssertFileContent("public/index.html", "/js/main.712a50b59d0f0dedb4e3606eaa3860b1f1a5305f6c42da30a2985e473ba314eb.js") + + b.EditFileReplaceAll("assets/js/main.js", "Hello", "Hello is Edited").Build() + + b.AssertFileContent("public/index.html", "/js/main.6535698cec9a21875f40ae03e96f30c4bee41a01e979224761e270b9034b2424.js") +} + +func TestRebuildVariationsJSBundled(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ["term", "taxonomy", "sitemap", "robotsTXT", "404", "rss"] +disableLiveReload = true +-- content/_index.md -- +--- +title: "Home" +--- +-- content/p1.md -- +--- +title: "P1" +layout: "main" +--- +-- content/p2.md -- +--- +title: "P2" +--- +{{< jsfingerprinted >}} +-- content/p3.md -- +--- +title: "P3" +layout: "plain" +--- +{{< jsfingerprinted >}} +-- content/main.js -- +console.log("Hello"); +-- content/foo.js -- +console.log("Foo"); +-- layouts/index.html -- +Home. +{{ $js := site.Home.Resources.Get "main.js" }} +{{ with $js }} +<script src="{{ .RelPermalink }}"></script> +{{ end }} +-- layouts/_default/single.html -- +Single. Deliberately no .Content in here. +-- layouts/_default/plain.html -- +Content: {{ .Content }}| +-- layouts/_default/main.html -- +{{ $js := site.Home.Resources.Get "main.js" }} +{{ with $js }} +<script> +{{ .Content }} +</script> +{{ end }} +-- layouts/shortcodes/jsfingerprinted.html -- +{{ $js := site.Home.Resources.Get "foo.js" | fingerprint }} +<script src="{{ $js.RelPermalink }}"></script> +` + + testCounters := &buildCounters{} + + b := NewIntegrationTestBuilder( + IntegrationTestConfig{ + T: t, + TxtarString: files, + Running: true, + // LogLevel: logg.LevelTrace, + // Verbose: true, + BuildCfg: BuildCfg{ + testCounters: testCounters, + }, + }, + ).Build() + + b.AssertFileContent("public/index.html", `<script src="/main.js"></script>`) + b.AssertFileContent("public/p1/index.html", "<script>\n\"console.log(\\\"Hello\\\");\"\n</script>") + b.AssertFileContent("public/p2/index.html", "Single. Deliberately no .Content in here.") + b.AssertFileContent("public/p3/index.html", "foo.57b4465b908531b43d4e4680ab1063d856b475cb1ae81ad43e0064ecf607bec1.js") + b.AssertRenderCountPage(4) + + // Edit JS file. + b.EditFileReplaceFunc("content/main.js", func(s string) string { + return strings.Replace(s, "Hello", "Hello is Edited", 1) + }).Build() + + b.AssertFileContent("public/p1/index.html", "<script>\n\"console.log(\\\"Hello is Edited\\\");\"\n</script>") + // The p1 (the one inlining the JS) should be rebuilt. + b.AssertRenderCountPage(2) + // But not the content file. + b.AssertRenderCountContent(0) + + // This is included with RelPermalink in a shortcode used in p3, but it's fingerprinted + // so we need to rebuild on change. + b.EditFileReplaceFunc("content/foo.js", func(s string) string { + return strings.Replace(s, "Foo", "Foo Edited", 1) + }).Build() + + // Verify that the hash has changed. + b.AssertFileContent("public/p3/index.html", "foo.3a332a088521231e5fc9bd22f15e0ccf507faa7b373fbff22959005b9a80481c.js") + + b.AssertRenderCountPage(1) + b.AssertRenderCountContent(1) +} + +func TestRebuildEditData(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableLiveReload = true +[security] +enableInlineShortcodes=true +-- data/mydata.yaml -- +foo: bar +-- content/_index.md -- +--- +title: "Home" +--- +{{< data "mydata.foo" >}}} +-- content/p1.md -- +--- +title: "P1" +--- + +Foo inline: {{< foo.inline >}}{{ site.Data.mydata.foo }}|{{< /foo.inline >}} +-- layouts/shortcodes/data.html -- +{{ $path := split (.Get 0) "." }} +{{ $data := index site.Data $path }} +Foo: {{ $data }}| +-- layouts/index.html -- +Content: {{ .Content }}| +-- layouts/_default/single.html -- +Single: {{ .Content }}| +` + b := TestRunning(t, files) + + b.AssertFileContent("public/index.html", "Foo: bar|") + b.AssertFileContent("public/p1/index.html", "Foo inline: bar|") + b.EditFileReplaceFunc("data/mydata.yaml", func(s string) string { + return strings.Replace(s, "bar", "bar edited", 1) + }).Build() + b.AssertFileContent("public/index.html", "Foo: bar edited|") + b.AssertFileContent("public/p1/index.html", "Foo inline: bar edited|") +} + +func TestRebuildEditHomeContent(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableLiveReload = true +-- content/_index.md -- +--- +title: "Home" +--- +Home. +-- layouts/index.html -- +Content: {{ .Content }} +` + b := TestRunning(t, files) + + b.AssertFileContent("public/index.html", "Content: <p>Home.</p>") + b.EditFileReplaceAll("content/_index.md", "Home.", "Home").Build() + b.AssertFileContent("public/index.html", "Content: <p>Home</p>") +} + +// Issue #13014. +func TestRebuildEditNotPermalinkableCustomOutputFormatTemplateInFastRenderMode(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com/docs/" +disableLiveReload = true +[internal] +fastRenderMode = true +disableKinds = ["taxonomy", "term", "sitemap", "robotsTXT", "404"] +[outputFormats] + [outputFormats.SearchIndex] + baseName = 'Search' + isPlainText = true + mediaType = 'text/plain' + noAlternative = true + permalinkable = false + +[outputs] + home = ['HTML', 'SearchIndex'] +-- content/_index.md -- +--- +title: "Home" +--- +Home. +-- layouts/index.html -- +Home. +-- layouts/_default/index.searchindex.txt -- +Text. {{ .Title }}|{{ .RelPermalink }}| + +` + b := TestRunning(t, files, TestOptInfo()) + + b.AssertFileContent("public/search.txt", "Text.") + + b.EditFileReplaceAll("layouts/_default/index.searchindex.txt", "Text.", "Text Edited.").Build() + + b.BuildPartial("/docs/search.txt") + + b.AssertFileContent("public/search.txt", "Text Edited.") +} + +func TestRebuildVariationsAssetsJSImport(t *testing.T) { + t.Parallel() + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ["term", "taxonomy"] +disableLiveReload = true +-- layouts/index.html -- +Home. {{ now }} +{{ with (resources.Get "js/main.js" | js.Build | fingerprint) }} +<script>{{ .Content | safeJS }}</script> +{{ end }} +-- assets/js/lib/foo.js -- +export function foo() { + console.log("Foo"); +} +-- assets/js/main.js -- +import { foo } from "./lib/foo.js"; +console.log("Hello"); +foo(); +` + + b := NewIntegrationTestBuilder( + IntegrationTestConfig{ + T: t, + TxtarString: files, + Running: true, + // LogLevel: logg.LevelTrace, + NeedsOsFS: true, + }, + ).Build() + + b.AssertFileContent("public/index.html", "Home.", "Hello", "Foo") + // Edit the imported file. + b.EditFileReplaceAll("assets/js/lib/foo.js", "Foo", "Foo Edited").Build() + b.AssertFileContent("public/index.html", "Home.", "Hello", "Foo Edited") +} + +func TestRebuildVariationsAssetsPostCSSImport(t *testing.T) { + if !htesting.IsCI() { + t.Skip("skip CI only") + } + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ["term", "taxonomy", "sitemap", "rss"] +disableLiveReload = true +-- assets/css/lib/foo.css -- +body { + background: red; +} +-- assets/css/main.css -- +@import "lib/foo.css"; +-- package.json -- +{ + "devDependencies": { + "postcss-cli": "^9.0.1" + } +} +-- content/p1.md -- +--- +title: "P1" +--- +-- content/p2.md -- +--- +title: "P2" +layout: "foo" +--- +{{< fingerprinted >}} +-- content/p3.md -- +--- +title: "P3" +layout: "foo" +--- +{{< notfingerprinted >}} +-- layouts/shortcodes/fingerprinted.html -- +Fingerprinted. +{{ $opts := dict "inlineImports" true "noMap" true }} +{{ with (resources.Get "css/main.css" | postCSS $opts | fingerprint) }} +<style src="{{ .RelPermalink }}"></style> +{{ end }} +-- layouts/shortcodes/notfingerprinted.html -- +Fingerprinted. +{{ $opts := dict "inlineImports" true "noMap" true }} +{{ with (resources.Get "css/main.css" | postCSS $opts) }} +<style src="{{ .RelPermalink }}"></style> +{{ end }} +-- layouts/index.html -- +Home. +{{ $opts := dict "inlineImports" true "noMap" true }} +{{ with (resources.Get "css/main.css" | postCSS $opts) }} +<style>{{ .Content | safeCSS }}</style> +{{ end }} +-- layouts/_default/foo.html -- +Foo. +{{ .Title }}|{{ .Content }}| +-- layouts/_default/single.html -- +Single. +{{ $opts := dict "inlineImports" true "noMap" true }} +{{ with (resources.Get "css/main.css" | postCSS $opts) }} +<style src="{{ .RelPermalink }}"></style> +{{ end }} +` + + b := NewIntegrationTestBuilder( + IntegrationTestConfig{ + T: t, + TxtarString: files, + Running: true, + NeedsOsFS: true, + NeedsNpmInstall: true, + // LogLevel: logg.LevelDebug, + }, + ).Build() + + b.AssertFileContent("public/index.html", "Home.", "<style>body {\n\tbackground: red;\n}</style>") + b.AssertFileContent("public/p1/index.html", "Single.", "/css/main.css") + b.AssertRenderCountPage(4) + + // Edit the imported file. + b.EditFileReplaceFunc("assets/css/lib/foo.css", func(s string) string { + return strings.Replace(s, "red", "blue", 1) + }).Build() + + b.AssertRenderCountPage(4) + + b.AssertFileContent("public/index.html", "Home.", "<style>body {\n\tbackground: blue;\n}</style>") +} + +func TestRebuildI18n(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableLiveReload = true +-- i18n/en.toml -- +hello = "Hello" +-- layouts/index.html -- +Hello: {{ i18n "hello" }} +` + + b := TestRunning(t, files) + + b.AssertFileContent("public/index.html", "Hello: Hello") + + b.EditFileReplaceAll("i18n/en.toml", "Hello", "Hugo").Build() + + b.AssertFileContent("public/index.html", "Hello: Hugo") +} + +func TestRebuildEditContentNonDefaultLanguage(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableLiveReload = true +defaultContentLanguage = "en" +defaultContentLanguageInSubdir = true +[languages] +[languages.en] +weight = 1 +[languages.nn] +weight = 2 +-- content/p1/index.en.md -- +--- +title: "P1 en" +--- +P1 en. +-- content/p1/b.en.md -- +--- +title: "B en" +--- +B en. +-- content/p1/f1.en.txt -- +F1 en +-- content/p1/index.nn.md -- +--- +title: "P1 nn" +--- +P1 nn. +-- content/p1/b.nn.md -- +--- +title: "B nn" +--- +B nn. +-- content/p1/f1.nn.txt -- +F1 nn +-- layouts/_default/single.html -- +Single: {{ .Title }}|{{ .Content }}|Bundled File: {{ with .Resources.GetMatch "f1.*" }}{{ .Content }}{{ end }}|Bundled Page: {{ with .Resources.GetMatch "b.*" }}{{ .Content }}{{ end }}| +` + + b := TestRunning(t, files) + + b.AssertFileContent("public/nn/p1/index.html", "Single: P1 nn|<p>P1 nn.</p>", "F1 nn|") + b.EditFileReplaceAll("content/p1/index.nn.md", "P1 nn.", "P1 nn edit.").Build() + b.AssertFileContent("public/nn/p1/index.html", "Single: P1 nn|<p>P1 nn edit.</p>\n|") + b.EditFileReplaceAll("content/p1/f1.nn.txt", "F1 nn", "F1 nn edit.").Build() + b.AssertFileContent("public/nn/p1/index.html", "Bundled File: F1 nn edit.") + b.EditFileReplaceAll("content/p1/b.nn.md", "B nn.", "B nn edit.").Build() + b.AssertFileContent("public/nn/p1/index.html", "B nn edit.") +} + +func TestRebuildEditContentNonDefaultLanguageDifferentBundles(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableLiveReload = true +defaultContentLanguage = "en" +defaultContentLanguageInSubdir = true +[languages] +[languages.en] +weight = 1 +contentDir = "content/en" +[languages.nn] +weight = 2 +contentDir = "content/nn" +-- content/en/p1en/index.md -- +--- +title: "P1 en" +--- +-- content/nn/p1nn/index.md -- +--- +title: "P1 nn" +--- +P1 nn. +-- layouts/_default/single.html -- +Single: {{ .Title }}|{{ .Content }}| +` + + b := TestRunning(t, files) + + b.AssertFileContent("public/nn/p1nn/index.html", "Single: P1 nn|<p>P1 nn.</p>") + b.EditFileReplaceAll("content/nn/p1nn/index.md", "P1 nn.", "P1 nn edit.").Build() + b.AssertFileContent("public/nn/p1nn/index.html", "Single: P1 nn|<p>P1 nn edit.</p>\n|") + b.AssertFileContent("public/nn/p1nn/index.html", "P1 nn edit.") +} + +func TestRebuildVariationsAssetsSassImport(t *testing.T) { + if !htesting.IsCI() { + t.Skip("skip CI only") + } + + filesTemplate := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ["term", "taxonomy"] +disableLiveReload = true +-- assets/css/lib/foo.scss -- +body { + background: red; +} +-- assets/css/main.scss -- +@import "lib/foo"; +-- layouts/index.html -- +Home. +{{ $opts := dict "transpiler" "TRANSPILER" }} +{{ with (resources.Get "css/main.scss" | toCSS $opts) }} +<style>{{ .Content | safeCSS }}</style> +{{ end }} +` + + runTest := func(transpiler string) { + t.Run(transpiler, func(t *testing.T) { + files := strings.Replace(filesTemplate, "TRANSPILER", transpiler, 1) + b := NewIntegrationTestBuilder( + IntegrationTestConfig{ + T: t, + TxtarString: files, + Running: true, + NeedsOsFS: true, + }, + ).Build() + + b.AssertFileContent("public/index.html", "Home.", "background: red") + + // Edit the imported file. + b.EditFileReplaceFunc("assets/css/lib/foo.scss", func(s string) string { + return strings.Replace(s, "red", "blue", 1) + }).Build() + + b.AssertFileContent("public/index.html", "Home.", "background: blue") + }) + } + + if scss.Supports() { + runTest("libsass") + } + + if dartsass.Supports() { + runTest("dartsass") + } +} + +func benchmarkFilesEdit(count int) string { + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ["term", "taxonomy"] +disableLiveReload = true +-- layouts/_default/single.html -- +Single: {{ .Title }}|{{ .Content }}| +-- layouts/_default/list.html -- +List: {{ .Title }}|{{ .Content }}| +-- content/mysect/_index.md -- +--- +title: "My Sect" +--- + ` + + contentTemplate := ` +--- +title: "P%d" +--- +P%d Content. +` + + for i := range count { + files += fmt.Sprintf("-- content/mysect/p%d/index.md --\n%s", i, fmt.Sprintf(contentTemplate, i, i)) + } + + return files +} + +func BenchmarkRebuildContentFileChange(b *testing.B) { + files := benchmarkFilesEdit(500) + + cfg := IntegrationTestConfig{ + T: b, + TxtarString: files, + Running: true, + // Verbose: true, + // LogLevel: logg.LevelInfo, + } + builders := make([]*IntegrationTestBuilder, b.N) + + for i := range builders { + builders[i] = NewIntegrationTestBuilder(cfg) + builders[i].Build() + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + bb := builders[i] + bb.EditFileReplaceFunc("content/mysect/p123/index.md", func(s string) string { + return s + "... Edited" + }).Build() + // fmt.Println(bb.LogString()) + } +} + +func TestRebuildConcat(t *testing.T) { + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableLiveReload = true +disableKinds = ["taxonomy", "term", "sitemap", "robotsTXT", "404", "rss"] +-- assets/a.css -- +a +-- assets/b.css -- +b +-- assets/c.css -- +c +-- assets/common/c1.css -- +c1 +-- assets/common/c2.css -- +c2 +-- layouts/index.html -- +{{ $a := resources.Get "a.css" }} +{{ $b := resources.Get "b.css" }} +{{ $common := resources.Match "common/*.css" | resources.Concat "common.css" | minify }} +{{ $ab := slice $a $b $common | resources.Concat "ab.css" }} +all: {{ $ab.RelPermalink }} +` + b := TestRunning(t, files) + + b.AssertFileContent("public/ab.css", "abc1c2") + b.EditFileReplaceAll("assets/common/c2.css", "c2", "c2 edited").Build() + b.AssertFileContent("public/ab.css", "abc1c2 edited") + b.AddFiles("assets/common/c3.css", "c3").Build() + b.AssertFileContent("public/ab.css", "abc1c2 editedc3") +} + +func TestRebuildEditArchetypeFile(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableLiveReload = true +-- archetypes/default.md -- +--- +title: "Default" +--- +` + + b := TestRunning(t, files) + // Just make sure that it doesn't panic. + b.EditFileReplaceAll("archetypes/default.md", "Default", "Default Edited").Build() +} + +func TestRebuildEditMixedCaseTemplateFileIssue12165(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableLiveReload = true +-- layouts/partials/MyTemplate.html -- +MyTemplate +-- layouts/index.html -- +MyTemplate: {{ partial "MyTemplate.html" . }}| + + +` + + b := TestRunning(t, files) + + b.AssertFileContent("public/index.html", "MyTemplate: MyTemplate") + + b.EditFileReplaceAll("layouts/partials/MyTemplate.html", "MyTemplate", "MyTemplate Edited").Build() + + b.AssertFileContent("public/index.html", "MyTemplate: MyTemplate Edited") +} + +func TestRebuildEditInlinePartial13723(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableLiveReload = true +title = "Foo" +-- layouts/baseof.html -- +{{ block "main" . }}Main.{{ end }} +{{ partial "myinlinepartialinbaseof.html" . }}| + {{- define "_partials/myinlinepartialinbaseof.html" }} + My inline partial in baseof. + {{ end }} +-- layouts/_partials/mypartial.html -- +Mypartial. +{{ partial "myinlinepartial.html" . }}| +{{- define "_partials/myinlinepartial.html" }} +Mypartial Inline.|{{ .Title }}| +{{ end }} +-- layouts/_partials/myotherpartial.html -- +Myotherpartial. +{{ partial "myotherinlinepartial.html" . }}| +{{- define "_partials/myotherinlinepartial.html" }} +Myotherpartial Inline.|{{ .Title }}| +{{ return "myotherinlinepartial" }} +{{ end }} +-- layouts/all.html -- +{{ define "main" }} +{{ partial "mypartial.html" . }}| +{{ partial "myotherpartial.html" . }}| + {{ partial "myinlinepartialinall.html" . }}| +{{ end }} + {{- define "_partials/myinlinepartialinall.html" }} + My inline partial in all. + {{ end }} + +` + b := TestRunning(t, files) + b.AssertFileContent("public/index.html", "Mypartial.", "Mypartial Inline.|Foo") + + // Edit inline partial in partial. + b.EditFileReplaceAll("layouts/_partials/mypartial.html", "Mypartial Inline.", "Mypartial Inline Edited.").Build() + b.AssertFileContent("public/index.html", "Mypartial Inline Edited.|Foo") + + // Edit inline partial in baseof. + b.EditFileReplaceAll("layouts/baseof.html", "My inline partial in baseof.", "My inline partial in baseof Edited.").Build() + b.AssertFileContent("public/index.html", "My inline partial in baseof Edited.") + + // Edit inline partial in all. + b.EditFileReplaceAll("layouts/all.html", "My inline partial in all.", "My inline partial in all Edited.").Build() + b.AssertFileContent("public/index.html", "My inline partial in all Edited.") +} + +func TestRebuildEditAsciidocContentFile(t *testing.T) { + if !asciidocext.Supports() { + t.Skip("skip asciidoc") + } + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableLiveReload = true +disableKinds = ["taxonomy", "term", "sitemap", "robotsTXT", "404", "rss", "home", "section"] +[security] +[security.exec] +allow = ['^python$', '^rst2html.*', '^asciidoctor$'] +-- content/posts/p1.adoc -- +--- +title: "P1" +--- +P1 Content. +-- content/posts/p2.adoc -- +--- +title: "P2" +--- +P2 Content. +-- layouts/_default/single.html -- +Single: {{ .Title }}|{{ .Content }}| +` + b := TestRunning(t, files) + b.AssertFileContent("public/posts/p1/index.html", + "Single: P1|<div class=\"paragraph\">\n<p>P1 Content.</p>\n</div>\n|") + b.AssertRenderCountPage(2) + b.AssertRenderCountContent(2) + + b.EditFileReplaceAll("content/posts/p1.adoc", "P1 Content.", "P1 Content Edited.").Build() + + b.AssertFileContent("public/posts/p1/index.html", "Single: P1|<div class=\"paragraph\">\n<p>P1 Content Edited.</p>\n</div>\n|") + b.AssertRenderCountPage(1) + b.AssertRenderCountContent(1) +} + +func TestRebuildEditSingleListChangeUbuntuIssue12362(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['rss','section','sitemap','taxonomy','term'] +disableLiveReload = true +-- layouts/_default/list.html -- +{{ range .Pages }}{{ .Title }}|{{ end }} +-- layouts/_default/single.html -- +{{ .Title }} +-- content/p1.md -- +--- +title: p1 +--- +` + + b := TestRunning(t, files) + b.AssertFileContent("public/index.html", "p1|") + + b.AddFiles("content/p2.md", "---\ntitle: p2\n---").Build() + b.AssertFileContent("public/index.html", "p1|p2|") // this test passes, which doesn't match reality +} + +func TestRebuildHomeThenPageIssue12436(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ['sitemap','taxonomy','term'] +disableLiveReload = true +-- layouts/_default/list.html -- +{{ .Content }} +-- layouts/_default/single.html -- +{{ .Content }} +-- content/_index.md -- +--- +title: home +--- +home-content| +-- content/p1/index.md -- +--- +title: p1 +--- +p1-content| +` + + b := TestRunning(t, files) + + b.AssertFileContent("public/index.html", "home-content|") + b.AssertFileContent("public/p1/index.html", "p1-content|") + b.AssertRenderCountPage(3) + + b.EditFileReplaceAll("content/_index.md", "home-content", "home-content-foo").Build() + b.AssertFileContent("public/index.html", "home-content-foo") + b.AssertRenderCountPage(2) // Home page rss + html + + b.EditFileReplaceAll("content/p1/index.md", "p1-content", "p1-content-foo").Build() + b.AssertFileContent("public/p1/index.html", "p1-content-foo") +} + +func TestRebuildEditTagIssue13648(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableLiveReload = true +-- layouts/all.html -- +All. {{ range .Pages }}{{ .Title }}|{{ end }} +-- content/p1.md -- +--- +title: "P1" +tags: ["tag1"] +--- + +` + b := TestRunning(t, files) + + b.AssertFileContent("public/tags/index.html", "All. Tag1|") + b.EditFileReplaceAll("content/p1.md", "tag1", "tag2").Build() + + // Note that the below is still not correct, as this is effectively a rename, and + // Tag2 should be removed from the list. + // But that is a harder problem to tackle. + b.AssertFileContent("public/tags/index.html", "All. Tag1|Tag2|") +} + +func TestRebuildEditNonReferencedResourceIssue13748(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableLiveReload = true +-- content/mybundle/index.md -- +-- content/mybundle/resource.txt -- +This is a resource file. +-- layouts/all.html -- +All. +` + b := TestRunning(t, files) + + b.AssertFileContent("public/mybundle/resource.txt", "This is a resource file.") + b.EditFileReplaceAll("content/mybundle/resource.txt", "This is a resource file.", "This is an edited resource file.").Build() + b.AssertFileContent("public/mybundle/resource.txt", "This is an edited resource file.") +} diff --git a/hugolib/rendershortcodes_test.go b/hugolib/rendershortcodes_test.go new file mode 100644 index 000000000..d8b51d3ed --- /dev/null +++ b/hugolib/rendershortcodes_test.go @@ -0,0 +1,529 @@ +// 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 hugolib + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestRenderShortcodesBasic(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ["home", "taxonomy", "term"] +-- content/p1.md -- +--- +title: "p1" +--- +## p1-h1 +{{% include "p2" %}} +-- content/p2.md -- +--- +title: "p2" +--- +### p2-h1 +{{< withhtml >}} +### p2-h2 +{{% withmarkdown %}} +### p2-h3 +{{% include "p3" %}} +-- content/p3.md -- +--- +title: "p3" +--- +### p3-h1 +{{< withhtml >}} +### p3-h2 +{{% withmarkdown %}} +{{< level3 >}} +-- layouts/shortcodes/include.html -- +{{ $p := site.GetPage (.Get 0) }} +{{ $p.RenderShortcodes }} +-- layouts/shortcodes/withhtml.html -- +<div>{{ .Page.Title }} withhtml</div> +-- layouts/shortcodes/withmarkdown.html -- +#### {{ .Page.Title }} withmarkdown +-- layouts/shortcodes/level3.html -- +Level 3: {{ .Page.Title }} +-- layouts/_default/single.html -- +Fragments: {{ .Fragments.Identifiers }}| +HasShortcode Level 1: {{ .HasShortcode "include" }}| +HasShortcode Level 2: {{ .HasShortcode "withmarkdown" }}| +HasShortcode Level 3: {{ .HasShortcode "level3" }}| +HasShortcode not found: {{ .HasShortcode "notfound" }}| +Content: {{ .Content }}| +` + + b := Test(t, files) + + b.AssertNoRenderShortcodesArtifacts() + b.AssertFileContent("public/p1/index.html", + "Fragments: [p1-h1 p2-h1 p2-h2 p2-h3 p2-withmarkdown p3-h1 p3-h2 p3-withmarkdown]|", + "HasShortcode Level 1: true|", + "HasShortcode Level 2: true|", + "HasShortcode Level 3: true|", + "HasShortcode not found: false|", + ) +} + +func TestRenderShortcodesNestedMultipleOutputFormatTemplates(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ["home", "taxonomy", "term", "section", "rss", "sitemap", "robotsTXT", "404"] +[outputs] +page = ["html", "json"] +-- content/p1.md -- +--- +title: "p1" +--- +## p1-h1 +{{% include "p2" %}} +-- content/p2.md -- +--- +title: "p2" +--- +### p2-h1 +{{% myshort %}} +-- layouts/shortcodes/include.html -- +{{ $p := site.GetPage (.Get 0) }} +{{ $p.RenderShortcodes }} +-- layouts/shortcodes/myshort.html -- +Myshort HTML. +-- layouts/shortcodes/myshort.json -- +Myshort JSON. +-- layouts/_default/single.html -- +HTML: {{ .Content }} +-- layouts/_default/single.json -- +JSON: {{ .Content }} + + +` + + b := Test(t, files) + + b.AssertNoRenderShortcodesArtifacts() + b.AssertFileContent("public/p1/index.html", "Myshort HTML") + b.AssertFileContent("public/p1/index.json", "Myshort JSON") +} + +func TestRenderShortcodesEditNested(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableLiveReload = true +disableKinds = ["home", "taxonomy", "term", "section", "rss", "sitemap", "robotsTXT", "404"] +-- content/p1.md -- +--- +title: "p1" +--- +## p1-h1 +{{% include "p2" %}} +-- content/p2.md -- +--- +title: "p2" +--- +### p2-h1 +{{% myshort %}} +-- layouts/shortcodes/include.html -- +{{ $p := site.GetPage (.Get 0) }} +{{ $p.RenderShortcodes }} +-- layouts/shortcodes/myshort.html -- +Myshort Original. +-- layouts/_default/single.html -- + {{ .Content }} +` + b := TestRunning(t, files) + b.AssertNoRenderShortcodesArtifacts() + b.AssertFileContent("public/p1/index.html", "Myshort Original.") + + b.EditFileReplaceAll("layouts/shortcodes/myshort.html", "Original", "Edited").Build() + b.AssertNoRenderShortcodesArtifacts() + b.AssertFileContent("public/p1/index.html", "Myshort Edited.") +} + +func TestRenderShortcodesEditIncludedPage(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableLiveReload = true +disableKinds = ["home", "taxonomy", "term", "section", "rss", "sitemap", "robotsTXT", "404"] +-- content/p1.md -- +--- +title: "p1" +--- +## p1-h1 +{{% include "p2" %}} +-- content/p2.md -- +--- +title: "p2" +--- +### Original +{{% myshort %}} +-- layouts/shortcodes/include.html -- +{{ $p := site.GetPage (.Get 0) }} +{{ $p.RenderShortcodes }} +-- layouts/shortcodes/myshort.html -- +Myshort Original. +-- layouts/_default/single.html -- + {{ .Content }} + + + +` + + b := NewIntegrationTestBuilder( + IntegrationTestConfig{ + T: t, + TxtarString: files, + Running: true, + }, + ).Build() + + b.AssertNoRenderShortcodesArtifacts() + b.AssertFileContent("public/p1/index.html", "Original") + + b.EditFileReplaceFunc("content/p2.md", func(s string) string { + return strings.Replace(s, "Original", "Edited", 1) + }) + b.Build() + b.AssertNoRenderShortcodesArtifacts() + b.AssertFileContent("public/p1/index.html", "Edited") +} + +func TestRenderShortcodesEditSectionContentWithShortcodeInIncludedPageIssue12458(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableLiveReload = true +disableKinds = ["home", "taxonomy", "term", "rss", "sitemap", "robotsTXT", "404"] +-- content/mysection/_index.md -- +--- +title: "My Section" +--- +## p1-h1 +{{% include "p2" %}} +-- content/mysection/p2.md -- +--- +title: "p2" +--- +### Original +{{% myshort %}} +-- layouts/shortcodes/include.html -- +{{ $p := .Page.GetPage (.Get 0) }} +{{ $p.RenderShortcodes }} +-- layouts/shortcodes/myshort.html -- +Myshort Original. +-- layouts/_default/list.html -- + {{ .Content }} + + + +` + b := TestRunning(t, files) + + b.AssertNoRenderShortcodesArtifacts() + b.AssertFileContent("public/mysection/index.html", "p1-h1") + b.EditFileReplaceAll("content/mysection/_index.md", "p1-h1", "p1-h1 Edited").Build() + b.AssertNoRenderShortcodesArtifacts() + b.AssertFileContent("public/mysection/index.html", "p1-h1 Edited") +} + +func TestRenderShortcodesNestedPageContextIssue12356(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "rss", "sitemap", "robotsTXT", "404"] +-- layouts/_default/_markup/render-image.html -- +{{- with .PageInner.Resources.Get .Destination -}}Image: {{ .RelPermalink }}|{{- end -}} +-- layouts/_default/_markup/render-link.html -- +{{- with .PageInner.GetPage .Destination -}}Link: {{ .RelPermalink }}|{{- end -}} +-- layouts/_default/_markup/render-heading.html -- +Heading: {{ .PageInner.Title }}: {{ .PlainText }}| +-- layouts/_default/_markup/render-codeblock.html -- +CodeBlock: {{ .PageInner.Title }}: {{ .Type }}| +-- layouts/_default/list.html -- +Content:{{ .Content }}| +Fragments: {{ with .Fragments }}{{.Identifiers }}{{ end }}| +-- layouts/_default/single.html -- +Content:{{ .Content }}| +-- layouts/shortcodes/include.html -- +{{ with site.GetPage (.Get 0) }} + {{ .RenderShortcodes }} +{{ end }} +-- content/markdown/_index.md -- +--- +title: "Markdown" +--- +# H1 +|{{% include "/posts/p1" %}}| +![kitten](pixel3.png "Pixel 3") + +§§§go +fmt.Println("Hello") +§§§ + +-- content/markdown2/_index.md -- +--- +title: "Markdown 2" +--- +|{{< include "/posts/p1" >}}| +-- content/html/_index.html -- +--- +title: "HTML" +--- +|{{% include "/posts/p1" %}}| + +-- content/posts/p1/index.md -- +--- +title: "p1" +--- +## H2-p1 +![kitten](pixel1.png "Pixel 1") +![kitten](pixel2.png "Pixel 2") +[p2](p2) + +§§§bash +echo "Hello" +§§§ + +-- content/posts/p2/index.md -- +--- +title: "p2" +--- +-- content/posts/p1/pixel1.png -- +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg== +-- content/posts/p1/pixel2.png -- +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg== +-- content/markdown/pixel3.png -- +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg== +-- content/html/pixel4.png -- +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg== + +` + + b := Test(t, files) + + b.AssertNoRenderShortcodesArtifacts() + + b.AssertFileContent("public/markdown/index.html", + // Images. + "Image: /posts/p1/pixel1.png|\nImage: /posts/p1/pixel2.png|\n|\nImage: /markdown/pixel3.png|</p>\n|", + // Links. + "Link: /posts/p2/|", + // Code blocks + "CodeBlock: p1: bash|", "CodeBlock: Markdown: go|", + // Headings. + "Heading: Markdown: H1|", "Heading: p1: H2-p1|", + // Fragments. + "Fragments: [h1 h2-p1]|", + // Check that the special context markup is not rendered. + "! hugo_ctx", + ) + + b.AssertFileContent("public/markdown2/index.html", "! hugo_ctx", "Content:<p>|\n ![kitten](pixel1.png \"Pixel 1\")\n![kitten](pixel2.png \"Pixel 2\")\n|</p>\n|") + + b.AssertFileContent("public/html/index.html", "! hugo_ctx") +} + +// Issue 12854. +func TestRenderShortcodesWithHTML(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableLiveReload = true +disableKinds = ["home", "taxonomy", "term"] +markup.goldmark.renderer.unsafe = true +-- content/p1.md -- +--- +title: "p1" +--- +{{% include "p2" %}} +-- content/p2.md -- +--- +title: "p2" +--- +Hello <b>world</b>. Some **bold** text. Some Unicode: 神真美好. +-- layouts/shortcodes/include.html -- +{{ with site.GetPage (.Get 0) }} +<div>{{ .RenderShortcodes }}</div> +{{ end }} +-- layouts/_default/single.html -- +{{ .Content }} +` + + b := TestRunning(t, files, TestOptWarn()) + + b.AssertNoRenderShortcodesArtifacts() + b.AssertLogContains(filepath.ToSlash("WARN .RenderShortcodes detected inside HTML block in \"/content/p1.md\"; this may not be what you intended, see https://gohugo.io/methods/page/rendershortcodes/#limitations\nYou can suppress this warning by adding the following to your site configuration:\nignoreLogs = ['warning-rendershortcodes-in-html']")) + b.AssertFileContent("public/p1/index.html", "<div>Hello <b>world</b>. Some **bold** text. Some Unicode: 神真美好.\n</div>") + b.EditFileReplaceAll("content/p2.md", "Hello", "Hello Edited").Build() + b.AssertNoRenderShortcodesArtifacts() + b.AssertFileContent("public/p1/index.html", "<div>Hello Edited <b>world</b>. Some **bold** text. Some Unicode: 神真美好.\n</div>") +} + +func TestRenderShortcodesIncludeMarkdownFileWithoutTrailingNewline(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableLiveReload = true +disableKinds = ["home", "taxonomy", "term"] +markup.goldmark.renderer.unsafe = true +-- content/p1.md -- +--- +title: "p1" +--- +Content p1 id-1000.{{% include "p2" %}}{{% include "p3" %}} + +§§§ go +code_p1 +§§§ +§§§ go +code_p1_2 +§§§ + +§§§ go +code_p1_3 +§§§ +-- content/p2.md -- +--- +title: "p2" +--- +§§§ bash +code_p2 +§§§ +Foo. +-- content/p3.md -- +--- +title: "p3" +--- +§§§ php +code_p3 +§§§ +-- layouts/shortcodes/include.html -- +{{ with site.GetPage (.Get 0) -}} +{{ .RenderShortcodes -}} +{{ end -}} +-- layouts/_default/single.html -- +{{ .Content }} +-- layouts/_default/_markup/render-codeblock.html -- +<code>{{ .Inner | safeHTML }}</code> +` + + b := TestRunning(t, files, TestOptWarn()) + + b.AssertNoRenderShortcodesArtifacts() + b.AssertFileContentEquals("public/p1/index.html", "<p>Content p1 id-1000.</p>\n<code>code_p2</code><p>Foo.</p>\n<code>code_p3</code><code>code_p1</code><code>code_p1_2</code><code>code_p1_3</code>") + b.EditFileReplaceAll("content/p1.md", "id-1000.", "id-100.").Build() + b.AssertNoRenderShortcodesArtifacts() + b.AssertFileContentEquals("public/p1/index.html", "<p>Content p1 id-100.</p>\n<code>code_p2</code><p>Foo.</p>\n<code>code_p3</code><code>code_p1</code><code>code_p1_2</code><code>code_p1_3</code>") + b.EditFileReplaceAll("content/p2.md", "code_p2", "codep2").Build() + b.AssertNoRenderShortcodesArtifacts() + b.AssertFileContentEquals("public/p1/index.html", "<p>Content p1 id-100.</p>\n<code>codep2</code><p>Foo.</p>\n<code>code_p3</code><code>code_p1</code><code>code_p1_2</code><code>code_p1_3</code>") + b.EditFileReplaceAll("content/p3.md", "code_p3", "code_p3_edited").Build() + b.AssertNoRenderShortcodesArtifacts() + b.AssertFileContentEquals("public/p1/index.html", "<p>Content p1 id-100.</p>\n<code>codep2</code><p>Foo.</p>\n<code>code_p3_edited</code><code>code_p1</code><code>code_p1_2</code><code>code_p1_3</code>") +} + +// Issue 13004. +func TestRenderShortcodesIncludeShortRefEdit(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableLiveReload = true +disableKinds = ["home", "taxonomy", "term", "section", "rss", "sitemap", "robotsTXT", "404"] +-- content/first/p1.md -- +--- +title: "p1" +--- +## p1-h1 +{{% include "p2" %}} +-- content/second/p2.md -- +--- +title: "p2" +--- +### p2-h1 + +This is some **markup**. +-- layouts/shortcodes/include.html -- +{{ $p := site.GetPage (.Get 0) -}} +{{ $p.RenderShortcodes -}} +-- layouts/_default/single.html -- +{{ .Content }} +` + b := TestRunning(t, files) + b.AssertNoRenderShortcodesArtifacts() + b.AssertFileContentEquals("public/first/p1/index.html", "<h2 id=\"p1-h1\">p1-h1</h2>\n<h3 id=\"p2-h1\">p2-h1</h3>\n<p>This is some <strong>markup</strong>.</p>\n") + b.EditFileReplaceAll("content/second/p2.md", "p2-h1", "p2-h1-edited").Build() + b.AssertNoRenderShortcodesArtifacts() + b.AssertFileContentEquals("public/first/p1/index.html", "<h2 id=\"p1-h1\">p1-h1</h2>\n<h3 id=\"p2-h1-edited\">p2-h1-edited</h3>\n<p>This is some <strong>markup</strong>.</p>\n") +} + +// Issue 13051. +func TestRenderShortcodesEmptyParagraph(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['section','rss','sitemap','taxonomy','term'] +-- layouts/_default/home.html -- +{{ .Content }} +-- layouts/_default/single.html -- +{{ .Content }} +-- layouts/shortcodes/include.html -- + {{ with site.GetPage (.Get 0) }} + {{ .RenderShortcodes }} +{{ end }} +-- content/_index.md -- +--- +title: home +--- + +a + +{{% include "/snippet" %}} + +b + +-- content/snippet.md -- +--- +title: snippet +build: + render: never + list: never +--- + +_emphasized_ + +not emphasized + +` + + b := Test(t, files) + b.AssertNoRenderShortcodesArtifacts() + b.AssertFileContentEquals("public/index.html", + "<p>a</p>\n<p><em>emphasized</em></p>\n<p>not emphasized</p>\n<p>b</p>\n", + ) +} diff --git a/hugolib/renderstring_test.go b/hugolib/renderstring_test.go new file mode 100644 index 000000000..413943698 --- /dev/null +++ b/hugolib/renderstring_test.go @@ -0,0 +1,214 @@ +// 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 hugolib + +import ( + "testing" + + "github.com/bep/logg" + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/common/loggers" +) + +func TestRenderString(t *testing.T) { + b := newTestSitesBuilder(t) + + b.WithTemplates("index.html", ` +{{ $p := site.GetPage "p1.md" }} +{{ $optBlock := dict "display" "block" }} +{{ $optOrg := dict "markup" "org" }} +RSTART:{{ "**Bold Markdown**" | $p.RenderString }}:REND +RSTART:{{ "**Bold Block Markdown**" | $p.RenderString $optBlock }}:REND +RSTART:{{ "/italic org mode/" | $p.RenderString $optOrg }}:REND +RSTART:{{ "## Header2" | $p.RenderString }}:REND + + +`, "_default/_markup/render-heading.html", "Hook Heading: {{ .Level }}") + + b.WithContent("p1.md", `--- +title: "p1" +--- +`, + ) + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", ` +RSTART:<strong>Bold Markdown</strong>:REND +RSTART:<p><strong>Bold Block Markdown</strong></p> +RSTART:<em>italic org mode</em>:REND +RSTART:Hook Heading: 2:REND +`) +} + +// https://github.com/gohugoio/hugo/issues/6882 +func TestRenderStringOnListPage(t *testing.T) { + renderStringTempl := ` +{{ .RenderString "**Hello**" }} +` + b := newTestSitesBuilder(t) + b.WithContent("mysection/p1.md", `FOO`) + b.WithTemplates( + "index.html", renderStringTempl, + "_default/list.html", renderStringTempl, + "_default/single.html", renderStringTempl, + ) + + b.Build(BuildCfg{}) + + for _, filename := range []string{ + "index.html", + "mysection/index.html", + "categories/index.html", + "tags/index.html", + "mysection/p1/index.html", + } { + b.AssertFileContent("public/"+filename, `<strong>Hello</strong>`) + } +} + +// Issue 9433 +func TestRenderStringOnPageNotBackedByAFile(t *testing.T) { + t.Parallel() + logger := loggers.NewDefault() + b := newTestSitesBuilder(t).WithLogger(logger).WithConfigFile("toml", ` +disableKinds = ["page", "section", "taxonomy", "term"] +`) + b.WithTemplates("index.html", `{{ .RenderString "**Hello**" }}`).WithContent("p1.md", "") + b.BuildE(BuildCfg{}) + b.Assert(logger.LoggCount(logg.LevelWarn), qt.Equals, 0) +} + +func TestRenderStringWithShortcode(t *testing.T) { + t.Parallel() + + filesTemplate := ` +-- config.toml -- +title = "Hugo Rocks!" +enableInlineShortcodes = true +-- content/p1/index.md -- +--- +title: "P1" +--- +## First +-- layouts/shortcodes/mark1.md -- +{{ .Inner }} +-- layouts/shortcodes/mark2.md -- +1. Item Mark2 1 +1. Item Mark2 2 + 1. Item Mark2 2-1 +1. Item Mark2 3 +-- layouts/shortcodes/myhthml.html -- +Title: {{ .Page.Title }} +TableOfContents: {{ .Page.TableOfContents }} +Page Type: {{ printf "%T" .Page }} +-- layouts/_default/single.html -- +{{ .RenderString "Markdown: {{% mark2 %}}|HTML: {{< myhthml >}}|Inline: {{< foo.inline >}}{{ site.Title }}{{< /foo.inline >}}|" }} +HasShortcode: mark2:{{ .HasShortcode "mark2" }}:true +HasShortcode: foo:{{ .HasShortcode "foo" }}:false + +` + + t.Run("Basic", func(t *testing.T) { + b := NewIntegrationTestBuilder( + IntegrationTestConfig{ + T: t, + TxtarString: filesTemplate, + }, + ).Build() + + b.AssertFileContent("public/p1/index.html", + "<p>Markdown: 1. Item Mark2 1</p>\n<ol>\n<li>Item Mark2 2\n<ol>\n<li>Item Mark2 2-1</li>\n</ol>\n</li>\n<li>Item Mark2 3|", + "<a href=\"#first\">First</a>", // ToC + ` +HTML: Title: P1 +Inline: Hugo Rocks! +HasShortcode: mark2:true:true +HasShortcode: foo:false:false +Page Type: *hugolib.pageForShortcode`, + ) + }) + + t.Run("Edit shortcode", func(t *testing.T) { + b := NewIntegrationTestBuilder( + IntegrationTestConfig{ + T: t, + TxtarString: filesTemplate, + Running: true, + }, + ).Build() + + b.EditFiles("layouts/shortcodes/myhthml.html", "Edit shortcode").Build() + + b.AssertFileContent("public/p1/index.html", + `Edit shortcode`, + ) + }) +} + +// Issue 9959 +func TestRenderStringWithShortcodeInPageWithNoContentFile(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +-- layouts/shortcodes/myshort.html -- +Page Kind: {{ .Page.Kind }} +-- layouts/index.html -- +Short: {{ .RenderString "{{< myshort >}}" }} +Has myshort: {{ .HasShortcode "myshort" }} +Has other: {{ .HasShortcode "other" }} + + ` + + b := Test(t, files) + + b.AssertFileContent("public/index.html", + ` +Page Kind: home +Has myshort: true +Has other: false +`) +} + +func TestRenderStringWithShortcodeIssue10654(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +timeout = '300ms' +-- content/p1.md -- +--- +title: "P1" +--- +{{< toc >}} + +## Heading 1 + +{{< noop >}} + {{ not a shortcode +{{< /noop >}} +} +-- layouts/shortcodes/noop.html -- +{{ .Inner | $.Page.RenderString }} +-- layouts/shortcodes/toc.html -- +{{ .Page.TableOfContents }} +-- layouts/_default/single.html -- +{{ .Content }} +` + + b := Test(t, files) + + b.AssertFileContent("public/p1/index.html", `TableOfContents`) +} diff --git a/hugolib/resource_chain_test.go b/hugolib/resource_chain_test.go new file mode 100644 index 000000000..00e4c0060 --- /dev/null +++ b/hugolib/resource_chain_test.go @@ -0,0 +1,745 @@ +// 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 hugolib + +import ( + "fmt" + "io" + "math/rand" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + qt "github.com/frankban/quicktest" + + "github.com/gohugoio/hugo/common/hashing" + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/resources/resource_transformers/tocss/scss" +) + +func TestResourceChainBasic(t *testing.T) { + failIfHandler := func(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/fail.jpg" { + http.Error(w, "{ msg: failed }", http.StatusNotImplemented) + return + } + h.ServeHTTP(w, r) + }) + } + ts := httptest.NewServer( + failIfHandler(http.FileServer(http.Dir("testdata/"))), + ) + t.Cleanup(func() { + ts.Close() + }) + + b := newTestSitesBuilder(t) + b.WithTemplatesAdded("index.html", fmt.Sprintf(` +{{ $hello := "<h1> Hello World! </h1>" | resources.FromString "hello.html" | fingerprint "sha512" | minify | fingerprint }} +{{ $cssFingerprinted1 := "body { background-color: lightblue; }" | resources.FromString "styles.css" | minify | fingerprint }} +{{ $cssFingerprinted2 := "body { background-color: orange; }" | resources.FromString "styles2.css" | minify | fingerprint }} + + +HELLO: {{ $hello.Name }}|{{ $hello.RelPermalink }}|{{ $hello.Content | safeHTML }} + +{{ $img := resources.Get "images/sunset.jpg" }} +{{ $fit := $img.Fit "200x200" }} +{{ $fit2 := $fit.Fit "100x200" }} +{{ $img = $img | fingerprint }} +SUNSET: {{ $img.Name }}|{{ $img.RelPermalink }}|{{ $img.Width }}|{{ len $img.Content }} +FIT: {{ $fit.Name }}|{{ $fit.RelPermalink }}|{{ $fit.Width }} +CSS integrity Data first: {{ $cssFingerprinted1.Data.Integrity }} {{ $cssFingerprinted1.RelPermalink }} +CSS integrity Data last: {{ $cssFingerprinted2.RelPermalink }} {{ $cssFingerprinted2.Data.Integrity }} + +{{ $failedImg := try (resources.GetRemote "%[1]s/fail.jpg") }} +{{ $rimg := resources.GetRemote "%[1]s/sunset.jpg" }} +{{ $remotenotfound := resources.GetRemote "%[1]s/notfound.jpg" }} +{{ $localnotfound := resources.Get "images/notfound.jpg" }} +{{ $gopherprotocol := try (resources.GetRemote "gopher://example.org") }} +{{ $rfit := $rimg.Fit "200x200" }} +{{ $rfit2 := $rfit.Fit "100x200" }} +{{ $rimg = $rimg | fingerprint }} +SUNSET REMOTE: {{ $rimg.Name }}|{{ $rimg.RelPermalink }}|{{ $rimg.Width }}|{{ len $rimg.Content }} +FIT REMOTE: {{ $rfit.Name }}|{{ $rfit.RelPermalink }}|{{ $rfit.Width }} +REMOTE NOT FOUND: {{ if $remotenotfound }}FAILED{{ else}}OK{{ end }} +LOCAL NOT FOUND: {{ if $localnotfound }}FAILED{{ else}}OK{{ end }} +PRINT PROTOCOL ERROR1: {{ with $gopherprotocol }}{{ .Value | safeHTML }}{{ end }} +PRINT PROTOCOL ERROR2: {{ with $gopherprotocol }}{{ .Err | safeHTML }}{{ end }} +PRINT PROTOCOL ERROR DETAILS: {{ with $gopherprotocol }}{{ with .Err }}Err: {{ . | safeHTML }}{{ with .Cause }}|{{ with .Data }}Body: {{ .Body }}|StatusCode: {{ .StatusCode }}{{ end }}|{{ end }}{{ end }}{{ end }} +FAILED REMOTE ERROR DETAILS CONTENT: {{ with $failedImg }}{{ with .Err }}{{ with .Cause }}{{ . }}|{{ with .Data }}Body: {{ .Body }}|StatusCode: {{ .StatusCode }}|ContentLength: {{ .ContentLength }}|ContentType: {{ .ContentType }}{{ end }}{{ end }}{{ end }}{{ end }}| +`, ts.URL)) + + fs := b.Fs.Source + + imageDir := filepath.Join("assets", "images") + b.Assert(os.MkdirAll(imageDir, 0o777), qt.IsNil) + src, err := os.Open("testdata/sunset.jpg") + b.Assert(err, qt.IsNil) + out, err := fs.Create(filepath.Join(imageDir, "sunset.jpg")) + b.Assert(err, qt.IsNil) + _, err = io.Copy(out, src) + b.Assert(err, qt.IsNil) + out.Close() + + b.Running() + + for i := range 2 { + b.Logf("Test run %d", i) + b.Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", + fmt.Sprintf(` +SUNSET: /images/sunset.jpg|/images/sunset.a9bf1d944e19c0f382e0d8f51de690f7d0bc8fa97390c4242a86c3e5c0737e71.jpg|900|90587 +FIT: /images/sunset.jpg|/images/sunset_hu_f2aae87288f3c13b.jpg|200 +CSS integrity Data first: sha256-od9YaHw8nMOL8mUy97Sy8sKwMV3N4hI3aVmZXATxH+8= /styles.min.a1df58687c3c9cc38bf26532f7b4b2f2c2b0315dcde212376959995c04f11fef.css +CSS integrity Data last: /styles2.min.1cfc52986836405d37f9998a63fd6dd8608e8c410e5e3db1daaa30f78bc273ba.css sha256-HPxSmGg2QF03+ZmKY/1t2GCOjEEOXj2x2qow94vCc7o= + +SUNSET REMOTE: /sunset_%[1]s.jpg|/sunset_%[1]s.a9bf1d944e19c0f382e0d8f51de690f7d0bc8fa97390c4242a86c3e5c0737e71.jpg|900|90587 +FIT REMOTE: /sunset_%[1]s.jpg|/sunset_%[1]s_hu_f2aae87288f3c13b.jpg|200 +REMOTE NOT FOUND: OK +LOCAL NOT FOUND: OK +PRINT PROTOCOL ERROR DETAILS: Err: template: index.html:22:36: executing "index.html" at <resources.GetRemote>: error calling GetRemote: Get "gopher://example.org": unsupported protocol scheme "gopher"| +FAILED REMOTE ERROR DETAILS CONTENT: failed to fetch remote resource from '%[2]s/fail.jpg': Not Implemented|Body: { msg: failed } +|StatusCode: 501|ContentLength: 16|ContentType: text/plain; charset=utf-8| + + +`, hashing.HashString(ts.URL+"/sunset.jpg", map[string]any{}), ts.URL)) + + b.AssertFileContent("public/styles.min.a1df58687c3c9cc38bf26532f7b4b2f2c2b0315dcde212376959995c04f11fef.css", "body{background-color:#add8e6}") + b.AssertFileContent("public//styles2.min.1cfc52986836405d37f9998a63fd6dd8608e8c410e5e3db1daaa30f78bc273ba.css", "body{background-color:orange}") + + b.EditFiles("content/_index.md", ` +--- +title: "Home edit" +summary: "Edited summary" +--- + +Edited content. + +`) + + } +} + +func TestResourceChainPostProcess(t *testing.T) { + t.Parallel() + + rnd := rand.New(rand.NewSource(time.Now().UnixNano())) + + b := newTestSitesBuilder(t) + b.WithConfigFile("toml", ` +disableLiveReload = true +[minify] + minifyOutput = true + [minify.tdewolff] + [minify.tdewolff.html] + keepQuotes = false + keepWhitespace = false`) + b.WithContent("page1.md", "---\ntitle: Page1\n---") + b.WithContent("page2.md", "---\ntitle: Page2\n---") + + b.WithTemplates( + "_default/single.html", `{{ $hello := "<h1> Hello World! </h1>" | resources.FromString "hello.html" | minify | fingerprint "md5" | resources.PostProcess }} +HELLO: {{ $hello.RelPermalink }} +`, + "index.html", `Start. +{{ $hello := "<h1> Hello World! </h1>" | resources.FromString "hello.html" | minify | fingerprint "md5" | resources.PostProcess }} + +HELLO: {{ $hello.RelPermalink }}|Integrity: {{ $hello.Data.Integrity }}|MediaType: {{ $hello.MediaType.Type }} +HELLO2: Name: {{ $hello.Name }}|Content: {{ $hello.Content }}|Title: {{ $hello.Title }}|ResourceType: {{ $hello.ResourceType }} + +// Issue #10269 +{{ $m := dict "relPermalink" $hello.RelPermalink "integrity" $hello.Data.Integrity "mediaType" $hello.MediaType.Type }} +{{ $json := jsonify (dict "indent" " ") $m | resources.FromString "hello.json" -}} +JSON: {{ $json.RelPermalink }} + +// Issue #8884 +<a href="hugo.rocks">foo</a> +<a href="{{ $hello.RelPermalink }}" integrity="{{ $hello.Data.Integrity}}">Hello</a> +`+strings.Repeat("a b", rnd.Intn(10)+1)+` + + +End.`) + + b.Running() + b.Build(BuildCfg{}) + b.AssertFileContent("public/index.html", + `Start. +HELLO: /hello.min.a2d1cb24f24b322a7dad520414c523e9.html|Integrity: md5-otHLJPJLMip9rVIEFMUj6Q==|MediaType: text/html +HELLO2: Name: /hello.html|Content: <h1>Hello World!</h1>|Title: /hello.html|ResourceType: text +<a href=hugo.rocks>foo</a> +<a href="/hello.min.a2d1cb24f24b322a7dad520414c523e9.html" integrity="md5-otHLJPJLMip9rVIEFMUj6Q==">Hello</a> +End.`) + + b.AssertFileContent("public/page1/index.html", `HELLO: /hello.min.a2d1cb24f24b322a7dad520414c523e9.html`) + b.AssertFileContent("public/page2/index.html", `HELLO: /hello.min.a2d1cb24f24b322a7dad520414c523e9.html`) + b.AssertFileContent("public/hello.json", ` +integrity": "md5-otHLJPJLMip9rVIEFMUj6Q== +mediaType": "text/html +relPermalink": "/hello.min.a2d1cb24f24b322a7dad520414c523e9.html" +`) +} + +func BenchmarkResourceChainPostProcess(b *testing.B) { + for i := 0; i < b.N; i++ { + b.StopTimer() + s := newTestSitesBuilder(b) + for i := range 300 { + s.WithContent(fmt.Sprintf("page%d.md", i+1), "---\ntitle: Page\n---") + } + s.WithTemplates("_default/single.html", `Start. +Some text. + + +{{ $hello1 := "<h1> Hello World 2! </h1>" | resources.FromString "hello.html" | minify | fingerprint "md5" | resources.PostProcess }} +{{ $hello2 := "<h1> Hello World 2! </h1>" | resources.FromString (printf "%s.html" .Path) | minify | fingerprint "md5" | resources.PostProcess }} + +Some more text. + +HELLO: {{ $hello1.RelPermalink }}|Integrity: {{ $hello1.Data.Integrity }}|MediaType: {{ $hello1.MediaType.Type }} + +Some more text. + +HELLO2: Name: {{ $hello2.Name }}|Content: {{ $hello2.Content }}|Title: {{ $hello2.Title }}|ResourceType: {{ $hello2.ResourceType }} + +Some more text. + +HELLO2_2: Name: {{ $hello2.Name }}|Content: {{ $hello2.Content }}|Title: {{ $hello2.Title }}|ResourceType: {{ $hello2.ResourceType }} + +End. +`) + + b.StartTimer() + s.Build(BuildCfg{}) + + } +} + +func TestResourceChains(t *testing.T) { + t.Parallel() + + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/css/styles1.css": + w.Header().Set("Content-Type", "text/css") + w.Write([]byte(`h1 { + font-style: bold; + }`)) + return + + case "/js/script1.js": + w.Write([]byte(`var x; x = 5, document.getElementById("demo").innerHTML = x * 10`)) + return + + case "/mydata/json1.json": + w.Write([]byte(`{ + "employees": [ + { + "firstName": "John", + "lastName": "Doe" + }, + { + "firstName": "Anna", + "lastName": "Smith" + }, + { + "firstName": "Peter", + "lastName": "Jones" + } + ] + }`)) + return + + case "/mydata/xml1.xml": + w.Write([]byte(` + <hello> + <world>Hugo Rocks!</<world> + </hello>`)) + return + + case "/mydata/svg1.svg": + w.Header().Set("Content-Disposition", `attachment; filename="image.svg"`) + w.Write([]byte(` + <svg height="100" width="100"> + <path d="M1e2 1e2H3e2 2e2z"/> + </svg>`)) + return + + case "/mydata/html1.html": + w.Write([]byte(` + <html> + <a href=#>Cool</a> + </html>`)) + return + + case "/authenticated/": + w.Header().Set("Content-Type", "text/plain") + if r.Header.Get("Authorization") == "Bearer abcd" { + w.Write([]byte(`Welcome`)) + return + } + http.Error(w, "Forbidden", http.StatusForbidden) + return + + case "/post": + w.Header().Set("Content-Type", "text/plain") + if r.Method == http.MethodPost { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + w.Write(body) + return + } + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + http.Error(w, "Not found", http.StatusNotFound) + })) + t.Cleanup(func() { + ts.Close() + }) + + tests := []struct { + name string + shouldRun func() bool + prepare func(b *sitesBuilder) + verify func(b *sitesBuilder) + }{ + {"tocss", func() bool { return scss.Supports() }, func(b *sitesBuilder) { + b.WithTemplates("home.html", ` +{{ $scss := resources.Get "scss/styles2.scss" | toCSS }} +{{ $sass := resources.Get "sass/styles3.sass" | toCSS }} +{{ $scssCustomTarget := resources.Get "scss/styles2.scss" | toCSS (dict "targetPath" "styles/main.css") }} +{{ $scssCustomTargetString := resources.Get "scss/styles2.scss" | toCSS "styles/main.css" }} +{{ $scssMin := resources.Get "scss/styles2.scss" | toCSS | minify }} +{{ $scssFromTempl := ".{{ .Kind }} { color: blue; }" | resources.FromString "kindofblue.templ" | resources.ExecuteAsTemplate "kindofblue.scss" . | toCSS (dict "targetPath" "styles/templ.css") | minify }} +{{ $bundle1 := slice $scssFromTempl $scssMin | resources.Concat "styles/bundle1.css" }} +T1: Len Content: {{ len $scss.Content }}|RelPermalink: {{ $scss.RelPermalink }}|Permalink: {{ $scss.Permalink }}|MediaType: {{ $scss.MediaType.Type }} +T2: Content: {{ $scssMin.Content }}|RelPermalink: {{ $scssMin.RelPermalink }} +T3: Content: {{ len $scssCustomTarget.Content }}|RelPermalink: {{ $scssCustomTarget.RelPermalink }}|MediaType: {{ $scssCustomTarget.MediaType.Type }} +T4: Content: {{ len $scssCustomTargetString.Content }}|RelPermalink: {{ $scssCustomTargetString.RelPermalink }}|MediaType: {{ $scssCustomTargetString.MediaType.Type }} +T5: Content: {{ $sass.Content }}|T5 RelPermalink: {{ $sass.RelPermalink }}| +T6: {{ $bundle1.Permalink }} +`) + }, func(b *sitesBuilder) { + b.AssertFileContent("public/index.html", `T1: Len Content: 24|RelPermalink: /scss/styles2.css|Permalink: http://example.com/scss/styles2.css|MediaType: text/css`) + b.AssertFileContent("public/index.html", `T2: Content: body{color:#333}|RelPermalink: /scss/styles2.min.css`) + b.AssertFileContent("public/index.html", `T3: Content: 24|RelPermalink: /styles/main.css|MediaType: text/css`) + b.AssertFileContent("public/index.html", `T4: Content: 24|RelPermalink: /styles/main.css|MediaType: text/css`) + b.AssertFileContent("public/index.html", `T5: Content: .content-navigation {`) + b.AssertFileContent("public/index.html", `T5 RelPermalink: /sass/styles3.css|`) + b.AssertFileContent("public/index.html", `T6: http://example.com/styles/bundle1.css`) + + c.Assert(b.CheckExists("public/styles/templ.min.css"), qt.Equals, false) + b.AssertFileContent("public/styles/bundle1.css", `.home{color:blue}body{color:#333}`) + }}, + + {"minify", func() bool { return true }, func(b *sitesBuilder) { + b.WithConfigFile("toml", `[minify] + [minify.tdewolff] + [minify.tdewolff.html] + keepWhitespace = false +`) + b.WithTemplates("home.html", fmt.Sprintf(` +Min CSS: {{ ( resources.Get "css/styles1.css" | minify ).Content }} +Min CSS Remote: {{ ( resources.GetRemote "%[1]s/css/styles1.css" | minify ).Content }} +Min JS: {{ ( resources.Get "js/script1.js" | resources.Minify ).Content | safeJS }} +Min JS Remote: {{ ( resources.GetRemote "%[1]s/js/script1.js" | minify ).Content }} +Min JSON: {{ ( resources.Get "mydata/json1.json" | resources.Minify ).Content | safeHTML }} +Min JSON Remote: {{ ( resources.GetRemote "%[1]s/mydata/json1.json" | resources.Minify ).Content | safeHTML }} +Min XML: {{ ( resources.Get "mydata/xml1.xml" | resources.Minify ).Content | safeHTML }} +Min XML Remote: {{ ( resources.GetRemote "%[1]s/mydata/xml1.xml" | resources.Minify ).Content | safeHTML }} +Min SVG: {{ ( resources.Get "mydata/svg1.svg" | resources.Minify ).Content | safeHTML }} +Min SVG Remote: {{ ( resources.GetRemote "%[1]s/mydata/svg1.svg" | resources.Minify ).Content | safeHTML }} +Min SVG again: {{ ( resources.Get "mydata/svg1.svg" | resources.Minify ).Content | safeHTML }} +Min HTML: {{ ( resources.Get "mydata/html1.html" | resources.Minify ).Content | safeHTML }} +Min HTML Remote: {{ ( resources.GetRemote "%[1]s/mydata/html1.html" | resources.Minify ).Content | safeHTML }} +`, ts.URL)) + }, func(b *sitesBuilder) { + b.AssertFileContent("public/index.html", `Min CSS: h1{font-style:bold}`) + b.AssertFileContent("public/index.html", `Min CSS Remote: h1{font-style:bold}`) + b.AssertFileContent("public/index.html", `Min JS: var x=5;document.getElementById("demo").innerHTML=x*10`) + b.AssertFileContent("public/index.html", `Min JS Remote: var x=5;document.getElementById("demo").innerHTML=x*10`) + b.AssertFileContent("public/index.html", `Min JSON: {"employees":[{"firstName":"John","lastName":"Doe"},{"firstName":"Anna","lastName":"Smith"},{"firstName":"Peter","lastName":"Jones"}]}`) + b.AssertFileContent("public/index.html", `Min JSON Remote: {"employees":[{"firstName":"John","lastName":"Doe"},{"firstName":"Anna","lastName":"Smith"},{"firstName":"Peter","lastName":"Jones"}]}`) + b.AssertFileContent("public/index.html", `Min XML: <hello><world>Hugo Rocks!</<world></hello>`) + b.AssertFileContent("public/index.html", `Min XML Remote: <hello><world>Hugo Rocks!</<world></hello>`) + b.AssertFileContent("public/index.html", `Min SVG: <svg height="100" width="100"><path d="M1e2 1e2H3e2 2e2z"/></svg>`) + b.AssertFileContent("public/index.html", `Min SVG Remote: <svg height="100" width="100"><path d="M1e2 1e2H3e2 2e2z"/></svg>`) + b.AssertFileContent("public/index.html", `Min SVG again: <svg height="100" width="100"><path d="M1e2 1e2H3e2 2e2z"/></svg>`) + b.AssertFileContent("public/index.html", `Min HTML: <html><a href=#>Cool</a></html>`) + b.AssertFileContent("public/index.html", `Min HTML Remote: <html><a href=#>Cool</a></html>`) + }}, + + {"remote", func() bool { return true }, func(b *sitesBuilder) { + b.WithTemplates("home.html", fmt.Sprintf(` +{{$js := resources.GetRemote "%[1]s/js/script1.js" }} +Remote Filename: {{ $js.RelPermalink }} +{{$svg := resources.GetRemote "%[1]s/mydata/svg1.svg" }} +Remote Content-Disposition: {{ $svg.RelPermalink }} +{{$auth := resources.GetRemote "%[1]s/authenticated/" (dict "headers" (dict "Authorization" "Bearer abcd")) }} +Remote Authorization: {{ $auth.Content }} +{{$post := resources.GetRemote "%[1]s/post" (dict "method" "post" "body" "Request body") }} +Remote POST: {{ $post.Content }} +`, ts.URL)) + }, func(b *sitesBuilder) { + b.AssertFileContent("public/index.html", `Remote Filename: /script1_`) + b.AssertFileContent("public/index.html", `Remote Content-Disposition: /image_`) + b.AssertFileContent("public/index.html", `Remote Authorization: Welcome`) + b.AssertFileContent("public/index.html", `Remote POST: Request body`) + }}, + + {"concat", func() bool { return true }, func(b *sitesBuilder) { + b.WithTemplates("home.html", ` +{{ $a := "A" | resources.FromString "a.txt"}} +{{ $b := "B" | resources.FromString "b.txt"}} +{{ $c := "C" | resources.FromString "c.txt"}} +{{ $textResources := .Resources.Match "*.txt" }} +{{ $combined := slice $a $b $c | resources.Concat "bundle/concat.txt" }} +T1: Content: {{ $combined.Content }}|RelPermalink: {{ $combined.RelPermalink }}|Permalink: {{ $combined.Permalink }}|MediaType: {{ $combined.MediaType.Type }} +{{ with $textResources }} +{{ $combinedText := . | resources.Concat "bundle/concattxt.txt" }} +T2: Content: {{ $combinedText.Content }}|{{ $combinedText.RelPermalink }} +{{ end }} +{{/* https://github.com/gohugoio/hugo/issues/5269 */}} +{{ $css := "body { color: blue; }" | resources.FromString "styles.css" }} +{{ $minified := resources.Get "css/styles1.css" | minify }} +{{ slice $css $minified | resources.Concat "bundle/mixed.css" }} +{{/* https://github.com/gohugoio/hugo/issues/5403 */}} +{{ $d := "function D {} // A comment" | resources.FromString "d.js"}} +{{ $e := "(function E {})" | resources.FromString "e.js"}} +{{ $f := "(function F {})()" | resources.FromString "f.js"}} +{{ $jsResources := .Resources.Match "*.js" }} +{{ $combinedJs := slice $d $e $f | resources.Concat "bundle/concatjs.js" }} +T3: Content: {{ $combinedJs.Content }}|{{ $combinedJs.RelPermalink }} +`) + }, func(b *sitesBuilder) { + b.AssertFileContent("public/index.html", `T1: Content: ABC|RelPermalink: /bundle/concat.txt|Permalink: http://example.com/bundle/concat.txt|MediaType: text/plain`) + b.AssertFileContent("public/bundle/concat.txt", "ABC") + + b.AssertFileContent("public/index.html", `T2: Content: t1t|t2t|`) + b.AssertFileContent("public/bundle/concattxt.txt", "t1t|t2t|") + + b.AssertFileContent("public/index.html", `T3: Content: function D {} // A comment +; +(function E {}) +; +(function F {})()|`) + b.AssertFileContent("public/bundle/concatjs.js", `function D {} // A comment +; +(function E {}) +; +(function F {})()`) + }}, + + {"concat and fingerprint", func() bool { return true }, func(b *sitesBuilder) { + b.WithTemplates("home.html", ` +{{ $a := "A" | resources.FromString "a.txt"}} +{{ $b := "B" | resources.FromString "b.txt"}} +{{ $c := "C" | resources.FromString "c.txt"}} +{{ $combined := slice $a $b $c | resources.Concat "bundle/concat.txt" }} +{{ $fingerprinted := $combined | fingerprint }} +Fingerprinted: {{ $fingerprinted.RelPermalink }} +`) + }, func(b *sitesBuilder) { + b.AssertFileContent("public/index.html", "Fingerprinted: /bundle/concat.b5d4045c3f466fa91fe2cc6abe79232a1a57cdf104f7a26e716e0a1e2789df78.txt") + b.AssertFileContent("public/bundle/concat.b5d4045c3f466fa91fe2cc6abe79232a1a57cdf104f7a26e716e0a1e2789df78.txt", "ABC") + }}, + + {"fromstring", func() bool { return true }, func(b *sitesBuilder) { + b.WithTemplates("home.html", ` +{{ $r := "Hugo Rocks!" | resources.FromString "rocks/hugo.txt" }} +{{ $r.Content }}|{{ $r.RelPermalink }}|{{ $r.Permalink }}|{{ $r.MediaType.Type }} +`) + }, func(b *sitesBuilder) { + b.AssertFileContent("public/index.html", `Hugo Rocks!|/rocks/hugo.txt|http://example.com/rocks/hugo.txt|text/plain`) + b.AssertFileContent("public/rocks/hugo.txt", "Hugo Rocks!") + }}, + {"execute-as-template", func() bool { + return true + }, func(b *sitesBuilder) { + b.WithTemplates("home.html", ` +{{ $var := "Hugo Page" }} +{{ if .IsHome }} +{{ $var = "Hugo Home" }} +{{ end }} +T1: {{ $var }} +{{ $result := "{{ .Kind | upper }}" | resources.FromString "mytpl.txt" | resources.ExecuteAsTemplate "result.txt" . }} +T2: {{ $result.Content }}|{{ $result.RelPermalink}}|{{$result.MediaType.Type }} +`) + }, func(b *sitesBuilder) { + b.AssertFileContent("public/index.html", `T2: HOME|/result.txt|text/plain`, `T1: Hugo Home`) + }}, + {"fingerprint", func() bool { return true }, func(b *sitesBuilder) { + b.WithTemplates("home.html", ` +{{ $r := "ab" | resources.FromString "rocks/hugo.txt" }} +{{ $result := $r | fingerprint }} +{{ $result512 := $r | fingerprint "sha512" }} +{{ $resultMD5 := $r | fingerprint "md5" }} +T1: {{ $result.Content }}|{{ $result.RelPermalink}}|{{$result.MediaType.Type }}|{{ $result.Data.Integrity }}| +T2: {{ $result512.Content }}|{{ $result512.RelPermalink}}|{{$result512.MediaType.Type }}|{{ $result512.Data.Integrity }}| +T3: {{ $resultMD5.Content }}|{{ $resultMD5.RelPermalink}}|{{$resultMD5.MediaType.Type }}|{{ $resultMD5.Data.Integrity }}| +{{ $r2 := "bc" | resources.FromString "rocks/hugo2.txt" | fingerprint }} +{{/* https://github.com/gohugoio/hugo/issues/5296 */}} +T4: {{ $r2.Data.Integrity }}| + + +`) + }, func(b *sitesBuilder) { + b.AssertFileContent("public/index.html", `T1: ab|/rocks/hugo.fb8e20fc2e4c3f248c60c39bd652f3c1347298bb977b8b4d5903b85055620603.txt|text/plain|sha256-+44g/C5MPySMYMOb1lLzwTRymLuXe4tNWQO4UFViBgM=|`) + b.AssertFileContent("public/index.html", `T2: ab|/rocks/hugo.2d408a0717ec188158278a796c689044361dc6fdde28d6f04973b80896e1823975cdbf12eb63f9e0591328ee235d80e9b5bf1aa6a44f4617ff3caf6400eb172d.txt|text/plain|sha512-LUCKBxfsGIFYJ4p5bGiQRDYdxv3eKNbwSXO4CJbhgjl1zb8S62P54FkTKO4jXYDptb8apqRPRhf/PK9kAOsXLQ==|`) + b.AssertFileContent("public/index.html", `T3: ab|/rocks/hugo.187ef4436122d1cc2f40dc2b92f0eba0.txt|text/plain|md5-GH70Q2Ei0cwvQNwrkvDroA==|`) + b.AssertFileContent("public/index.html", `T4: sha256-Hgu9bGhroFC46wP/7txk/cnYCUf86CGrvl1tyNJSxaw=|`) + }}, + // https://github.com/gohugoio/hugo/issues/5226 + {"baseurl-path", func() bool { return true }, func(b *sitesBuilder) { + b.WithSimpleConfigFileAndBaseURL("https://example.com/hugo/") + b.WithTemplates("home.html", ` +{{ $r1 := "ab" | resources.FromString "rocks/hugo.txt" }} +T1: {{ $r1.Permalink }}|{{ $r1.RelPermalink }} +`) + }, func(b *sitesBuilder) { + b.AssertFileContent("public/index.html", `T1: https://example.com/hugo/rocks/hugo.txt|/hugo/rocks/hugo.txt`) + }}, + + // https://github.com/gohugoio/hugo/issues/4944 + {"Prevent resource publish on .Content only", func() bool { return true }, func(b *sitesBuilder) { + b.WithTemplates("home.html", ` +{{ $cssInline := "body { color: green; }" | resources.FromString "inline.css" | minify }} +{{ $cssPublish1 := "body { color: blue; }" | resources.FromString "external1.css" | minify }} +{{ $cssPublish2 := "body { color: orange; }" | resources.FromString "external2.css" | minify }} + +Inline: {{ $cssInline.Content }} +Publish 1: {{ $cssPublish1.Content }} {{ $cssPublish1.RelPermalink }} +Publish 2: {{ $cssPublish2.Permalink }} +`) + }, func(b *sitesBuilder) { + b.AssertFileContent("public/index.html", + `Inline: body{color:green}`, + "Publish 1: body{color:blue} /external1.min.css", + "Publish 2: http://example.com/external2.min.css", + ) + b.Assert(b.CheckExists("public/external2.css"), qt.Equals, false) + b.Assert(b.CheckExists("public/external1.css"), qt.Equals, false) + b.Assert(b.CheckExists("public/external2.min.css"), qt.Equals, true) + b.Assert(b.CheckExists("public/external1.min.css"), qt.Equals, true) + b.Assert(b.CheckExists("public/inline.min.css"), qt.Equals, false) + }}, + + {"unmarshal", func() bool { return true }, func(b *sitesBuilder) { + b.WithTemplates("home.html", ` +{{ $toml := "slogan = \"Hugo Rocks!\"" | resources.FromString "slogan.toml" | transform.Unmarshal }} +{{ $csv1 := "\"Hugo Rocks\",\"Hugo is Fast!\"" | resources.FromString "slogans.csv" | transform.Unmarshal }} +{{ $csv2 := "a;b;c" | transform.Unmarshal (dict "delimiter" ";") }} +{{ $xml := "<?xml version=\"1.0\" encoding=\"UTF-8\"?><note><to>You</to><from>Me</from><heading>Reminder</heading><body>Do not forget XML</body></note>" | transform.Unmarshal }} + +Slogan: {{ $toml.slogan }} +CSV1: {{ $csv1 }} {{ len (index $csv1 0) }} +CSV2: {{ $csv2 }} +XML: {{ $xml.body }} +`) + }, func(b *sitesBuilder) { + b.AssertFileContent("public/index.html", + `Slogan: Hugo Rocks!`, + `[[Hugo Rocks Hugo is Fast!]] 2`, + `CSV2: [[a b c]]`, + `XML: Do not forget XML`, + ) + }}, + {"resources.Get", func() bool { return true }, func(b *sitesBuilder) { + b.WithTemplates("home.html", `NOT FOUND: {{ if (resources.Get "this-does-not-exist") }}FAILED{{ else }}OK{{ end }}`) + }, func(b *sitesBuilder) { + b.AssertFileContent("public/index.html", "NOT FOUND: OK") + }}, + + {"template", func() bool { return true }, func(b *sitesBuilder) {}, func(b *sitesBuilder) { + }}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + if !test.shouldRun() { + t.Skip() + } + t.Parallel() + + b := newTestSitesBuilder(t).WithLogger(loggers.NewDefault()) + b.WithContent("_index.md", ` +--- +title: Home +--- + +Home. + +`, + "page1.md", ` +--- +title: Hello1 +--- + +Hello1 +`, + "page2.md", ` +--- +title: Hello2 +--- + +Hello2 +`, + "t1.txt", "t1t|", + "t2.txt", "t2t|", + ) + + b.WithSourceFile(filepath.Join("assets", "css", "styles1.css"), ` +h1 { + font-style: bold; +} +`) + + b.WithSourceFile(filepath.Join("assets", "js", "script1.js"), ` +var x; +x = 5; +document.getElementById("demo").innerHTML = x * 10; +`) + + b.WithSourceFile(filepath.Join("assets", "mydata", "json1.json"), ` +{ +"employees":[ + {"firstName":"John", "lastName":"Doe"}, + {"firstName":"Anna", "lastName":"Smith"}, + {"firstName":"Peter", "lastName":"Jones"} +] +} +`) + + b.WithSourceFile(filepath.Join("assets", "mydata", "svg1.svg"), ` +<svg height="100" width="100"> + <path d="M 100 100 L 300 100 L 200 100 z"/> +</svg> +`) + + b.WithSourceFile(filepath.Join("assets", "mydata", "xml1.xml"), ` +<hello> +<world>Hugo Rocks!</<world> +</hello> +`) + + b.WithSourceFile(filepath.Join("assets", "mydata", "html1.html"), ` +<html> +<a href="#"> +Cool +</a > +</html> +`) + + b.WithSourceFile(filepath.Join("assets", "scss", "styles2.scss"), ` +$color: #333; + +body { + color: $color; +} +`) + + b.WithSourceFile(filepath.Join("assets", "sass", "styles3.sass"), ` +$color: #333; + +.content-navigation + border-color: $color + +`) + + test.prepare(b) + b.Build(BuildCfg{}) + test.verify(b) + }) + } +} + +func TestResourcesMatch(t *testing.T) { + t.Parallel() + + b := newTestSitesBuilder(t) + + b.WithContent("page.md", "") + + b.WithSourceFile( + "assets/images/img1.png", "png", + "assets/images/img2.jpg", "jpg", + "assets/jsons/data1.json", "json1 content", + "assets/jsons/data2.json", "json2 content", + "assets/jsons/data3.xml", "xml content", + ) + + b.WithTemplates("index.html", ` +{{ $jsons := (resources.Match "jsons/*.json") }} +{{ $json := (resources.GetMatch "jsons/*.json") }} +{{ printf "jsonsMatch: %d" (len $jsons) }} +{{ printf "imagesByType: %d" (len (resources.ByType "image") ) }} +{{ printf "applicationByType: %d" (len (resources.ByType "application") ) }} +JSON: {{ $json.RelPermalink }}: {{ $json.Content }} +{{ range $jsons }} +{{- .RelPermalink }}: {{ .Content }} +{{ end }} +`) + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", + "JSON: /jsons/data1.json: json1 content", + "jsonsMatch: 2", + "imagesByType: 2", + "applicationByType: 3", + "/jsons/data1.json: json1 content") +} + +func TestResourceMinifyDisabled(t *testing.T) { + t.Parallel() + + b := newTestSitesBuilder(t).WithConfigFile("toml", ` +baseURL = "https://example.org" + +[minify] +disableXML=true + + +`) + + b.WithContent("page.md", "") + + b.WithSourceFile( + "assets/xml/data.xml", "<root> <foo> asdfasdf </foo> </root>", + ) + + b.WithTemplates("index.html", ` +{{ $xml := resources.Get "xml/data.xml" | minify | fingerprint }} +XML: {{ $xml.Content | safeHTML }}|{{ $xml.RelPermalink }} +`) + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", ` +XML: <root> <foo> asdfasdf </foo> </root>|/xml/data.min.3be4fddd19aaebb18c48dd6645215b822df74701957d6d36e59f203f9c30fd9f.xml +`) +} diff --git a/hugolib/robotstxt_test.go b/hugolib/robotstxt_test.go index e924cb8dc..c901ce662 100644 --- a/hugolib/robotstxt_test.go +++ b/hugolib/robotstxt_test.go @@ -16,7 +16,7 @@ package hugolib import ( "testing" - "github.com/spf13/viper" + "github.com/gohugoio/hugo/config" ) const robotTxtTemplate = `User-agent: Googlebot @@ -28,7 +28,7 @@ const robotTxtTemplate = `User-agent: Googlebot func TestRobotsTXTOutput(t *testing.T) { t.Parallel() - cfg := viper.New() + cfg := config.New() cfg.Set("baseURL", "http://auth/bub/") cfg.Set("enableRobotsTXT", true) @@ -38,5 +38,17 @@ func TestRobotsTXTOutput(t *testing.T) { b.Build(BuildCfg{}) b.AssertFileContent("public/robots.txt", "User-agent: Googlebot") - +} + +func TestRobotsTXTDefaultTemplate(t *testing.T) { + t.Parallel() + files := ` +-- hugo.toml -- +baseURL = "http://auth/bub/" +enableRobotsTXT = true +` + + b := Test(t, files) + + b.AssertFileContent("public/robots.txt", "User-agent: *") } diff --git a/hugolib/rss_test.go b/hugolib/rss_test.go index 9b5130507..34c2be393 100644 --- a/hugolib/rss_test.go +++ b/hugolib/rss_test.go @@ -1,4 +1,4 @@ -// Copyright 2016 The Hugo Authors. All rights reserved. +// 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. @@ -23,25 +23,22 @@ import ( func TestRSSOutput(t *testing.T) { t.Parallel() - var ( - cfg, fs = newTestCfg() - th = testHelper{cfg, fs, t} - ) rssLimit := len(weightedSources) - 1 - rssURI := "customrss.xml" - + cfg, fs := newTestCfg() cfg.Set("baseURL", "http://auth/bub/") - cfg.Set("rssURI", rssURI) cfg.Set("title", "RSSTest") cfg.Set("rssLimit", rssLimit) + th, configs := newTestHelperFromProvider(cfg, fs, t) + + rssURI := "index.xml" for _, src := range weightedSources { writeSource(t, fs, filepath.Join("content", "sect", src[0]), src[1]) } - buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{}) + buildSingleSite(t, deps.DepsCfg{Fs: fs, Configs: configs}, BuildCfg{}) // Home RSS th.assertFileContent(filepath.Join("public", rssURI), "<?xml", "rss version", "RSSTest") @@ -51,9 +48,99 @@ func TestRSSOutput(t *testing.T) { th.assertFileContent(filepath.Join("public", "categories", "hugo", rssURI), "<?xml", "rss version", "Hugo on RSSTest") // RSS Item Limit - content := readDestination(t, fs, filepath.Join("public", rssURI)) + content := readWorkingDir(t, fs, filepath.Join("public", rssURI)) c := strings.Count(content, "<item>") if c != rssLimit { t.Errorf("incorrect RSS item count: expected %d, got %d", rssLimit, c) } + + // Encoded summary + th.assertFileContent(filepath.Join("public", rssURI), "<?xml", "description", "A <em>custom</em> summary") +} + +// Before Hugo 0.49 we set the pseudo page kind RSS on the page when output to RSS. +// This had some unintended side effects, esp. when the only output format for that page +// was RSS. +// For the page kinds that can have multiple output formats, the Kind should be one of the +// standard home, page etc. +// This test has this single purpose: Check that the Kind is that of the source page. +// See https://github.com/gohugoio/hugo/issues/5138 +func TestRSSKind(t *testing.T) { + t.Parallel() + + b := newTestSitesBuilder(t) + b.WithSimpleConfigFile().WithTemplatesAdded("index.rss.xml", `RSS Kind: {{ .Kind }}`) + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/index.xml", "RSS Kind: home") +} + +func TestRSSCanonifyURLs(t *testing.T) { + t.Parallel() + + b := newTestSitesBuilder(t) + b.WithSimpleConfigFile().WithTemplatesAdded("index.rss.xml", `<rss>{{ range .Pages }}<item>{{ .Content | html }}</item>{{ end }}</rss>`) + b.WithContent("page.md", `--- +Title: My Page +--- + +Figure: + +{{< figure src="/images/sunset.jpg" title="Sunset" >}} + + + +`) + b.Build(BuildCfg{}) + + b.AssertFileContent("public/index.xml", "img src="http://example.com/images/sunset.jpg") +} + +// Issue 13332. +func TestRSSCanonifyURLsSubDir(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = 'https://example.org/subdir' +disableKinds = ['section','sitemap','taxonomy','term'] +[markup.goldmark.renderHooks.image] +enableDefault = true +[markup.goldmark.renderHooks.link] +enableDefault = true +-- layouts/_default/_markup/render-image.html -- +{{- $u := urls.Parse .Destination -}} +{{- $src := $u.String | relURL -}} +<img srcset="{{ $src }}" src="{{ $src }} 2x"> +<img src="{{ $src }}"> +{{- /**/ -}} +-- layouts/_default/home.html -- +{{ .Content }}| +-- layouts/_default/single.html -- +{{ .Content }}| +-- layouts/_default/rss.xml -- +{{ with site.GetPage "/s1/p2" }} + {{ .Content | transform.XMLEscape | safeHTML }} +{{ end }} +-- content/s1/p1.md -- +--- +title: p1 +--- +-- content/s1/p2/index.md -- +--- +title: p2 +--- +![alt](a.jpg) + +[p1](/s1/p1) +-- content/s1/p2/a.jpg -- +` + + b := Test(t, files) + + b.AssertFileContent("public/index.xml", "https://example.org/subdir/s1/p1/") + b.AssertFileContent("public/index.xml", + "img src="https://example.org/subdir/a.jpg", + "img srcset="https://example.org/subdir/a.jpg" src="https://example.org/subdir/a.jpg 2x") } diff --git a/hugolib/scratch.go b/hugolib/scratch.go deleted file mode 100644 index 37ed5df35..000000000 --- a/hugolib/scratch.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 hugolib - -import ( - "reflect" - "sort" - "sync" - - "github.com/gohugoio/hugo/tpl/math" -) - -// Scratch is a writable context used for stateful operations in Page/Node rendering. -type Scratch struct { - values map[string]interface{} - mu sync.RWMutex -} - -// 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{} - c.mu.RLock() - existingAddend, found := c.values[key] - c.mu.RUnlock() - if found { - var err error - - addendV := reflect.ValueOf(existingAddend) - - if addendV.Kind() == reflect.Slice || addendV.Kind() == reflect.Array { - nav := reflect.ValueOf(newAddend) - if nav.Kind() == reflect.Slice || nav.Kind() == reflect.Array { - newVal = reflect.AppendSlice(addendV, nav).Interface() - } else { - newVal = reflect.Append(addendV, nav).Interface() - } - } else { - newVal, err = math.DoArithmetic(existingAddend, newAddend, '+') - if err != nil { - return "", err - } - } - } else { - newVal = newAddend - } - c.mu.Lock() - c.values[key] = newVal - c.mu.Unlock() - return "", nil // have to return something to make it work with the Go templates -} - -// 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 { - c.mu.Lock() - c.values[key] = value - c.mu.Unlock() - return "" -} - -// Reset deletes the given key -func (c *Scratch) Delete(key string) string { - c.mu.Lock() - delete(c.values, key) - c.mu.Unlock() - return "" -} - -// Get returns a value previously set by Add or Set -func (c *Scratch) Get(key string) interface{} { - c.mu.RLock() - val := c.values[key] - c.mu.RUnlock() - - return val -} - -// 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 { - c.mu.Lock() - _, found := c.values[key] - if !found { - c.values[key] = make(map[string]interface{}) - } - - c.values[key].(map[string]interface{})[mapKey] = value - c.mu.Unlock() - return "" -} - -// GetSortedMapValues returns a sorted map previously filled with SetInMap -func (c *Scratch) GetSortedMapValues(key string) interface{} { - c.mu.RLock() - - if c.values[key] == nil { - c.mu.RUnlock() - return nil - } - - unsortedMap := c.values[key].(map[string]interface{}) - c.mu.RUnlock() - var keys []string - for mapKey := range unsortedMap { - keys = append(keys, mapKey) - } - - sort.Strings(keys) - - sortedArray := make([]interface{}, len(unsortedMap)) - for i, mapKey := range keys { - sortedArray[i] = unsortedMap[mapKey] - } - - return sortedArray -} - -func newScratch() *Scratch { - return &Scratch{values: make(map[string]interface{})} -} diff --git a/hugolib/scratch_test.go b/hugolib/scratch_test.go deleted file mode 100644 index 5ec2b89c8..000000000 --- a/hugolib/scratch_test.go +++ /dev/null @@ -1,170 +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 hugolib - -import ( - "reflect" - "sync" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestScratchAdd(t *testing.T) { - t.Parallel() - scratch := newScratch() - scratch.Add("int1", 10) - scratch.Add("int1", 20) - scratch.Add("int2", 20) - - assert.Equal(t, int64(30), scratch.Get("int1")) - assert.Equal(t, 20, scratch.Get("int2")) - - scratch.Add("float1", float64(10.5)) - scratch.Add("float1", float64(20.1)) - - assert.Equal(t, float64(30.6), scratch.Get("float1")) - - scratch.Add("string1", "Hello ") - scratch.Add("string1", "big ") - scratch.Add("string1", "World!") - - assert.Equal(t, "Hello big World!", scratch.Get("string1")) - - scratch.Add("scratch", scratch) - _, err := scratch.Add("scratch", scratch) - - if err == nil { - t.Errorf("Expected error from invalid arithmetic") - } - -} - -func TestScratchAddSlice(t *testing.T) { - t.Parallel() - scratch := newScratch() - - _, err := scratch.Add("intSlice", []int{1, 2}) - assert.Nil(t, err) - _, err = scratch.Add("intSlice", 3) - assert.Nil(t, err) - - sl := scratch.Get("intSlice") - expected := []int{1, 2, 3} - - if !reflect.DeepEqual(expected, sl) { - t.Errorf("Slice difference, go %q expected %q", sl, expected) - } - - _, err = scratch.Add("intSlice", []int{4, 5}) - - assert.Nil(t, err) - - sl = scratch.Get("intSlice") - expected = []int{1, 2, 3, 4, 5} - - if !reflect.DeepEqual(expected, sl) { - t.Errorf("Slice difference, go %q expected %q", sl, expected) - } - -} - -func TestScratchSet(t *testing.T) { - t.Parallel() - scratch := newScratch() - scratch.Set("key", "val") - assert.Equal(t, "val", scratch.Get("key")) -} - -func TestScratchDelete(t *testing.T) { - t.Parallel() - scratch := newScratch() - scratch.Set("key", "val") - scratch.Delete("key") - scratch.Add("key", "Lucy Parsons") - assert.Equal(t, "Lucy Parsons", scratch.Get("key")) -} - -// Issue #2005 -func TestScratchInParallel(t *testing.T) { - var wg sync.WaitGroup - scratch := newScratch() - key := "counter" - scratch.Set(key, int64(1)) - for i := 1; i <= 10; i++ { - wg.Add(1) - go func(j int) { - for k := 0; k < 10; k++ { - newVal := int64(k + j) - - _, err := scratch.Add(key, newVal) - if err != nil { - t.Errorf("Got err %s", err) - } - - scratch.Set(key, newVal) - - val := scratch.Get(key) - - if counter, ok := val.(int64); ok { - if counter < 1 { - t.Errorf("Got %d", counter) - } - } else { - t.Errorf("Got %T", val) - } - } - wg.Done() - }(i) - } - wg.Wait() -} - -func TestScratchGet(t *testing.T) { - t.Parallel() - scratch := newScratch() - nothing := scratch.Get("nothing") - if nothing != nil { - t.Errorf("Should not return anything, but got %v", nothing) - } -} - -func TestScratchSetInMap(t *testing.T) { - t.Parallel() - scratch := newScratch() - scratch.SetInMap("key", "lux", "Lux") - scratch.SetInMap("key", "abc", "Abc") - scratch.SetInMap("key", "zyx", "Zyx") - scratch.SetInMap("key", "abc", "Abc (updated)") - scratch.SetInMap("key", "def", "Def") - assert.Equal(t, []interface{}{0: "Abc (updated)", 1: "Def", 2: "Lux", 3: "Zyx"}, scratch.GetSortedMapValues("key")) -} - -func TestScratchGetSortedMapValues(t *testing.T) { - t.Parallel() - scratch := newScratch() - nothing := scratch.GetSortedMapValues("nothing") - if nothing != nil { - t.Errorf("Should not return anything, but got %v", nothing) - } -} - -func BenchmarkScratchGet(b *testing.B) { - scratch := newScratch() - scratch.Add("A", 1) - b.ResetTimer() - for i := 0; i < b.N; i++ { - scratch.Get("A") - } -} diff --git a/hugolib/securitypolicies_test.go b/hugolib/securitypolicies_test.go new file mode 100644 index 000000000..facda80eb --- /dev/null +++ b/hugolib/securitypolicies_test.go @@ -0,0 +1,191 @@ +// 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 hugolib + +import ( + "fmt" + "net/http" + "net/http/httptest" + "runtime" + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/markup/asciidocext" + "github.com/gohugoio/hugo/markup/pandoc" + "github.com/gohugoio/hugo/markup/rst" + "github.com/gohugoio/hugo/resources/resource_transformers/tocss/dartsass" +) + +func TestSecurityPolicies(t *testing.T) { + c := qt.New(t) + + testVariant := func(c *qt.C, withBuilder func(b *sitesBuilder), expectErr string) { + c.Helper() + b := newTestSitesBuilder(c) + withBuilder(b) + + if expectErr != "" { + err := b.BuildE(BuildCfg{}) + b.Assert(err, qt.IsNotNil) + b.Assert(err, qt.ErrorMatches, expectErr) + } else { + b.Build(BuildCfg{}) + } + } + + httpTestVariant := func(c *qt.C, templ, expectErr string, withBuilder func(b *sitesBuilder)) { + ts := httptest.NewServer(http.FileServer(http.Dir("testdata/"))) + c.Cleanup(func() { + ts.Close() + }) + cb := func(b *sitesBuilder) { + b.WithTemplatesAdded("index.html", fmt.Sprintf(templ, ts.URL)) + if withBuilder != nil { + withBuilder(b) + } + } + testVariant(c, cb, expectErr) + } + + c.Run("os.GetEnv, denied", func(c *qt.C) { + c.Parallel() + cb := func(b *sitesBuilder) { + b.WithTemplatesAdded("index.html", `{{ os.Getenv "FOOBAR" }}`) + } + testVariant(c, cb, `(?s).*"FOOBAR" is not whitelisted in policy "security\.funcs\.getenv".*`) + }) + + c.Run("os.GetEnv, OK", func(c *qt.C) { + c.Parallel() + cb := func(b *sitesBuilder) { + b.WithTemplatesAdded("index.html", `{{ os.Getenv "HUGO_FOO" }}`) + } + testVariant(c, cb, "") + }) + + c.Run("Asciidoc, denied", func(c *qt.C) { + c.Parallel() + if !asciidocext.Supports() { + c.Skip() + } + + cb := func(b *sitesBuilder) { + b.WithContent("page.ad", "foo") + } + + testVariant(c, cb, `(?s).*"asciidoctor" is not whitelisted in policy "security\.exec\.allow".*`) + }) + + c.Run("RST, denied", func(c *qt.C) { + c.Parallel() + if !rst.Supports() { + c.Skip() + } + + cb := func(b *sitesBuilder) { + b.WithContent("page.rst", "foo") + } + + if runtime.GOOS == "windows" { + testVariant(c, cb, `(?s).*python(\.exe)?" is not whitelisted in policy "security\.exec\.allow".*`) + } else { + testVariant(c, cb, `(?s).*"rst2html(\.py)?" is not whitelisted in policy "security\.exec\.allow".*`) + } + }) + + c.Run("Pandoc, denied", func(c *qt.C) { + c.Parallel() + if !pandoc.Supports() { + c.Skip() + } + + cb := func(b *sitesBuilder) { + b.WithContent("page.pdc", "foo") + } + + testVariant(c, cb, `(?s).*pandoc" is not whitelisted in policy "security\.exec\.allow".*`) + }) + + c.Run("Dart SASS, OK", func(c *qt.C) { + c.Parallel() + if !dartsass.Supports() { + c.Skip() + } + cb := func(b *sitesBuilder) { + b.WithTemplatesAdded("index.html", `{{ $scss := "body { color: #333; }" | resources.FromString "foo.scss" | css.Sass (dict "transpiler" "dartsass") }}`) + } + testVariant(c, cb, "") + }) + + c.Run("Dart SASS, denied", func(c *qt.C) { + c.Parallel() + if !dartsass.Supports() { + c.Skip() + } + cb := func(b *sitesBuilder) { + b.WithConfigFile("toml", ` +[security] +[security.exec] +allow="none" + + `) + b.WithTemplatesAdded("index.html", `{{ $scss := "body { color: #333; }" | resources.FromString "foo.scss" | css.Sass (dict "transpiler" "dartsass") }}`) + } + testVariant(c, cb, `(?s).*sass(-embedded)?" is not whitelisted in policy "security\.exec\.allow".*`) + }) + + c.Run("resources.GetRemote, OK", func(c *qt.C) { + c.Parallel() + httpTestVariant(c, `{{ $json := resources.GetRemote "%[1]s/fruits.json" }}{{ $json.Content }}`, "", nil) + }) + + c.Run("resources.GetRemote, denied method", func(c *qt.C) { + c.Parallel() + httpTestVariant(c, `{{ $json := resources.GetRemote "%[1]s/fruits.json" (dict "method" "DELETE" ) }}{{ $json.Content }}`, `(?s).*"DELETE" is not whitelisted in policy "security\.http\.method".*`, nil) + }) + + c.Run("resources.GetRemote, denied URL", func(c *qt.C) { + c.Parallel() + httpTestVariant(c, `{{ $json := resources.GetRemote "%[1]s/fruits.json" }}{{ $json.Content }}`, `(?s).*is not whitelisted in policy "security\.http\.urls".*`, + func(b *sitesBuilder) { + b.WithConfigFile("toml", ` +[security] +[security.http] +urls="none" +`) + }) + }) + + c.Run("resources.GetRemote, fake JSON", func(c *qt.C) { + c.Parallel() + httpTestVariant(c, `{{ $json := resources.GetRemote "%[1]s/fakejson.json" }}{{ $json.Content }}`, `(?s).*failed to resolve media type.*`, + func(b *sitesBuilder) { + b.WithConfigFile("toml", ` +`) + }) + }) + + c.Run("resources.GetRemote, fake JSON whitelisted", func(c *qt.C) { + c.Parallel() + httpTestVariant(c, `{{ $json := resources.GetRemote "%[1]s/fakejson.json" }}{{ $json.Content }}`, ``, + func(b *sitesBuilder) { + b.WithConfigFile("toml", ` +[security] +[security.http] +mediaTypes=["application/json"] + +`) + }) + }) +} diff --git a/hugolib/segments/segments.go b/hugolib/segments/segments.go new file mode 100644 index 000000000..941c4ea5c --- /dev/null +++ b/hugolib/segments/segments.go @@ -0,0 +1,257 @@ +// 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 segments + +import ( + "fmt" + + "github.com/gobwas/glob" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/common/predicate" + "github.com/gohugoio/hugo/config" + hglob "github.com/gohugoio/hugo/hugofs/glob" + "github.com/mitchellh/mapstructure" +) + +// Segments is a collection of named segments. +type Segments struct { + s map[string]excludeInclude +} + +type excludeInclude struct { + exclude predicate.P[SegmentMatcherFields] + include predicate.P[SegmentMatcherFields] +} + +// ShouldExcludeCoarse returns whether the given fields should be excluded. +// This is used for the coarser grained checks, e.g. language and output format. +// Note that ShouldExcludeCoarse(fields) == ShouldExcludeFine(fields) may +// not always be true, but ShouldExcludeCoarse(fields) == true == ShouldExcludeFine(fields) +// will always be truthful. +func (e excludeInclude) ShouldExcludeCoarse(fields SegmentMatcherFields) bool { + return e.exclude != nil && e.exclude(fields) +} + +// ShouldExcludeFine returns whether the given fields should be excluded. +// This is used for the finer grained checks, e.g. on individual pages. +func (e excludeInclude) ShouldExcludeFine(fields SegmentMatcherFields) bool { + if e.exclude != nil && e.exclude(fields) { + return true + } + return e.include != nil && !e.include(fields) +} + +type SegmentFilter interface { + // ShouldExcludeCoarse returns whether the given fields should be excluded on a coarse level. + ShouldExcludeCoarse(SegmentMatcherFields) bool + + // ShouldExcludeFine returns whether the given fields should be excluded on a fine level. + ShouldExcludeFine(SegmentMatcherFields) bool +} + +type segmentFilter struct { + coarse predicate.P[SegmentMatcherFields] + fine predicate.P[SegmentMatcherFields] +} + +func (f segmentFilter) ShouldExcludeCoarse(field SegmentMatcherFields) bool { + return f.coarse(field) +} + +func (f segmentFilter) ShouldExcludeFine(fields SegmentMatcherFields) bool { + return f.fine(fields) +} + +var ( + matchAll = func(SegmentMatcherFields) bool { return true } + matchNothing = func(SegmentMatcherFields) bool { return false } +) + +// Get returns a SegmentFilter for the given segments. +func (sms Segments) Get(onNotFound func(s string), ss ...string) SegmentFilter { + if ss == nil { + return segmentFilter{coarse: matchNothing, fine: matchNothing} + } + var sf segmentFilter + for _, s := range ss { + if seg, ok := sms.s[s]; ok { + if sf.coarse == nil { + sf.coarse = seg.ShouldExcludeCoarse + } else { + sf.coarse = sf.coarse.Or(seg.ShouldExcludeCoarse) + } + if sf.fine == nil { + sf.fine = seg.ShouldExcludeFine + } else { + sf.fine = sf.fine.Or(seg.ShouldExcludeFine) + } + } else if onNotFound != nil { + onNotFound(s) + } + } + + if sf.coarse == nil { + sf.coarse = matchAll + } + if sf.fine == nil { + sf.fine = matchAll + } + + return sf +} + +type SegmentConfig struct { + Excludes []SegmentMatcherFields + Includes []SegmentMatcherFields +} + +// SegmentMatcherFields is a matcher for a segment include or exclude. +// All of these are Glob patterns. +type SegmentMatcherFields struct { + Kind string + Path string + Lang string + Output string +} + +func getGlob(s string) (glob.Glob, error) { + if s == "" { + return nil, nil + } + g, err := hglob.GetGlob(s) + if err != nil { + return nil, fmt.Errorf("failed to compile Glob %q: %w", s, err) + } + return g, nil +} + +func compileSegments(f []SegmentMatcherFields) (predicate.P[SegmentMatcherFields], error) { + if f == nil { + return func(SegmentMatcherFields) bool { return false }, nil + } + var ( + result predicate.P[SegmentMatcherFields] + section predicate.P[SegmentMatcherFields] + ) + + addToSection := func(matcherFields SegmentMatcherFields, f func(fields SegmentMatcherFields) string) error { + s1 := f(matcherFields) + g, err := getGlob(s1) + if err != nil { + return err + } + matcher := func(fields SegmentMatcherFields) bool { + s2 := f(fields) + if s2 == "" { + return false + } + return g.Match(s2) + } + if section == nil { + section = matcher + } else { + section = section.And(matcher) + } + return nil + } + + for _, fields := range f { + if fields.Kind != "" { + if err := addToSection(fields, func(fields SegmentMatcherFields) string { return fields.Kind }); err != nil { + return result, err + } + } + if fields.Path != "" { + if err := addToSection(fields, func(fields SegmentMatcherFields) string { return fields.Path }); err != nil { + return result, err + } + } + if fields.Lang != "" { + if err := addToSection(fields, func(fields SegmentMatcherFields) string { return fields.Lang }); err != nil { + return result, err + } + } + if fields.Output != "" { + if err := addToSection(fields, func(fields SegmentMatcherFields) string { return fields.Output }); err != nil { + return result, err + } + } + + if result == nil { + result = section + } else { + result = result.Or(section) + } + section = nil + + } + + return result, nil +} + +func DecodeSegments(in map[string]any) (*config.ConfigNamespace[map[string]SegmentConfig, Segments], error) { + buildConfig := func(in any) (Segments, any, error) { + sms := Segments{ + s: map[string]excludeInclude{}, + } + m, err := maps.ToStringMapE(in) + if err != nil { + return sms, nil, err + } + if m == nil { + m = map[string]any{} + } + m = maps.CleanConfigStringMap(m) + + var scfgm map[string]SegmentConfig + if err := mapstructure.Decode(m, &scfgm); err != nil { + return sms, nil, err + } + + for k, v := range scfgm { + var ( + include predicate.P[SegmentMatcherFields] + exclude predicate.P[SegmentMatcherFields] + err error + ) + if v.Excludes != nil { + exclude, err = compileSegments(v.Excludes) + if err != nil { + return sms, nil, err + } + } + if v.Includes != nil { + include, err = compileSegments(v.Includes) + if err != nil { + return sms, nil, err + } + } + + ei := excludeInclude{ + exclude: exclude, + include: include, + } + sms.s[k] = ei + + } + + return sms, nil, nil + } + + ns, err := config.DecodeNamespace[map[string]SegmentConfig](in, buildConfig) + if err != nil { + return nil, fmt.Errorf("failed to decode segments: %w", err) + } + return ns, nil +} diff --git a/hugolib/segments/segments_integration_test.go b/hugolib/segments/segments_integration_test.go new file mode 100644 index 000000000..465a7abe0 --- /dev/null +++ b/hugolib/segments/segments_integration_test.go @@ -0,0 +1,76 @@ +// 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 segments_test + +import ( + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/hugolib" +) + +func TestSegments(t *testing.T) { + files := ` +-- hugo.toml -- +baseURL = "https://example.org/" +renderSegments = ["docs"] +[languages] +[languages.en] +weight = 1 +[languages.no] +weight = 2 +[languages.nb] +weight = 3 +[segments] +[segments.docs] +[[segments.docs.includes]] +kind = "{home,taxonomy,term}" +[[segments.docs.includes]] +path = "{/docs,/docs/**}" +[[segments.docs.excludes]] +path = "/blog/**" +[[segments.docs.excludes]] +lang = "n*" +output = "rss" +[[segments.docs.excludes]] +output = "json" +-- layouts/_default/single.html -- +Single: {{ .Title }}|{{ .RelPermalink }}| +-- layouts/_default/list.html -- +List: {{ .Title }}|{{ .RelPermalink }}| +-- content/docs/_index.md -- +-- content/docs/section1/_index.md -- +-- content/docs/section1/page1.md -- +--- +title: "Docs Page 1" +tags: ["tag1", "tag2"] +--- +-- content/blog/_index.md -- +-- content/blog/section1/page1.md -- +--- +title: "Blog Page 1" +tags: ["tag1", "tag2"] +--- +` + + b := hugolib.Test(t, files) + b.Assert(b.H.Configs.Base.RootConfig.RenderSegments, qt.DeepEquals, []string{"docs"}) + + b.AssertFileContent("public/docs/section1/page1/index.html", "Docs Page 1") + b.AssertFileExists("public/blog/section1/page1/index.html", false) + b.AssertFileExists("public/index.html", true) + b.AssertFileExists("public/index.xml", true) + b.AssertFileExists("public/no/index.html", true) + b.AssertFileExists("public/no/index.xml", false) +} diff --git a/hugolib/segments/segments_test.go b/hugolib/segments/segments_test.go new file mode 100644 index 000000000..1a2dfb97b --- /dev/null +++ b/hugolib/segments/segments_test.go @@ -0,0 +1,115 @@ +package segments + +import ( + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestCompileSegments(t *testing.T) { + c := qt.New(t) + + c.Run("excludes", func(c *qt.C) { + fields := []SegmentMatcherFields{ + { + Lang: "n*", + Output: "rss", + }, + } + + match, err := compileSegments(fields) + c.Assert(err, qt.IsNil) + + check := func() { + c.Assert(match, qt.IsNotNil) + c.Assert(match(SegmentMatcherFields{Lang: "no"}), qt.Equals, false) + c.Assert(match(SegmentMatcherFields{Lang: "no", Kind: "page"}), qt.Equals, false) + c.Assert(match(SegmentMatcherFields{Lang: "no", Output: "rss"}), qt.Equals, true) + c.Assert(match(SegmentMatcherFields{Lang: "no", Output: "html"}), qt.Equals, false) + c.Assert(match(SegmentMatcherFields{Kind: "page"}), qt.Equals, false) + c.Assert(match(SegmentMatcherFields{Lang: "no", Output: "rss", Kind: "page"}), qt.Equals, true) + } + + check() + + fields = []SegmentMatcherFields{ + { + Path: "/blog/**", + }, + { + Lang: "n*", + Output: "rss", + }, + } + + match, err = compileSegments(fields) + c.Assert(err, qt.IsNil) + check() + c.Assert(match(SegmentMatcherFields{Path: "/blog/foo"}), qt.Equals, true) + }) + + c.Run("includes", func(c *qt.C) { + fields := []SegmentMatcherFields{ + { + Path: "/docs/**", + }, + { + Lang: "no", + Output: "rss", + }, + } + + match, err := compileSegments(fields) + c.Assert(err, qt.IsNil) + c.Assert(match, qt.IsNotNil) + c.Assert(match(SegmentMatcherFields{Lang: "no"}), qt.Equals, false) + c.Assert(match(SegmentMatcherFields{Kind: "page"}), qt.Equals, false) + c.Assert(match(SegmentMatcherFields{Kind: "page", Path: "/blog/foo"}), qt.Equals, false) + c.Assert(match(SegmentMatcherFields{Lang: "en"}), qt.Equals, false) + c.Assert(match(SegmentMatcherFields{Lang: "no", Output: "rss"}), qt.Equals, true) + c.Assert(match(SegmentMatcherFields{Lang: "no", Output: "html"}), qt.Equals, false) + c.Assert(match(SegmentMatcherFields{Kind: "page", Path: "/docs/foo"}), qt.Equals, true) + }) + + c.Run("includes variant1", func(c *qt.C) { + c.Skip() + + fields := []SegmentMatcherFields{ + { + Kind: "home", + }, + { + Path: "{/docs,/docs/**}", + }, + } + + match, err := compileSegments(fields) + c.Assert(err, qt.IsNil) + c.Assert(match, qt.IsNotNil) + c.Assert(match(SegmentMatcherFields{Path: "/blog/foo"}), qt.Equals, false) + c.Assert(match(SegmentMatcherFields{Kind: "page", Path: "/docs/foo"}), qt.Equals, true) + c.Assert(match(SegmentMatcherFields{Kind: "home", Path: "/"}), qt.Equals, true) + }) +} + +func BenchmarkSegmentsMatch(b *testing.B) { + fields := []SegmentMatcherFields{ + { + Path: "/docs/**", + }, + { + Lang: "no", + Output: "rss", + }, + } + + match, err := compileSegments(fields) + if err != nil { + b.Fatal(err) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + match(SegmentMatcherFields{Lang: "no", Output: "rss"}) + } +} diff --git a/hugolib/shortcode.go b/hugolib/shortcode.go index 3cf472f82..56bf1ff9e 100644 --- a/hugolib/shortcode.go +++ b/hugolib/shortcode.go @@ -1,4 +1,4 @@ -// Copyright 2017 The Hugo Authors. All rights reserved. +// Copyright 2025 The Hugo Authors. All 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,60 +15,133 @@ package hugolib import ( "bytes" + "context" "errors" "fmt" "html/template" + "path" "reflect" "regexp" "sort" + "strconv" "strings" "sync" - "github.com/gohugoio/hugo/output" + "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/common/types" + "github.com/gohugoio/hugo/tpl/tplimpl" - "github.com/gohugoio/hugo/media" + "github.com/gohugoio/hugo/parser/pageparser" + "github.com/gohugoio/hugo/resources/page" + + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/common/text" + "github.com/gohugoio/hugo/common/urls" bp "github.com/gohugoio/hugo/bufferpool" - "github.com/gohugoio/hugo/helpers" "github.com/gohugoio/hugo/tpl" ) +var ( + _ urls.RefLinker = (*ShortcodeWithPage)(nil) + _ types.Unwrapper = (*ShortcodeWithPage)(nil) + _ text.Positioner = (*ShortcodeWithPage)(nil) + _ maps.StoreProvider = (*ShortcodeWithPage)(nil) +) + // ShortcodeWithPage is the "." context in a shortcode template. type ShortcodeWithPage struct { - Params interface{} + Params any Inner template.HTML - Page *Page + Page page.Page Parent *ShortcodeWithPage + Name string IsNamedParams bool - scratch *Scratch + + // 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. + Ordinal int + + // Indentation before the opening shortcode in the source. + indentation string + + innerDeindentInit sync.Once + innerDeindent template.HTML + + // pos is the position in bytes in the source file. Used for error logging. + posInit sync.Once + posOffset int + pos text.Position + + store *maps.Scratch +} + +// InnerDeindent returns the (potentially de-indented) inner content of the shortcode. +func (scp *ShortcodeWithPage) InnerDeindent() template.HTML { + if scp.indentation == "" { + return scp.Inner + } + scp.innerDeindentInit.Do(func() { + b := bp.GetBuffer() + text.VisitLinesAfter(string(scp.Inner), func(s string) { + if strings.HasPrefix(s, scp.indentation) { + b.WriteString(strings.TrimPrefix(s, scp.indentation)) + } else { + b.WriteString(s) + } + }) + scp.innerDeindent = template.HTML(b.String()) + bp.PutBuffer(b) + }) + + return scp.innerDeindent +} + +// Position returns this shortcode's detailed position. Note that this information +// may be expensive to calculate, so only use this in error situations. +func (scp *ShortcodeWithPage) Position() text.Position { + scp.posInit.Do(func() { + if p, ok := mustUnwrapPage(scp.Page).(pageContext); ok { + scp.pos = p.posOffset(scp.posOffset) + } + }) + return scp.pos } // Site returns information about the current site. -func (scp *ShortcodeWithPage) Site() *SiteInfo { - return scp.Page.Site +func (scp *ShortcodeWithPage) Site() page.Site { + return scp.Page.Site() } -// Ref is a shortcut to the Ref method on Page. -func (scp *ShortcodeWithPage) Ref(ref string) (string, error) { - return scp.Page.Ref(ref) +// Ref is a shortcut to the Ref method on Page. It passes itself as a context +// to get better error messages. +func (scp *ShortcodeWithPage) Ref(args map[string]any) (string, error) { + return scp.Page.RefFrom(args, scp) } -// RelRef is a shortcut to the RelRef method on Page. -func (scp *ShortcodeWithPage) RelRef(ref string) (string, error) { - return scp.Page.RelRef(ref) +// RelRef is a shortcut to the RelRef method on Page. It passes itself as a context +// to get better error messages. +func (scp *ShortcodeWithPage) RelRef(args map[string]any) (string, error) { + return scp.Page.RelRefFrom(args, scp) +} + +// Store returns this shortcode's Store. +func (scp *ShortcodeWithPage) Store() *maps.Scratch { + if scp.store == nil { + scp.store = maps.NewScratch() + } + return scp.store } // Scratch returns a scratch-pad scoped for this shortcode. This can be used // as a temporary storage for variables, counters etc. -func (scp *ShortcodeWithPage) Scratch() *Scratch { - if scp.scratch == nil { - scp.scratch = newScratch() - } - return scp.scratch +// Deprecated: Use Store instead. Note that from the templates this should be considered a "soft deprecation". +func (scp *ShortcodeWithPage) Scratch() *maps.Scratch { + return scp.Store() } // Get is a convenience method to look up shortcode parameters by its key. -func (scp *ShortcodeWithPage) Get(key interface{}) interface{} { +func (scp *ShortcodeWithPage) Get(key any) any { if scp.Params == nil { return nil } @@ -81,13 +154,15 @@ func (scp *ShortcodeWithPage) Get(key interface{}) interface{} { switch key.(type) { case int64, int32, int16, int8, int: if reflect.TypeOf(scp.Params).Kind() == reflect.Map { - return "error: cannot access named params by position" + // We treat this as a non error, so people can do similar to + // {{ $myParam := .Get "myParam" | default .Get 0 }} + // Without having to do additional checks. + return nil } else if reflect.TypeOf(scp.Params).Kind() == reflect.Slice { idx := int(reflect.ValueOf(key).Int()) ln := reflect.ValueOf(scp.Params).Len() if idx > ln-1 { - helpers.DistinctErrorLog.Printf("No shortcode param at .Get %d in page %s, have params: %v", idx, scp.Page.FullFilePath(), scp.Params) - return fmt.Sprintf("error: index out of range for positional param at position %d", idx) + return "" } x = reflect.ValueOf(scp.Params).Index(idx) } @@ -98,50 +173,98 @@ func (scp *ShortcodeWithPage) Get(key interface{}) interface{} { return "" } } else if reflect.TypeOf(scp.Params).Kind() == reflect.Slice { - if reflect.ValueOf(scp.Params).Len() == 1 && reflect.ValueOf(scp.Params).Index(0).String() == "" { - return nil - } - return "error: cannot access positional params by string name" + // We treat this as a non error, so people can do similar to + // {{ $myParam := .Get "myParam" | default .Get 0 }} + // Without having to do additional checks. + return nil } } - switch x.Kind() { - case reflect.String: - return x.String() - case reflect.Int64, reflect.Int32, reflect.Int16, reflect.Int8, reflect.Int: - return x.Int() - default: - return x - } + return x.Interface() +} +// For internal use only. +func (scp *ShortcodeWithPage) Unwrapv() any { + return scp.Page } // Note - this value must not contain any markup syntax -const shortcodePlaceholderPrefix = "HUGOSHORTCODE" +const shortcodePlaceholderPrefix = "HAHAHUGOSHORTCODE" + +func createShortcodePlaceholder(sid string, id uint64, ordinal int) string { + return shortcodePlaceholderPrefix + strconv.FormatUint(id, 10) + sid + strconv.Itoa(ordinal) + "HBHB" +} type shortcode struct { - name string - inner []interface{} // string or nested shortcode - params interface{} // map or array - err error + name string + isInline bool // inline shortcode. Any inner will be a Go template. + isClosing bool // whether a closing tag was provided + inner []any // string or nested shortcode + params any // map or array + ordinal int + + indentation string // indentation from source. + + templ *tplimpl.TemplInfo + + // If set, the rendered shortcode is sent as part of the surrounding content + // to Goldmark and similar. + // Before Hug0 0.55 we didn't send any shortcode output to the markup + // renderer, and this flag told Hugo to process the {{ .Inner }} content + // separately. + // The old behavior can be had by starting your shortcode template with: + // {{ $_hugo_config := `{ "version": 1 }`}} doMarkup bool + + // the placeholder in the source when passed to Goldmark etc. + // This also identifies the rendered shortcode. + placeholder string + + pos int // the position in bytes in the source file + length int // the length in bytes in the source file +} + +func (s shortcode) insertPlaceholder() bool { + return !s.doMarkup || s.configVersion() == 1 +} + +func (s shortcode) needsInner() bool { + return s.templ != nil && s.templ.ParseInfo.IsInner +} + +func (s shortcode) configVersion() int { + if s.templ == nil { + // Not set for inline shortcodes. + return 2 + } + return s.templ.ParseInfo.Config.Version +} + +func (s shortcode) innerString() string { + var sb strings.Builder + + for _, inner := range s.inner { + sb.WriteString(inner.(string)) + } + + return sb.String() } func (sc shortcode) String() string { // for testing (mostly), so any change here will break tests! - var params interface{} + var params any switch v := sc.params.(type) { - case map[string]string: + case map[string]any: // sort the keys so test assertions won't fail var keys []string for k := range v { keys = append(keys, k) } sort.Strings(keys) - var tmp = make([]string, len(keys)) + tmp := make(map[string]any) - for i, k := range keys { - tmp[i] = k + ":" + v[k] + for _, k := range keys { + tmp[k] = v[k] } params = tmp @@ -153,131 +276,157 @@ func (sc shortcode) String() string { return fmt.Sprintf("%s(%q, %t){%s}", sc.name, params, sc.doMarkup, sc.inner) } -// We may have special shortcode templates for AMP etc. -// Note that in the below, OutputFormat may be empty. -// We will try to look for the most specific shortcode template available. -type scKey struct { - Lang string - OutputFormat string - Suffix string - ShortcodePlaceholder string -} - -func newScKey(m media.Type, shortcodeplaceholder string) scKey { - return scKey{Suffix: m.Suffix, ShortcodePlaceholder: shortcodeplaceholder} -} - -func newScKeyFromLangAndOutputFormat(lang string, o output.Format, shortcodeplaceholder string) scKey { - return scKey{Lang: lang, Suffix: o.MediaType.Suffix, OutputFormat: o.Name, ShortcodePlaceholder: shortcodeplaceholder} -} - -func newDefaultScKey(shortcodeplaceholder string) scKey { - return newScKey(media.HTMLType, shortcodeplaceholder) -} - type shortcodeHandler struct { - init sync.Once + filename string + s *Site - p *Page - - // This is all shortcode rendering funcs for all potential output formats. - contentShortcodes map[scKey]func() (string, error) - - // This map contains the new or changed set of shortcodes that need - // to be rendered for the current output format. - contentShortcodesDelta map[scKey]func() (string, error) - - // This maps the shorcode placeholders with the rendered content. - // We will do (potential) partial re-rendering per output format, - // so keep this for the unchanged. - renderedShortcodes map[string]string - - // Maps the shortcodeplaceholder with the actual shortcode. - shortcodes map[string]shortcode + // Ordered list of shortcodes for a page. + shortcodes []*shortcode // All the shortcode names in this set. - nameSet map[string]bool + nameSet map[string]bool + nameSetMu sync.RWMutex + + // Configuration + enableInlineShortcodes bool } -func newShortcodeHandler(p *Page) *shortcodeHandler { - return &shortcodeHandler{ - p: p, - contentShortcodes: make(map[scKey]func() (string, error)), - shortcodes: make(map[string]shortcode), - nameSet: make(map[string]bool), - renderedShortcodes: make(map[string]string), - } -} - -// TODO(bep) make it non-global -var isInnerShortcodeCache = struct { - sync.RWMutex - m map[string]bool -}{m: make(map[string]bool)} - -// to avoid potential costly look-aheads for closing tags we look inside the template itself -// we could change the syntax to self-closing tags, but that would make users cry -// the value found is cached -func isInnerShortcode(t tpl.TemplateExecutor) (bool, error) { - isInnerShortcodeCache.RLock() - m, ok := isInnerShortcodeCache.m[t.Name()] - isInnerShortcodeCache.RUnlock() - - if ok { - return m, nil +func newShortcodeHandler(filename string, s *Site) *shortcodeHandler { + sh := &shortcodeHandler{ + filename: filename, + s: s, + enableInlineShortcodes: s.ExecHelper.Sec().EnableInlineShortcodes, + shortcodes: make([]*shortcode, 0, 4), + nameSet: make(map[string]bool), } - isInnerShortcodeCache.Lock() - defer isInnerShortcodeCache.Unlock() - match, _ := regexp.MatchString("{{.*?\\.Inner.*?}}", t.Tree()) - isInnerShortcodeCache.m[t.Name()] = match - - return match, nil + return sh } -func clearIsInnerShortcodeCache() { - isInnerShortcodeCache.Lock() - defer isInnerShortcodeCache.Unlock() - isInnerShortcodeCache.m = make(map[string]bool) -} +const ( + innerNewlineRegexp = "\n" + innerCleanupRegexp = `\A<p>(.*)</p>\n\z` + innerCleanupExpand = "$1" +) -func createShortcodePlaceholder(id int) string { - return fmt.Sprintf("HAHA%s-%dHBHB", shortcodePlaceholderPrefix, id) -} - -const innerNewlineRegexp = "\n" -const innerCleanupRegexp = `\A<p>(.*)</p>\n\z` -const innerCleanupExpand = "$1" - -func prepareShortcodeForPage(placeholder string, sc shortcode, parent *ShortcodeWithPage, p *Page) map[scKey]func() (string, error) { - - m := make(map[scKey]func() (string, error)) - lang := p.Lang() - - for _, f := range p.outputFormats { - // The most specific template will win. - key := newScKeyFromLangAndOutputFormat(lang, f, placeholder) - m[key] = func() (string, error) { - return renderShortcode(key, sc, nil, p), nil - } - } - - return m -} - -func renderShortcode( - tmplKey scKey, - sc shortcode, +func prepareShortcode( + ctx context.Context, + level int, + s *Site, + sc *shortcode, parent *ShortcodeWithPage, - p *Page) string { - - tmpl := getShortcodeTemplateForTemplateKey(tmplKey, sc.name, p.s.Tmpl) - if tmpl == nil { - p.s.Log.ERROR.Printf("Unable to locate template for shortcode %q in page %q", sc.name, p.Path()) - return "" + po *pageOutput, + isRenderString bool, +) (shortcodeRenderer, error) { + p := po.p + toParseErr := func(err error) error { + source := p.m.content.mustSource() + return p.parseError(fmt.Errorf("failed to render shortcode %q: %w", sc.name, err), source, sc.pos) + } + + // Allow the caller to delay the rendering of the shortcode if needed. + var fn shortcodeRenderFunc = func(ctx context.Context) ([]byte, bool, error) { + if p.m.pageConfig.ContentMediaType.IsMarkdown() && sc.doMarkup { + // Signal downwards that the content rendered will be + // parsed and rendered by Goldmark. + ctx = tpl.Context.IsInGoldmark.Set(ctx, true) + } + r, err := doRenderShortcode(ctx, level, s, sc, parent, po, isRenderString) + if err != nil { + return nil, false, toParseErr(err) + } + + b, hasVariants, err := r.renderShortcode(ctx) + if err != nil { + return nil, false, toParseErr(err) + } + return b, hasVariants, nil + } + + return fn, nil +} + +func doRenderShortcode( + ctx context.Context, + level int, + s *Site, + sc *shortcode, + parent *ShortcodeWithPage, + po *pageOutput, + isRenderString bool, +) (shortcodeRenderer, error) { + var tmpl *tplimpl.TemplInfo + p := po.p + + // Tracks whether this shortcode or any of its children has template variations + // in other languages or output formats. We are currently only interested in + // the output formats. + var hasVariants bool + + if sc.isInline { + if !p.s.ExecHelper.Sec().EnableInlineShortcodes { + return zeroShortcode, nil + } + templatePath := path.Join("_inline_shortcode", p.Path(), sc.name) + if sc.isClosing { + templStr := sc.innerString() + + var err error + tmpl, err = s.TemplateStore.TextParse(templatePath, templStr) + if err != nil { + if isRenderString { + return zeroShortcode, p.wrapError(err) + } + fe := herrors.NewFileErrorFromName(err, p.File().Filename()) + pos := fe.Position() + pos.LineNumber += p.posOffset(sc.pos).LineNumber + fe = fe.UpdatePosition(pos) + return zeroShortcode, p.wrapError(fe) + } + + } else { + // Re-use of shortcode defined earlier in the same page. + tmpl = s.TemplateStore.TextLookup(templatePath) + if tmpl == nil { + return zeroShortcode, fmt.Errorf("no earlier definition of shortcode %q found", sc.name) + } + } + } else { + ofCount := map[string]int{} + include := func(match *tplimpl.TemplInfo) bool { + ofCount[match.D.OutputFormat]++ + return true + } + base, layoutDescriptor := po.GetInternalTemplateBasePathAndDescriptor() + + // With shortcodes/mymarkdown.md (only), this allows {{% mymarkdown %}} when rendering HTML, + // but will not resolve any template when doing {{< mymarkdown >}}. + layoutDescriptor.AlwaysAllowPlainText = sc.doMarkup + q := tplimpl.TemplateQuery{ + Path: base, + Name: sc.name, + Category: tplimpl.CategoryShortcode, + Desc: layoutDescriptor, + Consider: include, + } + v, err := s.TemplateStore.LookupShortcode(q) + if v == nil { + return zeroShortcode, err + } + tmpl = v + hasVariants = hasVariants || len(ofCount) > 1 + } + + data := &ShortcodeWithPage{ + Ordinal: sc.ordinal, + posOffset: sc.pos, + indentation: sc.indentation, + Params: sc.params, + Page: newPageForShortcode(p), + Parent: parent, + Name: sc.name, } - data := &ShortcodeWithPage{Params: sc.params, Page: p, Parent: parent} if sc.params != nil { data.IsNamedParams = reflect.TypeOf(sc.params).Kind() == reflect.Map } @@ -285,27 +434,39 @@ func renderShortcode( if len(sc.inner) > 0 { var inner string for _, innerData := range sc.inner { - switch innerData.(type) { + switch innerData := innerData.(type) { case string: - inner += innerData.(string) - case shortcode: - inner += renderShortcode(tmplKey, innerData.(shortcode), data, p) + inner += innerData + case *shortcode: + s, err := prepareShortcode(ctx, level+1, s, innerData, data, po, isRenderString) + if err != nil { + return zeroShortcode, err + } + ss, more, err := s.renderShortcodeString(ctx) + hasVariants = hasVariants || more + if err != nil { + return zeroShortcode, err + } + inner += ss default: - p.s.Log.ERROR.Printf("Illegal state on shortcode rendering of %q in page %q. Illegal type in inner data: %s ", - sc.name, p.Path(), reflect.TypeOf(innerData)) - return "" + s.Log.Errorf("Illegal state on shortcode rendering of %q in page %q. Illegal type in inner data: %s ", + sc.name, p.File().Path(), reflect.TypeOf(innerData)) + return zeroShortcode, nil } } - if sc.doMarkup { - newInner := p.s.ContentSpec.RenderBytes(&helpers.RenderingContext{ - Content: []byte(inner), PageFmt: p.determineMarkupType(), - Cfg: p.Language(), - DocumentID: p.UniqueID(), - DocumentName: p.Path(), - Config: p.getRenderingConfig()}) + // Pre Hugo 0.55 this was the behavior even for the outer-most + // shortcode. + if sc.doMarkup && (level > 0 || sc.configVersion() == 1) { + var err error + b, err := p.pageOutput.contentRenderer.ParseAndRenderContent(ctx, []byte(inner), false) + if err != nil { + return zeroShortcode, err + } - // If the type is “unknown” or “markdown”, we assume the markdown + newInner := b.Bytes() + + // If the type is “” (unknown) or “markdown”, we assume the markdown // generation has been performed. Given the input: `a line`, markdown // specifies the HTML `<p>a line</p>\n`. When dealing with documents as a // whole, this is OK. When dealing with an `{{ .Inner }}` block in Hugo, @@ -314,12 +475,9 @@ func renderShortcode( // 1. Check to see if inner has a newline in it. If so, the Inner data is // unchanged. // 2 If inner does not have a newline, strip the wrapping <p> block and - // the newline. This was previously tricked out by wrapping shortcode - // substitutions in <div>HUGOSHORTCODE-1</div> which prevents the - // generation, but means that you can’t use shortcodes inside of - // markdown structures itself (e.g., `[foo]({{% ref foo.md %}})`). - switch p.determineMarkupType() { - case "unknown", "markdown": + // the newline. + switch p.m.pageConfig.Content.Markup { + case "", "markdown": if match, _ := regexp.MatchString(innerNewlineRegexp, inner); !match { cleaner, err := regexp.Compile(innerCleanupRegexp) @@ -337,128 +495,137 @@ func renderShortcode( } - return renderShortcodeWithPage(tmpl, data) -} + result, err := renderShortcodeWithPage(ctx, s.GetTemplateStore(), tmpl, data) -// The delta represents new output format-versions of the shortcodes, -// which, combined with the ones that do not have alternative representations, -// builds a complete set ready for a full rebuild of the Page content. -// This method returns false if there are no new shortcode variants in the -// current rendering context's output format. This mean we can safely reuse -// the content from the previous output format, if any. -func (s *shortcodeHandler) updateDelta() bool { - s.init.Do(func() { - s.contentShortcodes = createShortcodeRenderers(s.shortcodes, s.p) - }) - - contentShortcodes := s.contentShortcodesForOutputFormat(s.p.s.rc.Format) - - if s.contentShortcodesDelta == nil || len(s.contentShortcodesDelta) == 0 { - s.contentShortcodesDelta = contentShortcodes - return true + if err != nil && sc.isInline { + fe := herrors.NewFileErrorFromName(err, p.File().Filename()) + pos := fe.Position() + pos.LineNumber += p.posOffset(sc.pos).LineNumber + fe = fe.UpdatePosition(pos) + return zeroShortcode, fe } - delta := make(map[scKey]func() (string, error)) + if len(sc.inner) == 0 && len(sc.indentation) > 0 { + b := bp.GetBuffer() + i := 0 + text.VisitLinesAfter(result, func(line string) { + // The first line is correctly indented. + if i > 0 { + b.WriteString(sc.indentation) + } + i++ + b.WriteString(line) + }) - for k, v := range contentShortcodes { - if _, found := s.contentShortcodesDelta[k]; !found { - delta[k] = v - } + result = b.String() + bp.PutBuffer(b) } - s.contentShortcodesDelta = delta - - return len(delta) > 0 + return prerenderedShortcode{s: result, hasVariants: hasVariants}, err } -func (s *shortcodeHandler) contentShortcodesForOutputFormat(f output.Format) map[scKey]func() (string, error) { - contentShortcodesForOuputFormat := make(map[scKey]func() (string, error)) - lang := s.p.Lang() +func (s *shortcodeHandler) addName(name string) { + s.nameSetMu.Lock() + defer s.nameSetMu.Unlock() + s.nameSet[name] = true +} - for shortcodePlaceholder := range s.shortcodes { - - key := newScKeyFromLangAndOutputFormat(lang, f, shortcodePlaceholder) - renderFn, found := s.contentShortcodes[key] - - if !found { - key.OutputFormat = "" - renderFn, found = s.contentShortcodes[key] - } - - // Fall back to HTML - if !found && key.Suffix != "html" { - key.Suffix = "html" - renderFn, found = s.contentShortcodes[key] - } - - if !found { - panic(fmt.Sprintf("Shortcode %q could not be found", shortcodePlaceholder)) - } - contentShortcodesForOuputFormat[newScKeyFromLangAndOutputFormat(lang, f, shortcodePlaceholder)] = renderFn +func (s *shortcodeHandler) transferNames(in *shortcodeHandler) { + s.nameSetMu.Lock() + defer s.nameSetMu.Unlock() + for k := range in.nameSet { + s.nameSet[k] = true } - - return contentShortcodesForOuputFormat } -func (s *shortcodeHandler) executeShortcodesForDelta(p *Page) error { +func (s *shortcodeHandler) hasName(name string) bool { + s.nameSetMu.RLock() + defer s.nameSetMu.RUnlock() + _, ok := s.nameSet[name] + return ok +} - for k, render := range s.contentShortcodesDelta { - renderedShortcode, err := render() +func (s *shortcodeHandler) prepareShortcodesForPage(ctx context.Context, po *pageOutput, isRenderString bool) (map[string]shortcodeRenderer, error) { + rendered := make(map[string]shortcodeRenderer) + + for _, v := range s.shortcodes { + s, err := prepareShortcode(ctx, 0, s.s, v, nil, po, isRenderString) if err != nil { - return fmt.Errorf("Failed to execute shortcode in page %q: %s", p.Path(), err) + return nil, err } + rendered[v.placeholder] = s - s.renderedShortcodes[k.ShortcodePlaceholder] = renderedShortcode } - return nil - + return rendered, nil } -func createShortcodeRenderers(shortcodes map[string]shortcode, p *Page) map[scKey]func() (string, error) { - - shortcodeRenderers := make(map[scKey]func() (string, error)) - - for k, v := range shortcodes { - prepared := prepareShortcodeForPage(k, v, nil, p) - for kk, vv := range prepared { - shortcodeRenderers[kk] = vv +func posFromInput(filename string, input []byte, offset int) text.Position { + if offset < 0 { + return text.Position{ + Filename: filename, } } + lf := []byte("\n") + input = input[:offset] + lineNumber := bytes.Count(input, lf) + 1 + endOfLastLine := bytes.LastIndex(input, lf) - return shortcodeRenderers + return text.Position{ + Filename: filename, + LineNumber: lineNumber, + ColumnNumber: offset - endOfLastLine, + Offset: offset, + } } -var errShortCodeIllegalState = errors.New("Illegal shortcode state") - // pageTokens state: // - before: positioned just before the shortcode start // - after: shortcode(s) consumed (plural when they are nested) -func (s *shortcodeHandler) extractShortcode(pt *pageTokens, p *Page) (shortcode, error) { - sc := shortcode{} - var isInner = false +func (s *shortcodeHandler) extractShortcode(ordinal, level int, source []byte, pt *pageparser.Iterator) (*shortcode, error) { + if s == nil { + panic("handler nil") + } + sc := &shortcode{ordinal: ordinal} - var currItem item - var cnt = 0 + // Back up one to identify any indentation. + if pt.Pos() > 0 { + pt.Backup() + item := pt.Next() + if item.IsIndentation() { + sc.indentation = item.ValStr(source) + } + } + + cnt := 0 + nestedOrdinal := 0 + nextLevel := level + 1 + closed := false + const errorPrefix = "failed to extract shortcode" Loop: for { - currItem = pt.next() - - switch currItem.typ { - case tLeftDelimScWithMarkup, tLeftDelimScNoMarkup: - next := pt.peek() - if next.typ == tScClose { + currItem := pt.Next() + switch { + case currItem.IsLeftShortcodeDelim(): + next := pt.Peek() + if next.IsRightShortcodeDelim() { + // no name: {{< >}} or {{% %}} + return sc, errors.New("shortcode has no name") + } + if next.IsShortcodeClose() { continue } if cnt > 0 { // nested shortcode; append it to inner content - pt.backup3(currItem, next) - nested, err := s.extractShortcode(pt, p) - if nested.name != "" { - s.nameSet[nested.name] = true + pt.Backup() + nested, err := s.extractShortcode(nestedOrdinal, nextLevel, source, pt) + nestedOrdinal++ + if nested != nil && nested.name != "" { + s.addName(nested.name) } + if err == nil { sc.inner = append(sc.inner, nested) } else { @@ -466,89 +633,100 @@ Loop: } } else { - sc.doMarkup = currItem.typ == tLeftDelimScWithMarkup + sc.doMarkup = currItem.IsShortcodeMarkupDelimiter() } cnt++ - case tRightDelimScWithMarkup, tRightDelimScNoMarkup: + case currItem.IsRightShortcodeDelim(): // we trust the template on this: // if there's no inner, we're done - if !isInner { - return sc, nil + if !sc.isInline { + if !sc.templ.ParseInfo.IsInner { + return sc, nil + } } - case tScClose: - next := pt.peek() - if !isInner { - if next.typ == tError { - // return that error, more specific - continue + case currItem.IsShortcodeClose(): + closed = true + next := pt.Peek() + if !sc.isInline { + if !sc.needsInner() { + if next.IsError() { + // return that error, more specific + continue + } + name := sc.name + if name == "" { + name = next.ValStr(source) + } + return nil, fmt.Errorf("%s: shortcode %q does not evaluate .Inner or .InnerDeindent, yet a closing tag was provided", errorPrefix, name) } - return sc, fmt.Errorf("Shortcode '%s' in page '%s' has no .Inner, yet a closing tag was provided", next.val, p.FullFilePath()) } - if next.typ == tRightDelimScWithMarkup || next.typ == tRightDelimScNoMarkup { + if next.IsRightShortcodeDelim() { // self-closing - pt.consume(1) + pt.Consume(1) } else { - pt.consume(2) + sc.isClosing = true + pt.Consume(2) } return sc, nil - case tText: - sc.inner = append(sc.inner, currItem.val) - case tScName: - sc.name = currItem.val - // We pick the first template for an arbitrary output format - // if more than one. It is "all inner or no inner". - tmpl := getShortcodeTemplateForTemplateKey(scKey{}, sc.name, p.s.Tmpl) - if tmpl == nil { - return sc, fmt.Errorf("Unable to locate template for shortcode %q in page %q", sc.name, p.Path()) - } + case currItem.IsText(): + sc.inner = append(sc.inner, currItem.ValStr(source)) + case currItem.IsShortcodeName(): - var err error - isInner, err = isInnerShortcode(tmpl) - if err != nil { - return sc, fmt.Errorf("Failed to handle template for shortcode %q for page %q: %s", sc.name, p.Path(), err) - } + sc.name = currItem.ValStr(source) - case tScParam: - if !pt.isValueNext() { + // Used to check if the template expects inner content, + // so just pick one arbitrarily with the same name. + templ := s.s.TemplateStore.LookupShortcodeByName(sc.name) + if templ == nil { + return nil, fmt.Errorf("%s: template for shortcode %q not found", errorPrefix, sc.name) + } + sc.templ = templ + case currItem.IsInlineShortcodeName(): + sc.name = currItem.ValStr(source) + sc.isInline = true + case currItem.IsShortcodeParam(): + if !pt.IsValueNext() { continue - } else if pt.peek().typ == tScParamVal { + } else if pt.Peek().IsShortcodeParamVal() { // named params if sc.params == nil { - params := make(map[string]string) - params[currItem.val] = pt.next().val + params := make(map[string]any) + params[currItem.ValStr(source)] = pt.Next().ValTyped(source) sc.params = params } else { - if params, ok := sc.params.(map[string]string); ok { - params[currItem.val] = pt.next().val + if params, ok := sc.params.(map[string]any); ok { + params[currItem.ValStr(source)] = pt.Next().ValTyped(source) } else { - return sc, errShortCodeIllegalState + return sc, fmt.Errorf("%s: invalid state: invalid param type %T for shortcode %q, expected a map", errorPrefix, params, sc.name) } - } } else { // positional params if sc.params == nil { - var params []string - params = append(params, currItem.val) + var params []any + params = append(params, currItem.ValTyped(source)) sc.params = params } else { - if params, ok := sc.params.([]string); ok { - params = append(params, currItem.val) + if params, ok := sc.params.([]any); ok { + params = append(params, currItem.ValTyped(source)) sc.params = params } else { - return sc, errShortCodeIllegalState + return sc, fmt.Errorf("%s: invalid state: invalid param type %T for shortcode %q, expected a slice", errorPrefix, params, sc.name) } - } } - - case tError, tEOF: + case currItem.IsDone(): + if !currItem.IsError() { + if !closed && sc.needsInner() { + return sc, fmt.Errorf("%s: shortcode %q must be closed or self-closed", errorPrefix, sc.name) + } + } // handled by caller - pt.backup() + pt.Backup() break Loop } @@ -556,86 +734,16 @@ Loop: return sc, nil } -func (s *shortcodeHandler) extractShortcodes(stringToParse string, p *Page) (string, error) { - - startIdx := strings.Index(stringToParse, "{{") - - // short cut for docs with no shortcodes - if startIdx < 0 { - return stringToParse, nil - } - - // the parser takes a string; - // since this is an internal API, it could make sense to use the mutable []byte all the way, but - // it seems that the time isn't really spent in the byte copy operations, and the impl. gets a lot cleaner - pt := &pageTokens{lexer: newShortcodeLexer("parse-page", stringToParse, pos(startIdx))} - - id := 1 // incremented id, will be appended onto temp. shortcode placeholders - - result := bp.GetBuffer() - defer bp.PutBuffer(result) - //var result bytes.Buffer - - // the parser is guaranteed to return items in proper order or fail, so … - // … it's safe to keep some "global" state - var currItem item - var currShortcode shortcode - -Loop: - for { - currItem = pt.next() - - switch currItem.typ { - case tText: - result.WriteString(currItem.val) - case tLeftDelimScWithMarkup, tLeftDelimScNoMarkup: - // let extractShortcode handle left delim (will do so recursively) - pt.backup() - - currShortcode, err := s.extractShortcode(pt, p) - - if currShortcode.name != "" { - s.nameSet[currShortcode.name] = true - } - - if err != nil { - return result.String(), err - } - - if currShortcode.params == nil { - currShortcode.params = make([]string, 0) - } - - placeHolder := createShortcodePlaceholder(id) - result.WriteString(placeHolder) - s.shortcodes[placeHolder] = currShortcode - id++ - case tEOF: - break Loop - case tError: - err := fmt.Errorf("%s:%d: %s", - p.FullFilePath(), (p.lineNumRawContentStart() + pt.lexer.lineNum() - 1), currItem) - currShortcode.err = err - return result.String(), err - } - } - - return result.String(), nil - -} - -// Replace prefixed shortcode tokens (HUGOSHORTCODE-1, HUGOSHORTCODE-2) with the real content. +// Replace prefixed shortcode tokens with the real content. // Note: This function will rewrite the input slice. -func replaceShortcodeTokens(source []byte, prefix string, replacements map[string]string) ([]byte, error) { - - if len(replacements) == 0 { - return source, nil - } - - sourceLen := len(source) +func expandShortcodeTokens( + ctx context.Context, + source []byte, + tokenHandler func(ctx context.Context, token string) ([]byte, error), +) ([]byte, error) { start := 0 - pre := []byte("HAHA" + prefix) + pre := []byte(shortcodePlaceholderPrefix) post := []byte("HBHB") pStart := []byte("<p>") pEnd := []byte("</p>") @@ -651,12 +759,15 @@ func replaceShortcodeTokens(source []byte, prefix string, replacements map[strin } end := j + postIdx + 4 - - newVal := []byte(replacements[string(source[j:end])]) + key := string(source[j:end]) + newVal, err := tokenHandler(ctx, key) + if err != nil { + return nil, err + } // Issue #1148: Check for wrapping p-tags <p> if j >= 3 && bytes.Equal(source[j-3:j], pStart) { - if (k+4) < sourceLen && bytes.Equal(source[end:end+4], pEnd) { + if (k+4) < len(source) && bytes.Equal(source[end:end+4], pEnd) { j -= 3 end += 4 } @@ -672,56 +783,13 @@ func replaceShortcodeTokens(source []byte, prefix string, replacements map[strin return source, nil } -func getShortcodeTemplateForTemplateKey(key scKey, shortcodeName string, t tpl.TemplateFinder) *tpl.TemplateAdapter { - isInnerShortcodeCache.RLock() - defer isInnerShortcodeCache.RUnlock() - - var names []string - - suffix := strings.ToLower(key.Suffix) - outFormat := strings.ToLower(key.OutputFormat) - lang := strings.ToLower(key.Lang) - - if outFormat != "" && suffix != "" { - if lang != "" { - names = append(names, fmt.Sprintf("%s.%s.%s.%s", shortcodeName, lang, outFormat, suffix)) - } - names = append(names, fmt.Sprintf("%s.%s.%s", shortcodeName, outFormat, suffix)) - } - - if suffix != "" { - if lang != "" { - names = append(names, fmt.Sprintf("%s.%s.%s", shortcodeName, lang, suffix)) - } - names = append(names, fmt.Sprintf("%s.%s", shortcodeName, suffix)) - } - - names = append(names, shortcodeName) - - for _, name := range names { - - if x := t.Lookup("shortcodes/" + name); x != nil { - return x - } - if x := t.Lookup("theme/shortcodes/" + name); x != nil { - return x - } - if x := t.Lookup("_internal/shortcodes/" + name); x != nil { - return x - } - } - return nil -} - -func renderShortcodeWithPage(tmpl tpl.Template, data *ShortcodeWithPage) string { +func renderShortcodeWithPage(ctx context.Context, h *tplimpl.TemplateStore, tmpl *tplimpl.TemplInfo, data *ShortcodeWithPage) (string, error) { buffer := bp.GetBuffer() defer bp.PutBuffer(buffer) - isInnerShortcodeCache.RLock() - err := tmpl.Execute(buffer, data) - isInnerShortcodeCache.RUnlock() + err := h.ExecuteWithContext(ctx, tmpl, buffer, data) if err != nil { - data.Page.s.Log.ERROR.Printf("error processing shortcode %q for page %q: %s", tmpl.Name(), data.Page.Path(), err) + return "", fmt.Errorf("failed to process shortcode: %w", err) } - return buffer.String() + return buffer.String(), nil } diff --git a/hugolib/shortcode_page.go b/hugolib/shortcode_page.go new file mode 100644 index 000000000..3d27cc93c --- /dev/null +++ b/hugolib/shortcode_page.go @@ -0,0 +1,131 @@ +// 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 hugolib + +import ( + "context" + "html/template" + + "github.com/gohugoio/hugo/common/types" + "github.com/gohugoio/hugo/resources/page" +) + +// A placeholder for the TableOfContents markup. This is what we pass to the Goldmark etc. renderers. +var tocShortcodePlaceholder = createShortcodePlaceholder("TOC", 0, 0) + +// shortcodeRenderer is typically used to delay rendering of inner shortcodes +// marked with placeholders in the content. +type shortcodeRenderer interface { + renderShortcode(context.Context) ([]byte, bool, error) + renderShortcodeString(context.Context) (string, bool, error) +} + +type shortcodeRenderFunc func(context.Context) ([]byte, bool, error) + +func (f shortcodeRenderFunc) renderShortcode(ctx context.Context) ([]byte, bool, error) { + return f(ctx) +} + +func (f shortcodeRenderFunc) renderShortcodeString(ctx context.Context) (string, bool, error) { + b, has, err := f(ctx) + return string(b), has, err +} + +type prerenderedShortcode struct { + s string + hasVariants bool +} + +func (p prerenderedShortcode) renderShortcode(context.Context) ([]byte, bool, error) { + return []byte(p.s), p.hasVariants, nil +} + +func (p prerenderedShortcode) renderShortcodeString(context.Context) (string, bool, error) { + return p.s, p.hasVariants, nil +} + +var zeroShortcode = prerenderedShortcode{} + +// This is sent to the shortcodes. They cannot access the content +// they're a part of. It would cause an infinite regress. +// +// Go doesn't support virtual methods, so this careful dance is currently (I think) +// the best we can do. +type pageForShortcode struct { + page.PageWithoutContent + page.TableOfContentsProvider + page.MarkupProvider + page.ContentProvider + + // We need to replace it after we have rendered it, so provide a + // temporary placeholder. + toc template.HTML + + p *pageState +} + +var _ types.Unwrapper = (*pageForShortcode)(nil) + +func newPageForShortcode(p *pageState) page.Page { + return &pageForShortcode{ + PageWithoutContent: p, + TableOfContentsProvider: p, + MarkupProvider: page.NopPage, + ContentProvider: page.NopPage, + toc: template.HTML(tocShortcodePlaceholder), + p: p, + } +} + +// For internal use. +func (p *pageForShortcode) Unwrapv() any { + return p.PageWithoutContent.(page.Page) +} + +func (p *pageForShortcode) String() string { + return p.p.String() +} + +func (p *pageForShortcode) TableOfContents(context.Context) template.HTML { + return p.toc +} + +var _ types.Unwrapper = (*pageForRenderHooks)(nil) + +// This is what is sent into the content render hooks (link, image). +type pageForRenderHooks struct { + page.PageWithoutContent + page.TableOfContentsProvider + page.MarkupProvider + page.ContentProvider + p *pageState +} + +func newPageForRenderHook(p *pageState) page.Page { + return &pageForRenderHooks{ + PageWithoutContent: p, + MarkupProvider: page.NopPage, + ContentProvider: page.NopPage, + TableOfContentsProvider: p, + p: p, + } +} + +func (p *pageForRenderHooks) Unwrapv() any { + return p.p +} + +func (p *pageForRenderHooks) String() string { + return p.p.String() +} diff --git a/hugolib/shortcode_test.go b/hugolib/shortcode_test.go index 449d55abd..a1f12e77a 100644 --- a/hugolib/shortcode_test.go +++ b/hugolib/shortcode_test.go @@ -1,4 +1,4 @@ -// Copyright 2016 The Hugo Authors. All rights reserved. +// 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. @@ -14,601 +14,121 @@ package hugolib import ( + "context" "fmt" "path/filepath" "reflect" - "regexp" - "sort" "strings" "testing" - jww "github.com/spf13/jwalterweatherman" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/resources/kinds" - "github.com/spf13/afero" + "github.com/gohugoio/hugo/parser/pageparser" - "github.com/gohugoio/hugo/output" - - "github.com/gohugoio/hugo/media" - - "github.com/gohugoio/hugo/deps" - "github.com/gohugoio/hugo/helpers" - "github.com/gohugoio/hugo/tpl" - "github.com/stretchr/testify/require" + qt "github.com/frankban/quicktest" ) -// TODO(bep) remove -func pageFromString(in, filename string, withTemplate ...func(templ tpl.TemplateHandler) error) (*Page, error) { - s := newTestSite(nil) - if len(withTemplate) > 0 { - // Have to create a new site - var err error - cfg, fs := newTestCfg() +func TestExtractShortcodes(t *testing.T) { + b := newTestSitesBuilder(t).WithSimpleConfigFile() - d := deps.DepsCfg{Language: helpers.NewLanguage("en", cfg), Cfg: cfg, Fs: fs, WithTemplate: withTemplate[0]} + b.WithTemplates( + "pages/single.html", `EMPTY`, + "shortcodes/tag.html", `tag`, + "shortcodes/legacytag.html", `{{ $_hugo_config := "{ \"version\": 1 }" }}tag`, + "shortcodes/sc1.html", `sc1`, + "shortcodes/sc2.html", `sc2`, + "shortcodes/inner.html", `{{with .Inner }}{{ . }}{{ end }}`, + "shortcodes/inner2.html", `{{.Inner}}`, + "shortcodes/inner3.html", `{{.Inner}}`, + ).WithContent("page.md", `--- +title: "Shortcodes Galore!" +--- +`) - s, err = NewSiteForCfg(d) - if err != nil { - return nil, err + b.CreateSites().Build(BuildCfg{}) + + s := b.H.Sites[0] + + // Make it more regexp friendly + strReplacer := strings.NewReplacer("[", "{", "]", "}") + + str := func(s *shortcode) string { + if s == nil { + return "<nil>" + } + var version int + if s.templ != nil { + version = s.templ.ParseInfo.Config.Version + } + return strReplacer.Replace(fmt.Sprintf("%s;inline:%t;closing:%t;inner:%v;params:%v;ordinal:%d;markup:%t;version:%d;pos:%d", + s.name, s.isInline, s.isClosing, s.inner, s.params, s.ordinal, s.doMarkup, version, s.pos)) + } + + regexpCheck := func(re string) func(c *qt.C, shortcode *shortcode, err error) { + return func(c *qt.C, shortcode *shortcode, err error) { + c.Assert(err, qt.IsNil) + c.Assert(str(shortcode), qt.Matches, ".*"+re+".*", qt.Commentf("%s", shortcode.name)) } } - return s.NewPageFrom(strings.NewReader(in), filename) -} -func CheckShortCodeMatch(t *testing.T, input, expected string, withTemplate func(templ tpl.TemplateHandler) error) { - CheckShortCodeMatchAndError(t, input, expected, withTemplate, false) -} - -func CheckShortCodeMatchAndError(t *testing.T, input, expected string, withTemplate func(templ tpl.TemplateHandler) error, expectError bool) { - - cfg, fs := newTestCfg() - - // Need some front matter, see https://github.com/gohugoio/hugo/issues/2337 - contentFile := `--- -title: "Title" ---- -` + input - - writeSource(t, fs, "content/simple.md", contentFile) - - h, err := NewHugoSites(deps.DepsCfg{Fs: fs, Cfg: cfg, WithTemplate: withTemplate}) - - require.NoError(t, err) - require.Len(t, h.Sites, 1) - - err = h.Build(BuildCfg{}) - - if err != nil && !expectError { - t.Fatalf("Shortcode rendered error %s.", err) - } - - if err == nil && expectError { - t.Fatalf("No error from shortcode") - } - - require.Len(t, h.Sites[0].RegularPages, 1) - - output := strings.TrimSpace(string(h.Sites[0].RegularPages[0].Content)) - output = strings.TrimPrefix(output, "<p>") - output = strings.TrimSuffix(output, "</p>") - - expected = strings.TrimSpace(expected) - - if output != expected { - t.Fatalf("Shortcode render didn't match. got \n%q but expected \n%q", output, expected) - } -} - -func TestNonSC(t *testing.T) { - t.Parallel() - // notice the syntax diff from 0.12, now comment delims must be added - CheckShortCodeMatch(t, "{{%/* movie 47238zzb */%}}", "{{% movie 47238zzb %}}", nil) -} - -// Issue #929 -func TestHyphenatedSC(t *testing.T) { - t.Parallel() - wt := func(tem tpl.TemplateHandler) error { - - tem.AddTemplate("_internal/shortcodes/hyphenated-video.html", `Playing Video {{ .Get 0 }}`) - return nil - } - - CheckShortCodeMatch(t, "{{< hyphenated-video 47238zzb >}}", "Playing Video 47238zzb", wt) -} - -// Issue #1753 -func TestNoTrailingNewline(t *testing.T) { - t.Parallel() - wt := func(tem tpl.TemplateHandler) error { - tem.AddTemplate("_internal/shortcodes/a.html", `{{ .Get 0 }}`) - return nil - } - - CheckShortCodeMatch(t, "ab{{< a c >}}d", "abcd", wt) -} - -func TestPositionalParamSC(t *testing.T) { - t.Parallel() - wt := func(tem tpl.TemplateHandler) error { - tem.AddTemplate("_internal/shortcodes/video.html", `Playing Video {{ .Get 0 }}`) - return nil - } - - CheckShortCodeMatch(t, "{{< video 47238zzb >}}", "Playing Video 47238zzb", wt) - CheckShortCodeMatch(t, "{{< video 47238zzb 132 >}}", "Playing Video 47238zzb", wt) - CheckShortCodeMatch(t, "{{<video 47238zzb>}}", "Playing Video 47238zzb", wt) - CheckShortCodeMatch(t, "{{<video 47238zzb >}}", "Playing Video 47238zzb", wt) - CheckShortCodeMatch(t, "{{< video 47238zzb >}}", "Playing Video 47238zzb", wt) -} - -func TestPositionalParamIndexOutOfBounds(t *testing.T) { - t.Parallel() - wt := func(tem tpl.TemplateHandler) error { - tem.AddTemplate("_internal/shortcodes/video.html", `Playing Video {{ .Get 1 }}`) - return nil - } - CheckShortCodeMatch(t, "{{< video 47238zzb >}}", "Playing Video error: index out of range for positional param at position 1", wt) -} - -// some repro issues for panics in Go Fuzz testing - -func TestNamedParamSC(t *testing.T) { - t.Parallel() - wt := func(tem tpl.TemplateHandler) error { - tem.AddTemplate("_internal/shortcodes/img.html", `<img{{ with .Get "src" }} src="{{.}}"{{end}}{{with .Get "class"}} class="{{.}}"{{end}}>`) - return nil - } - CheckShortCodeMatch(t, `{{< img src="one" >}}`, `<img src="one">`, wt) - CheckShortCodeMatch(t, `{{< img class="aspen" >}}`, `<img class="aspen">`, wt) - CheckShortCodeMatch(t, `{{< img src= "one" >}}`, `<img src="one">`, wt) - CheckShortCodeMatch(t, `{{< img src ="one" >}}`, `<img src="one">`, wt) - CheckShortCodeMatch(t, `{{< img src = "one" >}}`, `<img src="one">`, wt) - CheckShortCodeMatch(t, `{{< img src = "one" class = "aspen grove" >}}`, `<img src="one" class="aspen grove">`, wt) -} - -// Issue #2294 -func TestNestedNamedMissingParam(t *testing.T) { - t.Parallel() - wt := func(tem tpl.TemplateHandler) error { - tem.AddTemplate("_internal/shortcodes/acc.html", `<div class="acc">{{ .Inner }}</div>`) - tem.AddTemplate("_internal/shortcodes/div.html", `<div {{with .Get "class"}} class="{{ . }}"{{ end }}>{{ .Inner }}</div>`) - tem.AddTemplate("_internal/shortcodes/div2.html", `<div {{with .Get 0}} class="{{ . }}"{{ end }}>{{ .Inner }}</div>`) - return nil - } - CheckShortCodeMatch(t, - `{{% acc %}}{{% div %}}d1{{% /div %}}{{% div2 %}}d2{{% /div2 %}}{{% /acc %}}`, - "<div class=\"acc\"><div >d1</div><div >d2</div>\n</div>", wt) -} - -func TestIsNamedParamsSC(t *testing.T) { - t.Parallel() - wt := func(tem tpl.TemplateHandler) error { - tem.AddTemplate("_internal/shortcodes/byposition.html", `<div id="{{ .Get 0 }}">`) - tem.AddTemplate("_internal/shortcodes/byname.html", `<div id="{{ .Get "id" }}">`) - tem.AddTemplate("_internal/shortcodes/ifnamedparams.html", `<div id="{{ if .IsNamedParams }}{{ .Get "id" }}{{ else }}{{ .Get 0 }}{{end}}">`) - return nil - } - CheckShortCodeMatch(t, `{{< ifnamedparams id="name" >}}`, `<div id="name">`, wt) - CheckShortCodeMatch(t, `{{< ifnamedparams position >}}`, `<div id="position">`, wt) - CheckShortCodeMatch(t, `{{< byname id="name" >}}`, `<div id="name">`, wt) - CheckShortCodeMatch(t, `{{< byname position >}}`, `<div id="error: cannot access positional params by string name">`, wt) - CheckShortCodeMatch(t, `{{< byposition position >}}`, `<div id="position">`, wt) - CheckShortCodeMatch(t, `{{< byposition id="name" >}}`, `<div id="error: cannot access named params by position">`, wt) -} - -func TestInnerSC(t *testing.T) { - t.Parallel() - wt := func(tem tpl.TemplateHandler) error { - tem.AddTemplate("_internal/shortcodes/inside.html", `<div{{with .Get "class"}} class="{{.}}"{{end}}>{{ .Inner }}</div>`) - return nil - } - CheckShortCodeMatch(t, `{{< inside class="aspen" >}}`, `<div class="aspen"></div>`, wt) - CheckShortCodeMatch(t, `{{< inside class="aspen" >}}More Here{{< /inside >}}`, "<div class=\"aspen\">More Here</div>", wt) - CheckShortCodeMatch(t, `{{< inside >}}More Here{{< /inside >}}`, "<div>More Here</div>", wt) -} - -func TestInnerSCWithMarkdown(t *testing.T) { - t.Parallel() - wt := func(tem tpl.TemplateHandler) error { - tem.AddTemplate("_internal/shortcodes/inside.html", `<div{{with .Get "class"}} class="{{.}}"{{end}}>{{ .Inner }}</div>`) - return nil - } - CheckShortCodeMatch(t, `{{% inside %}} -# More Here - -[link](http://spf13.com) and text - -{{% /inside %}}`, "<div><h1 id=\"more-here\">More Here</h1>\n\n<p><a href=\"http://spf13.com\">link</a> and text</p>\n</div>", wt) -} - -func TestInnerSCWithAndWithoutMarkdown(t *testing.T) { - t.Parallel() - wt := func(tem tpl.TemplateHandler) error { - tem.AddTemplate("_internal/shortcodes/inside.html", `<div{{with .Get "class"}} class="{{.}}"{{end}}>{{ .Inner }}</div>`) - return nil - } - CheckShortCodeMatch(t, `{{% inside %}} -# More Here - -[link](http://spf13.com) and text - -{{% /inside %}} - -And then: - -{{< inside >}} -# More Here - -This is **plain** text. - -{{< /inside >}} -`, "<div><h1 id=\"more-here\">More Here</h1>\n\n<p><a href=\"http://spf13.com\">link</a> and text</p>\n</div>\n\n<p>And then:</p>\n\n<p><div>\n# More Here\n\nThis is **plain** text.\n\n</div>", wt) -} - -func TestEmbeddedSC(t *testing.T) { - t.Parallel() - CheckShortCodeMatch(t, "{{% test %}}", "This is a simple Test", nil) - CheckShortCodeMatch(t, `{{% figure src="/found/here" class="bananas orange" %}}`, "\n<figure class=\"bananas orange\">\n \n <img src=\"/found/here\" />\n \n \n</figure>\n", nil) - CheckShortCodeMatch(t, `{{% figure src="/found/here" class="bananas orange" caption="This is a caption" %}}`, "\n<figure class=\"bananas orange\">\n \n <img src=\"/found/here\" alt=\"This is a caption\" />\n \n \n <figcaption>\n <p>\n This is a caption\n \n \n \n </p> \n </figcaption>\n \n</figure>\n", nil) -} - -func TestNestedSC(t *testing.T) { - t.Parallel() - wt := func(tem tpl.TemplateHandler) error { - tem.AddTemplate("_internal/shortcodes/scn1.html", `<div>Outer, inner is {{ .Inner }}</div>`) - tem.AddTemplate("_internal/shortcodes/scn2.html", `<div>SC2</div>`) - return nil - } - CheckShortCodeMatch(t, `{{% scn1 %}}{{% scn2 %}}{{% /scn1 %}}`, "<div>Outer, inner is <div>SC2</div>\n</div>", wt) - - CheckShortCodeMatch(t, `{{< scn1 >}}{{% scn2 %}}{{< /scn1 >}}`, "<div>Outer, inner is <div>SC2</div></div>", wt) -} - -func TestNestedComplexSC(t *testing.T) { - t.Parallel() - wt := func(tem tpl.TemplateHandler) error { - tem.AddTemplate("_internal/shortcodes/row.html", `-row-{{ .Inner}}-rowStop-`) - tem.AddTemplate("_internal/shortcodes/column.html", `-col-{{.Inner }}-colStop-`) - tem.AddTemplate("_internal/shortcodes/aside.html", `-aside-{{ .Inner }}-asideStop-`) - return nil - } - CheckShortCodeMatch(t, `{{< row >}}1-s{{% column %}}2-**s**{{< aside >}}3-**s**{{< /aside >}}4-s{{% /column %}}5-s{{< /row >}}6-s`, - "-row-1-s-col-2-<strong>s</strong>-aside-3-<strong>s</strong>-asideStop-4-s-colStop-5-s-rowStop-6-s", wt) - - // turn around the markup flag - CheckShortCodeMatch(t, `{{% row %}}1-s{{< column >}}2-**s**{{% aside %}}3-**s**{{% /aside %}}4-s{{< /column >}}5-s{{% /row %}}6-s`, - "-row-1-s-col-2-<strong>s</strong>-aside-3-<strong>s</strong>-asideStop-4-s-colStop-5-s-rowStop-6-s", wt) -} - -func TestParentShortcode(t *testing.T) { - t.Parallel() - wt := func(tem tpl.TemplateHandler) error { - tem.AddTemplate("_internal/shortcodes/r1.html", `1: {{ .Get "pr1" }} {{ .Inner }}`) - tem.AddTemplate("_internal/shortcodes/r2.html", `2: {{ .Parent.Get "pr1" }}{{ .Get "pr2" }} {{ .Inner }}`) - tem.AddTemplate("_internal/shortcodes/r3.html", `3: {{ .Parent.Parent.Get "pr1" }}{{ .Parent.Get "pr2" }}{{ .Get "pr3" }} {{ .Inner }}`) - return nil - } - CheckShortCodeMatch(t, `{{< r1 pr1="p1" >}}1: {{< r2 pr2="p2" >}}2: {{< r3 pr3="p3" >}}{{< /r3 >}}{{< /r2 >}}{{< /r1 >}}`, - "1: p1 1: 2: p1p2 2: 3: p1p2p3 ", wt) - -} - -func TestFigureOnlySrc(t *testing.T) { - t.Parallel() - CheckShortCodeMatch(t, `{{< figure src="/found/here" >}}`, "\n<figure>\n \n <img src=\"/found/here\" />\n \n \n</figure>\n", nil) -} - -func TestFigureImgWidth(t *testing.T) { - t.Parallel() - CheckShortCodeMatch(t, `{{% figure src="/found/here" class="bananas orange" alt="apple" width="100px" %}}`, "\n<figure class=\"bananas orange\">\n \n <img src=\"/found/here\" alt=\"apple\" width=\"100px\" />\n \n \n</figure>\n", nil) -} - -func TestFigureImgHeight(t *testing.T) { - t.Parallel() - CheckShortCodeMatch(t, `{{% figure src="/found/here" class="bananas orange" alt="apple" height="100px" %}}`, "\n<figure class=\"bananas orange\">\n \n <img src=\"/found/here\" alt=\"apple\" height=\"100px\" />\n \n \n</figure>\n", nil) -} - -func TestFigureImgWidthAndHeight(t *testing.T) { - t.Parallel() - CheckShortCodeMatch(t, `{{% figure src="/found/here" class="bananas orange" alt="apple" width="50" height="100" %}}`, "\n<figure class=\"bananas orange\">\n \n <img src=\"/found/here\" alt=\"apple\" width=\"50\" height=\"100\" />\n \n \n</figure>\n", nil) -} - -func TestFigureLinkNoTarget(t *testing.T) { - t.Parallel() - CheckShortCodeMatch(t, `{{< figure src="/found/here" link="/jump/here/on/clicking" >}}`, "\n<figure>\n <a href=\"/jump/here/on/clicking\">\n <img src=\"/found/here\" />\n </a>\n \n</figure>\n", nil) -} - -func TestFigureLinkWithTarget(t *testing.T) { - t.Parallel() - CheckShortCodeMatch(t, `{{< figure src="/found/here" link="/jump/here/on/clicking" target="_self" >}}`, "\n<figure>\n <a href=\"/jump/here/on/clicking\" target=\"_self\">\n <img src=\"/found/here\" />\n </a>\n \n</figure>\n", nil) -} - -func TestFigureLinkWithTargetAndRel(t *testing.T) { - t.Parallel() - CheckShortCodeMatch(t, `{{< figure src="/found/here" link="/jump/here/on/clicking" target="_blank" rel="noopener" >}}`, "\n<figure>\n <a href=\"/jump/here/on/clicking\" target=\"_blank\" rel=\"noopener\">\n <img src=\"/found/here\" />\n </a>\n \n</figure>\n", nil) -} - -const testScPlaceholderRegexp = "HAHAHUGOSHORTCODE-\\d+HBHB" - -func TestExtractShortcodes(t *testing.T) { - t.Parallel() - for i, this := range []struct { - name string - input string - expectShortCodes string - expect interface{} - expectErrorMsg string + for _, test := range []struct { + name string + input string + check func(c *qt.C, shortcode *shortcode, err error) }{ - {"text", "Some text.", "map[]", "Some text.", ""}, - {"invalid right delim", "{{< tag }}", "", false, "simple.md:4:.*unrecognized character.*}"}, - {"invalid close", "\n{{< /tag >}}", "", false, "simple.md:5:.*got closing shortcode, but none is open"}, - {"invalid close2", "\n\n{{< tag >}}{{< /anotherTag >}}", "", false, "simple.md:6: closing tag for shortcode 'anotherTag' does not match start tag"}, - {"unterminated quote 1", `{{< figure src="im caption="S" >}}`, "", false, "simple.md:4:.got pos.*"}, - {"unterminated quote 1", `{{< figure src="im" caption="S >}}`, "", false, "simple.md:4:.*unterm.*}"}, - {"one shortcode, no markup", "{{< tag >}}", "", testScPlaceholderRegexp, ""}, - {"one shortcode, markup", "{{% tag %}}", "", testScPlaceholderRegexp, ""}, - {"one pos param", "{{% tag param1 %}}", `tag([\"param1\"], true){[]}"]`, testScPlaceholderRegexp, ""}, - {"two pos params", "{{< tag param1 param2>}}", `tag([\"param1\" \"param2\"], false){[]}"]`, testScPlaceholderRegexp, ""}, - {"one named param", `{{% tag param1="value" %}}`, `tag([\"param1:value\"], true){[]}`, testScPlaceholderRegexp, ""}, - {"two named params", `{{< tag param1="value1" param2="value2" >}}`, `tag([\"param1:value1\" \"param2:value2\"], false){[]}"]`, - testScPlaceholderRegexp, ""}, - {"inner", `Some text. {{< inner >}}Inner Content{{< / inner >}}. Some more text.`, `inner([], false){[Inner Content]}`, - fmt.Sprintf("Some text. %s. Some more text.", testScPlaceholderRegexp), ""}, + {"one shortcode, no markup", "{{< tag >}}", regexpCheck("tag.*closing:false.*markup:false")}, + {"one shortcode, markup", "{{% tag %}}", regexpCheck("tag.*closing:false.*markup:true;version:2")}, + {"one shortcode, markup, legacy", "{{% legacytag %}}", regexpCheck("tag.*closing:false.*markup:true;version:1")}, + {"outer shortcode markup", "{{% inner %}}{{< tag >}}{{% /inner %}}", regexpCheck("inner.*closing:true.*markup:true")}, + {"inner shortcode markup", "{{< inner >}}{{% tag %}}{{< /inner >}}", regexpCheck("inner.*closing:true.*;markup:false;version:2")}, + {"one pos param", "{{% tag param1 %}}", regexpCheck("tag.*params:{param1}")}, + {"two pos params", "{{< tag param1 param2>}}", regexpCheck("tag.*params:{param1 param2}")}, + {"one named param", `{{% tag param1="value" %}}`, regexpCheck("tag.*params:map{param1:value}")}, + {"two named params", `{{< tag param1="value1" param2="value2" >}}`, regexpCheck("tag.*params:map{param\\d:value\\d param\\d:value\\d}")}, + {"inner", `{{< inner >}}Inner Content{{< / inner >}}`, regexpCheck("inner;inline:false;closing:true;inner:{Inner Content};")}, // issue #934 - {"inner self-closing", `Some text. {{< inner />}}. Some more text.`, `inner([], false){[]}`, - fmt.Sprintf("Some text. %s. Some more text.", testScPlaceholderRegexp), ""}, - {"close, but not inner", "{{< tag >}}foo{{< /tag >}}", "", false, "Shortcode 'tag' in page 'simple.md' has no .Inner.*"}, - {"nested inner", `Inner->{{< inner >}}Inner Content->{{% inner2 param1 %}}inner2txt{{% /inner2 %}}Inner close->{{< / inner >}}<-done`, - `inner([], false){[Inner Content-> inner2([\"param1\"], true){[inner2txt]} Inner close->]}`, - fmt.Sprintf("Inner->%s<-done", testScPlaceholderRegexp), ""}, - {"nested, nested inner", `Inner->{{< inner >}}inner2->{{% inner2 param1 %}}inner2txt->inner3{{< inner3>}}inner3txt{{</ inner3 >}}{{% /inner2 %}}final close->{{< / inner >}}<-done`, - `inner([], false){[inner2-> inner2([\"param1\"], true){[inner2txt->inner3 inner3(%!q(<nil>), false){[inner3txt]}]} final close->`, - fmt.Sprintf("Inner->%s<-done", testScPlaceholderRegexp), ""}, - {"two inner", `Some text. {{% inner %}}First **Inner** Content{{% / inner %}} {{< inner >}}Inner **Content**{{< / inner >}}. Some more text.`, - `map["HAHAHUGOSHORTCODE-1HBHB:inner([], true){[First **Inner** Content]}" "HAHAHUGOSHORTCODE-2HBHB:inner([], false){[Inner **Content**]}"]`, - fmt.Sprintf("Some text. %s %s. Some more text.", testScPlaceholderRegexp, testScPlaceholderRegexp), ""}, - {"closed without content", `Some text. {{< inner param1 >}}{{< / inner >}}. Some more text.`, `inner([\"param1\"], false){[]}`, - fmt.Sprintf("Some text. %s. Some more text.", testScPlaceholderRegexp), ""}, - {"two shortcodes", "{{< sc1 >}}{{< sc2 >}}", - `map["HAHAHUGOSHORTCODE-1HBHB:sc1([], false){[]}" "HAHAHUGOSHORTCODE-2HBHB:sc2([], false){[]}"]`, - testScPlaceholderRegexp + testScPlaceholderRegexp, ""}, - {"mix of shortcodes", `Hello {{< sc1 >}}world{{% sc2 p2="2"%}}. And that's it.`, - `map["HAHAHUGOSHORTCODE-1HBHB:sc1([], false){[]}" "HAHAHUGOSHORTCODE-2HBHB:sc2([\"p2:2\"]`, - fmt.Sprintf("Hello %sworld%s. And that's it.", testScPlaceholderRegexp, testScPlaceholderRegexp), ""}, - {"mix with inner", `Hello {{< sc1 >}}world{{% inner p2="2"%}}Inner{{%/ inner %}}. And that's it.`, - `map["HAHAHUGOSHORTCODE-1HBHB:sc1([], false){[]}" "HAHAHUGOSHORTCODE-2HBHB:inner([\"p2:2\"], true){[Inner]}"]`, - fmt.Sprintf("Hello %sworld%s. And that's it.", testScPlaceholderRegexp, testScPlaceholderRegexp), ""}, + {"inner self-closing", `{{< inner />}}`, regexpCheck("inner;.*inner:{}")}, + { + "nested inner", `{{< inner >}}Inner Content->{{% inner2 param1 %}}inner2txt{{% /inner2 %}}Inner close->{{< / inner >}}`, + regexpCheck("inner;.*inner:{Inner Content->.*Inner close->}"), + }, + { + "nested, nested inner", `{{< inner >}}inner2->{{% inner2 param1 %}}inner2txt->inner3{{< inner3>}}inner3txt{{</ inner3 >}}{{% /inner2 %}}final close->{{< / inner >}}`, + regexpCheck("inner:{inner2-> inner2.*{{inner2txt->inner3.*final close->}"), + }, + {"closed without content", `{{< inner param1 >}}{{< / inner >}}`, regexpCheck("inner.*inner:{}")}, + {"inline", `{{< my.inline >}}Hi{{< /my.inline >}}`, regexpCheck("my.inline;inline:true;closing:true;inner:{Hi};")}, } { - p, _ := pageFromString(simplePage, "simple.md", func(templ tpl.TemplateHandler) error { - templ.AddTemplate("_internal/shortcodes/tag.html", `tag`) - templ.AddTemplate("_internal/shortcodes/sc1.html", `sc1`) - templ.AddTemplate("_internal/shortcodes/sc2.html", `sc2`) - templ.AddTemplate("_internal/shortcodes/inner.html", `{{with .Inner }}{{ . }}{{ end }}`) - templ.AddTemplate("_internal/shortcodes/inner2.html", `{{.Inner}}`) - templ.AddTemplate("_internal/shortcodes/inner3.html", `{{.Inner}}`) - return nil + test := test + + t.Run(test.name, func(t *testing.T) { + t.Parallel() + c := qt.New(t) + + p, err := pageparser.ParseMain(strings.NewReader(test.input), pageparser.Config{}) + c.Assert(err, qt.IsNil) + handler := newShortcodeHandler("", s) + iter := p.Iterator() + + short, err := handler.extractShortcode(0, 0, p.Input(), iter) + + test.check(c, short, err) }) - - s := newShortcodeHandler(p) - content, err := s.extractShortcodes(this.input, p) - - if b, ok := this.expect.(bool); ok && !b { - if err == nil { - t.Fatalf("[%d] %s: ExtractShortcodes didn't return an expected error", i, this.name) - } else { - r, _ := regexp.Compile(this.expectErrorMsg) - if !r.MatchString(err.Error()) { - t.Fatalf("[%d] %s: ExtractShortcodes didn't return an expected error message, got %s but expected %s", - i, this.name, err.Error(), this.expectErrorMsg) - } - } - continue - } else { - if err != nil { - t.Fatalf("[%d] %s: failed: %q", i, this.name, err) - } - } - - shortCodes := s.shortcodes - - var expected string - av := reflect.ValueOf(this.expect) - switch av.Kind() { - case reflect.String: - expected = av.String() - } - - r, err := regexp.Compile(expected) - - if err != nil { - t.Fatalf("[%d] %s: Failed to compile regexp %q: %q", i, this.name, expected, err) - } - - if strings.Count(content, shortcodePlaceholderPrefix) != len(shortCodes) { - t.Fatalf("[%d] %s: Not enough placeholders, found %d", i, this.name, len(shortCodes)) - } - - if !r.MatchString(content) { - t.Fatalf("[%d] %s: Shortcode extract didn't match. got %q but expected %q", i, this.name, content, expected) - } - - for placeHolder, sc := range shortCodes { - if !strings.Contains(content, placeHolder) { - t.Fatalf("[%d] %s: Output does not contain placeholder %q", i, this.name, placeHolder) - } - - if sc.params == nil { - t.Fatalf("[%d] %s: Params is nil for shortcode '%s'", i, this.name, sc.name) - } - } - - if this.expectShortCodes != "" { - shortCodesAsStr := fmt.Sprintf("map%q", collectAndSortShortcodes(shortCodes)) - if !strings.Contains(shortCodesAsStr, this.expectShortCodes) { - t.Fatalf("[%d] %s: Shortcodes not as expected, got %s but expected %s", i, this.name, shortCodesAsStr, this.expectShortCodes) - } - } } } -func TestShortcodesInSite(t *testing.T) { - t.Parallel() - baseURL := "http://foo/bar" - - tests := []struct { - contentPath string - content string - outFile string - expected string - }{ - {"sect/doc1.md", `a{{< b >}}c`, - filepath.FromSlash("public/sect/doc1/index.html"), "<p>abc</p>\n"}, - // Issue #1642: Multiple shortcodes wrapped in P - // Deliberately forced to pass even if they maybe shouldn't. - {"sect/doc2.md", `a - -{{< b >}} -{{< c >}} -{{< d >}} - -e`, - filepath.FromSlash("public/sect/doc2/index.html"), - "<p>a</p>\n\n<p>b<br />\nc\nd</p>\n\n<p>e</p>\n"}, - {"sect/doc3.md", `a - -{{< b >}} -{{< c >}} - -{{< d >}} - -e`, - filepath.FromSlash("public/sect/doc3/index.html"), - "<p>a</p>\n\n<p>b<br />\nc</p>\n\nd\n\n<p>e</p>\n"}, - {"sect/doc4.md", `a -{{< b >}} -{{< b >}} -{{< b >}} -{{< b >}} -{{< b >}} - - - - - - - - - - -`, - filepath.FromSlash("public/sect/doc4/index.html"), - "<p>a\nb\nb\nb\nb\nb</p>\n"}, - // #2192 #2209: Shortcodes in markdown headers - {"sect/doc5.md", `# {{< b >}} -## {{% c %}}`, - filepath.FromSlash("public/sect/doc5/index.html"), "\n\n<h1 id=\"hahahugoshortcode-1hbhb\">b</h1>\n\n<h2 id=\"hahahugoshortcode-2hbhb\">c</h2>\n"}, - // #2223 pygments - {"sect/doc6.md", "\n```bash\nb = {{< b >}} c = {{% c %}}\n```\n", - filepath.FromSlash("public/sect/doc6/index.html"), - `<span class="nv">b</span>`}, - // #2249 - {"sect/doc7.ad", `_Shortcodes:_ *b: {{< b >}} c: {{% c %}}*`, - filepath.FromSlash("public/sect/doc7/index.html"), - "<div class=\"paragraph\">\n<p><em>Shortcodes:</em> <strong>b: b c: c</strong></p>\n</div>\n"}, - {"sect/doc8.rst", `**Shortcodes:** *b: {{< b >}} c: {{% c %}}*`, - filepath.FromSlash("public/sect/doc8/index.html"), - "<div class=\"document\">\n\n\n<p><strong>Shortcodes:</strong> <em>b: b c: c</em></p>\n</div>"}, - {"sect/doc9.mmark", ` ---- -menu: - main: - parent: 'parent' ---- -**Shortcodes:** *b: {{< b >}} c: {{% c %}}*`, - filepath.FromSlash("public/sect/doc9/index.html"), - "<p><strong>Shortcodes:</strong> <em>b: b c: c</em></p>\n"}, - // Issue #1229: Menus not available in shortcode. - {"sect/doc10.md", `--- -menu: - main: - identifier: 'parent' -tags: -- Menu ---- -**Menus:** {{< menu >}}`, - filepath.FromSlash("public/sect/doc10/index.html"), - "<p><strong>Menus:</strong> 1</p>\n"}, - // Issue #2323: Taxonomies not available in shortcode. - {"sect/doc11.md", `--- -tags: -- Bugs ---- -**Tags:** {{< tags >}}`, - filepath.FromSlash("public/sect/doc11/index.html"), - "<p><strong>Tags:</strong> 2</p>\n"}, - } - - sources := make([][2]string, len(tests)) - - for i, test := range tests { - sources[i] = [2]string{filepath.FromSlash(test.contentPath), test.content} - } - - addTemplates := func(templ tpl.TemplateHandler) error { - templ.AddTemplate("_default/single.html", "{{.Content}}") - - templ.AddTemplate("_internal/shortcodes/b.html", `b`) - templ.AddTemplate("_internal/shortcodes/c.html", `c`) - templ.AddTemplate("_internal/shortcodes/d.html", `d`) - templ.AddTemplate("_internal/shortcodes/menu.html", `{{ len (index .Page.Menus "main").Children }}`) - templ.AddTemplate("_internal/shortcodes/tags.html", `{{ len .Page.Site.Taxonomies.tags }}`) - - return nil - - } - - cfg, fs := newTestCfg() - - cfg.Set("defaultContentLanguage", "en") - cfg.Set("baseURL", baseURL) - cfg.Set("uglyURLs", false) - cfg.Set("verbose", true) - - cfg.Set("pygmentsUseClasses", true) - cfg.Set("pygmentsCodefences", true) - - writeSourcesToSource(t, "content", fs, sources...) - - s := buildSingleSite(t, deps.DepsCfg{WithTemplate: addTemplates, Fs: fs, Cfg: cfg}, BuildCfg{}) - th := testHelper{s.Cfg, s.Fs, t} - - for _, test := range tests { - if strings.HasSuffix(test.contentPath, ".ad") && !helpers.HasAsciidoc() { - fmt.Println("Skip Asciidoc test case as no Asciidoc present.") - continue - } else if strings.HasSuffix(test.contentPath, ".rst") && !helpers.HasRst() { - fmt.Println("Skip Rst test case as no rst2html present.") - continue - } else if strings.Contains(test.expected, "code") { - fmt.Println("Skip Pygments test case as no pygments present.") - continue - } - - th.assertFileContent(test.outFile, test.expected) - } - -} - func TestShortcodeMultipleOutputFormats(t *testing.T) { t.Parallel() siteConfig := ` baseURL = "http://example.com/blog" -paginate = 1 +disableKinds = ["section", "term", "taxonomy", "RSS", "sitemap", "robotsTXT", "404"] -disableKinds = ["section", "taxonomy", "taxonomyTerm", "RSS", "sitemap", "robotsTXT", "404"] +[pagination] +pagerSize = 1 [outputs] home = [ "HTML", "AMP", "Calendar" ] @@ -638,18 +158,8 @@ outputs: ["CSV"] CSV: {{< myShort >}} ` - pageTemplateShortcodeNotFound := `--- -title: "%s" -outputs: ["CSV"] ---- -# Doc - -NotFound: {{< thisDoesNotExist >}} -` - - mf := afero.NewMemMapFs() - - th, h := newTestSitesFromConfig(t, mf, siteConfig, + b := newTestSitesBuilder(t).WithConfigFile("toml", siteConfig) + b.WithTemplates( "layouts/_default/single.html", `Single HTML: {{ .Title }}|{{ .Content }}`, "layouts/_default/single.json", `Single JSON: {{ .Title }}|{{ .Content }}`, "layouts/_default/single.csv", `Single CSV: {{ .Title }}|{{ .Content }}`, @@ -666,23 +176,21 @@ NotFound: {{< thisDoesNotExist >}} "layouts/shortcodes/myInner.html", `myInner:--{{- .Inner -}}--`, ) - fs := th.Fs + b.WithContent("_index.md", fmt.Sprintf(pageTemplate, "Home"), + "sect/mypage.md", fmt.Sprintf(pageTemplate, "Single"), + "sect/mycsvpage.md", fmt.Sprintf(pageTemplateCSVOnly, "Single CSV"), + ) - writeSource(t, fs, "content/_index.md", fmt.Sprintf(pageTemplate, "Home")) - writeSource(t, fs, "content/sect/mypage.md", fmt.Sprintf(pageTemplate, "Single")) - writeSource(t, fs, "content/sect/mycsvpage.md", fmt.Sprintf(pageTemplateCSVOnly, "Single CSV")) - writeSource(t, fs, "content/sect/notfound.md", fmt.Sprintf(pageTemplateShortcodeNotFound, "Single CSV")) - - err := h.Build(BuildCfg{}) - require.Equal(t, "logged 1 error(s)", err.Error()) - require.Len(t, h.Sites, 1) + b.Build(BuildCfg{}) + h := b.H + b.Assert(len(h.Sites), qt.Equals, 1) s := h.Sites[0] - home := s.getPage(KindHome) - require.NotNil(t, home) - require.Len(t, home.outputFormats, 3) + home := s.getPageOldVersion(kinds.KindHome) + b.Assert(home, qt.Not(qt.IsNil)) + b.Assert(len(home.OutputFormats()), qt.Equals, 3) - th.assertFileContent("public/index.html", + b.AssertFileContent("public/index.html", "Home HTML", "ShortHTML", "ShortNoExt", @@ -690,7 +198,7 @@ NotFound: {{< thisDoesNotExist >}} "myInner:--ShortHTML--", ) - th.assertFileContent("public/amp/index.html", + b.AssertFileContent("public/amp/index.html", "Home AMP", "ShortAMP", "ShortNoExt", @@ -698,7 +206,7 @@ NotFound: {{< thisDoesNotExist >}} "myInner:--ShortAMP--", ) - th.assertFileContent("public/index.ics", + b.AssertFileContent("public/index.ics", "Home Calendar", "ShortCalendar", "ShortNoExt", @@ -706,7 +214,7 @@ NotFound: {{< thisDoesNotExist >}} "myInner:--ShortCalendar--", ) - th.assertFileContent("public/sect/mypage/index.html", + b.AssertFileContent("public/sect/mypage/index.html", "Single HTML", "ShortHTML", "ShortNoExt", @@ -714,7 +222,7 @@ NotFound: {{< thisDoesNotExist >}} "myInner:--ShortHTML--", ) - th.assertFileContent("public/sect/mypage/index.json", + b.AssertFileContent("public/sect/mypage/index.json", "Single JSON", "ShortJSON", "ShortNoExt", @@ -722,7 +230,7 @@ NotFound: {{< thisDoesNotExist >}} "myInner:--ShortJSON--", ) - th.assertFileContent("public/amp/sect/mypage/index.html", + b.AssertFileContent("public/amp/sect/mypage/index.html", // No special AMP template "Single HTML", "ShortAMP", @@ -731,37 +239,16 @@ NotFound: {{< thisDoesNotExist >}} "myInner:--ShortAMP--", ) - th.assertFileContent("public/sect/mycsvpage/index.csv", + b.AssertFileContent("public/sect/mycsvpage/index.csv", "Single CSV", "ShortCSV", ) - - th.assertFileContent("public/sect/notfound/index.csv", - "NotFound:", - "thisDoesNotExist", - ) - - require.Equal(t, uint64(1), s.Log.LogCountForLevel(jww.LevelError)) - -} - -func collectAndSortShortcodes(shortcodes map[string]shortcode) []string { - var asArray []string - - for key, sc := range shortcodes { - asArray = append(asArray, fmt.Sprintf("%s:%s", key, sc)) - } - - sort.Strings(asArray) - return asArray - } func BenchmarkReplaceShortcodeTokens(b *testing.B) { - type input struct { in []byte - replacements map[string]string + tokenHandler func(ctx context.Context, token string) ([]byte, error) expect []byte } @@ -777,23 +264,30 @@ func BenchmarkReplaceShortcodeTokens(b *testing.B) { {strings.Repeat("A ", 3000) + " HAHAHUGOSHORTCODE-1HBHB." + strings.Repeat("BC ", 1000) + " HAHAHUGOSHORTCODE-1HBHB.", map[string]string{"HAHAHUGOSHORTCODE-1HBHB": "Hello World"}, []byte(strings.Repeat("A ", 3000) + " Hello World." + strings.Repeat("BC ", 1000) + " Hello World.")}, } - var in = make([]input, b.N*len(data)) - var cnt = 0 + cnt := 0 + in := make([]input, b.N*len(data)) for i := 0; i < b.N; i++ { for _, this := range data { - in[cnt] = input{[]byte(this.input), this.replacements, this.expect} + replacements := make(map[string]shortcodeRenderer) + for k, v := range this.replacements { + replacements[k] = prerenderedShortcode{s: v} + } + tokenHandler := func(ctx context.Context, token string) ([]byte, error) { + return []byte(this.replacements[token]), nil + } + in[cnt] = input{[]byte(this.input), tokenHandler, this.expect} cnt++ } } b.ResetTimer() cnt = 0 + ctx := context.Background() for i := 0; i < b.N; i++ { for j := range data { currIn := in[cnt] cnt++ - results, err := replaceShortcodeTokens(currIn.in, "HUGOSHORTCODE", currIn.replacements) - + results, err := expandShortcodeTokens(ctx, currIn.in, currIn.tokenHandler) if err != nil { b.Fatalf("[%d] failed: %s", i, err) continue @@ -803,7 +297,58 @@ func BenchmarkReplaceShortcodeTokens(b *testing.B) { } } + } +} +func BenchmarkShortcodesInSite(b *testing.B) { + files := ` +-- config.toml -- +-- layouts/shortcodes/mark1.md -- +{{ .Inner }} +-- layouts/shortcodes/mark2.md -- +1. Item Mark2 1 +1. Item Mark2 2 + 1. Item Mark2 2-1 +1. Item Mark2 3 +-- layouts/_default/single.html -- +{{ .Content }} +` + + content := ` +--- +title: "Markdown Shortcode" +--- + +## List + +1. List 1 + {{§ mark1 §}} + 1. Item Mark1 1 + 1. Item Mark1 2 + {{§ mark2 §}} + {{§ /mark1 §}} + +` + + for i := 1; i < 100; i++ { + files += fmt.Sprintf("\n-- content/posts/p%d.md --\n"+content, i+1) + } + files = strings.ReplaceAll(files, "§", "%") + + cfg := IntegrationTestConfig{ + T: b, + TxtarString: files, + } + builders := make([]*IntegrationTestBuilder, b.N) + + for i := range builders { + builders[i] = NewIntegrationTestBuilder(cfg) + } + + b.ResetTimer() + + for i := 0; i < b.N; i++ { + builders[i].Build() } } @@ -813,38 +358,50 @@ func TestReplaceShortcodeTokens(t *testing.T) { input string prefix string replacements map[string]string - expect interface{} + expect any }{ - {"Hello HAHAPREFIX-1HBHB.", "PREFIX", map[string]string{"HAHAPREFIX-1HBHB": "World"}, "Hello World."}, - {"Hello HAHAPREFIX-1@}@.", "PREFIX", map[string]string{"HAHAPREFIX-1HBHB": "World"}, false}, - {"HAHAPREFIX2-1HBHB", "PREFIX2", map[string]string{"HAHAPREFIX2-1HBHB": "World"}, "World"}, + {"Hello HAHAHUGOSHORTCODE-1HBHB.", "PREFIX", map[string]string{"HAHAHUGOSHORTCODE-1HBHB": "World"}, "Hello World."}, + {"Hello HAHAHUGOSHORTCODE-1@}@.", "PREFIX", map[string]string{"HAHAHUGOSHORTCODE-1HBHB": "World"}, false}, + {"HAHAHUGOSHORTCODE2-1HBHB", "PREFIX2", map[string]string{"HAHAHUGOSHORTCODE2-1HBHB": "World"}, "World"}, {"Hello World!", "PREFIX2", map[string]string{}, "Hello World!"}, - {"!HAHAPREFIX-1HBHB", "PREFIX", map[string]string{"HAHAPREFIX-1HBHB": "World"}, "!World"}, - {"HAHAPREFIX-1HBHB!", "PREFIX", map[string]string{"HAHAPREFIX-1HBHB": "World"}, "World!"}, - {"!HAHAPREFIX-1HBHB!", "PREFIX", map[string]string{"HAHAPREFIX-1HBHB": "World"}, "!World!"}, - {"_{_PREFIX-1HBHB", "PREFIX", map[string]string{"HAHAPREFIX-1HBHB": "World"}, "_{_PREFIX-1HBHB"}, - {"Hello HAHAPREFIX-1HBHB.", "PREFIX", map[string]string{"HAHAPREFIX-1HBHB": "To You My Old Friend Who Told Me This Fantastic Story"}, "Hello To You My Old Friend Who Told Me This Fantastic Story."}, - {"A HAHAA-1HBHB asdf HAHAA-2HBHB.", "A", map[string]string{"HAHAA-1HBHB": "v1", "HAHAA-2HBHB": "v2"}, "A v1 asdf v2."}, - {"Hello HAHAPREFIX2-1HBHB. Go HAHAPREFIX2-2HBHB, Go, Go HAHAPREFIX2-3HBHB Go Go!.", "PREFIX2", map[string]string{"HAHAPREFIX2-1HBHB": "Europe", "HAHAPREFIX2-2HBHB": "Jonny", "HAHAPREFIX2-3HBHB": "Johnny"}, "Hello Europe. Go Jonny, Go, Go Johnny Go Go!."}, - {"A HAHAPREFIX-2HBHB HAHAPREFIX-1HBHB.", "PREFIX", map[string]string{"HAHAPREFIX-1HBHB": "A", "HAHAPREFIX-2HBHB": "B"}, "A B A."}, - {"A HAHAPREFIX-1HBHB HAHAPREFIX-2", "PREFIX", map[string]string{"HAHAPREFIX-1HBHB": "A"}, false}, - {"A HAHAPREFIX-1HBHB but not the second.", "PREFIX", map[string]string{"HAHAPREFIX-1HBHB": "A", "HAHAPREFIX-2HBHB": "B"}, "A A but not the second."}, - {"An HAHAPREFIX-1HBHB.", "PREFIX", map[string]string{"HAHAPREFIX-1HBHB": "A", "HAHAPREFIX-2HBHB": "B"}, "An A."}, - {"An HAHAPREFIX-1HBHB HAHAPREFIX-2HBHB.", "PREFIX", map[string]string{"HAHAPREFIX-1HBHB": "A", "HAHAPREFIX-2HBHB": "B"}, "An A B."}, - {"A HAHAPREFIX-1HBHB HAHAPREFIX-2HBHB HAHAPREFIX-3HBHB HAHAPREFIX-1HBHB HAHAPREFIX-3HBHB.", "PREFIX", map[string]string{"HAHAPREFIX-1HBHB": "A", "HAHAPREFIX-2HBHB": "B", "HAHAPREFIX-3HBHB": "C"}, "A A B C A C."}, - {"A HAHAPREFIX-1HBHB HAHAPREFIX-2HBHB HAHAPREFIX-3HBHB HAHAPREFIX-1HBHB HAHAPREFIX-3HBHB.", "PREFIX", map[string]string{"HAHAPREFIX-1HBHB": "A", "HAHAPREFIX-2HBHB": "B", "HAHAPREFIX-3HBHB": "C"}, "A A B C A C."}, + {"!HAHAHUGOSHORTCODE-1HBHB", "PREFIX", map[string]string{"HAHAHUGOSHORTCODE-1HBHB": "World"}, "!World"}, + {"HAHAHUGOSHORTCODE-1HBHB!", "PREFIX", map[string]string{"HAHAHUGOSHORTCODE-1HBHB": "World"}, "World!"}, + {"!HAHAHUGOSHORTCODE-1HBHB!", "PREFIX", map[string]string{"HAHAHUGOSHORTCODE-1HBHB": "World"}, "!World!"}, + {"_{_PREFIX-1HBHB", "PREFIX", map[string]string{"HAHAHUGOSHORTCODE-1HBHB": "World"}, "_{_PREFIX-1HBHB"}, + {"Hello HAHAHUGOSHORTCODE-1HBHB.", "PREFIX", map[string]string{"HAHAHUGOSHORTCODE-1HBHB": "To You My Old Friend Who Told Me This Fantastic Story"}, "Hello To You My Old Friend Who Told Me This Fantastic Story."}, + {"A HAHAHUGOSHORTCODE-1HBHB asdf HAHAHUGOSHORTCODE-2HBHB.", "A", map[string]string{"HAHAHUGOSHORTCODE-1HBHB": "v1", "HAHAHUGOSHORTCODE-2HBHB": "v2"}, "A v1 asdf v2."}, + {"Hello HAHAHUGOSHORTCODE2-1HBHB. Go HAHAHUGOSHORTCODE2-2HBHB, Go, Go HAHAHUGOSHORTCODE2-3HBHB Go Go!.", "PREFIX2", map[string]string{"HAHAHUGOSHORTCODE2-1HBHB": "Europe", "HAHAHUGOSHORTCODE2-2HBHB": "Jonny", "HAHAHUGOSHORTCODE2-3HBHB": "Johnny"}, "Hello Europe. Go Jonny, Go, Go Johnny Go Go!."}, + {"A HAHAHUGOSHORTCODE-2HBHB HAHAHUGOSHORTCODE-1HBHB.", "PREFIX", map[string]string{"HAHAHUGOSHORTCODE-1HBHB": "A", "HAHAHUGOSHORTCODE-2HBHB": "B"}, "A B A."}, + {"A HAHAHUGOSHORTCODE-1HBHB HAHAHUGOSHORTCODE-2", "PREFIX", map[string]string{"HAHAHUGOSHORTCODE-1HBHB": "A"}, false}, + {"A HAHAHUGOSHORTCODE-1HBHB but not the second.", "PREFIX", map[string]string{"HAHAHUGOSHORTCODE-1HBHB": "A", "HAHAHUGOSHORTCODE-2HBHB": "B"}, "A A but not the second."}, + {"An HAHAHUGOSHORTCODE-1HBHB.", "PREFIX", map[string]string{"HAHAHUGOSHORTCODE-1HBHB": "A", "HAHAHUGOSHORTCODE-2HBHB": "B"}, "An A."}, + {"An HAHAHUGOSHORTCODE-1HBHB HAHAHUGOSHORTCODE-2HBHB.", "PREFIX", map[string]string{"HAHAHUGOSHORTCODE-1HBHB": "A", "HAHAHUGOSHORTCODE-2HBHB": "B"}, "An A B."}, + {"A HAHAHUGOSHORTCODE-1HBHB HAHAHUGOSHORTCODE-2HBHB HAHAHUGOSHORTCODE-3HBHB HAHAHUGOSHORTCODE-1HBHB HAHAHUGOSHORTCODE-3HBHB.", "PREFIX", map[string]string{"HAHAHUGOSHORTCODE-1HBHB": "A", "HAHAHUGOSHORTCODE-2HBHB": "B", "HAHAHUGOSHORTCODE-3HBHB": "C"}, "A A B C A C."}, + {"A HAHAHUGOSHORTCODE-1HBHB HAHAHUGOSHORTCODE-2HBHB HAHAHUGOSHORTCODE-3HBHB HAHAHUGOSHORTCODE-1HBHB HAHAHUGOSHORTCODE-3HBHB.", "PREFIX", map[string]string{"HAHAHUGOSHORTCODE-1HBHB": "A", "HAHAHUGOSHORTCODE-2HBHB": "B", "HAHAHUGOSHORTCODE-3HBHB": "C"}, "A A B C A C."}, // Issue #1148 remove p-tags 10 => - {"Hello <p>HAHAPREFIX-1HBHB</p>. END.", "PREFIX", map[string]string{"HAHAPREFIX-1HBHB": "World"}, "Hello World. END."}, - {"Hello <p>HAHAPREFIX-1HBHB</p>. <p>HAHAPREFIX-2HBHB</p> END.", "PREFIX", map[string]string{"HAHAPREFIX-1HBHB": "World", "HAHAPREFIX-2HBHB": "THE"}, "Hello World. THE END."}, - {"Hello <p>HAHAPREFIX-1HBHB. END</p>.", "PREFIX", map[string]string{"HAHAPREFIX-1HBHB": "World"}, "Hello <p>World. END</p>."}, - {"<p>Hello HAHAPREFIX-1HBHB</p>. END.", "PREFIX", map[string]string{"HAHAPREFIX-1HBHB": "World"}, "<p>Hello World</p>. END."}, - {"Hello <p>HAHAPREFIX-1HBHB12", "PREFIX", map[string]string{"HAHAPREFIX-1HBHB": "World"}, "Hello <p>World12"}, - {"Hello HAHAP-1HBHB. HAHAP-1HBHB-HAHAP-1HBHB HAHAP-1HBHB HAHAP-1HBHB HAHAP-1HBHB END", "P", map[string]string{"HAHAP-1HBHB": strings.Repeat("BC", 100)}, + {"Hello <p>HAHAHUGOSHORTCODE-1HBHB</p>. END.", "PREFIX", map[string]string{"HAHAHUGOSHORTCODE-1HBHB": "World"}, "Hello World. END."}, + {"Hello <p>HAHAHUGOSHORTCODE-1HBHB</p>. <p>HAHAHUGOSHORTCODE-2HBHB</p> END.", "PREFIX", map[string]string{"HAHAHUGOSHORTCODE-1HBHB": "World", "HAHAHUGOSHORTCODE-2HBHB": "THE"}, "Hello World. THE END."}, + {"Hello <p>HAHAHUGOSHORTCODE-1HBHB. END</p>.", "PREFIX", map[string]string{"HAHAHUGOSHORTCODE-1HBHB": "World"}, "Hello <p>World. END</p>."}, + {"<p>Hello HAHAHUGOSHORTCODE-1HBHB</p>. END.", "PREFIX", map[string]string{"HAHAHUGOSHORTCODE-1HBHB": "World"}, "<p>Hello World</p>. END."}, + {"Hello <p>HAHAHUGOSHORTCODE-1HBHB12", "PREFIX", map[string]string{"HAHAHUGOSHORTCODE-1HBHB": "World"}, "Hello <p>World12"}, + { + "Hello HAHAHUGOSHORTCODE-1HBHB. HAHAHUGOSHORTCODE-1HBHB-HAHAHUGOSHORTCODE-1HBHB HAHAHUGOSHORTCODE-1HBHB HAHAHUGOSHORTCODE-1HBHB HAHAHUGOSHORTCODE-1HBHB END", "P", + map[string]string{"HAHAHUGOSHORTCODE-1HBHB": strings.Repeat("BC", 100)}, fmt.Sprintf("Hello %s. %s-%s %s %s %s END", - strings.Repeat("BC", 100), strings.Repeat("BC", 100), strings.Repeat("BC", 100), strings.Repeat("BC", 100), strings.Repeat("BC", 100), strings.Repeat("BC", 100))}, + strings.Repeat("BC", 100), strings.Repeat("BC", 100), strings.Repeat("BC", 100), strings.Repeat("BC", 100), strings.Repeat("BC", 100), strings.Repeat("BC", 100)), + }, } { - results, err := replaceShortcodeTokens([]byte(this.input), this.prefix, this.replacements) + replacements := make(map[string]shortcodeRenderer) + for k, v := range this.replacements { + replacements[k] = prerenderedShortcode{s: v} + } + tokenHandler := func(ctx context.Context, token string) ([]byte, error) { + return []byte(this.replacements[token]), nil + } + + ctx := context.Background() + results, err := expandShortcodeTokens(ctx, []byte(this.input), tokenHandler) if b, ok := this.expect.(bool); ok && !b { if err == nil { @@ -861,15 +418,850 @@ func TestReplaceShortcodeTokens(t *testing.T) { } } - } -func TestScKey(t *testing.T) { - require.Equal(t, scKey{Suffix: "xml", ShortcodePlaceholder: "ABCD"}, - newScKey(media.XMLType, "ABCD")) - require.Equal(t, scKey{Lang: "en", Suffix: "html", OutputFormat: "AMP", ShortcodePlaceholder: "EFGH"}, - newScKeyFromLangAndOutputFormat("en", output.AMPFormat, "EFGH")) - require.Equal(t, scKey{Suffix: "html", ShortcodePlaceholder: "IJKL"}, - newDefaultScKey("IJKL")) +func TestShortcodeGetContent(t *testing.T) { + t.Parallel() + contentShortcode := ` +{{- $t := .Get 0 -}} +{{- $p := .Get 1 -}} +{{- $k := .Get 2 -}} +{{- $page := $.Page.Site.GetPage "page" $p -}} +{{ if $page }} +{{- if eq $t "bundle" -}} +{{- .Scratch.Set "p" ($page.Resources.GetMatch (printf "%s*" $k)) -}} +{{- else -}} +{{- $.Scratch.Set "p" $page -}} +{{- end -}}P1:{{ .Page.Content }}|P2:{{ $p := ($.Scratch.Get "p") }}{{ $p.Title }}/{{ $p.Content }}| +{{- else -}} +{{- errorf "Page %s is nil" $p -}} +{{- end -}} +` + + var templates []string + var content []string + + contentWithShortcodeTemplate := `--- +title: doc%s +weight: %d +--- +Logo:{{< c "bundle" "b1" "logo.png" >}}:P1: {{< c "page" "section1/p1" "" >}}:BP1:{{< c "bundle" "b1" "bp1" >}}` + + simpleContentTemplate := `--- +title: doc%s +weight: %d +--- +C-%s` + + templates = append(templates, []string{"shortcodes/c.html", contentShortcode}...) + templates = append(templates, []string{"_default/single.html", "Single Content: {{ .Content }}"}...) + templates = append(templates, []string{"_default/list.html", "List Content: {{ .Content }}"}...) + + content = append(content, []string{"b1/index.md", fmt.Sprintf(contentWithShortcodeTemplate, "b1", 1)}...) + content = append(content, []string{"b1/logo.png", "PNG logo"}...) + content = append(content, []string{"b1/bp1.md", fmt.Sprintf(simpleContentTemplate, "bp1", 1, "bp1")}...) + + content = append(content, []string{"section1/_index.md", fmt.Sprintf(contentWithShortcodeTemplate, "s1", 2)}...) + content = append(content, []string{"section1/p1.md", fmt.Sprintf(simpleContentTemplate, "s1p1", 2, "s1p1")}...) + + content = append(content, []string{"section2/_index.md", fmt.Sprintf(simpleContentTemplate, "b1", 1, "b1")}...) + content = append(content, []string{"section2/s2p1.md", fmt.Sprintf(contentWithShortcodeTemplate, "bp1", 1)}...) + + builder := newTestSitesBuilder(t).WithDefaultMultiSiteConfig() + + builder.WithContent(content...).WithTemplates(templates...).CreateSites().Build(BuildCfg{}) + s := builder.H.Sites[0] + builder.Assert(len(s.RegularPages()), qt.Equals, 3) + + builder.AssertFileContent("public/en/section1/index.html", + "List Content: <p>Logo:P1:|P2:logo.png/PNG logo|:P1: P1:|P2:docs1p1/<p>C-s1p1</p>\n|", + "BP1:P1:|P2:docbp1/<p>C-bp1</p>", + ) + + builder.AssertFileContent("public/en/b1/index.html", + "Single Content: <p>Logo:P1:|P2:logo.png/PNG logo|:P1: P1:|P2:docs1p1/<p>C-s1p1</p>\n|", + "P2:docbp1/<p>C-bp1</p>", + ) + + builder.AssertFileContent("public/en/section2/s2p1/index.html", + "Single Content: <p>Logo:P1:|P2:logo.png/PNG logo|:P1: P1:|P2:docs1p1/<p>C-s1p1</p>\n|", + "P2:docbp1/<p>C-bp1</p>", + ) +} + +// https://github.com/gohugoio/hugo/issues/5833 +func TestShortcodeParentResourcesOnRebuild(t *testing.T) { + t.Parallel() + + b := newTestSitesBuilder(t).Running().WithSimpleConfigFile() + b.WithTemplatesAdded( + "index.html", ` +{{ $b := .Site.GetPage "b1" }} +b1 Content: {{ $b.Content }} +{{$p := $b.Resources.GetMatch "p1*" }} +Content: {{ $p.Content }} +{{ $article := .Site.GetPage "blog/article" }} +Article Content: {{ $article.Content }} +`, + "shortcodes/c.html", ` +{{ range .Page.Parent.Resources }} +* Parent resource: {{ .Name }}: {{ .RelPermalink }} +{{ end }} +`) + + pageContent := ` +--- +title: MyPage +--- + +SHORTCODE: {{< c >}} + +` + + b.WithContent("b1/index.md", pageContent, + "b1/logo.png", "PNG logo", + "b1/p1.md", pageContent, + "blog/_index.md", pageContent, + "blog/logo-article.png", "PNG logo", + "blog/article.md", pageContent, + ) + + b.Build(BuildCfg{}) + + assert := func(matchers ...string) { + allMatchers := append(matchers, "Parent resource: logo.png: /b1/logo.png", + "Article Content: <p>SHORTCODE: \n\n* Parent resource: logo-article.png: /blog/logo-article.png", + ) + + b.AssertFileContent("public/index.html", + allMatchers..., + ) + } + + assert() + + b.EditFiles("content/b1/index.md", pageContent+" Edit.") + + b.Build(BuildCfg{}) + + assert("Edit.") +} + +func TestShortcodePreserveOrder(t *testing.T) { + t.Parallel() + c := qt.New(t) + + contentTemplate := `--- +title: doc%d +weight: %d +--- +# doc + +{{< s1 >}}{{< s2 >}}{{< s3 >}}{{< s4 >}}{{< s5 >}} + +{{< nested >}} +{{< ordinal >}} {{< scratch >}} +{{< ordinal >}} {{< scratch >}} +{{< ordinal >}} {{< scratch >}} +{{< /nested >}} + +` + + ordinalShortcodeTemplate := `ordinal: {{ .Ordinal }}{{ .Page.Scratch.Set "ordinal" .Ordinal }}` + + nestedShortcode := `outer ordinal: {{ .Ordinal }} inner: {{ .Inner }}` + scratchGetShortcode := `scratch ordinal: {{ .Ordinal }} scratch get ordinal: {{ .Page.Scratch.Get "ordinal" }}` + shortcodeTemplate := `v%d: {{ .Ordinal }} sgo: {{ .Page.Scratch.Get "o2" }}{{ .Page.Scratch.Set "o2" .Ordinal }}|` + + var shortcodes []string + var content []string + + shortcodes = append(shortcodes, []string{"shortcodes/nested.html", nestedShortcode}...) + shortcodes = append(shortcodes, []string{"shortcodes/ordinal.html", ordinalShortcodeTemplate}...) + shortcodes = append(shortcodes, []string{"shortcodes/scratch.html", scratchGetShortcode}...) + + for i := 1; i <= 5; i++ { + sc := fmt.Sprintf(shortcodeTemplate, i) + sc = strings.Replace(sc, "%%", "%", -1) + shortcodes = append(shortcodes, []string{fmt.Sprintf("shortcodes/s%d.html", i), sc}...) + } + + for i := 1; i <= 3; i++ { + content = append(content, []string{fmt.Sprintf("p%d.md", i), fmt.Sprintf(contentTemplate, i, i)}...) + } + + builder := newTestSitesBuilder(t).WithDefaultMultiSiteConfig() + + builder.WithContent(content...).WithTemplatesAdded(shortcodes...).CreateSites().Build(BuildCfg{}) + + s := builder.H.Sites[0] + c.Assert(len(s.RegularPages()), qt.Equals, 3) + + builder.AssertFileContent("public/en/p1/index.html", `v1: 0 sgo: |v2: 1 sgo: 0|v3: 2 sgo: 1|v4: 3 sgo: 2|v5: 4 sgo: 3`) + builder.AssertFileContent("public/en/p1/index.html", `outer ordinal: 5 inner: +ordinal: 0 scratch ordinal: 1 scratch get ordinal: 0 +ordinal: 2 scratch ordinal: 3 scratch get ordinal: 2 +ordinal: 4 scratch ordinal: 5 scratch get ordinal: 4`) +} + +func TestShortcodeVariables(t *testing.T) { + t.Parallel() + c := qt.New(t) + + builder := newTestSitesBuilder(t).WithSimpleConfigFile() + + builder.WithContent("page.md", `--- +title: "Hugo Rocks!" +--- + +# doc + + {{< s1 >}} + +`).WithTemplatesAdded("layouts/shortcodes/s1.html", ` +Name: {{ .Name }} +{{ with .Position }} +File: {{ .Filename }} +Offset: {{ .Offset }} +Line: {{ .LineNumber }} +Column: {{ .ColumnNumber }} +String: {{ . | safeHTML }} +{{ end }} + +`).CreateSites().Build(BuildCfg{}) + + s := builder.H.Sites[0] + c.Assert(len(s.RegularPages()), qt.Equals, 1) + + builder.AssertFileContent("public/page/index.html", + filepath.FromSlash("File: content/page.md"), + "Line: 7", "Column: 4", "Offset: 40", + filepath.FromSlash("String: \"content/page.md:7:4\""), + "Name: s1", + ) +} + +func TestInlineShortcodes(t *testing.T) { + for _, enableInlineShortcodes := range []bool{true, false} { + enableInlineShortcodes := enableInlineShortcodes + t.Run(fmt.Sprintf("enableInlineShortcodes=%t", enableInlineShortcodes), + func(t *testing.T) { + t.Parallel() + conf := fmt.Sprintf(` +baseURL = "https://example.com" +enableInlineShortcodes = %t +`, enableInlineShortcodes) + + b := newTestSitesBuilder(t) + b.WithConfigFile("toml", conf) + + shortcodeContent := `FIRST:{{< myshort.inline "first" >}} +Page: {{ .Page.Title }} +Seq: {{ seq 3 }} +Param: {{ .Get 0 }} +{{< /myshort.inline >}}:END: + +SECOND:{{< myshort.inline "second" />}}:END +NEW INLINE: {{< n1.inline "5" >}}W1: {{ seq (.Get 0) }}{{< /n1.inline >}}:END: +INLINE IN INNER: {{< outer >}}{{< n2.inline >}}W2: {{ seq 4 }}{{< /n2.inline >}}{{< /outer >}}:END: +REUSED INLINE IN INNER: {{< outer >}}{{< n1.inline "3" />}}{{< /outer >}}:END: +## MARKDOWN DELIMITER: {{% mymarkdown.inline %}}**Hugo Rocks!**{{% /mymarkdown.inline %}} +` + + b.WithContent("page-md-shortcode.md", `--- +title: "Hugo" +--- +`+shortcodeContent) + + b.WithContent("_index.md", `--- +title: "Hugo Home" +--- + +`+shortcodeContent) + + b.WithTemplatesAdded("layouts/_default/single.html", ` +CONTENT:{{ .Content }} +TOC: {{ .TableOfContents }} +`) + + b.WithTemplatesAdded("layouts/index.html", ` +CONTENT:{{ .Content }} +TOC: {{ .TableOfContents }} +`) + + b.WithTemplatesAdded("layouts/shortcodes/outer.html", `Inner: {{ .Inner }}`) + + b.CreateSites().Build(BuildCfg{}) + + shouldContain := []string{ + "Seq: [1 2 3]", + "Param: first", + "Param: second", + "NEW INLINE: W1: [1 2 3 4 5]", + "INLINE IN INNER: Inner: W2: [1 2 3 4]", + "REUSED INLINE IN INNER: Inner: W1: [1 2 3]", + `<li><a href="#markdown-delimiter-hugo-rocks">MARKDOWN DELIMITER: <strong>Hugo Rocks!</strong></a></li>`, + } + + if enableInlineShortcodes { + b.AssertFileContent("public/page-md-shortcode/index.html", + shouldContain..., + ) + b.AssertFileContent("public/index.html", + shouldContain..., + ) + } else { + b.AssertFileContent("public/page-md-shortcode/index.html", + "FIRST::END", + "SECOND::END", + "NEW INLINE: :END", + "INLINE IN INNER: Inner: :END:", + "REUSED INLINE IN INNER: Inner: :END:", + ) + } + }) + + } +} + +// https://github.com/gohugoio/hugo/issues/5863 +func TestShortcodeNamespaced(t *testing.T) { + t.Parallel() + c := qt.New(t) + + builder := newTestSitesBuilder(t).WithSimpleConfigFile() + + builder.WithContent("page.md", `--- +title: "Hugo Rocks!" +--- + +# doc + + hello: {{< hello >}} + test/hello: {{< test/hello >}} + +`).WithTemplatesAdded( + "layouts/shortcodes/hello.html", `hello`, + "layouts/shortcodes/test/hello.html", `test/hello`).CreateSites().Build(BuildCfg{}) + + s := builder.H.Sites[0] + c.Assert(len(s.RegularPages()), qt.Equals, 1) + + builder.AssertFileContent("public/page/index.html", + "hello: hello", + "test/hello: test/hello", + ) +} + +func TestShortcodeParams(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.org" +-- layouts/shortcodes/hello.html -- +{{ range $i, $v := .Params }}{{ printf "- %v: %v (%T) " $i $v $v -}}{{ end }} +-- content/page.md -- +title: "Hugo Rocks!" +summary: "Foo" +--- + +# doc + +types positional: {{< hello true false 33 3.14 >}} +types named: {{< hello b1=true b2=false i1=33 f1=3.14 >}} +types string: {{< hello "true" trues "33" "3.14" >}} +escaped quoute: {{< hello "hello \"world\"." >}} +-- layouts/_default/single.html -- +Content: {{ .Content }}| +` + + b := Test(t, files) + + b.AssertFileContent("public/page/index.html", + "types positional: - 0: true (bool) - 1: false (bool) - 2: 33 (int) - 3: 3.14 (float64)", + "types named: - b1: true (bool) - b2: false (bool) - f1: 3.14 (float64) - i1: 33 (int)", + "types string: - 0: true (string) - 1: trues (string) - 2: 33 (string) - 3: 3.14 (string) ", + "hello "world". (string)", + ) +} + +func TestShortcodeRef(t *testing.T) { + t.Parallel() + + v := config.New() + v.Set("baseURL", "https://example.org") + + builder := newTestSitesBuilder(t).WithViper(v) + + for i := 1; i <= 2; i++ { + builder.WithContent(fmt.Sprintf("page%d.md", i), `--- +title: "Hugo Rocks!" +--- + + + +[Page 1]({{< ref "page1.md" >}}) +[Page 1 with anchor]({{< relref "page1.md#doc" >}}) +[Page 2]({{< ref "page2.md" >}}) +[Page 2 with anchor]({{< relref "page2.md#doc" >}}) + + +## Doc + + +`) + } + + builder.Build(BuildCfg{}) + + builder.AssertFileContent("public/page2/index.html", ` +<a href="/page1/#doc">Page 1 with anchor</a> +<a href="https://example.org/page2/">Page 2</a> +<a href="/page2/#doc">Page 2 with anchor</a></p> + +<h2 id="doc">Doc</h2> +`, + ) +} + +// https://github.com/gohugoio/hugo/issues/6857 +func TestShortcodeNoInner(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.org" +disableKinds = ["term", "taxonomy", "home", "section"] +-- content/mypage.md -- +--- +title: "No Inner!" +--- + +{{< noinner >}}{{< /noinner >}} + +-- layouts/shortcodes/noinner.html -- +No inner here. +-- layouts/_default/single.html -- +Content: {{ .Content }}| + +` + + b, err := TestE(t, files) + + assert := func() { + b.Assert(err.Error(), qt.Contains, filepath.FromSlash(`failed to extract shortcode: shortcode "noinner" does not evaluate .Inner or .InnerDeindent, yet a closing tag was provided`)) + } + + assert() + + b, err = TestE(t, strings.Replace(files, `{{< noinner >}}{{< /noinner >}}`, `{{< noinner />}}`, 1)) + + assert() +} + +func TestShortcodeStableOutputFormatTemplates(t *testing.T) { + t.Parallel() + + for range 5 { + + b := newTestSitesBuilder(t) + + const numPages = 10 + + for i := range numPages { + b.WithContent(fmt.Sprintf("page%d.md", i), `--- +title: "Page" +outputs: ["html", "css", "csv", "json"] +--- +{{< myshort >}} + +`) + } + + b.WithTemplates( + "_default/single.html", "{{ .Content }}", + "_default/single.css", "{{ .Content }}", + "_default/single.csv", "{{ .Content }}", + "_default/single.json", "{{ .Content }}", + "shortcodes/myshort.html", `Short-HTML`, + "shortcodes/myshort.csv", `Short-CSV`, + "shortcodes/myshort.txt", `Short-TXT`, + ) + + b.Build(BuildCfg{}) + + // helpers.PrintFs(b.Fs.Destination, "public", os.Stdout) + + for i := range numPages { + b.AssertFileContent(fmt.Sprintf("public/page%d/index.html", i), "Short-HTML") + b.AssertFileContent(fmt.Sprintf("public/page%d/index.csv", i), "Short-CSV") + b.AssertFileContent(fmt.Sprintf("public/page%d/index.json", i), "Short-CSV") + + } + + for i := range numPages { + b.AssertFileContent(fmt.Sprintf("public/page%d/styles.css", i), "Short-CSV") + } + + } +} + +// #9821 +func TestShortcodeMarkdownOutputFormat(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +-- content/p1.md -- +--- +title: "p1" +--- +{{% foo %}} +# The below would have failed using the HTML template parser. +-- layouts/shortcodes/foo.md -- +§§§ +<x +§§§ +-- layouts/_default/single.html -- +{{ .Content }} +` + + b := Test(t, files) + + b.AssertFileContent("public/p1/index.html", "<code><x") +} + +func TestShortcodePreserveIndentation(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +-- content/p1.md -- +--- +title: "p1" +--- + +## List With Indented Shortcodes + +1. List 1 + {{% mark1 %}} + 1. Item Mark1 1 + 1. Item Mark1 2 + {{% mark2 %}} + {{% /mark1 %}} +-- layouts/shortcodes/mark1.md -- +{{ .Inner }} +-- layouts/shortcodes/mark2.md -- +1. Item Mark2 1 +1. Item Mark2 2 + 1. Item Mark2 2-1 +1. Item Mark2 3 +-- layouts/_default/single.html -- +{{ .Content }} +` + + b := Test(t, files) + + b.AssertFileContent("public/p1/index.html", "<ol>\n<li>\n<p>List 1</p>\n<ol>\n<li>Item Mark1 1</li>\n<li>Item Mark1 2</li>\n<li>Item Mark2 1</li>\n<li>Item Mark2 2\n<ol>\n<li>Item Mark2 2-1</li>\n</ol>\n</li>\n<li>Item Mark2 3</li>\n</ol>\n</li>\n</ol>") +} + +func TestShortcodeCodeblockIndent(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +-- content/p1.md -- +--- +title: "p1" +--- + +## Code block + + {{% code %}} + +-- layouts/shortcodes/code.md -- +echo "foo"; +-- layouts/_default/single.html -- +{{ .Content }} +` + + b := Test(t, files) + + b.AssertFileContent("public/p1/index.html", "<pre><code>echo "foo";\n</code></pre>") +} + +func TestShortcodeHighlightDeindent(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +[markup] +[markup.highlight] +codeFences = true +noClasses = false +-- content/p1.md -- +--- +title: "p1" +--- + +## Indent 5 Spaces + + {{< highlight bash >}} + line 1; + line 2; + line 3; + {{< /highlight >}} + +-- layouts/_default/single.html -- +{{ .Content }} +` + + b := Test(t, files) + + b.AssertFileContent("public/p1/index.html", ` +<pre><code> <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">line 1<span class="p">;</span> +</span></span><span class="line"><span class="cl">line 2<span class="p">;</span> +</span></span><span class="line"><span class="cl">line 3<span class="p">;</span></span></span></code></pre></div> +</code></pre> + + `) +} + +// Issue 10236. +func TestShortcodeParamEscapedQuote(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +-- content/p1.md -- +--- +title: "p1" +--- + +{{< figure src="/media/spf13.jpg" title="Steve \"Francia\"." >}} + +-- layouts/shortcodes/figure.html -- +Title: {{ .Get "title" | safeHTML }} +-- layouts/_default/single.html -- +{{ .Content }} +` + + b := NewIntegrationTestBuilder( + IntegrationTestConfig{ + T: t, + TxtarString: files, + + Verbose: true, + }, + ).Build() + + b.AssertFileContent("public/p1/index.html", `Title: Steve "Francia".`) +} + +// Issue 10391. +func TestNestedShortcodeCustomOutputFormat(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- + +[outputFormats.Foobar] +baseName = "foobar" +isPlainText = true +mediaType = "application/json" +notAlternative = true + +[languages.en] +languageName = "English" + +[languages.en.outputs] +home = [ "HTML", "RSS", "Foobar" ] + +[languages.fr] +languageName = "Français" + +[[module.mounts]] +source = "content/en" +target = "content" +lang = "en" + +[[module.mounts]] +source = "content/fr" +target = "content" +lang = "fr" + +-- layouts/_default/list.foobar.json -- +{{- $.Scratch.Add "data" slice -}} +{{- range (where .Site.AllPages "Kind" "!=" "home") -}} + {{- $.Scratch.Add "data" (dict "content" (.Plain | truncate 10000) "type" .Type "full_url" .Permalink) -}} +{{- end -}} +{{- $.Scratch.Get "data" | jsonify -}} +-- content/en/p1.md -- +--- +title: "p1" +--- + +### More information + +{{< tabs >}} +{{% tab "Test" %}} + +It's a test + +{{% /tab %}} +{{< /tabs >}} + +-- content/fr/p2.md -- +--- +title: Test +--- + +### Plus d'informations + +{{< tabs >}} +{{% tab "Test" %}} + +C'est un test + +{{% /tab %}} +{{< /tabs >}} + +-- layouts/shortcodes/tabs.html -- +<div> + <div class="tab-content">{{ .Inner }}</div> +</div> + +-- layouts/shortcodes/tab.html -- +<div>{{ .Inner }}</div> + +-- layouts/_default/single.html -- +{{ .Content }} +` + + b := NewIntegrationTestBuilder( + IntegrationTestConfig{ + T: t, + TxtarString: files, + + Verbose: true, + }, + ).Build() + + b.AssertFileContent("public/fr/p2/index.html", `plus-dinformations`) +} + +// Issue 10671. +func TestShortcodeInnerShouldBeEmptyWhenNotClosed(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +disableKinds = ["home", "taxonomy", "term"] +-- content/p1.md -- +--- +title: "p1" +--- + +{{< sc "self-closing" />}} + +Text. + +{{< sc "closing-no-newline" >}}{{< /sc >}} + +-- layouts/shortcodes/sc.html -- +Inner: {{ .Get 0 }}: {{ len .Inner }} +InnerDeindent: {{ .Get 0 }}: {{ len .InnerDeindent }} +-- layouts/_default/single.html -- +{{ .Content }} +` + + b := NewIntegrationTestBuilder( + IntegrationTestConfig{ + T: t, + TxtarString: files, + + Verbose: true, + }, + ).Build() + + b.AssertFileContent("public/p1/index.html", ` +Inner: self-closing: 0 +InnerDeindent: self-closing: 0 +Inner: closing-no-newline: 0 +InnerDeindent: closing-no-newline: 0 + +`) +} + +// Issue 10675. +func TestShortcodeErrorWhenItShouldBeClosed(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +disableKinds = ["home", "taxonomy", "term"] +-- content/p1.md -- +--- +title: "p1" +--- + +{{< sc >}} + +Text. + +-- layouts/shortcodes/sc.html -- +Inner: {{ .Get 0 }}: {{ len .Inner }} +-- layouts/_default/single.html -- +{{ .Content }} +` + + b, err := NewIntegrationTestBuilder( + IntegrationTestConfig{ + T: t, + TxtarString: files, + + Verbose: true, + }, + ).BuildE() + + b.Assert(err, qt.Not(qt.IsNil)) + b.Assert(err.Error(), qt.Contains, `p1.md:5:1": failed to extract shortcode: shortcode "sc" must be closed or self-closed`) +} + +// Issue 10819. +func TestShortcodeInCodeFenceHyphen(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +disableKinds = ["home", "taxonomy", "term"] +-- content/p1.md -- +--- +title: "p1" +--- + +§§§go +{{< sc >}} +§§§ + +Text. + +-- layouts/shortcodes/sc.html -- +Hello. +-- layouts/_default/single.html -- +{{ .Content }} +` + + b := NewIntegrationTestBuilder( + IntegrationTestConfig{ + T: t, + TxtarString: files, + + Verbose: true, + }, + ).Build() + + b.AssertFileContent("public/p1/index.html", "<span style=\"color:#a6e22e\">Hello.</span>") } diff --git a/hugolib/shortcodeparser.go b/hugolib/shortcodeparser.go deleted file mode 100644 index 18b1454cd..000000000 --- a/hugolib/shortcodeparser.go +++ /dev/null @@ -1,587 +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 hugolib - -import ( - "fmt" - "strings" - "unicode" - "unicode/utf8" -) - -// The lexical scanning below 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 - -// parsing - -type pageTokens struct { - lexer *pagelexer - token [3]item // 3-item look-ahead is what we currently need - peekCount int -} - -func (t *pageTokens) next() item { - if t.peekCount > 0 { - t.peekCount-- - } else { - t.token[0] = t.lexer.nextItem() - } - return t.token[t.peekCount] -} - -// backs up one token. -func (t *pageTokens) backup() { - t.peekCount++ -} - -// backs up two tokens. -func (t *pageTokens) backup2(t1 item) { - t.token[1] = t1 - t.peekCount = 2 -} - -// backs up three tokens. -func (t *pageTokens) backup3(t2, t1 item) { - t.token[1] = t1 - t.token[2] = t2 - t.peekCount = 3 -} - -// check for non-error and non-EOF types coming next -func (t *pageTokens) isValueNext() bool { - i := t.peek() - return i.typ != tError && i.typ != tEOF -} - -// look at, but do not consume, the next item -// repeated, sequential calls will return the same item -func (t *pageTokens) peek() item { - if t.peekCount > 0 { - return t.token[t.peekCount-1] - } - t.peekCount = 1 - t.token[0] = t.lexer.nextItem() - return t.token[0] -} - -// convencience method to consume the next n tokens, but back off Errors and EOF -func (t *pageTokens) consume(cnt int) { - for i := 0; i < cnt; i++ { - token := t.next() - if token.typ == tError || token.typ == tEOF { - t.backup() - break - } - } -} - -// lexical scanning - -// position (in bytes) -type pos int - -type item struct { - typ itemType - pos pos - val string -} - -func (i item) String() string { - switch { - case i.typ == tEOF: - return "EOF" - case i.typ == tError: - return i.val - case i.typ > tKeywordMarker: - return fmt.Sprintf("<%s>", i.val) - case len(i.val) > 20: - return fmt.Sprintf("%.20q...", i.val) - } - return fmt.Sprintf("[%s]", i.val) -} - -type itemType int - -const ( - tError itemType = iota - tEOF - - // shortcode items - tLeftDelimScNoMarkup - tRightDelimScNoMarkup - tLeftDelimScWithMarkup - tRightDelimScWithMarkup - tScClose - tScName - tScParam - tScParamVal - - //itemIdentifier - tText // plain text, used for everything outside the shortcodes - - // preserved for later - keywords come after this - tKeywordMarker -) - -const eof = -1 - -// returns the next state in scanner. -type stateFunc func(*pagelexer) stateFunc - -type pagelexer struct { - name string - input string - state stateFunc - pos pos // input position - start pos // item start position - width pos // width of last element - lastPos pos // position of the last item returned by nextItem - - // shortcode state - currLeftDelimItem itemType - currRightDelimItem itemType - currShortcodeName string // is only set when a shortcode is in opened state - closingState int // > 0 = on its way to be closed - 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 - - // items delivered to client - items []item -} - -// note: the input position here is normally 0 (start), but -// can be set if position of first shortcode is known -func newShortcodeLexer(name, input string, inputPosition pos) *pagelexer { - lexer := &pagelexer{ - name: name, - input: input, - currLeftDelimItem: tLeftDelimScNoMarkup, - currRightDelimItem: tRightDelimScNoMarkup, - pos: inputPosition, - openShortcodes: make(map[string]bool), - items: make([]item, 0, 5), - } - lexer.runShortcodeLexer() - return lexer -} - -// main loop -// this looks kind of funky, but it works -func (l *pagelexer) runShortcodeLexer() { - for l.state = lexTextOutsideShortcodes; l.state != nil; { - l.state = l.state(l) - } -} - -// state functions - -const ( - leftDelimScNoMarkup = "{{<" - rightDelimScNoMarkup = ">}}" - leftDelimScWithMarkup = "{{%" - rightDelimScWithMarkup = "%}}" - leftComment = "/*" // comments in this context us used to to mark shortcodes as "not really a shortcode" - rightComment = "*/" -) - -func (l *pagelexer) next() rune { - if int(l.pos) >= len(l.input) { - l.width = 0 - return eof - } - - // looks expensive, but should produce the same iteration sequence as the string range loop - // see: http://blog.golang.org/strings - runeValue, runeWidth := utf8.DecodeRuneInString(l.input[l.pos:]) - l.width = pos(runeWidth) - l.pos += l.width - return runeValue -} - -// peek, but no consume -func (l *pagelexer) peek() rune { - r := l.next() - l.backup() - return r -} - -// steps back one -func (l *pagelexer) backup() { - l.pos -= l.width -} - -// 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]}) - l.start = l.pos -} - -// special case, do not send '\\' back to client -func (l *pagelexer) ignoreEscapesAndEmit(t itemType) { - val := strings.Map(func(r rune) rune { - if r == '\\' { - return -1 - } - return r - }, l.input[l.start:l.pos]) - l.items = append(l.items, item{t, l.start, val}) - l.start = l.pos -} - -// gets the current value (for debugging and error handling) -func (l *pagelexer) current() string { - return l.input[l.start:l.pos] -} - -// ignore current element -func (l *pagelexer) ignore() { - l.start = l.pos -} - -// nice to have in error logs -func (l *pagelexer) lineNum() int { - return strings.Count(l.input[:l.lastPos], "\n") + 1 -} - -// nil terminates the parser -func (l *pagelexer) errorf(format string, args ...interface{}) stateFunc { - l.items = append(l.items, item{tError, l.start, fmt.Sprintf(format, args...)}) - return nil -} - -// consumes and returns the next item -func (l *pagelexer) nextItem() item { - item := l.items[0] - l.items = l.items[1:] - l.lastPos = item.pos - return item -} - -// scans until an opening shortcode opening bracket. -// if no shortcodes, it will keep on scanning until EOF -func lexTextOutsideShortcodes(l *pagelexer) stateFunc { - for { - if strings.HasPrefix(l.input[l.pos:], leftDelimScWithMarkup) || strings.HasPrefix(l.input[l.pos:], leftDelimScNoMarkup) { - if l.pos > l.start { - l.emit(tText) - } - if strings.HasPrefix(l.input[l.pos:], leftDelimScWithMarkup) { - l.currLeftDelimItem = tLeftDelimScWithMarkup - l.currRightDelimItem = tRightDelimScWithMarkup - } else { - l.currLeftDelimItem = tLeftDelimScNoMarkup - l.currRightDelimItem = tRightDelimScNoMarkup - } - return lexShortcodeLeftDelim - - } - if l.next() == eof { - break - } - } - // Done! - if l.pos > l.start { - l.emit(tText) - } - l.emit(tEOF) - return nil -} - -func lexShortcodeLeftDelim(l *pagelexer) stateFunc { - l.pos += pos(len(l.currentLeftShortcodeDelim())) - if strings.HasPrefix(l.input[l.pos:], leftComment) { - return lexShortcodeComment - } - l.emit(l.currentLeftShortcodeDelimItem()) - l.elementStepNum = 0 - l.paramElements = 0 - return lexInsideShortcode -} - -func lexShortcodeComment(l *pagelexer) stateFunc { - posRightComment := strings.Index(l.input[l.pos:], rightComment) - if posRightComment <= 1 { - return l.errorf("comment must be closed") - } - // we emit all as text, except the comment markers - l.emit(tText) - l.pos += pos(len(leftComment)) - l.ignore() - l.pos += pos(posRightComment - len(leftComment)) - l.emit(tText) - l.pos += pos(len(rightComment)) - l.ignore() - if !strings.HasPrefix(l.input[l.pos:], l.currentRightShortcodeDelim()) { - return l.errorf("comment ends before the right shortcode delimiter") - } - l.pos += pos(len(l.currentRightShortcodeDelim())) - l.emit(tText) - return lexTextOutsideShortcodes -} - -func lexShortcodeRightDelim(l *pagelexer) stateFunc { - l.closingState = 0 - l.pos += pos(len(l.currentRightShortcodeDelim())) - l.emit(l.currentRightShortcodeDelimItem()) - return lexTextOutsideShortcodes -} - -// either: -// 1. param -// 2. "param" or "param\" -// 3. param="123" or param="123\" -// 4. param="Some \"escaped\" text" -func lexShortcodeParam(l *pagelexer, escapedQuoteStart bool) stateFunc { - - first := true - nextEq := false - - var r rune - - for { - r = l.next() - if first { - if r == '"' { - // a positional param with quotes - if l.paramElements == 2 { - return l.errorf("got quoted positional parameter. Cannot mix named and positional parameters") - } - l.paramElements = 1 - l.backup() - return lexShortcodeQuotedParamVal(l, !escapedQuoteStart, tScParam) - } - first = false - } else if r == '=' { - // a named param - l.backup() - nextEq = true - break - } - - if !isAlphaNumericOrHyphen(r) { - l.backup() - break - } - } - - if l.paramElements == 0 { - l.paramElements++ - - if nextEq { - l.paramElements++ - } - } else { - if nextEq && l.paramElements == 1 { - return l.errorf("got named parameter '%s'. Cannot mix named and positional parameters", l.current()) - } else if !nextEq && l.paramElements == 2 { - return l.errorf("got positional parameter '%s'. Cannot mix named and positional parameters", l.current()) - } - } - - l.emit(tScParam) - return lexInsideShortcode - -} - -func lexShortcodeQuotedParamVal(l *pagelexer, escapedQuotedValuesAllowed bool, typ itemType) stateFunc { - openQuoteFound := false - escapedInnerQuoteFound := false - escapedQuoteState := 0 - -Loop: - for { - switch r := l.next(); { - case r == '\\': - if l.peek() == '"' { - if openQuoteFound && !escapedQuotedValuesAllowed { - l.backup() - break Loop - } else if openQuoteFound { - // the coming quoute is inside - escapedInnerQuoteFound = true - escapedQuoteState = 1 - } - } - case r == eof, r == '\n': - return l.errorf("unterminated quoted string in shortcode parameter-argument: '%s'", l.current()) - case r == '"': - if escapedQuoteState == 0 { - if openQuoteFound { - l.backup() - break Loop - - } else { - openQuoteFound = true - l.ignore() - } - } else { - escapedQuoteState = 0 - } - - } - } - - if escapedInnerQuoteFound { - l.ignoreEscapesAndEmit(typ) - } else { - l.emit(typ) - } - - r := l.next() - - if r == '\\' { - if l.peek() == '"' { - // ignore the escaped closing quote - l.ignore() - l.next() - l.ignore() - } - } else if r == '"' { - // ignore closing quote - l.ignore() - } else { - // handled by next state - l.backup() - } - - return lexInsideShortcode -} - -// scans an alphanumeric inside shortcode -func lexIdentifierInShortcode(l *pagelexer) stateFunc { - lookForEnd := false -Loop: - for { - switch r := l.next(); { - case isAlphaNumericOrHyphen(r): - default: - l.backup() - word := l.input[l.start:l.pos] - if l.closingState > 0 && !l.openShortcodes[word] { - return l.errorf("closing tag for shortcode '%s' does not match start tag", word) - } else if l.closingState > 0 { - l.openShortcodes[word] = false - lookForEnd = true - } - - l.closingState = 0 - l.currShortcodeName = word - l.openShortcodes[word] = true - l.elementStepNum++ - l.emit(tScName) - break Loop - } - } - - if lookForEnd { - return lexEndOfShortcode - } - return lexInsideShortcode -} - -func lexEndOfShortcode(l *pagelexer) stateFunc { - if strings.HasPrefix(l.input[l.pos:], l.currentRightShortcodeDelim()) { - return lexShortcodeRightDelim - } - switch r := l.next(); { - case isSpace(r): - l.ignore() - default: - return l.errorf("unclosed shortcode") - } - return lexEndOfShortcode -} - -// scans the elements inside shortcode tags -func lexInsideShortcode(l *pagelexer) stateFunc { - if strings.HasPrefix(l.input[l.pos:], l.currentRightShortcodeDelim()) { - return lexShortcodeRightDelim - } - switch r := l.next(); { - case r == eof: - // eol is allowed inside shortcodes; this may go to end of document before it fails - return l.errorf("unclosed shortcode action") - case isSpace(r), isEndOfLine(r): - l.ignore() - case r == '=': - l.ignore() - return lexShortcodeQuotedParamVal(l, l.peek() != '\\', tScParamVal) - case r == '/': - if l.currShortcodeName == "" { - return l.errorf("got closing shortcode, but none is open") - } - l.closingState++ - l.emit(tScClose) - case r == '\\': - l.ignore() - if l.peek() == '"' { - return lexShortcodeParam(l, true) - } - case l.elementStepNum > 0 && (isAlphaNumericOrHyphen(r) || r == '"'): // positional params can have quotes - l.backup() - return lexShortcodeParam(l, false) - case isAlphaNumeric(r): - l.backup() - return lexIdentifierInShortcode - default: - return l.errorf("unrecognized character in shortcode action: %#U. Note: Parameters with non-alphanumeric args must be quoted", r) - } - return lexInsideShortcode -} - -// state helpers - -func (l *pagelexer) currentLeftShortcodeDelimItem() itemType { - return l.currLeftDelimItem -} - -func (l *pagelexer) currentRightShortcodeDelimItem() itemType { - return l.currRightDelimItem -} - -func (l *pagelexer) currentLeftShortcodeDelim() string { - if l.currLeftDelimItem == tLeftDelimScWithMarkup { - return leftDelimScWithMarkup - } - return leftDelimScNoMarkup - -} - -func (l *pagelexer) currentRightShortcodeDelim() string { - if l.currRightDelimItem == tRightDelimScWithMarkup { - return rightDelimScWithMarkup - } - return rightDelimScNoMarkup -} - -// helper functions - -func isSpace(r rune) bool { - return r == ' ' || r == '\t' -} - -func isAlphaNumericOrHyphen(r rune) bool { - // let unquoted YouTube ids as positional params slip through (they contain hyphens) - return isAlphaNumeric(r) || r == '-' -} - -func isEndOfLine(r rune) bool { - return r == '\r' || r == '\n' -} - -func isAlphaNumeric(r rune) bool { - return r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r) -} diff --git a/hugolib/shortcodeparser_test.go b/hugolib/shortcodeparser_test.go deleted file mode 100644 index 3103fd4de..000000000 --- a/hugolib/shortcodeparser_test.go +++ /dev/null @@ -1,202 +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 hugolib - -import ( - "testing" -) - -type shortCodeLexerTest struct { - name string - input string - items []item -} - -var ( - tstEOF = item{tEOF, 0, ""} - tstLeftNoMD = item{tLeftDelimScNoMarkup, 0, "{{<"} - tstRightNoMD = item{tRightDelimScNoMarkup, 0, ">}}"} - tstLeftMD = item{tLeftDelimScWithMarkup, 0, "{{%"} - tstRightMD = item{tRightDelimScWithMarkup, 0, "%}}"} - tstSCClose = item{tScClose, 0, "/"} - tstSC1 = item{tScName, 0, "sc1"} - tstSC2 = item{tScName, 0, "sc2"} - tstSC3 = item{tScName, 0, "sc3"} - tstParam1 = item{tScParam, 0, "param1"} - tstParam2 = item{tScParam, 0, "param2"} - tstVal = item{tScParamVal, 0, "Hello World"} -) - -var shortCodeLexerTests = []shortCodeLexerTest{ - {"empty", "", []item{tstEOF}}, - {"spaces", " \t\n", []item{{tText, 0, " \t\n"}, tstEOF}}, - {"text", `to be or not`, []item{{tText, 0, "to be or not"}, tstEOF}}, - {"no markup", `{{< sc1 >}}`, []item{tstLeftNoMD, tstSC1, tstRightNoMD, tstEOF}}, - {"with EOL", "{{< sc1 \n >}}", []item{tstLeftNoMD, tstSC1, tstRightNoMD, tstEOF}}, - - {"simple with markup", `{{% sc1 %}}`, []item{tstLeftMD, tstSC1, tstRightMD, tstEOF}}, - {"with spaces", `{{< sc1 >}}`, []item{tstLeftNoMD, tstSC1, tstRightNoMD, tstEOF}}, - {"mismatched rightDelim", `{{< sc1 %}}`, []item{tstLeftNoMD, tstSC1, - {tError, 0, "unrecognized character in shortcode action: U+0025 '%'. Note: Parameters with non-alphanumeric args must be quoted"}}}, - {"inner, markup", `{{% sc1 %}} inner {{% /sc1 %}}`, []item{ - tstLeftMD, - tstSC1, - tstRightMD, - {tText, 0, " inner "}, - tstLeftMD, - tstSCClose, - tstSC1, - tstRightMD, - tstEOF, - }}, - {"close, but no open", `{{< /sc1 >}}`, []item{ - tstLeftNoMD, {tError, 0, "got closing shortcode, but none is open"}}}, - {"close wrong", `{{< sc1 >}}{{< /another >}}`, []item{ - tstLeftNoMD, tstSC1, tstRightNoMD, tstLeftNoMD, tstSCClose, - {tError, 0, "closing tag for shortcode 'another' does not match start tag"}}}, - {"close, but no open, more", `{{< sc1 >}}{{< /sc1 >}}{{< /another >}}`, []item{ - tstLeftNoMD, tstSC1, tstRightNoMD, tstLeftNoMD, tstSCClose, tstSC1, tstRightNoMD, tstLeftNoMD, tstSCClose, - {tError, 0, "closing tag for shortcode 'another' does not match start tag"}}}, - {"close with extra keyword", `{{< sc1 >}}{{< /sc1 keyword>}}`, []item{ - tstLeftNoMD, tstSC1, tstRightNoMD, tstLeftNoMD, tstSCClose, tstSC1, - {tError, 0, "unclosed shortcode"}}}, - {"Youtube id", `{{< sc1 -ziL-Q_456igdO-4 >}}`, []item{ - tstLeftNoMD, tstSC1, {tScParam, 0, "-ziL-Q_456igdO-4"}, tstRightNoMD, tstEOF}}, - {"non-alphanumerics param quoted", `{{< sc1 "-ziL-.%QigdO-4" >}}`, []item{ - tstLeftNoMD, tstSC1, {tScParam, 0, "-ziL-.%QigdO-4"}, tstRightNoMD, tstEOF}}, - - {"two params", `{{< sc1 param1 param2 >}}`, []item{ - tstLeftNoMD, tstSC1, tstParam1, tstParam2, tstRightNoMD, tstEOF}}, - // issue #934 - {"self-closing", `{{< sc1 />}}`, []item{ - tstLeftNoMD, tstSC1, tstSCClose, tstRightNoMD, tstEOF}}, - // Issue 2498 - {"multiple self-closing", `{{< sc1 />}}{{< sc1 />}}`, []item{ - 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, 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, - tstLeftNoMD, tstSC2, tstParam1, tstSCClose, tstRightNoMD, tstEOF}}, - {"nested simple", `{{< sc1 >}}{{< sc2 >}}{{< /sc1 >}}`, []item{ - 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, tstSC1, tstRightNoMD, - {tText, 0, "ab"}, - tstLeftMD, tstSC2, tstParam1, tstRightMD, - {tText, 0, "cd"}, - tstLeftNoMD, tstSC3, tstRightNoMD, - {tText, 0, "ef"}, - tstLeftNoMD, tstSCClose, tstSC3, tstRightNoMD, - {tText, 0, "gh"}, - tstLeftMD, tstSCClose, tstSC2, tstRightMD, - {tText, 0, "ij"}, - tstLeftNoMD, tstSCClose, tstSC1, tstRightNoMD, - {tText, 0, "kl"}, tstEOF, - }}, - - {"two quoted params", `{{< sc1 "param nr. 1" "param nr. 2" >}}`, []item{ - tstLeftNoMD, tstSC1, {tScParam, 0, "param nr. 1"}, {tScParam, 0, "param nr. 2"}, tstRightNoMD, tstEOF}}, - {"two named params", `{{< sc1 param1="Hello World" param2="p2Val">}}`, []item{ - tstLeftNoMD, tstSC1, tstParam1, tstVal, tstParam2, {tScParamVal, 0, "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{ - tstLeftNoMD, tstSC1, tstParam1, - {tScParamVal, 0, `Hello `}, {tError, 0, `got positional parameter 'escaped'. Cannot mix named and positional parameters`}}}, - {"escaped quotes inside nonescaped quotes", - `{{< sc1 param1="Hello \"escaped\" World" >}}`, []item{ - tstLeftNoMD, tstSC1, tstParam1, {tScParamVal, 0, `Hello "escaped" World`}, tstRightNoMD, tstEOF}}, - {"escaped quotes inside nonescaped quotes in positional param", - `{{< sc1 "Hello \"escaped\" World" >}}`, []item{ - tstLeftNoMD, tstSC1, {tScParam, 0, `Hello "escaped" World`}, tstRightNoMD, tstEOF}}, - {"unterminated quote", `{{< sc1 param2="Hello World>}}`, []item{ - tstLeftNoMD, tstSC1, tstParam2, {tError, 0, "unterminated quoted string in shortcode parameter-argument: 'Hello World>}}'"}}}, - {"one named param, one not", `{{< sc1 param1="Hello World" p2 >}}`, []item{ - tstLeftNoMD, tstSC1, tstParam1, tstVal, - {tError, 0, "got positional parameter 'p2'. Cannot mix named and positional parameters"}}}, - {"one named param, one quoted positional param", `{{< sc1 param1="Hello World" "And Universe" >}}`, []item{ - tstLeftNoMD, tstSC1, tstParam1, tstVal, - {tError, 0, "got quoted positional parameter. Cannot mix named and positional parameters"}}}, - {"one quoted positional param, one named param", `{{< sc1 "param1" param2="And Universe" >}}`, []item{ - tstLeftNoMD, tstSC1, tstParam1, - {tError, 0, "got named parameter 'param2'. Cannot mix named and positional parameters"}}}, - {"ono positional param, one not", `{{< sc1 param1 param2="Hello World">}}`, []item{ - tstLeftNoMD, tstSC1, tstParam1, - {tError, 0, "got named parameter 'param2'. Cannot mix named and positional parameters"}}}, - {"commented out", `{{</* sc1 */>}}`, []item{ - {tText, 0, "{{<"}, {tText, 0, " sc1 "}, {tText, 0, ">}}"}, tstEOF}}, - {"commented out, missing close", `{{</* sc1 >}}`, []item{ - {tError, 0, "comment must be closed"}}}, - {"commented out, misplaced close", `{{</* sc1 >}}*/`, []item{ - {tText, 0, "{{<"}, {tText, 0, " sc1 >}}"}, {tError, 0, "comment ends before the right shortcode delimiter"}}}, -} - -func TestShortcodeLexer(t *testing.T) { - t.Parallel() - for i, test := range shortCodeLexerTests { - items := collect(&test) - if !equal(items, test.items) { - t.Errorf("[%d] %s: got\n\t%v\nexpected\n\t%v", i, test.name, items, test.items) - } - } -} - -func BenchmarkShortcodeLexer(b *testing.B) { - b.ResetTimer() - for i := 0; i < b.N; i++ { - for _, test := range shortCodeLexerTests { - items := collect(&test) - if !equal(items, test.items) { - b.Errorf("%s: got\n\t%v\nexpected\n\t%v", test.name, items, test.items) - } - } - } -} - -func collect(t *shortCodeLexerTest) (items []item) { - l := newShortcodeLexer(t.name, t.input, 0) - for { - item := l.nextItem() - items = append(items, item) - if item.typ == tEOF || item.typ == tError { - break - } - } - return -} - -// no positional checking, for now ... -func equal(i1, i2 []item) bool { - if len(i1) != len(i2) { - return false - } - for k := range i1 { - if i1[k].typ != i2[k].typ { - return false - } - if i1[k].val != i2[k].val { - return false - } - } - return true -} diff --git a/hugolib/site.go b/hugolib/site.go index b0d70bff2..acd3b5410 100644 --- a/hugolib/site.go +++ b/hugolib/site.go @@ -1,4 +1,4 @@ -// Copyright 2017 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,149 +14,820 @@ package hugolib import ( + "context" "errors" "fmt" - "html/template" "io" "mime" "net/url" "os" "path/filepath" + "runtime" "sort" - "strconv" "strings" + "sync" "time" - "github.com/gohugoio/hugo/resource" - - "golang.org/x/sync/errgroup" - + "github.com/bep/logg" + "github.com/gohugoio/hugo/cache/dynacache" + "github.com/gohugoio/hugo/common/htime" + "github.com/gohugoio/hugo/common/hugio" + "github.com/gohugoio/hugo/common/hugo" + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/common/para" + "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/hugolib/doctree" + "github.com/gohugoio/hugo/hugolib/pagesfromdata" + "github.com/gohugoio/hugo/internal/js/esbuild" + "github.com/gohugoio/hugo/internal/warpc" + "github.com/gohugoio/hugo/langs/i18n" + "github.com/gohugoio/hugo/modules" + "github.com/gohugoio/hugo/resources" - "github.com/gohugoio/hugo/media" + "github.com/gohugoio/hugo/tpl/tplimpl" + "github.com/gohugoio/hugo/tpl/tplimplinit" + xmaps "golang.org/x/exp/maps" - "github.com/markbates/inflect" - "golang.org/x/net/context" + // Loads the template funcs namespaces. + + "golang.org/x/text/unicode/norm" + + "github.com/gohugoio/hugo/common/paths" + + "github.com/gohugoio/hugo/identity" + + "github.com/gohugoio/hugo/markup/converter/hooks" + + "github.com/gohugoio/hugo/markup/converter" + + "github.com/gohugoio/hugo/common/text" + + "github.com/gohugoio/hugo/publisher" + + "github.com/gohugoio/hugo/langs" + + "github.com/gohugoio/hugo/resources/kinds" + "github.com/gohugoio/hugo/resources/page" + "github.com/gohugoio/hugo/resources/page/pagemeta" + "github.com/gohugoio/hugo/resources/page/siteidentities" + "github.com/gohugoio/hugo/resources/resource" + + "github.com/gohugoio/hugo/lazy" "github.com/fsnotify/fsnotify" bp "github.com/gohugoio/hugo/bufferpool" - "github.com/gohugoio/hugo/deps" "github.com/gohugoio/hugo/helpers" - "github.com/gohugoio/hugo/hugolib/pagemeta" + "github.com/gohugoio/hugo/navigation" "github.com/gohugoio/hugo/output" - "github.com/gohugoio/hugo/parser" - "github.com/gohugoio/hugo/related" - "github.com/gohugoio/hugo/source" "github.com/gohugoio/hugo/tpl" - "github.com/gohugoio/hugo/transform" - "github.com/spf13/afero" - "github.com/spf13/cast" - "github.com/spf13/nitro" - "github.com/spf13/viper" ) -var _ = transform.AbsURL +var _ page.Site = (*Site)(nil) -// used to indicate if run as a test. -var testMode bool +type siteState int -var defaultTimer *nitro.B +const ( + siteStateInit siteState = iota + siteStateReady +) -// Site contains all the information relevant for constructing a static -// site. The basic flow of information is as follows: -// -// 1. A list of Files is parsed and then converted into Pages. -// -// 2. Pages contain sections (based on the file they were generated from), -// aliases and slugs (included in a pages frontmatter) which are the -// various targets that will get generated. There will be canonical -// listing. The canonical path can be overruled based on a pattern. -// -// 3. Taxonomies are created via configuration and will present some aspect of -// the final page and typically a perm url. -// -// 4. All Pages are passed through a template based on their desired -// layout based on numerous different elements. -// -// 5. The entire collection of files is written to disk. type Site struct { - owner *HugoSites + state siteState + conf *allconfig.Config + language *langs.Language + languagei int + pageMap *pageMap + store *maps.Scratch - *PageCollections + // The owning container. + h *HugoSites - Taxonomies TaxonomyList + *deps.Deps - // Plural is what we get in the folder, so keep track of this mapping - // to get the singular form from that value. - taxonomiesPluralSingular map[string]string + // Page navigation. + *pageFinder + taxonomies page.TaxonomyList + menus navigation.Menus - // This is temporary, see https://github.com/gohugoio/hugo/issues/2835 - // Maps "actors-gerard-depardieu" to "Gérard Depardieu" when preserveTaxonomyNames - // is set. - taxonomiesOrigKey map[string]string + // Shortcut to the home page. Note that this may be nil if + // home page, for some odd reason, is disabled. + home *pageState - Sections Taxonomy - Info SiteInfo - Menus Menus - timer *nitro.B + // The last modification date of this site. + lastmod time.Time - layoutHandler *output.LayoutHandler - - draftCount int - futureCount int - expiredCount int - - Data map[string]interface{} - Language *helpers.Language - - disabledKinds map[string]bool - - // Output formats defined in site config per Page Kind, or some defaults - // if not set. - // Output formats defined in Page front matter will override these. - outputFormats map[string]output.Formats - - // All the output formats and media types available for this site. - // These values will be merged from the Hugo defaults, the site config and, - // finally, the language settings. - outputFormatsConfig output.Formats - mediaTypesConfig media.Types - - // How to handle page front matter. + relatedDocsHandler *page.RelatedDocsHandler + siteRefLinker + publisher publisher.Publisher frontmatterHandler pagemeta.FrontMatterHandler - // We render each site for all the relevant output formats in serial with - // this rendering context pointing to the current one. - rc *siteRenderingContext - // The output formats that we need to render this site in. This slice // will be fixed once set. // This will be the union of Site.Pages' outputFormats. // This slice will be sorted. renderFormats output.Formats - // Logger etc. - *deps.Deps `json:"-"` - resourceSpec *resource.Spec - - // The func used to title case titles. - titleFunc func(s string) string - - relatedDocsHandler *relatedDocsHandler + // Lazily loaded site dependencies + init *siteInit } -type siteRenderingContext struct { - output.Format +func (s *Site) Debug() { + fmt.Println("Debugging site", s.Lang(), "=>") + // fmt.Println(s.pageMap.testDump()) +} + +// NewHugoSites creates HugoSites from the given config. +func NewHugoSites(cfg deps.DepsCfg) (*HugoSites, error) { + conf := cfg.Configs.GetFirstLanguageConfig() + + var logger loggers.Logger + if cfg.TestLogger != nil { + logger = cfg.TestLogger + } else { + var logHookLast func(e *logg.Entry) error + if cfg.Configs.Base.PanicOnWarning { + logHookLast = loggers.PanicOnWarningHook + } + if cfg.StdOut == nil { + cfg.StdOut = os.Stdout + } + if cfg.StdErr == nil { + cfg.StdErr = os.Stderr + } + if cfg.LogLevel == 0 { + cfg.LogLevel = logg.LevelWarn + } + + logOpts := loggers.Options{ + Level: cfg.LogLevel, + DistinctLevel: logg.LevelWarn, // This will drop duplicate log warning and errors. + HandlerPost: logHookLast, + StdOut: cfg.StdOut, + StdErr: cfg.StdErr, + StoreErrors: conf.Watching(), + SuppressStatements: conf.IgnoredLogs(), + } + logger = loggers.New(logOpts) + + } + + memCache := dynacache.New(dynacache.Options{Watching: conf.Watching(), Log: logger}) + + var h *HugoSites + onSignalRebuild := func(ids ...identity.Identity) { + // This channel is buffered, but make sure we do this in a non-blocking way. + if cfg.ChangesFromBuild != nil { + go func() { + cfg.ChangesFromBuild <- ids + }() + } + } + + firstSiteDeps := &deps.Deps{ + Fs: cfg.Fs, + Log: logger, + Conf: conf, + BuildState: &deps.BuildState{ + OnSignalRebuild: onSignalRebuild, + }, + Counters: &deps.Counters{}, + MemCache: memCache, + TranslationProvider: i18n.NewTranslationProvider(), + WasmDispatchers: warpc.AllDispatchers( + warpc.Options{ + CompilationCacheDir: filepath.Join(conf.Dirs().CacheDir, "_warpc"), + + // Katex is relatively slow. + PoolSize: 8, + Infof: logger.InfoCommand("wasm").Logf, + Warnf: logger.WarnCommand("wasm").Logf, + }, + ), + } + + if err := firstSiteDeps.Init(); err != nil { + return nil, err + } + + batcherClient, err := esbuild.NewBatcherClient(firstSiteDeps) + if err != nil { + return nil, err + } + firstSiteDeps.JSBatcherClient = batcherClient + + confm := cfg.Configs + if err := confm.Validate(logger); err != nil { + return nil, err + } + var sites []*Site + + ns := &contentNodeShifter{ + numLanguages: len(confm.Languages), + } + + treeConfig := doctree.Config[contentNodeI]{ + Shifter: ns, + } + + pageTrees := &pageTrees{ + treePages: doctree.New( + treeConfig, + ), + treeResources: doctree.New( + treeConfig, + ), + treeTaxonomyEntries: doctree.NewTreeShiftTree[*weightedContentNode](doctree.DimensionLanguage.Index(), len(confm.Languages)), + treePagesFromTemplateAdapters: doctree.NewTreeShiftTree[*pagesfromdata.PagesFromTemplate](doctree.DimensionLanguage.Index(), len(confm.Languages)), + } + + pageTrees.createMutableTrees() + + for i, confp := range confm.ConfigLangs() { + language := confp.Language() + if language.Disabled { + continue + } + k := language.Lang + conf := confm.LanguageConfigMap[k] + frontmatterHandler, err := pagemeta.NewFrontmatterHandler(firstSiteDeps.Log, conf.Frontmatter) + if err != nil { + return nil, err + } + + langs.SetParams(language, conf.Params) + + s := &Site{ + conf: conf, + language: language, + languagei: i, + frontmatterHandler: frontmatterHandler, + store: maps.NewScratch(), + } + + if i == 0 { + firstSiteDeps.Site = s + s.Deps = firstSiteDeps + } else { + d, err := firstSiteDeps.Clone(s, confp) + if err != nil { + return nil, err + } + s.Deps = d + } + + s.pageMap = newPageMap(i, s, memCache, pageTrees) + + s.pageFinder = newPageFinder(s.pageMap) + s.siteRefLinker, err = newSiteRefLinker(s) + if err != nil { + return nil, err + } + // Set up the main publishing chain. + pub, err := publisher.NewDestinationPublisher( + firstSiteDeps.ResourceSpec, + s.conf.OutputFormats.Config, + s.conf.MediaTypes.Config, + ) + if err != nil { + return nil, err + } + + s.publisher = pub + s.relatedDocsHandler = page.NewRelatedDocsHandler(s.conf.Related) + // Site deps end. + + s.prepareInits() + sites = append(sites, s) + } + + if len(sites) == 0 { + return nil, errors.New("no sites to build") + } + + // Pull the default content language to the top, then sort the sites by language weight (if set) or lang. + defaultContentLanguage := confm.Base.DefaultContentLanguage + sort.Slice(sites, func(i, j int) bool { + li := sites[i].language + lj := sites[j].language + if li.Lang == defaultContentLanguage { + return true + } + + if lj.Lang == defaultContentLanguage { + return false + } + + if li.Weight != lj.Weight { + return li.Weight < lj.Weight + } + return li.Lang < lj.Lang + }) + + h, err = newHugoSites(cfg, firstSiteDeps, pageTrees, sites) + if err == nil && h == nil { + panic("hugo: newHugoSitesNew returned nil error and nil HugoSites") + } + + return h, err +} + +func newHugoSites(cfg deps.DepsCfg, d *deps.Deps, pageTrees *pageTrees, sites []*Site) (*HugoSites, error) { + numWorkers := config.GetNumWorkerMultiplier() + numWorkersSite := min(numWorkers, len(sites)) + workersSite := para.New(numWorkersSite) + + h := &HugoSites{ + Sites: sites, + Deps: sites[0].Deps, + Configs: cfg.Configs, + workersSite: workersSite, + numWorkersSites: numWorkers, + numWorkers: numWorkers, + pageTrees: pageTrees, + cachePages: dynacache.GetOrCreatePartition[string, + page.Pages](d.MemCache, "/pags/all", + dynacache.OptionsPartition{Weight: 10, ClearWhen: dynacache.ClearOnRebuild}, + ), + cacheContentSource: dynacache.GetOrCreatePartition[string, *resources.StaleValue[[]byte]](d.MemCache, "/cont/src", dynacache.OptionsPartition{Weight: 70, ClearWhen: dynacache.ClearOnChange}), + translationKeyPages: maps.NewSliceCache[page.Page](), + currentSite: sites[0], + skipRebuildForFilenames: make(map[string]bool), + init: &hugoSitesInit{ + data: lazy.New(), + gitInfo: lazy.New(), + }, + } + + // Assemble dependencies to be used in hugo.Deps. + var dependencies []*hugo.Dependency + var depFromMod func(m modules.Module) *hugo.Dependency + depFromMod = func(m modules.Module) *hugo.Dependency { + dep := &hugo.Dependency{ + Path: m.Path(), + Version: m.Version(), + Time: m.Time(), + Vendor: m.Vendor(), + } + + // These are pointers, but this all came from JSON so there's no recursive navigation, + // so just create new values. + if m.Replace() != nil { + dep.Replace = depFromMod(m.Replace()) + } + if m.Owner() != nil { + dep.Owner = depFromMod(m.Owner()) + } + return dep + } + for _, m := range d.Paths.AllModules() { + dependencies = append(dependencies, depFromMod(m)) + } + + h.hugoInfo = hugo.NewInfo(h.Configs.GetFirstLanguageConfig(), dependencies) + + var prototype *deps.Deps + for i, s := range sites { + s.h = h + // The template store needs to be initialized after the h container is set on s. + if i == 0 { + templateStore, err := tplimpl.NewStore( + tplimpl.StoreOptions{ + Fs: s.BaseFs.Layouts.Fs, + Log: s.Log, + DefaultContentLanguage: s.Conf.DefaultContentLanguage(), + Watching: s.Conf.Watching(), + PathParser: s.Conf.PathParser(), + Metrics: d.Metrics, + OutputFormats: s.conf.OutputFormats.Config, + MediaTypes: s.conf.MediaTypes.Config, + DefaultOutputFormat: s.conf.DefaultOutputFormat, + TaxonomySingularPlural: s.conf.Taxonomies, + }, tplimpl.SiteOptions{ + Site: s, + TemplateFuncs: tplimplinit.CreateFuncMap(s.Deps), + }) + if err != nil { + return nil, err + } + s.Deps.TemplateStore = templateStore + } else { + s.Deps.TemplateStore = prototype.TemplateStore.WithSiteOpts( + tplimpl.SiteOptions{ + Site: s, + TemplateFuncs: tplimplinit.CreateFuncMap(s.Deps), + }) + } + if err := s.Deps.Compile(prototype); err != nil { + return nil, err + } + if i == 0 { + prototype = s.Deps + } + } + + h.fatalErrorHandler = &fatalErrorHandler{ + h: h, + donec: make(chan bool), + } + + h.init.data.Add(func(context.Context) (any, error) { + err := h.loadData() + if err != nil { + return nil, fmt.Errorf("failed to load data: %w", err) + } + return nil, nil + }) + + h.init.gitInfo.Add(func(context.Context) (any, error) { + err := h.loadGitInfo() + if err != nil { + return nil, fmt.Errorf("failed to load Git info: %w", err) + } + return nil, nil + }) + + return h, nil +} + +// Returns the server port. +func (s *Site) ServerPort() int { + return s.conf.C.BaseURL.Port() +} + +// Returns the configured title for this Site. +func (s *Site) Title() string { + return s.conf.Title +} + +func (s *Site) Copyright() string { + return s.conf.Copyright +} + +func (s *Site) Config() page.SiteConfig { + return page.SiteConfig{ + Privacy: s.conf.Privacy, + Services: s.conf.Services, + } +} + +func (s *Site) LanguageCode() string { + return s.Language().LanguageCode() +} + +// Returns all Sites for all languages. +func (s *Site) Sites() page.Sites { + sites := make(page.Sites, len(s.h.Sites)) + for i, s := range s.h.Sites { + sites[i] = s.Site() + } + return sites +} + +// Returns Site currently rendering. +func (s *Site) Current() page.Site { + return s.h.currentSite +} + +// MainSections returns the list of main sections. +func (s *Site) MainSections() []string { + s.CheckReady() + return s.conf.C.MainSections +} + +// Returns a struct with some information about the build. +func (s *Site) Hugo() hugo.HugoInfo { + if s.h == nil { + panic("site: hugo: h not initialized") + } + if s.h.hugoInfo.Environment == "" { + panic("site: hugo: hugoInfo not initialized") + } + return s.h.hugoInfo +} + +// Returns the BaseURL for this Site. +func (s *Site) BaseURL() string { + return s.conf.C.BaseURL.WithPath +} + +// Deprecated: Use .Site.Lastmod instead. +func (s *Site) LastChange() time.Time { + s.CheckReady() + hugo.Deprecate(".Site.LastChange", "Use .Site.Lastmod instead.", "v0.123.0") + return s.lastmod +} + +// Returns the last modification date of the content. +func (s *Site) Lastmod() time.Time { + return s.lastmod +} + +// Returns the Params configured for this site. +func (s *Site) Params() maps.Params { + return s.conf.Params +} + +// Deprecated: Use taxonomies instead. +func (s *Site) Author() map[string]any { + if len(s.conf.Author) != 0 { + hugo.Deprecate(".Site.Author", "Implement taxonomy 'author' or use .Site.Params.Author instead.", "v0.124.0") + } + return s.conf.Author +} + +// Deprecated: Use taxonomies instead. +func (s *Site) Authors() page.AuthorList { + hugo.Deprecate(".Site.Authors", "Implement taxonomy 'authors' or use .Site.Params.Author instead.", "v0.124.0") + return page.AuthorList{} +} + +// Deprecated: Use .Site.Params instead. +func (s *Site) Social() map[string]string { + hugo.Deprecate(".Site.Social", "Implement taxonomy 'social' or use .Site.Params.Social instead.", "v0.124.0") + return s.conf.Social +} + +func (s *Site) Param(key any) (any, error) { + return resource.Param(s, nil, key) +} + +// Returns a map of all the data inside /data. +func (s *Site) Data() map[string]any { + return s.s.h.Data() +} + +func (s *Site) BuildDrafts() bool { + return s.conf.BuildDrafts +} + +// Deprecated: Use hugo.IsMultilingual instead. +func (s *Site) IsMultiLingual() bool { + hugo.Deprecate(".Site.IsMultiLingual", "Use hugo.IsMultilingual instead.", "v0.124.0") + return s.h.isMultilingual() +} + +func (s *Site) LanguagePrefix() string { + prefix := s.GetLanguagePrefix() + if prefix == "" { + return "" + } + return "/" + prefix +} + +func (s *Site) Site() page.Site { + return page.WrapSite(s) +} + +func (s *Site) ForEeachIdentityByName(name string, f func(identity.Identity) bool) { + if id, found := siteidentities.FromString(name); found { + if f(id) { + return + } + } +} + +// Pages returns all pages. +// This is for the current language only. +func (s *Site) Pages() page.Pages { + s.CheckReady() + return s.pageMap.getPagesInSection( + pageMapQueryPagesInSection{ + pageMapQueryPagesBelowPath: pageMapQueryPagesBelowPath{ + Path: "", + KeyPart: "global", + Include: pagePredicates.ShouldListGlobal, + }, + Recursive: true, + IncludeSelf: true, + }, + ) +} + +// RegularPages returns all the regular pages. +// This is for the current language only. +func (s *Site) RegularPages() page.Pages { + s.CheckReady() + return s.pageMap.getPagesInSection( + pageMapQueryPagesInSection{ + pageMapQueryPagesBelowPath: pageMapQueryPagesBelowPath{ + Path: "", + KeyPart: "global", + Include: pagePredicates.ShouldListGlobal.And(pagePredicates.KindPage), + }, + Recursive: true, + }, + ) +} + +// AllPages returns all pages for all sites. +func (s *Site) AllPages() page.Pages { + s.CheckReady() + return s.h.Pages() +} + +// AllRegularPages returns all regular pages for all sites. +func (s *Site) AllRegularPages() page.Pages { + s.CheckReady() + return s.h.RegularPages() +} + +func (s *Site) Store() *maps.Scratch { + return s.store +} + +func (s *Site) CheckReady() { + if s.state != siteStateReady { + panic("this method cannot be called before the site is fully initialized") + } +} + +func (s *Site) Taxonomies() page.TaxonomyList { + s.CheckReady() + s.init.taxonomies.Do(context.Background()) + return s.taxonomies +} + +type ( + taxonomiesConfig map[string]string + taxonomiesConfigValues struct { + views []viewName + viewsByTreeKey map[string]viewName + } +) + +func (t taxonomiesConfig) Values() taxonomiesConfigValues { + var views []viewName + for k, v := range t { + views = append(views, viewName{singular: k, plural: v, pluralTreeKey: cleanTreeKey(v)}) + } + sort.Slice(views, func(i, j int) bool { + return views[i].plural < views[j].plural + }) + + viewsByTreeKey := make(map[string]viewName) + for _, v := range views { + viewsByTreeKey[v.pluralTreeKey] = v + } + + return taxonomiesConfigValues{ + views: views, + viewsByTreeKey: viewsByTreeKey, + } +} + +// Lazily loaded site dependencies. +type siteInit struct { + prevNext *lazy.Init + prevNextInSection *lazy.Init + menus *lazy.Init + taxonomies *lazy.Init +} + +func (init *siteInit) Reset() { + init.prevNext.Reset() + init.prevNextInSection.Reset() + init.menus.Reset() + init.taxonomies.Reset() +} + +func (s *Site) prepareInits() { + s.init = &siteInit{} + + var init lazy.Init + + s.init.prevNext = init.Branch(func(context.Context) (any, error) { + regularPages := s.RegularPages() + if s.conf.Page.NextPrevSortOrder == "asc" { + regularPages = regularPages.Reverse() + } + for i, p := range regularPages { + np, ok := p.(nextPrevProvider) + if !ok { + continue + } + + pos := np.getNextPrev() + if pos == nil { + continue + } + + pos.nextPage = nil + pos.prevPage = nil + + if i > 0 { + pos.nextPage = regularPages[i-1] + } + + if i < len(regularPages)-1 { + pos.prevPage = regularPages[i+1] + } + } + return nil, nil + }) + + s.init.prevNextInSection = init.Branch(func(context.Context) (any, error) { + setNextPrev := func(pas page.Pages) { + for i, p := range pas { + np, ok := p.(nextPrevInSectionProvider) + if !ok { + continue + } + + pos := np.getNextPrevInSection() + if pos == nil { + continue + } + + pos.nextPage = nil + pos.prevPage = nil + + if i > 0 { + pos.nextPage = pas[i-1] + } + + if i < len(pas)-1 { + pos.prevPage = pas[i+1] + } + } + } + + sections := s.pageMap.getPagesInSection( + pageMapQueryPagesInSection{ + pageMapQueryPagesBelowPath: pageMapQueryPagesBelowPath{ + Path: "", + KeyPart: "sectionorhome", + Include: pagePredicates.KindSection.Or(pagePredicates.KindHome), + }, + IncludeSelf: true, + Recursive: true, + }, + ) + + for _, section := range sections { + ps := section.RegularPages() + if s.conf.Page.NextPrevInSectionSortOrder == "asc" { + ps = ps.Reverse() + } + setNextPrev(ps) + } + + return nil, nil + }) + + s.init.menus = init.Branch(func(context.Context) (any, error) { + err := s.assembleMenus() + return nil, err + }) + + s.init.taxonomies = init.Branch(func(ctx context.Context) (any, error) { + if err := s.pageMap.CreateSiteTaxonomies(ctx); err != nil { + return nil, err + } + return s.taxonomies, nil + }) +} + +func (s *Site) Menus() navigation.Menus { + s.CheckReady() + s.init.menus.Do(context.Background()) + return s.menus } func (s *Site) initRenderFormats() { formatSet := make(map[string]bool) formats := output.Formats{} - for _, p := range s.Pages { - for _, f := range p.outputFormats { - if !formatSet[f.Name] { - formats = append(formats, f) - formatSet[f.Name] = true + + w := &doctree.NodeShiftTreeWalker[contentNodeI]{ + Tree: s.pageMap.treePages, + Handle: func(key string, n contentNodeI, match doctree.DimensionFlag) (bool, error) { + if p, ok := n.(*pageState); ok { + for _, f := range p.m.pageConfig.ConfiguredOutputFormats { + if !formatSet[f.Name] { + formats = append(formats, f) + formatSet[f.Name] = true + } + } + } + return false, nil + }, + } + + if err := w.Walk(context.TODO()); err != nil { + panic(err) + } + + // Add the per kind configured output formats + for _, kind := range kinds.AllKindsInPages { + if siteFormats, found := s.conf.C.KindOutputFormats[kind]; found { + for _, f := range siteFormats { + if !formatSet[f.Name] { + formats = append(formats, f) + formatSet[f.Name] = true + } } } } @@ -165,333 +836,82 @@ func (s *Site) initRenderFormats() { s.renderFormats = formats } -func (s *Site) isEnabled(kind string) bool { - if kind == kindUnknown { - panic("Unknown kind") - } - return !s.disabledKinds[kind] +func (s *Site) GetInternalRelatedDocsHandler() *page.RelatedDocsHandler { + return s.relatedDocsHandler } -// reset returns a new Site prepared for rebuild. -func (s *Site) reset() *Site { - return &Site{Deps: s.Deps, - layoutHandler: output.NewLayoutHandler(s.PathSpec.ThemeSet()), - disabledKinds: s.disabledKinds, - titleFunc: s.titleFunc, - relatedDocsHandler: newSearchIndexHandler(s.relatedDocsHandler.cfg), - outputFormats: s.outputFormats, - outputFormatsConfig: s.outputFormatsConfig, - frontmatterHandler: s.frontmatterHandler, - mediaTypesConfig: s.mediaTypesConfig, - resourceSpec: s.resourceSpec, - Language: s.Language, - owner: s.owner, - PageCollections: newPageCollections()} +func (s *Site) Language() *langs.Language { + return s.language } -// newSite creates a new site with the given configuration. -func newSite(cfg deps.DepsCfg) (*Site, error) { - c := newPageCollections() +func (s *Site) Languages() langs.Languages { + return s.h.Configs.Languages +} - if cfg.Language == nil { - cfg.Language = helpers.NewDefaultLanguage(cfg.Cfg) +type siteRefLinker struct { + s *Site + + errorLogger logg.LevelLogger + notFoundURL string +} + +func newSiteRefLinker(s *Site) (siteRefLinker, error) { + logger := s.Log.Error() + + notFoundURL := s.conf.RefLinksNotFoundURL + errLevel := s.conf.RefLinksErrorLevel + if strings.EqualFold(errLevel, "warning") { + logger = s.Log.Warn() } + return siteRefLinker{s: s, errorLogger: logger, notFoundURL: notFoundURL}, nil +} - disabledKinds := make(map[string]bool) - for _, disabled := range cast.ToStringSlice(cfg.Language.Get("disableKinds")) { - disabledKinds[disabled] = true - } - - var ( - mediaTypesConfig []map[string]interface{} - outputFormatsConfig []map[string]interface{} - - siteOutputFormatsConfig output.Formats - siteMediaTypesConfig media.Types - err error - ) - - // Add language last, if set, so it gets precedence. - for _, cfg := range []config.Provider{cfg.Cfg, cfg.Language} { - if cfg.IsSet("mediaTypes") { - mediaTypesConfig = append(mediaTypesConfig, cfg.GetStringMap("mediaTypes")) - } - if cfg.IsSet("outputFormats") { - outputFormatsConfig = append(outputFormatsConfig, cfg.GetStringMap("outputFormats")) - } - } - - siteMediaTypesConfig, err = media.DecodeTypes(mediaTypesConfig...) - if err != nil { - return nil, err - } - - siteOutputFormatsConfig, err = output.DecodeFormats(siteMediaTypesConfig, outputFormatsConfig...) - if err != nil { - return nil, err - } - - outputFormats, err := createSiteOutputFormats(siteOutputFormatsConfig, cfg.Language) - if err != nil { - return nil, err - } - - var relatedContentConfig related.Config - - if cfg.Language.IsSet("related") { - relatedContentConfig, err = related.DecodeConfig(cfg.Language.Get("related")) - if err != nil { - return nil, err - } +func (s siteRefLinker) logNotFound(ref, what string, p page.Page, position text.Position) { + if position.IsValid() { + s.errorLogger.Logf("[%s] REF_NOT_FOUND: Ref %q: %s: %s", s.s.Lang(), ref, position.String(), what) + } else if p == nil { + s.errorLogger.Logf("[%s] REF_NOT_FOUND: Ref %q: %s", s.s.Lang(), ref, what) } else { - relatedContentConfig = related.DefaultConfig - taxonomies := cfg.Language.GetStringMapString("taxonomies") - if _, found := taxonomies["tag"]; found { - relatedContentConfig.Add(related.IndexConfig{Name: "tags", Weight: 80}) - } - } - - titleFunc := helpers.GetTitleFunc(cfg.Language.GetString("titleCaseStyle")) - - frontMatterHandler, err := pagemeta.NewFrontmatterHandler(cfg.Logger, cfg.Cfg) - if err != nil { - return nil, err - } - - s := &Site{ - PageCollections: c, - layoutHandler: output.NewLayoutHandler(cfg.Cfg.GetString("themesDir") != ""), - Language: cfg.Language, - disabledKinds: disabledKinds, - titleFunc: titleFunc, - relatedDocsHandler: newSearchIndexHandler(relatedContentConfig), - outputFormats: outputFormats, - outputFormatsConfig: siteOutputFormatsConfig, - mediaTypesConfig: siteMediaTypesConfig, - frontmatterHandler: frontMatterHandler, - } - - s.Info = newSiteInfo(siteBuilderCfg{s: s, pageCollections: c, language: s.Language}) - - return s, nil - -} - -// NewSite creates a new site with the given dependency configuration. -// The site will have a template system loaded and ready to use. -// Note: This is mainly used in single site tests. -func NewSite(cfg deps.DepsCfg) (*Site, error) { - s, err := newSite(cfg) - if err != nil { - return nil, err - } - - if err = applyDepsIfNeeded(cfg, s); err != nil { - return nil, err - } - - return s, nil -} - -// NewSiteDefaultLang creates a new site in the default language. -// The site will have a template system loaded and ready to use. -// Note: This is mainly used in single site tests. -// TODO(bep) test refactor -- remove -func NewSiteDefaultLang(withTemplate ...func(templ tpl.TemplateHandler) error) (*Site, error) { - v := viper.New() - if err := loadDefaultSettingsFor(v); err != nil { - return nil, err - } - return newSiteForLang(helpers.NewDefaultLanguage(v), withTemplate...) -} - -// NewEnglishSite creates a new site in English language. -// The site will have a template system loaded and ready to use. -// Note: This is mainly used in single site tests. -// TODO(bep) test refactor -- remove -func NewEnglishSite(withTemplate ...func(templ tpl.TemplateHandler) error) (*Site, error) { - v := viper.New() - if err := loadDefaultSettingsFor(v); err != nil { - return nil, err - } - return newSiteForLang(helpers.NewLanguage("en", v), withTemplate...) -} - -// newSiteForLang creates a new site in the given language. -func newSiteForLang(lang *helpers.Language, withTemplate ...func(templ tpl.TemplateHandler) error) (*Site, error) { - withTemplates := func(templ tpl.TemplateHandler) error { - for _, wt := range withTemplate { - if err := wt(templ); err != nil { - return err - } - } - return nil - } - - cfg := deps.DepsCfg{WithTemplate: withTemplates, Language: lang, Cfg: lang} - - return NewSiteForCfg(cfg) - -} - -// NewSiteForCfg creates a new site for the given configuration. -// The site will have a template system loaded and ready to use. -// Note: This is mainly used in single site tests. -func NewSiteForCfg(cfg deps.DepsCfg) (*Site, error) { - s, err := newSite(cfg) - - if err != nil { - return nil, err - } - - if err := applyDepsIfNeeded(cfg, s); err != nil { - return nil, err - } - return s, nil -} - -type SiteInfos []*SiteInfo - -// First is a convenience method to get the first Site, i.e. the main language. -func (s SiteInfos) First() *SiteInfo { - if len(s) == 0 { - return nil - } - return s[0] -} - -type SiteInfo struct { - Taxonomies TaxonomyList - Authors AuthorList - Social SiteSocial - *PageCollections - Menus *Menus - Hugo *HugoInfo - Title string - RSSLink string - Author map[string]interface{} - LanguageCode string - DisqusShortname string - GoogleAnalytics string - Copyright string - LastChange time.Time - Permalinks PermalinkOverrides - Params map[string]interface{} - BuildDrafts bool - canonifyURLs bool - relativeURLs bool - uglyURLs func(p *Page) bool - preserveTaxonomyNames bool - Data *map[string]interface{} - - owner *HugoSites - s *Site - multilingual *Multilingual - Language *helpers.Language - LanguagePrefix string - Languages helpers.Languages - defaultContentLanguageInSubdir bool - sectionPagesMenu string -} - -func (s *SiteInfo) String() string { - return fmt.Sprintf("Site(%q)", s.Title) -} - -func (s *SiteInfo) BaseURL() template.URL { - return template.URL(s.s.PathSpec.BaseURL.String()) -} - -// ServerPort returns the port part of the BaseURL, 0 if none found. -func (s *SiteInfo) ServerPort() int { - ps := s.s.PathSpec.BaseURL.URL().Port() - if ps == "" { - return 0 - } - p, err := strconv.Atoi(ps) - if err != nil { - return 0 - } - return p -} - -// Used in tests. - -type siteBuilderCfg struct { - language *helpers.Language - s *Site - pageCollections *PageCollections -} - -// TODO(bep) get rid of this -func newSiteInfo(cfg siteBuilderCfg) SiteInfo { - return SiteInfo{ - s: cfg.s, - multilingual: newMultiLingualForLanguage(cfg.language), - PageCollections: cfg.pageCollections, - Params: make(map[string]interface{}), - uglyURLs: func(p *Page) bool { - return false - }, + s.errorLogger.Logf("[%s] REF_NOT_FOUND: Ref %q from page %q: %s", s.s.Lang(), ref, p.Path(), what) } } -// SiteSocial is a place to put social details on a site level. These are the -// standard keys that themes will expect to have available, but can be -// expanded to any others on a per site basis -// github -// facebook -// facebook_admin -// twitter -// twitter_domain -// googleplus -// pinterest -// instagram -// youtube -// linkedin -type SiteSocial map[string]string - -// Param is a convenience method to do lookups in SiteInfo's Params map. -// -// This method is also implemented on Page and Node. -func (s *SiteInfo) Param(key interface{}) (interface{}, error) { - keyStr, err := cast.ToStringE(key) - if err != nil { - return nil, err - } - keyStr = strings.ToLower(keyStr) - return s.Params[keyStr], nil -} - -func (s *SiteInfo) IsMultiLingual() bool { - return len(s.Languages) > 1 -} - -func (s *SiteInfo) IsServer() bool { - return s.owner.running -} - -func (s *SiteInfo) refLink(ref string, page *Page, relative bool, outputFormat string) (string, error) { - var refURL *url.URL - var err error - - ref = filepath.ToSlash(ref) - ref = strings.TrimPrefix(ref, "/") - - refURL, err = url.Parse(ref) - +func (s *siteRefLinker) refLink(ref string, source any, relative bool, outputFormat string) (string, error) { + p, err := unwrapPage(source) if err != nil { return "", err } - var target *Page + var refURL *url.URL + + ref = filepath.ToSlash(ref) + + refURL, err = url.Parse(ref) + if err != nil { + return s.notFoundURL, err + } + + var target page.Page var link string if refURL.Path != "" { - target := s.getPage(KindPage, refURL.Path) + var err error + target, err = s.s.getPageRef(p, refURL.Path) + var pos text.Position + if err != nil || target == nil { + if p, ok := source.(text.Positioner); ok { + pos = p.Position() + } + } + + if err != nil { + s.logNotFound(refURL.Path, err.Error(), p, pos) + return s.notFoundURL, nil + } if target == nil { - return "", fmt.Errorf("No page found with path or logical name \"%s\".\n", refURL.Path) + s.logNotFound(refURL.Path, "page not found", p, pos) + return s.notFoundURL, nil } var permalinker Permalinker = target @@ -500,7 +920,8 @@ func (s *SiteInfo) refLink(ref string, page *Page, relative bool, outputFormat s o := target.OutputFormats().Get(outputFormat) if o == nil { - return "", fmt.Errorf("Output format %q not found for page %q", outputFormat, refURL.Path) + s.logNotFound(refURL.Path, fmt.Sprintf("output format %q", outputFormat), p, pos) + return s.notFoundURL, nil } permalinker = o } @@ -513,72 +934,94 @@ func (s *SiteInfo) refLink(ref string, page *Page, relative bool, outputFormat s } if refURL.Fragment != "" { + _ = target link = link + "#" + refURL.Fragment - if refURL.Path != "" && target != nil && !target.getRenderingConfig().PlainIDAnchors { - link = link + ":" + target.UniqueID() - } else if page != nil && !page.getRenderingConfig().PlainIDAnchors { - link = link + ":" + page.UniqueID() + if pctx, ok := target.(pageContext); ok { + if refURL.Path != "" { + if di, ok := pctx.getContentConverter().(converter.DocumentInfo); ok { + link = link + di.AnchorSuffix() + } + } + } else if pctx, ok := p.(pageContext); ok { + if di, ok := pctx.getContentConverter().(converter.DocumentInfo); ok { + link = link + di.AnchorSuffix() + } } + } return link, nil } -// Ref will give an absolute URL to ref in the given Page. -func (s *SiteInfo) Ref(ref string, page *Page, options ...string) (string, error) { - outputFormat := "" - if len(options) > 0 { - outputFormat = options[0] +func (s *Site) watching() bool { + return s.h != nil && s.h.Configs.Base.Internal.Watch +} + +type WhatChanged struct { + mu sync.Mutex + + needsPagesAssembly bool + + ids map[identity.Identity]bool +} + +func (w *WhatChanged) init() { + if w.ids == nil { + w.ids = make(map[identity.Identity]bool) } - - return s.refLink(ref, page, false, outputFormat) } -// RelRef will give an relative URL to ref in the given Page. -func (s *SiteInfo) RelRef(ref string, page *Page, options ...string) (string, error) { - outputFormat := "" - if len(options) > 0 { - outputFormat = options[0] +func (w *WhatChanged) Add(ids ...identity.Identity) { + w.mu.Lock() + defer w.mu.Unlock() + + w.init() + + for _, id := range ids { + w.ids[id] = true } - - return s.refLink(ref, page, true, outputFormat) } -func (s *Site) running() bool { - return s.owner.running +func (w *WhatChanged) Clear() { + w.mu.Lock() + defer w.mu.Unlock() + w.clear() } -func init() { - defaultTimer = nitro.Initalize() +func (w *WhatChanged) clear() { + w.ids = nil } -func (s *Site) timerStep(step string) { - if s.timer == nil { - s.timer = defaultTimer +func (w *WhatChanged) Changes() []identity.Identity { + if w == nil || w.ids == nil { + return nil } - s.timer.Step(step) + return xmaps.Keys(w.ids) } -type whatChanged struct { - source bool - other bool - files map[string]bool +func (w *WhatChanged) Drain() []identity.Identity { + w.mu.Lock() + defer w.mu.Unlock() + ids := w.Changes() + w.clear() + return ids } // RegisterMediaTypes will register the Site's media types in the mime // package, so it will behave correctly with Hugo's built-in server. func (s *Site) RegisterMediaTypes() { - for _, mt := range s.mediaTypesConfig { - // The last one will win if there are any duplicates. - _ = mime.AddExtensionType("."+mt.Suffix, mt.Type()+"; charset=utf-8") + for _, mt := range s.conf.MediaTypes.Config { + for _, suffix := range mt.Suffixes() { + _ = mime.AddExtensionType(mt.Delimiter+suffix, mt.Type) + } } } -func (s *Site) filterFileEvents(events []fsnotify.Event) []fsnotify.Event { - var filtered []fsnotify.Event +func (h *HugoSites) fileEventsFilter(events []fsnotify.Event) []fsnotify.Event { seen := make(map[fsnotify.Event]bool) + n := 0 for _, ev := range events { // Avoid processing the same event twice. if seen[ev] { @@ -586,899 +1029,303 @@ func (s *Site) filterFileEvents(events []fsnotify.Event) []fsnotify.Event { } seen[ev] = true - if s.SourceSpec.IgnoreFile(ev.Name) { + if h.SourceSpec.IgnoreFile(ev.Name) { continue } - // Throw away any directories - isRegular, err := s.SourceSpec.IsRegularSourceFile(ev.Name) - if err != nil && os.IsNotExist(err) && (ev.Op&fsnotify.Remove == fsnotify.Remove || ev.Op&fsnotify.Rename == fsnotify.Rename) { - // Force keep of event - isRegular = true - } - if !isRegular { - continue + if runtime.GOOS == "darwin" { // When a file system is HFS+, its filepath is in NFD form. + ev.Name = norm.NFC.String(ev.Name) } - filtered = append(filtered, ev) + events[n] = ev + n++ + } + events = events[:n] + + eventOrdinal := func(e fsnotify.Event) int { + // Pull the structural changes to the top. + if e.Op.Has(fsnotify.Create) { + return 1 + } + if e.Op.Has(fsnotify.Remove) { + return 2 + } + if e.Op.Has(fsnotify.Rename) { + return 3 + } + if e.Op.Has(fsnotify.Write) { + return 4 + } + return 5 } - return filtered + sort.Slice(events, func(i, j int) bool { + // First sort by event type. + if eventOrdinal(events[i]) != eventOrdinal(events[j]) { + return eventOrdinal(events[i]) < eventOrdinal(events[j]) + } + // Then sort by name. + return events[i].Name < events[j].Name + }) + + return events } -func (s *Site) translateFileEvents(events []fsnotify.Event) []fsnotify.Event { - var filtered []fsnotify.Event - - eventMap := make(map[string][]fsnotify.Event) - - // We often get a Remove etc. followed by a Create, a Create followed by a Write. - // Remove the superflous events to mage the update logic simpler. - for _, ev := range events { - eventMap[ev.Name] = append(eventMap[ev.Name], ev) - } - - for _, ev := range events { - mapped := eventMap[ev.Name] - - // Keep one - found := false - var kept fsnotify.Event - for i, ev2 := range mapped { - if i == 0 { - kept = ev2 - } - - if ev2.Op&fsnotify.Write == fsnotify.Write { - kept = ev2 - found = true - } - - if !found && ev2.Op&fsnotify.Create == fsnotify.Create { - kept = ev2 - } - } - - filtered = append(filtered, kept) - } - - return filtered +type fileEventInfo struct { + fsnotify.Event + fi os.FileInfo + added bool + removed bool + isChangedDir bool } -// reBuild partially rebuilds a site given the filesystem events. -// It returns whetever the content source was changed. -// TODO(bep) clean up/rewrite this method. -func (s *Site) processPartial(events []fsnotify.Event) (whatChanged, error) { - - events = s.filterFileEvents(events) - events = s.translateFileEvents(events) - - s.Log.DEBUG.Printf("Rebuild for events %q", events) - - h := s.owner - - s.timerStep("initialize rebuild") - - // First we need to determine what changed - - var ( - sourceChanged = []fsnotify.Event{} - sourceReallyChanged = []fsnotify.Event{} - contentFilesChanged []string - tmplChanged = []fsnotify.Event{} - dataChanged = []fsnotify.Event{} - i18nChanged = []fsnotify.Event{} - shortcodesChanged = make(map[string]bool) - sourceFilesChanged = make(map[string]bool) - - // prevent spamming the log on changes - logger = helpers.NewDistinctFeedbackLogger() - ) - +func (h *HugoSites) fileEventsApplyInfo(events []fsnotify.Event) []fileEventInfo { + var infos []fileEventInfo for _, ev := range events { - if s.isContentDirEvent(ev) { - logger.Println("Source changed", ev) - sourceChanged = append(sourceChanged, ev) - } - if s.isLayoutDirEvent(ev) { - logger.Println("Template changed", ev) - tmplChanged = append(tmplChanged, ev) - - if strings.Contains(ev.Name, "shortcodes") { - clearIsInnerShortcodeCache() - shortcode := filepath.Base(ev.Name) - shortcode = strings.TrimSuffix(shortcode, filepath.Ext(shortcode)) - shortcodesChanged[shortcode] = true - } - } - if s.isDataDirEvent(ev) { - logger.Println("Data changed", ev) - dataChanged = append(dataChanged, ev) - } - if s.isI18nEvent(ev) { - logger.Println("i18n changed", ev) - i18nChanged = append(dataChanged, ev) - } - } - - if len(tmplChanged) > 0 || len(i18nChanged) > 0 { - sites := s.owner.Sites - first := sites[0] - - // TOD(bep) globals clean - if err := first.Deps.LoadResources(); err != nil { - s.Log.ERROR.Println(err) - } - - s.TemplateHandler().PrintErrors() - - for i := 1; i < len(sites); i++ { - site := sites[i] - var err error - site.Deps, err = first.Deps.ForLanguage(site.Language) - if err != nil { - return whatChanged{}, err - } - } - - s.timerStep("template prep") - } - - if len(dataChanged) > 0 { - if err := s.readDataFromSourceFS(); err != nil { - s.Log.ERROR.Println(err) - } - } - - for _, ev := range sourceChanged { removed := false + added := false if ev.Op&fsnotify.Remove == fsnotify.Remove { removed = true } + fi, statErr := h.Fs.Source.Stat(ev.Name) + // Some editors (Vim) sometimes issue only a Rename operation when writing an existing file // Sometimes a rename operation means that file has been renamed other times it means - // it's been updated - if ev.Op&fsnotify.Rename == fsnotify.Rename { + // it's been updated. + if ev.Op.Has(fsnotify.Rename) { // If the file is still on disk, it's only been updated, if it's not, it's been moved - if ex, err := afero.Exists(s.Fs.Source, ev.Name); !ex || err != nil { + if statErr != nil { removed = true } } - if removed && isContentFile(ev.Name) { - h.removePageByFilename(ev.Name) + if ev.Op.Has(fsnotify.Create) { + added = true } - sourceReallyChanged = append(sourceReallyChanged, ev) - sourceFilesChanged[ev.Name] = true - } - - for shortcode := range shortcodesChanged { - // There are certain scenarios that, when a shortcode changes, - // it isn't sufficient to just rerender the already parsed shortcode. - // One example is if the user adds a new shortcode to the content file first, - // and then creates the shortcode on the file system. - // To handle these scenarios, we must do a full reprocessing of the - // pages that keeps a reference to the changed shortcode. - pagesWithShortcode := h.findPagesByShortcode(shortcode) - for _, p := range pagesWithShortcode { - contentFilesChanged = append(contentFilesChanged, p.File.Filename()) - } - } - - if len(sourceReallyChanged) > 0 || len(contentFilesChanged) > 0 { - var filenamesChanged []string - for _, e := range sourceReallyChanged { - filenamesChanged = append(filenamesChanged, e.Name) - } - if len(contentFilesChanged) > 0 { - filenamesChanged = append(filenamesChanged, contentFilesChanged...) - } - - filenamesChanged = helpers.UniqueStrings(filenamesChanged) - - if err := s.readAndProcessContent(filenamesChanged...); err != nil { - return whatChanged{}, err - } - } - - changed := whatChanged{ - source: len(sourceChanged) > 0, - other: len(tmplChanged) > 0 || len(i18nChanged) > 0 || len(dataChanged) > 0, - files: sourceFilesChanged, - } - - return changed, nil - -} - -func (s *Site) loadData(sourceDirs []string) (err error) { - s.Log.DEBUG.Printf("Load Data from %d source(s)", len(sourceDirs)) - s.Data = make(map[string]interface{}) - for _, sourceDir := range sourceDirs { - fs := s.SourceSpec.NewFilesystem(sourceDir) - for _, r := range fs.Files() { - if err := s.handleDataFile(r); err != nil { - return err - } - } - } - - return -} - -func (s *Site) handleDataFile(r source.ReadableFile) error { - var current map[string]interface{} - - f, err := r.Open() - if err != nil { - return fmt.Errorf("Failed to open data file %q: %s", r.LogicalName(), err) - } - defer f.Close() - - // Crawl in data tree to insert data - current = s.Data - for _, key := range strings.Split(r.Dir(), helpers.FilePathSeparator) { - if key != "" { - if _, ok := current[key]; !ok { - current[key] = make(map[string]interface{}) - } - current = current[key].(map[string]interface{}) - } - } - - data, err := s.readData(r) - if err != nil { - s.Log.ERROR.Printf("Failed to read data from %s: %s", filepath.Join(r.Path(), r.LogicalName()), err) - return nil - } - - if data == nil { - return nil - } - - // filepath.Walk walks the files in lexical order, '/' comes before '.' - // this warning could happen if - // 1. A theme uses the same key; the main data folder wins - // 2. A sub folder uses the same key: the sub folder wins - higherPrecedentData := current[r.BaseFileName()] - - switch data.(type) { - case nil: - // hear the crickets? - - case map[string]interface{}: - - switch higherPrecedentData.(type) { - case nil: - current[r.BaseFileName()] = data - case map[string]interface{}: - // merge maps: insert entries from data for keys that - // don't already exist in higherPrecedentData - higherPrecedentMap := higherPrecedentData.(map[string]interface{}) - for key, value := range data.(map[string]interface{}) { - if _, exists := higherPrecedentMap[key]; exists { - s.Log.WARN.Printf("Data for key '%s' in path '%s' is overridden higher precedence data already in the data tree", key, r.Path()) - } else { - higherPrecedentMap[key] = value - } - } - default: - // can't merge: higherPrecedentData is not a map - s.Log.WARN.Printf("The %T data from '%s' overridden by "+ - "higher precedence %T data already in the data tree", data, r.Path(), higherPrecedentData) - } - - case []interface{}: - if higherPrecedentData == nil { - current[r.BaseFileName()] = data - } else { - // we don't merge array data - s.Log.WARN.Printf("The %T data from '%s' overridden by "+ - "higher precedence %T data already in the data tree", data, r.Path(), higherPrecedentData) - } - - default: - s.Log.ERROR.Printf("unexpected data type %T in file %s", data, r.LogicalName()) - } - - return nil -} - -func (s *Site) readData(f source.ReadableFile) (interface{}, error) { - file, err := f.Open() - if err != nil { - return nil, fmt.Errorf("readData: failed to open data file: %s", err) - } - defer file.Close() - content := helpers.ReaderToBytes(file) - - switch f.Extension() { - case "yaml", "yml": - return parser.HandleYAMLData(content) - case "json": - return parser.HandleJSONData(content) - case "toml": - return parser.HandleTOMLMetaData(content) - default: - return nil, fmt.Errorf("Data not supported for extension '%s'", f.Extension()) - } -} - -func (s *Site) readDataFromSourceFS() error { - var dataSourceDirs []string - - // have to be last - duplicate keys in earlier entries will win - themeDataDir, err := s.PathSpec.GetThemeDataDirPath() - if err == nil { - dataSourceDirs = []string{s.absDataDir(), themeDataDir} - } else { - dataSourceDirs = []string{s.absDataDir()} - - } - - err = s.loadData(dataSourceDirs) - s.timerStep("load data") - return err -} - -func (s *Site) process(config BuildCfg) (err error) { - if err = s.initialize(); err != nil { - return - } - s.timerStep("initialize") - - if err = s.readDataFromSourceFS(); err != nil { - return - } - - s.timerStep("load i18n") - - if err := s.readAndProcessContent(); err != nil { - return err - } - s.timerStep("read and convert pages from source") - - return err - -} - -func (s *Site) setupSitePages() { - var siteLastChange time.Time - - for i, page := range s.RegularPages { - if i < len(s.RegularPages)-1 { - page.Next = s.RegularPages[i+1] - } - - if i > 0 { - page.Prev = s.RegularPages[i-1] - } - - // Determine Site.Info.LastChange - // Note that the logic to determine which date to use for Lastmod - // is already applied, so this is *the* date to use. - // We cannot just pick the last page in the default sort, because - // that may not be ordered by date. - if page.Lastmod.After(siteLastChange) { - siteLastChange = page.Lastmod - } - } - - s.Info.LastChange = siteLastChange -} - -func (s *Site) render(config *BuildCfg, outFormatIdx int) (err error) { - - if outFormatIdx == 0 { - if err = s.preparePages(); err != nil { - return - } - s.timerStep("prepare pages") - - // Note that even if disableAliases is set, the aliases themselves are - // preserved on page. The motivation with this is to be able to generate - // 301 redirects in a .htacess file and similar using a custom output format. - if !s.Cfg.GetBool("disableAliases") { - // Aliases must be rendered before pages. - // Some sites, Hugo docs included, have faulty alias definitions that point - // to itself or another real page. These will be overwritten in the next - // step. - if err = s.renderAliases(); err != nil { - return - } - s.timerStep("render and write aliases") - } - - } - - if err = s.renderPages(config); err != nil { - return - } - - s.timerStep("render and write pages") - - // TODO(bep) render consider this, ref. render404 etc. - if outFormatIdx > 0 { - return - } - - if err = s.renderSitemap(); err != nil { - return - } - s.timerStep("render and write Sitemap") - - if err = s.renderRobotsTXT(); err != nil { - return - } - s.timerStep("render and write robots.txt") - - if err = s.render404(); err != nil { - return - } - s.timerStep("render and write 404") - - return -} - -func (s *Site) Initialise() (err error) { - return s.initialize() -} - -func (s *Site) initialize() (err error) { - defer s.initializeSiteInfo() - s.Menus = Menus{} - - if err = s.checkDirectories(); err != nil { - return err - } - - return -} - -// HomeAbsURL is a convenience method giving the absolute URL to the home page. -func (s *SiteInfo) HomeAbsURL() string { - base := "" - if s.IsMultiLingual() { - base = s.Language.Lang - } - return s.owner.AbsURL(base, false) -} - -// SitemapAbsURL is a convenience method giving the absolute URL to the sitemap. -func (s *SiteInfo) SitemapAbsURL() string { - sitemapDefault := parseSitemap(s.s.Cfg.GetStringMap("sitemap")) - p := s.HomeAbsURL() - if !strings.HasSuffix(p, "/") { - p += "/" - } - p += sitemapDefault.Filename - return p -} - -func (s *Site) initializeSiteInfo() { - var ( - lang = s.Language - languages helpers.Languages - ) - - if s.owner != nil && s.owner.multilingual != nil { - languages = s.owner.multilingual.Languages - } - - params := lang.Params() - - permalinks := make(PermalinkOverrides) - for k, v := range s.Cfg.GetStringMapString("permalinks") { - permalinks[k] = pathPattern(v) - } - - defaultContentInSubDir := s.Cfg.GetBool("defaultContentLanguageInSubdir") - defaultContentLanguage := s.Cfg.GetString("defaultContentLanguage") - - languagePrefix := "" - if s.multilingualEnabled() && (defaultContentInSubDir || lang.Lang != defaultContentLanguage) { - languagePrefix = "/" + lang.Lang - } - - var multilingual *Multilingual - if s.owner != nil { - multilingual = s.owner.multilingual - } - - var uglyURLs = func(p *Page) bool { - return false - } - - v := s.Cfg.Get("uglyURLs") - if v != nil { - switch vv := v.(type) { - case bool: - uglyURLs = func(p *Page) bool { - return vv - } - case string: - // Is what be get from CLI (--uglyURLs) - vvv := cast.ToBool(vv) - uglyURLs = func(p *Page) bool { - return vvv - } - default: - m := cast.ToStringMapBool(v) - uglyURLs = func(p *Page) bool { - return m[p.Section()] - } - } - } - - s.Info = SiteInfo{ - Title: lang.GetString("title"), - Author: lang.GetStringMap("author"), - Social: lang.GetStringMapString("social"), - LanguageCode: lang.GetString("languageCode"), - Copyright: lang.GetString("copyright"), - DisqusShortname: lang.GetString("disqusShortname"), - multilingual: multilingual, - Language: lang, - LanguagePrefix: languagePrefix, - Languages: languages, - defaultContentLanguageInSubdir: defaultContentInSubDir, - sectionPagesMenu: lang.GetString("sectionPagesMenu"), - GoogleAnalytics: lang.GetString("googleAnalytics"), - BuildDrafts: s.Cfg.GetBool("buildDrafts"), - canonifyURLs: s.Cfg.GetBool("canonifyURLs"), - relativeURLs: s.Cfg.GetBool("relativeURLs"), - uglyURLs: uglyURLs, - preserveTaxonomyNames: lang.GetBool("preserveTaxonomyNames"), - PageCollections: s.PageCollections, - Menus: &s.Menus, - Params: params, - Permalinks: permalinks, - Data: &s.Data, - owner: s.owner, - s: s, - } - - rssOutputFormat, found := s.outputFormats[KindHome].GetByName(output.RSSFormat.Name) - - if found { - s.Info.RSSLink = s.permalink(rssOutputFormat.BaseFilename()) - } -} - -func (s *Site) dataDir() string { - return s.Cfg.GetString("dataDir") -} - -func (s *Site) absDataDir() string { - return s.PathSpec.AbsPathify(s.dataDir()) -} - -func (s *Site) i18nDir() string { - return s.Cfg.GetString("i18nDir") -} - -func (s *Site) absI18nDir() string { - return s.PathSpec.AbsPathify(s.i18nDir()) -} - -func (s *Site) isI18nEvent(e fsnotify.Event) bool { - if s.getI18nDir(e.Name) != "" { - return true - } - return s.getThemeI18nDir(e.Name) != "" -} - -func (s *Site) getI18nDir(path string) string { - return s.getRealDir(s.absI18nDir(), path) -} - -func (s *Site) getThemeI18nDir(path string) string { - if !s.PathSpec.ThemeSet() { - return "" - } - return s.getRealDir(filepath.Join(s.PathSpec.GetThemeDir(), s.i18nDir()), path) -} - -func (s *Site) isDataDirEvent(e fsnotify.Event) bool { - if s.getDataDir(e.Name) != "" { - return true - } - return s.getThemeDataDir(e.Name) != "" -} - -func (s *Site) getDataDir(path string) string { - return s.getRealDir(s.absDataDir(), path) -} - -func (s *Site) getThemeDataDir(path string) string { - if !s.PathSpec.ThemeSet() { - return "" - } - return s.getRealDir(filepath.Join(s.PathSpec.GetThemeDir(), s.dataDir()), path) -} - -func (s *Site) layoutDir() string { - return s.Cfg.GetString("layoutDir") -} - -func (s *Site) isLayoutDirEvent(e fsnotify.Event) bool { - if s.getLayoutDir(e.Name) != "" { - return true - } - return s.getThemeLayoutDir(e.Name) != "" -} - -func (s *Site) getLayoutDir(path string) string { - return s.getRealDir(s.PathSpec.GetLayoutDirPath(), path) -} - -func (s *Site) getThemeLayoutDir(path string) string { - if !s.PathSpec.ThemeSet() { - return "" - } - return s.getRealDir(filepath.Join(s.PathSpec.GetThemeDir(), s.layoutDir()), path) -} - -func (s *Site) absContentDir() string { - return s.PathSpec.AbsPathify(s.PathSpec.ContentDir()) -} - -func (s *Site) isContentDirEvent(e fsnotify.Event) bool { - relDir, _ := s.PathSpec.RelContentDir(e.Name) - return relDir != e.Name -} - -func (s *Site) getContentDir(path string) string { - return s.getRealDir(s.absContentDir(), path) -} - -// getRealDir gets the base path of the given path, also handling the case where -// base is a symlinked folder. -func (s *Site) getRealDir(base, path string) string { - - if strings.HasPrefix(path, base) { - return base - } - - realDir, err := helpers.GetRealPath(s.Fs.Source, base) - - if err != nil { - if !os.IsNotExist(err) { - s.Log.ERROR.Printf("Failed to get real path for %s: %s", path, err) - } - return "" - } - - if strings.HasPrefix(path, realDir) { - return realDir - } - - return "" -} - -func (s *Site) absPublishDir() string { - return s.PathSpec.AbsPathify(s.Cfg.GetString("publishDir")) -} - -func (s *Site) checkDirectories() (err error) { - if b, _ := helpers.DirExists(s.absContentDir(), s.Fs.Source); !b { - return errors.New("No source directory found, expecting to find it at " + s.absContentDir()) - } - return -} - -type contentCaptureResultHandler struct { - defaultContentProcessor *siteContentProcessor - contentProcessors map[string]*siteContentProcessor -} - -func (c *contentCaptureResultHandler) getContentProcessor(lang string) *siteContentProcessor { - proc, found := c.contentProcessors[lang] - if found { - return proc - } - return c.defaultContentProcessor -} - -func (c *contentCaptureResultHandler) handleSingles(fis ...*fileInfo) { - for _, fi := range fis { - proc := c.getContentProcessor(fi.Lang()) - proc.processSingle(fi) - } -} -func (c *contentCaptureResultHandler) handleBundles(d *bundleDirs) { - for _, b := range d.bundles { - proc := c.getContentProcessor(b.fi.Lang()) - proc.processBundle(b) - } -} - -func (c *contentCaptureResultHandler) handleCopyFiles(files ...pathLangFile) { - for _, proc := range c.contentProcessors { - proc.processAssets(files) - } -} - -func (s *Site) readAndProcessContent(filenames ...string) error { - ctx := context.Background() - g, ctx := errgroup.WithContext(ctx) - - defaultContentLanguage := s.SourceSpec.DefaultContentLanguage - - contentProcessors := make(map[string]*siteContentProcessor) - var defaultContentProcessor *siteContentProcessor - sites := s.owner.langSite() - for k, v := range sites { - if v.Language.Disabled { - continue - } - proc := newSiteContentProcessor(ctx, len(filenames) > 0, v) - contentProcessors[k] = proc - if k == defaultContentLanguage { - defaultContentProcessor = proc - } - g.Go(func() error { - return proc.process(ctx) + isChangedDir := statErr == nil && fi.IsDir() + + infos = append(infos, fileEventInfo{ + Event: ev, + fi: fi, + added: added, + removed: removed, + isChangedDir: isChangedDir, }) } - var ( - handler captureResultHandler - bundleMap *contentChangeMap - ) + n := 0 - mainHandler := &contentCaptureResultHandler{contentProcessors: contentProcessors, defaultContentProcessor: defaultContentProcessor} - - sourceSpec := source.NewSourceSpec(s.PathSpec, s.BaseFs.ContentFs) - - if s.running() { - // Need to track changes. - bundleMap = s.owner.ContentChanges - handler = &captureResultHandlerChain{handlers: []captureBundlesHandler{mainHandler, bundleMap}} - - } else { - handler = mainHandler - } - - c := newCapturer(s.Log, sourceSpec, handler, bundleMap, filenames...) - - err1 := c.capture() - - for _, proc := range contentProcessors { - proc.closeInput() - } - - err2 := g.Wait() - - if err1 != nil { - return err1 - } - return err2 -} - -func (s *Site) buildSiteMeta() (err error) { - defer s.timerStep("build Site meta") - - if len(s.Pages) == 0 { - return - } - - s.assembleTaxonomies() - - for _, p := range s.AllPages { - // this depends on taxonomies - p.setValuesForKind(s) - } - - return -} - -func (s *Site) getMenusFromConfig() Menus { - - ret := Menus{} - - if menus := s.Language.GetStringMap("menu"); menus != nil { - for name, menu := range menus { - m, err := cast.ToSliceE(menu) - if err != nil { - s.Log.ERROR.Printf("unable to process menus in site config\n") - s.Log.ERROR.Println(err) - } else { - for _, entry := range m { - s.Log.DEBUG.Printf("found menu: %q, in site config\n", name) - - menuEntry := MenuEntry{Menu: name} - ime, err := cast.ToStringMapE(entry) - if err != nil { - s.Log.ERROR.Printf("unable to process menus in site config\n") - s.Log.ERROR.Println(err) - } - - menuEntry.marshallMap(ime) - menuEntry.URL = s.Info.createNodeMenuEntryURL(menuEntry.URL) - - if ret[name] == nil { - ret[name] = &Menu{} - } - *ret[name] = ret[name].add(&menuEntry) + for _, ev := range infos { + // Remove any directories that's also represented by a file. + keep := true + if ev.isChangedDir { + for _, ev2 := range infos { + if ev2.fi != nil && !ev2.fi.IsDir() && filepath.Dir(ev2.Name) == ev.Name { + keep = false + break } } } - return ret + if keep { + infos[n] = ev + n++ + } } - return ret + infos = infos[:n] + + return infos } -func (s *SiteInfo) createNodeMenuEntryURL(in string) string { +func (h *HugoSites) fileEventsTrim(events []fsnotify.Event) []fsnotify.Event { + seen := make(map[string]bool) + n := 0 + for _, ev := range events { + if seen[ev.Name] { + continue + } + seen[ev.Name] = true + events[n] = ev + n++ + } + return events +} +func (h *HugoSites) fileEventsContentPaths(p []pathChange) []pathChange { + var bundles []pathChange + var dirs []pathChange + var regular []pathChange + + var others []pathChange + for _, p := range p { + if p.isDir { + dirs = append(dirs, p) + } else { + others = append(others, p) + } + } + + // Remove all files below dir. + if len(dirs) > 0 { + n := 0 + for _, d := range dirs { + dir := d.p.Path() + "/" + for _, o := range others { + if !strings.HasPrefix(o.p.Path(), dir) { + others[n] = o + n++ + } + } + + } + others = others[:n] + } + + for _, p := range others { + if p.p.IsBundle() { + bundles = append(bundles, p) + } else { + regular = append(regular, p) + } + } + + // Remove any files below leaf bundles. + // Remove any files in the same folder as branch bundles. + var keepers []pathChange + + for _, o := range regular { + keep := true + for _, b := range bundles { + prefix := b.p.Base() + "/" + if b.p.IsLeafBundle() && strings.HasPrefix(o.p.Path(), prefix) { + keep = false + break + } else if b.p.IsBranchBundle() && o.p.Dir() == b.p.Dir() { + keep = false + break + } + } + + if keep { + keepers = append(keepers, o) + } + } + + keepers = append(dirs, keepers...) + keepers = append(bundles, keepers...) + + return keepers +} + +// SitemapAbsURL is a convenience method giving the absolute URL to the sitemap. +func (s *Site) SitemapAbsURL() string { + base := "" + if len(s.conf.Languages) > 1 || s.Conf.DefaultContentLanguageInSubdir() { + base = s.Language().Lang + } + p := s.AbsURL(base, false) + if !strings.HasSuffix(p, "/") { + p += "/" + } + p += s.conf.Sitemap.Filename + return p +} + +func (s *Site) createNodeMenuEntryURL(in string) string { if !strings.HasPrefix(in, "/") { return in } // make it match the nodes menuEntryURL := in - menuEntryURL = helpers.SanitizeURLKeepTrailingSlash(s.s.PathSpec.URLize(menuEntryURL)) - if !s.canonifyURLs { - menuEntryURL = helpers.AddContextRoot(s.s.PathSpec.BaseURL.String(), menuEntryURL) + menuEntryURL = s.s.PathSpec.URLize(menuEntryURL) + if !s.conf.CanonifyURLs { + menuEntryURL = paths.AddContextRoot(s.s.PathSpec.Cfg.BaseURL().String(), menuEntryURL) } return menuEntryURL } -func (s *Site) assembleMenus() { - s.Menus = Menus{} +func (s *Site) assembleMenus() error { + s.menus = make(navigation.Menus) type twoD struct { MenuName, EntryName string } - flat := map[twoD]*MenuEntry{} - children := map[twoD]Menu{} + flat := map[twoD]*navigation.MenuEntry{} + children := map[twoD]navigation.Menu{} // add menu entries from config to flat hash - menuConfig := s.getMenusFromConfig() - for name, menu := range menuConfig { - for _, me := range *menu { + for name, menu := range s.conf.Menus.Config { + for _, me := range menu { + if types.IsNil(me.Page) && me.PageRef != "" { + // Try to resolve the page. + me.Page, _ = s.getPage(nil, me.PageRef) + } + + // If page is still nill, we must make sure that we have a URL that considers baseURL etc. + if types.IsNil(me.Page) { + me.ConfiguredURL = s.createNodeMenuEntryURL(me.MenuConfig.URL) + } else { + navigation.SetPageValues(me, me.Page) + } + flat[twoD{name, me.KeyName()}] = me } } - sectionPagesMenu := s.Info.sectionPagesMenu - pages := s.Pages + sectionPagesMenu := s.conf.SectionPagesMenu if sectionPagesMenu != "" { - for _, p := range pages { - if p.Kind == KindSection { - // From Hugo 0.22 we have nested sections, but until we get a - // feel of how that would work in this setting, let us keep - // this menu for the top level only. - id := p.Section() - if _, ok := flat[twoD{sectionPagesMenu, id}]; ok { - continue - } - - me := MenuEntry{Identifier: id, - Name: p.LinkTitle(), - Weight: p.Weight, - URL: p.RelPermalink()} - flat[twoD{sectionPagesMenu, me.KeyName()}] = &me + if err := s.pageMap.forEachPage(pagePredicates.ShouldListGlobal, func(p *pageState) (bool, error) { + if p.Kind() != kinds.KindSection || !p.m.shouldBeCheckedForMenuDefinitions() { + return false, nil } + + // The section pages menus are attached to the top level section. + id := p.Section() + if id == "" { + id = "/" + } + + if _, ok := flat[twoD{sectionPagesMenu, id}]; ok { + return false, nil + } + me := navigation.MenuEntry{ + MenuConfig: navigation.MenuConfig{ + Identifier: id, + Name: p.LinkTitle(), + Weight: p.Weight(), + }, + Page: p, + } + + navigation.SetPageValues(&me, p) + flat[twoD{sectionPagesMenu, me.KeyName()}] = &me + return false, nil + }); err != nil { + return err } } // Add menu entries provided by pages - for _, p := range pages { - for name, me := range p.Menus() { + if err := s.pageMap.forEachPage(pagePredicates.ShouldListGlobal, func(p *pageState) (bool, error) { + for name, me := range p.pageMenus.menus() { if _, ok := flat[twoD{name, me.KeyName()}]; ok { - s.Log.ERROR.Printf("Two or more menu items have the same name/identifier in Menu %q: %q.\nRename or set an unique identifier.\n", name, me.KeyName()) + err := p.wrapError(fmt.Errorf("duplicate menu entry with identifier %q in menu %q", me.KeyName(), name)) + s.Log.Warnln(err) continue } flat[twoD{name, me.KeyName()}] = me } + return false, nil + }); err != nil { + return err } // Create Children Menus First for _, e := range flat { if e.Parent != "" { - children[twoD{e.Menu, e.Parent}] = children[twoD{e.Menu, e.Parent}].add(e) + children[twoD{e.Menu, e.Parent}] = children[twoD{e.Menu, e.Parent}].Add(e) } } @@ -1487,7 +1334,11 @@ func (s *Site) assembleMenus() { _, ok := flat[twoD{p.MenuName, p.EntryName}] if !ok { // if parent does not exist, create one without a URL - flat[twoD{p.MenuName, p.EntryName}] = &MenuEntry{Name: p.EntryName, URL: ""} + flat[twoD{p.MenuName, p.EntryName}] = &navigation.MenuEntry{ + MenuConfig: navigation.MenuConfig{ + Name: p.EntryName, + }, + } } flat[twoD{p.MenuName, p.EntryName}].Children = childmenu } @@ -1495,432 +1346,269 @@ func (s *Site) assembleMenus() { // Assembling Top Level of Tree for menu, e := range flat { if e.Parent == "" { - _, ok := s.Menus[menu.MenuName] + _, ok := s.menus[menu.MenuName] if !ok { - s.Menus[menu.MenuName] = &Menu{} + s.menus[menu.MenuName] = navigation.Menu{} } - *s.Menus[menu.MenuName] = s.Menus[menu.MenuName].add(e) + s.menus[menu.MenuName] = s.menus[menu.MenuName].Add(e) } } -} - -func (s *Site) getTaxonomyKey(key string) string { - if s.Info.preserveTaxonomyNames { - // Keep as is - return key - } - return s.PathSpec.MakePathSanitized(key) -} - -// We need to create the top level taxonomy early in the build process -// to be able to determine the page Kind correctly. -func (s *Site) createTaxonomiesEntries() { - s.Taxonomies = make(TaxonomyList) - taxonomies := s.Language.GetStringMapString("taxonomies") - for _, plural := range taxonomies { - s.Taxonomies[plural] = make(Taxonomy) - } -} - -func (s *Site) assembleTaxonomies() { - s.taxonomiesPluralSingular = make(map[string]string) - s.taxonomiesOrigKey = make(map[string]string) - - taxonomies := s.Language.GetStringMapString("taxonomies") - - s.Log.INFO.Printf("found taxonomies: %#v\n", taxonomies) - - for singular, plural := range taxonomies { - s.taxonomiesPluralSingular[plural] = singular - - for _, p := range s.Pages { - vals := p.getParam(plural, !s.Info.preserveTaxonomyNames) - weight := p.getParamToLower(plural + "_weight") - if weight == nil { - weight = 0 - } - if vals != nil { - if v, ok := vals.([]string); ok { - for _, idx := range v { - x := WeightedPage{weight.(int), p} - s.Taxonomies[plural].add(s.getTaxonomyKey(idx), x) - if s.Info.preserveTaxonomyNames { - // Need to track the original - s.taxonomiesOrigKey[fmt.Sprintf("%s-%s", plural, s.PathSpec.MakePathSanitized(idx))] = idx - } - } - } else if v, ok := vals.(string); ok { - x := WeightedPage{weight.(int), p} - s.Taxonomies[plural].add(s.getTaxonomyKey(v), x) - if s.Info.preserveTaxonomyNames { - // Need to track the original - s.taxonomiesOrigKey[fmt.Sprintf("%s-%s", plural, s.PathSpec.MakePathSanitized(v))] = v - } - } else { - s.Log.ERROR.Printf("Invalid %s in %s\n", plural, p.File.Path()) - } - } - } - for k := range s.Taxonomies[plural] { - s.Taxonomies[plural][k].Sort() - } - } - - s.Info.Taxonomies = s.Taxonomies -} - -// Prepare site for a new full build. -func (s *Site) resetBuildState() { - - s.relatedDocsHandler = newSearchIndexHandler(s.relatedDocsHandler.cfg) - s.PageCollections = newPageCollectionsFromPages(s.rawAllPages) - // TODO(bep) get rid of this double - s.Info.PageCollections = s.PageCollections - - s.draftCount = 0 - s.futureCount = 0 - - s.expiredCount = 0 - - for _, p := range s.rawAllPages { - p.scratch = newScratch() - p.subSections = Pages{} - p.parent = nil - } -} - -func (s *Site) kindFromSections(sections []string) string { - if len(sections) == 0 { - return KindSection - } - - if _, isTaxonomy := s.Taxonomies[sections[0]]; isTaxonomy { - if len(sections) == 1 { - return KindTaxonomyTerm - } - return KindTaxonomy - } - return KindSection -} - -func (s *Site) layouts(p *PageOutput) ([]string, error) { - return s.layoutHandler.For(p.layoutDescriptor, p.outputFormat) -} - -func (s *Site) preparePages() error { - var errors []error - - for _, p := range s.Pages { - if err := p.prepareLayouts(); err != nil { - errors = append(errors, err) - } - if err := p.prepareData(s); err != nil { - errors = append(errors, err) - } - } - - if len(errors) != 0 { - return fmt.Errorf("Prepare pages failed: %.100q…", errors) - } return nil } -func errorCollator(results <-chan error, errs chan<- error) { - errMsgs := []string{} - for err := range results { - if err != nil { - errMsgs = append(errMsgs, err.Error()) - } +// get any language code to prefix the target file path with. +func (s *Site) getLanguageTargetPathLang(alwaysInSubDir bool) string { + if s.h.Conf.IsMultihost() { + return s.Language().Lang } - if len(errMsgs) == 0 { - errs <- nil - } else { - errs <- errors.New(strings.Join(errMsgs, "\n")) + + return s.getLanguagePermalinkLang(alwaysInSubDir) +} + +// get any language code to prefix the relative permalink with. +func (s *Site) getLanguagePermalinkLang(alwaysInSubDir bool) string { + if s.h.Conf.IsMultihost() { + return "" } + + if s.h.Conf.IsMultilingual() && alwaysInSubDir { + return s.Language().Lang + } + + return s.GetLanguagePrefix() +} + +// Prepare site for a new full build. +func (s *Site) resetBuildState(sourceChanged bool) { + s.relatedDocsHandler = s.relatedDocsHandler.Clone() + s.init.Reset() + s.pageMap.Reset() +} + +func (s *Site) errorCollator(results <-chan error, errs chan<- error) { + var errors []error + for e := range results { + errors = append(errors, e) + } + + errs <- s.h.pickOneAndLogTheRest(errors) + close(errs) } -func (s *Site) appendThemeTemplates(in []string) []string { - if !s.PathSpec.ThemeSet() { - return in +// GetPage looks up a page of a given type for the given ref. +// In Hugo <= 0.44 you had to add Page Kind (section, home) etc. as the first +// argument and then either a unix styled path (with or without a leading slash)) +// or path elements separated. +// When we now remove the Kind from this API, we need to make the transition as painless +// as possible for existing sites. Most sites will use {{ .Site.GetPage "section" "my/section" }}, +// i.e. 2 arguments, so we test for that. +func (s *Site) GetPage(ref ...string) (page.Page, error) { + s.CheckReady() + p, err := s.s.getPageForRefs(ref...) + + if p == nil { + // The nil struct has meaning in some situations, mostly to avoid breaking + // existing sites doing $nilpage.IsDescendant($p), which will always return + // false. + p = page.NilPage } - out := []string{} - // First place all non internal templates - for _, t := range in { - if !strings.HasPrefix(t, "_internal/") { - out = append(out, t) - } - } - - // Then place theme templates with the same names - for _, t := range in { - if !strings.HasPrefix(t, "_internal/") { - out = append(out, "theme/"+t) - } - } - - // Lastly place internal templates - for _, t := range in { - if strings.HasPrefix(t, "_internal/") { - out = append(out, t) - } - } - return out - + return p, err } -// GetPage looks up a page of a given type in the path given. -// {{ with .Site.GetPage "section" "blog" }}{{ .Title }}{{ end }} -// -// This will return nil when no page could be found, and will return the -// first page found if the key is ambigous. -func (s *SiteInfo) GetPage(typ string, path ...string) (*Page, error) { - return s.getPage(typ, path...), nil -} - -func (s *Site) permalinkForOutputFormat(link string, f output.Format) (string, error) { - var ( - baseURL string - err error - ) - - if f.Protocol != "" { - baseURL, err = s.PathSpec.BaseURL.WithProtocol(f.Protocol) - if err != nil { - return "", err - } +func (s *Site) absURLPath(targetPath string) string { + var path string + if s.conf.RelativeURLs { + path = helpers.GetDottedRelativePath(targetPath) } else { - baseURL = s.PathSpec.BaseURL.String() - } - return s.PathSpec.PermalinkForBaseURL(link, baseURL), nil -} - -func (s *Site) permalink(link string) string { - return s.PathSpec.PermalinkForBaseURL(link, s.PathSpec.BaseURL.String()) - -} - -func (s *Site) renderAndWriteXML(statCounter *uint64, name string, dest string, d interface{}, layouts ...string) error { - s.Log.DEBUG.Printf("Render XML for %q to %q", name, dest) - renderBuffer := bp.GetBuffer() - defer bp.PutBuffer(renderBuffer) - renderBuffer.WriteString("<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"yes\" ?>\n") - - if err := s.renderForLayouts(name, d, renderBuffer, layouts...); err != nil { - helpers.DistinctWarnLog.Println(err) - return nil - } - - outBuffer := bp.GetBuffer() - defer bp.PutBuffer(outBuffer) - - var path []byte - if s.Info.relativeURLs { - path = []byte(helpers.GetDottedRelativePath(dest)) - } else { - s := s.PathSpec.BaseURL.String() - if !strings.HasSuffix(s, "/") { - s += "/" + url := s.PathSpec.Cfg.BaseURL().String() + if !strings.HasSuffix(url, "/") { + url += "/" } - path = []byte(s) - } - transformer := transform.NewChain(transform.AbsURLInXML) - if err := transformer.Apply(outBuffer, renderBuffer, path); err != nil { - helpers.DistinctErrorLog.Println(err) - return nil + path = url } - return s.publish(statCounter, dest, outBuffer) - + return path } -func (s *Site) renderAndWritePage(statCounter *uint64, name string, dest string, p *PageOutput, layouts ...string) error { +const ( + pageDependencyScopeDefault int = iota + pageDependencyScopeGlobal +) + +func (s *Site) renderAndWritePage(statCounter *uint64, name string, targetPath string, p *pageState, d any, templ *tplimpl.TemplInfo) error { + s.h.buildCounters.pageRenderCounter.Add(1) renderBuffer := bp.GetBuffer() defer bp.PutBuffer(renderBuffer) - if err := s.renderForLayouts(p.Kind, p, renderBuffer, layouts...); err != nil { - helpers.DistinctWarnLog.Println(err) - return nil + of := p.outputFormat() + p.incrRenderState() + + ctx := tpl.Context.Page.Set(context.Background(), p) + ctx = tpl.Context.DependencyManagerScopedProvider.Set(ctx, p) + + if err := s.renderForTemplate(ctx, p.Kind(), of.Name, d, renderBuffer, templ); err != nil { + return err } if renderBuffer.Len() == 0 { return nil } - outBuffer := bp.GetBuffer() - defer bp.PutBuffer(outBuffer) + isHTML := of.IsHTML + isRSS := of.Name == "rss" - transformLinks := transform.NewEmptyTransforms() + pd := publisher.Descriptor{ + Src: renderBuffer, + TargetPath: targetPath, + StatCounter: statCounter, + OutputFormat: p.outputFormat(), + } - isHTML := p.outputFormat.IsHTML - - if isHTML { - if s.Info.relativeURLs || s.Info.canonifyURLs { - transformLinks = append(transformLinks, transform.AbsURL) + if isRSS { + // Always canonify URLs in RSS + pd.AbsURLPath = s.absURLPath(targetPath) + } else if isHTML { + if s.conf.RelativeURLs || s.conf.CanonifyURLs { + pd.AbsURLPath = s.absURLPath(targetPath) } - if s.running() && s.Cfg.GetBool("watch") && !s.Cfg.GetBool("disableLiveReload") { - transformLinks = append(transformLinks, transform.LiveReloadInject(s.Cfg.GetInt("liveReloadPort"))) + if s.watching() && s.conf.Internal.Running && !s.conf.DisableLiveReload { + pd.LiveReloadBaseURL = s.Conf.BaseURLLiveReload().URL() } // For performance reasons we only inject the Hugo generator tag on the home page. if p.IsHome() { - if !s.Cfg.GetBool("disableHugoGeneratorInject") { - transformLinks = append(transformLinks, transform.HugoGeneratorInject) - } + pd.AddHugoGeneratorTag = !s.conf.DisableHugoGeneratorInject } + } - var path []byte + return s.publisher.Publish(pd) +} - if s.Info.relativeURLs { - path = []byte(helpers.GetDottedRelativePath(dest)) - } else if s.Info.canonifyURLs { - url := s.PathSpec.BaseURL.String() - if !strings.HasSuffix(url, "/") { - url += "/" - } - path = []byte(url) - } +var infoOnMissingLayout = map[string]bool{ + // The 404 layout is very much optional in Hugo, but we do look for it. + "404": true, +} - transformer := transform.NewChain(transformLinks...) - if err := transformer.Apply(outBuffer, renderBuffer, path); err != nil { - helpers.DistinctErrorLog.Println(err) +// hookRendererTemplate is the canonical implementation of all hooks.ITEMRenderer, +// where ITEM is the thing being hooked. +type hookRendererTemplate struct { + templateHandler *tplimpl.TemplateStore + templ *tplimpl.TemplInfo + resolvePosition func(ctx any) text.Position +} + +func (hr hookRendererTemplate) RenderLink(cctx context.Context, w io.Writer, ctx hooks.LinkContext) error { + return hr.templateHandler.ExecuteWithContext(cctx, hr.templ, w, ctx) +} + +func (hr hookRendererTemplate) RenderHeading(cctx context.Context, w io.Writer, ctx hooks.HeadingContext) error { + return hr.templateHandler.ExecuteWithContext(cctx, hr.templ, w, ctx) +} + +func (hr hookRendererTemplate) RenderCodeblock(cctx context.Context, w hugio.FlexiWriter, ctx hooks.CodeblockContext) error { + return hr.templateHandler.ExecuteWithContext(cctx, hr.templ, w, ctx) +} + +func (hr hookRendererTemplate) RenderPassthrough(cctx context.Context, w io.Writer, ctx hooks.PassthroughContext) error { + return hr.templateHandler.ExecuteWithContext(cctx, hr.templ, w, ctx) +} + +func (hr hookRendererTemplate) RenderBlockquote(cctx context.Context, w hugio.FlexiWriter, ctx hooks.BlockquoteContext) error { + return hr.templateHandler.ExecuteWithContext(cctx, hr.templ, w, ctx) +} + +func (hr hookRendererTemplate) RenderTable(cctx context.Context, w hugio.FlexiWriter, ctx hooks.TableContext) error { + return hr.templateHandler.ExecuteWithContext(cctx, hr.templ, w, ctx) +} + +func (hr hookRendererTemplate) ResolvePosition(ctx any) text.Position { + return hr.resolvePosition(ctx) +} + +func (hr hookRendererTemplate) IsDefaultCodeBlockRenderer() bool { + return false +} + +func (s *Site) renderForTemplate(ctx context.Context, name, outputFormat string, d any, w io.Writer, templ *tplimpl.TemplInfo) (err error) { + if templ == nil { + s.logMissingLayout(name, "", "", outputFormat) return nil } - return s.publish(statCounter, dest, outBuffer) -} - -func (s *Site) renderForLayouts(name string, d interface{}, w io.Writer, layouts ...string) (err error) { - var templ tpl.Template - - defer func() { - if r := recover(); r != nil { - templName := "" - if templ != nil { - templName = templ.Name() - } - helpers.DistinctErrorLog.Printf("Failed to render %q: %s", templName, r) - // TOD(bep) we really need to fix this. Also see below. - if !s.running() && !testMode { - os.Exit(-1) - } - } - }() - - templ = s.findFirstTemplate(layouts...) - if templ == nil { - return fmt.Errorf("[%s] Unable to locate layout for %q: %s\n", s.Language.Lang, name, layouts) + if ctx == nil { + panic("nil context") } - if err = templ.Execute(w, d); err != nil { - // Behavior here should be dependent on if running in server or watch mode. - if p, ok := d.(*PageOutput); ok { - if p.File != nil { - helpers.DistinctErrorLog.Printf("Error while rendering %q in %q: %s", name, p.File.Dir(), err) - } else { - helpers.DistinctErrorLog.Printf("Error while rendering %q: %s", name, err) + if err = s.GetTemplateStore().ExecuteWithContext(ctx, templ, w, d); err != nil { + filename := name + if p, ok := d.(*pageState); ok { + filename = p.String() + } + return fmt.Errorf("render of %q failed: %w", filename, err) + } + return +} + +func (s *Site) shouldBuild(p page.Page) bool { + if !s.conf.IsKindEnabled(p.Kind()) { + return false + } + return shouldBuild(s.Conf.BuildFuture(), s.Conf.BuildExpired(), + s.Conf.BuildDrafts(), p.Draft(), p.PublishDate(), p.ExpiryDate()) +} + +func shouldBuild(buildFuture bool, buildExpired bool, buildDrafts bool, Draft bool, + publishDate time.Time, expiryDate time.Time, +) bool { + if !(buildDrafts || !Draft) { + return false + } + hnow := htime.Now() + if !buildFuture && !publishDate.IsZero() && publishDate.After(hnow) { + return false + } + if !buildExpired && !expiryDate.IsZero() && expiryDate.Before(hnow) { + return false + } + return true +} + +func (s *Site) render(ctx *siteRenderContext) (err error) { + if err := page.Clear(); err != nil { + return err + } + + if ctx.outIdx == 0 && s.h.buildCounter.Load() == 0 { + // Note that even if disableAliases is set, the aliases themselves are + // preserved on page. The motivation with this is to be able to generate + // 301 redirects in a .htaccess file and similar using a custom output format. + if !s.conf.DisableAliases { + // Aliases must be rendered before pages. + // Some sites, Hugo docs included, have faulty alias definitions that point + // to itself or another real page. These will be overwritten in the next + // step. + if err = s.renderAliases(); err != nil { + return } - } else { - helpers.DistinctErrorLog.Printf("Error while rendering %q: %s", name, err) - } - if !s.running() && !testMode { - // TODO(bep) check if this can be propagated - os.Exit(-1) - } else if testMode { - return } } + if err = s.renderPages(ctx); err != nil { + return + } + + if !ctx.shouldRenderStandalonePage("") { + return + } + + if err = s.renderMainLanguageRedirect(); err != nil { + return + } + return } - -func (s *Site) findFirstTemplate(layouts ...string) tpl.Template { - for _, layout := range layouts { - if templ := s.Tmpl.Lookup(layout); templ != nil { - return templ - } - } - return nil -} - -func (s *Site) publish(statCounter *uint64, path string, r io.Reader) (err error) { - s.PathSpec.ProcessingStats.Incr(statCounter) - - path = filepath.Join(s.absPublishDir(), path) - - return helpers.WriteToDisk(path, r, s.Fs.Destination) -} - -func getGoMaxProcs() int { - if gmp := os.Getenv("GOMAXPROCS"); gmp != "" { - if p, err := strconv.Atoi(gmp); err != nil { - return p - } - } - return 1 -} - -func (s *Site) newNodePage(typ string, sections ...string) *Page { - p := &Page{ - language: s.Language, - pageInit: &pageInit{}, - Kind: typ, - Source: Source{File: &source.FileInfo{}}, - Data: make(map[string]interface{}), - Site: &s.Info, - sections: sections, - s: s} - - p.outputFormats = p.s.outputFormats[p.Kind] - - return p - -} - -func (s *Site) newHomePage() *Page { - p := s.newNodePage(KindHome) - p.title = s.Info.Title - pages := Pages{} - p.Data["Pages"] = pages - p.Pages = pages - return p -} - -func (s *Site) newTaxonomyPage(plural, key string) *Page { - - p := s.newNodePage(KindTaxonomy, plural, key) - - if s.Info.preserveTaxonomyNames { - // Keep (mostly) as is in the title - // We make the first character upper case, mostly because - // it is easier to reason about in the tests. - p.title = helpers.FirstUpper(key) - key = s.PathSpec.MakePathSanitized(key) - } else { - p.title = strings.Replace(s.titleFunc(key), "-", " ", -1) - } - - return p -} - -func (s *Site) newSectionPage(name string) *Page { - p := s.newNodePage(KindSection, name) - - sectionName := helpers.FirstUpper(name) - if s.Cfg.GetBool("pluralizeListTitles") { - p.title = inflect.Pluralize(sectionName) - } else { - p.title = sectionName - } - return p -} - -func (s *Site) newTaxonomyTermsPage(plural string) *Page { - p := s.newNodePage(KindTaxonomyTerm, plural) - p.title = s.titleFunc(plural) - return p -} diff --git a/hugolib/siteJSONEncode_test.go b/hugolib/siteJSONEncode_test.go index 5bb6e52e8..94bac1873 100644 --- a/hugolib/siteJSONEncode_test.go +++ b/hugolib/siteJSONEncode_test.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Hugo Authors. All rights reserved. +// 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. @@ -14,12 +14,7 @@ package hugolib import ( - "encoding/json" "testing" - - "path/filepath" - - "github.com/gohugoio/hugo/deps" ) // Issue #1123 @@ -27,27 +22,23 @@ import ( // May be smart to run with: -timeout 4000ms func TestEncodePage(t *testing.T) { t.Parallel() - cfg, fs := newTestCfg() - writeSource(t, fs, filepath.Join("content", "page.md"), `--- -title: Simple + templ := `Page: |{{ index .Site.RegularPages 0 | jsonify }}| +Site: {{ site | jsonify }} +` + + b := newTestSitesBuilder(t) + b.WithSimpleConfigFile().WithTemplatesAdded("index.html", templ) + b.WithContent("page.md", `--- +title: "Page" +date: 2019-02-28 --- -Summary text -<!--more--> +Content. + `) - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{}) + b.Build(BuildCfg{}) - _, err := json.Marshal(s) - check(t, err) - - _, err = json.Marshal(s.RegularPages[0]) - check(t, err) -} - -func check(t *testing.T, err error) { - if err != nil { - t.Fatalf("Failed %s", err) - } + b.AssertFileContent("public/index.html", `"Date":"2019-02-28T00:00:00Z"`) } diff --git a/hugolib/site_benchmark_new_test.go b/hugolib/site_benchmark_new_test.go new file mode 100644 index 000000000..023d8e4d5 --- /dev/null +++ b/hugolib/site_benchmark_new_test.go @@ -0,0 +1,560 @@ +// 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 hugolib + +import ( + "fmt" + "math/rand" + "path" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/gohugoio/hugo/resources/page" + + qt "github.com/frankban/quicktest" +) + +type siteBenchmarkTestcase struct { + name string + create func(t testing.TB) *sitesBuilder + check func(s *sitesBuilder) +} + +func getBenchmarkSiteDeepContent(b testing.TB) *sitesBuilder { + pageContent := func(size int) string { + return getBenchmarkTestDataPageContentForMarkdown(size, false, "", benchmarkMarkdownSnippets) + } + + sb := newTestSitesBuilder(b).WithConfigFile("toml", ` +baseURL = "https://example.com" + +[languages] +[languages.en] +weight=1 +contentDir="content/en" +[languages.fr] +weight=2 +contentDir="content/fr" +[languages.no] +weight=3 +contentDir="content/no" +[languages.sv] +weight=4 +contentDir="content/sv" + +`) + + createContent := func(dir, name string) { + sb.WithContent(filepath.Join("content", dir, name), pageContent(1)) + } + + createBundledFiles := func(dir string) { + sb.WithContent(filepath.Join("content", dir, "data.json"), `{ "hello": "world" }`) + for i := 1; i <= 3; i++ { + sb.WithContent(filepath.Join("content", dir, fmt.Sprintf("page%d.md", i)), pageContent(1)) + } + } + + for _, lang := range []string{"en", "fr", "no", "sv"} { + for level := 1; level <= 5; level++ { + sectionDir := path.Join(lang, strings.Repeat("section/", level)) + createContent(sectionDir, "_index.md") + createBundledFiles(sectionDir) + for i := 1; i <= 3; i++ { + leafBundleDir := path.Join(sectionDir, fmt.Sprintf("bundle%d", i)) + createContent(leafBundleDir, "index.md") + createBundledFiles(path.Join(leafBundleDir, "assets1")) + createBundledFiles(path.Join(leafBundleDir, "assets1", "assets2")) + } + } + } + + return sb +} + +func getBenchmarkTestDataPageContentForMarkdown(size int, toml bool, category, markdown string) string { + base := `--- +title: "My Page" +%s +--- + +My page content. +` + if toml { + base = `+++ +title="My Page" +%s ++++ + +My page content. +` + } + + var categoryKey string + if category != "" { + categoryKey = fmt.Sprintf("categories: [%s]", category) + if toml { + categoryKey = fmt.Sprintf("categories=[%s]", category) + } + } + base = fmt.Sprintf(base, categoryKey) + + return base + strings.Repeat(markdown, size) +} + +const benchmarkMarkdownSnippets = ` + +## Links + + +This is [an example](http://example.com/ "Title") inline link. + +[This link](http://example.net/) has no title attribute. + +This is [Relative](/all-is-relative). + +See my [About](/about/) page for details. +` + +func getBenchmarkSiteTestCases() []siteBenchmarkTestcase { + pageContentWithCategory := func(size int, category string) string { + return getBenchmarkTestDataPageContentForMarkdown(size, false, category, benchmarkMarkdownSnippets) + } + + pageContent := func(size int) string { + return getBenchmarkTestDataPageContentForMarkdown(size, false, "", benchmarkMarkdownSnippets) + } + + config := ` +baseURL = "https://example.com" +` + + benchmarks := []siteBenchmarkTestcase{ + { + "Bundle with image", func(b testing.TB) *sitesBuilder { + sb := newTestSitesBuilder(b).WithConfigFile("toml", config) + sb.WithContent("content/blog/mybundle/index.md", pageContent(1)) + sb.WithSunset("content/blog/mybundle/sunset1.jpg") + + return sb + }, + func(s *sitesBuilder) { + s.AssertFileContent("public/blog/mybundle/index.html", "/blog/mybundle/sunset1.jpg") + s.CheckExists("public/blog/mybundle/sunset1.jpg") + }, + }, + { + "Bundle with JSON file", func(b testing.TB) *sitesBuilder { + sb := newTestSitesBuilder(b).WithConfigFile("toml", config) + sb.WithContent("content/blog/mybundle/index.md", pageContent(1)) + sb.WithContent("content/blog/mybundle/mydata.json", `{ "hello": "world" }`) + + return sb + }, + func(s *sitesBuilder) { + s.AssertFileContent("public/blog/mybundle/index.html", "Resources: application/json: /blog/mybundle/mydata.json") + s.CheckExists("public/blog/mybundle/mydata.json") + }, + }, + { + "Tags and categories", func(b testing.TB) *sitesBuilder { + sb := newTestSitesBuilder(b).WithConfigFile("toml", ` +title = "Tags and Cats" +baseURL = "https://example.com" + +`) + + const pageTemplate = ` +--- +title: "Some tags and cats" +categories: ["caGR", "cbGR"] +tags: ["taGR", "tbGR"] +--- + +Some content. + +` + for i := 1; i <= 100; i++ { + content := strings.Replace(pageTemplate, "GR", strconv.Itoa(i/3), -1) + sb.WithContent(fmt.Sprintf("content/page%d.md", i), content) + } + + return sb + }, + func(s *sitesBuilder) { + s.AssertFileContent("public/page3/index.html", "/page3/|Permalink: https://example.com/page3/") + s.AssertFileContent("public/tags/ta3/index.html", "a3") + }, + }, + { + "Canonify URLs", func(b testing.TB) *sitesBuilder { + sb := newTestSitesBuilder(b).WithConfigFile("toml", ` +title = "Canon" +baseURL = "https://example.com" +canonifyURLs = true + +`) + for i := 1; i <= 100; i++ { + sb.WithContent(fmt.Sprintf("content/page%d.md", i), pageContent(i)) + } + + return sb + }, + func(s *sitesBuilder) { + s.AssertFileContent("public/page8/index.html", "https://example.com/about/") + }, + }, + + { + "Deep content tree", func(b testing.TB) *sitesBuilder { + return getBenchmarkSiteDeepContent(b) + }, + func(s *sitesBuilder) { + s.CheckExists("public/blog/mybundle/index.html") + s.Assert(len(s.H.Sites), qt.Equals, 4) + s.Assert(len(s.H.Sites[0].RegularPages()), qt.Equals, len(s.H.Sites[1].RegularPages())) + s.Assert(len(s.H.Sites[0].RegularPages()), qt.Equals, 30) + }, + }, + { + "TOML front matter", func(b testing.TB) *sitesBuilder { + sb := newTestSitesBuilder(b).WithConfigFile("toml", config) + for i := 1; i <= 200; i++ { + content := getBenchmarkTestDataPageContentForMarkdown(1, true, "\"a\", \"b\", \"c\"", benchmarkMarkdownSnippets) + sb.WithContent(fmt.Sprintf("content/p%d.md", i), content) + } + + return sb + }, + func(s *sitesBuilder) { + }, + }, + { + "Many HTML templates", func(b testing.TB) *sitesBuilder { + pageTemplateTemplate := ` +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + <title>{{ if not .IsPage }}{{ .Title }}{{ else }}{{ printf "Site: %s" site.Title }}{{ end }} + + + +
    {{ .Content }}
    + + + +` + + sb := newTestSitesBuilder(b).WithConfigFile("toml", ` +baseURL = "https://example.com" + +[languages] +[languages.en] +weight=1 +contentDir="content/en" +[languages.fr] +weight=2 +contentDir="content/fr" +[languages.no] +weight=3 +contentDir="content/no" +[languages.sv] +weight=4 +contentDir="content/sv" + +`) + + createContent := func(dir, name string) { + sb.WithContent(filepath.Join("content", dir, name), pageContent(1)) + } + + for _, lang := range []string{"en", "fr", "no", "sv"} { + sb.WithTemplatesAdded(fmt.Sprintf("_default/single.%s.html", lang), pageTemplateTemplate) + sb.WithTemplatesAdded(fmt.Sprintf("_default/list.%s.html", lang), pageTemplateTemplate) + + for level := 1; level <= 5; level++ { + sectionDir := path.Join(lang, strings.Repeat("section/", level)) + createContent(sectionDir, "_index.md") + for i := 1; i <= 3; i++ { + leafBundleDir := path.Join(sectionDir, fmt.Sprintf("bundle%d", i)) + createContent(leafBundleDir, "index.md") + } + } + } + + return sb + }, + func(s *sitesBuilder) { + s.CheckExists("public/blog/mybundle/index.html") + s.Assert(len(s.H.Sites), qt.Equals, 4) + s.Assert(len(s.H.Sites[0].RegularPages()), qt.Equals, len(s.H.Sites[1].RegularPages())) + s.Assert(len(s.H.Sites[0].RegularPages()), qt.Equals, 15) + }, + }, + { + "Page collections", func(b testing.TB) *sitesBuilder { + pageTemplateTemplate := ` +{{ if .IsNode }} +{{ len .Paginator.Pages }} +{{ end }} +{{ len .Sections }} +{{ len .Pages }} +{{ len .RegularPages }} +{{ len .Resources }} +{{ len site.RegularPages }} +{{ len site.Pages }} +{{ with .NextInSection }}Next in section: {{ .RelPermalink }}{{ end }} +{{ with .PrevInSection }}Prev in section: {{ .RelPermalink }}{{ end }} +{{ with .Next }}Next: {{ .RelPermalink }}{{ end }} +{{ with .Prev }}Prev: {{ .RelPermalink }}{{ end }} +` + + sb := newTestSitesBuilder(b).WithConfigFile("toml", ` +baseURL = "https://example.com" + +[languages] +[languages.en] +weight=1 +contentDir="content/en" +[languages.fr] +weight=2 +contentDir="content/fr" +[languages.no] +weight=3 +contentDir="content/no" +[languages.sv] +weight=4 +contentDir="content/sv" + +`) + + sb.WithTemplates("index.html", pageTemplateTemplate) + sb.WithTemplates("_default/single.html", pageTemplateTemplate) + sb.WithTemplates("_default/list.html", pageTemplateTemplate) + + r := rand.New(rand.NewSource(99)) + + createContent := func(dir, name string) { + var content string + if strings.Contains(name, "_index") { + content = pageContent(1) + } else { + content = pageContentWithCategory(1, fmt.Sprintf("category%d", r.Intn(5)+1)) + } + + sb.WithContent(filepath.Join("content", dir, name), content) + } + + createBundledFiles := func(dir string) { + sb.WithContent(filepath.Join("content", dir, "data.json"), `{ "hello": "world" }`) + for i := 1; i <= 3; i++ { + sb.WithContent(filepath.Join("content", dir, fmt.Sprintf("page%d.md", i)), pageContent(1)) + } + } + + for _, lang := range []string{"en", "fr", "no", "sv"} { + for level := 1; level <= r.Intn(5)+1; level++ { + sectionDir := path.Join(lang, strings.Repeat("section/", level)) + createContent(sectionDir, "_index.md") + createBundledFiles(sectionDir) + for i := 1; i <= r.Intn(20)+1; i++ { + leafBundleDir := path.Join(sectionDir, fmt.Sprintf("bundle%d", i)) + createContent(leafBundleDir, "index.md") + createBundledFiles(path.Join(leafBundleDir, "assets1")) + createBundledFiles(path.Join(leafBundleDir, "assets1", "assets2")) + } + } + } + + return sb + }, + func(s *sitesBuilder) { + s.CheckExists("public/blog/mybundle/index.html") + s.Assert(len(s.H.Sites), qt.Equals, 4) + s.Assert(len(s.H.Sites[0].RegularPages()), qt.Equals, 26) + }, + }, + { + "List terms", func(b testing.TB) *sitesBuilder { + pageTemplateTemplate := ` + +` + + sb := newTestSitesBuilder(b).WithConfigFile("toml", ` +baseURL = "https://example.com" +`) + + sb.WithTemplates("_default/single.html", pageTemplateTemplate) + sb.WithTemplates("_default/list.html", "List") + + r := rand.New(rand.NewSource(99)) + + createContent := func(dir, name string) { + var content string + if strings.Contains(name, "_index") { + // Empty + } else { + content = pageContentWithCategory(1, fmt.Sprintf("category%d", r.Intn(5)+1)) + } + sb.WithContent(filepath.Join("content", dir, name), content) + } + + for level := 1; level <= r.Intn(5)+1; level++ { + sectionDir := path.Join(strings.Repeat("section/", level)) + createContent(sectionDir, "_index.md") + for i := 1; i <= r.Intn(33); i++ { + leafBundleDir := path.Join(sectionDir, fmt.Sprintf("bundle%d", i)) + createContent(leafBundleDir, "index.md") + } + } + + return sb + }, + func(s *sitesBuilder) { + s.AssertFileContent("public/section/bundle8/index.html", ``) + s.Assert(len(s.H.Sites), qt.Equals, 1) + s.Assert(len(s.H.Sites[0].RegularPages()), qt.Equals, 35) + }, + }, + } + + return benchmarks +} + +// Run the benchmarks below as tests. Mostly useful when adding new benchmark +// variants. +func TestBenchmarkSite(b *testing.T) { + benchmarks := getBenchmarkSiteTestCases() + for _, bm := range benchmarks { + if bm.name != "Deep content tree" { + continue + } + b.Run(bm.name, func(b *testing.T) { + s := bm.create(b) + + err := s.BuildE(BuildCfg{}) + if err != nil { + b.Fatal(err) + } + bm.check(s) + }) + } +} + +func TestBenchmarkSiteDeepContentEdit(t *testing.T) { + b := getBenchmarkSiteDeepContent(t).Running() + b.Build(BuildCfg{}) + + p := b.H.Sites[0].RegularPages()[12] + + b.EditFiles(p.File().Filename(), fmt.Sprintf(`--- +title: %s +--- + +Edited!!`, p.Title())) + + counters := &buildCounters{} + + b.Build(BuildCfg{testCounters: counters}) + + // We currently rebuild all the language versions of the same content file. + // We could probably optimize that case, but it's not trivial. + b.Assert(int(counters.contentRenderCounter.Load()), qt.Equals, 4) + b.AssertFileContent("public"+p.RelPermalink()+"index.html", "Edited!!") +} + +func BenchmarkSiteNew(b *testing.B) { + rnd := rand.New(rand.NewSource(32)) + benchmarks := getBenchmarkSiteTestCases() + for _, edit := range []bool{true, false} { + for _, bm := range benchmarks { + name := bm.name + if edit { + name = "Edit_" + name + } else { + name = "Regular_" + name + } + b.Run(name, func(b *testing.B) { + sites := make([]*sitesBuilder, b.N) + for i := 0; i < b.N; i++ { + sites[i] = bm.create(b) + if edit { + sites[i].Running() + } + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + if edit { + b.StopTimer() + } + s := sites[i] + err := s.BuildE(BuildCfg{}) + if err != nil { + b.Fatal(err) + } + bm.check(s) + + if edit { + if edit { + b.StartTimer() + } + // Edit a random page in a random language. + pages := s.H.Sites[rnd.Intn(len(s.H.Sites))].Pages() + var p page.Page + count := 0 + for { + count++ + if count > 100 { + panic("infinite loop") + } + p = pages[rnd.Intn(len(pages))] + if p.File() != nil { + break + } + } + + s.EditFiles(p.File().Filename(), fmt.Sprintf(`--- +title: %s +--- + +Edited!!`, p.Title())) + + err := s.BuildE(BuildCfg{}) + if err != nil { + b.Fatal(err) + } + } + } + }) + } + } +} diff --git a/hugolib/site_benchmark_test.go b/hugolib/site_benchmark_test.go deleted file mode 100644 index dbe29a94a..000000000 --- a/hugolib/site_benchmark_test.go +++ /dev/null @@ -1,335 +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 hugolib - -import ( - "flag" - "fmt" - "math/rand" - "path/filepath" - "strings" - "testing" - - "github.com/spf13/afero" -) - -type siteBuildingBenchmarkConfig struct { - Frontmatter string - NumPages int - NumLangs int - RootSections int - Render bool - Shortcodes bool - NumTags int - TagsPerPage int -} - -func (s siteBuildingBenchmarkConfig) String() string { - // Make it comma separated with no spaces, so it is both Bash and regexp friendly. - // To make it a short as possible, we only shows bools when enabled and ints when >= 0 (RootSections > 1) - sep := "," - id := s.Frontmatter + sep - id += fmt.Sprintf("num_langs=%d%s", s.NumLangs, sep) - - if s.RootSections > 1 { - id += fmt.Sprintf("num_root_sections=%d%s", s.RootSections, sep) - } - id += fmt.Sprintf("num_pages=%d%s", s.NumPages, sep) - - if s.NumTags > 0 { - id += fmt.Sprintf("num_tags=%d%s", s.NumTags, sep) - } - - if s.TagsPerPage > 0 { - id += fmt.Sprintf("tags_per_page=%d%s", s.TagsPerPage, sep) - } - - if s.Shortcodes { - id += "shortcodes" + sep - } - - if s.Render { - id += "render" + sep - } - - return strings.TrimSuffix(id, sep) -} - -var someLangs = []string{"en", "fr", "nn"} - -func BenchmarkSiteBuilding(b *testing.B) { - var ( - // The below represents the full matrix of benchmarks. Big! - allFrontmatters = []string{"YAML", "TOML"} - allNumRootSections = []int{1, 5} - allNumTags = []int{0, 1, 10, 20, 50, 100, 500, 1000, 5000} - allTagsPerPage = []int{0, 1, 5, 20, 50, 80} - allNumPages = []int{1, 10, 100, 500, 1000, 5000, 10000} - allDoRender = []bool{false, true} - allDoShortCodes = []bool{false, true} - allNumLangs = []int{1, 3} - ) - - var runDefault bool - - visitor := func(a *flag.Flag) { - if a.Name == "test.bench" && len(a.Value.String()) < 40 { - // The full suite is too big, so fall back to some smaller default if no - // restriction is set. - runDefault = true - } - } - - flag.Visit(visitor) - - if runDefault { - allFrontmatters = allFrontmatters[1:] - allNumRootSections = allNumRootSections[0:2] - allNumTags = allNumTags[0:2] - allTagsPerPage = allTagsPerPage[2:3] - allNumPages = allNumPages[2:5] - allDoRender = allDoRender[1:2] - allDoShortCodes = allDoShortCodes[1:2] - } - - var conf siteBuildingBenchmarkConfig - for _, numLangs := range allNumLangs { - conf.NumLangs = numLangs - for _, frontmatter := range allFrontmatters { - conf.Frontmatter = frontmatter - for _, rootSections := range allNumRootSections { - conf.RootSections = rootSections - for _, numTags := range allNumTags { - conf.NumTags = numTags - for _, tagsPerPage := range allTagsPerPage { - conf.TagsPerPage = tagsPerPage - for _, numPages := range allNumPages { - conf.NumPages = numPages - for _, render := range allDoRender { - conf.Render = render - for _, shortcodes := range allDoShortCodes { - conf.Shortcodes = shortcodes - doBenchMarkSiteBuilding(conf, b) - } - } - } - } - } - } - } - } -} - -func doBenchMarkSiteBuilding(conf siteBuildingBenchmarkConfig, b *testing.B) { - b.Run(conf.String(), func(b *testing.B) { - b.StopTimer() - sites := createHugoBenchmarkSites(b, b.N, conf) - b.StartTimer() - for i := 0; i < b.N; i++ { - h := sites[0] - - err := h.Build(BuildCfg{SkipRender: !conf.Render}) - if err != nil { - b.Fatal(err) - } - - // Try to help the GC - sites[0] = nil - sites = sites[1:] - } - }) -} - -func createHugoBenchmarkSites(b *testing.B, count int, cfg siteBuildingBenchmarkConfig) []*HugoSites { - someMarkdown := ` -An h1 header -============ - -Paragraphs are separated by a blank line. - -2nd paragraph. *Italic* and **bold**. Itemized lists -look like: - - * this one - * that one - * the other one - -Note that --- not considering the asterisk --- the actual text -content starts at 4-columns in. - -> Block quotes are -> written like so. -> -> They can span multiple paragraphs, -> if you like. - -Use 3 dashes for an em-dash. Use 2 dashes for ranges (ex., "it's all -in chapters 12--14"). Three dots ... will be converted to an ellipsis. -Unicode is supported. ☺ -` - - someMarkdownWithShortCode := someMarkdown + ` - -{{< myShortcode >}} - -` - - pageTemplateTOML := `+++ -title = "%s" -tags = %s -+++ -%s - -` - - pageTemplateYAML := `--- -title: "%s" -tags: -%s ---- -%s - -` - - siteConfig := ` -baseURL = "http://example.com/blog" - -paginate = 10 -defaultContentLanguage = "en" - -[outputs] -home = [ "HTML" ] -section = [ "HTML" ] -taxonomy = [ "HTML" ] -taxonomyTerm = [ "HTML" ] -page = [ "HTML" ] - -[languages] -%s - -[Taxonomies] -tag = "tags" -category = "categories" -` - - langConfigTemplate := ` -[languages.%s] -languageName = "Lang %s" -weight = %d -` - - langConfig := "" - - for i := 0; i < cfg.NumLangs; i++ { - langCode := someLangs[i] - langConfig += fmt.Sprintf(langConfigTemplate, langCode, langCode, i+1) - } - - siteConfig = fmt.Sprintf(siteConfig, langConfig) - - numTags := cfg.NumTags - - if cfg.TagsPerPage > numTags { - numTags = cfg.TagsPerPage - } - - var ( - contentPagesContent [3]string - tags = make([]string, numTags) - pageTemplate string - ) - - for i := 0; i < numTags; i++ { - tags[i] = fmt.Sprintf("Hugo %d", i+1) - } - - var tagsStr string - - if cfg.Shortcodes { - contentPagesContent = [3]string{ - someMarkdownWithShortCode, - strings.Repeat(someMarkdownWithShortCode, 2), - strings.Repeat(someMarkdownWithShortCode, 3), - } - } else { - contentPagesContent = [3]string{ - someMarkdown, - strings.Repeat(someMarkdown, 2), - strings.Repeat(someMarkdown, 3), - } - } - - sites := make([]*HugoSites, count) - for i := 0; i < count; i++ { - // Maybe consider reusing the Source fs - mf := afero.NewMemMapFs() - th, h := newTestSitesFromConfig(b, mf, siteConfig, - "layouts/_default/single.html", `Single HTML|{{ .Title }}|{{ .Content }}|{{ partial "myPartial" . }}`, - "layouts/_default/list.html", `List HTML|{{ .Title }}|{{ .Content }}|GetPage: {{ with .Site.GetPage "page" "sect3/page3.md" }}{{ .Title }}{{ end }}`, - "layouts/partials/myPartial.html", `Partial: {{ "Hello **world**!" | markdownify }}`, - "layouts/shortcodes/myShortcode.html", `

    MyShortcode

    `) - - fs := th.Fs - - pagesPerSection := cfg.NumPages / cfg.RootSections / cfg.NumLangs - for li := 0; li < cfg.NumLangs; li++ { - fileLangCodeID := "" - if li > 0 { - fileLangCodeID = "." + someLangs[li] + "." - } - - for i := 0; i < cfg.RootSections; i++ { - for j := 0; j < pagesPerSection; j++ { - var tagsSlice []string - - if numTags > 0 { - tagsStart := rand.Intn(numTags) - cfg.TagsPerPage - if tagsStart < 0 { - tagsStart = 0 - } - tagsSlice = tags[tagsStart : tagsStart+cfg.TagsPerPage] - } - - if cfg.Frontmatter == "TOML" { - pageTemplate = pageTemplateTOML - tagsStr = "[]" - if cfg.TagsPerPage > 0 { - tagsStr = strings.Replace(fmt.Sprintf("%q", tagsSlice), " ", ", ", -1) - } - } else { - // YAML - pageTemplate = pageTemplateYAML - for _, tag := range tagsSlice { - tagsStr += "\n- " + tag - } - } - - content := fmt.Sprintf(pageTemplate, fmt.Sprintf("Title%d_%d", i, j), tagsStr, contentPagesContent[rand.Intn(3)]) - - contentFilename := fmt.Sprintf("page%d%s.md", j, fileLangCodeID) - - writeSource(b, fs, filepath.Join("content", fmt.Sprintf("sect%d", i), contentFilename), content) - } - - content := fmt.Sprintf(pageTemplate, fmt.Sprintf("Section %d", i), "[]", contentPagesContent[rand.Intn(3)]) - indexContentFilename := fmt.Sprintf("_index%s.md", fileLangCodeID) - writeSource(b, fs, filepath.Join("content", fmt.Sprintf("sect%d", i), indexContentFilename), content) - } - } - - sites[i] = h - } - - return sites -} diff --git a/hugolib/site_output.go b/hugolib/site_output.go index 497092e8c..3438ea9f7 100644 --- a/hugolib/site_output.go +++ b/hugolib/site_output.go @@ -1,4 +1,4 @@ -// Copyright 2017-present The Hugo Authors. All rights reserved. +// 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. @@ -15,74 +15,77 @@ package hugolib import ( "fmt" - "path" "strings" - "github.com/gohugoio/hugo/config" - "github.com/gohugoio/hugo/helpers" "github.com/gohugoio/hugo/output" + "github.com/gohugoio/hugo/resources/kinds" "github.com/spf13/cast" ) -func createDefaultOutputFormats(allFormats output.Formats, cfg config.Provider) map[string]output.Formats { - rssOut, _ := allFormats.GetByName(output.RSSFormat.Name) +func createDefaultOutputFormats(allFormats output.Formats) map[string]output.Formats { + rssOut, rssFound := allFormats.GetByName(output.RSSFormat.Name) htmlOut, _ := allFormats.GetByName(output.HTMLFormat.Name) robotsOut, _ := allFormats.GetByName(output.RobotsTxtFormat.Name) sitemapOut, _ := allFormats.GetByName(output.SitemapFormat.Name) + httpStatus404Out, _ := allFormats.GetByName(output.HTTPStatus404HTMLFormat.Name) - // TODO(bep) this mumbo jumbo is deprecated and should be removed, but there are tests that - // depends on this, so that will have to wait. - rssBase := cfg.GetString("rssURI") - if rssBase == "" || rssBase == "index.xml" { - rssBase = rssOut.BaseName - } else { - // Remove in Hugo 0.36. - helpers.Deprecated("Site config", "rssURI", "Set baseName in outputFormats.RSS", true) - // RSS has now a well defined media type, so strip any suffix provided - rssBase = strings.TrimSuffix(rssBase, path.Ext(rssBase)) + defaultListTypes := output.Formats{htmlOut} + if rssFound { + defaultListTypes = append(defaultListTypes, rssOut) } - rssOut.BaseName = rssBase - - return map[string]output.Formats{ - KindPage: output.Formats{htmlOut}, - KindHome: output.Formats{htmlOut, rssOut}, - KindSection: output.Formats{htmlOut, rssOut}, - KindTaxonomy: output.Formats{htmlOut, rssOut}, - KindTaxonomyTerm: output.Formats{htmlOut, rssOut}, - // Below are for conistency. They are currently not used during rendering. - kindRSS: output.Formats{rssOut}, - kindSitemap: output.Formats{sitemapOut}, - kindRobotsTXT: output.Formats{robotsOut}, - kind404: output.Formats{htmlOut}, + m := map[string]output.Formats{ + kinds.KindPage: {htmlOut}, + kinds.KindHome: defaultListTypes, + kinds.KindSection: defaultListTypes, + kinds.KindTerm: defaultListTypes, + kinds.KindTaxonomy: defaultListTypes, + // Below are for consistency. They are currently not used during rendering. + kinds.KindSitemap: {sitemapOut}, + kinds.KindRobotsTXT: {robotsOut}, + kinds.KindStatus404: {httpStatus404Out}, } + // May be disabled + if rssFound { + m[kinds.KindRSS] = output.Formats{rssOut} + } + + return m } -func createSiteOutputFormats(allFormats output.Formats, cfg config.Provider) (map[string]output.Formats, error) { - defaultOutputFormats := createDefaultOutputFormats(allFormats, cfg) +func createSiteOutputFormats(allFormats output.Formats, outputs map[string]any, rssDisabled bool) (map[string]output.Formats, error) { + defaultOutputFormats := createDefaultOutputFormats(allFormats) - if !cfg.IsSet("outputs") { + if outputs == nil { return defaultOutputFormats, nil } outFormats := make(map[string]output.Formats) - outputs := cfg.GetStringMap("outputs") - - if outputs == nil || len(outputs) == 0 { + if len(outputs) == 0 { return outFormats, nil } seen := make(map[string]bool) for k, v := range outputs { + k = kinds.GetKindAny(k) + if k == "" { + // Invalid kind + continue + } var formats output.Formats vals := cast.ToStringSlice(v) for _, format := range vals { f, found := allFormats.GetByName(format) if !found { - return nil, fmt.Errorf("Failed to resolve output format %q from site config", format) + if rssDisabled && strings.EqualFold(format, "RSS") { + // This is legacy behavior. We used to have both + // a RSS page kind and output format. + continue + } + return nil, fmt.Errorf("failed to resolve output format %q from site config", format) } formats = append(formats, f) } @@ -103,5 +106,4 @@ func createSiteOutputFormats(allFormats output.Formats, cfg config.Provider) (ma } return outFormats, nil - } diff --git a/hugolib/site_output_test.go b/hugolib/site_output_test.go index 7da6f105f..caec4c700 100644 --- a/hugolib/site_output_test.go +++ b/hugolib/site_output_test.go @@ -1,4 +1,4 @@ -// Copyright 2017-present The Hugo Authors. All rights reserved. +// 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. @@ -14,40 +14,42 @@ package hugolib import ( + "fmt" "strings" "testing" + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/common/hstrings" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/resources/kinds" + "github.com/spf13/afero" - "github.com/stretchr/testify/require" - - "fmt" - - "github.com/gohugoio/hugo/helpers" "github.com/gohugoio/hugo/output" - "github.com/spf13/viper" ) func TestSiteWithPageOutputs(t *testing.T) { for _, outputs := range [][]string{{"html", "json", "calendar"}, {"json"}} { + outputs := outputs t.Run(fmt.Sprintf("%v", outputs), func(t *testing.T) { + t.Parallel() doTestSiteWithPageOutputs(t, outputs) }) } } func doTestSiteWithPageOutputs(t *testing.T, outputs []string) { - t.Parallel() - outputsStr := strings.Replace(fmt.Sprintf("%q", outputs), " ", ", ", -1) siteConfig := ` baseURL = "http://example.com/blog" -paginate = 1 defaultContentLanguage = "en" -disableKinds = ["page", "section", "taxonomy", "taxonomyTerm", "RSS", "sitemap", "robotsTXT", "404"] +disableKinds = ["section", "term", "taxonomy", "RSS", "sitemap", "robotsTXT", "404"] + +[pagination] +pagerSize = 1 [Taxonomies] tag = "tags" @@ -55,6 +57,7 @@ category = "categories" defaultContentLanguage = "en" + [languages] [languages.en] @@ -81,19 +84,16 @@ outputs: %s ` - mf := afero.NewMemMapFs() - - writeToFs(t, mf, "i18n/en.toml", ` + b := newTestSitesBuilder(t).WithConfigFile("toml", siteConfig) + b.WithI18n("en.toml", ` [elbow] other = "Elbow" -`) - writeToFs(t, mf, "i18n/nn.toml", ` +`, "nn.toml", ` [elbow] other = "Olboge" `) - th, h := newTestSitesFromConfig(t, mf, siteConfig, - + b.WithTemplates( // Case issue partials #3333 "layouts/partials/GoHugo.html", `Go Hugo Partial`, "layouts/_default/baseof.json", `START JSON:{{block "main" .}}default content{{ end }}:END JSON`, @@ -125,69 +125,74 @@ List HTML|{{.Title }}| Partial Hugo 1: {{ partial "GoHugo.html" . }} Partial Hugo 2: {{ partial "GoHugo" . -}} Content: {{ .Content }} +Len Pages: {{ .Kind }} {{ len .Site.RegularPages }} Page Number: {{ .Paginator.PageNumber }} {{ end }} `, + "layouts/_default/single.html", `{{ define "main" }}{{ .Content }}{{ end }}`, ) - require.Len(t, h.Sites, 2) - fs := th.Fs + b.WithContent("_index.md", fmt.Sprintf(pageTemplate, "JSON Home", outputsStr)) + b.WithContent("_index.nn.md", fmt.Sprintf(pageTemplate, "JSON Nynorsk Heim", outputsStr)) - writeSource(t, fs, "content/_index.md", fmt.Sprintf(pageTemplate, "JSON Home", outputsStr)) - writeSource(t, fs, "content/_index.nn.md", fmt.Sprintf(pageTemplate, "JSON Nynorsk Heim", outputsStr)) + for i := 1; i <= 10; i++ { + b.WithContent(fmt.Sprintf("p%d.md", i), fmt.Sprintf(pageTemplate, fmt.Sprintf("Page %d", i), outputsStr)) + } - err := h.Build(BuildCfg{}) + b.Build(BuildCfg{}) - require.NoError(t, err) + s := b.H.Sites[0] + b.Assert(s.language.Lang, qt.Equals, "en") - s := h.Sites[0] - require.Equal(t, "en", s.Language.Lang) + home := s.getPageOldVersion(kinds.KindHome) - home := s.getPage(KindHome) - - require.NotNil(t, home) + b.Assert(home, qt.Not(qt.IsNil)) lenOut := len(outputs) - require.Len(t, home.outputFormats, lenOut) + b.Assert(len(home.OutputFormats()), qt.Equals, lenOut) // There is currently always a JSON output to make it simpler ... altFormats := lenOut - 1 - hasHTML := helpers.InStringArray(outputs, "html") - th.assertFileContent("public/index.json", + hasHTML := hstrings.InSlice(outputs, "html") + b.AssertFileContent("public/index.json", "List JSON", fmt.Sprintf("Alt formats: %d", altFormats), ) if hasHTML { - th.assertFileContent("public/index.json", - "Alt Output: HTML", - "Output/Rel: JSON/alternate|", - "Output/Rel: HTML/canonical|", + b.AssertFileContent("public/index.json", + "Alt Output: html", + "Output/Rel: json/alternate|", + "Output/Rel: html/canonical|", "en: Elbow", "ShortJSON", "OtherShort:

    Hi!

    ", ) - th.assertFileContent("public/index.html", + b.AssertFileContent("public/index.html", // The HTML entity is a deliberate part of this test: The HTML templates are // parsed with html/template. - `List HTML|JSON Home|`, + `List HTML|JSON Home|`, "en: Elbow", "ShortHTML", "OtherShort:

    Hi!

    ", + "Len Pages: home 10", ) - th.assertFileContent("public/nn/index.html", + b.AssertFileContent("public/page/2/index.html", "Page Number: 2") + b.Assert(b.CheckExists("public/page/2/index.json"), qt.Equals, false) + + b.AssertFileContent("public/nn/index.html", "List HTML|JSON Nynorsk Heim|", "nn: Olboge") } else { - th.assertFileContent("public/index.json", - "Output/Rel: JSON/canonical|", + b.AssertFileContent("public/index.json", + "Output/Rel: json/canonical|", // JSON is plain text, so no need to safeHTML this and that - ``, + ``, "ShortJSON", "OtherShort:

    Hi!

    ", ) - th.assertFileContent("public/nn/index.json", + b.AssertFileContent("public/nn/index.json", "List JSON|JSON Nynorsk Heim|", "nn: Olboge", "ShortJSON", @@ -195,26 +200,21 @@ Content: {{ .Content }} } of := home.OutputFormats() - require.Len(t, of, lenOut) - require.Nil(t, of.Get("Hugo")) - require.NotNil(t, of.Get("json")) - json := of.Get("JSON") - _, err = home.AlternativeOutputFormats() - require.Error(t, err) - require.NotNil(t, json) - require.Equal(t, "/blog/index.json", json.RelPermalink()) - require.Equal(t, "http://example.com/blog/index.json", json.Permalink()) - if helpers.InStringArray(outputs, "cal") { + json := of.Get("JSON") + b.Assert(json, qt.Not(qt.IsNil)) + b.Assert(json.RelPermalink(), qt.Equals, "/blog/index.json") + b.Assert(json.Permalink(), qt.Equals, "http://example.com/blog/index.json") + + if hstrings.InSlice(outputs, "cal") { cal := of.Get("calendar") - require.NotNil(t, cal) - require.Equal(t, "/blog/index.ics", cal.RelPermalink()) - require.Equal(t, "webcal://example.com/blog/index.ics", cal.Permalink()) + b.Assert(cal, qt.Not(qt.IsNil)) + b.Assert(cal.RelPermalink(), qt.Equals, "/blog/index.ics") + b.Assert(cal.Permalink(), qt.Equals, "webcal://example.com/blog/index.ics") } - require.True(t, home.HasShortcode("myShort")) - require.False(t, home.HasShortcode("doesNotExist")) - + b.Assert(home.HasShortcode("myShort"), qt.Equals, true) + b.Assert(home.HasShortcode("doesNotExist"), qt.Equals, false) } // Issue #3447 @@ -222,10 +222,12 @@ func TestRedefineRSSOutputFormat(t *testing.T) { siteConfig := ` baseURL = "http://example.com/blog" -paginate = 1 defaultContentLanguage = "en" -disableKinds = ["page", "section", "taxonomy", "taxonomyTerm", "sitemap", "robotsTXT", "404"] +disableKinds = ["page", "section", "term", "taxonomy", "sitemap", "robotsTXT", "404"] + +[pagination] +pagerSize = 1 [outputFormats] [outputFormats.RSS] @@ -234,6 +236,8 @@ baseName = "feed" ` + c := qt.New(t) + mf := afero.NewMemMapFs() writeToFs(t, mf, "content/foo.html", `foo`) @@ -241,15 +245,14 @@ baseName = "feed" err := h.Build(BuildCfg{}) - require.NoError(t, err) + c.Assert(err, qt.IsNil) th.assertFileContent("public/feed.xml", "Recent content on") s := h.Sites[0] - //Issue #3450 - require.Equal(t, "http://example.com/blog/feed.xml", s.Info.RSSLink) - + // Issue #3450 + c.Assert(s.Home().OutputFormats().Get("rss").Permalink(), qt.Equals, "http://example.com/blog/feed.xml") } // Issue #3614 @@ -257,21 +260,21 @@ func TestDotLessOutputFormat(t *testing.T) { siteConfig := ` baseURL = "http://example.com/blog" -paginate = 1 defaultContentLanguage = "en" -disableKinds = ["page", "section", "taxonomy", "taxonomyTerm", "sitemap", "robotsTXT", "404"] +disableKinds = ["page", "section", "term", "taxonomy", "sitemap", "robotsTXT", "404"] + +[pagination] +pagerSize = 1 [mediaTypes] [mediaTypes."text/nodot"] -suffix = "" delimiter = "" [mediaTypes."text/defaultdelim"] -suffix = "defd" +suffixes = ["defd"] [mediaTypes."text/nosuffix"] -suffix = "" [mediaTypes."text/customdelim"] -suffix = "del" +suffixes = ["del"] delimiter = "_" [outputs] @@ -293,6 +296,8 @@ baseName = "customdelimbase" ` + c := qt.New(t) + mf := afero.NewMemMapFs() writeToFs(t, mf, "content/foo.html", `foo`) writeToFs(t, mf, "layouts/_default/list.dotless", `a dotless`) @@ -304,95 +309,142 @@ baseName = "customdelimbase" err := h.Build(BuildCfg{}) - require.NoError(t, err) + c.Assert(err, qt.IsNil) + + s := h.Sites[0] th.assertFileContent("public/_redirects", "a dotless") th.assertFileContent("public/defaultdelimbase.defd", "default delimim") // This looks weird, but the user has chosen this definition. - th.assertFileContent("public/nosuffixbase.", "no suffix") + th.assertFileContent("public/nosuffixbase", "no suffix") th.assertFileContent("public/customdelimbase_del", "custom delim") - s := h.Sites[0] - home := s.getPage(KindHome) - require.NotNil(t, home) + home := s.getPageOldVersion(kinds.KindHome) + c.Assert(home, qt.Not(qt.IsNil)) outputs := home.OutputFormats() - require.Equal(t, "/blog/_redirects", outputs.Get("DOTLESS").RelPermalink()) - require.Equal(t, "/blog/defaultdelimbase.defd", outputs.Get("DEF").RelPermalink()) - require.Equal(t, "/blog/nosuffixbase.", outputs.Get("NOS").RelPermalink()) - require.Equal(t, "/blog/customdelimbase_del", outputs.Get("CUS").RelPermalink()) + c.Assert(outputs.Get("DOTLESS").RelPermalink(), qt.Equals, "/blog/_redirects") + c.Assert(outputs.Get("DEF").RelPermalink(), qt.Equals, "/blog/defaultdelimbase.defd") + c.Assert(outputs.Get("NOS").RelPermalink(), qt.Equals, "/blog/nosuffixbase") + c.Assert(outputs.Get("CUS").RelPermalink(), qt.Equals, "/blog/customdelimbase_del") +} +// Issue 8030 +func TestGetOutputFormatRel(t *testing.T) { + b := newTestSitesBuilder(t). + WithSimpleConfigFileAndSettings(map[string]any{ + "outputFormats": map[string]any{ + "HUMANS": map[string]any{ + "mediaType": "text/plain", + "baseName": "humans", + "isPlainText": true, + "rel": "author", + }, + }, + }).WithTemplates("index.html", ` +{{- with ($.Site.GetPage "humans").OutputFormats.Get "humans" -}} + +{{- end -}} +`).WithContent("humans.md", `--- +outputs: +- HUMANS +--- +This is my content. +`) + + b.Build(BuildCfg{}) + b.AssertFileContent("public/index.html", ` + +`) } func TestCreateSiteOutputFormats(t *testing.T) { - assert := require.New(t) + t.Run("Basic", func(t *testing.T) { + c := qt.New(t) - outputsConfig := map[string]interface{}{ - KindHome: []string{"HTML", "JSON"}, - KindSection: []string{"JSON"}, - } + outputsConfig := map[string]any{ + kinds.KindHome: []string{"HTML", "JSON"}, + kinds.KindSection: []string{"JSON"}, + } - cfg := viper.New() - cfg.Set("outputs", outputsConfig) + cfg := config.New() + cfg.Set("outputs", outputsConfig) - outputs, err := createSiteOutputFormats(output.DefaultFormats, cfg) - assert.NoError(err) - assert.Equal(output.Formats{output.JSONFormat}, outputs[KindSection]) - assert.Equal(output.Formats{output.HTMLFormat, output.JSONFormat}, outputs[KindHome]) + outputs, err := createSiteOutputFormats(output.DefaultFormats, cfg.GetStringMap("outputs"), false) + c.Assert(err, qt.IsNil) + c.Assert(outputs[kinds.KindSection], deepEqualsOutputFormats, output.Formats{output.JSONFormat}) + c.Assert(outputs[kinds.KindHome], deepEqualsOutputFormats, output.Formats{output.HTMLFormat, output.JSONFormat}) - // Defaults - assert.Equal(output.Formats{output.HTMLFormat, output.RSSFormat}, outputs[KindTaxonomy]) - assert.Equal(output.Formats{output.HTMLFormat, output.RSSFormat}, outputs[KindTaxonomyTerm]) - assert.Equal(output.Formats{output.HTMLFormat}, outputs[KindPage]) + // Defaults + c.Assert(outputs[kinds.KindTerm], deepEqualsOutputFormats, output.Formats{output.HTMLFormat, output.RSSFormat}) + c.Assert(outputs[kinds.KindTaxonomy], deepEqualsOutputFormats, output.Formats{output.HTMLFormat, output.RSSFormat}) + c.Assert(outputs[kinds.KindPage], deepEqualsOutputFormats, output.Formats{output.HTMLFormat}) - // These aren't (currently) in use when rendering in Hugo, - // but the pages needs to be assigned an output format, - // so these should also be correct/sensible. - assert.Equal(output.Formats{output.RSSFormat}, outputs[kindRSS]) - assert.Equal(output.Formats{output.SitemapFormat}, outputs[kindSitemap]) - assert.Equal(output.Formats{output.RobotsTxtFormat}, outputs[kindRobotsTXT]) - assert.Equal(output.Formats{output.HTMLFormat}, outputs[kind404]) + // These aren't (currently) in use when rendering in Hugo, + // but the pages needs to be assigned an output format, + // so these should also be correct/sensible. + c.Assert(outputs[kinds.KindRSS], deepEqualsOutputFormats, output.Formats{output.RSSFormat}) + c.Assert(outputs[kinds.KindSitemap], deepEqualsOutputFormats, output.Formats{output.SitemapFormat}) + c.Assert(outputs[kinds.KindRobotsTXT], deepEqualsOutputFormats, output.Formats{output.RobotsTxtFormat}) + c.Assert(outputs[kinds.KindStatus404], deepEqualsOutputFormats, output.Formats{output.HTTPStatus404HTMLFormat}) + }) + // Issue #4528 + t.Run("Mixed case", func(t *testing.T) { + c := qt.New(t) + cfg := config.New() + + outputsConfig := map[string]any{ + // Note that we in Hugo 0.53.0 renamed this Kind to "taxonomy", + // but keep this test to test the legacy mapping. + "taxonomyterm": []string{"JSON"}, + } + cfg.Set("outputs", outputsConfig) + + outputs, err := createSiteOutputFormats(output.DefaultFormats, cfg.GetStringMap("outputs"), false) + c.Assert(err, qt.IsNil) + c.Assert(outputs[kinds.KindTaxonomy], deepEqualsOutputFormats, output.Formats{output.JSONFormat}) + }) } func TestCreateSiteOutputFormatsInvalidConfig(t *testing.T) { - assert := require.New(t) + c := qt.New(t) - outputsConfig := map[string]interface{}{ - KindHome: []string{"FOO", "JSON"}, + outputsConfig := map[string]any{ + kinds.KindHome: []string{"FOO", "JSON"}, } - cfg := viper.New() + cfg := config.New() cfg.Set("outputs", outputsConfig) - _, err := createSiteOutputFormats(output.DefaultFormats, cfg) - assert.Error(err) + _, err := createSiteOutputFormats(output.DefaultFormats, cfg.GetStringMap("outputs"), false) + c.Assert(err, qt.Not(qt.IsNil)) } func TestCreateSiteOutputFormatsEmptyConfig(t *testing.T) { - assert := require.New(t) + c := qt.New(t) - outputsConfig := map[string]interface{}{ - KindHome: []string{}, + outputsConfig := map[string]any{ + kinds.KindHome: []string{}, } - cfg := viper.New() + cfg := config.New() cfg.Set("outputs", outputsConfig) - outputs, err := createSiteOutputFormats(output.DefaultFormats, cfg) - assert.NoError(err) - assert.Equal(output.Formats{output.HTMLFormat, output.RSSFormat}, outputs[KindHome]) + outputs, err := createSiteOutputFormats(output.DefaultFormats, cfg.GetStringMap("outputs"), false) + c.Assert(err, qt.IsNil) + c.Assert(outputs[kinds.KindHome], deepEqualsOutputFormats, output.Formats{output.HTMLFormat, output.RSSFormat}) } func TestCreateSiteOutputFormatsCustomFormats(t *testing.T) { - assert := require.New(t) + c := qt.New(t) - outputsConfig := map[string]interface{}{ - KindHome: []string{}, + outputsConfig := map[string]any{ + kinds.KindHome: []string{}, } - cfg := viper.New() + cfg := config.New() cfg.Set("outputs", outputsConfig) var ( @@ -400,7 +452,240 @@ func TestCreateSiteOutputFormatsCustomFormats(t *testing.T) { customHTML = output.Format{Name: "HTML", BaseName: "customHTML"} ) - outputs, err := createSiteOutputFormats(output.Formats{customRSS, customHTML}, cfg) - assert.NoError(err) - assert.Equal(output.Formats{customHTML, customRSS}, outputs[KindHome]) + outputs, err := createSiteOutputFormats(output.Formats{customRSS, customHTML}, cfg.GetStringMap("outputs"), false) + c.Assert(err, qt.IsNil) + c.Assert(outputs[kinds.KindHome], deepEqualsOutputFormats, output.Formats{customHTML, customRSS}) +} + +// https://github.com/gohugoio/hugo/issues/5849 +func TestOutputFormatPermalinkable(t *testing.T) { + config := ` +baseURL = "https://example.com" + + + +# DAMP is similar to AMP, but not permalinkable. +[outputFormats] +[outputFormats.damp] +mediaType = "text/html" +path = "damp" +[outputFormats.ramp] +mediaType = "text/html" +path = "ramp" +permalinkable = true +[outputFormats.base] +mediaType = "text/html" +isHTML = true +baseName = "that" +permalinkable = true +[outputFormats.nobase] +mediaType = "application/json" +permalinkable = true +isPlainText = true + +` + + b := newTestSitesBuilder(t).WithConfigFile("toml", config) + b.WithContent("_index.md", ` +--- +Title: Home Sweet Home +outputs: [ "html", "amp", "damp", "base" ] +--- + +`) + + b.WithContent("blog/html-amp.md", ` +--- +Title: AMP and HTML +outputs: [ "html", "amp" ] +--- + +`) + + b.WithContent("blog/html-damp.md", ` +--- +Title: DAMP and HTML +outputs: [ "html", "damp" ] +--- + +`) + + b.WithContent("blog/html-ramp.md", ` +--- +Title: RAMP and HTML +outputs: [ "html", "ramp" ] +--- + +`) + + b.WithContent("blog/html.md", ` +--- +Title: HTML only +outputs: [ "html" ] +--- + +`) + + b.WithContent("blog/amp.md", ` +--- +Title: AMP only +outputs: [ "amp" ] +--- + +`) + + b.WithContent("blog/html-base-nobase.md", ` +--- +Title: HTML, Base and Nobase +outputs: [ "html", "base", "nobase" ] +--- + +`) + + const commonTemplate = ` +This RelPermalink: {{ .RelPermalink }} +Output Formats: {{ len .OutputFormats }};{{ range .OutputFormats }}{{ .Name }};{{ .RelPermalink }}|{{ end }} + +` + + b.WithTemplatesAdded("index.html", commonTemplate) + b.WithTemplatesAdded("_default/single.html", commonTemplate) + b.WithTemplatesAdded("_default/single.json", commonTemplate) + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", + "This RelPermalink: /", + "Output Formats: 4;html;/|amp;/amp/|damp;/damp/|base;/that.html|", + ) + + b.AssertFileContent("public/amp/index.html", + "This RelPermalink: /amp/", + "Output Formats: 4;html;/|amp;/amp/|damp;/damp/|base;/that.html|", + ) + + b.AssertFileContent("public/blog/html-amp/index.html", + "Output Formats: 2;html;/blog/html-amp/|amp;/amp/blog/html-amp/|", + "This RelPermalink: /blog/html-amp/") + + b.AssertFileContent("public/amp/blog/html-amp/index.html", + "Output Formats: 2;html;/blog/html-amp/|amp;/amp/blog/html-amp/|", + "This RelPermalink: /amp/blog/html-amp/") + + // Damp is not permalinkable + b.AssertFileContent("public/damp/blog/html-damp/index.html", + "This RelPermalink: /blog/html-damp/", + "Output Formats: 2;html;/blog/html-damp/|damp;/damp/blog/html-damp/|") + + b.AssertFileContent("public/blog/html-ramp/index.html", + "This RelPermalink: /blog/html-ramp/", + "Output Formats: 2;html;/blog/html-ramp/|ramp;/ramp/blog/html-ramp/|") + + b.AssertFileContent("public/ramp/blog/html-ramp/index.html", + "This RelPermalink: /ramp/blog/html-ramp/", + "Output Formats: 2;html;/blog/html-ramp/|ramp;/ramp/blog/html-ramp/|") + + // https://github.com/gohugoio/hugo/issues/5877 + outputFormats := "Output Formats: 3;html;/blog/html-base-nobase/|base;/blog/html-base-nobase/that.html|nobase;/blog/html-base-nobase/index.json|" + + b.AssertFileContent("public/blog/html-base-nobase/index.json", + "This RelPermalink: /blog/html-base-nobase/index.json", + outputFormats, + ) + + b.AssertFileContent("public/blog/html-base-nobase/that.html", + "This RelPermalink: /blog/html-base-nobase/that.html", + outputFormats, + ) + + b.AssertFileContent("public/blog/html-base-nobase/index.html", + "This RelPermalink: /blog/html-base-nobase/", + outputFormats, + ) +} + +func TestSiteWithPageNoOutputs(t *testing.T) { + t.Parallel() + + b := newTestSitesBuilder(t) + b.WithConfigFile("toml", ` +baseURL = "https://example.com" + +[outputFormats.o1] +mediaType = "text/html" + + + +`) + b.WithContent("outputs-empty.md", `--- +title: "Empty Outputs" +outputs: [] +--- + +Word1. Word2. + +`, + "outputs-string.md", `--- +title: "Outputs String" +outputs: "o1" +--- + +Word1. Word2. + +`) + + b.WithTemplates("index.html", ` +{{ range .Site.RegularPages }} +WordCount: {{ .WordCount }} +{{ end }} +`) + + b.WithTemplates("_default/single.html", `HTML: {{ .Content }}`) + b.WithTemplates("_default/single.o1.html", `O1: {{ .Content }}`) + + b.Build(BuildCfg{}) + + b.AssertFileContent( + "public/index.html", + " WordCount: 2") + + b.AssertFileContent("public/outputs-empty/index.html", "HTML:", "Word1. Word2.") + b.AssertFileContent("public/outputs-string/index.html", "O1:", "Word1. Word2.") +} + +func TestOuputFormatFrontMatterTermIssue12275(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['home','page','rss','section','sitemap','taxonomy'] +-- content/p1.md -- +--- +title: p1 +tags: + - tag-a + - tag-b +--- +-- content/tags/tag-a/_index.md -- +--- +title: tag-a +outputs: + - html + - json +--- +-- content/tags/tag-b/_index.md -- +--- +title: tag-b +--- +-- layouts/_default/term.html -- +{{ .Title }} +-- layouts/_default/term.json -- +{{ jsonify (dict "title" .Title) }} +` + + b := Test(t, files) + + b.AssertFileContent("public/tags/tag-a/index.html", "tag-a") + b.AssertFileContent("public/tags/tag-b/index.html", "tag-b") + b.AssertFileContent("public/tags/tag-a/index.json", `{"title":"tag-a"}`) // failing test } diff --git a/hugolib/site_render.go b/hugolib/site_render.go index a2031e0c0..6dbb19827 100644 --- a/hugolib/site_render.go +++ b/hugolib/site_render.go @@ -1,4 +1,4 @@ -// Copyright 2016 The Hugo Authors. All rights reserved. +// 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. @@ -14,43 +14,96 @@ package hugolib import ( + "context" "fmt" "path" "strings" "sync" - "github.com/gohugoio/hugo/output" + "github.com/bep/logg" + "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/hugolib/doctree" + "github.com/gohugoio/hugo/tpl/tplimpl" + + "github.com/gohugoio/hugo/config" + + "github.com/gohugoio/hugo/resources/kinds" + "github.com/gohugoio/hugo/resources/page" ) -// renderPages renders pages each corresponding to a markdown file. -// TODO(bep np doc -func (s *Site) renderPages(cfg *BuildCfg) error { +type siteRenderContext struct { + cfg *BuildCfg + + infol logg.LevelLogger + + // languageIdx is the zero based index of the site. + languageIdx int + + // Zero based index for all output formats combined. + sitesOutIdx int + + // Zero based index of the output formats configured within a Site. + // Note that these outputs are sorted. + outIdx int + + multihost bool +} + +// Whether to render 404.html, robotsTXT.txt and similar. +// These are usually rendered once in the root of public. +func (s siteRenderContext) shouldRenderStandalonePage(kind string) bool { + if s.multihost || kind == kinds.KindSitemap { + // 1 per site + return s.outIdx == 0 + } + + if kind == kinds.KindTemporary || kind == kinds.KindStatus404 { + // 1 for all output formats + return s.outIdx == 0 + } + + // 1 for all sites and output formats. + return s.languageIdx == 0 && s.outIdx == 0 +} + +// renderPages renders pages concurrently. +func (s *Site) renderPages(ctx *siteRenderContext) error { + numWorkers := config.GetNumWorkerMultiplier() results := make(chan error) - pages := make(chan *Page) + pages := make(chan *pageState, numWorkers) // buffered for performance errs := make(chan error) - go errorCollator(results, errs) - - numWorkers := getGoMaxProcs() * 4 + go s.errorCollator(results, errs) wg := &sync.WaitGroup{} - for i := 0; i < numWorkers; i++ { + for range numWorkers { wg.Add(1) - go pageRenderer(s, pages, results, wg) + go pageRenderer(ctx, s, pages, results, wg) } - if len(s.headlessPages) > 0 { - wg.Add(1) - go headlessPagesPublisher(s, wg) + cfg := ctx.cfg + w := &doctree.NodeShiftTreeWalker[contentNodeI]{ + Tree: s.pageMap.treePages, + Handle: func(key string, n contentNodeI, match doctree.DimensionFlag) (bool, error) { + if p, ok := n.(*pageState); ok { + if cfg.shouldRender(ctx.infol, p) { + select { + case <-s.h.Done(): + return true, nil + default: + pages <- p + } + } + } + return false, nil + }, } - for _, page := range s.Pages { - if cfg.shouldRender(page) { - pages <- page - } + if err := w.Walk(context.Background()); err != nil { + return err } close(pages) @@ -61,376 +114,250 @@ func (s *Site) renderPages(cfg *BuildCfg) error { err := <-errs if err != nil { - return fmt.Errorf("Error(s) rendering pages: %s", err) + return fmt.Errorf("failed to render pages: %w", herrors.ImproveRenderErr(err)) } return nil } -func headlessPagesPublisher(s *Site, wg *sync.WaitGroup) { +func pageRenderer( + ctx *siteRenderContext, + s *Site, + pages <-chan *pageState, + results chan<- error, + wg *sync.WaitGroup, +) { defer wg.Done() - for _, page := range s.headlessPages { - outFormat := page.outputFormats[0] // There is only one - pageOutput, err := newPageOutput(page, false, outFormat) - if err == nil { - page.mainPageOutput = pageOutput - err = pageOutput.renderResources() + + for p := range pages { + if p.m.isStandalone() && !ctx.shouldRenderStandalonePage(p.Kind()) { + continue } + if p.m.pageConfig.Build.PublishResources { + if err := p.renderResources(); err != nil { + s.SendError(p.errorf(err, "failed to render page resources")) + continue + } + } + + if !p.render { + // Nothing more to do for this page. + continue + } + + templ, found, err := p.resolveTemplate() if err != nil { - s.Log.ERROR.Printf("Failed to render resources for headless page %q: %s", page, err) + s.SendError(p.errorf(err, "failed to resolve template")) + continue + } + + if !found { + s.Log.Trace( + func() string { + return fmt.Sprintf("no layout for kind %q found", p.Kind()) + }, + ) + // Don't emit warning for missing 404 etc. pages. + if !p.m.isStandalone() { + s.logMissingLayout("", p.Layout(), p.Kind(), p.f.Name) + } + continue + } + + targetPath := p.targetPaths().TargetFilename + + s.Log.Trace( + func() string { + return fmt.Sprintf("rendering outputFormat %q kind %q using layout %q to %q", p.pageOutput.f.Name, p.Kind(), templ.Name(), targetPath) + }, + ) + + var d any = p + switch p.Kind() { + case kinds.KindSitemapIndex: + d = s.h.Sites + } + + if err := s.renderAndWritePage(&s.PathSpec.ProcessingStats.Pages, "page "+p.Title(), targetPath, p, d, templ); err != nil { + results <- err + } + + if p.paginator != nil && p.paginator.current != nil { + if err := s.renderPaginator(p, templ); err != nil { + results <- err + } } } } -func pageRenderer(s *Site, pages <-chan *Page, results chan<- error, wg *sync.WaitGroup) { - defer wg.Done() - - for page := range pages { - - for i, outFormat := range page.outputFormats { - - var ( - pageOutput *PageOutput - err error - ) - - if i == 0 { - pageOutput, err = newPageOutput(page, false, outFormat) - page.mainPageOutput = pageOutput - } - - if outFormat != page.s.rc.Format { - // Will be rendered ... later. - continue - } - - if pageOutput == nil { - pageOutput, err = page.mainPageOutput.copyWithFormat(outFormat) - } - - if err != nil { - s.Log.ERROR.Printf("Failed to create output page for type %q for page %q: %s", outFormat.Name, page, err) - continue - } - - // We only need to re-publish the resources if the output format is different - // from all of the previous (e.g. the "amp" use case). - shouldRender := i == 0 - if i > 0 { - for j := i; j >= 0; j-- { - if outFormat.Path != page.outputFormats[j].Path { - shouldRender = true - } else { - shouldRender = false - } - } - } - - if shouldRender { - if err := pageOutput.renderResources(); err != nil { - s.Log.ERROR.Printf("Failed to render resources for page %q: %s", page, err) - continue - } - } - - var layouts []string - - if page.selfLayout != "" { - layouts = []string{page.selfLayout} - } else { - layouts, err = s.layouts(pageOutput) - if err != nil { - s.Log.ERROR.Printf("Failed to resolve layout output %q for page %q: %s", outFormat.Name, page, err) - continue - } - } - - switch pageOutput.outputFormat.Name { - - case "RSS": - if err := s.renderRSS(pageOutput); err != nil { - results <- err - } - default: - targetPath, err := pageOutput.targetPath() - if err != nil { - s.Log.ERROR.Printf("Failed to create target path for output %q for page %q: %s", outFormat.Name, page, err) - continue - } - - s.Log.DEBUG.Printf("Render %s to %q with layouts %q", pageOutput.Kind, targetPath, layouts) - - if err := s.renderAndWritePage(&s.PathSpec.ProcessingStats.Pages, "page "+pageOutput.FullFilePath(), targetPath, pageOutput, layouts...); err != nil { - results <- err - } - - if pageOutput.IsNode() { - if err := s.renderPaginator(pageOutput); err != nil { - results <- err - } - } - } - - } +func (s *Site) logMissingLayout(name, layout, kind, outputFormat string) { + log := s.Log.Warn() + if name != "" && infoOnMissingLayout[name] { + log = s.Log.Info() } + + errMsg := "You should create a template file which matches Hugo Layouts Lookup Rules for this combination." + var args []any + msg := "found no layout file for" + if outputFormat != "" { + msg += " %q" + args = append(args, outputFormat) + } + + if layout != "" { + msg += " for layout %q" + args = append(args, layout) + } + + if kind != "" { + msg += " for kind %q" + args = append(args, kind) + } + + if name != "" { + msg += " for %q" + args = append(args, name) + } + + msg += ": " + errMsg + + log.Logf(msg, args...) } // renderPaginator must be run after the owning Page has been rendered. -func (s *Site) renderPaginator(p *PageOutput) error { - if p.paginator != nil { - s.Log.DEBUG.Printf("Render paginator for page %q", p.Path()) - paginatePath := s.Cfg.GetString("paginatePath") +func (s *Site) renderPaginator(p *pageState, templ *tplimpl.TemplInfo) error { + paginatePath := s.Conf.Pagination().Path - // write alias for page 1 - addend := fmt.Sprintf("/%s/%d", paginatePath, 1) - target, err := p.createTargetPath(p.outputFormat, false, addend) - if err != nil { - return err - } + d := p.targetPathDescriptor + f := p.outputFormat() + d.Type = f - // TODO(bep) do better - link := newOutputFormat(p.Page, p.outputFormat).Permalink() - if err := s.writeDestAlias(target, link, nil); err != nil { - return err - } - - pagers := p.paginator.Pagers() - - for i, pager := range pagers { - if i == 0 { - // already created - continue - } - - pagerNode, err := p.copy() - if err != nil { - return err - } - - pagerNode.origOnCopy = p.Page - - pagerNode.paginator = pager - if pager.TotalPages() > 0 { - first, _ := pager.page(0) - pagerNode.Date = first.Date - pagerNode.Lastmod = first.Lastmod - } - - pageNumber := i + 1 - addend := fmt.Sprintf("/%s/%d", paginatePath, pageNumber) - targetPath, _ := p.targetPath(addend) - layouts, err := p.layouts() - - if err != nil { - return err - } - - if err := s.renderAndWritePage( - &s.PathSpec.ProcessingStats.PaginatorPages, - pagerNode.title, - targetPath, pagerNode, layouts...); err != nil { - return err - } - - } + if p.paginator.current == nil || p.paginator.current != p.paginator.current.First() { + panic(fmt.Sprintf("invalid paginator state for %q", p.pathOrTitle())) } + + if f.IsHTML && !s.Conf.Pagination().DisableAliases { + // Write alias for page 1 + d.Addends = fmt.Sprintf("/%s/%d", paginatePath, 1) + targetPaths := page.CreateTargetPaths(d) + + if err := s.writeDestAlias(targetPaths.TargetFilename, p.Permalink(), f, p); err != nil { + return err + } + + } + + // Render pages for the rest + for current := p.paginator.current.Next(); current != nil; current = current.Next() { + + p.paginator.current = current + d.Addends = fmt.Sprintf("/%s/%d", paginatePath, current.PageNumber()) + targetPaths := page.CreateTargetPaths(d) + + if err := s.renderAndWritePage( + &s.PathSpec.ProcessingStats.PaginatorPages, + p.Title(), + targetPaths.TargetFilename, p, p, templ); err != nil { + return err + } + + } + return nil } -func (s *Site) renderRSS(p *PageOutput) error { - - if !s.isEnabled(kindRSS) { - return nil - } - - p.Kind = kindRSS - - limit := s.Cfg.GetInt("rssLimit") - if limit >= 0 && len(p.Pages) > limit { - p.Pages = p.Pages[:limit] - p.Data["Pages"] = p.Pages - } - - layouts, err := s.layoutHandler.For( - p.layoutDescriptor, - p.outputFormat) - if err != nil { - return err - } - - targetPath, err := p.targetPath() - if err != nil { - return err - } - - return s.renderAndWriteXML(&s.PathSpec.ProcessingStats.Pages, p.title, - targetPath, p, layouts...) -} - -func (s *Site) render404() error { - if !s.isEnabled(kind404) { - return nil - } - - p := s.newNodePage(kind404) - - p.title = "404 Page not found" - p.Data["Pages"] = s.Pages - p.Pages = s.Pages - p.URLPath.URL = "404.html" - - if err := p.initTargetPathDescriptor(); err != nil { - return err - } - - nfLayouts := []string{"404.html"} - - htmlOut := output.HTMLFormat - htmlOut.BaseName = "404" - - pageOutput, err := newPageOutput(p, false, htmlOut) - if err != nil { - return err - } - - targetPath, err := pageOutput.targetPath() - if err != nil { - s.Log.ERROR.Printf("Failed to create target path for page %q: %s", p, err) - } - - return s.renderAndWritePage(&s.PathSpec.ProcessingStats.Pages, "404 page", targetPath, pageOutput, s.appendThemeTemplates(nfLayouts)...) -} - -func (s *Site) renderSitemap() error { - if !s.isEnabled(kindSitemap) { - return nil - } - - sitemapDefault := parseSitemap(s.Cfg.GetStringMap("sitemap")) - - n := s.newNodePage(kindSitemap) - - // Include all pages (regular, home page, taxonomies etc.) - pages := s.Pages - - page := s.newNodePage(kindSitemap) - page.URLPath.URL = "" - if err := page.initTargetPathDescriptor(); err != nil { - return err - } - page.Sitemap.ChangeFreq = sitemapDefault.ChangeFreq - page.Sitemap.Priority = sitemapDefault.Priority - page.Sitemap.Filename = sitemapDefault.Filename - - n.Data["Pages"] = pages - n.Pages = pages - - // TODO(bep) we have several of these - if err := page.initTargetPathDescriptor(); err != nil { - return err - } - - // TODO(bep) this should be done somewhere else - for _, page := range pages { - if page.Sitemap.ChangeFreq == "" { - page.Sitemap.ChangeFreq = sitemapDefault.ChangeFreq - } - - if page.Sitemap.Priority == -1 { - page.Sitemap.Priority = sitemapDefault.Priority - } - - if page.Sitemap.Filename == "" { - page.Sitemap.Filename = sitemapDefault.Filename - } - } - - smLayouts := []string{"sitemap.xml", "_default/sitemap.xml", "_internal/_default/sitemap.xml"} - addLanguagePrefix := n.Site.IsMultiLingual() - - return s.renderAndWriteXML(&s.PathSpec.ProcessingStats.Sitemaps, "sitemap", - n.addLangPathPrefixIfFlagSet(page.Sitemap.Filename, addLanguagePrefix), n, s.appendThemeTemplates(smLayouts)...) -} - -func (s *Site) renderRobotsTXT() error { - if !s.isEnabled(kindRobotsTXT) { - return nil - } - - if !s.Cfg.GetBool("enableRobotsTXT") { - return nil - } - - p := s.newNodePage(kindRobotsTXT) - if err := p.initTargetPathDescriptor(); err != nil { - return err - } - p.Data["Pages"] = s.Pages - p.Pages = s.Pages - - rLayouts := []string{"robots.txt", "_default/robots.txt", "_internal/_default/robots.txt"} - - pageOutput, err := newPageOutput(p, false, output.RobotsTxtFormat) - if err != nil { - return err - } - - targetPath, err := pageOutput.targetPath() - if err != nil { - s.Log.ERROR.Printf("Failed to create target path for page %q: %s", p, err) - } - - return s.renderAndWritePage(&s.PathSpec.ProcessingStats.Pages, "Robots Txt", targetPath, pageOutput, s.appendThemeTemplates(rLayouts)...) - -} - // renderAliases renders shell pages that simply have a redirect in the header. func (s *Site) renderAliases() error { - for _, p := range s.Pages { - if len(p.Aliases) == 0 { - continue - } + w := &doctree.NodeShiftTreeWalker[contentNodeI]{ + Tree: s.pageMap.treePages, + Handle: func(key string, n contentNodeI, match doctree.DimensionFlag) (bool, error) { + p := n.(*pageState) - for _, f := range p.outputFormats { - if !f.IsHTML { - continue + // We cannot alias a page that's not rendered. + if p.m.noLink() || p.skipRender() { + return false, nil } - o := newOutputFormat(p, f) - plink := o.Permalink() + if len(p.Aliases()) == 0 { + return false, nil + } - for _, a := range p.Aliases { - if f.Path != "" { - // Make sure AMP and similar doesn't clash with regular aliases. - a = path.Join(a, f.Path) + pathSeen := make(map[string]bool) + for _, of := range p.OutputFormats() { + if !of.Format.IsHTML { + continue } - lang := p.Lang() + f := of.Format - if s.owner.multihost && !strings.HasPrefix(a, "/"+lang) { - // These need to be in its language root. - a = path.Join(lang, a) + if pathSeen[f.Path] { + continue } + pathSeen[f.Path] = true - if err := s.writeDestAlias(a, plink, p); err != nil { - return err + plink := of.Permalink() + + for _, a := range p.Aliases() { + isRelative := !strings.HasPrefix(a, "/") + + if isRelative { + // Make alias relative, where "." will be on the + // same directory level as the current page. + basePath := path.Join(p.targetPaths().SubResourceBaseLink, "..") + a = path.Join(basePath, a) + + } else { + // Make sure AMP and similar doesn't clash with regular aliases. + a = path.Join(f.Path, a) + } + + if s.conf.C.IsUglyURLSection(p.Section()) && !strings.HasSuffix(a, ".html") { + a += ".html" + } + + lang := p.Language().Lang + + if s.h.Configs.IsMultihost && !strings.HasPrefix(a, "/"+lang) { + // These need to be in its language root. + a = path.Join(lang, a) + } + + err := s.writeDestAlias(a, plink, f, p) + if err != nil { + return true, err + } } } - } + return false, nil + }, + } + return w.Walk(context.TODO()) +} + +// renderMainLanguageRedirect creates a redirect to the main language home, +// depending on if it lives in sub folder (e.g. /en) or not. +func (s *Site) renderMainLanguageRedirect() error { + if s.conf.DisableDefaultLanguageRedirect { + return nil + } + if s.h.Conf.IsMultihost() || !(s.h.Conf.DefaultContentLanguageInSubdir() || s.h.Conf.IsMultilingual()) { + // No need for a redirect + return nil } - if s.owner.multilingual.enabled() && !s.owner.IsMultihost() { - mainLang := s.owner.multilingual.DefaultLang - if s.Info.defaultContentLanguageInSubdir { - mainLangURL := s.PathSpec.AbsURL(mainLang.Lang, false) - s.Log.DEBUG.Printf("Write redirect to main language %s: %s", mainLang, mainLangURL) - if err := s.publishDestAlias(true, "/", mainLangURL, nil); err != nil { + html, found := s.conf.OutputFormats.Config.GetByName("html") + if found { + mainLang := s.conf.DefaultContentLanguage + if s.conf.DefaultContentLanguageInSubdir { + mainLangURL := s.PathSpec.AbsURL(mainLang+"/", false) + s.Log.Debugf("Write redirect to main language %s: %s", mainLang, mainLangURL) + if err := s.publishDestAlias(true, "/", mainLangURL, html, nil); err != nil { return err } } else { mainLangURL := s.PathSpec.AbsURL("", false) - s.Log.DEBUG.Printf("Write redirect to main language %s: %s", mainLang, mainLangURL) - if err := s.publishDestAlias(true, mainLang.Lang, mainLangURL, nil); err != nil { + s.Log.Debugf("Write redirect to main language %s: %s", mainLang, mainLangURL) + if err := s.publishDestAlias(true, mainLang, mainLangURL, html, nil); err != nil { return err } } diff --git a/hugolib/site_sections.go b/hugolib/site_sections.go index c8bca03e3..385f3f291 100644 --- a/hugolib/site_sections.go +++ b/hugolib/site_sections.go @@ -1,4 +1,4 @@ -// Copyright 2017 The Hugo Authors. All rights reserved. +// 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. @@ -14,326 +14,17 @@ package hugolib import ( - "fmt" - "path" - "strconv" - "strings" - - "github.com/gohugoio/hugo/helpers" - - radix "github.com/hashicorp/go-immutable-radix" + "github.com/gohugoio/hugo/resources/page" ) // Sections returns the top level sections. -func (s *SiteInfo) Sections() Pages { - home, err := s.Home() - if err == nil { - return home.Sections() - } - return nil +func (s *Site) Sections() page.Pages { + s.CheckReady() + return s.Home().Sections() } // Home is a shortcut to the home page, equivalent to .Site.GetPage "home". -func (s *SiteInfo) Home() (*Page, error) { - return s.GetPage(KindHome) -} - -// Parent returns a section's parent section or a page's section. -// To get a section's subsections, see Page's Sections method. -func (p *Page) Parent() *Page { - return p.parent -} - -// 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. -func (p *Page) CurrentSection() *Page { - v := p - if v.origOnCopy != nil { - v = v.origOnCopy - } - if v.IsHome() || v.IsSection() { - return v - } - - return v.parent -} - -// InSection returns whether the given page is in the current section. -// Note that this will always return false for pages that are -// not either regular, home or section pages. -func (p *Page) InSection(other interface{}) (bool, error) { - if p == nil || other == nil { - return false, nil - } - - pp, err := unwrapPage(other) - if err != nil { - return false, err - } - - if pp == nil { - return false, nil - } - - return pp.CurrentSection() == p.CurrentSection(), nil -} - -// IsDescendant returns whether the current page is a descendant of the given page. -// Note that this method is not relevant for taxonomy lists and taxonomy terms pages. -func (p *Page) IsDescendant(other interface{}) (bool, error) { - pp, err := unwrapPage(other) - if err != nil { - return false, err - } - - if pp.Kind == KindPage && len(p.sections) == len(pp.sections) { - // A regular page is never its section's descendant. - return false, nil - } - return helpers.HasStringsPrefix(p.sections, pp.sections), nil -} - -// IsAncestor returns whether the current page is an ancestor of the given page. -// Note that this method is not relevant for taxonomy lists and taxonomy terms pages. -func (p *Page) IsAncestor(other interface{}) (bool, error) { - pp, err := unwrapPage(other) - if err != nil { - return false, err - } - - if p.Kind == KindPage && len(p.sections) == len(pp.sections) { - // A regular page is never its section's ancestor. - return false, nil - } - - return helpers.HasStringsPrefix(pp.sections, p.sections), nil -} - -// Eq returns whether the current page equals the given page. -// Note that this is more accurate than doing `{{ if eq $page $otherPage }}` -// since a Page can be embedded in another type. -func (p *Page) Eq(other interface{}) bool { - pp, err := unwrapPage(other) - if err != nil { - return false - } - - return p == pp -} - -func unwrapPage(in interface{}) (*Page, error) { - if po, ok := in.(*PageOutput); ok { - in = po.Page - } - - pp, ok := in.(*Page) - if !ok { - return nil, fmt.Errorf("%T not supported", in) - } - return pp, nil -} - -// Sections returns this section's subsections, if any. -// Note that for non-sections, this method will always return an empty list. -func (p *Page) Sections() Pages { - return p.subSections -} - -func (s *Site) assembleSections() Pages { - var newPages Pages - - if !s.isEnabled(KindSection) { - return newPages - } - - // Maps section kind pages to their path, i.e. "my/section" - sectionPages := make(map[string]*Page) - - // The sections with content files will already have been created. - for _, sect := range s.findPagesByKind(KindSection) { - sectionPages[path.Join(sect.sections...)] = sect - } - - const ( - sectKey = "__hs" - sectSectKey = "_a" + sectKey - sectPageKey = "_b" + sectKey - ) - - var ( - inPages = radix.New().Txn() - inSections = radix.New().Txn() - undecided Pages - ) - - home := s.findFirstPageByKindIn(KindHome, s.Pages) - - for i, p := range s.Pages { - if p.Kind != KindPage { - continue - } - - if len(p.sections) == 0 { - // Root level pages. These will have the home page as their Parent. - p.parent = home - continue - } - - sectionKey := path.Join(p.sections...) - sect, found := sectionPages[sectionKey] - - if !found && len(p.sections) == 1 { - // We only create content-file-less sections for the root sections. - sect = s.newSectionPage(p.sections[0]) - sectionPages[sectionKey] = sect - newPages = append(newPages, sect) - found = true - } - - if len(p.sections) > 1 { - // Create the root section if not found. - _, rootFound := sectionPages[p.sections[0]] - if !rootFound { - sect = s.newSectionPage(p.sections[0]) - sectionPages[p.sections[0]] = sect - newPages = append(newPages, sect) - } - } - - if found { - pagePath := path.Join(sectionKey, sectPageKey, strconv.Itoa(i)) - inPages.Insert([]byte(pagePath), p) - } else { - undecided = append(undecided, p) - } - } - - // Create any missing sections in the tree. - // A sub-section needs a content file, but to create a navigational tree, - // given a content file in /content/a/b/c/_index.md, we cannot create just - // the c section. - for _, sect := range sectionPages { - for i := len(sect.sections); i > 0; i-- { - sectionPath := sect.sections[:i] - sectionKey := path.Join(sectionPath...) - sect, found := sectionPages[sectionKey] - if !found { - sect = s.newSectionPage(sectionPath[len(sectionPath)-1]) - sect.sections = sectionPath - sectionPages[sectionKey] = sect - newPages = append(newPages, sect) - } - } - } - - for k, sect := range sectionPages { - inPages.Insert([]byte(path.Join(k, sectSectKey)), sect) - inSections.Insert([]byte(k), sect) - } - - var ( - currentSection *Page - children Pages - rootSections = inSections.Commit().Root() - ) - - for i, p := range undecided { - // Now we can decide where to put this page into the tree. - sectionKey := path.Join(p.sections...) - _, v, _ := rootSections.LongestPrefix([]byte(sectionKey)) - sect := v.(*Page) - pagePath := path.Join(path.Join(sect.sections...), sectSectKey, "u", strconv.Itoa(i)) - inPages.Insert([]byte(pagePath), p) - } - - var rootPages = inPages.Commit().Root() - - rootPages.Walk(func(path []byte, v interface{}) bool { - p := v.(*Page) - - if p.Kind == KindSection { - if currentSection != nil { - // A new section - currentSection.setPagePages(children) - } - - currentSection = p - children = make(Pages, 0) - - return false - - } - - // Regular page - p.parent = currentSection - children = append(children, p) - return false - }) - - if currentSection != nil { - currentSection.setPagePages(children) - } - - // Build the sections hierarchy - for _, sect := range sectionPages { - if len(sect.sections) == 1 { - sect.parent = home - } else { - parentSearchKey := path.Join(sect.sections[:len(sect.sections)-1]...) - _, v, _ := rootSections.LongestPrefix([]byte(parentSearchKey)) - p := v.(*Page) - sect.parent = p - } - - if sect.parent != nil { - sect.parent.subSections = append(sect.parent.subSections, sect) - } - } - - var ( - sectionsParamId = "mainSections" - sectionsParamIdLower = strings.ToLower(sectionsParamId) - mainSections interface{} - mainSectionsFound bool - maxSectionWeight int - ) - - mainSections, mainSectionsFound = s.Info.Params[sectionsParamIdLower] - - for _, sect := range sectionPages { - if sect.parent != nil { - sect.parent.subSections.Sort() - } - - for i, p := range sect.Pages { - if i > 0 { - p.NextInSection = sect.Pages[i-1] - } - if i < len(sect.Pages)-1 { - p.PrevInSection = sect.Pages[i+1] - } - } - - if !mainSectionsFound { - weight := len(sect.Pages) + (len(sect.Sections()) * 5) - if weight >= maxSectionWeight { - mainSections = []string{sect.Section()} - maxSectionWeight = weight - } - } - } - - // Try to make this as backwards compatible as possible. - s.Info.Params[sectionsParamId] = mainSections - s.Info.Params[sectionsParamIdLower] = mainSections - - return newPages - -} - -func (p *Page) setPagePages(pages Pages) { - pages.Sort() - p.Pages = pages - p.Data = make(map[string]interface{}) - p.Data["Pages"] = pages +func (s *Site) Home() page.Page { + s.CheckReady() + return s.s.home } diff --git a/hugolib/site_sections_test.go b/hugolib/site_sections_test.go index a1b80407c..0bf166092 100644 --- a/hugolib/site_sections_test.go +++ b/hugolib/site_sections_test.go @@ -1,4 +1,4 @@ -// Copyright 2017-present The Hugo Authors. All rights reserved. +// 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. @@ -19,21 +19,23 @@ import ( "strings" "testing" + qt "github.com/frankban/quicktest" "github.com/gohugoio/hugo/deps" - "github.com/stretchr/testify/require" + "github.com/gohugoio/hugo/htesting" + "github.com/gohugoio/hugo/resources/kinds" + "github.com/gohugoio/hugo/resources/page" ) func TestNestedSections(t *testing.T) { - t.Parallel() - var ( - assert = require.New(t) + c = qt.New(t) cfg, fs = newTestCfg() - th = testHelper{cfg, fs, t} ) + tt := htesting.NewPinnedRunner(c, "") + cfg.Set("permalinks", map[string]string{ - "perm a": ":sections/:title", + "perm-a": ":sections/:title", }) pageTemplate := `--- @@ -104,200 +106,318 @@ Content writeSource(t, fs, filepath.Join("layouts", "_default", "single.html"), "Single|{{ .Title }}") writeSource(t, fs, filepath.Join("layouts", "_default", "list.html"), ` -{{ $sect := (.Site.GetPage "section" "l1" "l2") }} +{{ $sect := (.Site.GetPage "l1/l2") }} List|{{ .Title }}|L1/l2-IsActive: {{ .InSection $sect }} {{ range .Paginator.Pages }} PAG|{{ .Title }}|{{ $sect.InSection . }} {{ end }} +{{/* https://github.com/gohugoio/hugo/issues/4989 */}} +{{ $sections := (.Site.GetPage "section" .Section).Sections.ByWeight }} `) - cfg.Set("paginate", 2) + cfg.Set("pagination.pagerSize", 2) - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{}) + th, configs := newTestHelperFromProvider(cfg, fs, t) - require.Len(t, s.RegularPages, 21) + s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Configs: configs}, BuildCfg{}) + + c.Assert(len(s.RegularPages()), qt.Equals, 21) tests := []struct { sections string - verify func(p *Page) + verify func(c *qt.C, p page.Page) }{ - {"elsewhere", func(p *Page) { - assert.Len(p.Pages, 1) - for _, p := range p.Pages { - assert.Equal([]string{"elsewhere"}, p.sections) + {"elsewhere", func(c *qt.C, p page.Page) { + c.Assert(len(p.Pages()), qt.Equals, 1) + for _, p := range p.Pages() { + c.Assert(p.SectionsPath(), qt.Equals, "/elsewhere") } }}, - {"post", func(p *Page) { - assert.Len(p.Pages, 2) - for _, p := range p.Pages { - assert.Equal("post", p.Section()) + {"post", func(c *qt.C, p page.Page) { + c.Assert(len(p.Pages()), qt.Equals, 2) + for _, p := range p.Pages() { + c.Assert(p.Section(), qt.Equals, "post") } }}, - {"empty1", func(p *Page) { + {"empty1", func(c *qt.C, p page.Page) { // > b,c - assert.NotNil(p.s.getPage(KindSection, "empty1", "b")) - assert.NotNil(p.s.getPage(KindSection, "empty1", "b", "c")) - + c.Assert(getPage(p, "/empty1/b"), qt.IsNil) // No _index.md page. + c.Assert(getPage(p, "/empty1/b/c"), qt.Not(qt.IsNil)) }}, - {"empty2", func(p *Page) { - // > b,c,d where b and d have content files. - b := p.s.getPage(KindSection, "empty2", "b") - assert.NotNil(b) - assert.Equal("T40_-1", b.title) - c := p.s.getPage(KindSection, "empty2", "b", "c") - assert.NotNil(c) - assert.Equal("Cs", c.title) - d := p.s.getPage(KindSection, "empty2", "b", "c", "d") - assert.NotNil(d) - assert.Equal("T41_-1", d.title) + {"empty2", func(c *qt.C, p page.Page) { + // > b,c,d where b and d have _index.md files. + b := getPage(p, "/empty2/b") + c.Assert(b, qt.Not(qt.IsNil)) + c.Assert(b.Title(), qt.Equals, "T40_-1") - assert.False(c.Eq(d)) - assert.True(c.Eq(c)) - assert.False(c.Eq("asdf")) + cp := getPage(p, "/empty2/b/c") + c.Assert(cp, qt.IsNil) // No _index.md + d := getPage(p, "/empty2/b/c/d") + c.Assert(d, qt.Not(qt.IsNil)) + c.Assert(d.Title(), qt.Equals, "T41_-1") + + c.Assert(cp.Eq(d), qt.Equals, false) + c.Assert(cp.Eq(cp), qt.Equals, true) + c.Assert(cp.Eq("asdf"), qt.Equals, false) }}, - {"empty3", func(p *Page) { + {"empty3", func(c *qt.C, p page.Page) { // b,c,d with regular page in b - b := p.s.getPage(KindSection, "empty3", "b") - assert.NotNil(b) - assert.Len(b.Pages, 1) - assert.Equal("empty3.md", b.Pages[0].File.LogicalName()) - + b := getPage(p, "/empty3/b") + c.Assert(b, qt.IsNil) // No _index.md + e3 := getPage(p, "/empty3/b/empty3") + c.Assert(e3, qt.Not(qt.IsNil)) + c.Assert(e3.File().LogicalName(), qt.Equals, "empty3.md") }}, - {"top", func(p *Page) { - assert.Equal("Tops", p.title) - assert.Len(p.Pages, 2) - assert.Equal("mypage2.md", p.Pages[0].LogicalName()) - assert.Equal("mypage3.md", p.Pages[1].LogicalName()) + {"empty3", func(c *qt.C, p page.Page) { + xxx := getPage(p, "/empty3/nil") + c.Assert(xxx, qt.IsNil) + }}, + {"top", func(c *qt.C, p page.Page) { + c.Assert(p.Title(), qt.Equals, "Tops") + c.Assert(len(p.Pages()), qt.Equals, 2) + c.Assert(p.Pages()[0].File().LogicalName(), qt.Equals, "mypage2.md") + c.Assert(p.Pages()[1].File().LogicalName(), qt.Equals, "mypage3.md") home := p.Parent() - assert.True(home.IsHome()) - assert.Len(p.Sections(), 0) - assert.Equal(home, home.CurrentSection()) - active, err := home.InSection(home) - assert.NoError(err) - assert.True(active) + c.Assert(home.IsHome(), qt.Equals, true) + c.Assert(len(p.Sections()), qt.Equals, 0) + c.Assert(home.CurrentSection(), qt.Equals, home) + active := home.InSection(home) + c.Assert(active, qt.Equals, true) + c.Assert(p.FirstSection(), qt.Equals, p) + c.Assert(len(p.Ancestors()), qt.Equals, 1) }}, - {"l1", func(p *Page) { - assert.Equal("L1s", p.title) - assert.Len(p.Pages, 2) - assert.True(p.Parent().IsHome()) - assert.Len(p.Sections(), 2) + {"l1", func(c *qt.C, p page.Page) { + c.Assert(p.Title(), qt.Equals, "L1s") + c.Assert(len(p.Pages()), qt.Equals, 4) // 2 pages + 2 sections + c.Assert(p.Parent().IsHome(), qt.Equals, true) + c.Assert(len(p.Sections()), qt.Equals, 2) + c.Assert(len(p.Ancestors()), qt.Equals, 1) }}, - {"l1,l2", func(p *Page) { - assert.Equal("T2_-1", p.title) - assert.Len(p.Pages, 3) - assert.Equal(p, p.Pages[0].Parent()) - assert.Equal("L1s", p.Parent().title) - assert.Equal("/l1/l2/", p.URLPath.URL) - assert.Equal("/l1/l2/", p.RelPermalink()) - assert.Len(p.Sections(), 1) + {"l1,l2", func(c *qt.C, p page.Page) { + c.Assert(p.Title(), qt.Equals, "T2_-1") + c.Assert(len(p.Pages()), qt.Equals, 4) // 3 pages + 1 section + c.Assert(p.Pages()[0].Parent(), qt.Equals, p) + c.Assert(p.Parent().Title(), qt.Equals, "L1s") + c.Assert(p.RelPermalink(), qt.Equals, "/l1/l2/") + c.Assert(len(p.Sections()), qt.Equals, 1) + c.Assert(len(p.Ancestors()), qt.Equals, 2) - for _, child := range p.Pages { - assert.Equal(p, child.CurrentSection()) - active, err := child.InSection(p) - assert.NoError(err) - assert.True(active) - active, err = p.InSection(child) - assert.NoError(err) - assert.True(active) - active, err = p.InSection(p.s.getPage(KindHome)) - assert.NoError(err) - assert.False(active) + for _, child := range p.Pages() { + if child.IsSection() { + c.Assert(child.CurrentSection(), qt.Equals, child) + continue + } - isAncestor, err := p.IsAncestor(child) - assert.NoError(err) - assert.True(isAncestor) - isAncestor, err = child.IsAncestor(p) - assert.NoError(err) - assert.False(isAncestor) + c.Assert(child.CurrentSection(), qt.Equals, p) + active := child.InSection(p) - isDescendant, err := p.IsDescendant(child) - assert.NoError(err) - assert.False(isDescendant) - isDescendant, err = child.IsDescendant(p) - assert.NoError(err) - assert.True(isDescendant) + c.Assert(active, qt.Equals, true) + active = p.InSection(child) + c.Assert(active, qt.Equals, true) + active = p.InSection(getPage(p, "/")) + c.Assert(active, qt.Equals, false) + + isAncestor := p.IsAncestor(child) + c.Assert(isAncestor, qt.Equals, true) + isAncestor = child.IsAncestor(p) + c.Assert(isAncestor, qt.Equals, false) + + isDescendant := p.IsDescendant(child) + c.Assert(isDescendant, qt.Equals, false) + isDescendant = child.IsDescendant(p) + c.Assert(isDescendant, qt.Equals, true) } - assert.Equal(p, p.CurrentSection()) - + c.Assert(p.Eq(p.CurrentSection()), qt.Equals, true) }}, - {"l1,l2_2", func(p *Page) { - assert.Equal("T22_-1", p.title) - assert.Len(p.Pages, 2) - assert.Equal(filepath.FromSlash("l1/l2_2/page_2_2_1.md"), p.Pages[0].Path()) - assert.Equal("L1s", p.Parent().title) - assert.Len(p.Sections(), 0) + {"l1,l2_2", func(c *qt.C, p page.Page) { + c.Assert(p.Title(), qt.Equals, "T22_-1") + c.Assert(len(p.Pages()), qt.Equals, 2) + c.Assert(p.Pages()[0].File().Path(), qt.Equals, filepath.FromSlash("l1/l2_2/page_2_2_1.md")) + c.Assert(p.Parent().Title(), qt.Equals, "L1s") + c.Assert(len(p.Sections()), qt.Equals, 0) + c.Assert(len(p.Ancestors()), qt.Equals, 2) }}, - {"l1,l2,l3", func(p *Page) { - assert.Equal("T3_-1", p.title) - assert.Len(p.Pages, 2) - assert.Equal("T2_-1", p.Parent().title) - assert.Len(p.Sections(), 0) + {"l1,l2,l3", func(c *qt.C, p page.Page) { + nilp, _ := p.GetPage("this/does/not/exist") - l1 := p.s.getPage(KindSection, "l1") - isDescendant, err := l1.IsDescendant(p) - assert.NoError(err) - assert.False(isDescendant) - isDescendant, err = p.IsDescendant(l1) - assert.NoError(err) - assert.True(isDescendant) + c.Assert(p.Title(), qt.Equals, "T3_-1") + c.Assert(len(p.Pages()), qt.Equals, 2) + c.Assert(p.Parent().Title(), qt.Equals, "T2_-1") + c.Assert(len(p.Sections()), qt.Equals, 0) + c.Assert(len(p.Ancestors()), qt.Equals, 3) - isAncestor, err := l1.IsAncestor(p) - assert.NoError(err) - assert.True(isAncestor) - isAncestor, err = p.IsAncestor(l1) - assert.NoError(err) - assert.False(isAncestor) + l1 := getPage(p, "/l1") + isDescendant := l1.IsDescendant(p) + c.Assert(isDescendant, qt.Equals, false) + isDescendant = l1.IsDescendant(nil) + c.Assert(isDescendant, qt.Equals, false) + isDescendant = nilp.IsDescendant(p) + c.Assert(isDescendant, qt.Equals, false) + isDescendant = p.IsDescendant(l1) + c.Assert(isDescendant, qt.Equals, true) + isAncestor := l1.IsAncestor(p) + c.Assert(isAncestor, qt.Equals, true) + isAncestor = p.IsAncestor(l1) + c.Assert(isAncestor, qt.Equals, false) + c.Assert(p.FirstSection(), qt.Equals, l1) + isAncestor = p.IsAncestor(nil) + c.Assert(isAncestor, qt.Equals, false) + c.Assert(isAncestor, qt.Equals, false) + + l3 := getPage(p, "/l1/l2/l3") + c.Assert(l3.FirstSection(), qt.Equals, l1) }}, - {"perm a,link", func(p *Page) { - assert.Equal("T9_-1", p.title) - assert.Equal("/perm-a/link/", p.RelPermalink()) - assert.Len(p.Pages, 4) - first := p.Pages[0] - assert.Equal("/perm-a/link/t1_1/", first.RelPermalink()) + {"perm a,link", func(c *qt.C, p page.Page) { + c.Assert(p.Title(), qt.Equals, "T9_-1") + c.Assert(p.RelPermalink(), qt.Equals, "/perm-a/link/") + c.Assert(len(p.Pages()), qt.Equals, 4) + first := p.Pages()[0] + c.Assert(first.RelPermalink(), qt.Equals, "/perm-a/link/t1_1/") th.assertFileContent("public/perm-a/link/t1_1/index.html", "Single|T1_1") - last := p.Pages[3] - assert.Equal("/perm-a/link/t1_5/", last.RelPermalink()) - + last := p.Pages()[3] + c.Assert(last.RelPermalink(), qt.Equals, "/perm-a/link/t1_5/") }}, } - home := s.getPage(KindHome) + home := s.getPageOldVersion(kinds.KindHome) for _, test := range tests { - sections := strings.Split(test.sections, ",") - p := s.getPage(KindSection, sections...) - assert.NotNil(p, fmt.Sprint(sections)) + test := test + tt.Run(fmt.Sprintf("sections %s", test.sections), func(c *qt.C) { + c.Parallel() + sections := strings.Split(test.sections, ",") + p := s.getPageOldVersion(kinds.KindSection, sections...) + c.Assert(p, qt.Not(qt.IsNil), qt.Commentf(fmt.Sprint(sections))) - if p.Pages != nil { - assert.Equal(p.Pages, p.Data["Pages"]) - } - assert.NotNil(p.Parent(), fmt.Sprintf("Parent nil: %q", test.sections)) - test.verify(p) + if p.Pages() != nil { + c.Assert(p.Data().(page.Data).Pages(), deepEqualsPages, p.Pages()) + } + c.Assert(p.Parent(), qt.Not(qt.IsNil)) + test.verify(c, p) + }) } - assert.NotNil(home) + c.Assert(home, qt.Not(qt.IsNil)) + c.Assert(len(home.Ancestors()), qt.Equals, 0) - assert.Len(home.Sections(), 9) - assert.Equal(home.Sections(), s.Info.Sections()) + c.Assert(len(home.Sections()), qt.Equals, 9) + c.Assert(s.Sections(), deepEqualsPages, home.Sections()) - rootPage := s.getPage(KindPage, "mypage.md") - assert.NotNil(rootPage) - assert.True(rootPage.Parent().IsHome()) + rootPage := s.getPageOldVersion(kinds.KindPage, "mypage.md") + c.Assert(rootPage, qt.Not(qt.IsNil)) + c.Assert(rootPage.Parent().IsHome(), qt.Equals, true) + // https://github.com/gohugoio/hugo/issues/6365 + c.Assert(rootPage.Sections(), qt.HasLen, 0) - // Add a odd test for this as this looks a little bit off, but I'm not in the mood - // to think too hard a out this right now. It works, but people will have to spell - // out the directory name as is. - // If we later decide to do something about this, we will have to do some normalization in - // getPage. - // TODO(bep) - sectionWithSpace := s.getPage(KindSection, "Spaces in Section") - require.NotNil(t, sectionWithSpace) - require.Equal(t, "/spaces-in-section/", sectionWithSpace.RelPermalink()) + sectionWithSpace := s.getPageOldVersion(kinds.KindSection, "Spaces in Section") + // s.h.pageTrees.debugPrint() + c.Assert(sectionWithSpace, qt.Not(qt.IsNil)) + c.Assert(sectionWithSpace.RelPermalink(), qt.Equals, "/spaces-in-section/") th.assertFileContent("public/l1/l2/page/2/index.html", "L1/l2-IsActive: true", "PAG|T2_3|true") - +} + +func TestNextInSectionNested(t *testing.T) { + t.Parallel() + + pageContent := `--- +title: "The Page" +weight: %d +--- +Some content. +` + createPageContent := func(weight int) string { + return fmt.Sprintf(pageContent, weight) + } + + b := newTestSitesBuilder(t) + b.WithSimpleConfigFile() + b.WithTemplates("_default/single.html", ` +Prev: {{ with .PrevInSection }}{{ .RelPermalink }}{{ end }}| +Next: {{ with .NextInSection }}{{ .RelPermalink }}{{ end }}| +`) + + b.WithContent("blog/page1.md", createPageContent(1)) + b.WithContent("blog/page2.md", createPageContent(2)) + b.WithContent("blog/cool/_index.md", createPageContent(1)) + b.WithContent("blog/cool/cool1.md", createPageContent(1)) + b.WithContent("blog/cool/cool2.md", createPageContent(2)) + b.WithContent("root1.md", createPageContent(1)) + b.WithContent("root2.md", createPageContent(2)) + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/root1/index.html", + "Prev: /root2/|", "Next: |") + b.AssertFileContent("public/root2/index.html", + "Prev: |", "Next: /root1/|") + b.AssertFileContent("public/blog/page1/index.html", + "Prev: /blog/page2/|", "Next: |") + b.AssertFileContent("public/blog/page2/index.html", + "Prev: |", "Next: /blog/page1/|") + b.AssertFileContent("public/blog/cool/cool1/index.html", + "Prev: /blog/cool/cool2/|", "Next: |") + b.AssertFileContent("public/blog/cool/cool2/index.html", + "Prev: |", "Next: /blog/cool/cool1/|") +} + +func TestSectionEntries(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com/" +-- content/myfirstsection/p1.md -- +--- +title: "P1" +--- +P1 +-- content/a/b/c/_index.md -- +--- +title: "C" +--- +C +-- content/a/b/c/mybundle/index.md -- +--- +title: "My Bundle" +--- +-- layouts/_default/list.html -- +Kind: {{ .Kind }}|RelPermalink: {{ .RelPermalink }}|SectionsPath: {{ .SectionsPath }}|SectionsEntries: {{ .SectionsEntries }}|Len: {{ len .SectionsEntries }}| +-- layouts/_default/single.html -- +Kind: {{ .Kind }}|RelPermalink: {{ .RelPermalink }}|SectionsPath: {{ .SectionsPath }}|SectionsEntries: {{ .SectionsEntries }}|Len: {{ len .SectionsEntries }}| +` + + b := Test(t, files) + + b.AssertFileContent("public/myfirstsection/p1/index.html", "RelPermalink: /myfirstsection/p1/|SectionsPath: /myfirstsection|SectionsEntries: [myfirstsection]|Len: 1") + b.AssertFileContent("public/a/b/c/index.html", "RelPermalink: /a/b/c/|SectionsPath: /a/b/c|SectionsEntries: [a b c]|Len: 3") + b.AssertFileContent("public/a/b/c/mybundle/index.html", "Kind: page|RelPermalink: /a/b/c/mybundle/|SectionsPath: /a/b/c|SectionsEntries: [a b c]|Len: 3") + b.AssertFileContent("public/index.html", "Kind: home|RelPermalink: /|SectionsPath: /|SectionsEntries: []|Len: 0") +} + +func TestParentWithPageOverlap(t *testing.T) { + files := ` +-- hugo.toml -- +baseURL = "https://example.com/" +-- content/docs/_index.md -- +-- content/docs/logs/_index.md -- +-- content/docs/logs/sdk.md -- +-- content/docs/logs/sdk_exporters/stdout.md -- +-- layouts/_default/list.html -- +{{ .RelPermalink }}|{{ with .Parent}}{{ .RelPermalink }}{{ end }}| +-- layouts/_default/single.html -- +{{ .RelPermalink }}|{{ with .Parent}}{{ .RelPermalink }}{{ end }}| + +` + b := Test(t, files) + + b.AssertFileContent("public/index.html", "/||") + b.AssertFileContent("public/docs/index.html", "/docs/|/|") + b.AssertFileContent("public/docs/logs/index.html", "/docs/logs/|/docs/|") + b.AssertFileContent("public/docs/logs/sdk/index.html", "/docs/logs/sdk/|/docs/logs/|") + b.AssertFileContent("public/docs/logs/sdk_exporters/stdout/index.html", "/docs/logs/sdk_exporters/stdout/|/docs/logs/|") } diff --git a/hugolib/site_stats_test.go b/hugolib/site_stats_test.go index 522b5636b..c045963f3 100644 --- a/hugolib/site_stats_test.go +++ b/hugolib/site_stats_test.go @@ -16,26 +16,26 @@ package hugolib import ( "bytes" "fmt" - "io/ioutil" "testing" "github.com/gohugoio/hugo/helpers" - "github.com/spf13/afero" - "github.com/stretchr/testify/require" + qt "github.com/frankban/quicktest" ) func TestSiteStats(t *testing.T) { t.Parallel() - assert := require.New(t) + c := qt.New(t) siteConfig := ` baseURL = "http://example.com/blog" -paginate = 1 defaultContentLanguage = "nn" +[pagination] +pagerSize = 1 + [languages] [languages.nn] languageName = "Nynorsk" @@ -55,47 +55,74 @@ tags: %s categories: %s -aliases: [Ali%d] +aliases: [/Ali%d] --- # Doc ` - th, h := newTestSitesFromConfig(t, afero.NewMemMapFs(), siteConfig, - "layouts/_default/single.html", "Single|{{ .Title }}|{{ .Content }}", - "layouts/_default/list.html", `List|{{ .Title }}|Pages: {{ .Paginator.TotalPages }}|{{ .Content }}`, - "layouts/_default/terms.html", "Terms List|{{ .Title }}|{{ .Content }}", + b := newTestSitesBuilder(t).WithConfigFile("toml", siteConfig) + + b.WithTemplates( + "_default/single.html", "Single|{{ .Title }}|{{ .Content }}", + "_default/list.html", `List|{{ .Title }}|Pages: {{ .Paginator.TotalPages }}|{{ .Content }}`, + "_default/terms.html", "Terms List|{{ .Title }}|{{ .Content }}", ) - require.Len(t, h.Sites, 2) - fs := th.Fs - - for i := 0; i < 2; i++ { - for j := 0; j < 2; j++ { + for i := range 2 { + for j := range 2 { pageID := i + j + 1 - writeSource(t, fs, fmt.Sprintf("content/sect/p%d.md", pageID), + b.WithContent(fmt.Sprintf("content/sect/p%d.md", pageID), fmt.Sprintf(pageTemplate, pageID, fmt.Sprintf("- tag%d", j), fmt.Sprintf("- category%d", j), pageID)) } } - for i := 0; i < 5; i++ { - writeSource(t, fs, fmt.Sprintf("content/assets/image%d.png", i+1), "image") + for i := range 5 { + b.WithContent(fmt.Sprintf("assets/image%d.png", i+1), "image") } - err := h.Build(BuildCfg{}) - - assert.NoError(err) + b.Build(BuildCfg{}) + h := b.H stats := []*helpers.ProcessingStats{ h.Sites[0].PathSpec.ProcessingStats, - h.Sites[1].PathSpec.ProcessingStats} - - stats[0].Table(ioutil.Discard) - stats[1].Table(ioutil.Discard) + h.Sites[1].PathSpec.ProcessingStats, + } var buff bytes.Buffer helpers.ProcessingStatsTable(&buff, stats...) - assert.Contains(buff.String(), "Pages | 19 | 6") - + c.Assert(buff.String(), qt.Contains, "Pages │ 21 │ 7") +} + +func TestSiteLastmod(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com/" +-- content/_index.md -- +--- +date: 2023-01-01 +--- +-- content/posts/_index.md -- +--- +date: 2023-02-01 +--- +-- content/posts/post-1.md -- +--- +date: 2023-03-01 +--- +-- content/posts/post-2.md -- +--- +date: 2023-04-01 +--- +-- layouts/index.html -- +site.Lastmod: {{ .Site.Lastmod.Format "2006-01-02" }} +home.Lastmod: {{ site.Home.Lastmod.Format "2006-01-02" }} + +` + b := Test(t, files) + + b.AssertFileContent("public/index.html", "site.Lastmod: 2023-04-01\nhome.Lastmod: 2023-01-01") } diff --git a/hugolib/site_test.go b/hugolib/site_test.go index 7286c4c36..199c878cd 100644 --- a/hugolib/site_test.go +++ b/hugolib/site_test.go @@ -1,4 +1,4 @@ -// Copyright 2016 The Hugo Authors. All rights reserved. +// 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. @@ -14,51 +14,28 @@ package hugolib import ( + "context" + "encoding/json" "fmt" + "os" "path/filepath" "strings" "testing" - "github.com/markbates/inflect" - - "github.com/gohugoio/hugo/helpers" + "github.com/gobuffalo/flect" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/publisher" + qt "github.com/frankban/quicktest" "github.com/gohugoio/hugo/deps" - "github.com/gohugoio/hugo/hugofs" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/gohugoio/hugo/resources/kinds" + "github.com/gohugoio/hugo/resources/page" ) -const ( - templateMissingFunc = "{{ .Title | funcdoesnotexists }}" - templateWithURLAbs = "
    Going" -) - -func init() { - testMode = true -} - -func pageMust(p *Page, err error) *Page { - if err != nil { - panic(err) - } - return p -} - -func TestRenderWithInvalidTemplate(t *testing.T) { - t.Parallel() - cfg, fs := newTestCfg() - - writeSource(t, fs, filepath.Join("content", "foo.md"), "foo") - - withTemplate := createWithTemplateFromNameValues("missing", templateMissingFunc) - - buildSingleSiteExpected(t, true, deps.DepsCfg{Fs: fs, Cfg: cfg, WithTemplate: withTemplate}, BuildCfg{}) - -} - func TestDraftAndFutureRender(t *testing.T) { t.Parallel() + c := qt.New(t) + sources := [][2]string{ {filepath.FromSlash("sect/doc1.md"), "---\ntitle: doc1\ndraft: true\npublishdate: \"2414-05-29\"\n---\n# doc1\n*some content*"}, {filepath.FromSlash("sect/doc2.md"), "---\ntitle: doc2\ndraft: true\npublishdate: \"2012-05-29\"\n---\n# doc2\n*some content*"}, @@ -66,7 +43,7 @@ func TestDraftAndFutureRender(t *testing.T) { {filepath.FromSlash("sect/doc4.md"), "---\ntitle: doc4\ndraft: false\npublishdate: \"2012-05-29\"\n---\n# doc4\n*some content*"}, } - siteSetup := func(t *testing.T, configKeyValues ...interface{}) *Site { + siteSetup := func(t *testing.T, configKeyValues ...any) *Site { cfg, fs := newTestCfg() cfg.Set("baseURL", "http://auth/bub") @@ -74,24 +51,25 @@ func TestDraftAndFutureRender(t *testing.T) { for i := 0; i < len(configKeyValues); i += 2 { cfg.Set(configKeyValues[i].(string), configKeyValues[i+1]) } + configs, err := loadTestConfigFromProvider(cfg) + c.Assert(err, qt.IsNil) for _, src := range sources { writeSource(t, fs, filepath.Join("content", src[0]), src[1]) - } - return buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{}) + return buildSingleSite(t, deps.DepsCfg{Fs: fs, Configs: configs}, BuildCfg{}) } // Testing Defaults.. Only draft:true and publishDate in the past should be rendered s := siteSetup(t) - if len(s.RegularPages) != 1 { + if len(s.RegularPages()) != 1 { t.Fatal("Draft or Future dated content published unexpectedly") } // only publishDate in the past should be rendered s = siteSetup(t, "buildDrafts", true) - if len(s.RegularPages) != 2 { + if len(s.RegularPages()) != 2 { t.Fatal("Future Dated Posts published unexpectedly") } @@ -100,7 +78,7 @@ func TestDraftAndFutureRender(t *testing.T) { "buildDrafts", false, "buildFuture", true) - if len(s.RegularPages) != 2 { + if len(s.RegularPages()) != 2 { t.Fatal("Draft posts published unexpectedly") } @@ -109,14 +87,14 @@ func TestDraftAndFutureRender(t *testing.T) { "buildDrafts", true, "buildFuture", true) - if len(s.RegularPages) != 4 { + if len(s.RegularPages()) != 4 { t.Fatal("Drafts or Future posts not included as expected") } - } func TestFutureExpirationRender(t *testing.T) { t.Parallel() + c := qt.New(t) sources := [][2]string{ {filepath.FromSlash("sect/doc3.md"), "---\ntitle: doc1\nexpirydate: \"2400-05-29\"\n---\n# doc1\n*some content*"}, {filepath.FromSlash("sect/doc4.md"), "---\ntitle: doc2\nexpirydate: \"2000-05-29\"\n---\n# doc2\n*some content*"}, @@ -126,27 +104,29 @@ func TestFutureExpirationRender(t *testing.T) { cfg, fs := newTestCfg() cfg.Set("baseURL", "http://auth/bub") + configs, err := loadTestConfigFromProvider(cfg) + c.Assert(err, qt.IsNil) + for _, src := range sources { writeSource(t, fs, filepath.Join("content", src[0]), src[1]) - } - return buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{}) + return buildSingleSite(t, deps.DepsCfg{Fs: fs, Configs: configs}, BuildCfg{}) } s := siteSetup(t) - if len(s.AllPages) != 1 { - if len(s.RegularPages) > 1 { + if len(s.AllPages()) != 1 { + if len(s.RegularPages()) > 1 { t.Fatal("Expired content published unexpectedly") } - if len(s.RegularPages) < 1 { + if len(s.RegularPages()) < 1 { t.Fatal("Valid content expired unexpectedly") } } - if s.AllPages[0].title == "doc2" { + if s.AllPages()[0].Title() == "doc2" { t.Fatal("Expired content published unexpectedly") } } @@ -155,6 +135,9 @@ func TestLastChange(t *testing.T) { t.Parallel() cfg, fs := newTestCfg() + c := qt.New(t) + configs, err := loadTestConfigFromProvider(cfg) + c.Assert(err, qt.IsNil) writeSource(t, fs, filepath.Join("content", "sect/doc1.md"), "---\ntitle: doc1\nweight: 1\ndate: 2014-05-29\n---\n# doc1\n*some content*") writeSource(t, fs, filepath.Join("content", "sect/doc2.md"), "---\ntitle: doc2\nweight: 2\ndate: 2015-05-29\n---\n# doc2\n*some content*") @@ -162,24 +145,26 @@ func TestLastChange(t *testing.T) { writeSource(t, fs, filepath.Join("content", "sect/doc4.md"), "---\ntitle: doc4\nweight: 4\ndate: 2016-05-29\n---\n# doc4\n*some content*") writeSource(t, fs, filepath.Join("content", "sect/doc5.md"), "---\ntitle: doc5\nweight: 3\n---\n# doc5\n*some content*") - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{SkipRender: true}) + s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Configs: configs}, BuildCfg{SkipRender: true}) - require.False(t, s.Info.LastChange.IsZero(), "Site.LastChange is zero") - require.Equal(t, 2017, s.Info.LastChange.Year(), "Site.LastChange should be set to the page with latest Lastmod (year 2017)") + c.Assert(s.Lastmod().IsZero(), qt.Equals, false) + c.Assert(s.Lastmod().Year(), qt.Equals, 2017) } // Issue #_index func TestPageWithUnderScoreIndexInFilename(t *testing.T) { t.Parallel() + c := qt.New(t) cfg, fs := newTestCfg() + configs, err := loadTestConfigFromProvider(cfg) + c.Assert(err, qt.IsNil) writeSource(t, fs, filepath.Join("content", "sect/my_index_file.md"), "---\ntitle: doc1\nweight: 1\ndate: 2014-05-29\n---\n# doc1\n*some content*") - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{SkipRender: true}) - - require.Len(t, s.RegularPages, 1) + s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Configs: configs}, BuildCfg{SkipRender: true}) + c.Assert(len(s.RegularPages()), qt.Equals, 1) } // Issue #957 @@ -193,6 +178,7 @@ func TestCrossrefs(t *testing.T) { } func doTestCrossrefs(t *testing.T, relative, uglyURLs bool) { + c := qt.New(t) baseURL := "http://foo/bar" @@ -236,12 +222,12 @@ THE END.`, refShortcode), // Issue #1753: Should not add a trailing newline after shortcode. { filepath.FromSlash("sect/doc3.md"), - fmt.Sprintf(`**Ref 1:**{{< %s "sect/doc3.md" >}}.`, refShortcode), + fmt.Sprintf(`**Ref 1:** {{< %s "sect/doc3.md" >}}.`, refShortcode), }, // Issue #3703 { filepath.FromSlash("sect/doc4.md"), - fmt.Sprintf(`**Ref 1:**{{< %s "%s" >}}.`, refShortcode, doc3Slashed), + fmt.Sprintf(`**Ref 1:** {{< %s "%s" >}}.`, refShortcode, doc3Slashed), }, } @@ -250,38 +236,39 @@ THE END.`, refShortcode), cfg.Set("baseURL", baseURL) cfg.Set("uglyURLs", uglyURLs) cfg.Set("verbose", true) + configs, err := loadTestConfigFromProvider(cfg) + c.Assert(err, qt.IsNil) for _, src := range sources { writeSource(t, fs, filepath.Join("content", src[0]), src[1]) } + writeSource(t, fs, filepath.Join("layouts", "_default", "single.html"), "{{.Content}}") s := buildSingleSite( t, deps.DepsCfg{ - Fs: fs, - Cfg: cfg, - WithTemplate: createWithTemplateFromNameValues("_default/single.html", "{{.Content}}")}, + Fs: fs, + Configs: configs, + }, BuildCfg{}) - require.Len(t, s.RegularPages, 4) + c.Assert(len(s.RegularPages()), qt.Equals, 4) - th := testHelper{s.Cfg, s.Fs, t} + th := newTestHelper(s.conf, s.Fs, t) tests := []struct { doc string expected string }{ {filepath.FromSlash(fmt.Sprintf("public/sect/doc1%s", expectedPathSuffix)), fmt.Sprintf("

    Ref 2: %s/sect/doc2%s

    \n", expectedBase, expectedURLSuffix)}, - {filepath.FromSlash(fmt.Sprintf("public/sect/doc2%s", expectedPathSuffix)), fmt.Sprintf("

    Ref 1:

    \n\n%s/sect/doc1%s\n\n

    THE END.

    \n", expectedBase, expectedURLSuffix)}, - {filepath.FromSlash(fmt.Sprintf("public/sect/doc3%s", expectedPathSuffix)), fmt.Sprintf("

    Ref 1:%s/sect/doc3%s.

    \n", expectedBase, expectedURLSuffix)}, - {filepath.FromSlash(fmt.Sprintf("public/sect/doc4%s", expectedPathSuffix)), fmt.Sprintf("

    Ref 1:%s/sect/doc3%s.

    \n", expectedBase, expectedURLSuffix)}, + {filepath.FromSlash(fmt.Sprintf("public/sect/doc2%s", expectedPathSuffix)), fmt.Sprintf("

    Ref 1:

    \n%s/sect/doc1%s\n

    THE END.

    \n", expectedBase, expectedURLSuffix)}, + {filepath.FromSlash(fmt.Sprintf("public/sect/doc3%s", expectedPathSuffix)), fmt.Sprintf("

    Ref 1: %s/sect/doc3%s.

    \n", expectedBase, expectedURLSuffix)}, + {filepath.FromSlash(fmt.Sprintf("public/sect/doc4%s", expectedPathSuffix)), fmt.Sprintf("

    Ref 1: %s/sect/doc3%s.

    \n", expectedBase, expectedURLSuffix)}, } for _, test := range tests { th.assertFileContent(test.doc, test.expected) - } - } // Issue #939 @@ -294,18 +281,16 @@ func TestShouldAlwaysHaveUglyURLs(t *testing.T) { } func doTestShouldAlwaysHaveUglyURLs(t *testing.T, uglyURLs bool) { - cfg, fs := newTestCfg() + c := qt.New(t) cfg.Set("verbose", true) cfg.Set("baseURL", "http://auth/bub") - cfg.Set("rssURI", "index.xml") - cfg.Set("blackfriday", - map[string]interface{}{ - "plainIDAnchors": true}) - cfg.Set("uglyURLs", uglyURLs) + configs, err := loadTestConfigFromProvider(cfg) + c.Assert(err, qt.IsNil) + sources := [][2]string{ {filepath.FromSlash("sect/doc1.md"), "---\nmarkup: markdown\n---\n# title\nsome *content*"}, {filepath.FromSlash("sect/doc2.md"), "---\nurl: /ugly.html\nmarkup: markdown\n---\n# title\ndoc2 *content*"}, @@ -321,7 +306,7 @@ func doTestShouldAlwaysHaveUglyURLs(t *testing.T, uglyURLs bool) { writeSource(t, fs, filepath.Join("layouts", "rss.xml"), "RSS") writeSource(t, fs, filepath.Join("layouts", "sitemap.xml"), "SITEMAP") - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{}) + s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Configs: configs}, BuildCfg{}) var expectedPagePath string if uglyURLs { @@ -335,57 +320,187 @@ func doTestShouldAlwaysHaveUglyURLs(t *testing.T, uglyURLs bool) { expected string }{ {filepath.FromSlash("public/index.html"), "Home Sweet Home."}, - {filepath.FromSlash(expectedPagePath), "\n\n

    title

    \n\n

    some content

    \n"}, + {filepath.FromSlash(expectedPagePath), "

    title

    \n

    some content

    \n"}, {filepath.FromSlash("public/404.html"), "Page Not Found."}, - {filepath.FromSlash("public/index.xml"), "\nRSS"}, - {filepath.FromSlash("public/sitemap.xml"), "\nSITEMAP"}, + {filepath.FromSlash("public/index.xml"), "RSS"}, + {filepath.FromSlash("public/sitemap.xml"), "SITEMAP"}, // Issue #1923 - {filepath.FromSlash("public/ugly.html"), "\n\n

    title

    \n\n

    doc2 content

    \n"}, + {filepath.FromSlash("public/ugly.html"), "

    title

    \n

    doc2 content

    \n"}, } - for _, p := range s.RegularPages { - assert.False(t, p.IsHome()) + for _, p := range s.RegularPages() { + c.Assert(p.IsHome(), qt.Equals, false) } for _, test := range tests { - content := readDestination(t, fs, test.doc) + content := readWorkingDir(t, fs, test.doc) if content != test.expected { t.Errorf("%s content expected:\n%q\ngot:\n%q", test.doc, test.expected, content) } } - -} - -func TestNewSiteDefaultLang(t *testing.T) { - t.Parallel() - s, err := NewSiteDefaultLang() - require.NoError(t, err) - require.Equal(t, hugofs.Os, s.Fs.Source) - require.Equal(t, hugofs.Os, s.Fs.Destination) } // Issue #3355 func TestShouldNotWriteZeroLengthFilesToDestination(t *testing.T) { + c := qt.New(t) + cfg, fs := newTestCfg() + configs, err := loadTestConfigFromProvider(cfg) + c.Assert(err, qt.IsNil) writeSource(t, fs, filepath.Join("content", "simple.html"), "simple") writeSource(t, fs, filepath.Join("layouts", "_default/single.html"), "{{.Content}}") writeSource(t, fs, filepath.Join("layouts", "_default/list.html"), "") - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{}) - th := testHelper{s.Cfg, s.Fs, t} + s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Configs: configs}, BuildCfg{}) + th := newTestHelper(s.conf, s.Fs, t) th.assertFileNotExist(filepath.Join("public", "index.html")) } +func TestMainSections(t *testing.T) { + c := qt.New(t) + for _, paramSet := range []bool{false, true} { + c.Run(fmt.Sprintf("param-%t", paramSet), func(c *qt.C) { + v := config.New() + if paramSet { + v.Set("params", map[string]any{ + "mainSections": []string{"a1", "a2"}, + }) + } + + b := newTestSitesBuilder(c).WithViper(v) + + for i := range 20 { + b.WithContent(fmt.Sprintf("page%d.md", i), `--- +title: "Page" +--- +`) + } + + for i := range 5 { + b.WithContent(fmt.Sprintf("blog/page%d.md", i), `--- +title: "Page" +tags: ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"] +--- +`) + } + + for i := range 3 { + b.WithContent(fmt.Sprintf("docs/page%d.md", i), `--- +title: "Page" +--- +`) + } + + b.WithTemplates("index.html", ` +mainSections: {{ .Site.Params.mainSections }} + +{{ range (where .Site.RegularPages "Type" "in" .Site.Params.mainSections) }} +Main section page: {{ .RelPermalink }} +{{ end }} +`) + + b.Build(BuildCfg{}) + + if paramSet { + b.AssertFileContent("public/index.html", "mainSections: [a1 a2]") + } else { + b.AssertFileContent("public/index.html", "mainSections: [blog]", "Main section page: /blog/page3/") + } + }) + } +} + +func TestMainSectionsMoveToSite(t *testing.T) { + t.Run("defined in params", func(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +disableKinds = ['RSS','sitemap','taxonomy','term'] +[params] +mainSections=["a", "b"] +-- content/mysect/page1.md -- +-- layouts/index.html -- +{{/* Behaviour before Hugo 0.112.0. */}} +MainSections Params: {{ site.Params.mainSections }}| +MainSections Site method: {{ site.MainSections }}| + + + ` + + b := Test(t, files) + + b.AssertFileContent("public/index.html", ` +MainSections Params: [a b]| +MainSections Site method: [a b]| + `) + }) + + t.Run("defined in top level config", func(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +disableKinds = ['RSS','sitemap','taxonomy','term'] +mainSections=["a", "b"] +[params] +[params.sub] +mainSections=["c", "d"] +-- content/mysect/page1.md -- +-- layouts/index.html -- +{{/* Behaviour before Hugo 0.112.0. */}} +MainSections Params: {{ site.Params.mainSections }}| +MainSections Param sub: {{ site.Params.sub.mainSections }}| +MainSections Site method: {{ site.MainSections }}| + + +` + + b := Test(t, files) + + b.AssertFileContent("public/index.html", ` +MainSections Params: [a b]| +MainSections Param sub: [c d]| +MainSections Site method: [a b]| +`) + }) + + t.Run("guessed from pages", func(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +disableKinds = ['RSS','sitemap','taxonomy','term'] +-- content/mysect/page1.md -- +-- layouts/index.html -- +MainSections Params: {{ site.Params.mainSections }}| +MainSections Site method: {{ site.MainSections }}| + + + ` + + b := Test(t, files) + + b.AssertFileContent("public/index.html", ` +MainSections Params: [mysect]| +MainSections Site method: [mysect]| + `) + }) +} + // Issue #1176 func TestSectionNaming(t *testing.T) { - t.Parallel() for _, canonify := range []bool{true, false} { for _, uglify := range []bool{true, false} { for _, pluralize := range []bool{true, false} { + canonify := canonify + uglify := uglify + pluralize := pluralize t.Run(fmt.Sprintf("canonify=%t,uglify=%t,pluralize=%t", canonify, uglify, pluralize), func(t *testing.T) { + t.Parallel() doTestSectionNaming(t, canonify, uglify, pluralize) }) } @@ -394,6 +509,7 @@ func TestSectionNaming(t *testing.T) { } func doTestSectionNaming(t *testing.T, canonify, uglify, pluralize bool) { + c := qt.New(t) var expectedPathSuffix string @@ -418,20 +534,21 @@ func doTestSectionNaming(t *testing.T, canonify, uglify, pluralize bool) { cfg.Set("pluralizeListTitles", pluralize) cfg.Set("canonifyURLs", canonify) + configs, err := loadTestConfigFromProvider(cfg) + c.Assert(err, qt.IsNil) + for _, src := range sources { writeSource(t, fs, filepath.Join("content", src[0]), src[1]) } writeSource(t, fs, filepath.Join("layouts", "_default/single.html"), "{{.Content}}") - writeSource(t, fs, filepath.Join("layouts", "_default/list.html"), "{{.Title}}") + writeSource(t, fs, filepath.Join("layouts", "_default/list.html"), "{{ .Kind }}|{{.Title}}") - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{}) + s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Configs: configs}, BuildCfg{}) - mainSections, err := s.Info.Param("mainSections") - require.NoError(t, err) - require.Equal(t, []string{"sect"}, mainSections) + c.Assert(s.MainSections(), qt.DeepEquals, []string{"sect"}) - th := testHelper{s.Cfg, s.Fs, t} + th := newTestHelper(s.conf, s.Fs, t) tests := []struct { doc string pluralAware bool @@ -448,125 +565,11 @@ func doTestSectionNaming(t *testing.T, canonify, uglify, pluralize bool) { for _, test := range tests { if test.pluralAware && pluralize { - test.expected = inflect.Pluralize(test.expected) + test.expected = flect.Pluralize(test.expected) } th.assertFileContent(filepath.Join("public", test.doc), test.expected) } - -} -func TestSkipRender(t *testing.T) { - t.Parallel() - sources := [][2]string{ - {filepath.FromSlash("sect/doc1.html"), "---\nmarkup: markdown\n---\n# title\nsome *content*"}, - {filepath.FromSlash("sect/doc2.html"), "more content"}, - {filepath.FromSlash("sect/doc3.md"), "# doc3\n*some* content"}, - {filepath.FromSlash("sect/doc4.md"), "---\ntitle: doc4\n---\n# doc4\n*some content*"}, - {filepath.FromSlash("sect/doc5.html"), "{{ template \"head\" }}body5"}, - {filepath.FromSlash("sect/doc6.html"), "{{ template \"head_abs\" }}body5"}, - {filepath.FromSlash("doc7.html"), "doc7 content"}, - {filepath.FromSlash("sect/doc8.html"), "---\nmarkup: md\n---\n# title\nsome *content*"}, - // Issue #3021 - {filepath.FromSlash("doc9.html"), "doc9: {{< myshortcode >}}"}, - } - - cfg, fs := newTestCfg() - - cfg.Set("verbose", true) - cfg.Set("canonifyURLs", true) - cfg.Set("uglyURLs", true) - cfg.Set("baseURL", "http://auth/bub") - - for _, src := range sources { - writeSource(t, fs, filepath.Join("content", src[0]), src[1]) - - } - - writeSource(t, fs, filepath.Join("layouts", "_default/single.html"), "{{.Content}}") - writeSource(t, fs, filepath.Join("layouts", "head"), "") - writeSource(t, fs, filepath.Join("layouts", "head_abs"), "") - writeSource(t, fs, filepath.Join("layouts", "shortcodes", "myshortcode.html"), "SHORT") - - buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{}) - - tests := []struct { - doc string - expected string - }{ - {filepath.FromSlash("public/sect/doc1.html"), "\n\n

    title

    \n\n

    some content

    \n"}, - {filepath.FromSlash("public/sect/doc2.html"), "more content"}, - {filepath.FromSlash("public/sect/doc3.html"), "\n\n

    doc3

    \n\n

    some content

    \n"}, - {filepath.FromSlash("public/sect/doc4.html"), "\n\n

    doc4

    \n\n

    some content

    \n"}, - {filepath.FromSlash("public/sect/doc5.html"), "body5"}, - {filepath.FromSlash("public/sect/doc6.html"), "body5"}, - {filepath.FromSlash("public/doc7.html"), "doc7 content"}, - {filepath.FromSlash("public/sect/doc8.html"), "\n\n

    title

    \n\n

    some content

    \n"}, - {filepath.FromSlash("public/doc9.html"), "doc9: SHORT"}, - } - - for _, test := range tests { - file, err := fs.Destination.Open(test.doc) - if err != nil { - t.Fatalf("Did not find %s in target.", test.doc) - } - - content := helpers.ReaderToString(file) - - if content != test.expected { - t.Errorf("%s content expected:\n%q\ngot:\n%q", test.doc, test.expected, content) - } - } -} - -func TestAbsURLify(t *testing.T) { - t.Parallel() - sources := [][2]string{ - {filepath.FromSlash("sect/doc1.html"), "link"}, - {filepath.FromSlash("blue/doc2.html"), "---\nf: t\n---\nmore content"}, - } - for _, baseURL := range []string{"http://auth/bub", "http://base", "//base"} { - for _, canonify := range []bool{true, false} { - - cfg, fs := newTestCfg() - - cfg.Set("uglyURLs", true) - cfg.Set("canonifyURLs", canonify) - cfg.Set("baseURL", baseURL) - - for _, src := range sources { - writeSource(t, fs, filepath.Join("content", src[0]), src[1]) - - } - - writeSource(t, fs, filepath.Join("layouts", "blue/single.html"), templateWithURLAbs) - - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{}) - th := testHelper{s.Cfg, s.Fs, t} - - tests := []struct { - file, expected string - }{ - {"public/blue/doc2.html", "Going"}, - {"public/sect/doc1.html", "link"}, - } - - for _, test := range tests { - - expected := test.expected - - if strings.Contains(expected, "%s") { - expected = fmt.Sprintf(expected, baseURL) - } - - if !canonify { - expected = strings.Replace(expected, baseURL, "", -1) - } - - th.assertFileContent(test.file, expected) - - } - } - } } var weightedPage1 = `+++ @@ -603,63 +606,76 @@ date = "2012-01-01" publishdate = "2012-01-01" my_param = "baz" my_date = 2010-05-27T07:32:00Z +summary = "A _custom_ summary" categories = [ "hugo" ] +++ Front Matter with Ordered Pages 4. This is longer content` +var weightedPage5 = `+++ +weight = "5" +title = "Five" + +[build] +render = "never" ++++ +Front Matter with Ordered Pages 5` + var weightedSources = [][2]string{ {filepath.FromSlash("sect/doc1.md"), weightedPage1}, {filepath.FromSlash("sect/doc2.md"), weightedPage2}, {filepath.FromSlash("sect/doc3.md"), weightedPage3}, {filepath.FromSlash("sect/doc4.md"), weightedPage4}, + {filepath.FromSlash("sect/doc5.md"), weightedPage5}, } func TestOrderedPages(t *testing.T) { t.Parallel() + c := qt.New(t) cfg, fs := newTestCfg() cfg.Set("baseURL", "http://auth/bub") + configs, err := loadTestConfigFromProvider(cfg) + c.Assert(err, qt.IsNil) for _, src := range weightedSources { writeSource(t, fs, filepath.Join("content", src[0]), src[1]) - } - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{SkipRender: true}) + s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Configs: configs}, BuildCfg{SkipRender: true}) - if s.getPage(KindSection, "sect").Pages[1].title != "Three" || s.getPage(KindSection, "sect").Pages[2].title != "Four" { + if s.getPageOldVersion(kinds.KindSection, "sect").Pages()[1].Title() != "Three" || s.getPageOldVersion(kinds.KindSection, "sect").Pages()[2].Title() != "Four" { t.Error("Pages in unexpected order.") } - bydate := s.RegularPages.ByDate() + bydate := s.RegularPages().ByDate() - if bydate[0].title != "One" { - t.Errorf("Pages in unexpected order. First should be '%s', got '%s'", "One", bydate[0].title) + if bydate[0].Title() != "One" { + t.Errorf("Pages in unexpected order. First should be '%s', got '%s'", "One", bydate[0].Title()) } rev := bydate.Reverse() - if rev[0].title != "Three" { - t.Errorf("Pages in unexpected order. First should be '%s', got '%s'", "Three", rev[0].title) + if rev[0].Title() != "Three" { + t.Errorf("Pages in unexpected order. First should be '%s', got '%s'", "Three", rev[0].Title()) } - bypubdate := s.RegularPages.ByPublishDate() + bypubdate := s.RegularPages().ByPublishDate() - if bypubdate[0].title != "One" { - t.Errorf("Pages in unexpected order. First should be '%s', got '%s'", "One", bypubdate[0].title) + if bypubdate[0].Title() != "One" { + t.Errorf("Pages in unexpected order. First should be '%s', got '%s'", "One", bypubdate[0].Title()) } rbypubdate := bypubdate.Reverse() - if rbypubdate[0].title != "Three" { - t.Errorf("Pages in unexpected order. First should be '%s', got '%s'", "Three", rbypubdate[0].title) + if rbypubdate[0].Title() != "Three" { + t.Errorf("Pages in unexpected order. First should be '%s', got '%s'", "Three", rbypubdate[0].Title()) } - bylength := s.RegularPages.ByLength() - if bylength[0].title != "One" { - t.Errorf("Pages in unexpected order. First should be '%s', got '%s'", "One", bylength[0].title) + bylength := s.RegularPages().ByLength(context.Background()) + if bylength[0].Title() != "One" { + t.Errorf("Pages in unexpected order. First should be '%s', got '%s'", "One", bylength[0].Title()) } rbylength := bylength.Reverse() - if rbylength[0].title != "Four" { - t.Errorf("Pages in unexpected order. First should be '%s', got '%s'", "Four", rbylength[0].title) + if rbylength[0].Title() != "Four" { + t.Errorf("Pages in unexpected order. First should be '%s', got '%s'", "Four", rbylength[0].Title()) } } @@ -672,19 +688,17 @@ var groupedSources = [][2]string{ func TestGroupedPages(t *testing.T) { t.Parallel() - defer func() { - if r := recover(); r != nil { - fmt.Println("Recovered in f", r) - } - }() + c := qt.New(t) cfg, fs := newTestCfg() cfg.Set("baseURL", "http://auth/bub") + configs, err := loadTestConfigFromProvider(cfg) + c.Assert(err, qt.IsNil) writeSourcesToSource(t, "content", fs, groupedSources...) - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{}) + s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Configs: configs}, BuildCfg{}) - rbysection, err := s.RegularPages.GroupBy("Section", "desc") + rbysection, err := s.RegularPages().GroupBy(context.Background(), "Section", "desc") if err != nil { t.Fatalf("Unable to make PageGroup array: %s", err) } @@ -698,14 +712,14 @@ func TestGroupedPages(t *testing.T) { if rbysection[2].Key != "sect1" { t.Errorf("PageGroup array in unexpected order. Third group key should be '%s', got '%s'", "sect1", rbysection[2].Key) } - if rbysection[0].Pages[0].title != "Four" { - t.Errorf("PageGroup has an unexpected page. First group's pages should have '%s', got '%s'", "Four", rbysection[0].Pages[0].title) + if rbysection[0].Pages[0].Title() != "Four" { + t.Errorf("PageGroup has an unexpected page. First group's pages should have '%s', got '%s'", "Four", rbysection[0].Pages[0].Title()) } if len(rbysection[2].Pages) != 2 { t.Errorf("PageGroup has unexpected number of pages. Third group should have '%d' pages, got '%d' pages", 2, len(rbysection[2].Pages)) } - bytype, err := s.RegularPages.GroupBy("Type", "asc") + bytype, err := s.RegularPages().GroupBy(context.Background(), "Type", "asc") if err != nil { t.Fatalf("Unable to make PageGroup array: %s", err) } @@ -718,14 +732,14 @@ func TestGroupedPages(t *testing.T) { if bytype[2].Key != "sect3" { t.Errorf("PageGroup array in unexpected order. Third group key should be '%s', got '%s'", "sect3", bytype[2].Key) } - if bytype[2].Pages[0].title != "Four" { - t.Errorf("PageGroup has an unexpected page. Third group's data should have '%s', got '%s'", "Four", bytype[0].Pages[0].title) + if bytype[2].Pages[0].Title() != "Four" { + t.Errorf("PageGroup has an unexpected page. Third group's data should have '%s', got '%s'", "Four", bytype[0].Pages[0].Title()) } if len(bytype[0].Pages) != 2 { t.Errorf("PageGroup has unexpected number of pages. First group should have '%d' pages, got '%d' pages", 2, len(bytype[2].Pages)) } - bydate, err := s.RegularPages.GroupByDate("2006-01", "asc") + bydate, err := s.RegularPages().GroupByDate("2006-01", "asc") if err != nil { t.Fatalf("Unable to make PageGroup array: %s", err) } @@ -736,7 +750,7 @@ func TestGroupedPages(t *testing.T) { t.Errorf("PageGroup array in unexpected order. Second group key should be '%s', got '%s'", "2012-01", bydate[1].Key) } - bypubdate, err := s.RegularPages.GroupByPublishDate("2006") + bypubdate, err := s.RegularPages().GroupByPublishDate("2006") if err != nil { t.Fatalf("Unable to make PageGroup array: %s", err) } @@ -746,14 +760,14 @@ func TestGroupedPages(t *testing.T) { if bypubdate[1].Key != "0001" { t.Errorf("PageGroup array in unexpected order. Second group key should be '%s', got '%s'", "0001", bypubdate[1].Key) } - if bypubdate[0].Pages[0].title != "Three" { - t.Errorf("PageGroup has an unexpected page. Third group's pages should have '%s', got '%s'", "Three", bypubdate[0].Pages[0].title) + if bypubdate[0].Pages[0].Title() != "Three" { + t.Errorf("PageGroup has an unexpected page. Third group's pages should have '%s', got '%s'", "Three", bypubdate[0].Pages[0].Title()) } if len(bypubdate[0].Pages) != 3 { t.Errorf("PageGroup has unexpected number of pages. First group should have '%d' pages, got '%d' pages", 3, len(bypubdate[0].Pages)) } - byparam, err := s.RegularPages.GroupByParam("my_param", "desc") + byparam, err := s.RegularPages().GroupByParam("my_param", "desc") if err != nil { t.Fatalf("Unable to make PageGroup array: %s", err) } @@ -766,19 +780,22 @@ func TestGroupedPages(t *testing.T) { if byparam[2].Key != "bar" { t.Errorf("PageGroup array in unexpected order. Third group key should be '%s', got '%s'", "bar", byparam[2].Key) } - if byparam[2].Pages[0].title != "Three" { - t.Errorf("PageGroup has an unexpected page. Third group's pages should have '%s', got '%s'", "Three", byparam[2].Pages[0].title) + if byparam[2].Pages[0].Title() != "Three" { + t.Errorf("PageGroup has an unexpected page. Third group's pages should have '%s', got '%s'", "Three", byparam[2].Pages[0].Title()) } if len(byparam[0].Pages) != 2 { t.Errorf("PageGroup has unexpected number of pages. First group should have '%d' pages, got '%d' pages", 2, len(byparam[0].Pages)) } - _, err = s.RegularPages.GroupByParam("not_exist") - if err == nil { - t.Errorf("GroupByParam didn't return an expected error") + byNonExistentParam, err := s.RegularPages().GroupByParam("not_exist") + if err != nil { + t.Errorf("GroupByParam returned an error when it shouldn't") + } + if len(byNonExistentParam) != 0 { + t.Errorf("PageGroup array has unexpected elements. Group length should be '%d', got '%d'", 0, len(byNonExistentParam)) } - byOnlyOneParam, err := s.RegularPages.GroupByParam("only_one") + byOnlyOneParam, err := s.RegularPages().GroupByParam("only_one") if err != nil { t.Fatalf("Unable to make PageGroup array: %s", err) } @@ -789,7 +806,7 @@ func TestGroupedPages(t *testing.T) { t.Errorf("PageGroup array in unexpected order. First group key should be '%s', got '%s'", "yes", byOnlyOneParam[0].Key) } - byParamDate, err := s.RegularPages.GroupByParamDate("my_date", "2006-01") + byParamDate, err := s.RegularPages().GroupByParamDate("my_date", "2006-01") if err != nil { t.Fatalf("Unable to make PageGroup array: %s", err) } @@ -799,8 +816,8 @@ func TestGroupedPages(t *testing.T) { if byParamDate[1].Key != "1979-05" { t.Errorf("PageGroup array in unexpected order. Second group key should be '%s', got '%s'", "1979-05", byParamDate[1].Key) } - if byParamDate[1].Pages[0].title != "One" { - t.Errorf("PageGroup has an unexpected page. Second group's pages should have '%s', got '%s'", "One", byParamDate[1].Pages[0].title) + if byParamDate[1].Pages[0].Title() != "One" { + t.Errorf("PageGroup has an unexpected page. Second group's pages should have '%s', got '%s'", "One", byParamDate[1].Pages[0].Title()) } if len(byParamDate[0].Pages) != 2 { t.Errorf("PageGroup has unexpected number of pages. First group should have '%d' pages, got '%d' pages", 2, len(byParamDate[2].Pages)) @@ -821,7 +838,7 @@ tags = "a" tags_weight = 33 title = "bar" categories = [ "d", "e" ] -categories_weight = 11 +categories_weight = 11.0 alias = "spf13" date = 1979-05-27T07:32:00Z +++ @@ -838,6 +855,8 @@ Front Matter with weighted tags and categories` func TestWeightedTaxonomies(t *testing.T) { t.Parallel() + c := qt.New(t) + sources := [][2]string{ {filepath.FromSlash("sect/doc1.md"), pageWithWeightedTaxonomies2}, {filepath.FromSlash("sect/doc2.md"), pageWithWeightedTaxonomies1}, @@ -852,26 +871,30 @@ func TestWeightedTaxonomies(t *testing.T) { cfg.Set("baseURL", "http://auth/bub") cfg.Set("taxonomies", taxonomies) + configs, err := loadTestConfigFromProvider(cfg) + c.Assert(err, qt.IsNil) writeSourcesToSource(t, "content", fs, sources...) - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{}) + s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Configs: configs}, BuildCfg{}) - if s.Taxonomies["tags"]["a"][0].Page.title != "foo" { - t.Errorf("Pages in unexpected order, 'foo' expected first, got '%v'", s.Taxonomies["tags"]["a"][0].Page.title) + if s.Taxonomies()["tags"]["a"][0].Page.Title() != "foo" { + t.Errorf("Pages in unexpected order, 'foo' expected first, got '%v'", s.Taxonomies()["tags"]["a"][0].Page.Title()) } - if s.Taxonomies["categories"]["d"][0].Page.title != "bar" { - t.Errorf("Pages in unexpected order, 'bar' expected first, got '%v'", s.Taxonomies["categories"]["d"][0].Page.title) + if s.Taxonomies()["categories"]["d"][0].Page.Title() != "bar" { + t.Errorf("Pages in unexpected order, 'bar' expected first, got '%v'", s.Taxonomies()["categories"]["d"][0].Page.Title()) } - if s.Taxonomies["categories"]["e"][0].Page.title != "bza" { - t.Errorf("Pages in unexpected order, 'bza' expected first, got '%v'", s.Taxonomies["categories"]["e"][0].Page.title) + if s.Taxonomies()["categories"]["e"][0].Page.Title() != "bza" { + t.Errorf("Pages in unexpected order, 'bza' expected first, got '%v'", s.Taxonomies()["categories"]["e"][0].Page.Title()) } } func setupLinkingMockSite(t *testing.T) *Site { sources := [][2]string{ {filepath.FromSlash("level2/unique.md"), ""}, + {filepath.FromSlash("_index.md"), ""}, + {filepath.FromSlash("common.md"), ""}, {filepath.FromSlash("rootfile.md"), ""}, {filepath.FromSlash("root-image.png"), ""}, @@ -882,33 +905,40 @@ func setupLinkingMockSite(t *testing.T) *Site { {filepath.FromSlash("level2/common.png"), ""}, {filepath.FromSlash("level2/level3/start.md"), ""}, + {filepath.FromSlash("level2/level3/_index.md"), ""}, {filepath.FromSlash("level2/level3/3-root.md"), ""}, {filepath.FromSlash("level2/level3/common.md"), ""}, {filepath.FromSlash("level2/level3/3-image.png"), ""}, {filepath.FromSlash("level2/level3/common.png"), ""}, + + {filepath.FromSlash("level2/level3/embedded.dot.md"), ""}, + + {filepath.FromSlash("leafbundle/index.md"), ""}, } cfg, fs := newTestCfg() cfg.Set("baseURL", "http://auth/") cfg.Set("uglyURLs", false) - cfg.Set("outputs", map[string]interface{}{ + cfg.Set("outputs", map[string]any{ "page": []string{"HTML", "AMP"}, }) cfg.Set("pluralizeListTitles", false) cfg.Set("canonifyURLs", false) - cfg.Set("blackfriday", - map[string]interface{}{}) - writeSourcesToSource(t, "content", fs, sources...) - return buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{}) + configs, err := loadTestConfigFromProvider(cfg) + if err != nil { + t.Fatal(err) + } + writeSourcesToSource(t, "content", fs, sources...) + return buildSingleSite(t, deps.DepsCfg{Fs: fs, Configs: configs}, BuildCfg{}) } func TestRefLinking(t *testing.T) { t.Parallel() site := setupLinkingMockSite(t) - currentPage := site.getPage(KindPage, "level2/level3/start.md") + currentPage := site.getPageOldVersion(kinds.KindPage, "level2/level3/start.md") if currentPage == nil { t.Fatalf("failed to find current page in site") } @@ -919,14 +949,317 @@ func TestRefLinking(t *testing.T) { relative bool expected string }{ + // different refs resolving to the same unique filename: + {"/level2/unique.md", "", true, "/level2/unique/"}, + {"../unique.md", "", true, "/level2/unique/"}, {"unique.md", "", true, "/level2/unique/"}, + {"level2/common.md", "", true, "/level2/common/"}, {"3-root.md", "", true, "/level2/level3/3-root/"}, + {"../..", "", true, "/"}, + + // different refs resolving to the same ambiguous top-level filename: + {"../../common.md", "", true, "/common/"}, + {"/common.md", "", true, "/common/"}, + + // different refs resolving to the same ambiguous level-2 filename: + {"/level2/common.md", "", true, "/level2/common/"}, + {"../common.md", "", true, "/level2/common/"}, + {"common.md", "", true, "/level2/level3/common/"}, + + // different refs resolving to the same section: + {"/level2", "", true, "/level2/"}, + {"..", "", true, "/level2/"}, + {"../", "", true, "/level2/"}, + + // different refs resolving to the same subsection: + {"/level2/level3", "", true, "/level2/level3/"}, + {"/level2/level3/_index.md", "", true, "/level2/level3/"}, + {".", "", true, "/level2/level3/"}, + {"./", "", true, "/level2/level3/"}, + + {"embedded.dot.md", "", true, "/level2/level3/embedded.dot/"}, + + // test empty link, as well as fragment only link + {"", "", true, ""}, } { - if out, err := site.Info.refLink(test.link, currentPage, test.relative, test.outputFormat); err != nil || out != test.expected { - t.Errorf("[%d] Expected %s to resolve to (%s), got (%s) - error: %s", i, test.link, test.expected, out, err) - } + t.Run(fmt.Sprintf("t%dt", i), func(t *testing.T) { + checkLinkCase(site, test.link, currentPage, test.relative, test.outputFormat, test.expected, t, i) + + // make sure fragment links are also handled + checkLinkCase(site, test.link+"#intro", currentPage, test.relative, test.outputFormat, test.expected+"#intro", t, i) + }) } // TODO: and then the failure cases. } + +func TestRelRefWithTrailingSlash(t *testing.T) { + files := ` +-- hugo.toml -- +-- content/docs/5.3/examples/_index.md -- +--- +title: "Examples" +--- +-- content/_index.md -- +--- +title: "Home" +--- + +Examples: {{< relref "/docs/5.3/examples/" >}} +-- layouts/home.html -- +Content: {{ .Content }}| +` + + b := Test(t, files) + + b.AssertFileContent("public/index.html", "Examples: /docs/5.3/examples/") +} + +func checkLinkCase(site *Site, link string, currentPage page.Page, relative bool, outputFormat string, expected string, t *testing.T, i int) { + t.Helper() + if out, err := site.refLink(link, currentPage, relative, outputFormat); err != nil || out != expected { + t.Fatalf("[%d] Expected %q from %q to resolve to %q, got %q - error: %s", i, link, currentPage.Path(), expected, out, err) + } +} + +// https://github.com/gohugoio/hugo/issues/6952 +func TestRefIssues(t *testing.T) { + b := newTestSitesBuilder(t) + b.WithContent( + "post/b1/index.md", "---\ntitle: pb1\n---\nRef: {{< ref \"b2\" >}}", + "post/b2/index.md", "---\ntitle: pb2\n---\n", + "post/nested-a/content-a.md", "---\ntitle: ca\n---\n{{< ref \"content-b\" >}}", + "post/nested-b/content-b.md", "---\ntitle: ca\n---\n", + ) + b.WithTemplates("index.html", `Home`) + b.WithTemplates("_default/single.html", `Content: {{ .Content }}`) + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/post/b1/index.html", `Content:

    Ref: http://example.com/post/b2/

    `) + b.AssertFileContent("public/post/nested-a/content-a/index.html", `Content: http://example.com/post/nested-b/content-b/`) +} + +func TestClassCollector(t *testing.T) { + for _, minify := range []bool{false, true} { + t.Run(fmt.Sprintf("minify-%t", minify), func(t *testing.T) { + statsFilename := "hugo_stats.json" + defer os.Remove(statsFilename) + + b := newTestSitesBuilder(t) + b.WithConfigFile("toml", fmt.Sprintf(` + + +minify = %t + +[build] + writeStats = true + +`, minify)) + + b.WithTemplates("index.html", ` + +
    Foo
    + +Some text. + +
    Foo
    + +FOO + + {{ .Title }} + + +`) + + b.WithContent("p1.md", "") + + b.Build(BuildCfg{}) + + b.AssertFileContent("hugo_stats.json", ` + { + "htmlElements": { + "tags": [ + "a", + "div", + "span" + ], + "classes": [ + "a", + "b", + "c", + "d", + "e", + "hover:text-gradient", + "[&>p]:text-red-600", + "inline-block", + "lowercase", + "pb-1", + "px-3", + "rounded", + "text-base", + "z" + ], + "ids": [ + "el1", + "el2" + ] + } + } +`) + }) + } +} + +func TestClassCollectorConfigWriteStats(t *testing.T) { + r := func(writeStatsConfig string) *IntegrationTestBuilder { + files := ` +-- hugo.toml -- +WRITE_STATS_CONFIG +-- layouts/_default/list.html -- +
    Foo
    + +` + files = strings.Replace(files, "WRITE_STATS_CONFIG", writeStatsConfig, 1) + + b := NewIntegrationTestBuilder( + IntegrationTestConfig{ + T: t, + TxtarString: files, + NeedsOsFS: true, + }, + ).Build() + + return b + } + + // Legacy config. + b := r(` +[build] +writeStats = true +`) + + b.AssertFileContent("hugo_stats.json", "myclass", "div", "myid") + + b = r(` +[build] +writeStats = false + `) + + b.AssertFileExists("public/hugo_stats.json", false) + + b = r(` +[build.buildStats] +enable = true +`) + + b.AssertFileContent("hugo_stats.json", "myclass", "div", "myid") + + b = r(` +[build.buildStats] +enable = true +disableids = true +`) + + b.AssertFileContent("hugo_stats.json", "myclass", "div", "! myid") + + b = r(` +[build.buildStats] +enable = true +disableclasses = true +`) + + b.AssertFileContent("hugo_stats.json", "! myclass", "div", "myid") + + b = r(` +[build.buildStats] +enable = true +disabletags = true + `) + + b.AssertFileContent("hugo_stats.json", "myclass", "! div", "myid") + + b = r(` +[build.buildStats] +enable = true +disabletags = true +disableclasses = true + `) + + b.AssertFileContent("hugo_stats.json", "! myclass", "! div", "myid") + + b = r(` +[build.buildStats] +enable = false + `) + b.AssertFileExists("public/hugo_stats.json", false) +} + +func TestClassCollectorStress(t *testing.T) { + statsFilename := "hugo_stats.json" + defer os.Remove(statsFilename) + + b := newTestSitesBuilder(t) + b.WithConfigFile("toml", ` + +disableKinds = ["home", "section", "term", "taxonomy" ] + +[languages] +[languages.en] +[languages.nb] +[languages.no] +[languages.sv] + + +[build] + writeStats = true + +`) + + b.WithTemplates("_default/single.html", ` +
    Foo
    + +Some text. + +{{ $n := index (shuffle (seq 1 20)) 0 }} + +{{ "Foo" | strings.Repeat $n | safeHTML }} + +
    +ABC. +
    + +
    + +{{ $n := index (shuffle (seq 1 5)) 0 }} + +{{ "
    " | safeHTML }} + +`) + + for _, lang := range []string{"en", "nb", "no", "sv"} { + for i := 100; i <= 999; i++ { + b.WithContent(fmt.Sprintf("p%d.%s.md", i, lang), fmt.Sprintf("---\ntitle: p%s%d\n---", lang, i)) + } + } + + b.Build(BuildCfg{}) + + contentMem := b.FileContent(statsFilename) + cb, err := os.ReadFile(statsFilename) + b.Assert(err, qt.IsNil) + contentFile := string(cb) + + for _, content := range []string{contentMem, contentFile} { + + stats := &publisher.PublishStats{} + b.Assert(json.Unmarshal([]byte(content), stats), qt.IsNil) + + els := stats.HTMLElements + + b.Assert(els.Classes, qt.HasLen, 3606) // (4 * 900) + 4 +2 + b.Assert(els.Tags, qt.HasLen, 8) + b.Assert(els.IDs, qt.HasLen, 1) + } +} diff --git a/hugolib/site_url_test.go b/hugolib/site_url_test.go index 2be615963..091251f80 100644 --- a/hugolib/site_url_test.go +++ b/hugolib/site_url_test.go @@ -1,4 +1,4 @@ -// Copyright 2016 The Hugo Authors. All rights reserved. +// 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. @@ -18,81 +18,15 @@ import ( "path/filepath" "testing" - "html/template" - + qt "github.com/frankban/quicktest" "github.com/gohugoio/hugo/deps" - "github.com/stretchr/testify/require" + "github.com/gohugoio/hugo/resources/kinds" ) -const slugDoc1 = "---\ntitle: slug doc 1\nslug: slug-doc-1\naliases:\n - sd1/foo/\n - sd2\n - sd3/\n - sd4.html\n---\nslug doc 1 content\n" - -const slugDoc2 = `--- -title: slug doc 2 -slug: slug-doc-2 ---- -slug doc 2 content -` - -var urlFakeSource = [][2]string{ - {filepath.FromSlash("content/blue/doc1.md"), slugDoc1}, - {filepath.FromSlash("content/blue/doc2.md"), slugDoc2}, -} - -// Issue #1105 -func TestShouldNotAddTrailingSlashToBaseURL(t *testing.T) { - t.Parallel() - for i, this := range []struct { - in string - expected string - }{ - {"http://base.com/", "http://base.com/"}, - {"http://base.com/sub/", "http://base.com/sub/"}, - {"http://base.com/sub", "http://base.com/sub"}, - {"http://base.com", "http://base.com"}} { - - cfg, fs := newTestCfg() - cfg.Set("baseURL", this.in) - d := deps.DepsCfg{Cfg: cfg, Fs: fs} - s, err := NewSiteForCfg(d) - require.NoError(t, err) - s.initializeSiteInfo() - - if s.Info.BaseURL() != template.URL(this.expected) { - t.Errorf("[%d] got %s expected %s", i, s.Info.BaseURL(), this.expected) - } - } -} - -func TestPageCount(t *testing.T) { - t.Parallel() - cfg, fs := newTestCfg() - cfg.Set("uglyURLs", false) - cfg.Set("paginate", 10) - - writeSourcesToSource(t, "", fs, urlFakeSource...) - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{}) - - _, err := s.Fs.Destination.Open("public/blue") - if err != nil { - t.Errorf("No indexed rendered.") - } - - for _, pth := range []string{ - "public/sd1/foo/index.html", - "public/sd2/index.html", - "public/sd3/index.html", - "public/sd4.html", - } { - if _, err := s.Fs.Destination.Open(filepath.FromSlash(pth)); err != nil { - t.Errorf("No alias rendered: %s", pth) - } - } -} - func TestUglyURLsPerSection(t *testing.T) { t.Parallel() - assert := require.New(t) + c := qt.New(t) const dt = `--- title: Do not go gentle into that good night @@ -109,29 +43,31 @@ Do not go gentle into that good night. cfg.Set("uglyURLs", map[string]bool{ "sect2": true, }) + configs, err := loadTestConfigFromProvider(cfg) + c.Assert(err, qt.IsNil) writeSource(t, fs, filepath.Join("content", "sect1", "p1.md"), dt) writeSource(t, fs, filepath.Join("content", "sect2", "p2.md"), dt) - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{SkipRender: true}) + s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Configs: configs}, BuildCfg{SkipRender: true}) - assert.Len(s.RegularPages, 2) + c.Assert(len(s.RegularPages()), qt.Equals, 2) - notUgly := s.getPage(KindPage, "sect1/p1.md") - assert.NotNil(notUgly) - assert.Equal("sect1", notUgly.Section()) - assert.Equal("/sect1/p1/", notUgly.RelPermalink()) + notUgly := s.getPageOldVersion(kinds.KindPage, "sect1/p1.md") + c.Assert(notUgly, qt.Not(qt.IsNil)) + c.Assert(notUgly.Section(), qt.Equals, "sect1") + c.Assert(notUgly.RelPermalink(), qt.Equals, "/sect1/p1/") - ugly := s.getPage(KindPage, "sect2/p2.md") - assert.NotNil(ugly) - assert.Equal("sect2", ugly.Section()) - assert.Equal("/sect2/p2.html", ugly.RelPermalink()) + ugly := s.getPageOldVersion(kinds.KindPage, "sect2/p2.md") + c.Assert(ugly, qt.Not(qt.IsNil)) + c.Assert(ugly.Section(), qt.Equals, "sect2") + c.Assert(ugly.RelPermalink(), qt.Equals, "/sect2/p2.html") } func TestSectionWithURLInFrontMatter(t *testing.T) { t.Parallel() - assert := require.New(t) + c := qt.New(t) const st = `--- title: Do not go gentle into that good night @@ -155,14 +91,13 @@ Do not go gentle into that good night. ` cfg, fs := newTestCfg() - th := testHelper{cfg, fs, t} - - cfg.Set("paginate", 1) + cfg.Set("pagination.pagerSize", 1) + th, configs := newTestHelperFromProvider(cfg, fs, t) writeSource(t, fs, filepath.Join("content", "sect1", "_index.md"), fmt.Sprintf(st, "/ss1/")) writeSource(t, fs, filepath.Join("content", "sect2", "_index.md"), fmt.Sprintf(st, "/ss2/")) - for i := 0; i < 5; i++ { + for i := range 5 { writeSource(t, fs, filepath.Join("content", "sect1", fmt.Sprintf("p%d.md", i+1)), pt) writeSource(t, fs, filepath.Join("content", "sect2", fmt.Sprintf("p%d.md", i+1)), pt) } @@ -171,14 +106,30 @@ Do not go gentle into that good night. writeSource(t, fs, filepath.Join("layouts", "_default", "list.html"), "P{{.Paginator.PageNumber}}|URL: {{.Paginator.URL}}|{{ if .Paginator.HasNext }}Next: {{.Paginator.Next.URL }}{{ end }}") - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{}) + s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Configs: configs}, BuildCfg{}) - assert.Len(s.RegularPages, 10) + c.Assert(len(s.RegularPages()), qt.Equals, 10) - sect1 := s.getPage(KindSection, "sect1") - assert.NotNil(sect1) - assert.Equal("/ss1/", sect1.RelPermalink()) + sect1 := s.getPageOldVersion(kinds.KindSection, "sect1") + c.Assert(sect1, qt.Not(qt.IsNil)) + c.Assert(sect1.RelPermalink(), qt.Equals, "/ss1/") th.assertFileContent(filepath.Join("public", "ss1", "index.html"), "P1|URL: /ss1/|Next: /ss1/page/2/") th.assertFileContent(filepath.Join("public", "ss1", "page", "2", "index.html"), "P2|URL: /ss1/page/2/|Next: /ss1/page/3/") - +} + +func TestSectionsEntries(t *testing.T) { + files := ` +-- hugo.toml -- +-- content/withfile/_index.md -- +-- content/withoutfile/p1.md -- +-- layouts/_default/list.html -- +SectionsEntries: {{ .SectionsEntries }} + + +` + + b := Test(t, files) + + b.AssertFileContent("public/withfile/index.html", "SectionsEntries: [withfile]") + b.AssertFileContent("public/withoutfile/index.html", "SectionsEntries: [withoutfile]") } diff --git a/hugolib/sitemap.go b/hugolib/sitemap.go deleted file mode 100644 index 64d6f5b7a..000000000 --- a/hugolib/sitemap.go +++ /dev/null @@ -1,45 +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 hugolib - -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 parseSitemap(input map[string]interface{}) Sitemap { - sitemap := Sitemap{Priority: -1, Filename: "sitemap.xml"} - - for key, value := range input { - switch key { - case "changefreq": - sitemap.ChangeFreq = cast.ToString(value) - case "priority": - sitemap.Priority = cast.ToFloat64(value) - case "filename": - sitemap.Filename = cast.ToString(value) - default: - jww.WARN.Printf("Unknown Sitemap field: %s\n", key) - } - } - - return sitemap -} diff --git a/hugolib/sitemap_test.go b/hugolib/sitemap_test.go index 002f772d8..922ecbc12 100644 --- a/hugolib/sitemap_test.go +++ b/hugolib/sitemap_test.go @@ -1,4 +1,4 @@ -// Copyright 2016 The Hugo Authors. All rights reserved. +// 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. @@ -14,89 +14,215 @@ package hugolib import ( + "reflect" + "strings" "testing" - "reflect" - - "github.com/stretchr/testify/require" - - "github.com/gohugoio/hugo/deps" - "github.com/gohugoio/hugo/tpl" + "github.com/gohugoio/hugo/config" ) -const sitemapTemplate = ` - {{ 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 }} - - {{ end }} -` - -func TestSitemapOutput(t *testing.T) { +func TestSitemapBasic(t *testing.T) { t.Parallel() - for _, internal := range []bool{false, true} { - doTestSitemapOutput(t, internal) - } + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ["term", "taxonomy"] +-- content/sect/doc1.md -- +--- +title: doc1 +--- +Doc1 +-- content/sect/doc2.md -- +--- +title: doc2 +--- +Doc2 +` + + b := Test(t, files) + + b.AssertFileContent("public/sitemap.xml", " https://example.com/sect/doc1/", "doc2") } -func doTestSitemapOutput(t *testing.T, internal bool) { +func TestSitemapMultilingual(t *testing.T) { + t.Parallel() - cfg, fs := newTestCfg() - cfg.Set("baseURL", "http://auth/bub/") + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ["term", "taxonomy"] +defaultContentLanguage = "en" +[languages] +[languages.en] +weight = 1 +languageName = "English" +[languages.nn] +weight = 2 +languageName = "Nynorsk" +-- content/sect/doc1.md -- +--- +title: doc1 +--- +Doc1 +-- content/sect/doc2.md -- +--- +title: doc2 +--- +Doc2 +-- content/sect/doc2.nn.md -- +--- +title: doc2 +--- +Doc2 +` - depsCfg := deps.DepsCfg{Fs: fs, Cfg: cfg} + b := Test(t, files) - depsCfg.WithTemplate = func(templ tpl.TemplateHandler) error { - if !internal { - templ.AddTemplate("sitemap.xml", sitemapTemplate) - } + b.AssertFileContent("public/sitemap.xml", "https://example.com/en/sitemap.xml", "https://example.com/nn/sitemap.xml") + b.AssertFileContent("public/en/sitemap.xml", " https://example.com/sect/doc1/", "doc2") + b.AssertFileContent("public/nn/sitemap.xml", " https://example.com/nn/sect/doc2/") +} - // We want to check that the 404 page is not included in the sitemap - // output. This template should have no effect either way, but include - // it for the clarity. - templ.AddTemplate("404.html", "Not found") - return nil - } +// https://github.com/gohugoio/hugo/issues/5910 +func TestSitemapOutputFormats(t *testing.T) { + t.Parallel() - writeSourcesToSource(t, "content", fs, weightedSources...) - s := buildSingleSite(t, depsCfg, BuildCfg{}) - th := testHelper{s.Cfg, s.Fs, t} - outputSitemap := "public/sitemap.xml" + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ["term", "taxonomy"] +-- content/blog/html-amp.md -- +--- +Title: AMP and HTML +outputs: [ "html", "amp" ] +--- - th.assertFileContent(outputSitemap, - // Regular page - " http://auth/bub/sect/doc1/", - // Home page - "http://auth/bub/", - // Section - "http://auth/bub/sect/", - // Tax terms - "http://auth/bub/categories/", - // Tax list - "http://auth/bub/categories/hugo/", - ) +` - content := readDestination(th.T, th.Fs, outputSitemap) - require.NotContains(t, content, "404") + b := Test(t, files) + // Should link to the HTML version. + b.AssertFileContent("public/sitemap.xml", " https://example.com/blog/html-amp/") } func TestParseSitemap(t *testing.T) { t.Parallel() - expected := Sitemap{Priority: 3.0, Filename: "doo.xml", ChangeFreq: "3"} - input := map[string]interface{}{ + expected := config.SitemapConfig{ChangeFreq: "3", Disable: true, Filename: "doo.xml", Priority: 3.0} + input := map[string]any{ "changefreq": "3", - "priority": 3.0, + "disable": true, "filename": "doo.xml", + "priority": 3.0, "unknown": "ignore", } - result := parseSitemap(input) + result, err := config.DecodeSitemap(config.SitemapConfig{}, input) + if err != nil { + t.Fatalf("Failed to parse sitemap: %s", err) + } if !reflect.DeepEqual(expected, result) { t.Errorf("Got \n%v expected \n%v", result, expected) } - +} + +func TestSitemapShouldNotUseListXML(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ["term", "taxonomy"] +[languages] +[languages.en] +weight = 1 +languageName = "English" +[languages.nn] +weight = 2 +-- layouts/list.xml -- +Site: {{ .Site.Title }}| +-- layouts/home -- +Home. + +` + + b := Test(t, files) + + b.AssertFileContent("public/sitemap.xml", "https://example.com/en/sitemap.xml") +} + +func TestSitemapAndContentBundleNamedSitemap(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['home','rss','section','taxonomy','term'] +-- layouts/_default/single.html -- +layouts/_default/single.html +-- layouts/sitemap/single.html -- +layouts/sitemap/single.html +-- content/sitemap/index.md -- +--- +title: My sitemap +type: sitemap +--- +` + + b := Test(t, files) + + b.AssertFileExists("public/sitemap.xml", true) +} + +// Issue 12266 +func TestSitemapIssue12266(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = 'https://example.org/' +disableKinds = ['rss','taxonomy','term'] +defaultContentLanguage = 'en' +defaultContentLanguageInSubdir = true +[languages.de] +[languages.en] + ` + + // Test A: multilingual with defaultContentLanguageInSubdir = true + b := Test(t, files) + + b.AssertFileContent("public/sitemap.xml", + "https://example.org/de/sitemap.xml", + "https://example.org/en/sitemap.xml", + ) + b.AssertFileContent("public/de/sitemap.xml", "https://example.org/de/") + b.AssertFileContent("public/en/sitemap.xml", "https://example.org/en/") + + // Test B: multilingual with defaultContentLanguageInSubdir = false + files = strings.ReplaceAll(files, "defaultContentLanguageInSubdir = true", "defaultContentLanguageInSubdir = false") + + b = Test(t, files) + + b.AssertFileContent("public/sitemap.xml", + "https://example.org/de/sitemap.xml", + "https://example.org/en/sitemap.xml", + ) + b.AssertFileContent("public/de/sitemap.xml", "https://example.org/de/") + b.AssertFileContent("public/en/sitemap.xml", "https://example.org/") + + // Test C: monolingual with defaultContentLanguageInSubdir = false + files = strings.ReplaceAll(files, "[languages.de]", "") + files = strings.ReplaceAll(files, "[languages.en]", "") + + b = Test(t, files) + + b.AssertFileExists("public/en/sitemap.xml", false) + b.AssertFileContent("public/sitemap.xml", "https://example.org/") + + // Test D: monolingual with defaultContentLanguageInSubdir = true + files = strings.ReplaceAll(files, "defaultContentLanguageInSubdir = false", "defaultContentLanguageInSubdir = true") + + b = Test(t, files) + + b.AssertFileContent("public/sitemap.xml", "https://example.org/en/sitemap.xml") + b.AssertFileContent("public/en/sitemap.xml", "https://example.org/en/") } diff --git a/hugolib/taxonomy.go b/hugolib/taxonomy.go deleted file mode 100644 index c8447d1ba..000000000 --- a/hugolib/taxonomy.go +++ /dev/null @@ -1,224 +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 hugolib - -import ( - "fmt" - "sort" -) - -// The TaxonomyList is a list of all taxonomies and their values -// e.g. List['tags'] => TagTaxonomy (from above) -type TaxonomyList map[string]Taxonomy - -func (tl TaxonomyList) String() string { - return fmt.Sprintf("TaxonomyList(%d)", len(tl)) -} - -// A Taxonomy is a map of keywords to a list of pages. -// For example -// TagTaxonomy['technology'] = WeightedPages -// TagTaxonomy['go'] = WeightedPages2 -type Taxonomy map[string]WeightedPages - -// WeightedPages is a list of Pages with their corresponding (and relative) weight -// [{Weight: 30, Page: *1}, {Weight: 40, Page: *2}] -type WeightedPages []WeightedPage - -// A WeightedPage is a Page with a weight. -type WeightedPage struct { - Weight int - *Page -} - -func (w WeightedPage) String() string { - return fmt.Sprintf("WeightedPage(%d,%q)", w.Weight, w.Page.title) -} - -// 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 - -// OrderedTaxonomyEntry is similar to an element of a Taxonomy, but with the key embedded (as name) -// e.g: {Name: Technology, WeightedPages: Taxonomyedpages} -type OrderedTaxonomyEntry struct { - Name string - WeightedPages WeightedPages -} - -// Get the weighted pages for the given key. -func (i Taxonomy) Get(key string) WeightedPages { - return i[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 WeightedPage) { - i[key] = append(i[key], w) -} - -// TaxonomyArray returns an ordered taxonomy with a non defined order. -func (i Taxonomy) TaxonomyArray() OrderedTaxonomy { - ies := make([]OrderedTaxonomyEntry, len(i)) - count := 0 - for k, v := range i { - ies[count] = OrderedTaxonomyEntry{Name: k, WeightedPages: v} - count++ - } - return ies -} - -// Alphabetical returns an ordered taxonomy sorted by key name. -func (i Taxonomy) Alphabetical() OrderedTaxonomy { - name := func(i1, i2 *OrderedTaxonomyEntry) bool { - return i1.Name < i2.Name - } - - ia := i.TaxonomyArray() - oiBy(name).Sort(ia) - return ia -} - -// ByCount returns an ordered taxonomy sorted by # of pages per key. -// If taxonomies have the same # of pages, sort them alphabetical -func (i Taxonomy) ByCount() OrderedTaxonomy { - count := func(i1, i2 *OrderedTaxonomyEntry) bool { - li1 := len(i1.WeightedPages) - li2 := len(i2.WeightedPages) - - if li1 == li2 { - return i1.Name < i2.Name - } - return li1 > li2 - } - - ia := i.TaxonomyArray() - oiBy(count).Sort(ia) - return ia -} - -// Pages returns the Pages for this taxonomy. -func (ie OrderedTaxonomyEntry) Pages() Pages { - return ie.WeightedPages.Pages() -} - -// Count returns the count the pages in this taxonomy. -func (ie OrderedTaxonomyEntry) Count() int { - return len(ie.WeightedPages) -} - -// Term returns the name given to this taxonomy. -func (ie OrderedTaxonomyEntry) Term() string { - return ie.Name -} - -// Reverse reverses the order of the entries in this taxonomy. -func (t OrderedTaxonomy) Reverse() OrderedTaxonomy { - for i, j := 0, len(t)-1; i < j; i, j = i+1, j-1 { - t[i], t[j] = t[j], t[i] - } - - return t -} - -// A type to implement the sort interface for TaxonomyEntries. -type orderedTaxonomySorter struct { - taxonomy OrderedTaxonomy - by oiBy -} - -// Closure used in the Sort.Less method. -type oiBy func(i1, i2 *OrderedTaxonomyEntry) bool - -func (by oiBy) Sort(taxonomy OrderedTaxonomy) { - ps := &orderedTaxonomySorter{ - taxonomy: taxonomy, - by: by, // The Sort method's receiver is the function (closure) that defines the sort order. - } - sort.Stable(ps) -} - -// Len is part of sort.Interface. -func (s *orderedTaxonomySorter) Len() int { - return len(s.taxonomy) -} - -// Swap is part of sort.Interface. -func (s *orderedTaxonomySorter) Swap(i, j int) { - s.taxonomy[i], s.taxonomy[j] = s.taxonomy[j], s.taxonomy[i] -} - -// Less is part of sort.Interface. It is implemented by calling the "by" closure in the sorter. -func (s *orderedTaxonomySorter) Less(i, j int) bool { - return s.by(&s.taxonomy[i], &s.taxonomy[j]) -} - -// Pages returns the Pages in this weighted page set. -func (wp WeightedPages) Pages() Pages { - pages := make(Pages, len(wp)) - for i := range wp { - pages[i] = wp[i].Page - } - return pages -} - -// Prev returns the previous Page relative to the given Page in -// this weighted page set. -func (wp WeightedPages) Prev(cur *Page) *Page { - for x, c := range wp { - if c.Page.UniqueID() == cur.UniqueID() { - if x == 0 { - return wp[len(wp)-1].Page - } - return wp[x-1].Page - } - } - return nil -} - -// Next returns the next Page relative to the given Page in -// this weighted page set. -func (wp WeightedPages) Next(cur *Page) *Page { - for x, c := range wp { - if c.Page.UniqueID() == cur.UniqueID() { - if x < len(wp)-1 { - return wp[x+1].Page - } - return wp[0].Page - } - } - return nil -} - -func (wp WeightedPages) Len() int { return len(wp) } -func (wp WeightedPages) Swap(i, j int) { wp[i], wp[j] = wp[j], wp[i] } - -// Sort stable sorts this weighted page set. -func (wp WeightedPages) Sort() { sort.Stable(wp) } - -// Count returns the number of pages in this weighted page set. -func (wp WeightedPages) Count() int { return len(wp) } - -func (wp WeightedPages) Less(i, j int) bool { - if wp[i].Weight == wp[j].Weight { - if wp[i].Page.Date.Equal(wp[j].Page.Date) { - return wp[i].Page.title < wp[j].Page.title - } - return wp[i].Page.Date.After(wp[i].Page.Date) - } - return wp[i].Weight < wp[j].Weight -} - -// TODO mimic PagesSorter for WeightedPages diff --git a/hugolib/taxonomy_test.go b/hugolib/taxonomy_test.go index 0445de58f..7aeaa780c 100644 --- a/hugolib/taxonomy_test.go +++ b/hugolib/taxonomy_test.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Hugo Authors. All rights reserved. +// 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. @@ -20,65 +20,77 @@ import ( "strings" "testing" - "github.com/stretchr/testify/require" + "github.com/gohugoio/hugo/resources/kinds" + "github.com/gohugoio/hugo/resources/page" + + qt "github.com/frankban/quicktest" "github.com/gohugoio/hugo/deps" ) -func TestByCountOrderOfTaxonomies(t *testing.T) { +func TestTaxonomiesCountOrder(t *testing.T) { t.Parallel() - taxonomies := make(map[string]string) + c := qt.New(t) + taxonomies := make(map[string]string) taxonomies["tag"] = "tags" taxonomies["category"] = "categories" cfg, fs := newTestCfg() + cfg.Set("titleCaseStyle", "none") cfg.Set("taxonomies", taxonomies) + configs, err := loadTestConfigFromProvider(cfg) + c.Assert(err, qt.IsNil) - writeSource(t, fs, filepath.Join("content", "page.md"), pageYamlWithTaxonomiesA) + const pageContent = `--- +tags: ['a', 'B', 'c'] +categories: 'd' +--- +YAML frontmatter with tags and categories taxonomy.` - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{}) + writeSource(t, fs, filepath.Join("content", "page.md"), pageContent) + + s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Configs: configs}, BuildCfg{}) st := make([]string, 0) - for _, t := range s.Taxonomies["tags"].ByCount() { - st = append(st, t.Name) + for _, t := range s.Taxonomies()["tags"].ByCount() { + st = append(st, t.Page().Title()+":"+t.Name) } - if !reflect.DeepEqual(st, []string{"a", "b", "c"}) { - t.Fatalf("ordered taxonomies do not match [a, b, c]. Got: %s", st) + expect := []string{"a:a", "B:b", "c:c"} + + if !reflect.DeepEqual(st, expect) { + t.Fatalf("ordered taxonomies mismatch, expected\n%v\ngot\n%q", expect, st) } } -// func TestTaxonomiesWithAndWithoutContentFile(t *testing.T) { for _, uglyURLs := range []bool{false, true} { - for _, preserveTaxonomyNames := range []bool{false, true} { - t.Run(fmt.Sprintf("uglyURLs=%t,preserveTaxonomyNames=%t", uglyURLs, preserveTaxonomyNames), func(t *testing.T) { - doTestTaxonomiesWithAndWithoutContentFile(t, preserveTaxonomyNames, uglyURLs) - }) - } + uglyURLs := uglyURLs + t.Run(fmt.Sprintf("uglyURLs=%t", uglyURLs), func(t *testing.T) { + t.Parallel() + doTestTaxonomiesWithAndWithoutContentFile(t, uglyURLs) + }) } } -func doTestTaxonomiesWithAndWithoutContentFile(t *testing.T, preserveTaxonomyNames, uglyURLs bool) { - t.Parallel() +func doTestTaxonomiesWithAndWithoutContentFile(t *testing.T, uglyURLs bool) { + t.Helper() siteConfig := ` baseURL = "http://example.com/blog" -preserveTaxonomyNames = %t +titleCaseStyle = "firstupper" uglyURLs = %t - -paginate = 1 defaultContentLanguage = "en" - +[pagination] +pagerSize = 1 [Taxonomies] tag = "tags" category = "categories" other = "others" empty = "empties" permalinked = "permalinkeds" - [permalinks] permalinkeds = "/perma/:slug/" ` @@ -97,30 +109,22 @@ permalinkeds: # Doc ` - siteConfig = fmt.Sprintf(siteConfig, preserveTaxonomyNames, uglyURLs) + siteConfig = fmt.Sprintf(siteConfig, uglyURLs) - th, h := newTestSitesFromConfigWithDefaultTemplates(t, siteConfig) - require.Len(t, h.Sites, 1) + b := newTestSitesBuilder(t).WithConfigFile("toml", siteConfig) - fs := th.Fs + b.WithContent( + "p1.md", fmt.Sprintf(pageTemplate, "t1/c1", "- Tag1", "- cAt1", "- o1", "- Pl1"), + "p2.md", fmt.Sprintf(pageTemplate, "t2/c1", "- tag2", "- cAt1", "- o1", "- Pl1"), + "p3.md", fmt.Sprintf(pageTemplate, "t2/c12", "- tag2", "- cat2", "- o1", "- Pl1"), + "p4.md", fmt.Sprintf(pageTemplate, "Hello World", "", "", "- \"Hello Hugo world\"", "- Pl1"), + "categories/_index.md", newTestPage("Category Terms", "2017-01-01", 10), + "tags/Tag1/_index.md", newTestPage("Tag1 List", "2017-01-01", 10), + // https://github.com/gohugoio/hugo/issues/5847 + "/tags/not-used/_index.md", newTestPage("Unused Tag List", "2018-01-01", 10), + ) - if preserveTaxonomyNames { - writeSource(t, fs, "content/p1.md", fmt.Sprintf(pageTemplate, "t1/c1", "- tag1", "- cat1", "- o1", "- pl1")) - } else { - // Check lower-casing of tags - writeSource(t, fs, "content/p1.md", fmt.Sprintf(pageTemplate, "t1/c1", "- Tag1", "- cAt1", "- o1", "- pl1")) - - } - writeSource(t, fs, "content/p2.md", fmt.Sprintf(pageTemplate, "t2/c1", "- tag2", "- cat1", "- o1", "- pl1")) - writeSource(t, fs, "content/p3.md", fmt.Sprintf(pageTemplate, "t2/c12", "- tag2", "- cat2", "- o1", "- pl1")) - writeSource(t, fs, "content/p4.md", fmt.Sprintf(pageTemplate, "Hello World", "", "", "- \"Hello Hugo world\"", "- pl1")) - - writeNewContentFile(t, fs.Source, "Category Terms", "2017-01-01", "content/categories/_index.md", 10) - writeNewContentFile(t, fs.Source, "Tag1 List", "2017-01-01", "content/tags/Tag1/_index.md", 10) - - err := h.Build(BuildCfg{}) - - require.NoError(t, err) + b.Build(BuildCfg{}) // So what we have now is: // 1. categories with terms content page, but no content page for the only c1 category @@ -136,30 +140,31 @@ permalinkeds: } // 1. - th.assertFileContent(pathFunc("public/categories/cat1/index.html"), "List", "Cat1") - th.assertFileContent(pathFunc("public/categories/index.html"), "Terms List", "Category Terms") + b.AssertFileContent(pathFunc("public/categories/cat1/index.html"), "List", "CAt1") + b.AssertFileContent(pathFunc("public/categories/index.html"), "Taxonomy Term Page", "Category Terms") // 2. - th.assertFileContent(pathFunc("public/tags/tag2/index.html"), "List", "Tag2") - th.assertFileContent(pathFunc("public/tags/tag1/index.html"), "List", "Tag1") - th.assertFileContent(pathFunc("public/tags/index.html"), "Terms List", "Tags") + b.AssertFileContent(pathFunc("public/tags/tag2/index.html"), "List", "tag2") + b.AssertFileContent(pathFunc("public/tags/tag1/index.html"), "List", "Tag1") + b.AssertFileContent(pathFunc("public/tags/index.html"), "Taxonomy Term Page", "Tags") // 3. - th.assertFileContent(pathFunc("public/others/o1/index.html"), "List", "O1") - th.assertFileContent(pathFunc("public/others/index.html"), "Terms List", "Others") + b.AssertFileContent(pathFunc("public/others/o1/index.html"), "List", "o1") + b.AssertFileContent(pathFunc("public/others/index.html"), "Taxonomy Term Page", "Others") // 4. - th.assertFileContent(pathFunc("public/perma/pl1/index.html"), "List", "Pl1") + b.AssertFileContent(pathFunc("public/perma/pl1/index.html"), "List", "Pl1") + // This looks kind of funky, but the taxonomy terms do not have a permalinks definition, // for good reasons. - th.assertFileContent(pathFunc("public/permalinkeds/index.html"), "Terms List", "Permalinkeds") + b.AssertFileContent(pathFunc("public/permalinkeds/index.html"), "Taxonomy Term Page", "Permalinkeds") - s := h.Sites[0] + s := b.H.Sites[0] - // Make sure that each KindTaxonomyTerm page has an appropriate number - // of KindTaxonomy pages in its Pages slice. + // Make sure that each kinds.KindTaxonomyTerm page has an appropriate number + // of kinds.KindTaxonomy pages in its Pages slice. taxonomyTermPageCounts := map[string]int{ - "tags": 2, + "tags": 3, "categories": 2, "others": 2, "empties": 0, @@ -167,47 +172,859 @@ permalinkeds: } for taxonomy, count := range taxonomyTermPageCounts { - term := s.getPage(KindTaxonomyTerm, taxonomy) - require.NotNil(t, term) - require.Len(t, term.Pages, count) + msg := qt.Commentf(taxonomy) + term := s.getPageOldVersion(kinds.KindTaxonomy, taxonomy) + b.Assert(term, qt.Not(qt.IsNil), msg) + b.Assert(len(term.Pages()), qt.Equals, count, msg) - for _, page := range term.Pages { - require.Equal(t, KindTaxonomy, page.Kind) + for _, p := range term.Pages() { + b.Assert(p.Kind(), qt.Equals, kinds.KindTerm) } } - cat1 := s.getPage(KindTaxonomy, "categories", "cat1") - require.NotNil(t, cat1) + cat1 := s.getPageOldVersion(kinds.KindTerm, "categories", "cat1") + b.Assert(cat1, qt.Not(qt.IsNil)) if uglyURLs { - require.Equal(t, "/blog/categories/cat1.html", cat1.RelPermalink()) + b.Assert(cat1.RelPermalink(), qt.Equals, "/blog/categories/cat1.html") } else { - require.Equal(t, "/blog/categories/cat1/", cat1.RelPermalink()) + b.Assert(cat1.RelPermalink(), qt.Equals, "/blog/categories/cat1/") } - pl1 := s.getPage(KindTaxonomy, "permalinkeds", "pl1") - permalinkeds := s.getPage(KindTaxonomyTerm, "permalinkeds") - require.NotNil(t, pl1) - require.NotNil(t, permalinkeds) + pl1 := s.getPageOldVersion(kinds.KindTerm, "permalinkeds", "pl1") + permalinkeds := s.getPageOldVersion(kinds.KindTaxonomy, "permalinkeds") + b.Assert(pl1, qt.Not(qt.IsNil)) + b.Assert(permalinkeds, qt.Not(qt.IsNil)) if uglyURLs { - require.Equal(t, "/blog/perma/pl1.html", pl1.RelPermalink()) - require.Equal(t, "/blog/permalinkeds.html", permalinkeds.RelPermalink()) + b.Assert(pl1.RelPermalink(), qt.Equals, "/blog/perma/pl1.html") + b.Assert(permalinkeds.RelPermalink(), qt.Equals, "/blog/permalinkeds.html") } else { - require.Equal(t, "/blog/perma/pl1/", pl1.RelPermalink()) - require.Equal(t, "/blog/permalinkeds/", permalinkeds.RelPermalink()) + b.Assert(pl1.RelPermalink(), qt.Equals, "/blog/perma/pl1/") + b.Assert(permalinkeds.RelPermalink(), qt.Equals, "/blog/permalinkeds/") } - // Issue #3070 preserveTaxonomyNames - if preserveTaxonomyNames { - helloWorld := s.getPage(KindTaxonomy, "others", "Hello Hugo world") - require.NotNil(t, helloWorld) - require.Equal(t, "Hello Hugo world", helloWorld.title) - } else { - helloWorld := s.getPage(KindTaxonomy, "others", "hello-hugo-world") - require.NotNil(t, helloWorld) - require.Equal(t, "Hello Hugo World", helloWorld.title) - } + helloWorld := s.getPageOldVersion(kinds.KindTerm, "others", "hello-hugo-world") + b.Assert(helloWorld, qt.Not(qt.IsNil)) + b.Assert(helloWorld.Title(), qt.Equals, "Hello Hugo world") // Issue #2977 - th.assertFileContent(pathFunc("public/empties/index.html"), "Terms List", "Empties") - + b.AssertFileContent(pathFunc("public/empties/index.html"), "Taxonomy Term Page", "Empties") +} + +// https://github.com/gohugoio/hugo/issues/5513 +// https://github.com/gohugoio/hugo/issues/5571 +func TestTaxonomiesPathSeparation(t *testing.T) { + t.Parallel() + + config := ` +baseURL = "https://example.com" +titleCaseStyle = "none" +[taxonomies] +"news/tag" = "news/tags" +"news/category" = "news/categories" +"t1/t2/t3" = "t1/t2/t3s" +"s1/s2/s3" = "s1/s2/s3s" +` + + pageContent := ` ++++ +title = "foo" +"news/categories" = ["a", "b", "c", "d/e", "f/g/h"] +"t1/t2/t3s" = ["t4/t5", "t4/t5/t6"] ++++ +Content. +` + + b := newTestSitesBuilder(t) + b.WithConfigFile("toml", config) + b.WithContent("page.md", pageContent) + b.WithContent("news/categories/b/_index.md", ` +--- +title: "This is B" +--- +`) + + b.WithContent("news/categories/f/g/h/_index.md", ` +--- +title: "This is H" +--- +`) + + b.WithContent("t1/t2/t3s/t4/t5/_index.md", ` +--- +title: "This is T5" +--- +`) + + b.WithContent("s1/s2/s3s/_index.md", ` +--- +title: "This is S3s" +--- +`) + + b.CreateSites().Build(BuildCfg{}) + + s := b.H.Sites[0] + + filterbyKind := func(kind string) page.Pages { + var pages page.Pages + for _, p := range s.Pages() { + if p.Kind() == kind { + pages = append(pages, p) + } + } + return pages + } + + ta := filterbyKind(kinds.KindTerm) + te := filterbyKind(kinds.KindTaxonomy) + + b.Assert(len(te), qt.Equals, 4) + b.Assert(len(ta), qt.Equals, 7) + + b.AssertFileContent("public/news/categories/a/index.html", "Taxonomy List Page 1|a|Hello|https://example.com/news/categories/a/|") + b.AssertFileContent("public/news/categories/b/index.html", "Taxonomy List Page 1|This is B|Hello|https://example.com/news/categories/b/|") + b.AssertFileContent("public/news/categories/d/e/index.html", "Taxonomy List Page 1|d/e|Hello|https://example.com/news/categories/d/e/|") + b.AssertFileContent("public/news/categories/f/g/h/index.html", "Taxonomy List Page 1|This is H|Hello|https://example.com/news/categories/f/g/h/|") + b.AssertFileContent("public/t1/t2/t3s/t4/t5/index.html", "Taxonomy List Page 1|This is T5|Hello|https://example.com/t1/t2/t3s/t4/t5/|") + b.AssertFileContent("public/t1/t2/t3s/t4/t5/t6/index.html", "Taxonomy List Page 1|t4/t5/t6|Hello|https://example.com/t1/t2/t3s/t4/t5/t6/|") + + b.AssertFileContent("public/news/categories/index.html", "Taxonomy Term Page 1|categories|Hello|https://example.com/news/categories/|") + b.AssertFileContent("public/t1/t2/t3s/index.html", "Taxonomy Term Page 1|t3s|Hello|https://example.com/t1/t2/t3s/|") + b.AssertFileContent("public/s1/s2/s3s/index.html", "Taxonomy Term Page 1|This is S3s|Hello|https://example.com/s1/s2/s3s/|") +} + +// https://github.com/gohugoio/hugo/issues/5719 +func TestTaxonomiesNextGenLoops(t *testing.T) { + b := newTestSitesBuilder(t).WithSimpleConfigFile() + + b.WithTemplatesAdded("index.html", ` +

    Tags

    + + +`) + + b.WithTemplatesAdded("_default/terms.html", ` +

    Terms

    +
      + {{ range .Data.Terms.Alphabetical }} +
    • {{ .Page.Title }} {{ .Count }}
    • + {{ end }} +
    +`) + + for i := range 10 { + b.WithContent(fmt.Sprintf("page%d.md", i+1), ` +--- +Title: "Taxonomy!" +tags: ["Hugo Rocks!", "Rocks I say!" ] +categories: ["This is Cool", "And new" ] +--- + +Content. + + `) + } + + b.CreateSites().Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", `
  • Hugo Rocks! 10
  • `) + b.AssertFileContent("public/categories/index.html", `
  • This Is Cool 10
  • `) + b.AssertFileContent("public/tags/index.html", `
  • Rocks I Say! 10
  • `) +} + +// Issue 6213 +func TestTaxonomiesNotForDrafts(t *testing.T) { + t.Parallel() + + b := newTestSitesBuilder(t) + b.WithContent("draft.md", `--- +title: "Draft" +draft: true +categories: ["drafts"] +--- + +`, + "regular.md", `--- +title: "Not Draft" +categories: ["regular"] +--- + +`) + + b.Build(BuildCfg{}) + s := b.H.Sites[0] + + b.Assert(b.CheckExists("public/categories/regular/index.html"), qt.Equals, true) + b.Assert(b.CheckExists("public/categories/drafts/index.html"), qt.Equals, false) + + reg, _ := s.getPage(nil, "categories/regular") + dra, _ := s.getPage(nil, "categories/draft") + b.Assert(reg, qt.Not(qt.IsNil)) + b.Assert(dra, qt.IsNil) +} + +func TestTaxonomiesIndexDraft(t *testing.T) { + t.Parallel() + b := newTestSitesBuilder(t) + b.WithContent( + "categories/_index.md", `--- +title: "The Categories" +draft: true +--- + +Content. + +`, + "page.md", `--- +title: "The Page" +categories: ["cool"] +--- + +Content. + +`, + ) + + b.WithTemplates("index.html", ` +{{ range .Site.Pages }} +{{ .RelPermalink }}|{{ .Title }}|{{ .WordCount }}|{{ .Content }}| +{{ end }} +`) + + b.Build(BuildCfg{}) + + b.AssertFileContentFn("public/index.html", func(s string) bool { + return !strings.Contains(s, "/categories/|") + }) +} + +// https://github.com/gohugoio/hugo/issues/6927 +func TestTaxonomiesHomeDraft(t *testing.T) { + t.Parallel() + + b := newTestSitesBuilder(t) + b.WithContent( + "_index.md", `--- +title: "Home" +draft: true +--- + +Content. + +`, + "posts/_index.md", `--- +title: "Posts" +draft: true +--- + +Content. + +`, + "posts/page.md", `--- +title: "The Page" +categories: ["cool"] +--- + +Content. + +`, + ) + + b.WithTemplates("index.html", ` +NO HOME FOR YOU +`) + + b.Build(BuildCfg{}) + + b.Assert(b.CheckExists("public/index.html"), qt.Equals, false) + b.Assert(b.CheckExists("public/categories/index.html"), qt.Equals, false) + b.Assert(b.CheckExists("public/posts/index.html"), qt.Equals, false) +} + +// https://github.com/gohugoio/hugo/issues/6173 +func TestTaxonomiesWithBundledResources(t *testing.T) { + b := newTestSitesBuilder(t) + b.WithTemplates("_default/list.html", ` +List {{ .Title }}: +{{ range .Resources }} +Resource: {{ .RelPermalink }}|{{ .MediaType }} +{{ end }} + `) + + b.WithContent("p1.md", `--- +title: Page +categories: ["funny"] +--- + `, + "categories/_index.md", "---\ntitle: Categories Page\n---", + "categories/data.json", "Category data", + "categories/funny/_index.md", "---\ntitle: Funny Category\n---", + "categories/funny/funnydata.json", "Category funny data", + ) + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/categories/index.html", `Resource: /categories/data.json|application/json`) + b.AssertFileContent("public/categories/funny/index.html", `Resource: /categories/funny/funnydata.json|application/json`) +} + +func TestTaxonomiesRemoveOne(t *testing.T) { + files := ` +-- hugo.toml -- +disableLiveReload = true +-- layouts/index.html -- +{{ $cats := .Site.Taxonomies.categories.cats }} +{{ if $cats }} +Len cats: {{ len $cats }} +{{ range $cats }} + Cats:|{{ .Page.RelPermalink }}| +{{ end }} +{{ end }} +{{ $funny := .Site.Taxonomies.categories.funny }} +{{ if $funny }} +Len funny: {{ len $funny }} +{{ range $funny }} + Funny:|{{ .Page.RelPermalink }}| +{{ end }} +{{ end }} +-- content/p1.md -- +--- +title: Page +categories: ["funny", "cats"] +--- +-- content/p2.md -- +--- +title: Page2 +categories: ["funny", "cats"] +--- + +` + b := TestRunning(t, files) + + b.AssertFileContent("public/index.html", ` +Len cats: 2 +Len funny: 2 +Cats:|/p1/| +Cats:|/p2/| +Funny:|/p1/| +Funny:|/p2/|`) + + // Remove one category from one of the pages. + b.EditFiles("content/p1.md", `--- +title: Page +categories: ["funny"] +--- + `) + + b.Build() + + b.AssertFileContent("public/index.html", ` +Len cats: 1 +Len funny: 2 +Cats:|/p2/| +Funny:|/p1/| +Funny:|/p2/|`) +} + +// https://github.com/gohugoio/hugo/issues/6590 +func TestTaxonomiesListPages(t *testing.T) { + b := newTestSitesBuilder(t) + b.WithTemplates("_default/list.html", ` + +{{ template "print-taxo" "categories.cats" }} +{{ template "print-taxo" "categories.funny" }} + +{{ define "print-taxo" }} +{{ $node := index site.Taxonomies (split $ ".") }} +{{ if $node }} +Len {{ $ }}: {{ len $node }} +{{ range $node }} + {{ $ }}:|{{ .Page.RelPermalink }}| +{{ end }} +{{ else }} +{{ $ }} not found. +{{ end }} +{{ end }} + `) + + b.WithContent("_index.md", `--- +title: Home +categories: ["funny", "cats"] +--- + `, "blog/p1.md", `--- +title: Page1 +categories: ["funny"] +--- + `, "blog/_index.md", `--- +title: Blog Section +categories: ["cats"] +--- + `, + ) + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", ` + +Len categories.cats: 2 +categories.cats:|/blog/| +categories.cats:|/| + +Len categories.funny: 2 +categories.funny:|/| +categories.funny:|/blog/p1/| +`) +} + +func TestTaxonomiesPageCollections(t *testing.T) { + t.Parallel() + + b := newTestSitesBuilder(t) + b.WithContent( + "_index.md", `--- +title: "Home Sweet Home" +categories: [ "dogs", "gorillas"] +--- +`, + "section/_index.md", `--- +title: "Section" +categories: [ "cats", "dogs", "birds"] +--- +`, + "section/p1.md", `--- +title: "Page1" +categories: ["funny", "cats"] +--- +`, "section/p2.md", `--- +title: "Page2" +categories: ["funny"] +--- +`) + + b.WithTemplatesAdded("index.html", ` +{{ $home := site.Home }} +{{ $section := site.GetPage "section" }} +{{ $categories := site.GetPage "categories" }} +{{ $funny := site.GetPage "categories/funny" }} +{{ $cats := site.GetPage "categories/cats" }} +{{ $p1 := site.GetPage "section/p1" }} + +Categories Pages: {{ range $categories.Pages}}{{.RelPermalink }}|{{ end }}:END +Funny Pages: {{ range $funny.Pages}}{{.RelPermalink }}|{{ end }}:END +Cats Pages: {{ range $cats.Pages}}{{.RelPermalink }}|{{ end }}:END +P1 Terms: {{ range $p1.GetTerms "categories" }}{{.RelPermalink }}|{{ end }}:END +Section Terms: {{ range $section.GetTerms "categories" }}{{.RelPermalink }}|{{ end }}:END +Home Terms: {{ range $home.GetTerms "categories" }}{{.RelPermalink }}|{{ end }}:END +Category Paginator {{ range $categories.Paginator.Pages }}{{ .RelPermalink }}|{{ end }}:END +Cats Paginator {{ range $cats.Paginator.Pages }}{{ .RelPermalink }}|{{ end }}:END + +`) + b.WithTemplatesAdded("404.html", ` +404 Terms: {{ range .GetTerms "categories" }}{{.RelPermalink }}|{{ end }}:END + `) + b.Build(BuildCfg{}) + + cat := b.GetPage("categories") + funny := b.GetPage("categories/funny") + + b.Assert(cat, qt.Not(qt.IsNil)) + b.Assert(funny, qt.Not(qt.IsNil)) + + b.Assert(cat.Parent().IsHome(), qt.Equals, true) + b.Assert(funny.Kind(), qt.Equals, "term") + b.Assert(funny.Parent(), qt.Equals, cat) + + b.AssertFileContent("public/index.html", ` +Categories Pages: /categories/birds/|/categories/cats/|/categories/dogs/|/categories/funny/|/categories/gorillas/|:END +Funny Pages: /section/p1/|/section/p2/|:END +Cats Pages: /section/p1/|/section/|:END +P1 Terms: /categories/funny/|/categories/cats/|:END +Section Terms: /categories/cats/|/categories/dogs/|/categories/birds/|:END +Home Terms: /categories/dogs/|/categories/gorillas/|:END +Cats Paginator /section/p1/|/section/|:END +Category Paginator /categories/birds/|/categories/cats/|/categories/dogs/|/categories/funny/|/categories/gorillas/|:END`, + ) + b.AssertFileContent("public/404.html", "\n404 Terms: :END\n\t") + b.AssertFileContent("public/categories/funny/index.xml", `http://example.com/section/p1/`) + b.AssertFileContent("public/categories/index.xml", `http://example.com/categories/funny/`) +} + +func TestTaxonomiesDirectoryOverlaps(t *testing.T) { + t.Parallel() + + b := newTestSitesBuilder(t).WithContent( + "abc/_index.md", "---\ntitle: \"abc\"\nabcdefgs: [abc]\n---", + "abc/p1.md", "---\ntitle: \"abc-p\"\n---", + "abcdefgh/_index.md", "---\ntitle: \"abcdefgh\"\n---", + "abcdefgh/p1.md", "---\ntitle: \"abcdefgh-p\"\n---", + "abcdefghijk/index.md", "---\ntitle: \"abcdefghijk\"\n---", + ) + + b.WithConfigFile("toml", ` +baseURL = "https://example.org" +titleCaseStyle = "none" + +[taxonomies] + abcdef = "abcdefs" + abcdefg = "abcdefgs" + abcdefghi = "abcdefghis" +`) + + b.WithTemplatesAdded("index.html", ` +{{ range site.Pages }}Page: {{ template "print-page" . }} +{{ end }} +{{ $abc := site.GetPage "abcdefgs/abc" }} +{{ $abcdefgs := site.GetPage "abcdefgs" }} +abc: {{ template "print-page" $abc }}|IsAncestor: {{ $abc.IsAncestor $abcdefgs }}|IsDescendant: {{ $abc.IsDescendant $abcdefgs }} +abcdefgs: {{ template "print-page" $abcdefgs }}|IsAncestor: {{ $abcdefgs.IsAncestor $abc }}|IsDescendant: {{ $abcdefgs.IsDescendant $abc }} + +{{ define "print-page" }}{{ .RelPermalink }}|{{ .Title }}|{{.Kind }}|Parent: {{ with .Parent }}{{ .RelPermalink }}{{ end }}|CurrentSection: {{ .CurrentSection.RelPermalink}}|FirstSection: {{ .FirstSection.RelPermalink }}{{ end }} + +`) + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", ` + Page: /||home|Parent: |CurrentSection: /| + Page: /abc/|abc|section|Parent: /|CurrentSection: /abc/| + Page: /abc/p1/|abc-p|page|Parent: /abc/|CurrentSection: /abc/| + Page: /abcdefgh/|abcdefgh|section|Parent: /|CurrentSection: /abcdefgh/| + Page: /abcdefgh/p1/|abcdefgh-p|page|Parent: /abcdefgh/|CurrentSection: /abcdefgh/| + Page: /abcdefghijk/|abcdefghijk|page|Parent: /|CurrentSection: /| + Page: /abcdefghis/|abcdefghis|taxonomy|Parent: /|CurrentSection: /abcdefghis/| + Page: /abcdefgs/|abcdefgs|taxonomy|Parent: /|CurrentSection: /abcdefgs/| + Page: /abcdefs/|abcdefs|taxonomy|Parent: /|CurrentSection: /abcdefs/| + abc: /abcdefgs/abc/|abc|term|Parent: /abcdefgs/|CurrentSection: /abcdefgs/abc/| + abcdefgs: /abcdefgs/|abcdefgs|taxonomy|Parent: /|CurrentSection: /abcdefgs/| + abc: /abcdefgs/abc/|abc|term|Parent: /abcdefgs/|CurrentSection: /abcdefgs/abc/|FirstSection: /abcdefgs/|IsAncestor: false|IsDescendant: true + abcdefgs: /abcdefgs/|abcdefgs|taxonomy|Parent: /|CurrentSection: /abcdefgs/|FirstSection: /abcdefgs/|IsAncestor: true|IsDescendant: false +`) +} + +func TestTaxonomiesWeightSort(t *testing.T) { + files := ` +-- layouts/index.html -- +{{ $a := site.GetPage "tags/a"}} +:{{ range $a.Pages }}{{ .RelPermalink }}|{{ end }}: +-- content/p1.md -- +--- +title: P1 +weight: 100 +tags: ['a'] +tags_weight: 20 +--- +-- content/p3.md -- +--- +title: P2 +weight: 200 +tags: ['a'] +tags_weight: 30 +--- +-- content/p2.md -- +--- +title: P3 +weight: 50 +tags: ['a'] +tags_weight: 40 +--- + ` + + b := Test(t, files) + + b.AssertFileContent("public/index.html", `:/p1/|/p3/|/p2/|:`) +} + +func TestTaxonomiesEmptyTagsString(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +[taxonomies] +tag = 'tags' +-- content/p1.md -- ++++ +title = "P1" +tags = '' ++++ +-- layouts/_default/single.html -- +Single. + +` + Test(t, files) +} + +func TestTaxonomiesSpaceInName(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +[taxonomies] +authors = 'book authors' +-- content/p1.md -- +--- +title: Good Omens +book authors: + - Neil Gaiman + - Terry Pratchett +--- +-- layouts/index.html -- +{{- $taxonomy := "book authors" }} +Len Book Authors: {{ len (index .Site.Taxonomies $taxonomy) }} +` + b := Test(t, files) + + b.AssertFileContent("public/index.html", "Len Book Authors: 2") +} + +func TestTaxonomiesListTermsHome(t *testing.T) { + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +[taxonomies] +tag = "tags" +-- content/_index.md -- +--- +title: "Home" +tags: ["a", "b", "c", "hello world"] +--- +-- content/tags/a/_index.md -- +--- +title: "A" +--- +-- content/tags/b/_index.md -- +--- +title: "B" +--- +-- content/tags/c/_index.md -- +--- +title: "C" +--- +-- content/tags/d/_index.md -- +--- +title: "D" +--- +-- content/tags/hello-world/_index.md -- +--- +title: "Hello World!" +--- +-- layouts/home.html -- +Terms: {{ range site.Taxonomies.tags }}{{ .Page.Title }}: {{ .Count }}|{{ end }}$ +` + b := Test(t, files) + + b.AssertFileContent("public/index.html", "Terms: A: 1|B: 1|C: 1|Hello World!: 1|$") +} + +func TestTaxonomiesTermTitleAndTerm(t *testing.T) { + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +[taxonomies] +tag = "tags" +-- content/_index.md -- +--- +title: "Home" +tags: ["hellO world"] +--- +-- layouts/_default/term.html -- +{{ .Title }}|{{ .Kind }}|{{ .Data.Singular }}|{{ .Data.Plural }}|{{ .Page.Data.Term }}| +` + + b := Test(t, files) + + b.AssertFileContent("public/tags/hello-world/index.html", "HellO World|term|tag|tags|hellO world|") +} + +func TestTermDraft(t *testing.T) { + t.Parallel() + + files := ` +-- layouts/_default/list.html -- +|{{ .Title }}| +-- content/p1.md -- +--- +title: p1 +tags: [a] +--- +-- content/tags/a/_index.md -- +--- +title: tag-a-title-override +draft: true +--- + ` + + b := Test(t, files) + + b.AssertFileExists("public/tags/a/index.html", false) +} + +func TestTermBuildNeverRenderNorList(t *testing.T) { + t.Parallel() + + files := ` +-- layouts/index.html -- +|{{ len site.Taxonomies.tags }}| +-- content/p1.md -- +--- +title: p1 +tags: [a] +--- +-- content/tags/a/_index.md -- +--- +title: tag-a-title-override +build: + render: never + list: never +--- + + ` + + b := Test(t, files) + + b.AssertFileExists("public/tags/a/index.html", false) + b.AssertFileContent("public/index.html", "|0|") +} + +func TestTaxonomiesTermLookup(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +[taxonomies] +tag = "tags" +-- content/_index.md -- +--- +title: "Home" +tags: ["a", "b"] +--- +-- layouts/taxonomy/tag.html -- +Tag: {{ .Title }}| +-- content/tags/a/_index.md -- +--- +title: tag-a-title-override +--- +` + + b := Test(t, files) + + b.AssertFileContent("public/tags/a/index.html", "Tag: tag-a-title-override|") +} + +func TestTaxonomyLookupIssue12193(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['page','rss','section','sitemap'] +[taxonomies] +author = 'authors' +-- layouts/_default/list.html -- +{{ .Title }}| +-- layouts/_default/author.terms.html -- +layouts/_default/author.terms.html +-- content/authors/_index.md -- +--- +title: Authors Page +--- +` + + b := Test(t, files) + + b.AssertFileExists("public/index.html", true) + b.AssertFileExists("public/authors/index.html", true) + b.AssertFileContent("public/authors/index.html", "layouts/_default/author.terms.html") // failing test +} + +func TestTaxonomyNestedEmptySectionsIssue12188(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['rss','sitemap'] +defaultContentLanguage = 'en' +defaultContentLanguageInSubdir = true +[languages.en] +weight = 1 +[languages.ja] +weight = 2 +[taxonomies] +'s1/category' = 's1/category' +-- layouts/_default/single.html -- +{{ .Title }}| +-- layouts/_default/list.html -- +{{ .Title }}| +-- content/s1/p1.en.md -- +--- +title: p1 +--- +` + + b := Test(t, files) + + b.AssertFileExists("public/en/s1/index.html", true) + b.AssertFileExists("public/en/s1/p1/index.html", true) + b.AssertFileExists("public/en/s1/category/index.html", true) + + b.AssertFileExists("public/ja/s1/index.html", false) // failing test + b.AssertFileExists("public/ja/s1/category/index.html", true) +} + +func BenchmarkTaxonomiesGetTerms(b *testing.B) { + createBuilders := func(b *testing.B, numPages int) []*IntegrationTestBuilder { + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ["RSS", "sitemap", "section"] +[taxononomies] +tag = "tags" +-- layouts/_default/list.html -- +List. +-- layouts/_default/single.html -- +GetTerms.tags: {{ range .GetTerms "tags" }}{{ .Title }}|{{ end }} +-- content/_index.md -- +` + + tagsVariants := []string{ + "tags: ['a']", + "tags: ['a', 'b']", + "tags: ['a', 'b', 'c']", + "tags: ['a', 'b', 'c', 'd']", + "tags: ['a', 'b', 'd', 'e']", + "tags: ['a', 'b', 'c', 'd', 'e']", + "tags: ['a', 'd']", + "tags: ['a', 'f']", + } + + for i := 1; i < numPages; i++ { + tags := tagsVariants[i%len(tagsVariants)] + files += fmt.Sprintf("\n-- content/posts/p%d.md --\n---\n%s\n---", i+1, tags) + } + cfg := IntegrationTestConfig{ + T: b, + TxtarString: files, + } + builders := make([]*IntegrationTestBuilder, b.N) + + for i := range builders { + builders[i] = NewIntegrationTestBuilder(cfg) + } + + b.ResetTimer() + + return builders + } + + for _, numPages := range []int{100, 1000, 10000, 20000} { + b.Run(fmt.Sprintf("pages_%d", numPages), func(b *testing.B) { + builders := createBuilders(b, numPages) + for i := 0; i < b.N; i++ { + builders[i].Build() + } + }) + } } diff --git a/hugolib/template_engines_test.go b/hugolib/template_engines_test.go deleted file mode 100644 index 6a046c9f5..000000000 --- a/hugolib/template_engines_test.go +++ /dev/null @@ -1,108 +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 hugolib - -import ( - "fmt" - "path/filepath" - "testing" - - "strings" - - "github.com/gohugoio/hugo/deps" -) - -func TestAllTemplateEngines(t *testing.T) { - t.Parallel() - noOp := func(s string) string { - return s - } - - amberFixer := func(s string) string { - fixed := strings.Replace(s, "{{ .Title", "{{ Title", -1) - fixed = strings.Replace(fixed, ".Content", "Content", -1) - fixed = strings.Replace(fixed, ".IsNamedParams", "IsNamedParams", -1) - fixed = strings.Replace(fixed, "{{", "#{", -1) - fixed = strings.Replace(fixed, "}}", "}", -1) - fixed = strings.Replace(fixed, `title "hello world"`, `title("hello world")`, -1) - - return fixed - } - - for _, config := range []struct { - suffix string - templateFixer func(s string) string - }{ - {"amber", amberFixer}, - {"html", noOp}, - {"ace", noOp}, - } { - t.Run(config.suffix, - func(t *testing.T) { - doTestTemplateEngine(t, config.suffix, config.templateFixer) - }) - } - -} - -func doTestTemplateEngine(t *testing.T, suffix string, templateFixer func(s string) string) { - - cfg, fs := newTestCfg() - - t.Log("Testing", suffix) - - templTemplate := ` -p - | - | Page Title: {{ .Title }} - br - | Page Content: {{ .Content }} - br - | {{ title "hello world" }} - -` - - templShortcodeTemplate := ` -p - | - | Shortcode: {{ .IsNamedParams }} -` - - templ := templateFixer(templTemplate) - shortcodeTempl := templateFixer(templShortcodeTemplate) - - writeSource(t, fs, filepath.Join("content", "p.md"), ` ---- -title: My Title ---- -My Content - -Shortcode: {{< myShort >}} - -`) - - writeSource(t, fs, filepath.Join("layouts", "_default", fmt.Sprintf("single.%s", suffix)), templ) - writeSource(t, fs, filepath.Join("layouts", "shortcodes", fmt.Sprintf("myShort.%s", suffix)), shortcodeTempl) - - s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{}) - th := testHelper{s.Cfg, s.Fs, t} - - th.assertFileContent(filepath.Join("public", "p", "index.html"), - "Page Title: My Title", - "My Content", - "Hello World", - "Shortcode: false", - ) - -} diff --git a/hugolib/template_test.go b/hugolib/template_test.go index eed3ee8ed..a08f83cb8 100644 --- a/hugolib/template_test.go +++ b/hugolib/template_test.go @@ -18,18 +18,22 @@ import ( "path/filepath" "testing" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/config/allconfig" + + qt "github.com/frankban/quicktest" "github.com/gohugoio/hugo/deps" "github.com/gohugoio/hugo/hugofs" - - "github.com/spf13/viper" ) +// TODO(bep) keep this until we release v0.146.0 as a security against breaking changes, but it's rather messy and mostly duplicate of +// tests in the tplimpl package, so eventually just remove it. func TestTemplateLookupOrder(t *testing.T) { - t.Parallel() var ( - fs *hugofs.Fs - cfg *viper.Viper - th testHelper + fs *hugofs.Fs + cfg config.Provider + th testHelper + configs *allconfig.Configs ) // Variants base templates: @@ -47,7 +51,6 @@ func TestTemplateLookupOrder(t *testing.T) { func(t *testing.T) { writeSource(t, fs, filepath.Join("layouts", "section", "sect1-baseof.html"), `Base: {{block "main" .}}block{{end}}`) writeSource(t, fs, filepath.Join("layouts", "section", "sect1.html"), `{{define "main"}}sect{{ end }}`) - }, func(t *testing.T) { th.assertFileContent(filepath.Join("public", "sect1", "index.html"), "Base: sect") @@ -58,7 +61,6 @@ func TestTemplateLookupOrder(t *testing.T) { func(t *testing.T) { writeSource(t, fs, filepath.Join("layouts", "baseof.html"), `Base: {{block "main" .}}block{{end}}`) writeSource(t, fs, filepath.Join("layouts", "index.html"), `{{define "main"}}index{{ end }}`) - }, func(t *testing.T) { th.assertFileContent(filepath.Join("public", "index.html"), "Base: index") @@ -69,7 +71,6 @@ func TestTemplateLookupOrder(t *testing.T) { func(t *testing.T) { writeSource(t, fs, filepath.Join("layouts", "_default", "list-baseof.html"), `Base: {{block "main" .}}block{{end}}`) writeSource(t, fs, filepath.Join("layouts", "_default", "list.html"), `{{define "main"}}list{{ end }}`) - }, func(t *testing.T) { th.assertFileContent(filepath.Join("public", "sect1", "index.html"), "Base: list") @@ -80,7 +81,6 @@ func TestTemplateLookupOrder(t *testing.T) { func(t *testing.T) { writeSource(t, fs, filepath.Join("layouts", "_default", "baseof.html"), `Base: {{block "main" .}}block{{end}}`) writeSource(t, fs, filepath.Join("layouts", "_default", "list.html"), `{{define "main"}}list{{ end }}`) - }, func(t *testing.T) { th.assertFileContent(filepath.Join("public", "sect1", "index.html"), "Base: list") @@ -93,7 +93,6 @@ func TestTemplateLookupOrder(t *testing.T) { writeSource(t, fs, filepath.Join("layouts", "section", "sect1-baseof.html"), `Base: {{block "main" .}}block{{end}}`) writeSource(t, fs, filepath.Join("themes", "mytheme", "layouts", "section", "sect-baseof.html"), `Base Theme: {{block "main" .}}block{{end}}`) writeSource(t, fs, filepath.Join("layouts", "section", "sect1.html"), `{{define "main"}}sect{{ end }}`) - }, func(t *testing.T) { th.assertFileContent(filepath.Join("public", "sect1", "index.html"), "Base: sect") @@ -105,7 +104,6 @@ func TestTemplateLookupOrder(t *testing.T) { cfg.Set("theme", "mytheme") writeSource(t, fs, filepath.Join("themes", "mytheme", "layouts", "section", "sect1-baseof.html"), `Base Theme: {{block "main" .}}block{{end}}`) writeSource(t, fs, filepath.Join("layouts", "section", "sect1.html"), `{{define "main"}}sect{{ end }}`) - }, func(t *testing.T) { th.assertFileContent(filepath.Join("public", "sect1", "index.html"), "Base Theme: sect") @@ -119,7 +117,6 @@ func TestTemplateLookupOrder(t *testing.T) { writeSource(t, fs, filepath.Join("themes", "mytheme", "layouts", "_default", "baseof.html"), `Base Theme: {{block "main" .}}block{{end}}`) writeSource(t, fs, filepath.Join("themes", "mytheme", "layouts", "_default", "list.html"), `{{define "main"}}list{{ end }}`) writeSource(t, fs, filepath.Join("themes", "mytheme", "layouts", "index.html"), `{{define "main"}}index{{ end }}`) - }, func(t *testing.T) { th.assertFileContent(filepath.Join("public", "sect1", "index.html"), "Base: list") @@ -132,7 +129,6 @@ func TestTemplateLookupOrder(t *testing.T) { cfg.Set("theme", "mytheme") writeSource(t, fs, filepath.Join("themes", "mytheme", "layouts", "_default", "baseof.html"), `Base Theme: {{block "main" .}}block{{end}}`) writeSource(t, fs, filepath.Join("themes", "mytheme", "layouts", "_default", "list.html"), `{{define "main"}}list{{ end }}`) - }, func(t *testing.T) { th.assertFileContent(filepath.Join("public", "sect1", "index.html"), "Base Theme: list") @@ -154,7 +150,6 @@ func TestTemplateLookupOrder(t *testing.T) { // sect2 with list template in /section writeSource(t, fs, filepath.Join("themes", "mytheme", "layouts", "section", "sect2.html"), `sect2 list`) - }, func(t *testing.T) { th.assertFileContent(filepath.Join("public", "sect1", "index.html"), "sect list") @@ -166,7 +161,6 @@ func TestTemplateLookupOrder(t *testing.T) { // Issue #2995 "Test section list and single template selection with base template", func(t *testing.T) { - writeSource(t, fs, filepath.Join("layouts", "_default", "baseof.html"), `Base Default: {{block "main" .}}block{{end}}`) writeSource(t, fs, filepath.Join("layouts", "sect1", "baseof.html"), `Base Sect1: {{block "main" .}}block{{end}}`) writeSource(t, fs, filepath.Join("layouts", "section", "sect2-baseof.html"), `Base Sect2: {{block "main" .}}block{{end}}`) @@ -179,7 +173,6 @@ func TestTemplateLookupOrder(t *testing.T) { // sect2 with list template in /section writeSource(t, fs, filepath.Join("layouts", "section", "sect2.html"), `{{define "main"}}sect2 list{{ end }}`) - }, func(t *testing.T) { th.assertFileContent(filepath.Join("public", "sect1", "index.html"), "Base Sect1", "sect1 list") @@ -193,22 +186,546 @@ func TestTemplateLookupOrder(t *testing.T) { }, } { - cfg, fs = newTestCfg() - th = testHelper{cfg, fs, t} + this := this + if this.name != "Variant 1" { + continue + } + t.Run(this.name, func(t *testing.T) { + // TODO(bep) there are some function vars need to pull down here to enable => t.Parallel() + cfg, fs = newTestCfg() + this.setup(t) + th, configs = newTestHelperFromProvider(cfg, fs, t) - for i := 1; i <= 3; i++ { - writeSource(t, fs, filepath.Join("content", fmt.Sprintf("sect%d", i), fmt.Sprintf("page%d.md", i)), `--- + for i := 1; i <= 3; i++ { + writeSource(t, fs, filepath.Join("content", fmt.Sprintf("sect%d", i), fmt.Sprintf("page%d.md", i)), `--- title: Template test --- Some content `) - } + } - this.setup(t) - - buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{}) - t.Log(this.name) - this.assert(t) + buildSingleSite(t, deps.DepsCfg{Fs: fs, Configs: configs}, BuildCfg{}) + // s.TemplateStore.PrintDebug("", 0, os.Stdout) + this.assert(t) + }) } } + +// https://github.com/gohugoio/hugo/issues/4895 +func TestTemplateBOM(t *testing.T) { + b := newTestSitesBuilder(t).WithSimpleConfigFile() + bom := "\ufeff" + + b.WithTemplatesAdded( + "_default/baseof.html", bom+` + Base: {{ block "main" . }}base main{{ end }}`, + "_default/single.html", bom+`{{ define "main" }}Hi!?{{ end }}`) + + b.WithContent("page.md", `--- +title: "Page" +--- + +Page Content +`) + + b.CreateSites().Build(BuildCfg{}) + + b.AssertFileContent("public/page/index.html", "Base: Hi!?") +} + +func TestTemplateManyBaseTemplates(t *testing.T) { + t.Parallel() + b := newTestSitesBuilder(t).WithSimpleConfigFile() + + numPages := 100 // To get some parallelism + + pageTemplate := `--- +title: "Page %d" +layout: "layout%d" +--- + +Content. +` + + singleTemplate := ` +{{ define "main" }}%d{{ end }} +` + baseTemplate := ` +Base %d: {{ block "main" . }}FOO{{ end }} +` + + for i := range numPages { + id := i + 1 + b.WithContent(fmt.Sprintf("page%d.md", id), fmt.Sprintf(pageTemplate, id, id)) + b.WithTemplates(fmt.Sprintf("_default/layout%d.html", id), fmt.Sprintf(singleTemplate, id)) + b.WithTemplates(fmt.Sprintf("_default/layout%d-baseof.html", id), fmt.Sprintf(baseTemplate, id)) + } + + b.Build(BuildCfg{}) + for i := range numPages { + id := i + 1 + b.AssertFileContent(fmt.Sprintf("public/page%d/index.html", id), fmt.Sprintf(`Base %d: %d`, id, id)) + } +} + +// https://github.com/gohugoio/hugo/issues/6790 +func TestTemplateNoBasePlease(t *testing.T) { + t.Parallel() + b := newTestSitesBuilder(t).WithSimpleConfigFile() + + b.WithTemplates("_default/list.html", ` +{{ define "main" }} + Bonjour +{{ end }} + +{{ printf "list" }} + + + `) + + b.WithTemplates( + "_default/single.html", ` +{{ printf "single" }} +{{ define "main" }} + Bonjour +{{ end }} + + +`) + + b.WithContent("blog/p1.md", `--- +title: The Page +--- +`) + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/blog/p1/index.html", `single`) + b.AssertFileContent("public/blog/index.html", `list`) +} + +// https://github.com/gohugoio/hugo/issues/6816 +func TestTemplateBaseWithComment(t *testing.T) { + t.Parallel() + b := newTestSitesBuilder(t).WithSimpleConfigFile() + b.WithTemplatesAdded( + "baseof.html", `Base: {{ block "main" . }}{{ end }}`, + "index.html", ` + {{/* A comment */}} + {{ define "main" }} + Bonjour + {{ end }} + + + `) + + b.Build(BuildCfg{}) + b.AssertFileContent("public/index.html", `Base: +Bonjour`) +} + +func TestTemplateLookupSite(t *testing.T) { + t.Run("basic", func(t *testing.T) { + t.Parallel() + b := newTestSitesBuilder(t).WithSimpleConfigFile() + b.WithTemplates( + "_default/single.html", `Single: {{ .Title }}`, + "_default/list.html", `List: {{ .Title }}`, + ) + + createContent := func(title string) string { + return fmt.Sprintf(`--- +title: %s +---`, title) + } + + b.WithContent( + "_index.md", createContent("Home Sweet Home"), + "p1.md", createContent("P1")) + + b.CreateSites().Build(BuildCfg{}) + b.AssertFileContent("public/index.html", `List: Home Sweet Home`) + b.AssertFileContent("public/p1/index.html", `Single: P1`) + }) + + { + } +} + +func TestTemplateLookupSitBaseOf(t *testing.T) { + t.Parallel() + b := newTestSitesBuilder(t).WithDefaultMultiSiteConfig() + + b.WithTemplatesAdded( + "index.html", `{{ define "main" }}Main Home En{{ end }}`, + "index.fr.html", `{{ define "main" }}Main Home Fr{{ end }}`, + "baseof.html", `Baseof en: {{ block "main" . }}main block{{ end }}`, + "baseof.fr.html", `Baseof fr: {{ block "main" . }}main block{{ end }}`, + "mysection/baseof.html", `Baseof mysection: {{ block "main" . }}mysection block{{ end }}`, + "_default/single.html", `{{ define "main" }}Main Default Single{{ end }}`, + "_default/list.html", `{{ define "main" }}Main Default List{{ end }}`, + ) + + b.WithContent("mysection/p1.md", `--- +title: My Page +--- + +`) + + b.CreateSites().Build(BuildCfg{}) + + b.AssertFileContent("public/en/index.html", `Baseof en: Main Home En`) + b.AssertFileContent("public/fr/index.html", `Baseof fr: Main Home Fr`) + b.AssertFileContent("public/en/mysection/index.html", `Baseof mysection: Main Default List`) + b.AssertFileContent("public/en/mysection/p1/index.html", `Baseof mysection: Main Default Single`) +} + +func TestTemplateFuncs(t *testing.T) { + b := newTestSitesBuilder(t).WithDefaultMultiSiteConfig() + + homeTpl := `Site: {{ site.Language.Lang }} / {{ .Site.Language.Lang }} / {{ site.BaseURL }} +Sites: {{ site.Sites.Default.Home.Language.Lang }} +Hugo: {{ hugo.Generator }} +` + + b.WithTemplatesAdded( + "index.html", homeTpl, + "index.fr.html", homeTpl, + ) + + b.CreateSites().Build(BuildCfg{}) + + b.AssertFileContent("public/en/index.html", + "Site: en / en / http://example.com/blog", + "Sites: en", + "Hugo: {"@type":"WebPage","headline":"{{$title}}"} + +{{/* Action/commands newlines, from Go 1.16, see https://github.com/golang/go/issues/29770 */}} +{{ $norway := dict + "country" "Norway" + "population" "5 millions" + "language" "Norwegian" + "language_code" "nb" + "weather" "freezing cold" + "capitol" "Oslo" + "largest_city" "Oslo" + "currency" "Norwegian krone" + "dialing_code" "+47" +}} + +Population in Norway is {{ + $norway.population + | lower + | upper +}} + +`, + ) + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", ` + +Population in Norway is 5 MILLIONS + +`) +} + +func TestPartialInline(t *testing.T) { + b := newTestSitesBuilder(t) + + b.WithContent("p1.md", "") + + b.WithTemplates( + "index.html", ` + +{{ $p1 := partial "p1" . }} +{{ $p2 := partial "p2" . }} + +P1: {{ $p1 }} +P2: {{ $p2 }} + +{{ define "partials/p1" }}Inline: p1{{ end }} + +{{ define "partials/p2" }} +{{ $value := 32 }} +{{ return $value }} +{{ end }} + + +`, + ) + + b.CreateSites().Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", + ` +P1: Inline: p1 +P2: 32`, + ) +} + +func TestPartialInlineBase(t *testing.T) { + b := newTestSitesBuilder(t) + + b.WithContent("p1.md", "") + + b.WithTemplates( + "baseof.html", `{{ $p3 := partial "p3" . }}P3: {{ $p3 }} +{{ block "main" . }}{{ end }}{{ define "partials/p3" }}Inline: p3{{ end }}`, + "index.html", ` +{{ define "main" }} + +{{ $p1 := partial "p1" . }} +{{ $p2 := partial "p2" . }} + +P1: {{ $p1 }} +P2: {{ $p2 }} + +{{ end }} + + +{{ define "partials/p1" }}Inline: p1{{ end }} + +{{ define "partials/p2" }} +{{ $value := 32 }} +{{ return $value }} +{{ end }} + + +`, + ) + + b.CreateSites().Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", + ` +P1: Inline: p1 +P2: 32 +P3: Inline: p3 +`, + ) +} + +// https://github.com/gohugoio/hugo/issues/7478 +func TestBaseWithAndWithoutDefine(t *testing.T) { + b := newTestSitesBuilder(t) + + b.WithContent("p1.md", "---\ntitle: P\n---\nContent") + + b.WithTemplates( + "_default/baseof.html", ` +::Header Start:{{ block "header" . }}{{ end }}:Header End: +::{{ block "main" . }}Main{{ end }}:: +`, "index.html", ` +{{ define "header" }} +Home Header +{{ end }} +{{ define "main" }} +This is home main +{{ end }} +`, + + "_default/single.html", ` +{{ define "main" }} +This is single main +{{ end }} +`, + ) + + b.CreateSites().Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", ` +Home Header +This is home main +`, + ) + + b.AssertFileContent("public/p1/index.html", ` + ::Header Start::Header End: +This is single main +`, + ) +} + +// Issue 9393. +func TestApplyWithNamespace(t *testing.T) { + b := newTestSitesBuilder(t) + + b.WithTemplates( + "index.html", ` +{{ $b := slice " a " " b " " c" }} +{{ $a := apply $b "strings.Trim" "." " " }} +a: {{ $a }} +`, + ).WithContent("p1.md", "") + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", `a: [a b c]`) +} + +// Legacy behavior for internal templates. +func TestOverrideInternalTemplate(t *testing.T) { + files := ` +-- hugo.toml -- +baseURL = "https://example.org" +-- layouts/index.html -- +{{ template "_internal/google_analytics_async.html" . }} +-- layouts/_internal/google_analytics_async.html -- +Overridden. +` + b := Test(t, files) + + b.AssertFileContent("public/index.html", "Overridden.") +} diff --git a/hugolib/testdata/cities.csv b/hugolib/testdata/cities.csv new file mode 100644 index 000000000..ee6b058b6 --- /dev/null +++ b/hugolib/testdata/cities.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/hugolib/testdata/fakejson.json b/hugolib/testdata/fakejson.json new file mode 100644 index 000000000..f191b280c Binary files /dev/null and b/hugolib/testdata/fakejson.json differ diff --git a/hugolib/testdata/fruits.json b/hugolib/testdata/fruits.json new file mode 100644 index 000000000..3bb802a16 --- /dev/null +++ b/hugolib/testdata/fruits.json @@ -0,0 +1,5 @@ +{ + "fruit": "Apple", + "size": "Large", + "color": "Red" +} diff --git a/hugolib/testdata/what-is-markdown.md b/hugolib/testdata/what-is-markdown.md new file mode 100644 index 000000000..87db650b7 --- /dev/null +++ b/hugolib/testdata/what-is-markdown.md @@ -0,0 +1,9702 @@ +# Introduction + +## What is Markdown? + +Markdown is a plain text format for writing structured documents, +based on conventions for indicating formatting in email +and usenet posts. It was developed by John Gruber (with +help from Aaron Swartz) and released in 2004 in the form of a +[syntax description](http://daringfireball.net/projects/markdown/syntax) +and a Perl script (`Markdown.pl`) for converting Markdown to +HTML. In the next decade, dozens of implementations were +developed in many languages. Some extended the original +Markdown syntax with conventions for footnotes, tables, and +other document elements. Some allowed Markdown documents to be +rendered in formats other than HTML. Websites like Reddit, +StackOverflow, and GitHub had millions of people using Markdown. +And Markdown started to be used beyond the web, to author books, +articles, slide shows, letters, and lecture notes. + +What distinguishes Markdown from many other lightweight markup +syntaxes, which are often easier to write, is its readability. +As Gruber writes: + +> The overriding design goal for Markdown's formatting syntax is +> to make it as readable as possible. The idea is that a +> Markdown-formatted document should be publishable as-is, as +> plain text, without looking like it's been marked up with tags +> or formatting instructions. +> () + +The point can be illustrated by comparing a sample of +[AsciiDoc](http://www.methods.co.nz/asciidoc/) with +an equivalent sample of Markdown. Here is a sample of +AsciiDoc from the AsciiDoc manual: + +``` +1. List item one. ++ +List item one continued with a second paragraph followed by an +Indented block. ++ +................. +$ ls *.sh +$ mv *.sh ~/tmp +................. ++ +List item continued with a third paragraph. + +2. List item two continued with an open block. ++ +-- +This paragraph is part of the preceding list item. + +a. This list is nested and does not require explicit item +continuation. ++ +This paragraph is part of the preceding list item. + +b. List item b. + +This paragraph belongs to item two of the outer list. +-- +``` + +And here is the equivalent in Markdown: +``` +1. List item one. + + List item one continued with a second paragraph followed by an + Indented block. + + $ ls *.sh + $ mv *.sh ~/tmp + + List item continued with a third paragraph. + +2. List item two continued with an open block. + + This paragraph is part of the preceding list item. + + 1. This list is nested and does not require explicit item continuation. + + This paragraph is part of the preceding list item. + + 2. List item b. + + This paragraph belongs to item two of the outer list. +``` + +The AsciiDoc version is, arguably, easier to write. You don't need +to worry about indentation. But the Markdown version is much easier +to read. The nesting of list items is apparent to the eye in the +source, not just in the processed document. + +## Why is a spec needed? + +John Gruber's [canonical description of Markdown's +syntax](http://daringfireball.net/projects/markdown/syntax) +does not specify the syntax unambiguously. Here are some examples of +questions it does not answer: + +1. How much indentation is needed for a sublist? The spec says that + continuation paragraphs need to be indented four spaces, but is + not fully explicit about sublists. It is natural to think that + they, too, must be indented four spaces, but `Markdown.pl` does + not require that. This is hardly a "corner case," and divergences + between implementations on this issue often lead to surprises for + users in real documents. (See [this comment by John + Gruber](http://article.gmane.org/gmane.text.markdown.general/1997).) + +2. Is a blank line needed before a block quote or heading? + Most implementations do not require the blank line. However, + this can lead to unexpected results in hard-wrapped text, and + also to ambiguities in parsing (note that some implementations + put the heading inside the blockquote, while others do not). + (John Gruber has also spoken [in favor of requiring the blank + lines](http://article.gmane.org/gmane.text.markdown.general/2146).) + +3. Is a blank line needed before an indented code block? + (`Markdown.pl` requires it, but this is not mentioned in the + documentation, and some implementations do not require it.) + + ``` markdown + paragraph + code? + ``` + +4. What is the exact rule for determining when list items get + wrapped in `

    ` tags? Can a list be partially "loose" and partially + "tight"? What should we do with a list like this? + + ``` markdown + 1. one + + 2. two + 3. three + ``` + + Or this? + + ``` markdown + 1. one + - a + + - b + 2. two + ``` + + (There are some relevant comments by John Gruber + [here](http://article.gmane.org/gmane.text.markdown.general/2554).) + +5. Can list markers be indented? Can ordered list markers be right-aligned? + + ``` markdown + 8. item 1 + 9. item 2 + 10. item 2a + ``` + +6. Is this one list with a thematic break in its second item, + or two lists separated by a thematic break? + + ``` markdown + * a + * * * * * + * b + ``` + +7. When list markers change from numbers to bullets, do we have + two lists or one? (The Markdown syntax description suggests two, + but the perl scripts and many other implementations produce one.) + + ``` markdown + 1. fee + 2. fie + - foe + - fum + ``` + +8. What are the precedence rules for the markers of inline structure? + For example, is the following a valid link, or does the code span + take precedence ? + + ``` markdown + [a backtick (`)](/url) and [another backtick (`)](/url). + ``` + +9. What are the precedence rules for markers of emphasis and strong + emphasis? For example, how should the following be parsed? + + ``` markdown + *foo *bar* baz* + ``` + +10. What are the precedence rules between block-level and inline-level + structure? For example, how should the following be parsed? + + ``` markdown + - `a long code span can contain a hyphen like this + - and it can screw things up` + ``` + +11. Can list items include section headings? (`Markdown.pl` does not + allow this, but does allow blockquotes to include headings.) + + ``` markdown + - # Heading + ``` + +12. Can list items be empty? + + ``` markdown + * a + * + * b + ``` + +13. Can link references be defined inside block quotes or list items? + + ``` markdown + > Blockquote [foo]. + > + > [foo]: /url + ``` + +14. If there are multiple definitions for the same reference, which takes + precedence? + + ``` markdown + [foo]: /url1 + [foo]: /url2 + + [foo][] + ``` + +In the absence of a spec, early implementers consulted `Markdown.pl` +to resolve these ambiguities. But `Markdown.pl` was quite buggy, and +gave manifestly bad results in many cases, so it was not a +satisfactory replacement for a spec. + +Because there is no unambiguous spec, implementations have diverged +considerably. As a result, users are often surprised to find that +a document that renders one way on one system (say, a GitHub wiki) +renders differently on another (say, converting to docbook using +pandoc). To make matters worse, because nothing in Markdown counts +as a "syntax error," the divergence often isn't discovered right away. + +## About this document + +This document attempts to specify Markdown syntax unambiguously. +It contains many examples with side-by-side Markdown and +HTML. These are intended to double as conformance tests. An +accompanying script `spec_tests.py` can be used to run the tests +against any Markdown program: + + python test/spec_tests.py --spec spec.txt --program PROGRAM + +Since this document describes how Markdown is to be parsed into +an abstract syntax tree, it would have made sense to use an abstract +representation of the syntax tree instead of HTML. But HTML is capable +of representing the structural distinctions we need to make, and the +choice of HTML for the tests makes it possible to run the tests against +an implementation without writing an abstract syntax tree renderer. + +This document is generated from a text file, `spec.txt`, written +in Markdown with a small extension for the side-by-side tests. +The script `tools/makespec.py` can be used to convert `spec.txt` into +HTML or CommonMark (which can then be converted into other formats). + +In the examples, the `→` character is used to represent tabs. + +# Preliminaries + +## Characters and lines + +Any sequence of [characters] is a valid CommonMark +document. + +A [character](@) is a Unicode code point. Although some +code points (for example, combining accents) do not correspond to +characters in an intuitive sense, all code points count as characters +for purposes of this spec. + +This spec does not specify an encoding; it thinks of lines as composed +of [characters] rather than bytes. A conforming parser may be limited +to a certain encoding. + +A [line](@) is a sequence of zero or more [characters] +other than newline (`U+000A`) or carriage return (`U+000D`), +followed by a [line ending] or by the end of file. + +A [line ending](@) is a newline (`U+000A`), a carriage return +(`U+000D`) not followed by a newline, or a carriage return and a +following newline. + +A line containing no characters, or a line containing only spaces +(`U+0020`) or tabs (`U+0009`), is called a [blank line](@). + +The following definitions of character classes will be used in this spec: + +A [whitespace character](@) is a space +(`U+0020`), tab (`U+0009`), newline (`U+000A`), line tabulation (`U+000B`), +form feed (`U+000C`), or carriage return (`U+000D`). + +[Whitespace](@) is a sequence of one or more [whitespace +characters]. + +A [Unicode whitespace character](@) is +any code point in the Unicode `Zs` general category, or a tab (`U+0009`), +carriage return (`U+000D`), newline (`U+000A`), or form feed +(`U+000C`). + +[Unicode whitespace](@) is a sequence of one +or more [Unicode whitespace characters]. + +A [space](@) is `U+0020`. + +A [non-whitespace character](@) is any character +that is not a [whitespace character]. + +An [ASCII punctuation character](@) +is `!`, `"`, `#`, `$`, `%`, `&`, `'`, `(`, `)`, +`*`, `+`, `,`, `-`, `.`, `/` (U+0021–2F), +`:`, `;`, `<`, `=`, `>`, `?`, `@` (U+003A–0040), +`[`, `\`, `]`, `^`, `_`, `` ` `` (U+005B–0060), +`{`, `|`, `}`, or `~` (U+007B–007E). + +A [punctuation character](@) is an [ASCII +punctuation character] or anything in +the general Unicode categories `Pc`, `Pd`, `Pe`, `Pf`, `Pi`, `Po`, or `Ps`. + +## Tabs + +Tabs in lines are not expanded to [spaces]. However, +in contexts where whitespace helps to define block structure, +tabs behave as if they were replaced by spaces with a tab stop +of 4 characters. + +Thus, for example, a tab can be used instead of four spaces +in an indented code block. (Note, however, that internal +tabs are passed through as literal tabs, not expanded to +spaces.) + +```````````````````````````````` example +→foo→baz→→bim +. +

    foo→baz→→bim
    +
    +```````````````````````````````` + +```````````````````````````````` example + →foo→baz→→bim +. +
    foo→baz→→bim
    +
    +```````````````````````````````` + +```````````````````````````````` example + a→a + ὐ→a +. +
    a→a
    +ὐ→a
    +
    +```````````````````````````````` + +In the following example, a continuation paragraph of a list +item is indented with a tab; this has exactly the same effect +as indentation with four spaces would: + +```````````````````````````````` example + - foo + +→bar +. +
      +
    • +

      foo

      +

      bar

      +
    • +
    +```````````````````````````````` + +```````````````````````````````` example +- foo + +→→bar +. +
      +
    • +

      foo

      +
        bar
      +
      +
    • +
    +```````````````````````````````` + +Normally the `>` that begins a block quote may be followed +optionally by a space, which is not considered part of the +content. In the following case `>` is followed by a tab, +which is treated as if it were expanded into three spaces. +Since one of these spaces is considered part of the +delimiter, `foo` is considered to be indented six spaces +inside the block quote context, so we get an indented +code block starting with two spaces. + +```````````````````````````````` example +>→→foo +. +
    +
      foo
    +
    +
    +```````````````````````````````` + +```````````````````````````````` example +-→→foo +. +
      +
    • +
        foo
      +
      +
    • +
    +```````````````````````````````` + + +```````````````````````````````` example + foo +→bar +. +
    foo
    +bar
    +
    +```````````````````````````````` + +```````````````````````````````` example + - foo + - bar +→ - baz +. +
      +
    • foo +
        +
      • bar +
          +
        • baz
        • +
        +
      • +
      +
    • +
    +```````````````````````````````` + +```````````````````````````````` example +#→Foo +. +

    Foo

    +```````````````````````````````` + +```````````````````````````````` example +*→*→*→ +. +
    +```````````````````````````````` + + +## Insecure characters + +For security reasons, the Unicode character `U+0000` must be replaced +with the REPLACEMENT CHARACTER (`U+FFFD`). + +# Blocks and inlines + +We can think of a document as a sequence of +[blocks](@)---structural elements like paragraphs, block +quotations, lists, headings, rules, and code blocks. Some blocks (like +block quotes and list items) contain other blocks; others (like +headings and paragraphs) contain [inline](@) content---text, +links, emphasized text, images, code spans, and so on. + +## Precedence + +Indicators of block structure always take precedence over indicators +of inline structure. So, for example, the following is a list with +two items, not a list with one item containing a code span: + +```````````````````````````````` example +- `one +- two` +. +
      +
    • `one
    • +
    • two`
    • +
    +```````````````````````````````` + + +This means that parsing can proceed in two steps: first, the block +structure of the document can be discerned; second, text lines inside +paragraphs, headings, and other block constructs can be parsed for inline +structure. The second step requires information about link reference +definitions that will be available only at the end of the first +step. Note that the first step requires processing lines in sequence, +but the second can be parallelized, since the inline parsing of +one block element does not affect the inline parsing of any other. + +## Container blocks and leaf blocks + +We can divide blocks into two types: +[container blocks](@), +which can contain other blocks, and [leaf blocks](@), +which cannot. + +# Leaf blocks + +This section describes the different kinds of leaf block that make up a +Markdown document. + +## Thematic breaks + +A line consisting of 0-3 spaces of indentation, followed by a sequence +of three or more matching `-`, `_`, or `*` characters, each followed +optionally by any number of spaces or tabs, forms a +[thematic break](@). + +```````````````````````````````` example +*** +--- +___ +. +
    +
    +
    +```````````````````````````````` + + +Wrong characters: + +```````````````````````````````` example ++++ +. +

    +++

    +```````````````````````````````` + + +```````````````````````````````` example +=== +. +

    ===

    +```````````````````````````````` + + +Not enough characters: + +```````````````````````````````` example +-- +** +__ +. +

    -- +** +__

    +```````````````````````````````` + + +One to three spaces indent are allowed: + +```````````````````````````````` example + *** + *** + *** +. +
    +
    +
    +```````````````````````````````` + + +Four spaces is too many: + +```````````````````````````````` example + *** +. +
    ***
    +
    +```````````````````````````````` + + +```````````````````````````````` example +Foo + *** +. +

    Foo +***

    +```````````````````````````````` + + +More than three characters may be used: + +```````````````````````````````` example +_____________________________________ +. +
    +```````````````````````````````` + + +Spaces are allowed between the characters: + +```````````````````````````````` example + - - - +. +
    +```````````````````````````````` + + +```````````````````````````````` example + ** * ** * ** * ** +. +
    +```````````````````````````````` + + +```````````````````````````````` example +- - - - +. +
    +```````````````````````````````` + + +Spaces are allowed at the end: + +```````````````````````````````` example +- - - - +. +
    +```````````````````````````````` + + +However, no other characters may occur in the line: + +```````````````````````````````` example +_ _ _ _ a + +a------ + +---a--- +. +

    _ _ _ _ a

    +

    a------

    +

    ---a---

    +```````````````````````````````` + + +It is required that all of the [non-whitespace characters] be the same. +So, this is not a thematic break: + +```````````````````````````````` example + *-* +. +

    -

    +```````````````````````````````` + + +Thematic breaks do not need blank lines before or after: + +```````````````````````````````` example +- foo +*** +- bar +. +
      +
    • foo
    • +
    +
    +
      +
    • bar
    • +
    +```````````````````````````````` + + +Thematic breaks can interrupt a paragraph: + +```````````````````````````````` example +Foo +*** +bar +. +

    Foo

    +
    +

    bar

    +```````````````````````````````` + + +If a line of dashes that meets the above conditions for being a +thematic break could also be interpreted as the underline of a [setext +heading], the interpretation as a +[setext heading] takes precedence. Thus, for example, +this is a setext heading, not a paragraph followed by a thematic break: + +```````````````````````````````` example +Foo +--- +bar +. +

    Foo

    +

    bar

    +```````````````````````````````` + + +When both a thematic break and a list item are possible +interpretations of a line, the thematic break takes precedence: + +```````````````````````````````` example +* Foo +* * * +* Bar +. +
      +
    • Foo
    • +
    +
    +
      +
    • Bar
    • +
    +```````````````````````````````` + + +If you want a thematic break in a list item, use a different bullet: + +```````````````````````````````` example +- Foo +- * * * +. +
      +
    • Foo
    • +
    • +
      +
    • +
    +```````````````````````````````` + + +## ATX headings + +An [ATX heading](@) +consists of a string of characters, parsed as inline content, between an +opening sequence of 1--6 unescaped `#` characters and an optional +closing sequence of any number of unescaped `#` characters. +The opening sequence of `#` characters must be followed by a +[space] or by the end of line. The optional closing sequence of `#`s must be +preceded by a [space] and may be followed by spaces only. The opening +`#` character may be indented 0-3 spaces. The raw contents of the +heading are stripped of leading and trailing spaces before being parsed +as inline content. The heading level is equal to the number of `#` +characters in the opening sequence. + +Simple headings: + +```````````````````````````````` example +# foo +## foo +### foo +#### foo +##### foo +###### foo +. +

    foo

    +

    foo

    +

    foo

    +

    foo

    +
    foo
    +
    foo
    +```````````````````````````````` + + +More than six `#` characters is not a heading: + +```````````````````````````````` example +####### foo +. +

    ####### foo

    +```````````````````````````````` + + +At least one space is required between the `#` characters and the +heading's contents, unless the heading is empty. Note that many +implementations currently do not require the space. However, the +space was required by the +[original ATX implementation](http://www.aaronsw.com/2002/atx/atx.py), +and it helps prevent things like the following from being parsed as +headings: + +```````````````````````````````` example +#5 bolt + +#hashtag +. +

    #5 bolt

    +

    #hashtag

    +```````````````````````````````` + + +This is not a heading, because the first `#` is escaped: + +```````````````````````````````` example +\## foo +. +

    ## foo

    +```````````````````````````````` + + +Contents are parsed as inlines: + +```````````````````````````````` example +# foo *bar* \*baz\* +. +

    foo bar *baz*

    +```````````````````````````````` + + +Leading and trailing [whitespace] is ignored in parsing inline content: + +```````````````````````````````` example +# foo +. +

    foo

    +```````````````````````````````` + + +One to three spaces indentation are allowed: + +```````````````````````````````` example + ### foo + ## foo + # foo +. +

    foo

    +

    foo

    +

    foo

    +```````````````````````````````` + + +Four spaces are too much: + +```````````````````````````````` example + # foo +. +
    # foo
    +
    +```````````````````````````````` + + +```````````````````````````````` example +foo + # bar +. +

    foo +# bar

    +```````````````````````````````` + + +A closing sequence of `#` characters is optional: + +```````````````````````````````` example +## foo ## + ### bar ### +. +

    foo

    +

    bar

    +```````````````````````````````` + + +It need not be the same length as the opening sequence: + +```````````````````````````````` example +# foo ################################## +##### foo ## +. +

    foo

    +
    foo
    +```````````````````````````````` + + +Spaces are allowed after the closing sequence: + +```````````````````````````````` example +### foo ### +. +

    foo

    +```````````````````````````````` + + +A sequence of `#` characters with anything but [spaces] following it +is not a closing sequence, but counts as part of the contents of the +heading: + +```````````````````````````````` example +### foo ### b +. +

    foo ### b

    +```````````````````````````````` + + +The closing sequence must be preceded by a space: + +```````````````````````````````` example +# foo# +. +

    foo#

    +```````````````````````````````` + + +Backslash-escaped `#` characters do not count as part +of the closing sequence: + +```````````````````````````````` example +### foo \### +## foo #\## +# foo \# +. +

    foo ###

    +

    foo ###

    +

    foo #

    +```````````````````````````````` + + +ATX headings need not be separated from surrounding content by blank +lines, and they can interrupt paragraphs: + +```````````````````````````````` example +**** +## foo +**** +. +
    +

    foo

    +
    +```````````````````````````````` + + +```````````````````````````````` example +Foo bar +# baz +Bar foo +. +

    Foo bar

    +

    baz

    +

    Bar foo

    +```````````````````````````````` + + +ATX headings can be empty: + +```````````````````````````````` example +## +# +### ### +. +

    +

    +

    +```````````````````````````````` + + +## Setext headings + +A [setext heading](@) consists of one or more +lines of text, each containing at least one [non-whitespace +character], with no more than 3 spaces indentation, followed by +a [setext heading underline]. The lines of text must be such +that, were they not followed by the setext heading underline, +they would be interpreted as a paragraph: they cannot be +interpretable as a [code fence], [ATX heading][ATX headings], +[block quote][block quotes], [thematic break][thematic breaks], +[list item][list items], or [HTML block][HTML blocks]. + +A [setext heading underline](@) is a sequence of +`=` characters or a sequence of `-` characters, with no more than 3 +spaces indentation and any number of trailing spaces. If a line +containing a single `-` can be interpreted as an +empty [list items], it should be interpreted this way +and not as a [setext heading underline]. + +The heading is a level 1 heading if `=` characters are used in +the [setext heading underline], and a level 2 heading if `-` +characters are used. The contents of the heading are the result +of parsing the preceding lines of text as CommonMark inline +content. + +In general, a setext heading need not be preceded or followed by a +blank line. However, it cannot interrupt a paragraph, so when a +setext heading comes after a paragraph, a blank line is needed between +them. + +Simple examples: + +```````````````````````````````` example +Foo *bar* +========= + +Foo *bar* +--------- +. +

    Foo bar

    +

    Foo bar

    +```````````````````````````````` + + +The content of the header may span more than one line: + +```````````````````````````````` example +Foo *bar +baz* +==== +. +

    Foo bar +baz

    +```````````````````````````````` + +The contents are the result of parsing the headings's raw +content as inlines. The heading's raw content is formed by +concatenating the lines and removing initial and final +[whitespace]. + +```````````````````````````````` example + Foo *bar +baz*→ +==== +. +

    Foo bar +baz

    +```````````````````````````````` + + +The underlining can be any length: + +```````````````````````````````` example +Foo +------------------------- + +Foo += +. +

    Foo

    +

    Foo

    +```````````````````````````````` + + +The heading content can be indented up to three spaces, and need +not line up with the underlining: + +```````````````````````````````` example + Foo +--- + + Foo +----- + + Foo + === +. +

    Foo

    +

    Foo

    +

    Foo

    +```````````````````````````````` + + +Four spaces indent is too much: + +```````````````````````````````` example + Foo + --- + + Foo +--- +. +
    Foo
    +---
    +
    +Foo
    +
    +
    +```````````````````````````````` + + +The setext heading underline can be indented up to three spaces, and +may have trailing spaces: + +```````````````````````````````` example +Foo + ---- +. +

    Foo

    +```````````````````````````````` + + +Four spaces is too much: + +```````````````````````````````` example +Foo + --- +. +

    Foo +---

    +```````````````````````````````` + + +The setext heading underline cannot contain internal spaces: + +```````````````````````````````` example +Foo += = + +Foo +--- - +. +

    Foo += =

    +

    Foo

    +
    +```````````````````````````````` + + +Trailing spaces in the content line do not cause a line break: + +```````````````````````````````` example +Foo +----- +. +

    Foo

    +```````````````````````````````` + + +Nor does a backslash at the end: + +```````````````````````````````` example +Foo\ +---- +. +

    Foo\

    +```````````````````````````````` + + +Since indicators of block structure take precedence over +indicators of inline structure, the following are setext headings: + +```````````````````````````````` example +`Foo +---- +` + + +. +

    `Foo

    +

    `

    +

    <a title="a lot

    +

    of dashes"/>

    +```````````````````````````````` + + +The setext heading underline cannot be a [lazy continuation +line] in a list item or block quote: + +```````````````````````````````` example +> Foo +--- +. +
    +

    Foo

    +
    +
    +```````````````````````````````` + + +```````````````````````````````` example +> foo +bar +=== +. +
    +

    foo +bar +===

    +
    +```````````````````````````````` + + +```````````````````````````````` example +- Foo +--- +. +
      +
    • Foo
    • +
    +
    +```````````````````````````````` + + +A blank line is needed between a paragraph and a following +setext heading, since otherwise the paragraph becomes part +of the heading's content: + +```````````````````````````````` example +Foo +Bar +--- +. +

    Foo +Bar

    +```````````````````````````````` + + +But in general a blank line is not required before or after +setext headings: + +```````````````````````````````` example +--- +Foo +--- +Bar +--- +Baz +. +
    +

    Foo

    +

    Bar

    +

    Baz

    +```````````````````````````````` + + +Setext headings cannot be empty: + +```````````````````````````````` example + +==== +. +

    ====

    +```````````````````````````````` + + +Setext heading text lines must not be interpretable as block +constructs other than paragraphs. So, the line of dashes +in these examples gets interpreted as a thematic break: + +```````````````````````````````` example +--- +--- +. +
    +
    +```````````````````````````````` + + +```````````````````````````````` example +- foo +----- +. +
      +
    • foo
    • +
    +
    +```````````````````````````````` + + +```````````````````````````````` example + foo +--- +. +
    foo
    +
    +
    +```````````````````````````````` + + +```````````````````````````````` example +> foo +----- +. +
    +

    foo

    +
    +
    +```````````````````````````````` + + +If you want a heading with `> foo` as its literal text, you can +use backslash escapes: + +```````````````````````````````` example +\> foo +------ +. +

    > foo

    +```````````````````````````````` + + +**Compatibility note:** Most existing Markdown implementations +do not allow the text of setext headings to span multiple lines. +But there is no consensus about how to interpret + +``` markdown +Foo +bar +--- +baz +``` + +One can find four different interpretations: + +1. paragraph "Foo", heading "bar", paragraph "baz" +2. paragraph "Foo bar", thematic break, paragraph "baz" +3. paragraph "Foo bar --- baz" +4. heading "Foo bar", paragraph "baz" + +We find interpretation 4 most natural, and interpretation 4 +increases the expressive power of CommonMark, by allowing +multiline headings. Authors who want interpretation 1 can +put a blank line after the first paragraph: + +```````````````````````````````` example +Foo + +bar +--- +baz +. +

    Foo

    +

    bar

    +

    baz

    +```````````````````````````````` + + +Authors who want interpretation 2 can put blank lines around +the thematic break, + +```````````````````````````````` example +Foo +bar + +--- + +baz +. +

    Foo +bar

    +
    +

    baz

    +```````````````````````````````` + + +or use a thematic break that cannot count as a [setext heading +underline], such as + +```````````````````````````````` example +Foo +bar +* * * +baz +. +

    Foo +bar

    +
    +

    baz

    +```````````````````````````````` + + +Authors who want interpretation 3 can use backslash escapes: + +```````````````````````````````` example +Foo +bar +\--- +baz +. +

    Foo +bar +--- +baz

    +```````````````````````````````` + + +## Indented code blocks + +An [indented code block](@) is composed of one or more +[indented chunks] separated by blank lines. +An [indented chunk](@) is a sequence of non-blank lines, +each indented four or more spaces. The contents of the code block are +the literal contents of the lines, including trailing +[line endings], minus four spaces of indentation. +An indented code block has no [info string]. + +An indented code block cannot interrupt a paragraph, so there must be +a blank line between a paragraph and a following indented code block. +(A blank line is not needed, however, between a code block and a following +paragraph.) + +```````````````````````````````` example + a simple + indented code block +. +
    a simple
    +  indented code block
    +
    +```````````````````````````````` + + +If there is any ambiguity between an interpretation of indentation +as a code block and as indicating that material belongs to a [list +item][list items], the list item interpretation takes precedence: + +```````````````````````````````` example + - foo + + bar +. +
      +
    • +

      foo

      +

      bar

      +
    • +
    +```````````````````````````````` + + +```````````````````````````````` example +1. foo + + - bar +. +
      +
    1. +

      foo

      +
        +
      • bar
      • +
      +
    2. +
    +```````````````````````````````` + + + +The contents of a code block are literal text, and do not get parsed +as Markdown: + +```````````````````````````````` example +
    + *hi* + + - one +. +
    <a/>
    +*hi*
    +
    +- one
    +
    +```````````````````````````````` + + +Here we have three chunks separated by blank lines: + +```````````````````````````````` example + chunk1 + + chunk2 + + + + chunk3 +. +
    chunk1
    +
    +chunk2
    +
    +
    +
    +chunk3
    +
    +```````````````````````````````` + + +Any initial spaces beyond four will be included in the content, even +in interior blank lines: + +```````````````````````````````` example + chunk1 + + chunk2 +. +
    chunk1
    +  
    +  chunk2
    +
    +```````````````````````````````` + + +An indented code block cannot interrupt a paragraph. (This +allows hanging indents and the like.) + +```````````````````````````````` example +Foo + bar + +. +

    Foo +bar

    +```````````````````````````````` + + +However, any non-blank line with fewer than four leading spaces ends +the code block immediately. So a paragraph may occur immediately +after indented code: + +```````````````````````````````` example + foo +bar +. +
    foo
    +
    +

    bar

    +```````````````````````````````` + + +And indented code can occur immediately before and after other kinds of +blocks: + +```````````````````````````````` example +# Heading + foo +Heading +------ + foo +---- +. +

    Heading

    +
    foo
    +
    +

    Heading

    +
    foo
    +
    +
    +```````````````````````````````` + + +The first line can be indented more than four spaces: + +```````````````````````````````` example + foo + bar +. +
        foo
    +bar
    +
    +```````````````````````````````` + + +Blank lines preceding or following an indented code block +are not included in it: + +```````````````````````````````` example + + + foo + + +. +
    foo
    +
    +```````````````````````````````` + + +Trailing spaces are included in the code block's content: + +```````````````````````````````` example + foo +. +
    foo  
    +
    +```````````````````````````````` + + + +## Fenced code blocks + +A [code fence](@) is a sequence +of at least three consecutive backtick characters (`` ` ``) or +tildes (`~`). (Tildes and backticks cannot be mixed.) +A [fenced code block](@) +begins with a code fence, indented no more than three spaces. + +The line with the opening code fence may optionally contain some text +following the code fence; this is trimmed of leading and trailing +whitespace and called the [info string](@). If the [info string] comes +after a backtick fence, it may not contain any backtick +characters. (The reason for this restriction is that otherwise +some inline code would be incorrectly interpreted as the +beginning of a fenced code block.) + +The content of the code block consists of all subsequent lines, until +a closing [code fence] of the same type as the code block +began with (backticks or tildes), and with at least as many backticks +or tildes as the opening code fence. If the leading code fence is +indented N spaces, then up to N spaces of indentation are removed from +each line of the content (if present). (If a content line is not +indented, it is preserved unchanged. If it is indented less than N +spaces, all of the indentation is removed.) + +The closing code fence may be indented up to three spaces, and may be +followed only by spaces, which are ignored. If the end of the +containing block (or document) is reached and no closing code fence +has been found, the code block contains all of the lines after the +opening code fence until the end of the containing block (or +document). (An alternative spec would require backtracking in the +event that a closing code fence is not found. But this makes parsing +much less efficient, and there seems to be no real down side to the +behavior described here.) + +A fenced code block may interrupt a paragraph, and does not require +a blank line either before or after. + +The content of a code fence is treated as literal text, not parsed +as inlines. The first word of the [info string] is typically used to +specify the language of the code sample, and rendered in the `class` +attribute of the `code` tag. However, this spec does not mandate any +particular treatment of the [info string]. + +Here is a simple example with backticks: + +```````````````````````````````` example +``` +< + > +``` +. +
    <
    + >
    +
    +```````````````````````````````` + + +With tildes: + +```````````````````````````````` example +~~~ +< + > +~~~ +. +
    <
    + >
    +
    +```````````````````````````````` + +Fewer than three backticks is not enough: + +```````````````````````````````` example +`` +foo +`` +. +

    foo

    +```````````````````````````````` + +The closing code fence must use the same character as the opening +fence: + +```````````````````````````````` example +``` +aaa +~~~ +``` +. +
    aaa
    +~~~
    +
    +```````````````````````````````` + + +```````````````````````````````` example +~~~ +aaa +``` +~~~ +. +
    aaa
    +```
    +
    +```````````````````````````````` + + +The closing code fence must be at least as long as the opening fence: + +```````````````````````````````` example +```` +aaa +``` +`````` +. +
    aaa
    +```
    +
    +```````````````````````````````` + + +```````````````````````````````` example +~~~~ +aaa +~~~ +~~~~ +. +
    aaa
    +~~~
    +
    +```````````````````````````````` + + +Unclosed code blocks are closed by the end of the document +(or the enclosing [block quote][block quotes] or [list item][list items]): + +```````````````````````````````` example +``` +. +
    +```````````````````````````````` + + +```````````````````````````````` example +````` + +``` +aaa +. +
    
    +```
    +aaa
    +
    +```````````````````````````````` + + +```````````````````````````````` example +> ``` +> aaa + +bbb +. +
    +
    aaa
    +
    +
    +

    bbb

    +```````````````````````````````` + + +A code block can have all empty lines as its content: + +```````````````````````````````` example +``` + + +``` +. +
    
    +  
    +
    +```````````````````````````````` + + +A code block can be empty: + +```````````````````````````````` example +``` +``` +. +
    +```````````````````````````````` + + +Fences can be indented. If the opening fence is indented, +content lines will have equivalent opening indentation removed, +if present: + +```````````````````````````````` example + ``` + aaa +aaa +``` +. +
    aaa
    +aaa
    +
    +```````````````````````````````` + + +```````````````````````````````` example + ``` +aaa + aaa +aaa + ``` +. +
    aaa
    +aaa
    +aaa
    +
    +```````````````````````````````` + + +```````````````````````````````` example + ``` + aaa + aaa + aaa + ``` +. +
    aaa
    + aaa
    +aaa
    +
    +```````````````````````````````` + + +Four spaces indentation produces an indented code block: + +```````````````````````````````` example + ``` + aaa + ``` +. +
    ```
    +aaa
    +```
    +
    +```````````````````````````````` + + +Closing fences may be indented by 0-3 spaces, and their indentation +need not match that of the opening fence: + +```````````````````````````````` example +``` +aaa + ``` +. +
    aaa
    +
    +```````````````````````````````` + + +```````````````````````````````` example + ``` +aaa + ``` +. +
    aaa
    +
    +```````````````````````````````` + + +This is not a closing fence, because it is indented 4 spaces: + +```````````````````````````````` example +``` +aaa + ``` +. +
    aaa
    +    ```
    +
    +```````````````````````````````` + + + +Code fences (opening and closing) cannot contain internal spaces: + +```````````````````````````````` example +``` ``` +aaa +. +

    +aaa

    +```````````````````````````````` + + +```````````````````````````````` example +~~~~~~ +aaa +~~~ ~~ +. +
    aaa
    +~~~ ~~
    +
    +```````````````````````````````` + + +Fenced code blocks can interrupt paragraphs, and can be followed +directly by paragraphs, without a blank line between: + +```````````````````````````````` example +foo +``` +bar +``` +baz +. +

    foo

    +
    bar
    +
    +

    baz

    +```````````````````````````````` + + +Other blocks can also occur before and after fenced code blocks +without an intervening blank line: + +```````````````````````````````` example +foo +--- +~~~ +bar +~~~ +# baz +. +

    foo

    +
    bar
    +
    +

    baz

    +```````````````````````````````` + + +An [info string] can be provided after the opening code fence. +Although this spec doesn't mandate any particular treatment of +the info string, the first word is typically used to specify +the language of the code block. In HTML output, the language is +normally indicated by adding a class to the `code` element consisting +of `language-` followed by the language name. + +```````````````````````````````` example +```ruby +def foo(x) + return 3 +end +``` +. +
    def foo(x)
    +  return 3
    +end
    +
    +```````````````````````````````` + + +```````````````````````````````` example +~~~~ ruby startline=3 $%@#$ +def foo(x) + return 3 +end +~~~~~~~ +. +
    def foo(x)
    +  return 3
    +end
    +
    +```````````````````````````````` + + +```````````````````````````````` example +````; +```` +. +
    +```````````````````````````````` + + +[Info strings] for backtick code blocks cannot contain backticks: + +```````````````````````````````` example +``` aa ``` +foo +. +

    aa +foo

    +```````````````````````````````` + + +[Info strings] for tilde code blocks can contain backticks and tildes: + +```````````````````````````````` example +~~~ aa ``` ~~~ +foo +~~~ +. +
    foo
    +
    +```````````````````````````````` + + +Closing code fences cannot have [info strings]: + +```````````````````````````````` example +``` +``` aaa +``` +. +
    ``` aaa
    +
    +```````````````````````````````` + + + +## HTML blocks + +An [HTML block](@) is a group of lines that is treated +as raw HTML (and will not be escaped in HTML output). + +There are seven kinds of [HTML block], which can be defined by their +start and end conditions. The block begins with a line that meets a +[start condition](@) (after up to three spaces optional indentation). +It ends with the first subsequent line that meets a matching [end +condition](@), or the last line of the document, or the last line of +the [container block](#container-blocks) containing the current HTML +block, if no line is encountered that meets the [end condition]. If +the first line meets both the [start condition] and the [end +condition], the block will contain just that line. + +1. **Start condition:** line begins with the string ``, or the end of the line.\ +**End condition:** line contains an end tag +``, ``, or `` (case-insensitive; it +need not match the start tag). + +2. **Start condition:** line begins with the string ``. + +3. **Start condition:** line begins with the string ``. + +4. **Start condition:** line begins with the string ``. + +5. **Start condition:** line begins with the string +``. + +6. **Start condition:** line begins the string `<` or ``, or +the string `/>`.\ +**End condition:** line is followed by a [blank line]. + +7. **Start condition:** line begins with a complete [open tag] +(with any [tag name] other than `script`, +`style`, or `pre`) or a complete [closing tag], +followed only by [whitespace] or the end of the line.\ +**End condition:** line is followed by a [blank line]. + +HTML blocks continue until they are closed by their appropriate +[end condition], or the last line of the document or other [container +block](#container-blocks). This means any HTML **within an HTML +block** that might otherwise be recognised as a start condition will +be ignored by the parser and passed through as-is, without changing +the parser's state. + +For instance, `
    ` within a HTML block started by `` will not affect
    +the parser state; as the HTML block was started in by start condition 6, it
    +will end at any blank line. This can be surprising:
    +
    +```````````````````````````````` example
    +
    +
    +**Hello**,
    +
    +_world_.
    +
    +
    +. +
    +
    +**Hello**,
    +

    world. +

    +
    +```````````````````````````````` + +In this case, the HTML block is terminated by the newline — the `**Hello**` +text remains verbatim — and regular parsing resumes, with a paragraph, +emphasised `world` and inline and block HTML following. + +All types of [HTML blocks] except type 7 may interrupt +a paragraph. Blocks of type 7 may not interrupt a paragraph. +(This restriction is intended to prevent unwanted interpretation +of long tags inside a wrapped paragraph as starting HTML blocks.) + +Some simple examples follow. Here are some basic HTML blocks +of type 6: + +```````````````````````````````` example + + + + +
    + hi +
    + +okay. +. + + + + +
    + hi +
    +

    okay.

    +```````````````````````````````` + + +```````````````````````````````` example +
    +*foo* +```````````````````````````````` + + +Here we have two HTML blocks with a Markdown paragraph between them: + +```````````````````````````````` example +
    + +*Markdown* + +
    +. +
    +

    Markdown

    +
    +```````````````````````````````` + + +The tag on the first line can be partial, as long +as it is split where there would be whitespace: + +```````````````````````````````` example +
    +
    +. +
    +
    +```````````````````````````````` + + +```````````````````````````````` example +
    +
    +. +
    +
    +```````````````````````````````` + + +An open tag need not be closed: +```````````````````````````````` example +
    +*foo* + +*bar* +. +
    +*foo* +

    bar

    +```````````````````````````````` + + + +A partial tag need not even be completed (garbage +in, garbage out): + +```````````````````````````````` example +
    +. + +```````````````````````````````` + + +```````````````````````````````` example +
    +foo +
    +. +
    +foo +
    +```````````````````````````````` + + +Everything until the next blank line or end of document +gets included in the HTML block. So, in the following +example, what looks like a Markdown code block +is actually part of the HTML block, which continues until a blank +line or the end of the document is reached: + +```````````````````````````````` example +
    +``` c +int x = 33; +``` +. +
    +``` c +int x = 33; +``` +```````````````````````````````` + + +To start an [HTML block] with a tag that is *not* in the +list of block-level tags in (6), you must put the tag by +itself on the first line (and it must be complete): + +```````````````````````````````` example + +*bar* + +. + +*bar* + +```````````````````````````````` + + +In type 7 blocks, the [tag name] can be anything: + +```````````````````````````````` example + +*bar* + +. + +*bar* + +```````````````````````````````` + + +```````````````````````````````` example + +*bar* + +. + +*bar* + +```````````````````````````````` + + +```````````````````````````````` example + +*bar* +. + +*bar* +```````````````````````````````` + + +These rules are designed to allow us to work with tags that +can function as either block-level or inline-level tags. +The `` tag is a nice example. We can surround content with +`` tags in three different ways. In this case, we get a raw +HTML block, because the `` tag is on a line by itself: + +```````````````````````````````` example + +*foo* + +. + +*foo* + +```````````````````````````````` + + +In this case, we get a raw HTML block that just includes +the `` tag (because it ends with the following blank +line). So the contents get interpreted as CommonMark: + +```````````````````````````````` example + + +*foo* + + +. + +

    foo

    +
    +```````````````````````````````` + + +Finally, in this case, the `` tags are interpreted +as [raw HTML] *inside* the CommonMark paragraph. (Because +the tag is not on a line by itself, we get inline HTML +rather than an [HTML block].) + +```````````````````````````````` example +*foo* +. +

    foo

    +```````````````````````````````` + + +HTML tags designed to contain literal content +(`script`, `style`, `pre`), comments, processing instructions, +and declarations are treated somewhat differently. +Instead of ending at the first blank line, these blocks +end at the first line containing a corresponding end tag. +As a result, these blocks can contain blank lines: + +A pre tag (type 1): + +```````````````````````````````` example +
    
    +import Text.HTML.TagSoup
    +
    +main :: IO ()
    +main = print $ parseTags tags
    +
    +okay +. +
    
    +import Text.HTML.TagSoup
    +
    +main :: IO ()
    +main = print $ parseTags tags
    +
    +

    okay

    +```````````````````````````````` + + +A script tag (type 1): + +```````````````````````````````` example + +okay +. + +

    okay

    +```````````````````````````````` + + +A style tag (type 1): + +```````````````````````````````` example + +okay +. + +

    okay

    +```````````````````````````````` + + +If there is no matching end tag, the block will end at the +end of the document (or the enclosing [block quote][block quotes] +or [list item][list items]): + +```````````````````````````````` example + +*foo* +. + +

    foo

    +```````````````````````````````` + + +```````````````````````````````` example +*bar* +*baz* +. +*bar* +

    baz

    +```````````````````````````````` + + +Note that anything on the last line after the +end tag will be included in the [HTML block]: + +```````````````````````````````` example +1. *bar* +. +1. *bar* +```````````````````````````````` + + +A comment (type 2): + +```````````````````````````````` example + +okay +. + +

    okay

    +```````````````````````````````` + + + +A processing instruction (type 3): + +```````````````````````````````` example +'; + +?> +okay +. +'; + +?> +

    okay

    +```````````````````````````````` + + +A declaration (type 4): + +```````````````````````````````` example + +. + +```````````````````````````````` + + +CDATA (type 5): + +```````````````````````````````` example + +okay +. + +

    okay

    +```````````````````````````````` + + +The opening tag can be indented 1-3 spaces, but not 4: + +```````````````````````````````` example + + + +. + +
    <!-- foo -->
    +
    +```````````````````````````````` + + +```````````````````````````````` example +
    + +
    +. +
    +
    <div>
    +
    +```````````````````````````````` + + +An HTML block of types 1--6 can interrupt a paragraph, and need not be +preceded by a blank line. + +```````````````````````````````` example +Foo +
    +bar +
    +. +

    Foo

    +
    +bar +
    +```````````````````````````````` + + +However, a following blank line is needed, except at the end of +a document, and except for blocks of types 1--5, [above][HTML +block]: + +```````````````````````````````` example +
    +bar +
    +*foo* +. +
    +bar +
    +*foo* +```````````````````````````````` + + +HTML blocks of type 7 cannot interrupt a paragraph: + +```````````````````````````````` example +Foo + +baz +. +

    Foo + +baz

    +```````````````````````````````` + + +This rule differs from John Gruber's original Markdown syntax +specification, which says: + +> The only restrictions are that block-level HTML elements — +> e.g. `
    `, ``, `
    `, `

    `, etc. — must be separated from +> surrounding content by blank lines, and the start and end tags of the +> block should not be indented with tabs or spaces. + +In some ways Gruber's rule is more restrictive than the one given +here: + +- It requires that an HTML block be preceded by a blank line. +- It does not allow the start tag to be indented. +- It requires a matching end tag, which it also does not allow to + be indented. + +Most Markdown implementations (including some of Gruber's own) do not +respect all of these restrictions. + +There is one respect, however, in which Gruber's rule is more liberal +than the one given here, since it allows blank lines to occur inside +an HTML block. There are two reasons for disallowing them here. +First, it removes the need to parse balanced tags, which is +expensive and can require backtracking from the end of the document +if no matching end tag is found. Second, it provides a very simple +and flexible way of including Markdown content inside HTML tags: +simply separate the Markdown from the HTML using blank lines: + +Compare: + +```````````````````````````````` example +

    + +*Emphasized* text. + +
    +. +
    +

    Emphasized text.

    +
    +```````````````````````````````` + + +```````````````````````````````` example +
    +*Emphasized* text. +
    +. +
    +*Emphasized* text. +
    +```````````````````````````````` + + +Some Markdown implementations have adopted a convention of +interpreting content inside tags as text if the open tag has +the attribute `markdown=1`. The rule given above seems a simpler and +more elegant way of achieving the same expressive power, which is also +much simpler to parse. + +The main potential drawback is that one can no longer paste HTML +blocks into Markdown documents with 100% reliability. However, +*in most cases* this will work fine, because the blank lines in +HTML are usually followed by HTML block tags. For example: + +```````````````````````````````` example +
    + + + + + + + +
    +Hi +
    +. + + + + +
    +Hi +
    +```````````````````````````````` + + +There are problems, however, if the inner tags are indented +*and* separated by spaces, as then they will be interpreted as +an indented code block: + +```````````````````````````````` example + + + + + + + + +
    + Hi +
    +. + + +
    <td>
    +  Hi
    +</td>
    +
    + +
    +```````````````````````````````` + + +Fortunately, blank lines are usually not necessary and can be +deleted. The exception is inside `
    ` tags, but as described
    +[above][HTML blocks], raw HTML blocks starting with `
    `
    +*can* contain blank lines.
    +
    +## Link reference definitions
    +
    +A [link reference definition](@)
    +consists of a [link label], indented up to three spaces, followed
    +by a colon (`:`), optional [whitespace] (including up to one
    +[line ending]), a [link destination],
    +optional [whitespace] (including up to one
    +[line ending]), and an optional [link
    +title], which if it is present must be separated
    +from the [link destination] by [whitespace].
    +No further [non-whitespace characters] may occur on the line.
    +
    +A [link reference definition]
    +does not correspond to a structural element of a document.  Instead, it
    +defines a label which can be used in [reference links]
    +and reference-style [images] elsewhere in the document.  [Link
    +reference definitions] can come either before or after the links that use
    +them.
    +
    +```````````````````````````````` example
    +[foo]: /url "title"
    +
    +[foo]
    +.
    +

    foo

    +```````````````````````````````` + + +```````````````````````````````` example + [foo]: + /url + 'the title' + +[foo] +. +

    foo

    +```````````````````````````````` + + +```````````````````````````````` example +[Foo*bar\]]:my_(url) 'title (with parens)' + +[Foo*bar\]] +. +

    Foo*bar]

    +```````````````````````````````` + + +```````````````````````````````` example +[Foo bar]: + +'title' + +[Foo bar] +. +

    Foo bar

    +```````````````````````````````` + + +The title may extend over multiple lines: + +```````````````````````````````` example +[foo]: /url ' +title +line1 +line2 +' + +[foo] +. +

    foo

    +```````````````````````````````` + + +However, it may not contain a [blank line]: + +```````````````````````````````` example +[foo]: /url 'title + +with blank line' + +[foo] +. +

    [foo]: /url 'title

    +

    with blank line'

    +

    [foo]

    +```````````````````````````````` + + +The title may be omitted: + +```````````````````````````````` example +[foo]: +/url + +[foo] +. +

    foo

    +```````````````````````````````` + + +The link destination may not be omitted: + +```````````````````````````````` example +[foo]: + +[foo] +. +

    [foo]:

    +

    [foo]

    +```````````````````````````````` + + However, an empty link destination may be specified using + angle brackets: + +```````````````````````````````` example +[foo]: <> + +[foo] +. +

    foo

    +```````````````````````````````` + +The title must be separated from the link destination by +whitespace: + +```````````````````````````````` example +[foo]: (baz) + +[foo] +. +

    [foo]: (baz)

    +

    [foo]

    +```````````````````````````````` + + +Both title and destination can contain backslash escapes +and literal backslashes: + +```````````````````````````````` example +[foo]: /url\bar\*baz "foo\"bar\baz" + +[foo] +. +

    foo

    +```````````````````````````````` + + +A link can come before its corresponding definition: + +```````````````````````````````` example +[foo] + +[foo]: url +. +

    foo

    +```````````````````````````````` + + +If there are several matching definitions, the first one takes +precedence: + +```````````````````````````````` example +[foo] + +[foo]: first +[foo]: second +. +

    foo

    +```````````````````````````````` + + +As noted in the section on [Links], matching of labels is +case-insensitive (see [matches]). + +```````````````````````````````` example +[FOO]: /url + +[Foo] +. +

    Foo

    +```````````````````````````````` + + +```````````````````````````````` example +[ΑΓΩ]: /φου + +[αγω] +. +

    αγω

    +```````````````````````````````` + + +Here is a link reference definition with no corresponding link. +It contributes nothing to the document. + +```````````````````````````````` example +[foo]: /url +. +```````````````````````````````` + + +Here is another one: + +```````````````````````````````` example +[ +foo +]: /url +bar +. +

    bar

    +```````````````````````````````` + + +This is not a link reference definition, because there are +[non-whitespace characters] after the title: + +```````````````````````````````` example +[foo]: /url "title" ok +. +

    [foo]: /url "title" ok

    +```````````````````````````````` + + +This is a link reference definition, but it has no title: + +```````````````````````````````` example +[foo]: /url +"title" ok +. +

    "title" ok

    +```````````````````````````````` + + +This is not a link reference definition, because it is indented +four spaces: + +```````````````````````````````` example + [foo]: /url "title" + +[foo] +. +
    [foo]: /url "title"
    +
    +

    [foo]

    +```````````````````````````````` + + +This is not a link reference definition, because it occurs inside +a code block: + +```````````````````````````````` example +``` +[foo]: /url +``` + +[foo] +. +
    [foo]: /url
    +
    +

    [foo]

    +```````````````````````````````` + + +A [link reference definition] cannot interrupt a paragraph. + +```````````````````````````````` example +Foo +[bar]: /baz + +[bar] +. +

    Foo +[bar]: /baz

    +

    [bar]

    +```````````````````````````````` + + +However, it can directly follow other block elements, such as headings +and thematic breaks, and it need not be followed by a blank line. + +```````````````````````````````` example +# [Foo] +[foo]: /url +> bar +. +

    Foo

    +
    +

    bar

    +
    +```````````````````````````````` + +```````````````````````````````` example +[foo]: /url +bar +=== +[foo] +. +

    bar

    +

    foo

    +```````````````````````````````` + +```````````````````````````````` example +[foo]: /url +=== +[foo] +. +

    === +foo

    +```````````````````````````````` + + +Several [link reference definitions] +can occur one after another, without intervening blank lines. + +```````````````````````````````` example +[foo]: /foo-url "foo" +[bar]: /bar-url + "bar" +[baz]: /baz-url + +[foo], +[bar], +[baz] +. +

    foo, +bar, +baz

    +```````````````````````````````` + + +[Link reference definitions] can occur +inside block containers, like lists and block quotations. They +affect the entire document, not just the container in which they +are defined: + +```````````````````````````````` example +[foo] + +> [foo]: /url +. +

    foo

    +
    +
    +```````````````````````````````` + + +Whether something is a [link reference definition] is +independent of whether the link reference it defines is +used in the document. Thus, for example, the following +document contains just a link reference definition, and +no visible content: + +```````````````````````````````` example +[foo]: /url +. +```````````````````````````````` + + +## Paragraphs + +A sequence of non-blank lines that cannot be interpreted as other +kinds of blocks forms a [paragraph](@). +The contents of the paragraph are the result of parsing the +paragraph's raw content as inlines. The paragraph's raw content +is formed by concatenating the lines and removing initial and final +[whitespace]. + +A simple example with two paragraphs: + +```````````````````````````````` example +aaa + +bbb +. +

    aaa

    +

    bbb

    +```````````````````````````````` + + +Paragraphs can contain multiple lines, but no blank lines: + +```````````````````````````````` example +aaa +bbb + +ccc +ddd +. +

    aaa +bbb

    +

    ccc +ddd

    +```````````````````````````````` + + +Multiple blank lines between paragraph have no effect: + +```````````````````````````````` example +aaa + + +bbb +. +

    aaa

    +

    bbb

    +```````````````````````````````` + + +Leading spaces are skipped: + +```````````````````````````````` example + aaa + bbb +. +

    aaa +bbb

    +```````````````````````````````` + + +Lines after the first may be indented any amount, since indented +code blocks cannot interrupt paragraphs. + +```````````````````````````````` example +aaa + bbb + ccc +. +

    aaa +bbb +ccc

    +```````````````````````````````` + + +However, the first line may be indented at most three spaces, +or an indented code block will be triggered: + +```````````````````````````````` example + aaa +bbb +. +

    aaa +bbb

    +```````````````````````````````` + + +```````````````````````````````` example + aaa +bbb +. +
    aaa
    +
    +

    bbb

    +```````````````````````````````` + + +Final spaces are stripped before inline parsing, so a paragraph +that ends with two or more spaces will not end with a [hard line +break]: + +```````````````````````````````` example +aaa +bbb +. +

    aaa
    +bbb

    +```````````````````````````````` + + +## Blank lines + +[Blank lines] between block-level elements are ignored, +except for the role they play in determining whether a [list] +is [tight] or [loose]. + +Blank lines at the beginning and end of the document are also ignored. + +```````````````````````````````` example + + +aaa + + +# aaa + + +. +

    aaa

    +

    aaa

    +```````````````````````````````` + + + +# Container blocks + +A [container block](#container-blocks) is a block that has other +blocks as its contents. There are two basic kinds of container blocks: +[block quotes] and [list items]. +[Lists] are meta-containers for [list items]. + +We define the syntax for container blocks recursively. The general +form of the definition is: + +> If X is a sequence of blocks, then the result of +> transforming X in such-and-such a way is a container of type Y +> with these blocks as its content. + +So, we explain what counts as a block quote or list item by explaining +how these can be *generated* from their contents. This should suffice +to define the syntax, although it does not give a recipe for *parsing* +these constructions. (A recipe is provided below in the section entitled +[A parsing strategy](#appendix-a-parsing-strategy).) + +## Block quotes + +A [block quote marker](@) +consists of 0-3 spaces of initial indent, plus (a) the character `>` together +with a following space, or (b) a single character `>` not followed by a space. + +The following rules define [block quotes]: + +1. **Basic case.** If a string of lines *Ls* constitute a sequence + of blocks *Bs*, then the result of prepending a [block quote + marker] to the beginning of each line in *Ls* + is a [block quote](#block-quotes) containing *Bs*. + +2. **Laziness.** If a string of lines *Ls* constitute a [block + quote](#block-quotes) with contents *Bs*, then the result of deleting + the initial [block quote marker] from one or + more lines in which the next [non-whitespace character] after the [block + quote marker] is [paragraph continuation + text] is a block quote with *Bs* as its content. + [Paragraph continuation text](@) is text + that will be parsed as part of the content of a paragraph, but does + not occur at the beginning of the paragraph. + +3. **Consecutiveness.** A document cannot contain two [block + quotes] in a row unless there is a [blank line] between them. + +Nothing else counts as a [block quote](#block-quotes). + +Here is a simple example: + +```````````````````````````````` example +> # Foo +> bar +> baz +. +
    +

    Foo

    +

    bar +baz

    +
    +```````````````````````````````` + + +The spaces after the `>` characters can be omitted: + +```````````````````````````````` example +># Foo +>bar +> baz +. +
    +

    Foo

    +

    bar +baz

    +
    +```````````````````````````````` + + +The `>` characters can be indented 1-3 spaces: + +```````````````````````````````` example + > # Foo + > bar + > baz +. +
    +

    Foo

    +

    bar +baz

    +
    +```````````````````````````````` + + +Four spaces gives us a code block: + +```````````````````````````````` example + > # Foo + > bar + > baz +. +
    > # Foo
    +> bar
    +> baz
    +
    +```````````````````````````````` + + +The Laziness clause allows us to omit the `>` before +[paragraph continuation text]: + +```````````````````````````````` example +> # Foo +> bar +baz +. +
    +

    Foo

    +

    bar +baz

    +
    +```````````````````````````````` + + +A block quote can contain some lazy and some non-lazy +continuation lines: + +```````````````````````````````` example +> bar +baz +> foo +. +
    +

    bar +baz +foo

    +
    +```````````````````````````````` + + +Laziness only applies to lines that would have been continuations of +paragraphs had they been prepended with [block quote markers]. +For example, the `> ` cannot be omitted in the second line of + +``` markdown +> foo +> --- +``` + +without changing the meaning: + +```````````````````````````````` example +> foo +--- +. +
    +

    foo

    +
    +
    +```````````````````````````````` + + +Similarly, if we omit the `> ` in the second line of + +``` markdown +> - foo +> - bar +``` + +then the block quote ends after the first line: + +```````````````````````````````` example +> - foo +- bar +. +
    +
      +
    • foo
    • +
    +
    +
      +
    • bar
    • +
    +```````````````````````````````` + + +For the same reason, we can't omit the `> ` in front of +subsequent lines of an indented or fenced code block: + +```````````````````````````````` example +> foo + bar +. +
    +
    foo
    +
    +
    +
    bar
    +
    +```````````````````````````````` + + +```````````````````````````````` example +> ``` +foo +``` +. +
    +
    +
    +

    foo

    +
    +```````````````````````````````` + + +Note that in the following case, we have a [lazy +continuation line]: + +```````````````````````````````` example +> foo + - bar +. +
    +

    foo +- bar

    +
    +```````````````````````````````` + + +To see why, note that in + +```markdown +> foo +> - bar +``` + +the `- bar` is indented too far to start a list, and can't +be an indented code block because indented code blocks cannot +interrupt paragraphs, so it is [paragraph continuation text]. + +A block quote can be empty: + +```````````````````````````````` example +> +. +
    +
    +```````````````````````````````` + + +```````````````````````````````` example +> +> +> +. +
    +
    +```````````````````````````````` + + +A block quote can have initial or final blank lines: + +```````````````````````````````` example +> +> foo +> +. +
    +

    foo

    +
    +```````````````````````````````` + + +A blank line always separates block quotes: + +```````````````````````````````` example +> foo + +> bar +. +
    +

    foo

    +
    +
    +

    bar

    +
    +```````````````````````````````` + + +(Most current Markdown implementations, including John Gruber's +original `Markdown.pl`, will parse this example as a single block quote +with two paragraphs. But it seems better to allow the author to decide +whether two block quotes or one are wanted.) + +Consecutiveness means that if we put these block quotes together, +we get a single block quote: + +```````````````````````````````` example +> foo +> bar +. +
    +

    foo +bar

    +
    +```````````````````````````````` + + +To get a block quote with two paragraphs, use: + +```````````````````````````````` example +> foo +> +> bar +. +
    +

    foo

    +

    bar

    +
    +```````````````````````````````` + + +Block quotes can interrupt paragraphs: + +```````````````````````````````` example +foo +> bar +. +

    foo

    +
    +

    bar

    +
    +```````````````````````````````` + + +In general, blank lines are not needed before or after block +quotes: + +```````````````````````````````` example +> aaa +*** +> bbb +. +
    +

    aaa

    +
    +
    +
    +

    bbb

    +
    +```````````````````````````````` + + +However, because of laziness, a blank line is needed between +a block quote and a following paragraph: + +```````````````````````````````` example +> bar +baz +. +
    +

    bar +baz

    +
    +```````````````````````````````` + + +```````````````````````````````` example +> bar + +baz +. +
    +

    bar

    +
    +

    baz

    +```````````````````````````````` + + +```````````````````````````````` example +> bar +> +baz +. +
    +

    bar

    +
    +

    baz

    +```````````````````````````````` + + +It is a consequence of the Laziness rule that any number +of initial `>`s may be omitted on a continuation line of a +nested block quote: + +```````````````````````````````` example +> > > foo +bar +. +
    +
    +
    +

    foo +bar

    +
    +
    +
    +```````````````````````````````` + + +```````````````````````````````` example +>>> foo +> bar +>>baz +. +
    +
    +
    +

    foo +bar +baz

    +
    +
    +
    +```````````````````````````````` + + +When including an indented code block in a block quote, +remember that the [block quote marker] includes +both the `>` and a following space. So *five spaces* are needed after +the `>`: + +```````````````````````````````` example +> code + +> not code +. +
    +
    code
    +
    +
    +
    +

    not code

    +
    +```````````````````````````````` + + + +## List items + +A [list marker](@) is a +[bullet list marker] or an [ordered list marker]. + +A [bullet list marker](@) +is a `-`, `+`, or `*` character. + +An [ordered list marker](@) +is a sequence of 1--9 arabic digits (`0-9`), followed by either a +`.` character or a `)` character. (The reason for the length +limit is that with 10 digits we start seeing integer overflows +in some browsers.) + +The following rules define [list items]: + +1. **Basic case.** If a sequence of lines *Ls* constitute a sequence of + blocks *Bs* starting with a [non-whitespace character], and *M* is a + list marker of width *W* followed by 1 ≤ *N* ≤ 4 spaces, then the result + of prepending *M* and the following spaces to the first line of + *Ls*, and indenting subsequent lines of *Ls* by *W + N* spaces, is a + list item with *Bs* as its contents. The type of the list item + (bullet or ordered) is determined by the type of its list marker. + If the list item is ordered, then it is also assigned a start + number, based on the ordered list marker. + + Exceptions: + + 1. When the first list item in a [list] interrupts + a paragraph---that is, when it starts on a line that would + otherwise count as [paragraph continuation text]---then (a) + the lines *Ls* must not begin with a blank line, and (b) if + the list item is ordered, the start number must be 1. + 2. If any line is a [thematic break][thematic breaks] then + that line is not a list item. + +For example, let *Ls* be the lines + +```````````````````````````````` example +A paragraph +with two lines. + + indented code + +> A block quote. +. +

    A paragraph +with two lines.

    +
    indented code
    +
    +
    +

    A block quote.

    +
    +```````````````````````````````` + + +And let *M* be the marker `1.`, and *N* = 2. Then rule #1 says +that the following is an ordered list item with start number 1, +and the same contents as *Ls*: + +```````````````````````````````` example +1. A paragraph + with two lines. + + indented code + + > A block quote. +. +
      +
    1. +

      A paragraph +with two lines.

      +
      indented code
      +
      +
      +

      A block quote.

      +
      +
    2. +
    +```````````````````````````````` + + +The most important thing to notice is that the position of +the text after the list marker determines how much indentation +is needed in subsequent blocks in the list item. If the list +marker takes up two spaces, and there are three spaces between +the list marker and the next [non-whitespace character], then blocks +must be indented five spaces in order to fall under the list +item. + +Here are some examples showing how far content must be indented to be +put under the list item: + +```````````````````````````````` example +- one + + two +. +
      +
    • one
    • +
    +

    two

    +```````````````````````````````` + + +```````````````````````````````` example +- one + + two +. +
      +
    • +

      one

      +

      two

      +
    • +
    +```````````````````````````````` + + +```````````````````````````````` example + - one + + two +. +
      +
    • one
    • +
    +
     two
    +
    +```````````````````````````````` + + +```````````````````````````````` example + - one + + two +. +
      +
    • +

      one

      +

      two

      +
    • +
    +```````````````````````````````` + + +It is tempting to think of this in terms of columns: the continuation +blocks must be indented at least to the column of the first +[non-whitespace character] after the list marker. However, that is not quite right. +The spaces after the list marker determine how much relative indentation +is needed. Which column this indentation reaches will depend on +how the list item is embedded in other constructions, as shown by +this example: + +```````````````````````````````` example + > > 1. one +>> +>> two +. +
    +
    +
      +
    1. +

      one

      +

      two

      +
    2. +
    +
    +
    +```````````````````````````````` + + +Here `two` occurs in the same column as the list marker `1.`, +but is actually contained in the list item, because there is +sufficient indentation after the last containing blockquote marker. + +The converse is also possible. In the following example, the word `two` +occurs far to the right of the initial text of the list item, `one`, but +it is not considered part of the list item, because it is not indented +far enough past the blockquote marker: + +```````````````````````````````` example +>>- one +>> + > > two +. +
    +
    +
      +
    • one
    • +
    +

    two

    +
    +
    +```````````````````````````````` + + +Note that at least one space is needed between the list marker and +any following content, so these are not list items: + +```````````````````````````````` example +-one + +2.two +. +

    -one

    +

    2.two

    +```````````````````````````````` + + +A list item may contain blocks that are separated by more than +one blank line. + +```````````````````````````````` example +- foo + + + bar +. +
      +
    • +

      foo

      +

      bar

      +
    • +
    +```````````````````````````````` + + +A list item may contain any kind of block: + +```````````````````````````````` example +1. foo + + ``` + bar + ``` + + baz + + > bam +. +
      +
    1. +

      foo

      +
      bar
      +
      +

      baz

      +
      +

      bam

      +
      +
    2. +
    +```````````````````````````````` + + +A list item that contains an indented code block will preserve +empty lines within the code block verbatim. + +```````````````````````````````` example +- Foo + + bar + + + baz +. +
      +
    • +

      Foo

      +
      bar
      +
      +
      +baz
      +
      +
    • +
    +```````````````````````````````` + +Note that ordered list start numbers must be nine digits or less: + +```````````````````````````````` example +123456789. ok +. +
      +
    1. ok
    2. +
    +```````````````````````````````` + + +```````````````````````````````` example +1234567890. not ok +. +

    1234567890. not ok

    +```````````````````````````````` + + +A start number may begin with 0s: + +```````````````````````````````` example +0. ok +. +
      +
    1. ok
    2. +
    +```````````````````````````````` + + +```````````````````````````````` example +003. ok +. +
      +
    1. ok
    2. +
    +```````````````````````````````` + + +A start number may not be negative: + +```````````````````````````````` example +-1. not ok +. +

    -1. not ok

    +```````````````````````````````` + + + +2. **Item starting with indented code.** If a sequence of lines *Ls* + constitute a sequence of blocks *Bs* starting with an indented code + block, and *M* is a list marker of width *W* followed by + one space, then the result of prepending *M* and the following + space to the first line of *Ls*, and indenting subsequent lines of + *Ls* by *W + 1* spaces, is a list item with *Bs* as its contents. + If a line is empty, then it need not be indented. The type of the + list item (bullet or ordered) is determined by the type of its list + marker. If the list item is ordered, then it is also assigned a + start number, based on the ordered list marker. + +An indented code block will have to be indented four spaces beyond +the edge of the region where text will be included in the list item. +In the following case that is 6 spaces: + +```````````````````````````````` example +- foo + + bar +. +
      +
    • +

      foo

      +
      bar
      +
      +
    • +
    +```````````````````````````````` + + +And in this case it is 11 spaces: + +```````````````````````````````` example + 10. foo + + bar +. +
      +
    1. +

      foo

      +
      bar
      +
      +
    2. +
    +```````````````````````````````` + + +If the *first* block in the list item is an indented code block, +then by rule #2, the contents must be indented *one* space after the +list marker: + +```````````````````````````````` example + indented code + +paragraph + + more code +. +
    indented code
    +
    +

    paragraph

    +
    more code
    +
    +```````````````````````````````` + + +```````````````````````````````` example +1. indented code + + paragraph + + more code +. +
      +
    1. +
      indented code
      +
      +

      paragraph

      +
      more code
      +
      +
    2. +
    +```````````````````````````````` + + +Note that an additional space indent is interpreted as space +inside the code block: + +```````````````````````````````` example +1. indented code + + paragraph + + more code +. +
      +
    1. +
       indented code
      +
      +

      paragraph

      +
      more code
      +
      +
    2. +
    +```````````````````````````````` + + +Note that rules #1 and #2 only apply to two cases: (a) cases +in which the lines to be included in a list item begin with a +[non-whitespace character], and (b) cases in which +they begin with an indented code +block. In a case like the following, where the first block begins with +a three-space indent, the rules do not allow us to form a list item by +indenting the whole thing and prepending a list marker: + +```````````````````````````````` example + foo + +bar +. +

    foo

    +

    bar

    +```````````````````````````````` + + +```````````````````````````````` example +- foo + + bar +. +
      +
    • foo
    • +
    +

    bar

    +```````````````````````````````` + + +This is not a significant restriction, because when a block begins +with 1-3 spaces indent, the indentation can always be removed without +a change in interpretation, allowing rule #1 to be applied. So, in +the above case: + +```````````````````````````````` example +- foo + + bar +. +
      +
    • +

      foo

      +

      bar

      +
    • +
    +```````````````````````````````` + + +3. **Item starting with a blank line.** If a sequence of lines *Ls* + starting with a single [blank line] constitute a (possibly empty) + sequence of blocks *Bs*, not separated from each other by more than + one blank line, and *M* is a list marker of width *W*, + then the result of prepending *M* to the first line of *Ls*, and + indenting subsequent lines of *Ls* by *W + 1* spaces, is a list + item with *Bs* as its contents. + If a line is empty, then it need not be indented. The type of the + list item (bullet or ordered) is determined by the type of its list + marker. If the list item is ordered, then it is also assigned a + start number, based on the ordered list marker. + +Here are some list items that start with a blank line but are not empty: + +```````````````````````````````` example +- + foo +- + ``` + bar + ``` +- + baz +. +
      +
    • foo
    • +
    • +
      bar
      +
      +
    • +
    • +
      baz
      +
      +
    • +
    +```````````````````````````````` + +When the list item starts with a blank line, the number of spaces +following the list marker doesn't change the required indentation: + +```````````````````````````````` example +- + foo +. +
      +
    • foo
    • +
    +```````````````````````````````` + + +A list item can begin with at most one blank line. +In the following example, `foo` is not part of the list +item: + +```````````````````````````````` example +- + + foo +. +
      +
    • +
    +

    foo

    +```````````````````````````````` + + +Here is an empty bullet list item: + +```````````````````````````````` example +- foo +- +- bar +. +
      +
    • foo
    • +
    • +
    • bar
    • +
    +```````````````````````````````` + + +It does not matter whether there are spaces following the [list marker]: + +```````````````````````````````` example +- foo +- +- bar +. +
      +
    • foo
    • +
    • +
    • bar
    • +
    +```````````````````````````````` + + +Here is an empty ordered list item: + +```````````````````````````````` example +1. foo +2. +3. bar +. +
      +
    1. foo
    2. +
    3. +
    4. bar
    5. +
    +```````````````````````````````` + + +A list may start or end with an empty list item: + +```````````````````````````````` example +* +. +
      +
    • +
    +```````````````````````````````` + +However, an empty list item cannot interrupt a paragraph: + +```````````````````````````````` example +foo +* + +foo +1. +. +

    foo +*

    +

    foo +1.

    +```````````````````````````````` + + +4. **Indentation.** If a sequence of lines *Ls* constitutes a list item + according to rule #1, #2, or #3, then the result of indenting each line + of *Ls* by 1-3 spaces (the same for each line) also constitutes a + list item with the same contents and attributes. If a line is + empty, then it need not be indented. + +Indented one space: + +```````````````````````````````` example + 1. A paragraph + with two lines. + + indented code + + > A block quote. +. +
      +
    1. +

      A paragraph +with two lines.

      +
      indented code
      +
      +
      +

      A block quote.

      +
      +
    2. +
    +```````````````````````````````` + + +Indented two spaces: + +```````````````````````````````` example + 1. A paragraph + with two lines. + + indented code + + > A block quote. +. +
      +
    1. +

      A paragraph +with two lines.

      +
      indented code
      +
      +
      +

      A block quote.

      +
      +
    2. +
    +```````````````````````````````` + + +Indented three spaces: + +```````````````````````````````` example + 1. A paragraph + with two lines. + + indented code + + > A block quote. +. +
      +
    1. +

      A paragraph +with two lines.

      +
      indented code
      +
      +
      +

      A block quote.

      +
      +
    2. +
    +```````````````````````````````` + + +Four spaces indent gives a code block: + +```````````````````````````````` example + 1. A paragraph + with two lines. + + indented code + + > A block quote. +. +
    1.  A paragraph
    +    with two lines.
    +
    +        indented code
    +
    +    > A block quote.
    +
    +```````````````````````````````` + + + +5. **Laziness.** If a string of lines *Ls* constitute a [list + item](#list-items) with contents *Bs*, then the result of deleting + some or all of the indentation from one or more lines in which the + next [non-whitespace character] after the indentation is + [paragraph continuation text] is a + list item with the same contents and attributes. The unindented + lines are called + [lazy continuation line](@)s. + +Here is an example with [lazy continuation lines]: + +```````````````````````````````` example + 1. A paragraph +with two lines. + + indented code + + > A block quote. +. +
      +
    1. +

      A paragraph +with two lines.

      +
      indented code
      +
      +
      +

      A block quote.

      +
      +
    2. +
    +```````````````````````````````` + + +Indentation can be partially deleted: + +```````````````````````````````` example + 1. A paragraph + with two lines. +. +
      +
    1. A paragraph +with two lines.
    2. +
    +```````````````````````````````` + + +These examples show how laziness can work in nested structures: + +```````````````````````````````` example +> 1. > Blockquote +continued here. +. +
    +
      +
    1. +
      +

      Blockquote +continued here.

      +
      +
    2. +
    +
    +```````````````````````````````` + + +```````````````````````````````` example +> 1. > Blockquote +> continued here. +. +
    +
      +
    1. +
      +

      Blockquote +continued here.

      +
      +
    2. +
    +
    +```````````````````````````````` + + + +6. **That's all.** Nothing that is not counted as a list item by rules + #1--5 counts as a [list item](#list-items). + +The rules for sublists follow from the general rules +[above][List items]. A sublist must be indented the same number +of spaces a paragraph would need to be in order to be included +in the list item. + +So, in this case we need two spaces indent: + +```````````````````````````````` example +- foo + - bar + - baz + - boo +. +
      +
    • foo +
        +
      • bar +
          +
        • baz +
            +
          • boo
          • +
          +
        • +
        +
      • +
      +
    • +
    +```````````````````````````````` + + +One is not enough: + +```````````````````````````````` example +- foo + - bar + - baz + - boo +. +
      +
    • foo
    • +
    • bar
    • +
    • baz
    • +
    • boo
    • +
    +```````````````````````````````` + + +Here we need four, because the list marker is wider: + +```````````````````````````````` example +10) foo + - bar +. +
      +
    1. foo +
        +
      • bar
      • +
      +
    2. +
    +```````````````````````````````` + + +Three is not enough: + +```````````````````````````````` example +10) foo + - bar +. +
      +
    1. foo
    2. +
    +
      +
    • bar
    • +
    +```````````````````````````````` + + +A list may be the first block in a list item: + +```````````````````````````````` example +- - foo +. +
      +
    • +
        +
      • foo
      • +
      +
    • +
    +```````````````````````````````` + + +```````````````````````````````` example +1. - 2. foo +. +
      +
    1. +
        +
      • +
          +
        1. foo
        2. +
        +
      • +
      +
    2. +
    +```````````````````````````````` + + +A list item can contain a heading: + +```````````````````````````````` example +- # Foo +- Bar + --- + baz +. +
      +
    • +

      Foo

      +
    • +
    • +

      Bar

      +baz
    • +
    +```````````````````````````````` + + +### Motivation + +John Gruber's Markdown spec says the following about list items: + +1. "List markers typically start at the left margin, but may be indented + by up to three spaces. List markers must be followed by one or more + spaces or a tab." + +2. "To make lists look nice, you can wrap items with hanging indents.... + But if you don't want to, you don't have to." + +3. "List items may consist of multiple paragraphs. Each subsequent + paragraph in a list item must be indented by either 4 spaces or one + tab." + +4. "It looks nice if you indent every line of the subsequent paragraphs, + but here again, Markdown will allow you to be lazy." + +5. "To put a blockquote within a list item, the blockquote's `>` + delimiters need to be indented." + +6. "To put a code block within a list item, the code block needs to be + indented twice — 8 spaces or two tabs." + +These rules specify that a paragraph under a list item must be indented +four spaces (presumably, from the left margin, rather than the start of +the list marker, but this is not said), and that code under a list item +must be indented eight spaces instead of the usual four. They also say +that a block quote must be indented, but not by how much; however, the +example given has four spaces indentation. Although nothing is said +about other kinds of block-level content, it is certainly reasonable to +infer that *all* block elements under a list item, including other +lists, must be indented four spaces. This principle has been called the +*four-space rule*. + +The four-space rule is clear and principled, and if the reference +implementation `Markdown.pl` had followed it, it probably would have +become the standard. However, `Markdown.pl` allowed paragraphs and +sublists to start with only two spaces indentation, at least on the +outer level. Worse, its behavior was inconsistent: a sublist of an +outer-level list needed two spaces indentation, but a sublist of this +sublist needed three spaces. It is not surprising, then, that different +implementations of Markdown have developed very different rules for +determining what comes under a list item. (Pandoc and python-Markdown, +for example, stuck with Gruber's syntax description and the four-space +rule, while discount, redcarpet, marked, PHP Markdown, and others +followed `Markdown.pl`'s behavior more closely.) + +Unfortunately, given the divergences between implementations, there +is no way to give a spec for list items that will be guaranteed not +to break any existing documents. However, the spec given here should +correctly handle lists formatted with either the four-space rule or +the more forgiving `Markdown.pl` behavior, provided they are laid out +in a way that is natural for a human to read. + +The strategy here is to let the width and indentation of the list marker +determine the indentation necessary for blocks to fall under the list +item, rather than having a fixed and arbitrary number. The writer can +think of the body of the list item as a unit which gets indented to the +right enough to fit the list marker (and any indentation on the list +marker). (The laziness rule, #5, then allows continuation lines to be +unindented if needed.) + +This rule is superior, we claim, to any rule requiring a fixed level of +indentation from the margin. The four-space rule is clear but +unnatural. It is quite unintuitive that + +``` markdown +- foo + + bar + + - baz +``` + +should be parsed as two lists with an intervening paragraph, + +``` html +
      +
    • foo
    • +
    +

    bar

    +
      +
    • baz
    • +
    +``` + +as the four-space rule demands, rather than a single list, + +``` html +
      +
    • +

      foo

      +

      bar

      +
        +
      • baz
      • +
      +
    • +
    +``` + +The choice of four spaces is arbitrary. It can be learned, but it is +not likely to be guessed, and it trips up beginners regularly. + +Would it help to adopt a two-space rule? The problem is that such +a rule, together with the rule allowing 1--3 spaces indentation of the +initial list marker, allows text that is indented *less than* the +original list marker to be included in the list item. For example, +`Markdown.pl` parses + +``` markdown + - one + + two +``` + +as a single list item, with `two` a continuation paragraph: + +``` html +
      +
    • +

      one

      +

      two

      +
    • +
    +``` + +and similarly + +``` markdown +> - one +> +> two +``` + +as + +``` html +
    +
      +
    • +

      one

      +

      two

      +
    • +
    +
    +``` + +This is extremely unintuitive. + +Rather than requiring a fixed indent from the margin, we could require +a fixed indent (say, two spaces, or even one space) from the list marker (which +may itself be indented). This proposal would remove the last anomaly +discussed. Unlike the spec presented above, it would count the following +as a list item with a subparagraph, even though the paragraph `bar` +is not indented as far as the first paragraph `foo`: + +``` markdown + 10. foo + + bar +``` + +Arguably this text does read like a list item with `bar` as a subparagraph, +which may count in favor of the proposal. However, on this proposal indented +code would have to be indented six spaces after the list marker. And this +would break a lot of existing Markdown, which has the pattern: + +``` markdown +1. foo + + indented code +``` + +where the code is indented eight spaces. The spec above, by contrast, will +parse this text as expected, since the code block's indentation is measured +from the beginning of `foo`. + +The one case that needs special treatment is a list item that *starts* +with indented code. How much indentation is required in that case, since +we don't have a "first paragraph" to measure from? Rule #2 simply stipulates +that in such cases, we require one space indentation from the list marker +(and then the normal four spaces for the indented code). This will match the +four-space rule in cases where the list marker plus its initial indentation +takes four spaces (a common case), but diverge in other cases. + +## Lists + +A [list](@) is a sequence of one or more +list items [of the same type]. The list items +may be separated by any number of blank lines. + +Two list items are [of the same type](@) +if they begin with a [list marker] of the same type. +Two list markers are of the +same type if (a) they are bullet list markers using the same character +(`-`, `+`, or `*`) or (b) they are ordered list numbers with the same +delimiter (either `.` or `)`). + +A list is an [ordered list](@) +if its constituent list items begin with +[ordered list markers], and a +[bullet list](@) if its constituent list +items begin with [bullet list markers]. + +The [start number](@) +of an [ordered list] is determined by the list number of +its initial list item. The numbers of subsequent list items are +disregarded. + +A list is [loose](@) if any of its constituent +list items are separated by blank lines, or if any of its constituent +list items directly contain two block-level elements with a blank line +between them. Otherwise a list is [tight](@). +(The difference in HTML output is that paragraphs in a loose list are +wrapped in `

    ` tags, while paragraphs in a tight list are not.) + +Changing the bullet or ordered list delimiter starts a new list: + +```````````````````````````````` example +- foo +- bar ++ baz +. +

      +
    • foo
    • +
    • bar
    • +
    +
      +
    • baz
    • +
    +```````````````````````````````` + + +```````````````````````````````` example +1. foo +2. bar +3) baz +. +
      +
    1. foo
    2. +
    3. bar
    4. +
    +
      +
    1. baz
    2. +
    +```````````````````````````````` + + +In CommonMark, a list can interrupt a paragraph. That is, +no blank line is needed to separate a paragraph from a following +list: + +```````````````````````````````` example +Foo +- bar +- baz +. +

    Foo

    +
      +
    • bar
    • +
    • baz
    • +
    +```````````````````````````````` + +`Markdown.pl` does not allow this, through fear of triggering a list +via a numeral in a hard-wrapped line: + +``` markdown +The number of windows in my house is +14. The number of doors is 6. +``` + +Oddly, though, `Markdown.pl` *does* allow a blockquote to +interrupt a paragraph, even though the same considerations might +apply. + +In CommonMark, we do allow lists to interrupt paragraphs, for +two reasons. First, it is natural and not uncommon for people +to start lists without blank lines: + +``` markdown +I need to buy +- new shoes +- a coat +- a plane ticket +``` + +Second, we are attracted to a + +> [principle of uniformity](@): +> if a chunk of text has a certain +> meaning, it will continue to have the same meaning when put into a +> container block (such as a list item or blockquote). + +(Indeed, the spec for [list items] and [block quotes] presupposes +this principle.) This principle implies that if + +``` markdown + * I need to buy + - new shoes + - a coat + - a plane ticket +``` + +is a list item containing a paragraph followed by a nested sublist, +as all Markdown implementations agree it is (though the paragraph +may be rendered without `

    ` tags, since the list is "tight"), +then + +``` markdown +I need to buy +- new shoes +- a coat +- a plane ticket +``` + +by itself should be a paragraph followed by a nested sublist. + +Since it is well established Markdown practice to allow lists to +interrupt paragraphs inside list items, the [principle of +uniformity] requires us to allow this outside list items as +well. ([reStructuredText](http://docutils.sourceforge.net/rst.html) +takes a different approach, requiring blank lines before lists +even inside other list items.) + +In order to solve of unwanted lists in paragraphs with +hard-wrapped numerals, we allow only lists starting with `1` to +interrupt paragraphs. Thus, + +```````````````````````````````` example +The number of windows in my house is +14. The number of doors is 6. +. +

    The number of windows in my house is +14. The number of doors is 6.

    +```````````````````````````````` + +We may still get an unintended result in cases like + +```````````````````````````````` example +The number of windows in my house is +1. The number of doors is 6. +. +

    The number of windows in my house is

    +
      +
    1. The number of doors is 6.
    2. +
    +```````````````````````````````` + +but this rule should prevent most spurious list captures. + +There can be any number of blank lines between items: + +```````````````````````````````` example +- foo + +- bar + + +- baz +. +
      +
    • +

      foo

      +
    • +
    • +

      bar

      +
    • +
    • +

      baz

      +
    • +
    +```````````````````````````````` + +```````````````````````````````` example +- foo + - bar + - baz + + + bim +. +
      +
    • foo +
        +
      • bar +
          +
        • +

          baz

          +

          bim

          +
        • +
        +
      • +
      +
    • +
    +```````````````````````````````` + + +To separate consecutive lists of the same type, or to separate a +list from an indented code block that would otherwise be parsed +as a subparagraph of the final list item, you can insert a blank HTML +comment: + +```````````````````````````````` example +- foo +- bar + + + +- baz +- bim +. +
      +
    • foo
    • +
    • bar
    • +
    + +
      +
    • baz
    • +
    • bim
    • +
    +```````````````````````````````` + + +```````````````````````````````` example +- foo + + notcode + +- foo + + + + code +. +
      +
    • +

      foo

      +

      notcode

      +
    • +
    • +

      foo

      +
    • +
    + +
    code
    +
    +```````````````````````````````` + + +List items need not be indented to the same level. The following +list items will be treated as items at the same list level, +since none is indented enough to belong to the previous list +item: + +```````````````````````````````` example +- a + - b + - c + - d + - e + - f +- g +. +
      +
    • a
    • +
    • b
    • +
    • c
    • +
    • d
    • +
    • e
    • +
    • f
    • +
    • g
    • +
    +```````````````````````````````` + + +```````````````````````````````` example +1. a + + 2. b + + 3. c +. +
      +
    1. +

      a

      +
    2. +
    3. +

      b

      +
    4. +
    5. +

      c

      +
    6. +
    +```````````````````````````````` + +Note, however, that list items may not be indented more than +three spaces. Here `- e` is treated as a paragraph continuation +line, because it is indented more than three spaces: + +```````````````````````````````` example +- a + - b + - c + - d + - e +. +
      +
    • a
    • +
    • b
    • +
    • c
    • +
    • d +- e
    • +
    +```````````````````````````````` + +And here, `3. c` is treated as in indented code block, +because it is indented four spaces and preceded by a +blank line. + +```````````````````````````````` example +1. a + + 2. b + + 3. c +. +
      +
    1. +

      a

      +
    2. +
    3. +

      b

      +
    4. +
    +
    3. c
    +
    +```````````````````````````````` + + +This is a loose list, because there is a blank line between +two of the list items: + +```````````````````````````````` example +- a +- b + +- c +. +
      +
    • +

      a

      +
    • +
    • +

      b

      +
    • +
    • +

      c

      +
    • +
    +```````````````````````````````` + + +So is this, with a empty second item: + +```````````````````````````````` example +* a +* + +* c +. +
      +
    • +

      a

      +
    • +
    • +
    • +

      c

      +
    • +
    +```````````````````````````````` + + +These are loose lists, even though there is no space between the items, +because one of the items directly contains two block-level elements +with a blank line between them: + +```````````````````````````````` example +- a +- b + + c +- d +. +
      +
    • +

      a

      +
    • +
    • +

      b

      +

      c

      +
    • +
    • +

      d

      +
    • +
    +```````````````````````````````` + + +```````````````````````````````` example +- a +- b + + [ref]: /url +- d +. +
      +
    • +

      a

      +
    • +
    • +

      b

      +
    • +
    • +

      d

      +
    • +
    +```````````````````````````````` + + +This is a tight list, because the blank lines are in a code block: + +```````````````````````````````` example +- a +- ``` + b + + + ``` +- c +. +
      +
    • a
    • +
    • +
      b
      +
      +
      +
      +
    • +
    • c
    • +
    +```````````````````````````````` + + +This is a tight list, because the blank line is between two +paragraphs of a sublist. So the sublist is loose while +the outer list is tight: + +```````````````````````````````` example +- a + - b + + c +- d +. +
      +
    • a +
        +
      • +

        b

        +

        c

        +
      • +
      +
    • +
    • d
    • +
    +```````````````````````````````` + + +This is a tight list, because the blank line is inside the +block quote: + +```````````````````````````````` example +* a + > b + > +* c +. +
      +
    • a +
      +

      b

      +
      +
    • +
    • c
    • +
    +```````````````````````````````` + + +This list is tight, because the consecutive block elements +are not separated by blank lines: + +```````````````````````````````` example +- a + > b + ``` + c + ``` +- d +. +
      +
    • a +
      +

      b

      +
      +
      c
      +
      +
    • +
    • d
    • +
    +```````````````````````````````` + + +A single-paragraph list is tight: + +```````````````````````````````` example +- a +. +
      +
    • a
    • +
    +```````````````````````````````` + + +```````````````````````````````` example +- a + - b +. +
      +
    • a +
        +
      • b
      • +
      +
    • +
    +```````````````````````````````` + + +This list is loose, because of the blank line between the +two block elements in the list item: + +```````````````````````````````` example +1. ``` + foo + ``` + + bar +. +
      +
    1. +
      foo
      +
      +

      bar

      +
    2. +
    +```````````````````````````````` + + +Here the outer list is loose, the inner list tight: + +```````````````````````````````` example +* foo + * bar + + baz +. +
      +
    • +

      foo

      +
        +
      • bar
      • +
      +

      baz

      +
    • +
    +```````````````````````````````` + + +```````````````````````````````` example +- a + - b + - c + +- d + - e + - f +. +
      +
    • +

      a

      +
        +
      • b
      • +
      • c
      • +
      +
    • +
    • +

      d

      +
        +
      • e
      • +
      • f
      • +
      +
    • +
    +```````````````````````````````` + + +# Inlines + +Inlines are parsed sequentially from the beginning of the character +stream to the end (left to right, in left-to-right languages). +Thus, for example, in + +```````````````````````````````` example +`hi`lo` +. +

    hilo`

    +```````````````````````````````` + +`hi` is parsed as code, leaving the backtick at the end as a literal +backtick. + + +## Backslash escapes + +Any ASCII punctuation character may be backslash-escaped: + +```````````````````````````````` example +\!\"\#\$\%\&\'\(\)\*\+\,\-\.\/\:\;\<\=\>\?\@\[\\\]\^\_\`\{\|\}\~ +. +

    !"#$%&'()*+,-./:;<=>?@[\]^_`{|}~

    +```````````````````````````````` + + +Backslashes before other characters are treated as literal +backslashes: + +```````````````````````````````` example +\→\A\a\ \3\φ\« +. +

    \→\A\a\ \3\φ\«

    +```````````````````````````````` + + +Escaped characters are treated as regular characters and do +not have their usual Markdown meanings: + +```````````````````````````````` example +\*not emphasized* +\
    not a tag +\[not a link](/foo) +\`not code` +1\. not a list +\* not a list +\# not a heading +\[foo]: /url "not a reference" +\ö not a character entity +. +

    *not emphasized* +<br/> not a tag +[not a link](/foo) +`not code` +1. not a list +* not a list +# not a heading +[foo]: /url "not a reference" +&ouml; not a character entity

    +```````````````````````````````` + + +If a backslash is itself escaped, the following character is not: + +```````````````````````````````` example +\\*emphasis* +. +

    \emphasis

    +```````````````````````````````` + + +A backslash at the end of the line is a [hard line break]: + +```````````````````````````````` example +foo\ +bar +. +

    foo
    +bar

    +```````````````````````````````` + + +Backslash escapes do not work in code blocks, code spans, autolinks, or +raw HTML: + +```````````````````````````````` example +`` \[\` `` +. +

    \[\`

    +```````````````````````````````` + + +```````````````````````````````` example + \[\] +. +
    \[\]
    +
    +```````````````````````````````` + + +```````````````````````````````` example +~~~ +\[\] +~~~ +. +
    \[\]
    +
    +```````````````````````````````` + + +```````````````````````````````` example + +. +

    http://example.com?find=\*

    +```````````````````````````````` + + +```````````````````````````````` example + +. + +```````````````````````````````` + + +But they work in all other contexts, including URLs and link titles, +link references, and [info strings] in [fenced code blocks]: + +```````````````````````````````` example +[foo](/bar\* "ti\*tle") +. +

    foo

    +```````````````````````````````` + + +```````````````````````````````` example +[foo] + +[foo]: /bar\* "ti\*tle" +. +

    foo

    +```````````````````````````````` + + +```````````````````````````````` example +``` foo\+bar +foo +``` +. +
    foo
    +
    +```````````````````````````````` + + + +## Entity and numeric character references + +Valid HTML entity references and numeric character references +can be used in place of the corresponding Unicode character, +with the following exceptions: + +- Entity and character references are not recognized in code + blocks and code spans. + +- Entity and character references cannot stand in place of + special characters that define structural elements in + CommonMark. For example, although `*` can be used + in place of a literal `*` character, `*` cannot replace + `*` in emphasis delimiters, bullet list markers, or thematic + breaks. + +Conforming CommonMark parsers need not store information about +whether a particular character was represented in the source +using a Unicode character or an entity reference. + +[Entity references](@) consist of `&` + any of the valid +HTML5 entity names + `;`. The +document +is used as an authoritative source for the valid entity +references and their corresponding code points. + +```````````````````````````````` example +  & © Æ Ď +¾ ℋ ⅆ +∲ ≧̸ +. +

      & © Æ Ď +¾ ℋ ⅆ +∲ ≧̸

    +```````````````````````````````` + + +[Decimal numeric character +references](@) +consist of `&#` + a string of 1--7 arabic digits + `;`. A +numeric character reference is parsed as the corresponding +Unicode character. Invalid Unicode code points will be replaced by +the REPLACEMENT CHARACTER (`U+FFFD`). For security reasons, +the code point `U+0000` will also be replaced by `U+FFFD`. + +```````````````````````````````` example +# Ӓ Ϡ � +. +

    # Ӓ Ϡ �

    +```````````````````````````````` + + +[Hexadecimal numeric character +references](@) consist of `&#` + +either `X` or `x` + a string of 1-6 hexadecimal digits + `;`. +They too are parsed as the corresponding Unicode character (this +time specified with a hexadecimal numeral instead of decimal). + +```````````````````````````````` example +" ആ ಫ +. +

    " ആ ಫ

    +```````````````````````````````` + + +Here are some nonentities: + +```````````````````````````````` example +  &x; &#; &#x; +� +&#abcdef0; +&ThisIsNotDefined; &hi?; +. +

    &nbsp &x; &#; &#x; +&#87654321; +&#abcdef0; +&ThisIsNotDefined; &hi?;

    +```````````````````````````````` + + +Although HTML5 does accept some entity references +without a trailing semicolon (such as `©`), these are not +recognized here, because it makes the grammar too ambiguous: + +```````````````````````````````` example +© +. +

    &copy

    +```````````````````````````````` + + +Strings that are not on the list of HTML5 named entities are not +recognized as entity references either: + +```````````````````````````````` example +&MadeUpEntity; +. +

    &MadeUpEntity;

    +```````````````````````````````` + + +Entity and numeric character references are recognized in any +context besides code spans or code blocks, including +URLs, [link titles], and [fenced code block][] [info strings]: + +```````````````````````````````` example + +. + +```````````````````````````````` + + +```````````````````````````````` example +[foo](/föö "föö") +. +

    foo

    +```````````````````````````````` + + +```````````````````````````````` example +[foo] + +[foo]: /föö "föö" +. +

    foo

    +```````````````````````````````` + + +```````````````````````````````` example +``` föö +foo +``` +. +
    foo
    +
    +```````````````````````````````` + + +Entity and numeric character references are treated as literal +text in code spans and code blocks: + +```````````````````````````````` example +`föö` +. +

    f&ouml;&ouml;

    +```````````````````````````````` + + +```````````````````````````````` example + föfö +. +
    f&ouml;f&ouml;
    +
    +```````````````````````````````` + + +Entity and numeric character references cannot be used +in place of symbols indicating structure in CommonMark +documents. + +```````````````````````````````` example +*foo* +*foo* +. +

    *foo* +foo

    +```````````````````````````````` + +```````````````````````````````` example +* foo + +* foo +. +

    * foo

    +
      +
    • foo
    • +
    +```````````````````````````````` + +```````````````````````````````` example +foo bar +. +

    foo + +bar

    +```````````````````````````````` + +```````````````````````````````` example + foo +. +

    →foo

    +```````````````````````````````` + + +```````````````````````````````` example +[a](url "tit") +. +

    [a](url "tit")

    +```````````````````````````````` + + +## Code spans + +A [backtick string](@) +is a string of one or more backtick characters (`` ` ``) that is neither +preceded nor followed by a backtick. + +A [code span](@) begins with a backtick string and ends with +a backtick string of equal length. The contents of the code span are +the characters between the two backtick strings, normalized in the +following ways: + +- First, [line endings] are converted to [spaces]. +- If the resulting string both begins *and* ends with a [space] + character, but does not consist entirely of [space] + characters, a single [space] character is removed from the + front and back. This allows you to include code that begins + or ends with backtick characters, which must be separated by + whitespace from the opening or closing backtick strings. + +This is a simple code span: + +```````````````````````````````` example +`foo` +. +

    foo

    +```````````````````````````````` + + +Here two backticks are used, because the code contains a backtick. +This example also illustrates stripping of a single leading and +trailing space: + +```````````````````````````````` example +`` foo ` bar `` +. +

    foo ` bar

    +```````````````````````````````` + + +This example shows the motivation for stripping leading and trailing +spaces: + +```````````````````````````````` example +` `` ` +. +

    ``

    +```````````````````````````````` + +Note that only *one* space is stripped: + +```````````````````````````````` example +` `` ` +. +

    ``

    +```````````````````````````````` + +The stripping only happens if the space is on both +sides of the string: + +```````````````````````````````` example +` a` +. +

    a

    +```````````````````````````````` + +Only [spaces], and not [unicode whitespace] in general, are +stripped in this way: + +```````````````````````````````` example +` b ` +. +

     b 

    +```````````````````````````````` + +No stripping occurs if the code span contains only spaces: + +```````````````````````````````` example +` ` +` ` +. +

      +

    +```````````````````````````````` + + +[Line endings] are treated like spaces: + +```````````````````````````````` example +`` +foo +bar +baz +`` +. +

    foo bar baz

    +```````````````````````````````` + +```````````````````````````````` example +`` +foo +`` +. +

    foo

    +```````````````````````````````` + + +Interior spaces are not collapsed: + +```````````````````````````````` example +`foo bar +baz` +. +

    foo bar baz

    +```````````````````````````````` + +Note that browsers will typically collapse consecutive spaces +when rendering `` elements, so it is recommended that +the following CSS be used: + + code{white-space: pre-wrap;} + + +Note that backslash escapes do not work in code spans. All backslashes +are treated literally: + +```````````````````````````````` example +`foo\`bar` +. +

    foo\bar`

    +```````````````````````````````` + + +Backslash escapes are never needed, because one can always choose a +string of *n* backtick characters as delimiters, where the code does +not contain any strings of exactly *n* backtick characters. + +```````````````````````````````` example +``foo`bar`` +. +

    foo`bar

    +```````````````````````````````` + +```````````````````````````````` example +` foo `` bar ` +. +

    foo `` bar

    +```````````````````````````````` + + +Code span backticks have higher precedence than any other inline +constructs except HTML tags and autolinks. Thus, for example, this is +not parsed as emphasized text, since the second `*` is part of a code +span: + +```````````````````````````````` example +*foo`*` +. +

    *foo*

    +```````````````````````````````` + + +And this is not parsed as a link: + +```````````````````````````````` example +[not a `link](/foo`) +. +

    [not a link](/foo)

    +```````````````````````````````` + + +Code spans, HTML tags, and autolinks have the same precedence. +Thus, this is code: + +```````````````````````````````` example +`` +. +

    <a href="">`

    +```````````````````````````````` + + +But this is an HTML tag: + +```````````````````````````````` example +
    ` +. +

    `

    +```````````````````````````````` + + +And this is code: + +```````````````````````````````` example +`` +. +

    <http://foo.bar.baz>`

    +```````````````````````````````` + + +But this is an autolink: + +```````````````````````````````` example +` +. +

    http://foo.bar.`baz`

    +```````````````````````````````` + + +When a backtick string is not closed by a matching backtick string, +we just have literal backticks: + +```````````````````````````````` example +```foo`` +. +

    ```foo``

    +```````````````````````````````` + + +```````````````````````````````` example +`foo +. +

    `foo

    +```````````````````````````````` + +The following case also illustrates the need for opening and +closing backtick strings to be equal in length: + +```````````````````````````````` example +`foo``bar`` +. +

    `foobar

    +```````````````````````````````` + + +## Emphasis and strong emphasis + +John Gruber's original [Markdown syntax +description](http://daringfireball.net/projects/markdown/syntax#em) says: + +> Markdown treats asterisks (`*`) and underscores (`_`) as indicators of +> emphasis. Text wrapped with one `*` or `_` will be wrapped with an HTML +> `` tag; double `*`'s or `_`'s will be wrapped with an HTML `` +> tag. + +This is enough for most users, but these rules leave much undecided, +especially when it comes to nested emphasis. The original +`Markdown.pl` test suite makes it clear that triple `***` and +`___` delimiters can be used for strong emphasis, and most +implementations have also allowed the following patterns: + +``` markdown +***strong emph*** +***strong** in emph* +***emph* in strong** +**in strong *emph*** +*in emph **strong*** +``` + +The following patterns are less widely supported, but the intent +is clear and they are useful (especially in contexts like bibliography +entries): + +``` markdown +*emph *with emph* in it* +**strong **with strong** in it** +``` + +Many implementations have also restricted intraword emphasis to +the `*` forms, to avoid unwanted emphasis in words containing +internal underscores. (It is best practice to put these in code +spans, but users often do not.) + +``` markdown +internal emphasis: foo*bar*baz +no emphasis: foo_bar_baz +``` + +The rules given below capture all of these patterns, while allowing +for efficient parsing strategies that do not backtrack. + +First, some definitions. A [delimiter run](@) is either +a sequence of one or more `*` characters that is not preceded or +followed by a non-backslash-escaped `*` character, or a sequence +of one or more `_` characters that is not preceded or followed by +a non-backslash-escaped `_` character. + +A [left-flanking delimiter run](@) is +a [delimiter run] that is (1) not followed by [Unicode whitespace], +and either (2a) not followed by a [punctuation character], or +(2b) followed by a [punctuation character] and +preceded by [Unicode whitespace] or a [punctuation character]. +For purposes of this definition, the beginning and the end of +the line count as Unicode whitespace. + +A [right-flanking delimiter run](@) is +a [delimiter run] that is (1) not preceded by [Unicode whitespace], +and either (2a) not preceded by a [punctuation character], or +(2b) preceded by a [punctuation character] and +followed by [Unicode whitespace] or a [punctuation character]. +For purposes of this definition, the beginning and the end of +the line count as Unicode whitespace. + +Here are some examples of delimiter runs. + + - left-flanking but not right-flanking: + + ``` + ***abc + _abc + **"abc" + _"abc" + ``` + + - right-flanking but not left-flanking: + + ``` + abc*** + abc_ + "abc"** + "abc"_ + ``` + + - Both left and right-flanking: + + ``` + abc***def + "abc"_"def" + ``` + + - Neither left nor right-flanking: + + ``` + abc *** def + a _ b + ``` + +(The idea of distinguishing left-flanking and right-flanking +delimiter runs based on the character before and the character +after comes from Roopesh Chander's +[vfmd](http://www.vfmd.org/vfmd-spec/specification/#procedure-for-identifying-emphasis-tags). +vfmd uses the terminology "emphasis indicator string" instead of "delimiter +run," and its rules for distinguishing left- and right-flanking runs +are a bit more complex than the ones given here.) + +The following rules define emphasis and strong emphasis: + +1. A single `*` character [can open emphasis](@) + iff (if and only if) it is part of a [left-flanking delimiter run]. + +2. A single `_` character [can open emphasis] iff + it is part of a [left-flanking delimiter run] + and either (a) not part of a [right-flanking delimiter run] + or (b) part of a [right-flanking delimiter run] + preceded by punctuation. + +3. A single `*` character [can close emphasis](@) + iff it is part of a [right-flanking delimiter run]. + +4. A single `_` character [can close emphasis] iff + it is part of a [right-flanking delimiter run] + and either (a) not part of a [left-flanking delimiter run] + or (b) part of a [left-flanking delimiter run] + followed by punctuation. + +5. A double `**` [can open strong emphasis](@) + iff it is part of a [left-flanking delimiter run]. + +6. A double `__` [can open strong emphasis] iff + it is part of a [left-flanking delimiter run] + and either (a) not part of a [right-flanking delimiter run] + or (b) part of a [right-flanking delimiter run] + preceded by punctuation. + +7. A double `**` [can close strong emphasis](@) + iff it is part of a [right-flanking delimiter run]. + +8. A double `__` [can close strong emphasis] iff + it is part of a [right-flanking delimiter run] + and either (a) not part of a [left-flanking delimiter run] + or (b) part of a [left-flanking delimiter run] + followed by punctuation. + +9. Emphasis begins with a delimiter that [can open emphasis] and ends + with a delimiter that [can close emphasis], and that uses the same + character (`_` or `*`) as the opening delimiter. The + opening and closing delimiters must belong to separate + [delimiter runs]. If one of the delimiters can both + open and close emphasis, then the sum of the lengths of the + delimiter runs containing the opening and closing delimiters + must not be a multiple of 3 unless both lengths are + multiples of 3. + +10. Strong emphasis begins with a delimiter that + [can open strong emphasis] and ends with a delimiter that + [can close strong emphasis], and that uses the same character + (`_` or `*`) as the opening delimiter. The + opening and closing delimiters must belong to separate + [delimiter runs]. If one of the delimiters can both open + and close strong emphasis, then the sum of the lengths of + the delimiter runs containing the opening and closing + delimiters must not be a multiple of 3 unless both lengths + are multiples of 3. + +11. A literal `*` character cannot occur at the beginning or end of + `*`-delimited emphasis or `**`-delimited strong emphasis, unless it + is backslash-escaped. + +12. A literal `_` character cannot occur at the beginning or end of + `_`-delimited emphasis or `__`-delimited strong emphasis, unless it + is backslash-escaped. + +Where rules 1--12 above are compatible with multiple parsings, +the following principles resolve ambiguity: + +13. The number of nestings should be minimized. Thus, for example, + an interpretation `...` is always preferred to + `...`. + +14. An interpretation `...` is always + preferred to `...`. + +15. When two potential emphasis or strong emphasis spans overlap, + so that the second begins before the first ends and ends after + the first ends, the first takes precedence. Thus, for example, + `*foo _bar* baz_` is parsed as `foo _bar baz_` rather + than `*foo bar* baz`. + +16. When there are two potential emphasis or strong emphasis spans + with the same closing delimiter, the shorter one (the one that + opens later) takes precedence. Thus, for example, + `**foo **bar baz**` is parsed as `**foo bar baz` + rather than `foo **bar baz`. + +17. Inline code spans, links, images, and HTML tags group more tightly + than emphasis. So, when there is a choice between an interpretation + that contains one of these elements and one that does not, the + former always wins. Thus, for example, `*[foo*](bar)` is + parsed as `*foo*` rather than as + `[foo](bar)`. + +These rules can be illustrated through a series of examples. + +Rule 1: + +```````````````````````````````` example +*foo bar* +. +

    foo bar

    +```````````````````````````````` + + +This is not emphasis, because the opening `*` is followed by +whitespace, and hence not part of a [left-flanking delimiter run]: + +```````````````````````````````` example +a * foo bar* +. +

    a * foo bar*

    +```````````````````````````````` + + +This is not emphasis, because the opening `*` is preceded +by an alphanumeric and followed by punctuation, and hence +not part of a [left-flanking delimiter run]: + +```````````````````````````````` example +a*"foo"* +. +

    a*"foo"*

    +```````````````````````````````` + + +Unicode nonbreaking spaces count as whitespace, too: + +```````````````````````````````` example +* a * +. +

    * a *

    +```````````````````````````````` + + +Intraword emphasis with `*` is permitted: + +```````````````````````````````` example +foo*bar* +. +

    foobar

    +```````````````````````````````` + + +```````````````````````````````` example +5*6*78 +. +

    5678

    +```````````````````````````````` + + +Rule 2: + +```````````````````````````````` example +_foo bar_ +. +

    foo bar

    +```````````````````````````````` + + +This is not emphasis, because the opening `_` is followed by +whitespace: + +```````````````````````````````` example +_ foo bar_ +. +

    _ foo bar_

    +```````````````````````````````` + + +This is not emphasis, because the opening `_` is preceded +by an alphanumeric and followed by punctuation: + +```````````````````````````````` example +a_"foo"_ +. +

    a_"foo"_

    +```````````````````````````````` + + +Emphasis with `_` is not allowed inside words: + +```````````````````````````````` example +foo_bar_ +. +

    foo_bar_

    +```````````````````````````````` + + +```````````````````````````````` example +5_6_78 +. +

    5_6_78

    +```````````````````````````````` + + +```````````````````````````````` example +пристаням_стремятся_ +. +

    пристаням_стремятся_

    +```````````````````````````````` + + +Here `_` does not generate emphasis, because the first delimiter run +is right-flanking and the second left-flanking: + +```````````````````````````````` example +aa_"bb"_cc +. +

    aa_"bb"_cc

    +```````````````````````````````` + + +This is emphasis, even though the opening delimiter is +both left- and right-flanking, because it is preceded by +punctuation: + +```````````````````````````````` example +foo-_(bar)_ +. +

    foo-(bar)

    +```````````````````````````````` + + +Rule 3: + +This is not emphasis, because the closing delimiter does +not match the opening delimiter: + +```````````````````````````````` example +_foo* +. +

    _foo*

    +```````````````````````````````` + + +This is not emphasis, because the closing `*` is preceded by +whitespace: + +```````````````````````````````` example +*foo bar * +. +

    *foo bar *

    +```````````````````````````````` + + +A newline also counts as whitespace: + +```````````````````````````````` example +*foo bar +* +. +

    *foo bar +*

    +```````````````````````````````` + + +This is not emphasis, because the second `*` is +preceded by punctuation and followed by an alphanumeric +(hence it is not part of a [right-flanking delimiter run]: + +```````````````````````````````` example +*(*foo) +. +

    *(*foo)

    +```````````````````````````````` + + +The point of this restriction is more easily appreciated +with this example: + +```````````````````````````````` example +*(*foo*)* +. +

    (foo)

    +```````````````````````````````` + + +Intraword emphasis with `*` is allowed: + +```````````````````````````````` example +*foo*bar +. +

    foobar

    +```````````````````````````````` + + + +Rule 4: + +This is not emphasis, because the closing `_` is preceded by +whitespace: + +```````````````````````````````` example +_foo bar _ +. +

    _foo bar _

    +```````````````````````````````` + + +This is not emphasis, because the second `_` is +preceded by punctuation and followed by an alphanumeric: + +```````````````````````````````` example +_(_foo) +. +

    _(_foo)

    +```````````````````````````````` + + +This is emphasis within emphasis: + +```````````````````````````````` example +_(_foo_)_ +. +

    (foo)

    +```````````````````````````````` + + +Intraword emphasis is disallowed for `_`: + +```````````````````````````````` example +_foo_bar +. +

    _foo_bar

    +```````````````````````````````` + + +```````````````````````````````` example +_пристаням_стремятся +. +

    _пристаням_стремятся

    +```````````````````````````````` + + +```````````````````````````````` example +_foo_bar_baz_ +. +

    foo_bar_baz

    +```````````````````````````````` + + +This is emphasis, even though the closing delimiter is +both left- and right-flanking, because it is followed by +punctuation: + +```````````````````````````````` example +_(bar)_. +. +

    (bar).

    +```````````````````````````````` + + +Rule 5: + +```````````````````````````````` example +**foo bar** +. +

    foo bar

    +```````````````````````````````` + + +This is not strong emphasis, because the opening delimiter is +followed by whitespace: + +```````````````````````````````` example +** foo bar** +. +

    ** foo bar**

    +```````````````````````````````` + + +This is not strong emphasis, because the opening `**` is preceded +by an alphanumeric and followed by punctuation, and hence +not part of a [left-flanking delimiter run]: + +```````````````````````````````` example +a**"foo"** +. +

    a**"foo"**

    +```````````````````````````````` + + +Intraword strong emphasis with `**` is permitted: + +```````````````````````````````` example +foo**bar** +. +

    foobar

    +```````````````````````````````` + + +Rule 6: + +```````````````````````````````` example +__foo bar__ +. +

    foo bar

    +```````````````````````````````` + + +This is not strong emphasis, because the opening delimiter is +followed by whitespace: + +```````````````````````````````` example +__ foo bar__ +. +

    __ foo bar__

    +```````````````````````````````` + + +A newline counts as whitespace: +```````````````````````````````` example +__ +foo bar__ +. +

    __ +foo bar__

    +```````````````````````````````` + + +This is not strong emphasis, because the opening `__` is preceded +by an alphanumeric and followed by punctuation: + +```````````````````````````````` example +a__"foo"__ +. +

    a__"foo"__

    +```````````````````````````````` + + +Intraword strong emphasis is forbidden with `__`: + +```````````````````````````````` example +foo__bar__ +. +

    foo__bar__

    +```````````````````````````````` + + +```````````````````````````````` example +5__6__78 +. +

    5__6__78

    +```````````````````````````````` + + +```````````````````````````````` example +пристаням__стремятся__ +. +

    пристаням__стремятся__

    +```````````````````````````````` + + +```````````````````````````````` example +__foo, __bar__, baz__ +. +

    foo, bar, baz

    +```````````````````````````````` + + +This is strong emphasis, even though the opening delimiter is +both left- and right-flanking, because it is preceded by +punctuation: + +```````````````````````````````` example +foo-__(bar)__ +. +

    foo-(bar)

    +```````````````````````````````` + + + +Rule 7: + +This is not strong emphasis, because the closing delimiter is preceded +by whitespace: + +```````````````````````````````` example +**foo bar ** +. +

    **foo bar **

    +```````````````````````````````` + + +(Nor can it be interpreted as an emphasized `*foo bar *`, because of +Rule 11.) + +This is not strong emphasis, because the second `**` is +preceded by punctuation and followed by an alphanumeric: + +```````````````````````````````` example +**(**foo) +. +

    **(**foo)

    +```````````````````````````````` + + +The point of this restriction is more easily appreciated +with these examples: + +```````````````````````````````` example +*(**foo**)* +. +

    (foo)

    +```````````````````````````````` + + +```````````````````````````````` example +**Gomphocarpus (*Gomphocarpus physocarpus*, syn. +*Asclepias physocarpa*)** +. +

    Gomphocarpus (Gomphocarpus physocarpus, syn. +Asclepias physocarpa)

    +```````````````````````````````` + + +```````````````````````````````` example +**foo "*bar*" foo** +. +

    foo "bar" foo

    +```````````````````````````````` + + +Intraword emphasis: + +```````````````````````````````` example +**foo**bar +. +

    foobar

    +```````````````````````````````` + + +Rule 8: + +This is not strong emphasis, because the closing delimiter is +preceded by whitespace: + +```````````````````````````````` example +__foo bar __ +. +

    __foo bar __

    +```````````````````````````````` + + +This is not strong emphasis, because the second `__` is +preceded by punctuation and followed by an alphanumeric: + +```````````````````````````````` example +__(__foo) +. +

    __(__foo)

    +```````````````````````````````` + + +The point of this restriction is more easily appreciated +with this example: + +```````````````````````````````` example +_(__foo__)_ +. +

    (foo)

    +```````````````````````````````` + + +Intraword strong emphasis is forbidden with `__`: + +```````````````````````````````` example +__foo__bar +. +

    __foo__bar

    +```````````````````````````````` + + +```````````````````````````````` example +__пристаням__стремятся +. +

    __пристаням__стремятся

    +```````````````````````````````` + + +```````````````````````````````` example +__foo__bar__baz__ +. +

    foo__bar__baz

    +```````````````````````````````` + + +This is strong emphasis, even though the closing delimiter is +both left- and right-flanking, because it is followed by +punctuation: + +```````````````````````````````` example +__(bar)__. +. +

    (bar).

    +```````````````````````````````` + + +Rule 9: + +Any nonempty sequence of inline elements can be the contents of an +emphasized span. + +```````````````````````````````` example +*foo [bar](/url)* +. +

    foo bar

    +```````````````````````````````` + + +```````````````````````````````` example +*foo +bar* +. +

    foo +bar

    +```````````````````````````````` + + +In particular, emphasis and strong emphasis can be nested +inside emphasis: + +```````````````````````````````` example +_foo __bar__ baz_ +. +

    foo bar baz

    +```````````````````````````````` + + +```````````````````````````````` example +_foo _bar_ baz_ +. +

    foo bar baz

    +```````````````````````````````` + + +```````````````````````````````` example +__foo_ bar_ +. +

    foo bar

    +```````````````````````````````` + + +```````````````````````````````` example +*foo *bar** +. +

    foo bar

    +```````````````````````````````` + + +```````````````````````````````` example +*foo **bar** baz* +. +

    foo bar baz

    +```````````````````````````````` + +```````````````````````````````` example +*foo**bar**baz* +. +

    foobarbaz

    +```````````````````````````````` + +Note that in the preceding case, the interpretation + +``` markdown +

    foobarbaz

    +``` + + +is precluded by the condition that a delimiter that +can both open and close (like the `*` after `foo`) +cannot form emphasis if the sum of the lengths of +the delimiter runs containing the opening and +closing delimiters is a multiple of 3 unless +both lengths are multiples of 3. + + +For the same reason, we don't get two consecutive +emphasis sections in this example: + +```````````````````````````````` example +*foo**bar* +. +

    foo**bar

    +```````````````````````````````` + + +The same condition ensures that the following +cases are all strong emphasis nested inside +emphasis, even when the interior spaces are +omitted: + + +```````````````````````````````` example +***foo** bar* +. +

    foo bar

    +```````````````````````````````` + + +```````````````````````````````` example +*foo **bar*** +. +

    foo bar

    +```````````````````````````````` + + +```````````````````````````````` example +*foo**bar*** +. +

    foobar

    +```````````````````````````````` + + +When the lengths of the interior closing and opening +delimiter runs are *both* multiples of 3, though, +they can match to create emphasis: + +```````````````````````````````` example +foo***bar***baz +. +

    foobarbaz

    +```````````````````````````````` + +```````````````````````````````` example +foo******bar*********baz +. +

    foobar***baz

    +```````````````````````````````` + + +Indefinite levels of nesting are possible: + +```````````````````````````````` example +*foo **bar *baz* bim** bop* +. +

    foo bar baz bim bop

    +```````````````````````````````` + + +```````````````````````````````` example +*foo [*bar*](/url)* +. +

    foo bar

    +```````````````````````````````` + + +There can be no empty emphasis or strong emphasis: + +```````````````````````````````` example +** is not an empty emphasis +. +

    ** is not an empty emphasis

    +```````````````````````````````` + + +```````````````````````````````` example +**** is not an empty strong emphasis +. +

    **** is not an empty strong emphasis

    +```````````````````````````````` + + + +Rule 10: + +Any nonempty sequence of inline elements can be the contents of an +strongly emphasized span. + +```````````````````````````````` example +**foo [bar](/url)** +. +

    foo bar

    +```````````````````````````````` + + +```````````````````````````````` example +**foo +bar** +. +

    foo +bar

    +```````````````````````````````` + + +In particular, emphasis and strong emphasis can be nested +inside strong emphasis: + +```````````````````````````````` example +__foo _bar_ baz__ +. +

    foo bar baz

    +```````````````````````````````` + + +```````````````````````````````` example +__foo __bar__ baz__ +. +

    foo bar baz

    +```````````````````````````````` + + +```````````````````````````````` example +____foo__ bar__ +. +

    foo bar

    +```````````````````````````````` + + +```````````````````````````````` example +**foo **bar**** +. +

    foo bar

    +```````````````````````````````` + + +```````````````````````````````` example +**foo *bar* baz** +. +

    foo bar baz

    +```````````````````````````````` + + +```````````````````````````````` example +**foo*bar*baz** +. +

    foobarbaz

    +```````````````````````````````` + + +```````````````````````````````` example +***foo* bar** +. +

    foo bar

    +```````````````````````````````` + + +```````````````````````````````` example +**foo *bar*** +. +

    foo bar

    +```````````````````````````````` + + +Indefinite levels of nesting are possible: + +```````````````````````````````` example +**foo *bar **baz** +bim* bop** +. +

    foo bar baz +bim bop

    +```````````````````````````````` + + +```````````````````````````````` example +**foo [*bar*](/url)** +. +

    foo bar

    +```````````````````````````````` + + +There can be no empty emphasis or strong emphasis: + +```````````````````````````````` example +__ is not an empty emphasis +. +

    __ is not an empty emphasis

    +```````````````````````````````` + + +```````````````````````````````` example +____ is not an empty strong emphasis +. +

    ____ is not an empty strong emphasis

    +```````````````````````````````` + + + +Rule 11: + +```````````````````````````````` example +foo *** +. +

    foo ***

    +```````````````````````````````` + + +```````````````````````````````` example +foo *\** +. +

    foo *

    +```````````````````````````````` + + +```````````````````````````````` example +foo *_* +. +

    foo _

    +```````````````````````````````` + + +```````````````````````````````` example +foo ***** +. +

    foo *****

    +```````````````````````````````` + + +```````````````````````````````` example +foo **\*** +. +

    foo *

    +```````````````````````````````` + + +```````````````````````````````` example +foo **_** +. +

    foo _

    +```````````````````````````````` + + +Note that when delimiters do not match evenly, Rule 11 determines +that the excess literal `*` characters will appear outside of the +emphasis, rather than inside it: + +```````````````````````````````` example +**foo* +. +

    *foo

    +```````````````````````````````` + + +```````````````````````````````` example +*foo** +. +

    foo*

    +```````````````````````````````` + + +```````````````````````````````` example +***foo** +. +

    *foo

    +```````````````````````````````` + + +```````````````````````````````` example +****foo* +. +

    ***foo

    +```````````````````````````````` + + +```````````````````````````````` example +**foo*** +. +

    foo*

    +```````````````````````````````` + + +```````````````````````````````` example +*foo**** +. +

    foo***

    +```````````````````````````````` + + + +Rule 12: + +```````````````````````````````` example +foo ___ +. +

    foo ___

    +```````````````````````````````` + + +```````````````````````````````` example +foo _\__ +. +

    foo _

    +```````````````````````````````` + + +```````````````````````````````` example +foo _*_ +. +

    foo *

    +```````````````````````````````` + + +```````````````````````````````` example +foo _____ +. +

    foo _____

    +```````````````````````````````` + + +```````````````````````````````` example +foo __\___ +. +

    foo _

    +```````````````````````````````` + + +```````````````````````````````` example +foo __*__ +. +

    foo *

    +```````````````````````````````` + + +```````````````````````````````` example +__foo_ +. +

    _foo

    +```````````````````````````````` + + +Note that when delimiters do not match evenly, Rule 12 determines +that the excess literal `_` characters will appear outside of the +emphasis, rather than inside it: + +```````````````````````````````` example +_foo__ +. +

    foo_

    +```````````````````````````````` + + +```````````````````````````````` example +___foo__ +. +

    _foo

    +```````````````````````````````` + + +```````````````````````````````` example +____foo_ +. +

    ___foo

    +```````````````````````````````` + + +```````````````````````````````` example +__foo___ +. +

    foo_

    +```````````````````````````````` + + +```````````````````````````````` example +_foo____ +. +

    foo___

    +```````````````````````````````` + + +Rule 13 implies that if you want emphasis nested directly inside +emphasis, you must use different delimiters: + +```````````````````````````````` example +**foo** +. +

    foo

    +```````````````````````````````` + + +```````````````````````````````` example +*_foo_* +. +

    foo

    +```````````````````````````````` + + +```````````````````````````````` example +__foo__ +. +

    foo

    +```````````````````````````````` + + +```````````````````````````````` example +_*foo*_ +. +

    foo

    +```````````````````````````````` + + +However, strong emphasis within strong emphasis is possible without +switching delimiters: + +```````````````````````````````` example +****foo**** +. +

    foo

    +```````````````````````````````` + + +```````````````````````````````` example +____foo____ +. +

    foo

    +```````````````````````````````` + + + +Rule 13 can be applied to arbitrarily long sequences of +delimiters: + +```````````````````````````````` example +******foo****** +. +

    foo

    +```````````````````````````````` + + +Rule 14: + +```````````````````````````````` example +***foo*** +. +

    foo

    +```````````````````````````````` + + +```````````````````````````````` example +_____foo_____ +. +

    foo

    +```````````````````````````````` + + +Rule 15: + +```````````````````````````````` example +*foo _bar* baz_ +. +

    foo _bar baz_

    +```````````````````````````````` + + +```````````````````````````````` example +*foo __bar *baz bim__ bam* +. +

    foo bar *baz bim bam

    +```````````````````````````````` + + +Rule 16: + +```````````````````````````````` example +**foo **bar baz** +. +

    **foo bar baz

    +```````````````````````````````` + + +```````````````````````````````` example +*foo *bar baz* +. +

    *foo bar baz

    +```````````````````````````````` + + +Rule 17: + +```````````````````````````````` example +*[bar*](/url) +. +

    *bar*

    +```````````````````````````````` + + +```````````````````````````````` example +_foo [bar_](/url) +. +

    _foo bar_

    +```````````````````````````````` + + +```````````````````````````````` example +* +. +

    *

    +```````````````````````````````` + + +```````````````````````````````` example +** +. +

    **

    +```````````````````````````````` + + +```````````````````````````````` example +__ +. +

    __

    +```````````````````````````````` + + +```````````````````````````````` example +*a `*`* +. +

    a *

    +```````````````````````````````` + + +```````````````````````````````` example +_a `_`_ +. +

    a _

    +```````````````````````````````` + + +```````````````````````````````` example +**a +. +

    **ahttp://foo.bar/?q=**

    +```````````````````````````````` + + +```````````````````````````````` example +__a +. +

    __ahttp://foo.bar/?q=__

    +```````````````````````````````` + + + +## Links + +A link contains [link text] (the visible text), a [link destination] +(the URI that is the link destination), and optionally a [link title]. +There are two basic kinds of links in Markdown. In [inline links] the +destination and title are given immediately after the link text. In +[reference links] the destination and title are defined elsewhere in +the document. + +A [link text](@) consists of a sequence of zero or more +inline elements enclosed by square brackets (`[` and `]`). The +following rules apply: + +- Links may not contain other links, at any level of nesting. If + multiple otherwise valid link definitions appear nested inside each + other, the inner-most definition is used. + +- Brackets are allowed in the [link text] only if (a) they + are backslash-escaped or (b) they appear as a matched pair of brackets, + with an open bracket `[`, a sequence of zero or more inlines, and + a close bracket `]`. + +- Backtick [code spans], [autolinks], and raw [HTML tags] bind more tightly + than the brackets in link text. Thus, for example, + `` [foo`]` `` could not be a link text, since the second `]` + is part of a code span. + +- The brackets in link text bind more tightly than markers for + [emphasis and strong emphasis]. Thus, for example, `*[foo*](url)` is a link. + +A [link destination](@) consists of either + +- a sequence of zero or more characters between an opening `<` and a + closing `>` that contains no line breaks or unescaped + `<` or `>` characters, or + +- a nonempty sequence of characters that does not start with + `<`, does not include ASCII space or control characters, and + includes parentheses only if (a) they are backslash-escaped or + (b) they are part of a balanced pair of unescaped parentheses. + (Implementations may impose limits on parentheses nesting to + avoid performance issues, but at least three levels of nesting + should be supported.) + +A [link title](@) consists of either + +- a sequence of zero or more characters between straight double-quote + characters (`"`), including a `"` character only if it is + backslash-escaped, or + +- a sequence of zero or more characters between straight single-quote + characters (`'`), including a `'` character only if it is + backslash-escaped, or + +- a sequence of zero or more characters between matching parentheses + (`(...)`), including a `(` or `)` character only if it is + backslash-escaped. + +Although [link titles] may span multiple lines, they may not contain +a [blank line]. + +An [inline link](@) consists of a [link text] followed immediately +by a left parenthesis `(`, optional [whitespace], an optional +[link destination], an optional [link title] separated from the link +destination by [whitespace], optional [whitespace], and a right +parenthesis `)`. The link's text consists of the inlines contained +in the [link text] (excluding the enclosing square brackets). +The link's URI consists of the link destination, excluding enclosing +`<...>` if present, with backslash-escapes in effect as described +above. The link's title consists of the link title, excluding its +enclosing delimiters, with backslash-escapes in effect as described +above. + +Here is a simple inline link: + +```````````````````````````````` example +[link](/uri "title") +. +

    link

    +```````````````````````````````` + + +The title may be omitted: + +```````````````````````````````` example +[link](/uri) +. +

    link

    +```````````````````````````````` + + +Both the title and the destination may be omitted: + +```````````````````````````````` example +[link]() +. +

    link

    +```````````````````````````````` + + +```````````````````````````````` example +[link](<>) +. +

    link

    +```````````````````````````````` + +The destination can only contain spaces if it is +enclosed in pointy brackets: + +```````````````````````````````` example +[link](/my uri) +. +

    [link](/my uri)

    +```````````````````````````````` + +```````````````````````````````` example +[link](
    ) +. +

    link

    +```````````````````````````````` + +The destination cannot contain line breaks, +even if enclosed in pointy brackets: + +```````````````````````````````` example +[link](foo +bar) +. +

    [link](foo +bar)

    +```````````````````````````````` + +```````````````````````````````` example +[link]() +. +

    [link]()

    +```````````````````````````````` + +The destination can contain `)` if it is enclosed +in pointy brackets: + +```````````````````````````````` example +[a]() +. +

    a

    +```````````````````````````````` + +Pointy brackets that enclose links must be unescaped: + +```````````````````````````````` example +[link]() +. +

    [link](<foo>)

    +```````````````````````````````` + +These are not links, because the opening pointy bracket +is not matched properly: + +```````````````````````````````` example +[a]( +[a](c) +. +

    [a](<b)c +[a](<b)c> +[a](c)

    +```````````````````````````````` + +Parentheses inside the link destination may be escaped: + +```````````````````````````````` example +[link](\(foo\)) +. +

    link

    +```````````````````````````````` + +Any number of parentheses are allowed without escaping, as long as they are +balanced: + +```````````````````````````````` example +[link](foo(and(bar))) +. +

    link

    +```````````````````````````````` + +However, if you have unbalanced parentheses, you need to escape or use the +`<...>` form: + +```````````````````````````````` example +[link](foo\(and\(bar\)) +. +

    link

    +```````````````````````````````` + + +```````````````````````````````` example +[link]() +. +

    link

    +```````````````````````````````` + + +Parentheses and other symbols can also be escaped, as usual +in Markdown: + +```````````````````````````````` example +[link](foo\)\:) +. +

    link

    +```````````````````````````````` + + +A link can contain fragment identifiers and queries: + +```````````````````````````````` example +[link](#fragment) + +[link](http://example.com#fragment) + +[link](http://example.com?foo=3#frag) +. +

    link

    +

    link

    +

    link

    +```````````````````````````````` + + +Note that a backslash before a non-escapable character is +just a backslash: + +```````````````````````````````` example +[link](foo\bar) +. +

    link

    +```````````````````````````````` + + +URL-escaping should be left alone inside the destination, as all +URL-escaped characters are also valid URL characters. Entity and +numerical character references in the destination will be parsed +into the corresponding Unicode code points, as usual. These may +be optionally URL-escaped when written as HTML, but this spec +does not enforce any particular policy for rendering URLs in +HTML or other formats. Renderers may make different decisions +about how to escape or normalize URLs in the output. + +```````````````````````````````` example +[link](foo%20bä) +. +

    link

    +```````````````````````````````` + + +Note that, because titles can often be parsed as destinations, +if you try to omit the destination and keep the title, you'll +get unexpected results: + +```````````````````````````````` example +[link]("title") +. +

    link

    +```````````````````````````````` + + +Titles may be in single quotes, double quotes, or parentheses: + +```````````````````````````````` example +[link](/url "title") +[link](/url 'title') +[link](/url (title)) +. +

    link +link +link

    +```````````````````````````````` + + +Backslash escapes and entity and numeric character references +may be used in titles: + +```````````````````````````````` example +[link](/url "title \""") +. +

    link

    +```````````````````````````````` + + +Titles must be separated from the link using a [whitespace]. +Other [Unicode whitespace] like non-breaking space doesn't work. + +```````````````````````````````` example +[link](/url "title") +. +

    link

    +```````````````````````````````` + + +Nested balanced quotes are not allowed without escaping: + +```````````````````````````````` example +[link](/url "title "and" title") +. +

    [link](/url "title "and" title")

    +```````````````````````````````` + + +But it is easy to work around this by using a different quote type: + +```````````````````````````````` example +[link](/url 'title "and" title') +. +

    link

    +```````````````````````````````` + + +(Note: `Markdown.pl` did allow double quotes inside a double-quoted +title, and its test suite included a test demonstrating this. +But it is hard to see a good rationale for the extra complexity this +brings, since there are already many ways---backslash escaping, +entity and numeric character references, or using a different +quote type for the enclosing title---to write titles containing +double quotes. `Markdown.pl`'s handling of titles has a number +of other strange features. For example, it allows single-quoted +titles in inline links, but not reference links. And, in +reference links but not inline links, it allows a title to begin +with `"` and end with `)`. `Markdown.pl` 1.0.1 even allows +titles with no closing quotation mark, though 1.0.2b8 does not. +It seems preferable to adopt a simple, rational rule that works +the same way in inline links and link reference definitions.) + +[Whitespace] is allowed around the destination and title: + +```````````````````````````````` example +[link]( /uri + "title" ) +. +

    link

    +```````````````````````````````` + + +But it is not allowed between the link text and the +following parenthesis: + +```````````````````````````````` example +[link] (/uri) +. +

    [link] (/uri)

    +```````````````````````````````` + + +The link text may contain balanced brackets, but not unbalanced ones, +unless they are escaped: + +```````````````````````````````` example +[link [foo [bar]]](/uri) +. +

    link [foo [bar]]

    +```````````````````````````````` + + +```````````````````````````````` example +[link] bar](/uri) +. +

    [link] bar](/uri)

    +```````````````````````````````` + + +```````````````````````````````` example +[link [bar](/uri) +. +

    [link bar

    +```````````````````````````````` + + +```````````````````````````````` example +[link \[bar](/uri) +. +

    link [bar

    +```````````````````````````````` + + +The link text may contain inline content: + +```````````````````````````````` example +[link *foo **bar** `#`*](/uri) +. +

    link foo bar #

    +```````````````````````````````` + + +```````````````````````````````` example +[![moon](moon.jpg)](/uri) +. +

    moon

    +```````````````````````````````` + + +However, links may not contain other links, at any level of nesting. + +```````````````````````````````` example +[foo [bar](/uri)](/uri) +. +

    [foo bar](/uri)

    +```````````````````````````````` + + +```````````````````````````````` example +[foo *[bar [baz](/uri)](/uri)*](/uri) +. +

    [foo [bar baz](/uri)](/uri)

    +```````````````````````````````` + + +```````````````````````````````` example +![[[foo](uri1)](uri2)](uri3) +. +

    [foo](uri2)

    +```````````````````````````````` + + +These cases illustrate the precedence of link text grouping over +emphasis grouping: + +```````````````````````````````` example +*[foo*](/uri) +. +

    *foo*

    +```````````````````````````````` + + +```````````````````````````````` example +[foo *bar](baz*) +. +

    foo *bar

    +```````````````````````````````` + + +Note that brackets that *aren't* part of links do not take +precedence: + +```````````````````````````````` example +*foo [bar* baz] +. +

    foo [bar baz]

    +```````````````````````````````` + + +These cases illustrate the precedence of HTML tags, code spans, +and autolinks over link grouping: + +```````````````````````````````` example +[foo +. +

    [foo

    +```````````````````````````````` + + +```````````````````````````````` example +[foo`](/uri)` +. +

    [foo](/uri)

    +```````````````````````````````` + + +```````````````````````````````` example +[foo +. +

    [foohttp://example.com/?search=](uri)

    +```````````````````````````````` + + +There are three kinds of [reference link](@)s: +[full](#full-reference-link), [collapsed](#collapsed-reference-link), +and [shortcut](#shortcut-reference-link). + +A [full reference link](@) +consists of a [link text] immediately followed by a [link label] +that [matches] a [link reference definition] elsewhere in the document. + +A [link label](@) begins with a left bracket (`[`) and ends +with the first right bracket (`]`) that is not backslash-escaped. +Between these brackets there must be at least one [non-whitespace character]. +Unescaped square bracket characters are not allowed inside the +opening and closing square brackets of [link labels]. A link +label can have at most 999 characters inside the square +brackets. + +One label [matches](@) +another just in case their normalized forms are equal. To normalize a +label, strip off the opening and closing brackets, +perform the *Unicode case fold*, strip leading and trailing +[whitespace] and collapse consecutive internal +[whitespace] to a single space. If there are multiple +matching reference link definitions, the one that comes first in the +document is used. (It is desirable in such cases to emit a warning.) + +The contents of the first link label are parsed as inlines, which are +used as the link's text. The link's URI and title are provided by the +matching [link reference definition]. + +Here is a simple example: + +```````````````````````````````` example +[foo][bar] + +[bar]: /url "title" +. +

    foo

    +```````````````````````````````` + + +The rules for the [link text] are the same as with +[inline links]. Thus: + +The link text may contain balanced brackets, but not unbalanced ones, +unless they are escaped: + +```````````````````````````````` example +[link [foo [bar]]][ref] + +[ref]: /uri +. +

    link [foo [bar]]

    +```````````````````````````````` + + +```````````````````````````````` example +[link \[bar][ref] + +[ref]: /uri +. +

    link [bar

    +```````````````````````````````` + + +The link text may contain inline content: + +```````````````````````````````` example +[link *foo **bar** `#`*][ref] + +[ref]: /uri +. +

    link foo bar #

    +```````````````````````````````` + + +```````````````````````````````` example +[![moon](moon.jpg)][ref] + +[ref]: /uri +. +

    moon

    +```````````````````````````````` + + +However, links may not contain other links, at any level of nesting. + +```````````````````````````````` example +[foo [bar](/uri)][ref] + +[ref]: /uri +. +

    [foo bar]ref

    +```````````````````````````````` + + +```````````````````````````````` example +[foo *bar [baz][ref]*][ref] + +[ref]: /uri +. +

    [foo bar baz]ref

    +```````````````````````````````` + + +(In the examples above, we have two [shortcut reference links] +instead of one [full reference link].) + +The following cases illustrate the precedence of link text grouping over +emphasis grouping: + +```````````````````````````````` example +*[foo*][ref] + +[ref]: /uri +. +

    *foo*

    +```````````````````````````````` + + +```````````````````````````````` example +[foo *bar][ref] + +[ref]: /uri +. +

    foo *bar

    +```````````````````````````````` + + +These cases illustrate the precedence of HTML tags, code spans, +and autolinks over link grouping: + +```````````````````````````````` example +[foo + +[ref]: /uri +. +

    [foo

    +```````````````````````````````` + + +```````````````````````````````` example +[foo`][ref]` + +[ref]: /uri +. +

    [foo][ref]

    +```````````````````````````````` + + +```````````````````````````````` example +[foo + +[ref]: /uri +. +

    [foohttp://example.com/?search=][ref]

    +```````````````````````````````` + + +Matching is case-insensitive: + +```````````````````````````````` example +[foo][BaR] + +[bar]: /url "title" +. +

    foo

    +```````````````````````````````` + + +Unicode case fold is used: + +```````````````````````````````` example +[Толпой][Толпой] is a Russian word. + +[ТОЛПОЙ]: /url +. +

    Толпой is a Russian word.

    +```````````````````````````````` + + +Consecutive internal [whitespace] is treated as one space for +purposes of determining matching: + +```````````````````````````````` example +[Foo + bar]: /url + +[Baz][Foo bar] +. +

    Baz

    +```````````````````````````````` + + +No [whitespace] is allowed between the [link text] and the +[link label]: + +```````````````````````````````` example +[foo] [bar] + +[bar]: /url "title" +. +

    [foo] bar

    +```````````````````````````````` + + +```````````````````````````````` example +[foo] +[bar] + +[bar]: /url "title" +. +

    [foo] +bar

    +```````````````````````````````` + + +This is a departure from John Gruber's original Markdown syntax +description, which explicitly allows whitespace between the link +text and the link label. It brings reference links in line with +[inline links], which (according to both original Markdown and +this spec) cannot have whitespace after the link text. More +importantly, it prevents inadvertent capture of consecutive +[shortcut reference links]. If whitespace is allowed between the +link text and the link label, then in the following we will have +a single reference link, not two shortcut reference links, as +intended: + +``` markdown +[foo] +[bar] + +[foo]: /url1 +[bar]: /url2 +``` + +(Note that [shortcut reference links] were introduced by Gruber +himself in a beta version of `Markdown.pl`, but never included +in the official syntax description. Without shortcut reference +links, it is harmless to allow space between the link text and +link label; but once shortcut references are introduced, it is +too dangerous to allow this, as it frequently leads to +unintended results.) + +When there are multiple matching [link reference definitions], +the first is used: + +```````````````````````````````` example +[foo]: /url1 + +[foo]: /url2 + +[bar][foo] +. +

    bar

    +```````````````````````````````` + + +Note that matching is performed on normalized strings, not parsed +inline content. So the following does not match, even though the +labels define equivalent inline content: + +```````````````````````````````` example +[bar][foo\!] + +[foo!]: /url +. +

    [bar][foo!]

    +```````````````````````````````` + + +[Link labels] cannot contain brackets, unless they are +backslash-escaped: + +```````````````````````````````` example +[foo][ref[] + +[ref[]: /uri +. +

    [foo][ref[]

    +

    [ref[]: /uri

    +```````````````````````````````` + + +```````````````````````````````` example +[foo][ref[bar]] + +[ref[bar]]: /uri +. +

    [foo][ref[bar]]

    +

    [ref[bar]]: /uri

    +```````````````````````````````` + + +```````````````````````````````` example +[[[foo]]] + +[[[foo]]]: /url +. +

    [[[foo]]]

    +

    [[[foo]]]: /url

    +```````````````````````````````` + + +```````````````````````````````` example +[foo][ref\[] + +[ref\[]: /uri +. +

    foo

    +```````````````````````````````` + + +Note that in this example `]` is not backslash-escaped: + +```````````````````````````````` example +[bar\\]: /uri + +[bar\\] +. +

    bar\

    +```````````````````````````````` + + +A [link label] must contain at least one [non-whitespace character]: + +```````````````````````````````` example +[] + +[]: /uri +. +

    []

    +

    []: /uri

    +```````````````````````````````` + + +```````````````````````````````` example +[ + ] + +[ + ]: /uri +. +

    [ +]

    +

    [ +]: /uri

    +```````````````````````````````` + + +A [collapsed reference link](@) +consists of a [link label] that [matches] a +[link reference definition] elsewhere in the +document, followed by the string `[]`. +The contents of the first link label are parsed as inlines, +which are used as the link's text. The link's URI and title are +provided by the matching reference link definition. Thus, +`[foo][]` is equivalent to `[foo][foo]`. + +```````````````````````````````` example +[foo][] + +[foo]: /url "title" +. +

    foo

    +```````````````````````````````` + + +```````````````````````````````` example +[*foo* bar][] + +[*foo* bar]: /url "title" +. +

    foo bar

    +```````````````````````````````` + + +The link labels are case-insensitive: + +```````````````````````````````` example +[Foo][] + +[foo]: /url "title" +. +

    Foo

    +```````````````````````````````` + + + +As with full reference links, [whitespace] is not +allowed between the two sets of brackets: + +```````````````````````````````` example +[foo] +[] + +[foo]: /url "title" +. +

    foo +[]

    +```````````````````````````````` + + +A [shortcut reference link](@) +consists of a [link label] that [matches] a +[link reference definition] elsewhere in the +document and is not followed by `[]` or a link label. +The contents of the first link label are parsed as inlines, +which are used as the link's text. The link's URI and title +are provided by the matching link reference definition. +Thus, `[foo]` is equivalent to `[foo][]`. + +```````````````````````````````` example +[foo] + +[foo]: /url "title" +. +

    foo

    +```````````````````````````````` + + +```````````````````````````````` example +[*foo* bar] + +[*foo* bar]: /url "title" +. +

    foo bar

    +```````````````````````````````` + + +```````````````````````````````` example +[[*foo* bar]] + +[*foo* bar]: /url "title" +. +

    [foo bar]

    +```````````````````````````````` + + +```````````````````````````````` example +[[bar [foo] + +[foo]: /url +. +

    [[bar foo

    +```````````````````````````````` + + +The link labels are case-insensitive: + +```````````````````````````````` example +[Foo] + +[foo]: /url "title" +. +

    Foo

    +```````````````````````````````` + + +A space after the link text should be preserved: + +```````````````````````````````` example +[foo] bar + +[foo]: /url +. +

    foo bar

    +```````````````````````````````` + + +If you just want bracketed text, you can backslash-escape the +opening bracket to avoid links: + +```````````````````````````````` example +\[foo] + +[foo]: /url "title" +. +

    [foo]

    +```````````````````````````````` + + +Note that this is a link, because a link label ends with the first +following closing bracket: + +```````````````````````````````` example +[foo*]: /url + +*[foo*] +. +

    *foo*

    +```````````````````````````````` + + +Full and compact references take precedence over shortcut +references: + +```````````````````````````````` example +[foo][bar] + +[foo]: /url1 +[bar]: /url2 +. +

    foo

    +```````````````````````````````` + +```````````````````````````````` example +[foo][] + +[foo]: /url1 +. +

    foo

    +```````````````````````````````` + +Inline links also take precedence: + +```````````````````````````````` example +[foo]() + +[foo]: /url1 +. +

    foo

    +```````````````````````````````` + +```````````````````````````````` example +[foo](not a link) + +[foo]: /url1 +. +

    foo(not a link)

    +```````````````````````````````` + +In the following case `[bar][baz]` is parsed as a reference, +`[foo]` as normal text: + +```````````````````````````````` example +[foo][bar][baz] + +[baz]: /url +. +

    [foo]bar

    +```````````````````````````````` + + +Here, though, `[foo][bar]` is parsed as a reference, since +`[bar]` is defined: + +```````````````````````````````` example +[foo][bar][baz] + +[baz]: /url1 +[bar]: /url2 +. +

    foobaz

    +```````````````````````````````` + + +Here `[foo]` is not parsed as a shortcut reference, because it +is followed by a link label (even though `[bar]` is not defined): + +```````````````````````````````` example +[foo][bar][baz] + +[baz]: /url1 +[foo]: /url2 +. +

    [foo]bar

    +```````````````````````````````` + + + +## Images + +Syntax for images is like the syntax for links, with one +difference. Instead of [link text], we have an +[image description](@). The rules for this are the +same as for [link text], except that (a) an +image description starts with `![` rather than `[`, and +(b) an image description may contain links. +An image description has inline elements +as its contents. When an image is rendered to HTML, +this is standardly used as the image's `alt` attribute. + +```````````````````````````````` example +![foo](/url "title") +. +

    foo

    +```````````````````````````````` + + +```````````````````````````````` example +![foo *bar*] + +[foo *bar*]: train.jpg "train & tracks" +. +

    foo bar

    +```````````````````````````````` + + +```````````````````````````````` example +![foo ![bar](/url)](/url2) +. +

    foo bar

    +```````````````````````````````` + + +```````````````````````````````` example +![foo [bar](/url)](/url2) +. +

    foo bar

    +```````````````````````````````` + + +Though this spec is concerned with parsing, not rendering, it is +recommended that in rendering to HTML, only the plain string content +of the [image description] be used. Note that in +the above example, the alt attribute's value is `foo bar`, not `foo +[bar](/url)` or `foo bar`. Only the plain string +content is rendered, without formatting. + +```````````````````````````````` example +![foo *bar*][] + +[foo *bar*]: train.jpg "train & tracks" +. +

    foo bar

    +```````````````````````````````` + + +```````````````````````````````` example +![foo *bar*][foobar] + +[FOOBAR]: train.jpg "train & tracks" +. +

    foo bar

    +```````````````````````````````` + + +```````````````````````````````` example +![foo](train.jpg) +. +

    foo

    +```````````````````````````````` + + +```````````````````````````````` example +My ![foo bar](/path/to/train.jpg "title" ) +. +

    My foo bar

    +```````````````````````````````` + + +```````````````````````````````` example +![foo]() +. +

    foo

    +```````````````````````````````` + + +```````````````````````````````` example +![](/url) +. +

    +```````````````````````````````` + + +Reference-style: + +```````````````````````````````` example +![foo][bar] + +[bar]: /url +. +

    foo

    +```````````````````````````````` + + +```````````````````````````````` example +![foo][bar] + +[BAR]: /url +. +

    foo

    +```````````````````````````````` + + +Collapsed: + +```````````````````````````````` example +![foo][] + +[foo]: /url "title" +. +

    foo

    +```````````````````````````````` + + +```````````````````````````````` example +![*foo* bar][] + +[*foo* bar]: /url "title" +. +

    foo bar

    +```````````````````````````````` + + +The labels are case-insensitive: + +```````````````````````````````` example +![Foo][] + +[foo]: /url "title" +. +

    Foo

    +```````````````````````````````` + + +As with reference links, [whitespace] is not allowed +between the two sets of brackets: + +```````````````````````````````` example +![foo] +[] + +[foo]: /url "title" +. +

    foo +[]

    +```````````````````````````````` + + +Shortcut: + +```````````````````````````````` example +![foo] + +[foo]: /url "title" +. +

    foo

    +```````````````````````````````` + + +```````````````````````````````` example +![*foo* bar] + +[*foo* bar]: /url "title" +. +

    foo bar

    +```````````````````````````````` + + +Note that link labels cannot contain unescaped brackets: + +```````````````````````````````` example +![[foo]] + +[[foo]]: /url "title" +. +

    ![[foo]]

    +

    [[foo]]: /url "title"

    +```````````````````````````````` + + +The link labels are case-insensitive: + +```````````````````````````````` example +![Foo] + +[foo]: /url "title" +. +

    Foo

    +```````````````````````````````` + + +If you just want a literal `!` followed by bracketed text, you can +backslash-escape the opening `[`: + +```````````````````````````````` example +!\[foo] + +[foo]: /url "title" +. +

    ![foo]

    +```````````````````````````````` + + +If you want a link after a literal `!`, backslash-escape the +`!`: + +```````````````````````````````` example +\![foo] + +[foo]: /url "title" +. +

    !foo

    +```````````````````````````````` + + +## Autolinks + +[Autolink](@)s are absolute URIs and email addresses inside +`<` and `>`. They are parsed as links, with the URL or email address +as the link label. + +A [URI autolink](@) consists of `<`, followed by an +[absolute URI] followed by `>`. It is parsed as +a link to the URI, with the URI as the link's label. + +An [absolute URI](@), +for these purposes, consists of a [scheme] followed by a colon (`:`) +followed by zero or more characters other than ASCII +[whitespace] and control characters, `<`, and `>`. If +the URI includes these characters, they must be percent-encoded +(e.g. `%20` for a space). + +For purposes of this spec, a [scheme](@) is any sequence +of 2--32 characters beginning with an ASCII letter and followed +by any combination of ASCII letters, digits, or the symbols plus +("+"), period ("."), or hyphen ("-"). + +Here are some valid autolinks: + +```````````````````````````````` example + +. +

    http://foo.bar.baz

    +```````````````````````````````` + + +```````````````````````````````` example + +. +

    http://foo.bar.baz/test?q=hello&id=22&boolean

    +```````````````````````````````` + + +```````````````````````````````` example + +. +

    irc://foo.bar:2233/baz

    +```````````````````````````````` + + +Uppercase is also fine: + +```````````````````````````````` example + +. +

    MAILTO:FOO@BAR.BAZ

    +```````````````````````````````` + + +Note that many strings that count as [absolute URIs] for +purposes of this spec are not valid URIs, because their +schemes are not registered or because of other problems +with their syntax: + +```````````````````````````````` example + +. +

    a+b+c:d

    +```````````````````````````````` + + +```````````````````````````````` example + +. +

    made-up-scheme://foo,bar

    +```````````````````````````````` + + +```````````````````````````````` example + +. +

    http://../

    +```````````````````````````````` + + +```````````````````````````````` example + +. +

    localhost:5001/foo

    +```````````````````````````````` + + +Spaces are not allowed in autolinks: + +```````````````````````````````` example + +. +

    <http://foo.bar/baz bim>

    +```````````````````````````````` + + +Backslash-escapes do not work inside autolinks: + +```````````````````````````````` example + +. +

    http://example.com/\[\

    +```````````````````````````````` + + +An [email autolink](@) +consists of `<`, followed by an [email address], +followed by `>`. The link's label is the email address, +and the URL is `mailto:` followed by the email address. + +An [email address](@), +for these purposes, is anything that matches +the [non-normative regex from the HTML5 +spec](https://html.spec.whatwg.org/multipage/forms.html#e-mail-state-(type=email)): + + /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])? + (?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/ + +Examples of email autolinks: + +```````````````````````````````` example + +. +

    foo@bar.example.com

    +```````````````````````````````` + + +```````````````````````````````` example + +. +

    foo+special@Bar.baz-bar0.com

    +```````````````````````````````` + + +Backslash-escapes do not work inside email autolinks: + +```````````````````````````````` example + +. +

    <foo+@bar.example.com>

    +```````````````````````````````` + + +These are not autolinks: + +```````````````````````````````` example +<> +. +

    <>

    +```````````````````````````````` + + +```````````````````````````````` example +< http://foo.bar > +. +

    < http://foo.bar >

    +```````````````````````````````` + + +```````````````````````````````` example + +. +

    <m:abc>

    +```````````````````````````````` + + +```````````````````````````````` example + +. +

    <foo.bar.baz>

    +```````````````````````````````` + + +```````````````````````````````` example +http://example.com +. +

    http://example.com

    +```````````````````````````````` + + +```````````````````````````````` example +foo@bar.example.com +. +

    foo@bar.example.com

    +```````````````````````````````` + + +## Raw HTML + +Text between `<` and `>` that looks like an HTML tag is parsed as a +raw HTML tag and will be rendered in HTML without escaping. +Tag and attribute names are not limited to current HTML tags, +so custom tags (and even, say, DocBook tags) may be used. + +Here is the grammar for tags: + +A [tag name](@) consists of an ASCII letter +followed by zero or more ASCII letters, digits, or +hyphens (`-`). + +An [attribute](@) consists of [whitespace], +an [attribute name], and an optional +[attribute value specification]. + +An [attribute name](@) +consists of an ASCII letter, `_`, or `:`, followed by zero or more ASCII +letters, digits, `_`, `.`, `:`, or `-`. (Note: This is the XML +specification restricted to ASCII. HTML5 is laxer.) + +An [attribute value specification](@) +consists of optional [whitespace], +a `=` character, optional [whitespace], and an [attribute +value]. + +An [attribute value](@) +consists of an [unquoted attribute value], +a [single-quoted attribute value], or a [double-quoted attribute value]. + +An [unquoted attribute value](@) +is a nonempty string of characters not +including [whitespace], `"`, `'`, `=`, `<`, `>`, or `` ` ``. + +A [single-quoted attribute value](@) +consists of `'`, zero or more +characters not including `'`, and a final `'`. + +A [double-quoted attribute value](@) +consists of `"`, zero or more +characters not including `"`, and a final `"`. + +An [open tag](@) consists of a `<` character, a [tag name], +zero or more [attributes], optional [whitespace], an optional `/` +character, and a `>` character. + +A [closing tag](@) consists of the string ``. + +An [HTML comment](@) consists of ``, +where *text* does not start with `>` or `->`, does not end with `-`, +and does not contain `--`. (See the +[HTML5 spec](http://www.w3.org/TR/html5/syntax.html#comments).) + +A [processing instruction](@) +consists of the string ``, and the string +`?>`. + +A [declaration](@) consists of the +string ``, and the character `>`. + +A [CDATA section](@) consists of +the string ``, and the string `]]>`. + +An [HTML tag](@) consists of an [open tag], a [closing tag], +an [HTML comment], a [processing instruction], a [declaration], +or a [CDATA section]. + +Here are some simple open tags: + +```````````````````````````````` example + +. +

    +```````````````````````````````` + + +Empty elements: + +```````````````````````````````` example + +. +

    +```````````````````````````````` + + +[Whitespace] is allowed: + +```````````````````````````````` example + +. +

    +```````````````````````````````` + + +With attributes: + +```````````````````````````````` example + +. +

    +```````````````````````````````` + + +Custom tag names can be used: + +```````````````````````````````` example +Foo +. +

    Foo

    +```````````````````````````````` + + +Illegal tag names, not parsed as HTML: + +```````````````````````````````` example +<33> <__> +. +

    <33> <__>

    +```````````````````````````````` + + +Illegal attribute names: + +```````````````````````````````` example +
    +. +

    <a h*#ref="hi">

    +```````````````````````````````` + + +Illegal attribute values: + +```````````````````````````````` example +
    +. +

    </a href="foo">

    +```````````````````````````````` + + +Comments: + +```````````````````````````````` example +foo +. +

    foo

    +```````````````````````````````` + + +```````````````````````````````` example +foo +. +

    foo <!-- not a comment -- two hyphens -->

    +```````````````````````````````` + + +Not comments: + +```````````````````````````````` example +foo foo --> + +foo +. +

    foo <!--> foo -->

    +

    foo <!-- foo--->

    +```````````````````````````````` + + +Processing instructions: + +```````````````````````````````` example +foo +. +

    foo

    +```````````````````````````````` + + +Declarations: + +```````````````````````````````` example +foo +. +

    foo

    +```````````````````````````````` + + +CDATA sections: + +```````````````````````````````` example +foo &<]]> +. +

    foo &<]]>

    +```````````````````````````````` + + +Entity and numeric character references are preserved in HTML +attributes: + +```````````````````````````````` example +foo
    +. +

    foo

    +```````````````````````````````` + + +Backslash escapes do not work in HTML attributes: + +```````````````````````````````` example +foo +. +

    foo

    +```````````````````````````````` + + +```````````````````````````````` example + +. +

    <a href=""">

    +```````````````````````````````` + + +## Hard line breaks + +A line break (not in a code span or HTML tag) that is preceded +by two or more spaces and does not occur at the end of a block +is parsed as a [hard line break](@) (rendered +in HTML as a `
    ` tag): + +```````````````````````````````` example +foo +baz +. +

    foo
    +baz

    +```````````````````````````````` + + +For a more visible alternative, a backslash before the +[line ending] may be used instead of two spaces: + +```````````````````````````````` example +foo\ +baz +. +

    foo
    +baz

    +```````````````````````````````` + + +More than two spaces can be used: + +```````````````````````````````` example +foo +baz +. +

    foo
    +baz

    +```````````````````````````````` + + +Leading spaces at the beginning of the next line are ignored: + +```````````````````````````````` example +foo + bar +. +

    foo
    +bar

    +```````````````````````````````` + + +```````````````````````````````` example +foo\ + bar +. +

    foo
    +bar

    +```````````````````````````````` + + +Line breaks can occur inside emphasis, links, and other constructs +that allow inline content: + +```````````````````````````````` example +*foo +bar* +. +

    foo
    +bar

    +```````````````````````````````` + + +```````````````````````````````` example +*foo\ +bar* +. +

    foo
    +bar

    +```````````````````````````````` + + +Line breaks do not occur inside code spans + +```````````````````````````````` example +`code +span` +. +

    code span

    +```````````````````````````````` + + +```````````````````````````````` example +`code\ +span` +. +

    code\ span

    +```````````````````````````````` + + +or HTML tags: + +```````````````````````````````` example +
    +. +

    +```````````````````````````````` + + +```````````````````````````````` example + +. +

    +```````````````````````````````` + + +Hard line breaks are for separating inline content within a block. +Neither syntax for hard line breaks works at the end of a paragraph or +other block element: + +```````````````````````````````` example +foo\ +. +

    foo\

    +```````````````````````````````` + + +```````````````````````````````` example +foo +. +

    foo

    +```````````````````````````````` + + +```````````````````````````````` example +### foo\ +. +

    foo\

    +```````````````````````````````` + + +```````````````````````````````` example +### foo +. +

    foo

    +```````````````````````````````` + + +## Soft line breaks + +A regular line break (not in a code span or HTML tag) that is not +preceded by two or more spaces or a backslash is parsed as a +[softbreak](@). (A softbreak may be rendered in HTML either as a +[line ending] or as a space. The result will be the same in +browsers. In the examples here, a [line ending] will be used.) + +```````````````````````````````` example +foo +baz +. +

    foo +baz

    +```````````````````````````````` + + +Spaces at the end of the line and beginning of the next line are +removed: + +```````````````````````````````` example +foo + baz +. +

    foo +baz

    +```````````````````````````````` + + +A conforming parser may render a soft line break in HTML either as a +line break or as a space. + +A renderer may also provide an option to render soft line breaks +as hard line breaks. + +## Textual content + +Any characters not given an interpretation by the above rules will +be parsed as plain textual content. + +```````````````````````````````` example +hello $.;'there +. +

    hello $.;'there

    +```````````````````````````````` + + +```````````````````````````````` example +Foo χρῆν +. +

    Foo χρῆν

    +```````````````````````````````` + + +Internal spaces are preserved verbatim: + +```````````````````````````````` example +Multiple spaces +. +

    Multiple spaces

    +```````````````````````````````` + + + + +# Appendix: A parsing strategy + +In this appendix we describe some features of the parsing strategy +used in the CommonMark reference implementations. + +## Overview + +Parsing has two phases: + +1. In the first phase, lines of input are consumed and the block +structure of the document---its division into paragraphs, block quotes, +list items, and so on---is constructed. Text is assigned to these +blocks but not parsed. Link reference definitions are parsed and a +map of links is constructed. + +2. In the second phase, the raw text contents of paragraphs and headings +are parsed into sequences of Markdown inline elements (strings, +code spans, links, emphasis, and so on), using the map of link +references constructed in phase 1. + +At each point in processing, the document is represented as a tree of +**blocks**. The root of the tree is a `document` block. The `document` +may have any number of other blocks as **children**. These children +may, in turn, have other blocks as children. The last child of a block +is normally considered **open**, meaning that subsequent lines of input +can alter its contents. (Blocks that are not open are **closed**.) +Here, for example, is a possible document tree, with the open blocks +marked by arrows: + +``` tree +-> document + -> block_quote + paragraph + "Lorem ipsum dolor\nsit amet." + -> list (type=bullet tight=true bullet_char=-) + list_item + paragraph + "Qui *quodsi iracundia*" + -> list_item + -> paragraph + "aliquando id" +``` + +## Phase 1: block structure + +Each line that is processed has an effect on this tree. The line is +analyzed and, depending on its contents, the document may be altered +in one or more of the following ways: + +1. One or more open blocks may be closed. +2. One or more new blocks may be created as children of the + last open block. +3. Text may be added to the last (deepest) open block remaining + on the tree. + +Once a line has been incorporated into the tree in this way, +it can be discarded, so input can be read in a stream. + +For each line, we follow this procedure: + +1. First we iterate through the open blocks, starting with the +root document, and descending through last children down to the last +open block. Each block imposes a condition that the line must satisfy +if the block is to remain open. For example, a block quote requires a +`>` character. A paragraph requires a non-blank line. +In this phase we may match all or just some of the open +blocks. But we cannot close unmatched blocks yet, because we may have a +[lazy continuation line]. + +2. Next, after consuming the continuation markers for existing +blocks, we look for new block starts (e.g. `>` for a block quote). +If we encounter a new block start, we close any blocks unmatched +in step 1 before creating the new block as a child of the last +matched block. + +3. Finally, we look at the remainder of the line (after block +markers like `>`, list markers, and indentation have been consumed). +This is text that can be incorporated into the last open +block (a paragraph, code block, heading, or raw HTML). + +Setext headings are formed when we see a line of a paragraph +that is a [setext heading underline]. + +Reference link definitions are detected when a paragraph is closed; +the accumulated text lines are parsed to see if they begin with +one or more reference link definitions. Any remainder becomes a +normal paragraph. + +We can see how this works by considering how the tree above is +generated by four lines of Markdown: + +``` markdown +> Lorem ipsum dolor +sit amet. +> - Qui *quodsi iracundia* +> - aliquando id +``` + +At the outset, our document model is just + +``` tree +-> document +``` + +The first line of our text, + +``` markdown +> Lorem ipsum dolor +``` + +causes a `block_quote` block to be created as a child of our +open `document` block, and a `paragraph` block as a child of +the `block_quote`. Then the text is added to the last open +block, the `paragraph`: + +``` tree +-> document + -> block_quote + -> paragraph + "Lorem ipsum dolor" +``` + +The next line, + +``` markdown +sit amet. +``` + +is a "lazy continuation" of the open `paragraph`, so it gets added +to the paragraph's text: + +``` tree +-> document + -> block_quote + -> paragraph + "Lorem ipsum dolor\nsit amet." +``` + +The third line, + +``` markdown +> - Qui *quodsi iracundia* +``` + +causes the `paragraph` block to be closed, and a new `list` block +opened as a child of the `block_quote`. A `list_item` is also +added as a child of the `list`, and a `paragraph` as a child of +the `list_item`. The text is then added to the new `paragraph`: + +``` tree +-> document + -> block_quote + paragraph + "Lorem ipsum dolor\nsit amet." + -> list (type=bullet tight=true bullet_char=-) + -> list_item + -> paragraph + "Qui *quodsi iracundia*" +``` + +The fourth line, + +``` markdown +> - aliquando id +``` + +causes the `list_item` (and its child the `paragraph`) to be closed, +and a new `list_item` opened up as child of the `list`. A `paragraph` +is added as a child of the new `list_item`, to contain the text. +We thus obtain the final tree: + +``` tree +-> document + -> block_quote + paragraph + "Lorem ipsum dolor\nsit amet." + -> list (type=bullet tight=true bullet_char=-) + list_item + paragraph + "Qui *quodsi iracundia*" + -> list_item + -> paragraph + "aliquando id" +``` + +## Phase 2: inline structure + +Once all of the input has been parsed, all open blocks are closed. + +We then "walk the tree," visiting every node, and parse raw +string contents of paragraphs and headings as inlines. At this +point we have seen all the link reference definitions, so we can +resolve reference links as we go. + +``` tree +document + block_quote + paragraph + str "Lorem ipsum dolor" + softbreak + str "sit amet." + list (type=bullet tight=true bullet_char=-) + list_item + paragraph + str "Qui " + emph + str "quodsi iracundia" + list_item + paragraph + str "aliquando id" +``` + +Notice how the [line ending] in the first paragraph has +been parsed as a `softbreak`, and the asterisks in the first list item +have become an `emph`. + +### An algorithm for parsing nested emphasis and links + +By far the trickiest part of inline parsing is handling emphasis, +strong emphasis, links, and images. This is done using the following +algorithm. + +When we're parsing inlines and we hit either + +- a run of `*` or `_` characters, or +- a `[` or `![` + +we insert a text node with these symbols as its literal content, and we +add a pointer to this text node to the [delimiter stack](@). + +The [delimiter stack] is a doubly linked list. Each +element contains a pointer to a text node, plus information about + +- the type of delimiter (`[`, `![`, `*`, `_`) +- the number of delimiters, +- whether the delimiter is "active" (all are active to start), and +- whether the delimiter is a potential opener, a potential closer, + or both (which depends on what sort of characters precede + and follow the delimiters). + +When we hit a `]` character, we call the *look for link or image* +procedure (see below). + +When we hit the end of the input, we call the *process emphasis* +procedure (see below), with `stack_bottom` = NULL. + +#### *look for link or image* + +Starting at the top of the delimiter stack, we look backwards +through the stack for an opening `[` or `![` delimiter. + +- If we don't find one, we return a literal text node `]`. + +- If we do find one, but it's not *active*, we remove the inactive + delimiter from the stack, and return a literal text node `]`. + +- If we find one and it's active, then we parse ahead to see if + we have an inline link/image, reference link/image, compact reference + link/image, or shortcut reference link/image. + + + If we don't, then we remove the opening delimiter from the + delimiter stack and return a literal text node `]`. + + + If we do, then + + * We return a link or image node whose children are the inlines + after the text node pointed to by the opening delimiter. + + * We run *process emphasis* on these inlines, with the `[` opener + as `stack_bottom`. + + * We remove the opening delimiter. + + * If we have a link (and not an image), we also set all + `[` delimiters before the opening delimiter to *inactive*. (This + will prevent us from getting links within links.) + +#### *process emphasis* + +Parameter `stack_bottom` sets a lower bound to how far we +descend in the [delimiter stack]. If it is NULL, we can +go all the way to the bottom. Otherwise, we stop before +visiting `stack_bottom`. + +Let `current_position` point to the element on the [delimiter stack] +just above `stack_bottom` (or the first element if `stack_bottom` +is NULL). + +We keep track of the `openers_bottom` for each delimiter +type (`*`, `_`) and each length of the closing delimiter run +(modulo 3). Initialize this to `stack_bottom`. + +Then we repeat the following until we run out of potential +closers: + +- Move `current_position` forward in the delimiter stack (if needed) + until we find the first potential closer with delimiter `*` or `_`. + (This will be the potential closer closest + to the beginning of the input -- the first one in parse order.) + +- Now, look back in the stack (staying above `stack_bottom` and + the `openers_bottom` for this delimiter type) for the + first matching potential opener ("matching" means same delimiter). + +- If one is found: + + + Figure out whether we have emphasis or strong emphasis: + if both closer and opener spans have length >= 2, we have + strong, otherwise regular. + + + Insert an emph or strong emph node accordingly, after + the text node corresponding to the opener. + + + Remove any delimiters between the opener and closer from + the delimiter stack. + + + Remove 1 (for regular emph) or 2 (for strong emph) delimiters + from the opening and closing text nodes. If they become empty + as a result, remove them and remove the corresponding element + of the delimiter stack. If the closing node is removed, reset + `current_position` to the next element in the stack. + +- If none is found: + + + Set `openers_bottom` to the element before `current_position`. + (We know that there are no openers for this kind of closer up to and + including this point, so this puts a lower bound on future searches.) + + + If the closer at `current_position` is not a potential opener, + remove it from the delimiter stack (since we know it can't + be a closer either). + + + Advance `current_position` to the next element in the stack. + +After we're done, we remove all delimiters above `stack_bottom` from the +delimiter stack. + diff --git a/hugolib/testhelpers_test.go b/hugolib/testhelpers_test.go index 5300eee22..2007b658d 100644 --- a/hugolib/testhelpers_test.go +++ b/hugolib/testhelpers_test.go @@ -1,46 +1,78 @@ package hugolib import ( - "path/filepath" - "testing" - "bytes" + "context" "fmt" + "image/jpeg" + "io" + "io/fs" + "math/rand" + "os" + "path/filepath" "regexp" "strings" + "testing" "text/template" + "time" - "github.com/sanity-io/litter" + "github.com/gohugoio/hugo/config/allconfig" + "github.com/gohugoio/hugo/config/security" + "github.com/gohugoio/hugo/htesting" - jww "github.com/spf13/jwalterweatherman" + "github.com/gohugoio/hugo/output" + "github.com/gohugoio/hugo/parser/metadecoders" + "github.com/google/go-cmp/cmp" + + "github.com/gohugoio/hugo/parser" + + "github.com/fsnotify/fsnotify" + "github.com/gohugoio/hugo/common/hexec" + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/common/maps" "github.com/gohugoio/hugo/config" "github.com/gohugoio/hugo/deps" + "github.com/gohugoio/hugo/resources/page" + "github.com/sanity-io/litter" "github.com/spf13/afero" + "github.com/spf13/cast" "github.com/gohugoio/hugo/helpers" - "github.com/gohugoio/hugo/tpl" - "github.com/spf13/viper" - "io/ioutil" - "os" - - "log" + "github.com/gohugoio/hugo/resources/resource" + qt "github.com/frankban/quicktest" "github.com/gohugoio/hugo/hugofs" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -const () +var ( + deepEqualsPages = qt.CmpEquals(cmp.Comparer(func(p1, p2 *pageState) bool { return p1 == p2 })) + deepEqualsOutputFormats = qt.CmpEquals(cmp.Comparer(func(o1, o2 output.Format) bool { + return o1.Name == o2.Name && o1.MediaType.Type == o2.MediaType.Type + })) +) type sitesBuilder struct { - Cfg config.Provider - Fs *hugofs.Fs - T testing.TB + Cfg config.Provider + Configs *allconfig.Configs + environ []string + + Fs *hugofs.Fs + T testing.TB + depsCfg deps.DepsCfg + + *qt.C + + logger loggers.Logger + rnd *rand.Rand dumper litter.Options + // Used to test partial rebuilds. + changedFiles []string + removedFiles []string + // Aka the Hugo server mode. running bool @@ -49,30 +81,40 @@ type sitesBuilder struct { theme string // Default toml - configFormat string + configFormat string + configFileSet bool + configSet bool // Default is empty. // TODO(bep) revisit this and consider always setting it to something. // Consider this in relation to using the BaseFs.PublishFs to all publishing. workingDir string + addNothing bool // Base data/content - contentFilePairs []string - templateFilePairs []string - i18nFilePairs []string - dataFilePairs []string + contentFilePairs []filenameContent + templateFilePairs []filenameContent + i18nFilePairs []filenameContent + dataFilePairs []filenameContent // Additional data/content. // As in "use the base, but add these on top". - contentFilePairsAdded []string - templateFilePairsAdded []string - i18nFilePairsAdded []string - dataFilePairsAdded []string + contentFilePairsAdded []filenameContent + templateFilePairsAdded []filenameContent + i18nFilePairsAdded []filenameContent + dataFilePairsAdded []filenameContent +} + +type filenameContent struct { + filename string + content string } func newTestSitesBuilder(t testing.TB) *sitesBuilder { - v := viper.New() - fs := hugofs.NewMem(v) + v := config.New() + v.Set("publishDir", "public") + v.Set("disableLiveReload", true) + fs := hugofs.NewFromOld(afero.NewMemMapFs(), v) litterOptions := litter.Options{ HidePrivateFields: true, @@ -80,7 +122,27 @@ func newTestSitesBuilder(t testing.TB) *sitesBuilder { Separator: " ", } - return &sitesBuilder{T: t, Fs: fs, configFormat: "toml", dumper: litterOptions} + return &sitesBuilder{ + T: t, C: qt.New(t), Fs: fs, configFormat: "toml", + dumper: litterOptions, rnd: rand.New(rand.NewSource(time.Now().Unix())), + } +} + +func newTestSitesBuilderFromDepsCfg(t testing.TB, d deps.DepsCfg) *sitesBuilder { + c := qt.New(t) + + litterOptions := litter.Options{ + HidePrivateFields: true, + StripPackageNames: true, + Separator: " ", + } + + b := &sitesBuilder{T: t, C: c, depsCfg: d, Fs: d.Fs, dumper: litterOptions, rnd: rand.New(rand.NewSource(time.Now().Unix()))} + workingDir := d.Configs.LoadingInfo.BaseConfig.WorkingDir + + b.WithWorkingDir(workingDir) + + return b } func (s *sitesBuilder) Running() *sitesBuilder { @@ -88,12 +150,31 @@ func (s *sitesBuilder) Running() *sitesBuilder { return s } -func (s *sitesBuilder) WithWorkingDir(dir string) *sitesBuilder { - s.workingDir = dir +func (s *sitesBuilder) WithNothingAdded() *sitesBuilder { + s.addNothing = true return s } -func (s *sitesBuilder) WithConfigTemplate(data interface{}, format, configTemplate string) *sitesBuilder { +func (s *sitesBuilder) WithLogger(logger loggers.Logger) *sitesBuilder { + s.logger = logger + return s +} + +func (s *sitesBuilder) WithWorkingDir(dir string) *sitesBuilder { + s.workingDir = filepath.FromSlash(dir) + return s +} + +func (s *sitesBuilder) WithEnviron(env ...string) *sitesBuilder { + for i := 0; i < len(env); i += 2 { + s.environ = append(s.environ, fmt.Sprintf("%s=%s", env[i], env[i+1])) + } + return s +} + +func (s *sitesBuilder) WithConfigTemplate(data any, format, configTemplate string) *sitesBuilder { + s.T.Helper() + if format == "" { format = "toml" } @@ -107,49 +188,120 @@ func (s *sitesBuilder) WithConfigTemplate(data interface{}, format, configTempla return s.WithConfigFile(format, b.String()) } -func (s *sitesBuilder) WithViper(v *viper.Viper) *sitesBuilder { - loadDefaultSettingsFor(v) - s.Cfg = v - return s +func (s *sitesBuilder) WithViper(v config.Provider) *sitesBuilder { + s.T.Helper() + if s.configFileSet { + s.T.Fatal("WithViper: use Viper or config.toml, not both") + } + defer func() { + s.configSet = true + }() + + // Write to a config file to make sure the tests follow the same code path. + var buff bytes.Buffer + m := v.Get("").(maps.Params) + s.Assert(parser.InterfaceToConfig(m, metadecoders.TOML, &buff), qt.IsNil) + return s.WithConfigFile("toml", buff.String()) } func (s *sitesBuilder) WithConfigFile(format, conf string) *sitesBuilder { - writeSource(s.T, s.Fs, "config."+format, conf) + s.T.Helper() + if s.configSet { + s.T.Fatal("WithConfigFile: use config.Config or config.toml, not both") + } + s.configFileSet = true + filename := s.absFilename("config." + format) + writeSource(s.T, s.Fs, filename, conf) s.configFormat = format return s } func (s *sitesBuilder) WithThemeConfigFile(format, conf string) *sitesBuilder { + s.T.Helper() if s.theme == "" { s.theme = "test-theme" } filename := filepath.Join("themes", s.theme, "config."+format) - writeSource(s.T, s.Fs, filename, conf) + writeSource(s.T, s.Fs, s.absFilename(filename), conf) return s } -func (s *sitesBuilder) WithSimpleConfigFile() *sitesBuilder { - var config = ` -baseURL = "http://example.com/" +func (s *sitesBuilder) WithSourceFile(filenameContent ...string) *sitesBuilder { + s.T.Helper() + for i := 0; i < len(filenameContent); i += 2 { + writeSource(s.T, s.Fs, s.absFilename(filenameContent[i]), filenameContent[i+1]) + } + return s +} + +func (s *sitesBuilder) absFilename(filename string) string { + filename = filepath.FromSlash(filename) + if filepath.IsAbs(filename) { + return filename + } + if s.workingDir != "" && !strings.HasPrefix(filename, s.workingDir) { + filename = filepath.Join(s.workingDir, filename) + } + return filename +} + +const commonConfigSections = ` + +[services] +[services.disqus] +shortname = "disqus_shortname" +[services.googleAnalytics] +id = "UA-ga_id" + +[privacy] +[privacy.disqus] +disable = false +[privacy.googleAnalytics] +respectDoNotTrack = true +[privacy.instagram] +simple = true +[privacy.x] +enableDNT = true +[privacy.vimeo] +disable = false +[privacy.youtube] +disable = false +privacyEnhanced = true + ` + +func (s *sitesBuilder) WithSimpleConfigFile() *sitesBuilder { + s.T.Helper() + return s.WithSimpleConfigFileAndBaseURL("http://example.com/") +} + +func (s *sitesBuilder) WithSimpleConfigFileAndBaseURL(baseURL string) *sitesBuilder { + s.T.Helper() + return s.WithSimpleConfigFileAndSettings(map[string]any{"baseURL": baseURL}) +} + +func (s *sitesBuilder) WithSimpleConfigFileAndSettings(settings any) *sitesBuilder { + s.T.Helper() + var buf bytes.Buffer + parser.InterfaceToConfig(settings, metadecoders.TOML, &buf) + config := buf.String() + commonConfigSections return s.WithConfigFile("toml", config) } func (s *sitesBuilder) WithDefaultMultiSiteConfig() *sitesBuilder { - var defaultMultiSiteConfig = ` + defaultMultiSiteConfig := ` baseURL = "http://example.com/blog" -paginate = 1 disablePathToLower = true defaultContentLanguage = "en" defaultContentLanguageInSubdir = true +[pagination] +pagerSize = 1 + [permalinks] other = "/somewhere/else/:filename" -[blackfriday] -angledQuotes = true - [Taxonomies] tag = "tags" @@ -158,8 +310,6 @@ tag = "tags" weight = 10 title = "In English" languageName = "English" -[Languages.en.blackfriday] -angledQuotes = false [[Languages.en.menu.main]] url = "/" name = "Home" @@ -176,7 +326,8 @@ plaque = "plaques" weight = 30 title = "På nynorsk" languageName = "Nynorsk" -paginatePath = "side" +[Languages.nn.pagination] +path = "side" [Languages.nn.Taxonomies] lag = "lag" [[Languages.nn.menu.main]] @@ -188,64 +339,121 @@ weight = 1 weight = 40 title = "På bokmål" languageName = "Bokmål" -paginatePath = "side" +[Languages.nb.pagination] +path = "side" [Languages.nb.Taxonomies] lag = "lag" -` +` + commonConfigSections return s.WithConfigFile("toml", defaultMultiSiteConfig) +} +func (s *sitesBuilder) WithSunset(in string) { + // Write a real image into one of the bundle above. + src, err := os.Open(filepath.FromSlash("testdata/sunset.jpg")) + s.Assert(err, qt.IsNil) + + out, err := s.Fs.Source.Create(filepath.FromSlash(filepath.Join(s.workingDir, in))) + s.Assert(err, qt.IsNil) + + _, err = io.Copy(out, src) + s.Assert(err, qt.IsNil) + + out.Close() + src.Close() +} + +func (s *sitesBuilder) createFilenameContent(pairs []string) []filenameContent { + var slice []filenameContent + s.appendFilenameContent(&slice, pairs...) + return slice +} + +func (s *sitesBuilder) appendFilenameContent(slice *[]filenameContent, pairs ...string) { + if len(pairs)%2 != 0 { + panic("file content mismatch") + } + for i := 0; i < len(pairs); i += 2 { + c := filenameContent{ + filename: pairs[i], + content: pairs[i+1], + } + *slice = append(*slice, c) + } } func (s *sitesBuilder) WithContent(filenameContent ...string) *sitesBuilder { - s.contentFilePairs = append(s.contentFilePairs, filenameContent...) + s.appendFilenameContent(&s.contentFilePairs, filenameContent...) return s } func (s *sitesBuilder) WithContentAdded(filenameContent ...string) *sitesBuilder { - s.contentFilePairsAdded = append(s.contentFilePairsAdded, filenameContent...) + s.appendFilenameContent(&s.contentFilePairsAdded, filenameContent...) return s } func (s *sitesBuilder) WithTemplates(filenameContent ...string) *sitesBuilder { - s.templateFilePairs = append(s.templateFilePairs, filenameContent...) + s.appendFilenameContent(&s.templateFilePairs, filenameContent...) return s } func (s *sitesBuilder) WithTemplatesAdded(filenameContent ...string) *sitesBuilder { - s.templateFilePairsAdded = append(s.templateFilePairsAdded, filenameContent...) + s.appendFilenameContent(&s.templateFilePairsAdded, filenameContent...) return s } func (s *sitesBuilder) WithData(filenameContent ...string) *sitesBuilder { - s.dataFilePairs = append(s.dataFilePairs, filenameContent...) + s.appendFilenameContent(&s.dataFilePairs, filenameContent...) return s } func (s *sitesBuilder) WithDataAdded(filenameContent ...string) *sitesBuilder { - s.dataFilePairsAdded = append(s.dataFilePairsAdded, filenameContent...) + s.appendFilenameContent(&s.dataFilePairsAdded, filenameContent...) return s } func (s *sitesBuilder) WithI18n(filenameContent ...string) *sitesBuilder { - s.i18nFilePairs = append(s.i18nFilePairs, filenameContent...) + s.appendFilenameContent(&s.i18nFilePairs, filenameContent...) return s } func (s *sitesBuilder) WithI18nAdded(filenameContent ...string) *sitesBuilder { - s.i18nFilePairsAdded = append(s.i18nFilePairsAdded, filenameContent...) + s.appendFilenameContent(&s.i18nFilePairsAdded, filenameContent...) return s } -func (s *sitesBuilder) writeFilePairs(folder string, filenameContent []string) *sitesBuilder { - if len(filenameContent)%2 != 0 { - s.Fatalf("expect filenameContent for %q in pairs (%d)", folder, len(filenameContent)) - } +func (s *sitesBuilder) EditFiles(filenameContent ...string) *sitesBuilder { for i := 0; i < len(filenameContent); i += 2 { - filename, content := filenameContent[i], filenameContent[i+1] + filename, content := filepath.FromSlash(filenameContent[i]), filenameContent[i+1] + absFilename := s.absFilename(filename) + s.changedFiles = append(s.changedFiles, absFilename) + writeSource(s.T, s.Fs, absFilename, content) + + } + return s +} + +func (s *sitesBuilder) RemoveFiles(filenames ...string) *sitesBuilder { + for _, filename := range filenames { + absFilename := s.absFilename(filename) + s.removedFiles = append(s.removedFiles, absFilename) + s.Assert(s.Fs.Source.Remove(absFilename), qt.IsNil) + } + return s +} + +func (s *sitesBuilder) writeFilePairs(folder string, files []filenameContent) *sitesBuilder { + // We have had some "filesystem ordering" bugs that we have not discovered in + // our tests running with the in memory filesystem. + // That file system is backed by a map so not sure how this helps, but some + // randomness in tests doesn't hurt. + // TODO(bep) this turns out to be more confusing than helpful. + // s.rnd.Shuffle(len(files), func(i, j int) { files[i], files[j] = files[j], files[i] }) + + for _, fc := range files { target := folder // TODO(bep) clean up this magic. - if strings.HasPrefix(filename, folder) { + if strings.HasPrefix(fc.filename, folder) { target = "" } @@ -253,57 +461,162 @@ func (s *sitesBuilder) writeFilePairs(folder string, filenameContent []string) * target = filepath.Join(s.workingDir, target) } - writeSource(s.T, s.Fs, filepath.Join(target, filename), content) + writeSource(s.T, s.Fs, filepath.Join(target, fc.filename), fc.content) } return s } func (s *sitesBuilder) CreateSites() *sitesBuilder { - s.addDefaults() - s.writeFilePairs("content", s.contentFilePairs) - s.writeFilePairs("content", s.contentFilePairsAdded) - s.writeFilePairs("layouts", s.templateFilePairs) - s.writeFilePairs("layouts", s.templateFilePairsAdded) - s.writeFilePairs("data", s.dataFilePairs) - s.writeFilePairs("data", s.dataFilePairsAdded) - s.writeFilePairs("i18n", s.i18nFilePairs) - s.writeFilePairs("i18n", s.i18nFilePairsAdded) - - if s.Cfg == nil { - cfg, configFiles, err := LoadConfig(ConfigSourceDescriptor{Fs: s.Fs.Source, Filename: "config." + s.configFormat}) - if err != nil { - s.Fatalf("Failed to load config: %s", err) - } - expectedConfigs := 1 - if s.theme != "" { - expectedConfigs = 2 - } - require.Equal(s.T, expectedConfigs, len(configFiles), fmt.Sprintf("Configs: %v", configFiles)) - s.Cfg = cfg - } - - sites, err := NewHugoSites(deps.DepsCfg{Fs: s.Fs, Cfg: s.Cfg, Running: s.running}) - if err != nil { + if err := s.CreateSitesE(); err != nil { s.Fatalf("Failed to create sites: %s", err) } - s.H = sites + + s.Assert(s.Fs.PublishDir, qt.IsNotNil) + s.Assert(s.Fs.WorkingDirReadOnly, qt.IsNotNil) return s } +func (s *sitesBuilder) LoadConfig() error { + if !s.configFileSet { + s.WithSimpleConfigFile() + } + + flags := config.New() + flags.Set("internal", map[string]any{ + "running": s.running, + "watch": s.running, + }) + + if s.workingDir != "" { + flags.Set("workingDir", s.workingDir) + } + + res, err := allconfig.LoadConfig(allconfig.ConfigSourceDescriptor{ + Fs: s.Fs.Source, + Logger: s.logger, + Flags: flags, + Environ: s.environ, + Filename: "config." + s.configFormat, + }) + if err != nil { + return err + } + + s.Cfg = res.LoadingInfo.Cfg + s.Configs = res + + return nil +} + +func (s *sitesBuilder) CreateSitesE() error { + if !s.addNothing { + if _, ok := s.Fs.Source.(*afero.OsFs); ok { + for _, dir := range []string{ + "content/sect", + "layouts/_default", + "layouts/_default/_markup", + "layouts/partials", + "layouts/shortcodes", + "data", + "i18n", + } { + if err := os.MkdirAll(filepath.Join(s.workingDir, dir), 0o777); err != nil { + return fmt.Errorf("failed to create %q: %w", dir, err) + } + } + } + + s.addDefaults() + s.writeFilePairs("content", s.contentFilePairsAdded) + s.writeFilePairs("layouts", s.templateFilePairsAdded) + s.writeFilePairs("data", s.dataFilePairsAdded) + s.writeFilePairs("i18n", s.i18nFilePairsAdded) + + s.writeFilePairs("i18n", s.i18nFilePairs) + s.writeFilePairs("data", s.dataFilePairs) + s.writeFilePairs("content", s.contentFilePairs) + s.writeFilePairs("layouts", s.templateFilePairs) + + } + + if err := s.LoadConfig(); err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + s.Fs.PublishDir = hugofs.NewCreateCountingFs(s.Fs.PublishDir) + + depsCfg := s.depsCfg + depsCfg.Fs = s.Fs + if depsCfg.Configs.IsZero() { + depsCfg.Configs = s.Configs + } + depsCfg.TestLogger = s.logger + + sites, err := NewHugoSites(depsCfg) + if err != nil { + return fmt.Errorf("failed to create sites: %w", err) + } + s.H = sites + + return nil +} + +func (s *sitesBuilder) BuildE(cfg BuildCfg) error { + if s.H == nil { + s.CreateSites() + } + + return s.H.Build(cfg) +} + func (s *sitesBuilder) Build(cfg BuildCfg) *sitesBuilder { + s.T.Helper() return s.build(cfg, false) } func (s *sitesBuilder) BuildFail(cfg BuildCfg) *sitesBuilder { + s.T.Helper() return s.build(cfg, true) } +func (s *sitesBuilder) changeEvents() []fsnotify.Event { + var events []fsnotify.Event + + for _, v := range s.changedFiles { + events = append(events, fsnotify.Event{ + Name: v, + Op: fsnotify.Write, + }) + } + for _, v := range s.removedFiles { + events = append(events, fsnotify.Event{ + Name: v, + Op: fsnotify.Remove, + }) + } + + return events +} + func (s *sitesBuilder) build(cfg BuildCfg, shouldFail bool) *sitesBuilder { + s.Helper() + defer func() { + s.changedFiles = nil + }() + if s.H == nil { s.CreateSites() } - err := s.H.Build(cfg) + + err := s.H.Build(cfg, s.changeEvents()...) + + if err == nil { + logErrorCount := s.H.NumLogErrors() + if logErrorCount > 0 { + err = fmt.Errorf("logged %d errors", logErrorCount) + } + } if err != nil && !shouldFail { s.Fatalf("Build failed: %s", err) } else if err == nil && shouldFail { @@ -314,7 +627,6 @@ func (s *sitesBuilder) build(cfg BuildCfg, shouldFail bool) *sitesBuilder { } func (s *sitesBuilder) addDefaults() { - var ( contentTemplate = `--- title: doc1 @@ -325,9 +637,7 @@ date: "2018-02-28" --- # doc1 *some "content"* - {{< shortcode >}} - {{< lingo >}} ` @@ -338,17 +648,23 @@ date: "2018-02-28" "content/sect/doc1.nn.md", contentTemplate, } - defaultTemplates = []string{ - "_default/single.html", "Single: {{ .Title }}|{{ i18n \"hello\" }}|{{.Lang}}|{{ .Content }}", - "_default/list.html", "{{ $p := .Paginator }}List Page {{ $p.PageNumber }}: {{ .Title }}|{{ i18n \"hello\" }}|{{ .Permalink }}|Pager: {{ template \"_internal/pagination.html\" . }}", - "index.html", "{{ $p := .Paginator }}Default Home Page {{ $p.PageNumber }}: {{ .Title }}|{{ .IsHome }}|{{ i18n \"hello\" }}|{{ .Permalink }}|{{ .Site.Data.hugo.slogan }}", - "index.fr.html", "{{ $p := .Paginator }}French Home Page {{ $p.PageNumber }}: {{ .Title }}|{{ .IsHome }}|{{ i18n \"hello\" }}|{{ .Permalink }}|{{ .Site.Data.hugo.slogan }}", + listTemplateCommon = "{{ $p := .Paginator }}{{ $p.PageNumber }}|{{ .Title }}|{{ i18n \"hello\" }}|{{ .Permalink }}|Pager: {{ template \"_internal/pagination.html\" . }}|Kind: {{ .Kind }}|Content: {{ .Content }}|Len Pages: {{ len .Pages }}|Len RegularPages: {{ len .RegularPages }}| HasParent: {{ if .Parent }}YES{{ else }}NO{{ end }}" + defaultTemplates = []string{ + "_default/single.html", "Single: {{ .Title }}|{{ i18n \"hello\" }}|{{.Language.Lang}}|RelPermalink: {{ .RelPermalink }}|Permalink: {{ .Permalink }}|{{ .Content }}|Resources: {{ range .Resources }}{{ .MediaType }}: {{ .RelPermalink}} -- {{ end }}|Summary: {{ .Summary }}|Truncated: {{ .Truncated }}|Parent: {{ .Parent.Title }}", + "_default/list.html", "List Page " + listTemplateCommon, + "index.html", "{{ $p := .Paginator }}Default Home Page {{ $p.PageNumber }}: {{ .Title }}|{{ .IsHome }}|{{ i18n \"hello\" }}|{{ .Permalink }}|{{ .Site.Data.hugo.slogan }}|String Resource: {{ ( \"Hugo Pipes\" | resources.FromString \"text/pipes.txt\").RelPermalink }}|String Resource Permalink: {{ ( \"Hugo Pipes\" | resources.FromString \"text/pipes.txt\").Permalink }}", + "index.fr.html", "{{ $p := .Paginator }}French Home Page {{ $p.PageNumber }}: {{ .Title }}|{{ .IsHome }}|{{ i18n \"hello\" }}|{{ .Permalink }}|{{ .Site.Data.hugo.slogan }}|String Resource: {{ ( \"Hugo Pipes\" | resources.FromString \"text/pipes.txt\").RelPermalink }}|String Resource Permalink: {{ ( \"Hugo Pipes\" | resources.FromString \"text/pipes.txt\").Permalink }}", + "_default/terms.html", "Taxonomy Term Page " + listTemplateCommon, + "_default/taxonomy.html", "Taxonomy List Page " + listTemplateCommon, // Shortcodes "shortcodes/shortcode.html", "Shortcode: {{ i18n \"hello\" }}", // A shortcode in multiple languages "shortcodes/lingo.html", "LingoDefault", "shortcodes/lingo.fr.html", "LingoFrench", + // Special templates + "404.html", "404|{{ .Lang }}|{{ .Title }}", + "robots.txt", "robots|{{ .Lang }}|{{ .Title }}", } defaultI18n = []string{ @@ -368,54 +684,133 @@ hello: ) if len(s.contentFilePairs) == 0 { - s.writeFilePairs("content", defaultContent) + s.writeFilePairs("content", s.createFilenameContent(defaultContent)) } + if len(s.templateFilePairs) == 0 { - s.writeFilePairs("layouts", defaultTemplates) + s.writeFilePairs("layouts", s.createFilenameContent(defaultTemplates)) } if len(s.dataFilePairs) == 0 { - s.writeFilePairs("data", defaultData) + s.writeFilePairs("data", s.createFilenameContent(defaultData)) } if len(s.i18nFilePairs) == 0 { - s.writeFilePairs("i18n", defaultI18n) + s.writeFilePairs("i18n", s.createFilenameContent(defaultI18n)) } } -func (s *sitesBuilder) Fatalf(format string, args ...interface{}) { - Fatalf(s.T, format, args...) +func (s *sitesBuilder) Fatalf(format string, args ...any) { + s.T.Helper() + s.T.Fatalf(format, args...) } -func Fatalf(t testing.TB, format string, args ...interface{}) { - trace := strings.Join(assert.CallerInfo(), "\n\r\t\t\t") - format = format + "\n%s" - args = append(args, trace) - t.Fatalf(format, args...) +func (s *sitesBuilder) AssertFileContentFn(filename string, f func(s string) bool) { + s.T.Helper() + content := s.FileContent(filename) + if !f(content) { + s.Fatalf("Assert failed for %q in content\n%s", filename, content) + } +} + +// Helper to migrate tests to new format. +func (s *sitesBuilder) DumpTxtar() string { + var sb strings.Builder + + skipRe := regexp.MustCompile(`^(public|resources|package-lock.json|go.sum)`) + + afero.Walk(s.Fs.Source, s.workingDir, func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + rel := strings.TrimPrefix(path, s.workingDir+"/") + if skipRe.MatchString(rel) { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + if info == nil || info.IsDir() { + return nil + } + sb.WriteString(fmt.Sprintf("-- %s --\n", rel)) + b, err := afero.ReadFile(s.Fs.Source, path) + s.Assert(err, qt.IsNil) + sb.WriteString(strings.TrimSpace(string(b))) + sb.WriteString("\n") + return nil + }) + + return sb.String() +} + +func (s *sitesBuilder) AssertHome(matches ...string) { + s.AssertFileContent("public/index.html", matches...) } func (s *sitesBuilder) AssertFileContent(filename string, matches ...string) { - content := readDestination(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.T.Helper() + content := s.FileContent(filename) + for _, m := range matches { + lines := strings.Split(m, "\n") + for _, match := range lines { + match = strings.TrimSpace(match) + if match == "" { + continue + } + if !strings.Contains(content, match) { + s.Assert(content, qt.Contains, match, qt.Commentf(match+" not in: \n"+content)) + } } } } -func (s *sitesBuilder) AssertObject(expected string, object interface{}) { +func (s *sitesBuilder) AssertFileDoesNotExist(filename string) { + if s.CheckExists(filename) { + s.Fatalf("File %q exists but must not exist.", filename) + } +} + +func (s *sitesBuilder) AssertImage(width, height int, filename string) { + f, err := s.Fs.WorkingDirReadOnly.Open(filename) + s.Assert(err, qt.IsNil) + defer f.Close() + cfg, err := jpeg.DecodeConfig(f) + s.Assert(err, qt.IsNil) + s.Assert(cfg.Width, qt.Equals, width) + s.Assert(cfg.Height, qt.Equals, height) +} + +func (s *sitesBuilder) AssertNoDuplicateWrites() { + s.Helper() + hugofs.WalkFilesystems(s.Fs.PublishDir, func(fs afero.Fs) bool { + if dfs, ok := fs.(hugofs.DuplicatesReporter); ok { + s.Assert(dfs.ReportDuplicates(), qt.Equals, "") + } + return false + }) +} + +func (s *sitesBuilder) FileContent(filename string) string { + s.Helper() + filename = filepath.FromSlash(filename) + return readWorkingDir(s.T, s.Fs, filename) +} + +func (s *sitesBuilder) AssertObject(expected string, object any) { + s.T.Helper() got := s.dumper.Sdump(object) expected = strings.TrimSpace(expected) if expected != got { fmt.Println(got) - diff := helpers.DiffStrings(expected, got) + diff := htesting.DiffStrings(expected, got) s.Fatalf("diff:\n%s\nexpected\n%s\ngot\n%s", diff, expected, got) } } func (s *sitesBuilder) AssertFileContentRe(filename string, matches ...string) { - content := readDestination(s.T, s.Fs, filename) + content := readWorkingDir(s.T, s.Fs, filename) for _, match := range matches { - r := regexp.MustCompile(match) + r := regexp.MustCompile("(?s)" + match) if !r.MatchString(content) { s.Fatalf("No match for %q in content for %s\n%q", match, filename, content) } @@ -423,178 +818,161 @@ func (s *sitesBuilder) AssertFileContentRe(filename string, matches ...string) { } func (s *sitesBuilder) CheckExists(filename string) bool { - return destinationExists(s.Fs, filepath.Clean(filename)) + return workingDirExists(s.Fs, filepath.Clean(filename)) } -type testHelper struct { - Cfg config.Provider - Fs *hugofs.Fs - T testing.TB +func (s *sitesBuilder) GetPage(ref string) page.Page { + p, err := s.H.Sites[0].getPage(nil, ref) + s.Assert(err, qt.IsNil) + return p } -func (th testHelper) assertFileContent(filename string, matches ...string) { - filename = th.replaceDefaultContentLanguageValue(filename) - content := readDestination(th.T, th.Fs, filename) - for _, match := range matches { - match = th.replaceDefaultContentLanguageValue(match) - require.True(th.T, strings.Contains(content, match), fmt.Sprintf("File no match for\n%q in\n%q:\n%s", strings.Replace(match, "%", "%%", -1), filename, strings.Replace(content, "%", "%%", -1))) +func (s *sitesBuilder) GetPageRel(p page.Page, ref string) page.Page { + p, err := s.H.Sites[0].getPage(p, ref) + s.Assert(err, qt.IsNil) + return p +} + +func (s *sitesBuilder) NpmInstall() hexec.Runner { + sc := security.DefaultConfig + var err error + sc.Exec.Allow, err = security.NewWhitelist("npm") + s.Assert(err, qt.IsNil) + ex := hexec.New(sc, s.workingDir, loggers.NewDefault()) + command, err := ex.New("npm", "install") + s.Assert(err, qt.IsNil) + return command +} + +func newTestHelperFromProvider(cfg config.Provider, fs *hugofs.Fs, t testing.TB) (testHelper, *allconfig.Configs) { + res, err := allconfig.LoadConfig(allconfig.ConfigSourceDescriptor{ + Flags: cfg, + Fs: fs.Source, + }) + if err != nil { + t.Fatal(err) + } + return newTestHelper(res.Base, fs, t), res +} + +func newTestHelper(cfg *allconfig.Config, fs *hugofs.Fs, t testing.TB) testHelper { + return testHelper{ + Cfg: cfg, + Fs: fs, + C: qt.New(t), } } -func (th testHelper) assertFileContentRegexp(filename string, matches ...string) { +type testHelper struct { + Cfg *allconfig.Config + Fs *hugofs.Fs + *qt.C +} + +func (th testHelper) assertFileContent(filename string, matches ...string) { + th.Helper() filename = th.replaceDefaultContentLanguageValue(filename) - content := readDestination(th.T, th.Fs, filename) + content := readWorkingDir(th, th.Fs, filename) for _, match := range matches { match = th.replaceDefaultContentLanguageValue(match) - r := regexp.MustCompile(match) - require.True(th.T, r.MatchString(content), fmt.Sprintf("File no match for\n%q in\n%q:\n%s", strings.Replace(match, "%", "%%", -1), filename, strings.Replace(content, "%", "%%", -1))) + th.Assert(strings.Contains(content, match), qt.Equals, true, qt.Commentf(match+" not in: \n"+content)) } } func (th testHelper) assertFileNotExist(filename string) { - exists, err := helpers.Exists(filename, th.Fs.Destination) - require.NoError(th.T, err) - require.False(th.T, exists) + exists, err := helpers.Exists(filename, th.Fs.PublishDir) + th.Assert(err, qt.IsNil) + th.Assert(exists, qt.Equals, false) } func (th testHelper) replaceDefaultContentLanguageValue(value string) string { - defaultInSubDir := th.Cfg.GetBool("defaultContentLanguageInSubDir") - replace := th.Cfg.GetString("defaultContentLanguage") + "/" + defaultInSubDir := th.Cfg.DefaultContentLanguageInSubdir + replace := th.Cfg.DefaultContentLanguage + "/" if !defaultInSubDir { value = strings.Replace(value, replace, "", 1) - } return value } -func newTestPathSpec(fs *hugofs.Fs, v *viper.Viper) *helpers.PathSpec { - l := helpers.NewDefaultLanguage(v) - ps, _ := helpers.NewPathSpec(fs, l) - return ps -} - -func newTestDefaultPathSpec() *helpers.PathSpec { - v := viper.New() - // Easier to reason about in tests. - v.Set("disablePathToLower", true) - v.Set("contentDir", "content") - fs := hugofs.NewDefault(v) - ps, _ := helpers.NewPathSpec(fs, v) - return ps -} - -func newTestCfg() (*viper.Viper, *hugofs.Fs) { - - v := viper.New() - fs := hugofs.NewMem(v) - - v.SetFs(fs.Source) - - loadDefaultSettingsFor(v) - - // Default is false, but true is easier to use as default in tests - v.Set("defaultContentLanguageInSubdir", true) - - return v, fs - -} - -// newTestSite creates a new site in the English language with in-memory Fs. -// The site will have a template system loaded and ready to use. -// Note: This is only used in single site tests. -func newTestSite(t testing.TB, configKeyValues ...interface{}) *Site { - - cfg, fs := newTestCfg() - - for i := 0; i < len(configKeyValues); i += 2 { - cfg.Set(configKeyValues[i].(string), configKeyValues[i+1]) +func loadTestConfigFromProvider(cfg config.Provider) (*allconfig.Configs, error) { + workingDir := cfg.GetString("workingDir") + fs := afero.NewMemMapFs() + if workingDir != "" { + fs.MkdirAll(workingDir, 0o755) } + res, err := allconfig.LoadConfig(allconfig.ConfigSourceDescriptor{Flags: cfg, Fs: fs}) + return res, err +} - d := deps.DepsCfg{Language: helpers.NewLanguage("en", cfg), Fs: fs, Cfg: cfg} +func newTestCfg(withConfig ...func(cfg config.Provider) error) (config.Provider, *hugofs.Fs) { + mm := afero.NewMemMapFs() + cfg := config.New() + cfg.Set("defaultContentLanguageInSubdir", false) + cfg.Set("publishDir", "public") - s, err := NewSiteForCfg(d) + fs := hugofs.NewFromOld(hugofs.NewBaseFileDecorator(mm), cfg) - if err != nil { - Fatalf(t, "Failed to create Site: %s", err) - } - return s + return cfg, fs } func newTestSitesFromConfig(t testing.TB, afs afero.Fs, tomlConfig string, layoutPathContentPairs ...string) (testHelper, *HugoSites) { if len(layoutPathContentPairs)%2 != 0 { - Fatalf(t, "Layouts must be provided in pairs") + t.Fatalf("Layouts must be provided in pairs") } + c := qt.New(t) + + writeToFs(t, afs, filepath.Join("content", ".gitkeep"), "") writeToFs(t, afs, "config.toml", tomlConfig) - cfg, err := LoadConfigDefault(afs) - require.NoError(t, err) + cfg, err := allconfig.LoadConfig(allconfig.ConfigSourceDescriptor{Fs: afs}) + c.Assert(err, qt.IsNil) - fs := hugofs.NewFrom(afs, cfg) - th := testHelper{cfg, fs, t} + fs := hugofs.NewFrom(afs, cfg.LoadingInfo.BaseConfig) + th := newTestHelper(cfg.Base, fs, t) for i := 0; i < len(layoutPathContentPairs); i += 2 { writeSource(t, fs, layoutPathContentPairs[i], layoutPathContentPairs[i+1]) } - h, err := NewHugoSites(deps.DepsCfg{Fs: fs, Cfg: cfg}) + h, err := NewHugoSites(deps.DepsCfg{Fs: fs, Configs: cfg}) - require.NoError(t, err) + c.Assert(err, qt.IsNil) return th, h } -func newTestSitesFromConfigWithDefaultTemplates(t testing.TB, tomlConfig string) (testHelper, *HugoSites) { - return newTestSitesFromConfig(t, afero.NewMemMapFs(), tomlConfig, - "layouts/_default/single.html", "Single|{{ .Title }}|{{ .Content }}", - "layouts/_default/list.html", "List|{{ .Title }}|{{ .Content }}", - "layouts/_default/terms.html", "Terms List|{{ .Title }}|{{ .Content }}", - ) -} - -func newDebugLogger() *jww.Notepad { - return jww.NewNotepad(jww.LevelDebug, jww.LevelError, os.Stdout, ioutil.Discard, "", log.Ldate|log.Ltime) -} - -func newErrorLogger() *jww.Notepad { - return jww.NewNotepad(jww.LevelError, jww.LevelError, os.Stdout, ioutil.Discard, "", log.Ldate|log.Ltime) -} - -func newWarningLogger() *jww.Notepad { - return jww.NewNotepad(jww.LevelWarn, jww.LevelError, os.Stdout, ioutil.Discard, "", log.Ldate|log.Ltime) -} - -func createWithTemplateFromNameValues(additionalTemplates ...string) func(templ tpl.TemplateHandler) error { - - return func(templ tpl.TemplateHandler) error { - for i := 0; i < len(additionalTemplates); i += 2 { - err := templ.AddTemplate(additionalTemplates[i], additionalTemplates[i+1]) - if err != nil { - return err - } - } - return nil - } -} - +// TODO(bep) replace these with the builder func buildSingleSite(t testing.TB, depsCfg deps.DepsCfg, buildCfg BuildCfg) *Site { - return buildSingleSiteExpected(t, false, depsCfg, buildCfg) + t.Helper() + return buildSingleSiteExpected(t, false, false, depsCfg, buildCfg) } -func buildSingleSiteExpected(t testing.TB, expectBuildError bool, depsCfg deps.DepsCfg, buildCfg BuildCfg) *Site { - h, err := NewHugoSites(depsCfg) +func buildSingleSiteExpected(t testing.TB, expectSiteInitError, expectBuildError bool, depsCfg deps.DepsCfg, buildCfg BuildCfg) *Site { + t.Helper() + b := newTestSitesBuilderFromDepsCfg(t, depsCfg).WithNothingAdded() - require.NoError(t, err) - require.Len(t, h.Sites, 1) + err := b.CreateSitesE() + + if expectSiteInitError { + b.Assert(err, qt.Not(qt.IsNil)) + return nil + } else { + b.Assert(err, qt.IsNil) + } + + h := b.H + + b.Assert(len(h.Sites), qt.Equals, 1) if expectBuildError { - require.Error(t, h.Build(buildCfg)) + b.Assert(h.Build(buildCfg), qt.Not(qt.IsNil)) return nil } - require.NoError(t, h.Build(buildCfg)) + b.Assert(h.Build(buildCfg), qt.IsNil) return h.Sites[0] } @@ -605,14 +983,23 @@ func writeSourcesToSource(t *testing.T, base string, fs *hugofs.Fs, sources ...[ } } -func dumpPages(pages ...*Page) { - for i, p := range pages { - fmt.Printf("%d: Kind: %s Title: %-10s RelPermalink: %-10s Path: %-10s sections: %s Len Sections(): %d\n", - i+1, - p.Kind, p.title, p.RelPermalink(), p.Path(), p.sections, len(p.Sections())) +func getPage(in page.Page, ref string) page.Page { + p, err := in.GetPage(ref) + if err != nil { + panic(err) } + return p } -func isCI() bool { - return os.Getenv("CI") != "" +func content(c resource.ContentProvider) string { + cc, err := c.Content(context.Background()) + if err != nil { + panic(err) + } + + ccs, err := cast.ToStringE(cc) + if err != nil { + panic(err) + } + return ccs } diff --git a/hugolib/testsite/.gitignore b/hugolib/testsite/.gitignore new file mode 100644 index 000000000..ab8b69cbc --- /dev/null +++ b/hugolib/testsite/.gitignore @@ -0,0 +1 @@ +config.toml \ No newline at end of file diff --git a/hugolib/testsite/CODEOWNERS b/hugolib/testsite/CODEOWNERS new file mode 100644 index 000000000..41f196327 --- /dev/null +++ b/hugolib/testsite/CODEOWNERS @@ -0,0 +1 @@ +* @bep \ No newline at end of file diff --git a/hugolib/testsite/content_nn/first-post.md b/hugolib/testsite/content_nn/first-post.md new file mode 100644 index 000000000..1c3b4e831 --- /dev/null +++ b/hugolib/testsite/content_nn/first-post.md @@ -0,0 +1,4 @@ +--- +title: "Min første dag" +lastmod: 1972-02-28 +--- \ No newline at end of file diff --git a/hugolib/translations.go b/hugolib/translations.go deleted file mode 100644 index 2682363f0..000000000 --- a/hugolib/translations.go +++ /dev/null @@ -1,59 +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 hugolib - -// Translations represent the other translations for a given page. The -// string here is the language code, as affected by the `post.LANG.md` -// filename. -type Translations map[string]*Page - -func pagesToTranslationsMap(pages []*Page) map[string]Translations { - out := make(map[string]Translations) - - for _, page := range pages { - base := page.TranslationKey() - - pageTranslation, present := out[base] - if !present { - pageTranslation = make(Translations) - } - - pageLang := page.Lang() - if pageLang == "" { - continue - } - - pageTranslation[pageLang] = page - out[base] = pageTranslation - } - - return out -} - -func assignTranslationsToPages(allTranslations map[string]Translations, pages []*Page) { - for _, page := range pages { - page.translations = page.translations[:0] - base := page.TranslationKey() - trans, exist := allTranslations[base] - if !exist { - continue - } - - for _, translatedPage := range trans { - page.translations = append(page.translations, translatedPage) - } - - pageBy(languagePageSort).Sort(page.translations) - } -} diff --git a/hugoreleaser.env b/hugoreleaser.env new file mode 100644 index 000000000..6da749524 --- /dev/null +++ b/hugoreleaser.env @@ -0,0 +1,123 @@ +# Release env. +# These will be replaced by script before release. +HUGORELEASER_TAG=v0.147.9 +HUGORELEASER_COMMITISH=29bdbde19c288d190e889294a862103c6efb70bf + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/hugoreleaser.yaml b/hugoreleaser.yaml new file mode 100644 index 000000000..368bc898f --- /dev/null +++ b/hugoreleaser.yaml @@ -0,0 +1,272 @@ +project: hugo + +# Common definitions. +definitions: + archive_type_zip: &archive_type_zip + type: + format: zip + extension: .zip + env_extended_linux: &env_extended_linux + - CGO_ENABLED=1 + - CC=aarch64-linux-gnu-gcc + - CXX=aarch64-linux-gnu-g++ + env_extended_windows: &env_extended_windows + - CGO_ENABLED=1 + - CC=x86_64-w64-mingw32-gcc + - CXX=x86_64-w64-mingw32-g++ + env_extended_darwin: &env_extended_darwin + - CGO_ENABLED=1 + - CC=o64-clang + - CXX=o64-clang++ + name_template_extended_withdeploy: &name_template_extended_withdeploy "{{ .Project }}_extended_withdeploy_{{ .Tag | trimPrefix `v` }}_{{ .Goos }}-{{ .Goarch }}" + name_template_extended: &name_template_extended "{{ .Project }}_extended_{{ .Tag | trimPrefix `v` }}_{{ .Goos }}-{{ .Goarch }}" + archive_deb: &archive_deb + binary_dir: /usr/local/bin + extra_files: [] + type: + format: _plugin + extension: .deb + plugin: + id: deb + type: gorun + command: github.com/gohugoio/hugoreleaser-archive-plugins/deb@latest + custom_settings: + vendor: gohugo.io + homepage: https://github.com/gohugoio/hugo + maintainer: Bjørn Erik Pedersen + description: A fast and flexible Static Site Generator written in Go. + license: Apache-2.0 +archive_alias_replacements: + linux-amd64.tar.gz: Linux-64bit.tar.gz +go_settings: + go_proxy: https://proxy.golang.org + go_exe: go +build_settings: + binary: hugo + flags: + - -buildmode + - exe + env: + - CGO_ENABLED=0 + ldflags: -s -w -X github.com/gohugoio/hugo/common/hugo.vendorInfo=gohugoio +archive_settings: + name_template: "{{ .Project }}_{{ .Tag | trimPrefix `v` }}_{{ .Goos }}-{{ .Goarch }}" + extra_files: + - source_path: README.md + target_path: README.md + - source_path: LICENSE + target_path: LICENSE + type: + format: tar.gz + extension: .tar.gz +release_settings: + name: ${HUGORELEASER_TAG} + type: github + repository: hugo + repository_owner: gohugoio + draft: true + prerelease: false + release_notes_settings: + generate: true + short_threshold: 10 + short_title: What's Changed + groups: + - regexp: "Merge commit|Squashed|releaser:" + ignore: true + - title: Note + regexp: (note|deprecated) + ordinal: 10 + - title: Bug fixes + regexp: fix + ordinal: 15 + - title: Dependency Updates + regexp: deps + ordinal: 30 + - title: Build Setup + regexp: (snap|release|update to) + ordinal: 40 + - title: Documentation + regexp: (doc|readme) + ordinal: 40 + - title: Improvements + regexp: .* + ordinal: 20 +builds: + - path: container1/unix/regular + os: + - goos: darwin + archs: + - goarch: universal + - goos: linux + archs: + - goarch: amd64 + - goarch: arm64 + - goarch: arm + build_settings: + env: + - CGO_ENABLED=0 + - GOARM=7 + - goos: dragonfly + archs: + - goarch: amd64 + - goos: freebsd + archs: + - goarch: amd64 + - goos: netbsd + archs: + - goarch: amd64 + - goos: openbsd + archs: + - goarch: amd64 + - goos: solaris + archs: + - goarch: amd64 + - path: container1/unix/extended + build_settings: + flags: + - -buildmode + - exe + - -tags + - extended + env: + - CGO_ENABLED=1 + os: + - goos: darwin + build_settings: + env: *env_extended_darwin + archs: + - goarch: universal + - goos: linux + archs: + - goarch: amd64 + - path: container1/unix/extended-withdeploy + build_settings: + flags: + - -buildmode + - exe + - -tags + - extended,withdeploy + env: + - CGO_ENABLED=1 + os: + - goos: darwin + build_settings: + env: *env_extended_darwin + archs: + - goarch: universal + - goos: linux + archs: + - goarch: amd64 + - path: container2/linux/extended + build_settings: + flags: + - -buildmode + - exe + - -tags + - extended + os: + - goos: linux + build_settings: + env: *env_extended_linux + archs: + - goarch: arm64 + - path: container2/linux/extended-withdeploy + build_settings: + flags: + - -buildmode + - exe + - -tags + - extended,withdeploy + os: + - goos: linux + build_settings: + env: *env_extended_linux + archs: + - goarch: arm64 + - path: container1/windows/regular + os: + - goos: windows + build_settings: + binary: hugo.exe + archs: + - goarch: amd64 + - goarch: arm64 + - path: container1/windows/extended + build_settings: + flags: + - -buildmode + - exe + - -tags + - extended + env: *env_extended_windows + ldflags: -s -w -X github.com/gohugoio/hugo/common/hugo.vendorInfo=gohugoio -extldflags '-static' + os: + - goos: windows + build_settings: + binary: hugo.exe + archs: + - goarch: amd64 + - path: container1/windows/extended-withdeploy + build_settings: + flags: + - -buildmode + - exe + - -tags + - extended,withdeploy + env: *env_extended_windows + ldflags: -s -w -X github.com/gohugoio/hugo/common/hugo.vendorInfo=gohugoio -extldflags '-static' + os: + - goos: windows + build_settings: + binary: hugo.exe + archs: + - goarch: amd64 +archives: + - paths: + - builds/container1/unix/regular/** + - paths: + - builds/container1/unix/extended/** + archive_settings: + name_template: *name_template_extended + - paths: + - builds/container1/unix/extended-withdeploy/** + archive_settings: + name_template: *name_template_extended_withdeploy + - paths: + - builds/container2/*/extended/** + archive_settings: + name_template: *name_template_extended + - paths: + - builds/container2/*/extended-withdeploy/** + archive_settings: + name_template: *name_template_extended_withdeploy + - paths: + - builds/**/windows/regular/** + archive_settings: *archive_type_zip + - paths: + - builds/**/windows/extended/** + archive_settings: + name_template: *name_template_extended + <<: *archive_type_zip + - paths: + - builds/**/windows/extended-withdeploy/** + archive_settings: + name_template: *name_template_extended_withdeploy + <<: *archive_type_zip + - paths: + - builds/**/regular/linux/{arm64,amd64} + archive_settings: *archive_deb + - paths: + - builds/**/extended/linux/{arm64,amd64} + archive_settings: + name_template: *name_template_extended + <<: *archive_deb + - paths: + - builds/**/extended-withdeploy/linux/{arm64,amd64} + archive_settings: + name_template: *name_template_extended_withdeploy + <<: *archive_deb +releases: + - paths: + - archives/** + path: r1 diff --git a/i18n/i18n.go b/i18n/i18n.go deleted file mode 100644 index 73417fb32..000000000 --- a/i18n/i18n.go +++ /dev/null @@ -1,113 +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 i18n - -import ( - "github.com/gohugoio/hugo/config" - "github.com/gohugoio/hugo/helpers" - "github.com/nicksnyder/go-i18n/i18n/bundle" - jww "github.com/spf13/jwalterweatherman" -) - -var ( - i18nWarningLogger = helpers.NewDistinctFeedbackLogger() -) - -// Translator handles i18n translations. -type Translator struct { - translateFuncs map[string]bundle.TranslateFunc - cfg config.Provider - logger *jww.Notepad -} - -// NewTranslator creates a new Translator for the given language bundle and configuration. -func NewTranslator(b *bundle.Bundle, cfg config.Provider, logger *jww.Notepad) Translator { - t := Translator{cfg: cfg, logger: logger, translateFuncs: make(map[string]bundle.TranslateFunc)} - t.initFuncs(b) - return t -} - -// Func gets the translate func for the given language, or for the default -// configured language if not found. -func (t Translator) Func(lang string) bundle.TranslateFunc { - if f, ok := t.translateFuncs[lang]; ok { - return f - } - t.logger.WARN.Printf("Translation func for language %v not found, use default.", lang) - if f, ok := t.translateFuncs[t.cfg.GetString("defaultContentLanguage")]; ok { - return f - } - t.logger.WARN.Println("i18n not initialized, check that you have language file (in i18n) that matches the site language or the default language.") - return func(translationID string, args ...interface{}) string { - return "" - } - -} - -func (t Translator) initFuncs(bndl *bundle.Bundle) { - defaultContentLanguage := t.cfg.GetString("defaultContentLanguage") - - defaultT, err := bndl.Tfunc(defaultContentLanguage) - if err != nil { - jww.WARN.Printf("No translation bundle found for default language %q", defaultContentLanguage) - } - - enableMissingTranslationPlaceholders := t.cfg.GetBool("enableMissingTranslationPlaceholders") - for _, lang := range bndl.LanguageTags() { - currentLang := lang - - t.translateFuncs[currentLang] = func(translationID string, args ...interface{}) string { - tFunc, err := bndl.Tfunc(currentLang) - if err != nil { - jww.WARN.Printf("could not load translations for language %q (%s), will use default content language.\n", lang, err) - } - - translated := tFunc(translationID, args...) - if translated != translationID { - return translated - } - // If there is no translation for translationID, - // then Tfunc returns translationID itself. - // But if user set same translationID and translation, we should check - // if it really untranslated: - if isIDTranslated(currentLang, translationID, bndl) { - return translated - } - - if t.cfg.GetBool("logI18nWarnings") { - i18nWarningLogger.Printf("i18n|MISSING_TRANSLATION|%s|%s", currentLang, translationID) - } - if enableMissingTranslationPlaceholders { - return "[i18n] " + translationID - } - if defaultT != nil { - translated := defaultT(translationID, args...) - if translated != translationID { - return translated - } - if isIDTranslated(defaultContentLanguage, translationID, bndl) { - return translated - } - } - return "" - } - } -} - -// If bndl contains the translationID for specified currentLang, -// then the translationID is actually translated. -func isIDTranslated(lang, id string, b *bundle.Bundle) bool { - _, contains := b.Translations()[lang][id] - return contains -} diff --git a/i18n/i18n_test.go b/i18n/i18n_test.go deleted file mode 100644 index 4f5b3fbac..000000000 --- a/i18n/i18n_test.go +++ /dev/null @@ -1,219 +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 i18n - -import ( - "path/filepath" - "testing" - - "github.com/gohugoio/hugo/tpl/tplimpl" - - "github.com/spf13/afero" - - "github.com/gohugoio/hugo/deps" - - "io/ioutil" - "os" - - "github.com/gohugoio/hugo/helpers" - - "log" - - "github.com/gohugoio/hugo/config" - "github.com/gohugoio/hugo/hugofs" - jww "github.com/spf13/jwalterweatherman" - "github.com/spf13/viper" - "github.com/stretchr/testify/require" -) - -var logger = jww.NewNotepad(jww.LevelError, jww.LevelError, os.Stdout, ioutil.Discard, "", log.Ldate|log.Ltime) - -type i18nTest struct { - data map[string][]byte - args interface{} - lang, id, expected, expectedFlag string -} - -var i18nTests = []i18nTest{ - // All translations present - { - data: map[string][]byte{ - "en.toml": []byte("[hello]\nother = \"Hello, World!\""), - "es.toml": []byte("[hello]\nother = \"¡Hola, Mundo!\""), - }, - args: nil, - lang: "es", - id: "hello", - expected: "¡Hola, Mundo!", - expectedFlag: "¡Hola, Mundo!", - }, - // Translation missing in current language but present in default - { - data: map[string][]byte{ - "en.toml": []byte("[hello]\nother = \"Hello, World!\""), - "es.toml": []byte("[goodbye]\nother = \"¡Adiós, Mundo!\""), - }, - args: nil, - lang: "es", - id: "hello", - expected: "Hello, World!", - expectedFlag: "[i18n] hello", - }, - // Translation missing in default language but present in current - { - data: map[string][]byte{ - "en.toml": []byte("[goodbye]\nother = \"Goodbye, World!\""), - "es.toml": []byte("[hello]\nother = \"¡Hola, Mundo!\""), - }, - args: nil, - lang: "es", - id: "hello", - expected: "¡Hola, Mundo!", - expectedFlag: "¡Hola, Mundo!", - }, - // Translation missing in both default and current language - { - data: map[string][]byte{ - "en.toml": []byte("[goodbye]\nother = \"Goodbye, World!\""), - "es.toml": []byte("[goodbye]\nother = \"¡Adiós, Mundo!\""), - }, - args: nil, - lang: "es", - id: "hello", - expected: "", - expectedFlag: "[i18n] hello", - }, - // Default translation file missing or empty - { - data: map[string][]byte{ - "en.toml": []byte(""), - }, - args: nil, - lang: "es", - id: "hello", - expected: "", - expectedFlag: "[i18n] hello", - }, - // Context provided - { - data: map[string][]byte{ - "en.toml": []byte("[wordCount]\nother = \"Hello, {{.WordCount}} people!\""), - "es.toml": []byte("[wordCount]\nother = \"¡Hola, {{.WordCount}} gente!\""), - }, - args: struct { - WordCount int - }{ - 50, - }, - lang: "es", - id: "wordCount", - expected: "¡Hola, 50 gente!", - expectedFlag: "¡Hola, 50 gente!", - }, - // Same id and translation in current language - // https://github.com/gohugoio/hugo/issues/2607 - { - data: map[string][]byte{ - "es.toml": []byte("[hello]\nother = \"hello\""), - "en.toml": []byte("[hello]\nother = \"hi\""), - }, - args: nil, - lang: "es", - id: "hello", - expected: "hello", - expectedFlag: "hello", - }, - // Translation missing in current language, but same id and translation in default - { - data: map[string][]byte{ - "es.toml": []byte("[bye]\nother = \"bye\""), - "en.toml": []byte("[hello]\nother = \"hello\""), - }, - args: nil, - lang: "es", - id: "hello", - expected: "hello", - expectedFlag: "[i18n] hello", - }, - // Unknown language code should get its plural spec from en - { - data: map[string][]byte{ - "en.toml": []byte(`[readingTime] -one ="one minute read" -other = "{{.Count}} minutes read"`), - "klingon.toml": []byte(`[readingTime] -one = "eitt minutt med lesing" -other = "{{ .Count }} minuttar lesing"`), - }, - args: 3, - lang: "klingon", - id: "readingTime", - expected: "3 minuttar lesing", - expectedFlag: "3 minuttar lesing", - }, -} - -func doTestI18nTranslate(t *testing.T, test i18nTest, cfg config.Provider) string { - assert := require.New(t) - fs := hugofs.NewMem(cfg) - tp := NewTranslationProvider() - depsCfg := newDepsConfig(tp, cfg, fs) - d, err := deps.New(depsCfg) - assert.NoError(err) - - for file, content := range test.data { - err := afero.WriteFile(fs.Source, filepath.Join("i18n", file), []byte(content), 0755) - assert.NoError(err) - } - - assert.NoError(d.LoadResources()) - f := tp.t.Func(test.lang) - return f(test.id, test.args) - -} - -func newDepsConfig(tp *TranslationProvider, cfg config.Provider, fs *hugofs.Fs) deps.DepsCfg { - l := helpers.NewLanguage("en", cfg) - l.Set("i18nDir", "i18n") - return deps.DepsCfg{ - Language: l, - Cfg: cfg, - Fs: fs, - Logger: logger, - TemplateProvider: tplimpl.DefaultTemplateProvider, - TranslationProvider: tp, - } -} - -func TestI18nTranslate(t *testing.T) { - var actual, expected string - v := viper.New() - v.SetDefault("defaultContentLanguage", "en") - v.Set("contentDir", "content") - - // Test without and with placeholders - for _, enablePlaceholders := range []bool{false, true} { - v.Set("enableMissingTranslationPlaceholders", enablePlaceholders) - - for _, test := range i18nTests { - if enablePlaceholders { - expected = test.expectedFlag - } else { - expected = test.expected - } - actual = doTestI18nTranslate(t, test, v) - require.Equal(t, expected, actual) - } - } -} diff --git a/i18n/translationProvider.go b/i18n/translationProvider.go deleted file mode 100644 index fa5664210..000000000 --- a/i18n/translationProvider.go +++ /dev/null @@ -1,110 +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 i18n - -import ( - "errors" - "fmt" - - "github.com/gohugoio/hugo/helpers" - - "github.com/gohugoio/hugo/deps" - "github.com/gohugoio/hugo/source" - "github.com/nicksnyder/go-i18n/i18n/bundle" - "github.com/nicksnyder/go-i18n/i18n/language" -) - -// TranslationProvider provides translation handling, i.e. loading -// of bundles etc. -type TranslationProvider struct { - t Translator -} - -// NewTranslationProvider creates a new translation provider. -func NewTranslationProvider() *TranslationProvider { - return &TranslationProvider{} -} - -// Update updates the i18n func in the provided Deps. -func (tp *TranslationProvider) Update(d *deps.Deps) error { - dir := d.PathSpec.AbsPathify(d.Cfg.GetString("i18nDir")) - sp := source.NewSourceSpec(d.PathSpec, d.Fs.Source) - sources := []source.Input{sp.NewFilesystem(dir)} - - themeI18nDir, err := d.PathSpec.GetThemeI18nDirPath() - - if err == nil { - sources = []source.Input{sp.NewFilesystem(themeI18nDir), sources[0]} - } - - d.Log.DEBUG.Printf("Load I18n from %q", sources) - - i18nBundle := bundle.New() - - en := language.GetPluralSpec("en") - if en == nil { - return errors.New("The English language has vanished like an old oak table!") - } - var newLangs []string - - for _, currentSource := range sources { - for _, r := range currentSource.Files() { - currentSpec := language.GetPluralSpec(r.BaseFileName()) - if currentSpec == nil { - // This may is a language code not supported by go-i18n, it may be - // Klingon or ... not even a fake language. Make sure it works. - newLangs = append(newLangs, r.BaseFileName()) - } - } - } - - if len(newLangs) > 0 { - language.RegisterPluralSpec(newLangs, en) - } - - for _, currentSource := range sources { - for _, r := range currentSource.Files() { - if err := addTranslationFile(i18nBundle, r); err != nil { - return err - } - } - } - - tp.t = NewTranslator(i18nBundle, d.Cfg, d.Log) - - d.Translate = tp.t.Func(d.Language.Lang) - - return nil - -} - -func addTranslationFile(bundle *bundle.Bundle, r source.ReadableFile) error { - f, err := r.Open() - if err != nil { - return fmt.Errorf("Failed to open translations file %q: %s", r.LogicalName(), err) - } - defer f.Close() - err = bundle.ParseTranslationFileBytes(r.LogicalName(), helpers.ReaderToBytes(f)) - if err != nil { - return fmt.Errorf("Failed to load translations in file %q: %s", r.LogicalName(), err) - } - return nil -} - -// Clone sets the language func for the new language. -func (tp *TranslationProvider) Clone(d *deps.Deps) error { - d.Translate = tp.t.Func(d.Language.Lang) - - return nil -} diff --git a/identity/finder.go b/identity/finder.go new file mode 100644 index 000000000..9d9f9d138 --- /dev/null +++ b/identity/finder.go @@ -0,0 +1,337 @@ +// 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 identity + +import ( + "fmt" + "sync" + + "github.com/gohugoio/hugo/compare" +) + +// NewFinder creates a new Finder. +// This is a thread safe implementation with a cache. +func NewFinder(cfg FinderConfig) *Finder { + return &Finder{cfg: cfg, answers: make(map[ManagerIdentity]FinderResult), seenFindOnce: make(map[Identity]bool)} +} + +var searchIDPool = sync.Pool{ + New: func() any { + return &searchID{seen: make(map[Manager]bool)} + }, +} + +func getSearchID() *searchID { + return searchIDPool.Get().(*searchID) +} + +func putSearchID(sid *searchID) { + sid.id = nil + sid.isDp = false + sid.isPeq = false + sid.hasEqer = false + sid.maxDepth = 0 + sid.dp = nil + sid.peq = nil + sid.eqer = nil + clear(sid.seen) + searchIDPool.Put(sid) +} + +// GetSearchID returns a searchID from the pool. + +// Finder finds identities inside another. +type Finder struct { + cfg FinderConfig + + answers map[ManagerIdentity]FinderResult + muAnswers sync.RWMutex + + seenFindOnce map[Identity]bool + muSeenFindOnce sync.RWMutex +} + +type FinderResult int + +const ( + FinderNotFound FinderResult = iota + FinderFoundOneOfManyRepetition + FinderFoundOneOfMany + FinderFound +) + +// Contains returns whether in contains id. +func (f *Finder) Contains(id, in Identity, maxDepth int) FinderResult { + if id == Anonymous || in == Anonymous { + return FinderNotFound + } + + if id == GenghisKhan && in == GenghisKhan { + return FinderNotFound + } + + if id == GenghisKhan { + return FinderFound + } + + if id == in { + return FinderFound + } + + if id == nil || in == nil { + return FinderNotFound + } + + var ( + isDp bool + isPeq bool + + dp IsProbablyDependentProvider + peq compare.ProbablyEqer + ) + + if !f.cfg.Exact { + dp, isDp = id.(IsProbablyDependentProvider) + peq, isPeq = id.(compare.ProbablyEqer) + } + + eqer, hasEqer := id.(compare.Eqer) + + sid := getSearchID() + sid.id = id + sid.isDp = isDp + sid.isPeq = isPeq + sid.hasEqer = hasEqer + sid.dp = dp + sid.peq = peq + sid.eqer = eqer + sid.maxDepth = maxDepth + + defer putSearchID(sid) + + r := FinderNotFound + if i := f.checkOne(sid, in, 0); i > r { + r = i + } + if r == FinderFound { + return r + } + + m := GetDependencyManager(in) + if m != nil { + if i := f.checkManager(sid, m, 0); i > r { + r = i + } + } + return r +} + +func (f *Finder) checkMaxDepth(sid *searchID, level int) FinderResult { + if sid.maxDepth >= 0 && level > sid.maxDepth { + return FinderNotFound + } + if level > 100 { + // This should never happen, but some false positives are probably better than a panic. + if !f.cfg.Exact { + return FinderFound + } + panic("too many levels") + } + return -1 +} + +func (f *Finder) checkManager(sid *searchID, m Manager, level int) FinderResult { + if r := f.checkMaxDepth(sid, level); r >= 0 { + return r + } + + if m == nil { + return FinderNotFound + } + if sid.seen[m] { + return FinderNotFound + } + sid.seen[m] = true + + f.muAnswers.RLock() + r, ok := f.answers[ManagerIdentity{Manager: m, Identity: sid.id}] + f.muAnswers.RUnlock() + if ok { + return r + } + + r = f.search(sid, m, level) + + if r == FinderFoundOneOfMany { + // Don't cache this one. + return r + } + + f.muAnswers.Lock() + f.answers[ManagerIdentity{Manager: m, Identity: sid.id}] = r + f.muAnswers.Unlock() + + return r +} + +func (f *Finder) checkOne(sid *searchID, v Identity, depth int) (r FinderResult) { + if ff, ok := v.(FindFirstManagerIdentityProvider); ok { + f.muSeenFindOnce.RLock() + mi := ff.FindFirstManagerIdentity() + seen := f.seenFindOnce[mi.Identity] + f.muSeenFindOnce.RUnlock() + if seen { + return FinderFoundOneOfManyRepetition + } + + r = f.doCheckOne(sid, mi.Identity, depth) + if r == 0 { + r = f.checkManager(sid, mi.Manager, depth) + } + + if r > FinderFoundOneOfManyRepetition { + f.muSeenFindOnce.Lock() + // Double check. + if f.seenFindOnce[mi.Identity] { + f.muSeenFindOnce.Unlock() + return FinderFoundOneOfManyRepetition + } + f.seenFindOnce[mi.Identity] = true + f.muSeenFindOnce.Unlock() + r = FinderFoundOneOfMany + } + return r + } else { + return f.doCheckOne(sid, v, depth) + } +} + +func (f *Finder) doCheckOne(sid *searchID, v Identity, depth int) FinderResult { + id2 := Unwrap(v) + if id2 == Anonymous { + return FinderNotFound + } + id := sid.id + if sid.hasEqer { + if sid.eqer.Eq(id2) { + return FinderFound + } + } else if id == id2 { + return FinderFound + } + + if f.cfg.Exact { + return FinderNotFound + } + + if id2 == nil { + return FinderNotFound + } + + if id2 == GenghisKhan { + return FinderFound + } + + if id.IdentifierBase() == id2.IdentifierBase() { + return FinderFound + } + + if sid.isDp && sid.dp.IsProbablyDependent(id2) { + return FinderFound + } + + if sid.isPeq && sid.peq.ProbablyEq(id2) { + return FinderFound + } + + if pdep, ok := id2.(IsProbablyDependencyProvider); ok && pdep.IsProbablyDependency(id) { + return FinderFound + } + + if peq, ok := id2.(compare.ProbablyEqer); ok && peq.ProbablyEq(id) { + return FinderFound + } + + return FinderNotFound +} + +// search searches for id in ids. +func (f *Finder) search(sid *searchID, m Manager, depth int) FinderResult { + id := sid.id + + if id == Anonymous { + return FinderNotFound + } + + if !f.cfg.Exact && id == GenghisKhan { + return FinderNotFound + } + + var r FinderResult + m.forEeachIdentity( + func(v Identity) bool { + i := f.checkOne(sid, v, depth) + if i > r { + r = i + } + if r == FinderFound { + return true + } + m := GetDependencyManager(v) + if i := f.checkManager(sid, m, depth+1); i > r { + r = i + } + if r == FinderFound { + return true + } + return false + }, + ) + return r +} + +// FinderConfig provides configuration for the Finder. +// Note that we by default will use a strategy where probable matches are +// good enough. The primary use case for this is to identity the change set +// for a given changed identity (e.g. a template), and we don't want to +// have any false negatives there, but some false positives are OK. Also, speed is important. +type FinderConfig struct { + // Match exact matches only. + Exact bool +} + +// ManagerIdentity wraps a pair of Identity and Manager. +type ManagerIdentity struct { + Identity + Manager +} + +func (p ManagerIdentity) String() string { + return fmt.Sprintf("%s:%s", p.Identity.IdentifierBase(), p.Manager.IdentifierBase()) +} + +type searchID struct { + id Identity + isDp bool + isPeq bool + hasEqer bool + + maxDepth int + + seen map[Manager]bool + + dp IsProbablyDependentProvider + peq compare.ProbablyEqer + eqer compare.Eqer +} diff --git a/identity/finder_test.go b/identity/finder_test.go new file mode 100644 index 000000000..abfab9d75 --- /dev/null +++ b/identity/finder_test.go @@ -0,0 +1,58 @@ +// 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 provides ways to identify values in Hugo. Used for dependency tracking etc. +package identity_test + +import ( + "testing" + + "github.com/gohugoio/hugo/identity" +) + +func BenchmarkFinder(b *testing.B) { + m1 := identity.NewManager("") + m2 := identity.NewManager("") + m3 := identity.NewManager("") + m1.AddIdentity( + testIdentity{"base", "id1", "", "pe1"}, + testIdentity{"base2", "id2", "eq1", ""}, + m2, + m3, + ) + + b4 := testIdentity{"base4", "id4", "", ""} + b5 := testIdentity{"base5", "id5", "", ""} + + m2.AddIdentity(b4) + + f := identity.NewFinder(identity.FinderConfig{}) + + b.Run("Find one", func(b *testing.B) { + for i := 0; i < b.N; i++ { + r := f.Contains(b4, m1, -1) + if r == 0 { + b.Fatal("not found") + } + } + }) + + b.Run("Find none", func(b *testing.B) { + for i := 0; i < b.N; i++ { + r := f.Contains(b5, m1, -1) + if r > 0 { + b.Fatal("found") + } + } + }) +} diff --git a/identity/identity.go b/identity/identity.go new file mode 100644 index 000000000..c78ed0fdd --- /dev/null +++ b/identity/identity.go @@ -0,0 +1,521 @@ +// 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 provides ways to identify values in Hugo. Used for dependency tracking etc. +package identity + +import ( + "fmt" + "path" + "path/filepath" + "sort" + "strings" + "sync" + "sync/atomic" + + "github.com/gohugoio/hugo/common/types" + "github.com/gohugoio/hugo/compare" +) + +const ( + // Anonymous is an Identity that can be used when identity doesn't matter. + Anonymous = StringIdentity("__anonymous") + + // GenghisKhan is an Identity everyone relates to. + GenghisKhan = StringIdentity("__genghiskhan") + + StructuralChangeAdd = StringIdentity("__structural_change_add") + StructuralChangeRemove = StringIdentity("__structural_change_remove") +) + +var NopManager = new(nopManager) + +// NewIdentityManager creates a new Manager. +func NewManager(name string, opts ...ManagerOption) Manager { + idm := &identityManager{ + Identity: Anonymous, + name: name, + ids: Identities{}, + } + + for _, o := range opts { + o(idm) + } + + return idm +} + +// CleanString cleans s to be suitable as an identifier. +func CleanString(s string) string { + s = strings.ToLower(s) + s = strings.Trim(filepath.ToSlash(s), "/") + return "/" + path.Clean(s) +} + +// CleanStringIdentity cleans s to be suitable as an identifier and wraps it in a StringIdentity. +func CleanStringIdentity(s string) StringIdentity { + return StringIdentity(CleanString(s)) +} + +// GetDependencyManager returns the DependencyManager from v or nil if none found. +func GetDependencyManager(v any) Manager { + switch vv := v.(type) { + case Manager: + return vv + case types.Unwrapper: + return GetDependencyManager(vv.Unwrapv()) + case DependencyManagerProvider: + return vv.GetDependencyManager() + } + return nil +} + +// FirstIdentity returns the first Identity in v, Anonymous if none found +func FirstIdentity(v any) Identity { + var result Identity = Anonymous + WalkIdentitiesShallow(v, func(level int, id Identity) bool { + result = id + return result != Anonymous + }) + return result +} + +// PrintIdentityInfo is used for debugging/tests only. +func PrintIdentityInfo(v any) { + WalkIdentitiesDeep(v, func(level int, id Identity) bool { + var s string + if idm, ok := id.(*identityManager); ok { + s = " " + idm.name + } + fmt.Printf("%s%s (%T)%s\n", strings.Repeat(" ", level), id.IdentifierBase(), id, s) + return false + }) +} + +func Unwrap(id Identity) Identity { + switch t := id.(type) { + case IdentityProvider: + return t.GetIdentity() + default: + return id + } +} + +// WalkIdentitiesDeep walks identities in v and applies cb to every identity found. +// Return true from cb to terminate. +// If deep is true, it will also walk nested Identities in any Manager found. +func WalkIdentitiesDeep(v any, cb func(level int, id Identity) bool) { + seen := make(map[Identity]bool) + walkIdentities(v, 0, true, seen, cb) +} + +// WalkIdentitiesShallow will not walk into a Manager's Identities. +// See WalkIdentitiesDeep. +// cb is called for every Identity found and returns whether to terminate the walk. +func WalkIdentitiesShallow(v any, cb func(level int, id Identity) bool) { + walkIdentitiesShallow(v, 0, cb) +} + +// WithOnAddIdentity sets a callback that will be invoked when an identity is added to the manager. +func WithOnAddIdentity(f func(id Identity)) ManagerOption { + return func(m *identityManager) { + m.onAddIdentity = f + } +} + +// DependencyManagerProvider provides a manager for dependencies. +type DependencyManagerProvider interface { + GetDependencyManager() Manager +} + +// DependencyManagerProviderFunc is a function that implements the DependencyManagerProvider interface. +type DependencyManagerProviderFunc func() Manager + +func (d DependencyManagerProviderFunc) GetDependencyManager() Manager { + return d() +} + +// DependencyManagerScopedProvider provides a manager for dependencies with a given scope. +type DependencyManagerScopedProvider interface { + GetDependencyManagerForScope(scope int) Manager + GetDependencyManagerForScopesAll() []Manager +} + +// ForEeachIdentityProvider provides a way iterate over identities. +type ForEeachIdentityProvider interface { + // ForEeachIdentityProvider calls cb for each Identity. + // If cb returns true, the iteration is terminated. + // The return value is whether the iteration was terminated. + ForEeachIdentity(cb func(id Identity) bool) bool +} + +// ForEeachIdentityProviderFunc is a function that implements the ForEeachIdentityProvider interface. +type ForEeachIdentityProviderFunc func(func(id Identity) bool) bool + +func (f ForEeachIdentityProviderFunc) ForEeachIdentity(cb func(id Identity) bool) bool { + return f(cb) +} + +// ForEeachIdentityByNameProvider provides a way to look up identities by name. +type ForEeachIdentityByNameProvider interface { + // ForEeachIdentityByName calls cb for each Identity that relates to name. + // If cb returns true, the iteration is terminated. + ForEeachIdentityByName(name string, cb func(id Identity) bool) +} + +type FindFirstManagerIdentityProvider interface { + Identity + FindFirstManagerIdentity() ManagerIdentity +} + +func NewFindFirstManagerIdentityProvider(m Manager, id Identity) FindFirstManagerIdentityProvider { + return findFirstManagerIdentity{ + Identity: Anonymous, + ManagerIdentity: ManagerIdentity{ + Manager: m, Identity: id, + }, + } +} + +type findFirstManagerIdentity struct { + Identity + ManagerIdentity +} + +func (f findFirstManagerIdentity) FindFirstManagerIdentity() ManagerIdentity { + return f.ManagerIdentity +} + +// Identities stores identity providers. +type Identities map[Identity]bool + +func (ids Identities) AsSlice() []Identity { + s := make([]Identity, len(ids)) + i := 0 + for v := range ids { + s[i] = v + i++ + } + sort.Slice(s, func(i, j int) bool { + return s[i].IdentifierBase() < s[j].IdentifierBase() + }) + + return s +} + +func (ids Identities) String() string { + var sb strings.Builder + i := 0 + for id := range ids { + sb.WriteString(fmt.Sprintf("[%s]", id.IdentifierBase())) + if i < len(ids)-1 { + sb.WriteString(", ") + } + i++ + } + return sb.String() +} + +// Identity represents a thing in Hugo (a Page, a template etc.) +// Any implementation must be comparable/hashable. +type Identity interface { + IdentifierBase() string +} + +// IdentityGroupProvider can be implemented by tightly connected types. +// Current use case is Resource transformation via Hugo Pipes. +type IdentityGroupProvider interface { + GetIdentityGroup() Identity +} + +// IdentityProvider can be implemented by types that isn't itself and Identity, +// usually because they're not comparable/hashable. +type IdentityProvider interface { + GetIdentity() Identity +} + +// SignalRebuilder is an optional interface for types that can signal a rebuild. +type SignalRebuilder interface { + SignalRebuild(ids ...Identity) +} + +// IncrementByOne implements Incrementer adding 1 every time Incr is called. +type IncrementByOne struct { + counter uint64 +} + +func (c *IncrementByOne) Incr() int { + return int(atomic.AddUint64(&c.counter, uint64(1))) +} + +// Incrementer increments and returns the value. +// Typically used for IDs. +type Incrementer interface { + Incr() int +} + +// IsProbablyDependentProvider is an optional interface for Identity. +type IsProbablyDependentProvider interface { + IsProbablyDependent(other Identity) bool +} + +// IsProbablyDependencyProvider is an optional interface for Identity. +type IsProbablyDependencyProvider interface { + IsProbablyDependency(other Identity) bool +} + +// Manager is an Identity that also manages identities, typically dependencies. +type Manager interface { + Identity + AddIdentity(ids ...Identity) + AddIdentityForEach(ids ...ForEeachIdentityProvider) + GetIdentity() Identity + Reset() + forEeachIdentity(func(id Identity) bool) bool +} + +type ManagerOption func(m *identityManager) + +// StringIdentity is an Identity that wraps a string. +type StringIdentity string + +func (s StringIdentity) IdentifierBase() string { + return string(s) +} + +type identityManager struct { + Identity + + // Only used for debugging. + name string + + // mu protects _changes_ to this manager, + // reads currently assumes no concurrent writes. + mu sync.RWMutex + ids Identities + forEachIds []ForEeachIdentityProvider + + // Hooks used in debugging. + onAddIdentity func(id Identity) +} + +func (im *identityManager) AddIdentity(ids ...Identity) { + im.mu.Lock() + defer im.mu.Unlock() + + for _, id := range ids { + if id == nil || id == Anonymous { + continue + } + + if _, found := im.ids[id]; !found { + if im.onAddIdentity != nil { + im.onAddIdentity(id) + } + im.ids[id] = true + } + } +} + +func (im *identityManager) AddIdentityForEach(ids ...ForEeachIdentityProvider) { + im.mu.Lock() + im.forEachIds = append(im.forEachIds, ids...) + im.mu.Unlock() +} + +func (im *identityManager) ContainsIdentity(id Identity) FinderResult { + if im.Identity != Anonymous && id == im.Identity { + return FinderFound + } + + f := NewFinder(FinderConfig{Exact: true}) + r := f.Contains(id, im, -1) + + return r +} + +// Managers are always anonymous. +func (im *identityManager) GetIdentity() Identity { + return im.Identity +} + +func (im *identityManager) Reset() { + im.mu.Lock() + im.ids = Identities{} + im.mu.Unlock() +} + +func (im *identityManager) GetDependencyManagerForScope(int) Manager { + return im +} + +func (im *identityManager) GetDependencyManagerForScopesAll() []Manager { + return []Manager{im} +} + +func (im *identityManager) String() string { + return fmt.Sprintf("IdentityManager(%s)", im.name) +} + +func (im *identityManager) forEeachIdentity(fn func(id Identity) bool) bool { + // The absence of a lock here is deliberate. This is currently only used on server reloads + // in a single-threaded context. + for id := range im.ids { + if fn(id) { + return true + } + } + for _, fe := range im.forEachIds { + if fe.ForEeachIdentity(fn) { + return true + } + } + return false +} + +type nopManager int + +func (m *nopManager) AddIdentity(ids ...Identity) { +} + +func (m *nopManager) AddIdentityForEach(ids ...ForEeachIdentityProvider) { +} + +func (m *nopManager) IdentifierBase() string { + return "" +} + +func (m *nopManager) GetIdentity() Identity { + return Anonymous +} + +func (m *nopManager) Reset() { +} + +func (m *nopManager) forEeachIdentity(func(id Identity) bool) bool { + return false +} + +// returns whether further walking should be terminated. +func walkIdentities(v any, level int, deep bool, seen map[Identity]bool, cb func(level int, id Identity) bool) { + if level > 20 { + panic("too deep") + } + var cbRecursive func(level int, id Identity) bool + cbRecursive = func(level int, id Identity) bool { + if id == nil { + return false + } + if deep && seen[id] { + return false + } + seen[id] = true + if cb(level, id) { + return true + } + + if deep { + if m := GetDependencyManager(id); m != nil { + m.forEeachIdentity(func(id2 Identity) bool { + return walkIdentitiesShallow(id2, level+1, cbRecursive) + }) + } + } + return false + } + walkIdentitiesShallow(v, level, cbRecursive) +} + +// returns whether further walking should be terminated. +// Anonymous identities are skipped. +func walkIdentitiesShallow(v any, level int, cb func(level int, id Identity) bool) bool { + cb2 := func(level int, id Identity) bool { + if id == Anonymous { + return false + } + if id == nil { + return false + } + return cb(level, id) + } + + if id, ok := v.(Identity); ok { + if cb2(level, id) { + return true + } + } + + if ipd, ok := v.(IdentityProvider); ok { + if cb2(level, ipd.GetIdentity()) { + return true + } + } + + if ipdgp, ok := v.(IdentityGroupProvider); ok { + if cb2(level, ipdgp.GetIdentityGroup()) { + return true + } + } + + return false +} + +var ( + _ Identity = (*orIdentity)(nil) + _ compare.ProbablyEqer = (*orIdentity)(nil) +) + +func Or(a, b Identity) Identity { + return orIdentity{a: a, b: b} +} + +type orIdentity struct { + a, b Identity +} + +func (o orIdentity) IdentifierBase() string { + return o.a.IdentifierBase() +} + +func (o orIdentity) ProbablyEq(other any) bool { + otherID, ok := other.(Identity) + if !ok { + return false + } + + return probablyEq(o.a, otherID) || probablyEq(o.b, otherID) +} + +func probablyEq(a, b Identity) bool { + if a == b { + return true + } + + if a == Anonymous || b == Anonymous { + return false + } + + if a.IdentifierBase() == b.IdentifierBase() { + return true + } + + if a2, ok := a.(compare.ProbablyEqer); ok && a2.ProbablyEq(b) { + return true + } + + if a2, ok := a.(IsProbablyDependentProvider); ok { + return a2.IsProbablyDependent(b) + } + + return false +} diff --git a/identity/identity_test.go b/identity/identity_test.go new file mode 100644 index 000000000..f9b04aa14 --- /dev/null +++ b/identity/identity_test.go @@ -0,0 +1,211 @@ +// 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 identity_test + +import ( + "fmt" + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/identity" + "github.com/gohugoio/hugo/identity/identitytesting" +) + +func BenchmarkIdentityManager(b *testing.B) { + createIds := func(num int) []identity.Identity { + ids := make([]identity.Identity, num) + for i := range num { + name := fmt.Sprintf("id%d", i) + ids[i] = &testIdentity{base: name, name: name} + } + return ids + } + + b.Run("identity.NewManager", func(b *testing.B) { + for i := 0; i < b.N; i++ { + m := identity.NewManager("") + if m == nil { + b.Fatal("manager is nil") + } + } + }) + + b.Run("Add unique", func(b *testing.B) { + ids := createIds(b.N) + im := identity.NewManager("") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + im.AddIdentity(ids[i]) + } + + b.StopTimer() + }) + + b.Run("Add duplicates", func(b *testing.B) { + id := &testIdentity{base: "a", name: "b"} + im := identity.NewManager("") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + im.AddIdentity(id) + } + + b.StopTimer() + }) + + b.Run("Nop StringIdentity const", func(b *testing.B) { + const id = identity.StringIdentity("test") + for i := 0; i < b.N; i++ { + identity.NopManager.AddIdentity(id) + } + }) + + b.Run("Nop StringIdentity const other package", func(b *testing.B) { + for i := 0; i < b.N; i++ { + identity.NopManager.AddIdentity(identitytesting.TestIdentity) + } + }) + + b.Run("Nop StringIdentity var", func(b *testing.B) { + id := identity.StringIdentity("test") + for i := 0; i < b.N; i++ { + identity.NopManager.AddIdentity(id) + } + }) + + b.Run("Nop pointer identity", func(b *testing.B) { + id := &testIdentity{base: "a", name: "b"} + for i := 0; i < b.N; i++ { + identity.NopManager.AddIdentity(id) + } + }) + + b.Run("Nop Anonymous", func(b *testing.B) { + for i := 0; i < b.N; i++ { + identity.NopManager.AddIdentity(identity.Anonymous) + } + }) +} + +func BenchmarkIsNotDependent(b *testing.B) { + runBench := func(b *testing.B, id1, id2 identity.Identity) { + for i := 0; i < b.N; i++ { + isNotDependent(id1, id2) + } + } + + newNestedManager := func(depth, count int) identity.Manager { + m1 := identity.NewManager("") + for range depth { + m2 := identity.NewManager("") + m1.AddIdentity(m2) + for j := range count { + id := fmt.Sprintf("id%d", j) + m2.AddIdentity(&testIdentity{id, id, "", ""}) + } + m1 = m2 + } + return m1 + } + + type depthCount struct { + depth int + count int + } + + for _, dc := range []depthCount{{10, 5}} { + b.Run(fmt.Sprintf("Nested not found %d %d", dc.depth, dc.count), func(b *testing.B) { + im := newNestedManager(dc.depth, dc.count) + id1 := identity.StringIdentity("idnotfound") + b.ResetTimer() + runBench(b, im, id1) + }) + } +} + +func TestIdentityManager(t *testing.T) { + c := qt.New(t) + + newNestedManager := func() identity.Manager { + m1 := identity.NewManager("") + m2 := identity.NewManager("") + m3 := identity.NewManager("") + m1.AddIdentity( + testIdentity{"base", "id1", "", "pe1"}, + testIdentity{"base2", "id2", "eq1", ""}, + m2, + m3, + ) + + m2.AddIdentity(testIdentity{"base4", "id4", "", ""}) + + return m1 + } + + c.Run("Anonymous", func(c *qt.C) { + im := newNestedManager() + c.Assert(im.GetIdentity(), qt.Equals, identity.Anonymous) + im.AddIdentity(identity.Anonymous) + c.Assert(isNotDependent(identity.Anonymous, identity.Anonymous), qt.IsTrue) + }) + + c.Run("GenghisKhan", func(c *qt.C) { + c.Assert(isNotDependent(identity.GenghisKhan, identity.GenghisKhan), qt.IsTrue) + }) +} + +type testIdentity struct { + base string + name string + + idEq string + idProbablyEq string +} + +func (id testIdentity) Eq(other any) bool { + ot, ok := other.(testIdentity) + if !ok { + return false + } + if ot.idEq == "" || id.idEq == "" { + return false + } + return ot.idEq == id.idEq +} + +func (id testIdentity) IdentifierBase() string { + return id.base +} + +func (id testIdentity) Name() string { + return id.name +} + +func (id testIdentity) ProbablyEq(other any) bool { + ot, ok := other.(testIdentity) + if !ok { + return false + } + if ot.idProbablyEq == "" || id.idProbablyEq == "" { + return false + } + return ot.idProbablyEq == id.idProbablyEq +} + +func isNotDependent(a, b identity.Identity) bool { + f := identity.NewFinder(identity.FinderConfig{}) + r := f.Contains(b, a, -1) + return r == 0 +} diff --git a/identity/identitytesting/identitytesting.go b/identity/identitytesting/identitytesting.go new file mode 100644 index 000000000..74f3ec540 --- /dev/null +++ b/identity/identitytesting/identitytesting.go @@ -0,0 +1,5 @@ +package identitytesting + +import "github.com/gohugoio/hugo/identity" + +const TestIdentity = identity.StringIdentity("__testIdentity") diff --git a/identity/predicate_identity.go b/identity/predicate_identity.go new file mode 100644 index 000000000..bad247867 --- /dev/null +++ b/identity/predicate_identity.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 provides ways to identify values in Hugo. Used for dependency tracking etc. +package identity + +import ( + "fmt" + "sync/atomic" + + hglob "github.com/gohugoio/hugo/hugofs/glob" +) + +// NewGlobIdentity creates a new Identity that +// is probably dependent on any other Identity +// that matches the given pattern. +func NewGlobIdentity(pattern string) Identity { + glob, err := hglob.GetGlob(pattern) + if err != nil { + panic(err) + } + + predicate := func(other Identity) bool { + return glob.Match(other.IdentifierBase()) + } + + return NewPredicateIdentity(predicate, nil) +} + +var predicateIdentityCounter = &atomic.Uint32{} + +type predicateIdentity struct { + id string + probablyDependent func(Identity) bool + probablyDependency func(Identity) bool +} + +var ( + _ IsProbablyDependencyProvider = &predicateIdentity{} + _ IsProbablyDependentProvider = &predicateIdentity{} +) + +// NewPredicateIdentity creates a new Identity that implements both IsProbablyDependencyProvider and IsProbablyDependentProvider +// using the provided functions, both of which are optional. +func NewPredicateIdentity( + probablyDependent func(Identity) bool, + probablyDependency func(Identity) bool, +) *predicateIdentity { + if probablyDependent == nil { + probablyDependent = func(Identity) bool { return false } + } + if probablyDependency == nil { + probablyDependency = func(Identity) bool { return false } + } + return &predicateIdentity{probablyDependent: probablyDependent, probablyDependency: probablyDependency, id: fmt.Sprintf("predicate%d", predicateIdentityCounter.Add(1))} +} + +func (id *predicateIdentity) IdentifierBase() string { + return id.id +} + +func (id *predicateIdentity) IsProbablyDependent(other Identity) bool { + return id.probablyDependent(other) +} + +func (id *predicateIdentity) IsProbablyDependency(other Identity) bool { + return id.probablyDependency(other) +} diff --git a/identity/predicate_identity_test.go b/identity/predicate_identity_test.go new file mode 100644 index 000000000..3a54dee75 --- /dev/null +++ b/identity/predicate_identity_test.go @@ -0,0 +1,58 @@ +// 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 provides ways to identify values in Hugo. Used for dependency tracking etc. +package identity + +import ( + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestGlobIdentity(t *testing.T) { + c := qt.New(t) + + gid := NewGlobIdentity("/a/b/*") + + c.Assert(isNotDependent(gid, StringIdentity("/a/b/c")), qt.IsFalse) + c.Assert(isNotDependent(gid, StringIdentity("/a/c/d")), qt.IsTrue) + c.Assert(isNotDependent(StringIdentity("/a/b/c"), gid), qt.IsTrue) + c.Assert(isNotDependent(StringIdentity("/a/c/d"), gid), qt.IsTrue) +} + +func isNotDependent(a, b Identity) bool { + f := NewFinder(FinderConfig{}) + r := f.Contains(a, b, -1) + return r == 0 +} + +func TestPredicateIdentity(t *testing.T) { + c := qt.New(t) + + isDependent := func(id Identity) bool { + return id.IdentifierBase() == "foo" + } + isDependency := func(id Identity) bool { + return id.IdentifierBase() == "baz" + } + + id := NewPredicateIdentity(isDependent, isDependency) + + c.Assert(id.IsProbablyDependent(StringIdentity("foo")), qt.IsTrue) + c.Assert(id.IsProbablyDependent(StringIdentity("bar")), qt.IsFalse) + c.Assert(id.IsProbablyDependent(id), qt.IsFalse) + c.Assert(id.IsProbablyDependent(NewPredicateIdentity(isDependent, nil)), qt.IsFalse) + c.Assert(id.IsProbablyDependency(StringIdentity("baz")), qt.IsTrue) + c.Assert(id.IsProbablyDependency(StringIdentity("foo")), qt.IsFalse) +} diff --git a/identity/question.go b/identity/question.go new file mode 100644 index 000000000..78fcb8234 --- /dev/null +++ b/identity/question.go @@ -0,0 +1,57 @@ +// 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 identity + +import "sync" + +// NewQuestion creates a new question with the given identity. +func NewQuestion[T any](id Identity) *Question[T] { + return &Question[T]{ + Identity: id, + } +} + +// Answer takes a func that knows the answer. +// Note that this is a one-time operation, +// fn will not be invoked again it the question is already answered. +// Use Result to check if the question is answered. +func (q *Question[T]) Answer(fn func() T) { + q.mu.Lock() + defer q.mu.Unlock() + + if q.answered { + return + } + + q.fasit = fn() + q.answered = true +} + +// Result returns the fasit of the question (if answered), +// and a bool indicating if the question has been answered. +func (q *Question[T]) Result() (any, bool) { + q.mu.RLock() + defer q.mu.RUnlock() + + return q.fasit, q.answered +} + +// A Question is defined by its Identity and can be answered once. +type Question[T any] struct { + Identity + fasit T + + mu sync.RWMutex + answered bool +} diff --git a/identity/question_test.go b/identity/question_test.go new file mode 100644 index 000000000..bf1e1d06d --- /dev/null +++ b/identity/question_test.go @@ -0,0 +1,38 @@ +// 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 identity + +import ( + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestQuestion(t *testing.T) { + c := qt.New(t) + + q := NewQuestion[int](StringIdentity("2+2?")) + + v, ok := q.Result() + c.Assert(ok, qt.Equals, false) + c.Assert(v, qt.Equals, 0) + + q.Answer(func() int { + return 4 + }) + + v, ok = q.Result() + c.Assert(ok, qt.Equals, true) + c.Assert(v, qt.Equals, 4) +} diff --git a/internal/js/api.go b/internal/js/api.go new file mode 100644 index 000000000..30180dece --- /dev/null +++ b/internal/js/api.go @@ -0,0 +1,51 @@ +// 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 ( + "context" + + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/resources/resource" +) + +// BatcherClient is used to do JS batch operations. +type BatcherClient interface { + New(id string) (Batcher, error) + Store() *maps.Cache[string, Batcher] +} + +// BatchPackage holds a group of JavaScript resources. +type BatchPackage interface { + Groups() map[string]resource.Resources +} + +// Batcher is used to build JavaScript packages. +type Batcher interface { + Build(context.Context) (BatchPackage, error) + Config(ctx context.Context) OptionsSetter + Group(ctx context.Context, id string) BatcherGroup +} + +// BatcherGroup is a group of scripts and instances. +type BatcherGroup interface { + Instance(sid, iid string) OptionsSetter + Runner(id string) OptionsSetter + Script(id string) OptionsSetter +} + +// OptionsSetter is used to set options for a batch, script or instance. +type OptionsSetter interface { + SetOptions(map[string]any) string +} diff --git a/internal/js/esbuild/batch-esm-runner.gotmpl b/internal/js/esbuild/batch-esm-runner.gotmpl new file mode 100644 index 000000000..3193b4c30 --- /dev/null +++ b/internal/js/esbuild/batch-esm-runner.gotmpl @@ -0,0 +1,20 @@ +{{ range $i, $e := .Scripts -}} + {{ if eq .Export "*" }} + {{- printf "import %s as Script%d from %q;" .Export $i .Import -}} + {{ else -}} + {{- printf "import { %s as Script%d } from %q;" .Export $i .Import -}} + {{ end -}} +{{ end -}} +{{ range $i, $e := .Runners }} + {{- printf "import { %s as Run%d } from %q;" .Export $i .Import -}} +{{ end -}} +{{ if .Runners -}} + let group = { id: "{{ $.ID }}", scripts: [] } + {{ range $i, $e := .Scripts -}} + group.scripts.push({{ .RunnerJSON $i }}); + {{ end -}} + {{ range $i, $e := .Runners -}} + {{ $id := printf "Run%d" $i }} + {{ $id }}(group); + {{ end -}} +{{ end -}} diff --git a/internal/js/esbuild/batch.go b/internal/js/esbuild/batch.go new file mode 100644 index 000000000..aa50cf2c1 --- /dev/null +++ b/internal/js/esbuild/batch.go @@ -0,0 +1,1444 @@ +// 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 esbuild provides functions for building JavaScript resources. +package esbuild + +import ( + "bytes" + "context" + _ "embed" + "encoding/json" + "fmt" + "io" + "path" + "path/filepath" + "reflect" + "sort" + "strings" + "sync" + "sync/atomic" + + "github.com/evanw/esbuild/pkg/api" + "github.com/gohugoio/hugo/cache/dynacache" + "github.com/gohugoio/hugo/common/hugio" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/common/paths" + "github.com/gohugoio/hugo/deps" + "github.com/gohugoio/hugo/helpers" + "github.com/gohugoio/hugo/identity" + "github.com/gohugoio/hugo/internal/js" + "github.com/gohugoio/hugo/lazy" + "github.com/gohugoio/hugo/media" + "github.com/gohugoio/hugo/resources" + "github.com/gohugoio/hugo/resources/resource" + "github.com/gohugoio/hugo/resources/resource_factories/create" + "github.com/gohugoio/hugo/tpl/tplimpl" + "github.com/mitchellh/mapstructure" + "github.com/spf13/cast" +) + +var _ js.Batcher = (*batcher)(nil) + +const ( + NsBatch = "_hugo-js-batch" + + propsKeyImportContext = "importContext" + propsResoure = "resource" +) + +//go:embed batch-esm-runner.gotmpl +var runnerTemplateStr string + +var _ js.BatchPackage = (*Package)(nil) + +var _ buildToucher = (*optsHolder[scriptOptions])(nil) + +var ( + _ buildToucher = (*scriptGroup)(nil) + _ isBuiltOrTouchedProvider = (*scriptGroup)(nil) +) + +func NewBatcherClient(deps *deps.Deps) (js.BatcherClient, error) { + c := &BatcherClient{ + d: deps, + buildClient: NewBuildClient(deps.BaseFs.Assets, deps.ResourceSpec), + createClient: create.New(deps.ResourceSpec), + batcherStore: maps.NewCache[string, js.Batcher](), + bundlesStore: maps.NewCache[string, js.BatchPackage](), + } + + deps.BuildEndListeners.Add(func(...any) bool { + c.bundlesStore.Reset() + return false + }) + + return c, nil +} + +func (o optionsMap[K, C]) ByKey() optionsGetSetters[K, C] { + var values []optionsGetSetter[K, C] + for _, v := range o { + values = append(values, v) + } + + sort.Slice(values, func(i, j int) bool { + return values[i].Key().String() < values[j].Key().String() + }) + + return values +} + +func (o *opts[K, C]) Compiled() C { + o.h.checkCompileErr() + return o.h.compiled +} + +func (os optionsGetSetters[K, C]) Filter(predicate func(K) bool) optionsGetSetters[K, C] { + var a optionsGetSetters[K, C] + for _, v := range os { + if predicate(v.Key()) { + a = append(a, v) + } + } + return a +} + +func (o *optsHolder[C]) IdentifierBase() string { + return o.optionsID +} + +func (o *opts[K, C]) Key() K { + return o.key +} + +func (o *opts[K, C]) Reset() { + mu := o.once.ResetWithLock() + defer mu.Unlock() + o.h.resetCounter++ +} + +func (o *opts[K, C]) Get(id uint32) js.OptionsSetter { + var b *optsHolder[C] + o.once.Do(func() { + b = o.h + b.setBuilt(id) + }) + return b +} + +func (o *opts[K, C]) GetIdentity() identity.Identity { + return o.h +} + +func (o *optsHolder[C]) SetOptions(m map[string]any) string { + o.optsSetCounter++ + o.optsPrev = o.optsCurr + o.optsCurr = m + o.compiledPrev = o.compiled + o.compiled, o.compileErr = o.compiled.compileOptions(m, o.defaults) + o.checkCompileErr() + return "" +} + +// ValidateBatchID validates the given ID according to some very +func ValidateBatchID(id string, isTopLevel bool) error { + if id == "" { + return fmt.Errorf("id must be set") + } + // No Windows slashes. + if strings.Contains(id, "\\") { + return fmt.Errorf("id must not contain backslashes") + } + + // Allow forward slashes in top level IDs only. + if !isTopLevel && strings.Contains(id, "/") { + return fmt.Errorf("id must not contain forward slashes") + } + + return nil +} + +func newIsBuiltOrTouched() isBuiltOrTouched { + return isBuiltOrTouched{ + built: make(buildIDs), + touched: make(buildIDs), + } +} + +func newOpts[K any, C optionsCompiler[C]](key K, optionsID string, defaults defaultOptionValues) *opts[K, C] { + return &opts[K, C]{ + key: key, + h: &optsHolder[C]{ + optionsID: optionsID, + defaults: defaults, + isBuiltOrTouched: newIsBuiltOrTouched(), + }, + } +} + +// BatcherClient is a client for building JavaScript packages. +type BatcherClient struct { + d *deps.Deps + + once sync.Once + runnerTemplate *tplimpl.TemplInfo + + createClient *create.Client + buildClient *BuildClient + + batcherStore *maps.Cache[string, js.Batcher] + bundlesStore *maps.Cache[string, js.BatchPackage] +} + +// New creates a new Batcher with the given ID. +// This will be typically created once and reused across rebuilds. +func (c *BatcherClient) New(id string) (js.Batcher, error) { + var initErr error + c.once.Do(func() { + // We should fix the initialization order here (or use the Go template package directly), but we need to wait + // for the Hugo templates to be ready. + tmpl, err := c.d.TemplateStore.TextParse("batch-esm-runner", runnerTemplateStr) + if err != nil { + initErr = err + return + } + c.runnerTemplate = tmpl + }) + + if initErr != nil { + return nil, initErr + } + + dependencyManager := c.d.Conf.NewIdentityManager("jsbatch_" + id) + configID := "config_" + id + + b := &batcher{ + id: id, + scriptGroups: make(map[string]*scriptGroup), + dependencyManager: dependencyManager, + client: c, + configOptions: newOpts[scriptID, configOptions]( + scriptID(configID), + configID, + defaultOptionValues{}, + ), + } + + c.d.BuildEndListeners.Add(func(...any) bool { + b.reset() + return false + }) + + idFinder := identity.NewFinder(identity.FinderConfig{}) + + c.d.OnChangeListeners.Add(func(ids ...identity.Identity) bool { + for _, id := range ids { + if r := idFinder.Contains(id, b.dependencyManager, 50); r > 0 { + b.staleVersion.Add(1) + return false + } + + sp, ok := id.(identity.DependencyManagerScopedProvider) + if !ok { + continue + } + idms := sp.GetDependencyManagerForScopesAll() + + for _, g := range b.scriptGroups { + g.forEachIdentity(func(id2 identity.Identity) bool { + bt, ok := id2.(buildToucher) + if !ok { + return false + } + for _, id3 := range idms { + // This handles the removal of the only source for a script group (e.g. all shortcodes in a contnt page). + // Note the very shallow search. + if r := idFinder.Contains(id2, id3, 0); r > 0 { + bt.setTouched(b.buildCount) + return false + } + } + return false + }) + } + } + + return false + }) + + return b, nil +} + +func (c *BatcherClient) Store() *maps.Cache[string, js.Batcher] { + return c.batcherStore +} + +func (c *BatcherClient) buildBatchGroup(ctx context.Context, t *batchGroupTemplateContext) (resource.Resource, string, error) { + var buf bytes.Buffer + + if err := c.d.GetTemplateStore().ExecuteWithContext(ctx, c.runnerTemplate, &buf, t); err != nil { + return nil, "", err + } + + s := paths.AddLeadingSlash(t.keyPath + ".js") + r, err := c.createClient.FromString(s, buf.String()) + if err != nil { + return nil, "", err + } + + return r, s, nil +} + +// Package holds a group of JavaScript resources. +type Package struct { + id string + b *batcher + + groups map[string]resource.Resources +} + +func (p *Package) Groups() map[string]resource.Resources { + return p.groups +} + +type batchGroupTemplateContext struct { + keyPath string + ID string + Runners []scriptRunnerTemplateContext + Scripts []scriptBatchTemplateContext +} + +type batcher struct { + mu sync.Mutex + id string + buildCount uint32 + staleVersion atomic.Uint32 + scriptGroups scriptGroups + + client *BatcherClient + dependencyManager identity.Manager + + configOptions optionsGetSetter[scriptID, configOptions] + + // The last successfully built package. + // If this is non-nil and not stale, we can reuse it (e.g. on server rebuilds) + prevBuild *Package +} + +// Build builds the batch if not already built or if it's stale. +func (b *batcher) Build(ctx context.Context) (js.BatchPackage, error) { + key := dynacache.CleanKey(b.id + ".js") + p, err := b.client.bundlesStore.GetOrCreate(key, func() (js.BatchPackage, error) { + return b.build(ctx) + }) + if err != nil { + return nil, fmt.Errorf("failed to build JS batch %q: %w", b.id, err) + } + return p, nil +} + +func (b *batcher) Config(ctx context.Context) js.OptionsSetter { + return b.configOptions.Get(b.buildCount) +} + +func (b *batcher) Group(ctx context.Context, id string) js.BatcherGroup { + if err := ValidateBatchID(id, false); err != nil { + panic(err) + } + + b.mu.Lock() + defer b.mu.Unlock() + + group, found := b.scriptGroups[id] + if !found { + idm := b.client.d.Conf.NewIdentityManager("jsbatch_" + id) + b.dependencyManager.AddIdentity(idm) + + group = &scriptGroup{ + id: id, b: b, + isBuiltOrTouched: newIsBuiltOrTouched(), + dependencyManager: idm, + scriptsOptions: make(optionsMap[scriptID, scriptOptions]), + instancesOptions: make(optionsMap[instanceID, paramsOptions]), + runnersOptions: make(optionsMap[scriptID, scriptOptions]), + } + b.scriptGroups[id] = group + } + + group.setBuilt(b.buildCount) + + return group +} + +func (b *batcher) isStale() bool { + if b.staleVersion.Load() > 0 { + return true + } + + if b.removeNotSet() { + return true + } + + if b.configOptions.isStale() { + return true + } + + for _, v := range b.scriptGroups { + if v.isStale() { + return true + } + } + + return false +} + +func (b *batcher) build(ctx context.Context) (js.BatchPackage, error) { + b.mu.Lock() + defer b.mu.Unlock() + defer func() { + b.staleVersion.Store(0) + b.buildCount++ + }() + + if b.prevBuild != nil { + if !b.isStale() { + return b.prevBuild, nil + } + } + + p, err := b.doBuild(ctx) + if err != nil { + return nil, err + } + + b.prevBuild = p + + return p, nil +} + +func (b *batcher) doBuild(ctx context.Context) (*Package, error) { + type importContext struct { + name string + resourceGetter resource.ResourceGetter + scriptOptions scriptOptions + dm identity.Manager + } + + state := struct { + importResource *maps.Cache[string, resource.Resource] + resultResource *maps.Cache[string, resource.Resource] + importerImportContext *maps.Cache[string, importContext] + pathGroup *maps.Cache[string, string] + }{ + importResource: maps.NewCache[string, resource.Resource](), + resultResource: maps.NewCache[string, resource.Resource](), + importerImportContext: maps.NewCache[string, importContext](), + pathGroup: maps.NewCache[string, string](), + } + + multihostBasePaths := b.client.d.ResourceSpec.MultihostTargetBasePaths + + // Entry points passed to ESBuid. + var entryPoints []string + addResource := func(group, pth string, r resource.Resource, isResult bool) { + state.pathGroup.Set(paths.TrimExt(pth), group) + state.importResource.Set(pth, r) + if isResult { + state.resultResource.Set(pth, r) + } + entryPoints = append(entryPoints, pth) + } + + for _, g := range b.scriptGroups.Sorted() { + keyPath := g.id + + t := &batchGroupTemplateContext{ + keyPath: keyPath, + ID: g.id, + } + + instances := g.instancesOptions.ByKey() + + for _, vv := range g.scriptsOptions.ByKey() { + keyPath := keyPath + "_" + vv.Key().String() + opts := vv.Compiled() + impPath := path.Join(PrefixHugoVirtual, opts.Dir(), keyPath+opts.Resource.MediaType().FirstSuffix.FullSuffix) + impCtx := opts.ImportContext + + state.importerImportContext.Set(impPath, importContext{ + name: keyPath, + resourceGetter: impCtx, + scriptOptions: opts, + dm: g.dependencyManager, + }) + + bt := scriptBatchTemplateContext{ + opts: vv, + Import: impPath, + Instances: []scriptInstanceBatchTemplateContext{}, + } + state.importResource.Set(bt.Import, vv.Compiled().Resource) + predicate := func(k instanceID) bool { + return k.scriptID == vv.Key() + } + for _, vvv := range instances.Filter(predicate) { + bt.Instances = append(bt.Instances, scriptInstanceBatchTemplateContext{opts: vvv}) + } + + t.Scripts = append(t.Scripts, bt) + } + + for _, vv := range g.runnersOptions.ByKey() { + runnerKeyPath := keyPath + "_" + vv.Key().String() + runnerImpPath := paths.AddLeadingSlash(runnerKeyPath + "_runner" + vv.Compiled().Resource.MediaType().FirstSuffix.FullSuffix) + t.Runners = append(t.Runners, scriptRunnerTemplateContext{opts: vv, Import: runnerImpPath}) + addResource(g.id, runnerImpPath, vv.Compiled().Resource, false) + } + + r, s, err := b.client.buildBatchGroup(ctx, t) + if err != nil { + return nil, fmt.Errorf("failed to build JS batch: %w", err) + } + + state.importerImportContext.Set(s, importContext{ + name: s, + resourceGetter: nil, + dm: g.dependencyManager, + }) + + addResource(g.id, s, r, true) + } + + mediaTypes := b.client.d.ResourceSpec.MediaTypes() + + externalOptions := b.configOptions.Compiled().Options + if externalOptions.Format == "" { + externalOptions.Format = "esm" + } + if externalOptions.Format != "esm" { + return nil, fmt.Errorf("only esm format is currently supported") + } + + jsOpts := Options{ + ExternalOptions: externalOptions, + InternalOptions: InternalOptions{ + DependencyManager: b.dependencyManager, + Splitting: true, + ImportOnResolveFunc: func(imp string, args api.OnResolveArgs) string { + var importContextPath string + if args.Kind == api.ResolveEntryPoint { + importContextPath = args.Path + } else { + importContextPath = args.Importer + } + importContext, importContextFound := state.importerImportContext.Get(importContextPath) + + // We want to track the dependencies closest to where they're used. + dm := b.dependencyManager + if importContextFound { + dm = importContext.dm + } + + if r, found := state.importResource.Get(imp); found { + dm.AddIdentity(identity.FirstIdentity(r)) + return imp + } + + if importContext.resourceGetter != nil { + resolved := ResolveResource(imp, importContext.resourceGetter) + if resolved != nil { + resolvePath := resources.InternalResourceTargetPath(resolved) + dm.AddIdentity(identity.FirstIdentity(resolved)) + imp := PrefixHugoVirtual + resolvePath + state.importResource.Set(imp, resolved) + state.importerImportContext.Set(imp, importContext) + return imp + + } + } + return "" + }, + ImportOnLoadFunc: func(args api.OnLoadArgs) string { + imp := args.Path + + if r, found := state.importResource.Get(imp); found { + content, err := r.(resource.ContentProvider).Content(ctx) + if err != nil { + panic(err) + } + return cast.ToString(content) + } + return "" + }, + ImportParamsOnLoadFunc: func(args api.OnLoadArgs) json.RawMessage { + if importContext, found := state.importerImportContext.Get(args.Path); found { + if !importContext.scriptOptions.IsZero() { + return importContext.scriptOptions.Params + } + } + return nil + }, + ErrorMessageResolveFunc: func(args api.Message) *ErrorMessageResolved { + if loc := args.Location; loc != nil { + path := strings.TrimPrefix(loc.File, NsHugoImportResolveFunc+":") + if r, found := state.importResource.Get(path); found { + sourcePath := resources.InternalResourceSourcePathBestEffort(r) + + var contentr hugio.ReadSeekCloser + if cp, ok := r.(hugio.ReadSeekCloserProvider); ok { + contentr, _ = cp.ReadSeekCloser() + } + return &ErrorMessageResolved{ + Content: contentr, + Path: sourcePath, + Message: args.Text, + } + + } + + } + return nil + }, + ResolveSourceMapSource: func(s string) string { + if r, found := state.importResource.Get(s); found { + if ss := resources.InternalResourceSourcePath(r); ss != "" { + return ss + } + return PrefixHugoMemory + s + } + return "" + }, + EntryPoints: entryPoints, + }, + } + + result, err := b.client.buildClient.Build(jsOpts) + if err != nil { + return nil, fmt.Errorf("failed to build JS bundle: %w", err) + } + + groups := make(map[string]resource.Resources) + + createAndAddResource := func(targetPath, group string, o api.OutputFile, mt media.Type) error { + var sourceFilename string + if r, found := state.importResource.Get(targetPath); found { + sourceFilename = resources.InternalResourceSourcePathBestEffort(r) + } + targetPath = path.Join(b.id, targetPath) + + rd := resources.ResourceSourceDescriptor{ + LazyPublish: true, + OpenReadSeekCloser: func() (hugio.ReadSeekCloser, error) { + return hugio.NewReadSeekerNoOpCloserFromBytes(o.Contents), nil + }, + MediaType: mt, + TargetPath: targetPath, + SourceFilenameOrPath: sourceFilename, + } + r, err := b.client.d.ResourceSpec.NewResource(rd) + if err != nil { + return err + } + + groups[group] = append(groups[group], r) + + return nil + } + + outDir := b.client.d.AbsPublishDir + + createAndAddResources := func(o api.OutputFile) (bool, error) { + p := paths.ToSlashPreserveLeading(strings.TrimPrefix(o.Path, outDir)) + ext := path.Ext(p) + mt, _, found := mediaTypes.GetBySuffix(ext) + if !found { + return false, nil + } + + group, found := state.pathGroup.Get(paths.TrimExt(p)) + + if !found { + return false, nil + } + + if err := createAndAddResource(p, group, o, mt); err != nil { + return false, err + } + + return true, nil + } + + for _, o := range result.OutputFiles { + handled, err := createAndAddResources(o) + if err != nil { + return nil, err + } + + if !handled { + // Copy to destination. + // In a multihost setup, we will have multiple targets. + var targetFilenames []string + if len(multihostBasePaths) > 0 { + for _, base := range multihostBasePaths { + p := strings.TrimPrefix(o.Path, outDir) + targetFilename := filepath.Join(base, b.id, p) + targetFilenames = append(targetFilenames, targetFilename) + } + } else { + p := strings.TrimPrefix(o.Path, outDir) + targetFilename := filepath.Join(b.id, p) + targetFilenames = append(targetFilenames, targetFilename) + } + + fs := b.client.d.BaseFs.PublishFs + + if err := func() error { + fw, err := helpers.OpenFilesForWriting(fs, targetFilenames...) + if err != nil { + return err + } + defer fw.Close() + + fr := bytes.NewReader(o.Contents) + + _, err = io.Copy(fw, fr) + + return err + }(); err != nil { + return nil, fmt.Errorf("failed to copy to %q: %w", targetFilenames, err) + } + } + } + + p := &Package{ + id: path.Join(NsBatch, b.id), + b: b, + groups: groups, + } + + return p, nil +} + +func (b *batcher) removeNotSet() bool { + // We already have the lock. + var removed bool + currentBuildID := b.buildCount + for k, v := range b.scriptGroups { + if !v.isBuilt(currentBuildID) && v.isTouched(currentBuildID) { + // Remove entire group. + removed = true + delete(b.scriptGroups, k) + continue + } + if v.removeTouchedButNotSet() { + removed = true + } + if v.removeNotSet() { + removed = true + } + } + + return removed +} + +func (b *batcher) reset() { + b.mu.Lock() + defer b.mu.Unlock() + b.configOptions.Reset() + for _, v := range b.scriptGroups { + v.Reset() + } +} + +type buildIDs map[uint32]bool + +func (b buildIDs) Has(buildID uint32) bool { + return b[buildID] +} + +func (b buildIDs) Set(buildID uint32) { + b[buildID] = true +} + +type buildToucher interface { + setTouched(buildID uint32) +} + +type configOptions struct { + Options ExternalOptions +} + +func (s configOptions) isStaleCompiled(prev configOptions) bool { + return false +} + +func (s configOptions) compileOptions(m map[string]any, defaults defaultOptionValues) (configOptions, error) { + config, err := DecodeExternalOptions(m) + if err != nil { + return configOptions{}, err + } + + return configOptions{ + Options: config, + }, nil +} + +type defaultOptionValues struct { + defaultExport string +} + +type instanceID struct { + scriptID scriptID + instanceID string +} + +func (i instanceID) String() string { + return i.scriptID.String() + "_" + i.instanceID +} + +type isBuiltOrTouched struct { + built buildIDs + touched buildIDs +} + +func (i isBuiltOrTouched) setBuilt(id uint32) { + i.built.Set(id) +} + +func (i isBuiltOrTouched) isBuilt(id uint32) bool { + return i.built.Has(id) +} + +func (i isBuiltOrTouched) setTouched(id uint32) { + i.touched.Set(id) +} + +func (i isBuiltOrTouched) isTouched(id uint32) bool { + return i.touched.Has(id) +} + +type isBuiltOrTouchedProvider interface { + isBuilt(uint32) bool + isTouched(uint32) bool +} + +type key interface { + comparable + fmt.Stringer +} + +type optionsCompiler[C any] interface { + isStaleCompiled(C) bool + compileOptions(map[string]any, defaultOptionValues) (C, error) +} + +type optionsGetSetter[K, C any] interface { + isBuiltOrTouchedProvider + identity.IdentityProvider + // resource.StaleInfo + + Compiled() C + Key() K + Reset() + + Get(uint32) js.OptionsSetter + isStale() bool + currPrev() (map[string]any, map[string]any) +} + +type optionsGetSetters[K key, C any] []optionsGetSetter[K, C] + +type optionsMap[K key, C any] map[K]optionsGetSetter[K, C] + +type opts[K any, C optionsCompiler[C]] struct { + key K + h *optsHolder[C] + once lazy.OnceMore +} + +type optsHolder[C optionsCompiler[C]] struct { + optionsID string + + defaults defaultOptionValues + + // Keep track of one generation so we can detect changes. + // Note that most of this tracking is performed on the options/map level. + compiled C + compiledPrev C + compileErr error + + resetCounter uint32 + optsSetCounter uint32 + optsCurr map[string]any + optsPrev map[string]any + + isBuiltOrTouched +} + +type paramsOptions struct { + Params json.RawMessage +} + +func (s paramsOptions) isStaleCompiled(prev paramsOptions) bool { + return false +} + +func (s paramsOptions) compileOptions(m map[string]any, defaults defaultOptionValues) (paramsOptions, error) { + v := struct { + Params map[string]any + }{} + + if err := mapstructure.WeakDecode(m, &v); err != nil { + return paramsOptions{}, err + } + + paramsJSON, err := json.Marshal(v.Params) + if err != nil { + return paramsOptions{}, err + } + + return paramsOptions{ + Params: paramsJSON, + }, nil +} + +type scriptBatchTemplateContext struct { + opts optionsGetSetter[scriptID, scriptOptions] + Import string + Instances []scriptInstanceBatchTemplateContext +} + +func (s *scriptBatchTemplateContext) Export() string { + return s.opts.Compiled().Export +} + +func (c scriptBatchTemplateContext) MarshalJSON() (b []byte, err error) { + return json.Marshal(&struct { + ID string `json:"id"` + Instances []scriptInstanceBatchTemplateContext `json:"instances"` + }{ + ID: c.opts.Key().String(), + Instances: c.Instances, + }) +} + +func (b scriptBatchTemplateContext) RunnerJSON(i int) string { + script := fmt.Sprintf("Script%d", i) + + v := struct { + ID string `json:"id"` + + // Read-only live JavaScript binding. + Binding string `json:"binding"` + Instances []scriptInstanceBatchTemplateContext `json:"instances"` + }{ + b.opts.Key().String(), + script, + b.Instances, + } + + bb, err := json.Marshal(v) + if err != nil { + panic(err) + } + s := string(bb) + + // Remove the quotes to make it a valid JS object. + s = strings.ReplaceAll(s, fmt.Sprintf("%q", script), script) + + return s +} + +type scriptGroup struct { + mu sync.Mutex + id string + b *batcher + isBuiltOrTouched + dependencyManager identity.Manager + + scriptsOptions optionsMap[scriptID, scriptOptions] + instancesOptions optionsMap[instanceID, paramsOptions] + runnersOptions optionsMap[scriptID, scriptOptions] +} + +// For internal use only. +func (b *scriptGroup) GetDependencyManager() identity.Manager { + return b.dependencyManager +} + +// For internal use only. +func (b *scriptGroup) IdentifierBase() string { + return b.id +} + +func (s *scriptGroup) Instance(sid, id string) js.OptionsSetter { + if err := ValidateBatchID(sid, false); err != nil { + panic(err) + } + if err := ValidateBatchID(id, false); err != nil { + panic(err) + } + + s.mu.Lock() + defer s.mu.Unlock() + + iid := instanceID{scriptID: scriptID(sid), instanceID: id} + if v, found := s.instancesOptions[iid]; found { + return v.Get(s.b.buildCount) + } + + fullID := "instance_" + s.key() + "_" + iid.String() + + s.instancesOptions[iid] = newOpts[instanceID, paramsOptions]( + iid, + fullID, + defaultOptionValues{}, + ) + + return s.instancesOptions[iid].Get(s.b.buildCount) +} + +func (g *scriptGroup) Reset() { + for _, v := range g.scriptsOptions { + v.Reset() + } + for _, v := range g.instancesOptions { + v.Reset() + } + for _, v := range g.runnersOptions { + v.Reset() + } +} + +func (s *scriptGroup) Runner(id string) js.OptionsSetter { + if err := ValidateBatchID(id, false); err != nil { + panic(err) + } + + s.mu.Lock() + defer s.mu.Unlock() + sid := scriptID(id) + if v, found := s.runnersOptions[sid]; found { + return v.Get(s.b.buildCount) + } + + runnerIdentity := "runner_" + s.key() + "_" + id + + // A typical signature for a runner would be: + // export default function Run(scripts) {} + // The user can override the default export in the templates. + + s.runnersOptions[sid] = newOpts[scriptID, scriptOptions]( + sid, + runnerIdentity, + defaultOptionValues{ + defaultExport: "default", + }, + ) + + return s.runnersOptions[sid].Get(s.b.buildCount) +} + +func (s *scriptGroup) Script(id string) js.OptionsSetter { + if err := ValidateBatchID(id, false); err != nil { + panic(err) + } + + s.mu.Lock() + defer s.mu.Unlock() + sid := scriptID(id) + if v, found := s.scriptsOptions[sid]; found { + return v.Get(s.b.buildCount) + } + + scriptIdentity := "script_" + s.key() + "_" + id + + s.scriptsOptions[sid] = newOpts[scriptID, scriptOptions]( + sid, + scriptIdentity, + defaultOptionValues{ + defaultExport: "*", + }, + ) + + return s.scriptsOptions[sid].Get(s.b.buildCount) +} + +func (s *scriptGroup) isStale() bool { + for _, v := range s.scriptsOptions { + if v.isStale() { + return true + } + } + + for _, v := range s.instancesOptions { + if v.isStale() { + return true + } + } + + for _, v := range s.runnersOptions { + if v.isStale() { + return true + } + } + + return false +} + +func (v *scriptGroup) forEachIdentity( + f func(id identity.Identity) bool, +) bool { + if f(v) { + return true + } + for _, vv := range v.instancesOptions { + if f(vv.GetIdentity()) { + return true + } + } + + for _, vv := range v.scriptsOptions { + if f(vv.GetIdentity()) { + return true + } + } + + for _, vv := range v.runnersOptions { + if f(vv.GetIdentity()) { + return true + } + } + + return false +} + +func (s *scriptGroup) key() string { + return s.b.id + "_" + s.id +} + +func (g *scriptGroup) removeNotSet() bool { + currentBuildID := g.b.buildCount + if !g.isBuilt(currentBuildID) { + // This group was never accessed in this build. + return false + } + var removed bool + + if g.instancesOptions.isBuilt(currentBuildID) { + // A new instance has been set in this group for this build. + // Remove any instance that has not been set in this build. + for k, v := range g.instancesOptions { + if v.isBuilt(currentBuildID) { + continue + } + delete(g.instancesOptions, k) + removed = true + } + } + + if g.runnersOptions.isBuilt(currentBuildID) { + // A new runner has been set in this group for this build. + // Remove any runner that has not been set in this build. + for k, v := range g.runnersOptions { + if v.isBuilt(currentBuildID) { + continue + } + delete(g.runnersOptions, k) + removed = true + } + } + + if g.scriptsOptions.isBuilt(currentBuildID) { + // A new script has been set in this group for this build. + // Remove any script that has not been set in this build. + for k, v := range g.scriptsOptions { + if v.isBuilt(currentBuildID) { + continue + } + delete(g.scriptsOptions, k) + + // Also remove any instance with this ID. + for kk := range g.instancesOptions { + if kk.scriptID == k { + delete(g.instancesOptions, kk) + } + } + removed = true + } + } + + return removed +} + +func (g *scriptGroup) removeTouchedButNotSet() bool { + currentBuildID := g.b.buildCount + var removed bool + for k, v := range g.instancesOptions { + if v.isBuilt(currentBuildID) { + continue + } + if v.isTouched(currentBuildID) { + delete(g.instancesOptions, k) + removed = true + } + } + for k, v := range g.runnersOptions { + if v.isBuilt(currentBuildID) { + continue + } + if v.isTouched(currentBuildID) { + delete(g.runnersOptions, k) + removed = true + } + } + for k, v := range g.scriptsOptions { + if v.isBuilt(currentBuildID) { + continue + } + if v.isTouched(currentBuildID) { + delete(g.scriptsOptions, k) + removed = true + + // Also remove any instance with this ID. + for kk := range g.instancesOptions { + if kk.scriptID == k { + delete(g.instancesOptions, kk) + } + } + } + + } + return removed +} + +type scriptGroups map[string]*scriptGroup + +func (s scriptGroups) Sorted() []*scriptGroup { + var a []*scriptGroup + for _, v := range s { + a = append(a, v) + } + sort.Slice(a, func(i, j int) bool { + return a[i].id < a[j].id + }) + return a +} + +type scriptID string + +func (s scriptID) String() string { + return string(s) +} + +type scriptInstanceBatchTemplateContext struct { + opts optionsGetSetter[instanceID, paramsOptions] +} + +func (c scriptInstanceBatchTemplateContext) ID() string { + return c.opts.Key().instanceID +} + +func (c scriptInstanceBatchTemplateContext) MarshalJSON() (b []byte, err error) { + return json.Marshal(&struct { + ID string `json:"id"` + Params json.RawMessage `json:"params"` + }{ + ID: c.opts.Key().instanceID, + Params: c.opts.Compiled().Params, + }) +} + +type scriptOptions struct { + // The script to build. + Resource resource.Resource + + // The import context to use. + // Note that we will always fall back to the resource's own import context. + ImportContext resource.ResourceGetter + + // The export name to use for this script's group's runners (if any). + // If not set, the default export will be used. + Export string + + // Params marshaled to JSON. + Params json.RawMessage +} + +func (o *scriptOptions) Dir() string { + return path.Dir(resources.InternalResourceTargetPath(o.Resource)) +} + +func (s scriptOptions) IsZero() bool { + return s.Resource == nil +} + +func (s scriptOptions) isStaleCompiled(prev scriptOptions) bool { + if prev.IsZero() { + return false + } + + // All but the ImportContext are checked at the options/map level. + i1nil, i2nil := prev.ImportContext == nil, s.ImportContext == nil + if i1nil && i2nil { + return false + } + if i1nil || i2nil { + return true + } + // On its own this check would have too many false positives, but combined with the other checks it should be fine. + // We cannot do equality checking here. + if !prev.ImportContext.(resource.IsProbablySameResourceGetter).IsProbablySameResourceGetter(s.ImportContext) { + return true + } + + return false +} + +func (s scriptOptions) compileOptions(m map[string]any, defaults defaultOptionValues) (scriptOptions, error) { + v := struct { + Resource resource.Resource + ImportContext any + Export string + Params map[string]any + }{} + + if err := mapstructure.WeakDecode(m, &v); err != nil { + panic(err) + } + + var paramsJSON []byte + if v.Params != nil { + var err error + paramsJSON, err = json.Marshal(v.Params) + if err != nil { + panic(err) + } + } + + if v.Export == "" { + v.Export = defaults.defaultExport + } + + compiled := scriptOptions{ + Resource: v.Resource, + Export: v.Export, + ImportContext: resource.NewCachedResourceGetter(v.ImportContext), + Params: paramsJSON, + } + + if compiled.Resource == nil { + return scriptOptions{}, fmt.Errorf("resource not set") + } + + return compiled, nil +} + +type scriptRunnerTemplateContext struct { + opts optionsGetSetter[scriptID, scriptOptions] + Import string +} + +func (s *scriptRunnerTemplateContext) Export() string { + return s.opts.Compiled().Export +} + +func (c scriptRunnerTemplateContext) MarshalJSON() (b []byte, err error) { + return json.Marshal(&struct { + ID string `json:"id"` + }{ + ID: c.opts.Key().String(), + }) +} + +func (o optionsMap[K, C]) isBuilt(id uint32) bool { + for _, v := range o { + if v.isBuilt(id) { + return true + } + } + + return false +} + +func (o *opts[K, C]) isBuilt(id uint32) bool { + return o.h.isBuilt(id) +} + +func (o *opts[K, C]) isStale() bool { + if o.h.isStaleOpts() { + return true + } + if o.h.compiled.isStaleCompiled(o.h.compiledPrev) { + return true + } + return false +} + +func (o *optsHolder[C]) isStaleOpts() bool { + if o.optsSetCounter == 1 && o.resetCounter > 0 { + return false + } + isStale := func() bool { + if len(o.optsCurr) != len(o.optsPrev) { + return true + } + for k, v := range o.optsPrev { + vv, found := o.optsCurr[k] + if !found { + return true + } + if strings.EqualFold(k, propsKeyImportContext) { + // This is checked later. + } else if si, ok := vv.(resource.StaleInfo); ok { + if si.StaleVersion() > 0 { + return true + } + } else { + if !reflect.DeepEqual(v, vv) { + return true + } + } + } + return false + }() + + return isStale +} + +func (o *opts[K, C]) isTouched(id uint32) bool { + return o.h.isTouched(id) +} + +func (o *optsHolder[C]) checkCompileErr() { + if o.compileErr != nil { + panic(o.compileErr) + } +} + +func (o *opts[K, C]) currPrev() (map[string]any, map[string]any) { + return o.h.optsCurr, o.h.optsPrev +} + +func init() { + // We don't want any dependencies/change tracking on the top level Package, + // we want finer grained control via Package.Group. + var p any = &Package{} + if _, ok := p.(identity.Identity); ok { + panic("esbuid.Package should not implement identity.Identity") + } + if _, ok := p.(identity.DependencyManagerProvider); ok { + panic("esbuid.Package should not implement identity.DependencyManagerProvider") + } +} diff --git a/internal/js/esbuild/batch_integration_test.go b/internal/js/esbuild/batch_integration_test.go new file mode 100644 index 000000000..b4a2454ac --- /dev/null +++ b/internal/js/esbuild/batch_integration_test.go @@ -0,0 +1,723 @@ +// 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 provides functions for building JavaScript resources +package esbuild_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + qt "github.com/frankban/quicktest" + + "github.com/bep/logg" + "github.com/gohugoio/hugo/common/paths" + "github.com/gohugoio/hugo/hugolib" + "github.com/gohugoio/hugo/internal/js/esbuild" +) + +// Used to test misc. error situations etc. +const jsBatchFilesTemplate = ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "section"] +disableLiveReload = true +-- assets/js/styles.css -- +body { + background-color: red; +} +-- assets/js/main.js -- +import './styles.css'; +import * as params from '@params'; +import * as foo from 'mylib'; +console.log("Hello, Main!"); +console.log("params.p1", params.p1); +export default function Main() {}; +-- assets/js/runner.js -- +console.log("Hello, Runner!"); +-- node_modules/mylib/index.js -- +console.log("Hello, My Lib!"); +-- layouts/shortcodes/hdx.html -- +{{ $path := .Get "r" }} +{{ $r := or (.Page.Resources.Get $path) (resources.Get $path) }} +{{ $batch := (js.Batch "mybatch") }} +{{ $scriptID := $path | anchorize }} +{{ $instanceID := .Ordinal | string }} +{{ $group := .Page.RelPermalink | anchorize }} +{{ $params := .Params | default dict }} +{{ $export := .Get "export" | default "default" }} +{{ with $batch.Group $group }} + {{ with .Runner "create-elements" }} + {{ .SetOptions (dict "resource" (resources.Get "js/runner.js")) }} + {{ end }} + {{ with .Script $scriptID }} + {{ .SetOptions (dict + "resource" $r + "export" $export + "importContext" (slice $.Page) + ) + }} + {{ end }} + {{ with .Instance $scriptID $instanceID }} + {{ .SetOptions (dict "params" $params) }} + {{ end }} +{{ end }} +hdx-instance: {{ $scriptID }}: {{ $instanceID }}| +-- layouts/_default/baseof.html -- +Base. +{{ $batch := (js.Batch "mybatch") }} + {{ with $batch.Config }} + {{ .SetOptions (dict + "params" (dict "id" "config") + "sourceMap" "" + ) + }} +{{ end }} +{{ with (templates.Defer (dict "key" "global")) }} +Defer: +{{ $batch := (js.Batch "mybatch") }} +{{ range $k, $v := $batch.Build.Groups }} + {{ range $kk, $vv := . -}} + {{ $k }}: {{ .RelPermalink }} + {{ end }} +{{ end -}} +{{ end }} +{{ block "main" . }}Main{{ end }} +End. +-- layouts/_default/single.html -- +{{ define "main" }} +==> Single Template Content: {{ .Content }}$ +{{ $batch := (js.Batch "mybatch") }} +{{ with $batch.Group "mygroup" }} + {{ with .Runner "run" }} + {{ .SetOptions (dict "resource" (resources.Get "js/runner.js")) }} + {{ end }} + {{ with .Script "main" }} + {{ .SetOptions (dict "resource" (resources.Get "js/main.js") "params" (dict "p1" "param-p1-main" )) }} + {{ end }} + {{ with .Instance "main" "i1" }} + {{ .SetOptions (dict "params" (dict "title" "Instance 1")) }} + {{ end }} +{{ end }} +{{ end }} +-- layouts/index.html -- +{{ define "main" }} +Home. +{{ end }} +-- content/p1/index.md -- +--- +title: "P1" +--- + +Some content. + +{{< hdx r="p1script.js" myparam="p1-param-1" >}} +{{< hdx r="p1script.js" myparam="p1-param-2" >}} + +-- content/p1/p1script.js -- +console.log("P1 Script"); + + +` + +// Just to verify that the above file setup works. +func TestBatchTemplateOKBuild(t *testing.T) { + b := hugolib.Test(t, jsBatchFilesTemplate, hugolib.TestOptWithOSFs()) + b.AssertPublishDir("mybatch/mygroup.js", "mybatch/mygroup.css") +} + +func TestBatchRemoveAllInGroup(t *testing.T) { + files := jsBatchFilesTemplate + b := hugolib.TestRunning(t, files, hugolib.TestOptWithOSFs()) + + b.AssertFileContent("public/p1/index.html", "p1: /mybatch/p1.js") + + b.EditFiles("content/p1/index.md", ` +--- +title: "P1" +--- +Empty. +`) + b.Build() + + b.AssertFileContent("public/p1/index.html", "! p1: /mybatch/p1.js") + + // Add one script back. + b.EditFiles("content/p1/index.md", ` +--- +title: "P1" +--- + +{{< hdx r="p1script.js" myparam="p1-param-1-new" >}} +`) + b.Build() + + b.AssertFileContent("public/mybatch/p1.js", + "p1-param-1-new", + "p1script.js") +} + +func TestBatchEditInstance(t *testing.T) { + files := jsBatchFilesTemplate + b := hugolib.TestRunning(t, files, hugolib.TestOptWithOSFs()) + b.AssertFileContent("public/mybatch/mygroup.js", "Instance 1") + b.EditFileReplaceAll("layouts/_default/single.html", "Instance 1", "Instance 1 Edit").Build() + b.AssertFileContent("public/mybatch/mygroup.js", "Instance 1 Edit") +} + +func TestBatchEditScriptParam(t *testing.T) { + files := jsBatchFilesTemplate + b := hugolib.TestRunning(t, files, hugolib.TestOptWithOSFs()) + b.AssertFileContent("public/mybatch/mygroup.js", "param-p1-main") + b.EditFileReplaceAll("layouts/_default/single.html", "param-p1-main", "param-p1-main-edited").Build() + b.AssertFileContent("public/mybatch/mygroup.js", "param-p1-main-edited") +} + +func TestBatchMultiHost(t *testing.T) { + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "section"] +[languages] +[languages.en] +weight = 1 +baseURL = "https://example.com/en" +[languages.fr] +weight = 2 +baseURL = "https://example.com/fr" +disableLiveReload = true +-- assets/js/styles.css -- +body { + background-color: red; +} +-- assets/js/main.js -- +import * as foo from 'mylib'; +console.log("Hello, Main!"); +-- assets/js/runner.js -- +console.log("Hello, Runner!"); +-- node_modules/mylib/index.js -- +console.log("Hello, My Lib!"); +-- layouts/index.html -- +Home. +{{ $batch := (js.Batch "mybatch") }} + {{ with $batch.Config }} + {{ .SetOptions (dict + "params" (dict "id" "config") + "sourceMap" "" + ) + }} +{{ end }} +{{ with (templates.Defer (dict "key" "global")) }} +Defer: +{{ $batch := (js.Batch "mybatch") }} +{{ range $k, $v := $batch.Build.Groups }} + {{ range $kk, $vv := . -}} + {{ $k }}: {{ .RelPermalink }} + {{ end }} +{{ end -}} +{{ end }} +{{ $batch := (js.Batch "mybatch") }} +{{ with $batch.Group "mygroup" }} + {{ with .Runner "run" }} + {{ .SetOptions (dict "resource" (resources.Get "js/runner.js")) }} + {{ end }} + {{ with .Script "main" }} + {{ .SetOptions (dict "resource" (resources.Get "js/main.js") "params" (dict "p1" "param-p1-main" )) }} + {{ end }} + {{ with .Instance "main" "i1" }} + {{ .SetOptions (dict "params" (dict "title" "Instance 1")) }} + {{ end }} +{{ end }} + + +` + b := hugolib.Test(t, files, hugolib.TestOptWithOSFs()) + b.AssertPublishDir( + "en/mybatch/chunk-TOZKWCDE.js", "en/mybatch/mygroup.js ", + "fr/mybatch/mygroup.js", "fr/mybatch/chunk-TOZKWCDE.js") +} + +func TestBatchRenameBundledScript(t *testing.T) { + files := jsBatchFilesTemplate + b := hugolib.TestRunning(t, files, hugolib.TestOptWithOSFs()) + b.AssertFileContent("public/mybatch/p1.js", "P1 Script") + b.RenameFile("content/p1/p1script.js", "content/p1/p1script2.js") + _, err := b.BuildE() + b.Assert(err, qt.IsNotNil) + b.Assert(err.Error(), qt.Contains, "resource not set") + + // Rename it back. + b.RenameFile("content/p1/p1script2.js", "content/p1/p1script.js") + b.Build() +} + +func TestBatchErrorScriptResourceNotSet(t *testing.T) { + files := strings.Replace(jsBatchFilesTemplate, `(resources.Get "js/main.js")`, `(resources.Get "js/doesnotexist.js")`, 1) + b, err := hugolib.TestE(t, files, hugolib.TestOptWithOSFs()) + b.Assert(err, qt.IsNotNil) + b.Assert(err.Error(), qt.Contains, `error calling SetOptions: resource not set`) +} + +func TestBatchSlashInBatchID(t *testing.T) { + files := strings.ReplaceAll(jsBatchFilesTemplate, `"mybatch"`, `"my/batch"`) + b, err := hugolib.TestE(t, files, hugolib.TestOptWithOSFs()) + b.Assert(err, qt.IsNil) + b.AssertPublishDir("my/batch/mygroup.js") +} + +func TestBatchSourceMaps(t *testing.T) { + filesTemplate := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "section"] +disableLiveReload = true +-- assets/js/styles.css -- +body { + background-color: red; +} +-- assets/js/main.js -- +import * as foo from 'mylib'; +console.log("Hello, Main!"); +-- assets/js/runner.js -- +console.log("Hello, Runner!"); +-- node_modules/mylib/index.js -- +console.log("Hello, My Lib!"); +-- layouts/shortcodes/hdx.html -- +{{ $path := .Get "r" }} +{{ $r := or (.Page.Resources.Get $path) (resources.Get $path) }} +{{ $batch := (js.Batch "mybatch") }} +{{ $scriptID := $path | anchorize }} +{{ $instanceID := .Ordinal | string }} +{{ $group := .Page.RelPermalink | anchorize }} +{{ $params := .Params | default dict }} +{{ $export := .Get "export" | default "default" }} +{{ with $batch.Group $group }} + {{ with .Runner "create-elements" }} + {{ .SetOptions (dict "resource" (resources.Get "js/runner.js")) }} + {{ end }} + {{ with .Script $scriptID }} + {{ .SetOptions (dict + "resource" $r + "export" $export + "importContext" (slice $.Page) + ) + }} + {{ end }} + {{ with .Instance $scriptID $instanceID }} + {{ .SetOptions (dict "params" $params) }} + {{ end }} +{{ end }} +hdx-instance: {{ $scriptID }}: {{ $instanceID }}| +-- layouts/_default/baseof.html -- +Base. +{{ $batch := (js.Batch "mybatch") }} + {{ with $batch.Config }} + {{ .SetOptions (dict + "params" (dict "id" "config") + "sourceMap" "" + ) + }} +{{ end }} +{{ with (templates.Defer (dict "key" "global")) }} +Defer: +{{ $batch := (js.Batch "mybatch") }} +{{ range $k, $v := $batch.Build.Groups }} + {{ range $kk, $vv := . -}} + {{ $k }}: {{ .RelPermalink }} + {{ end }} +{{ end -}} +{{ end }} +{{ block "main" . }}Main{{ end }} +End. +-- layouts/_default/single.html -- +{{ define "main" }} +==> Single Template Content: {{ .Content }}$ +{{ $batch := (js.Batch "mybatch") }} +{{ with $batch.Group "mygroup" }} + {{ with .Runner "run" }} + {{ .SetOptions (dict "resource" (resources.Get "js/runner.js")) }} + {{ end }} + {{ with .Script "main" }} + {{ .SetOptions (dict "resource" (resources.Get "js/main.js") "params" (dict "p1" "param-p1-main" )) }} + {{ end }} + {{ with .Instance "main" "i1" }} + {{ .SetOptions (dict "params" (dict "title" "Instance 1")) }} + {{ end }} +{{ end }} +{{ end }} +-- layouts/index.html -- +{{ define "main" }} +Home. +{{ end }} +-- content/p1/index.md -- +--- +title: "P1" +--- + +Some content. + +{{< hdx r="p1script.js" myparam="p1-param-1" >}} +{{< hdx r="p1script.js" myparam="p1-param-2" >}} + +-- content/p1/p1script.js -- +import * as foo from 'mylib'; +console.lg("Foo", foo); +console.log("P1 Script"); +export default function P1Script() {}; + + +` + files := strings.Replace(filesTemplate, `"sourceMap" ""`, `"sourceMap" "linked"`, 1) + b := hugolib.TestRunning(t, files, hugolib.TestOptWithOSFs()) + b.AssertFileContent("public/mybatch/mygroup.js.map", "main.js", "! ns-hugo") + b.AssertFileContent("public/mybatch/mygroup.js", "sourceMappingURL=mygroup.js.map") + b.AssertFileContent("public/mybatch/p1.js", "sourceMappingURL=p1.js.map") + b.AssertFileContent("public/mybatch/mygroup_run_runner.js", "sourceMappingURL=mygroup_run_runner.js.map") + b.AssertFileContent("public/mybatch/chunk-UQKPPNA6.js", "sourceMappingURL=chunk-UQKPPNA6.js.map") + + 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) + } + } + + checkMap("public/mybatch/mygroup.js.map", 1) + checkMap("public/mybatch/p1.js.map", 1) + checkMap("public/mybatch/mygroup_run_runner.js.map", 0) + checkMap("public/mybatch/chunk-UQKPPNA6.js.map", 1) +} + +func TestBatchErrorRunnerResourceNotSet(t *testing.T) { + files := strings.Replace(jsBatchFilesTemplate, `(resources.Get "js/runner.js")`, `(resources.Get "js/doesnotexist.js")`, 1) + b, err := hugolib.TestE(t, files, hugolib.TestOptWithOSFs()) + b.Assert(err, qt.IsNotNil) + b.Assert(err.Error(), qt.Contains, `resource not set`) +} + +func TestBatchErrorScriptResourceInAssetsSyntaxError(t *testing.T) { + // Introduce JS syntax error in assets/js/main.js + files := strings.Replace(jsBatchFilesTemplate, `console.log("Hello, Main!");`, `console.log("Hello, Main!"`, 1) + b, err := hugolib.TestE(t, files, hugolib.TestOptWithOSFs()) + b.Assert(err, qt.IsNotNil) + b.Assert(err.Error(), qt.Contains, filepath.FromSlash(`assets/js/main.js:5:0": Expected ")" but found "console"`)) +} + +func TestBatchErrorScriptResourceInBundleSyntaxError(t *testing.T) { + // Introduce JS syntax error in content/p1/p1script.js + files := strings.Replace(jsBatchFilesTemplate, `console.log("P1 Script");`, `console.log("P1 Script"`, 1) + b, err := hugolib.TestE(t, files, hugolib.TestOptWithOSFs()) + b.Assert(err, qt.IsNotNil) + b.Assert(err.Error(), qt.Contains, filepath.FromSlash(`/content/p1/p1script.js:3:0": Expected ")" but found end of file`)) +} + +func TestBatch(t *testing.T) { + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term"] +disableLiveReload = true +baseURL = "https://example.com" +-- package.json -- +{ + "devDependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + } +} +-- assets/js/shims/react.js -- +-- assets/js/shims/react-dom.js -- +module.exports = window.ReactDOM; +module.exports = window.React; +-- content/mybundle/index.md -- +--- +title: "My Bundle" +--- +-- content/mybundle/mybundlestyles.css -- +@import './foo.css'; +@import './bar.css'; +@import './otherbundlestyles.css'; + +.mybundlestyles { + background-color: blue; +} +-- content/mybundle/bundlereact.jsx -- +import * as React from "react"; +import './foo.css'; +import './mybundlestyles.css'; +window.React1 = React; + +let text = 'Click me, too!' + +export default function MyBundleButton() { + return ( + + ) +} + +-- assets/js/reactrunner.js -- +import * as ReactDOM from 'react-dom/client'; +import * as React from 'react'; + +export default function Run(group) { + for (const module of group.scripts) { + for (const instance of module.instances) { + /* This is a convention in this project. */ + let elId = §§${module.id}-${instance.id}§§; + let el = document.getElementById(elId); + if (!el) { + console.warn(§§Element with id ${elId} not found§§); + continue; + } + const root = ReactDOM.createRoot(el); + const reactEl = React.createElement(module.mod, instance.params); + root.render(reactEl); + } + } +} +-- assets/other/otherbundlestyles.css -- +.otherbundlestyles { + background-color: red; +} +-- assets/other/foo.css -- +@import './bar.css'; + +.foo { + background-color: blue; +} +-- assets/other/bar.css -- +.bar { + background-color: red; +} +-- assets/js/button.css -- +button { + background-color: red; +} +-- assets/js/bar.css -- +.bar-assets { + background-color: red; +} +-- assets/js/helper.js -- +import './bar.css' + +export function helper() { + console.log('helper'); +} + +-- assets/js/react1styles_nested.css -- +.react1styles_nested { + background-color: red; +} +-- assets/js/react1styles.css -- +@import './react1styles_nested.css'; +.react1styles { + background-color: red; +} +-- assets/js/react1.jsx -- +import * as React from "react"; +import './button.css' +import './foo.css' +import './react1styles.css' + +window.React1 = React; + +let text = 'Click me' + +export default function MyButton() { + return ( + + ) +} + +-- assets/js/react2.jsx -- +import * as React from "react"; +import { helper } from './helper.js' +import './foo.css' + +window.React2 = React; + +let text = 'Click me, too!' + +export function MyOtherButton() { + return ( + + ) +} +-- assets/js/main1.js -- +import * as React from "react"; +import * as params from '@params'; + +console.log('main1.React', React) +console.log('main1.params.id', params.id) + +-- assets/js/main2.js -- +import * as React from "react"; +import * as params from '@params'; + +console.log('main2.React', React) +console.log('main2.params.id', params.id) + +export default function Main2() {}; + +-- assets/js/main3.js -- +import * as React from "react"; +import * as params from '@params'; +import * as config from '@params/config'; + +console.log('main3.params.id', params.id) +console.log('config.params.id', config.id) + +export default function Main3() {}; + +-- layouts/_default/single.html -- +Single. + +{{ $r := .Resources.GetMatch "*.jsx" }} +{{ $batch := (js.Batch "mybundle") }} +{{ $otherCSS := (resources.Match "/other/*.css").Mount "/other" "." }} + {{ with $batch.Config }} + {{ $shims := dict "react" "js/shims/react.js" "react-dom/client" "js/shims/react-dom.js" }} + {{ .SetOptions (dict + "target" "es2018" + "params" (dict "id" "config") + "shims" $shims + ) + }} +{{ end }} +{{ with $batch.Group "reactbatch" }} + {{ with .Script "r3" }} + {{ .SetOptions (dict + "resource" $r + "importContext" (slice $ $otherCSS) + "params" (dict "id" "r3") + ) + }} + {{ end }} + {{ with .Instance "r3" "r2i1" }} + {{ .SetOptions (dict "title" "r2 instance 1")}} + {{ end }} +{{ end }} +-- layouts/index.html -- +Home. +{{ with (templates.Defer (dict "key" "global")) }} +{{ $batch := (js.Batch "mybundle") }} +{{ range $k, $v := $batch.Build.Groups }} + {{ range $kk, $vv := . }} + {{ $k }}: {{ $kk }}: {{ .RelPermalink }} + {{ end }} + {{ end }} +{{ end }} +{{ $myContentBundle := site.GetPage "mybundle" }} +{{ $batch := (js.Batch "mybundle") }} +{{ $otherCSS := (resources.Match "/other/*.css").Mount "/other" "." }} +{{ with $batch.Group "mains" }} + {{ with .Script "main1" }} + {{ .SetOptions (dict + "resource" (resources.Get "js/main1.js") + "params" (dict "id" "main1") + ) + }} + {{ end }} + {{ with .Script "main2" }} + {{ .SetOptions (dict + "resource" (resources.Get "js/main2.js") + "params" (dict "id" "main2") + ) + }} + {{ end }} + {{ with .Script "main3" }} + {{ .SetOptions (dict + "resource" (resources.Get "js/main3.js") + ) + }} + {{ end }} +{{ with .Instance "main1" "m1i1" }}{{ .SetOptions (dict "params" (dict "title" "Main1 Instance 1"))}}{{ end }} +{{ with .Instance "main1" "m1i2" }}{{ .SetOptions (dict "params" (dict "title" "Main1 Instance 2"))}}{{ end }} +{{ end }} +{{ with $batch.Group "reactbatch" }} + {{ with .Runner "reactrunner" }} + {{ .SetOptions ( dict "resource" (resources.Get "js/reactrunner.js") )}} + {{ end }} + {{ with .Script "r1" }} + {{ .SetOptions (dict + "resource" (resources.Get "js/react1.jsx") + "importContext" (slice $myContentBundle $otherCSS) + "params" (dict "id" "r1") + ) + }} + {{ end }} + {{ with .Instance "r1" "i1" }}{{ .SetOptions (dict "params" (dict "title" "Instance 1"))}}{{ end }} + {{ with .Instance "r1" "i2" }}{{ .SetOptions (dict "params" (dict "title" "Instance 2"))}}{{ end }} + {{ with .Script "r2" }} + {{ .SetOptions (dict + "resource" (resources.Get "js/react2.jsx") + "export" "MyOtherButton" + "importContext" $otherCSS + "params" (dict "id" "r2") + ) + }} + {{ end }} + {{ with .Instance "r2" "i1" }}{{ .SetOptions (dict "params" (dict "title" "Instance 2-1"))}}{{ end }} +{{ end }} + +` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + NeedsOsFS: true, + NeedsNpmInstall: true, + TxtarString: files, + Running: true, + LogLevel: logg.LevelWarn, + // PrintAndKeepTempDir: true, + }).Build() + + b.AssertFileContent("public/index.html", + "mains: 0: /mybundle/mains.js", + "reactbatch: 2: /mybundle/reactbatch.css", + ) + + b.AssertFileContent("public/mybundle/reactbatch.css", + ".bar {", + ) + + // Verify params resolution. + b.AssertFileContent("public/mybundle/mains.js", + ` +var id = "main1"; +console.log("main1.params.id", id); +var id2 = "main2"; +console.log("main2.params.id", id2); + + +# Params from top level config. +var id3 = "config"; +console.log("main3.params.id", void 0); +console.log("config.params.id", id3); +`) + + b.EditFileReplaceAll("content/mybundle/mybundlestyles.css", ".mybundlestyles", ".mybundlestyles-edit").Build() + b.AssertFileContent("public/mybundle/reactbatch.css", ".mybundlestyles-edit {") + + b.EditFileReplaceAll("assets/other/bar.css", ".bar {", ".bar-edit {").Build() + b.AssertFileContent("public/mybundle/reactbatch.css", ".bar-edit {") + + b.EditFileReplaceAll("assets/other/bar.css", ".bar-edit {", ".bar-edit2 {").Build() + b.AssertFileContent("public/mybundle/reactbatch.css", ".bar-edit2 {") +} diff --git a/internal/js/esbuild/build.go b/internal/js/esbuild/build.go new file mode 100644 index 000000000..33b91eafc --- /dev/null +++ b/internal/js/esbuild/build.go @@ -0,0 +1,236 @@ +// 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 esbuild provides functions for building JavaScript resources. +package esbuild + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/evanw/esbuild/pkg/api" + "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/common/hugio" + "github.com/gohugoio/hugo/common/text" + "github.com/gohugoio/hugo/hugofs" + "github.com/gohugoio/hugo/hugolib/filesystems" + "github.com/gohugoio/hugo/identity" + "github.com/gohugoio/hugo/resources" +) + +// NewBuildClient creates a new BuildClient. +func NewBuildClient(fs *filesystems.SourceFilesystem, rs *resources.Spec) *BuildClient { + return &BuildClient{ + rs: rs, + sfs: fs, + } +} + +// BuildClient is a client for building JavaScript resources using esbuild. +type BuildClient struct { + rs *resources.Spec + sfs *filesystems.SourceFilesystem +} + +// Build builds the given JavaScript resources using esbuild with the given options. +func (c *BuildClient) Build(opts Options) (api.BuildResult, error) { + dependencyManager := opts.DependencyManager + if dependencyManager == nil { + dependencyManager = identity.NopManager + } + + opts.OutDir = c.rs.AbsPublishDir + opts.ResolveDir = c.rs.Cfg.BaseConfig().WorkingDir // where node_modules gets resolved + opts.AbsWorkingDir = opts.ResolveDir + opts.TsConfig = c.rs.ResolveJSConfigFile("tsconfig.json") + assetsResolver := newFSResolver(c.rs.Assets.Fs) + + if err := opts.validate(); err != nil { + return api.BuildResult{}, err + } + + if err := opts.compile(); err != nil { + return api.BuildResult{}, err + } + + var err error + opts.compiled.Plugins, err = createBuildPlugins(c.rs, assetsResolver, dependencyManager, opts) + if err != nil { + return api.BuildResult{}, err + } + + if opts.Inject != nil { + // Resolve the absolute filenames. + for i, ext := range opts.Inject { + impPath := filepath.FromSlash(ext) + if filepath.IsAbs(impPath) { + return api.BuildResult{}, fmt.Errorf("inject: absolute paths not supported, must be relative to /assets") + } + + m := assetsResolver.resolveComponent(impPath) + + if m == nil { + return api.BuildResult{}, fmt.Errorf("inject: file %q not found", ext) + } + + opts.Inject[i] = m.Filename + + } + + opts.compiled.Inject = opts.Inject + + } + + result := api.Build(opts.compiled) + + if len(result.Errors) > 0 { + createErr := func(msg api.Message) error { + if msg.Location == nil { + return errors.New(msg.Text) + } + var ( + contentr hugio.ReadSeekCloser + errorMessage string + loc = msg.Location + errorPath = loc.File + err error + ) + + var resolvedError *ErrorMessageResolved + + if opts.ErrorMessageResolveFunc != nil { + resolvedError = opts.ErrorMessageResolveFunc(msg) + } + + if resolvedError == nil { + if errorPath == stdinImporter { + errorPath = opts.StdinSourcePath + } + + errorMessage = msg.Text + + var namespace string + for _, ns := range hugoNamespaces { + if strings.HasPrefix(errorPath, ns) { + namespace = ns + break + } + } + + if namespace != "" { + namespace += ":" + errorMessage = strings.ReplaceAll(errorMessage, namespace, "") + errorPath = strings.TrimPrefix(errorPath, namespace) + contentr, err = hugofs.Os.Open(errorPath) + } else { + var fi os.FileInfo + fi, err = c.sfs.Fs.Stat(errorPath) + if err == nil { + m := fi.(hugofs.FileMetaInfo).Meta() + errorPath = m.Filename + contentr, err = m.Open() + } + } + } else { + contentr = resolvedError.Content + errorPath = resolvedError.Path + errorMessage = resolvedError.Message + } + + if contentr != nil { + defer contentr.Close() + } + + if err == nil { + fe := herrors. + NewFileErrorFromName(errors.New(errorMessage), errorPath). + UpdatePosition(text.Position{Offset: -1, LineNumber: loc.Line, ColumnNumber: loc.Column}). + UpdateContent(contentr, nil) + + return fe + } + + return fmt.Errorf("%s", errorMessage) + } + + var errors []error + + for _, msg := range result.Errors { + errors = append(errors, createErr(msg)) + } + + // Return 1, log the rest. + for i, err := range errors { + if i > 0 { + c.rs.Logger.Errorf("js.Build failed: %s", err) + } + } + + return result, errors[0] + } + + inOutputPathToAbsFilename := opts.ResolveSourceMapSource + opts.ResolveSourceMapSource = func(s string) string { + if inOutputPathToAbsFilename != nil { + if filename := inOutputPathToAbsFilename(s); filename != "" { + return filename + } + } + + if m := assetsResolver.resolveComponent(s); m != nil { + return m.Filename + } + + return "" + } + + for i, o := range result.OutputFiles { + if err := fixOutputFile(&o, func(s string) string { + if s == "" { + return opts.ResolveSourceMapSource(opts.StdinSourcePath) + } + var isNsHugo bool + if strings.HasPrefix(s, "ns-hugo") { + isNsHugo = true + idxColon := strings.Index(s, ":") + s = s[idxColon+1:] + } + + if !strings.HasPrefix(s, PrefixHugoVirtual) { + if !filepath.IsAbs(s) { + s = filepath.Join(opts.OutDir, s) + } + } + + if isNsHugo { + if ss := opts.ResolveSourceMapSource(s); ss != "" { + if strings.HasPrefix(ss, PrefixHugoMemory) { + // File not on disk, mark it for removal from the sources slice. + return "" + } + return ss + } + return "" + } + return s + }); err != nil { + return result, err + } + result.OutputFiles[i] = o + } + + return result, nil +} diff --git a/internal/js/esbuild/helpers.go b/internal/js/esbuild/helpers.go new file mode 100644 index 000000000..b4cb565b8 --- /dev/null +++ b/internal/js/esbuild/helpers.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 esbuild provides functions for building JavaScript resources. +package esbuild diff --git a/internal/js/esbuild/options.go b/internal/js/esbuild/options.go new file mode 100644 index 000000000..21f9e31cd --- /dev/null +++ b/internal/js/esbuild/options.go @@ -0,0 +1,411 @@ +// 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 esbuild + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strings" + + "github.com/gohugoio/hugo/common/hugio" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/common/paths" + "github.com/gohugoio/hugo/identity" + + "github.com/evanw/esbuild/pkg/api" + + "github.com/gohugoio/hugo/media" + "github.com/mitchellh/mapstructure" +) + +var ( + nameTarget = map[string]api.Target{ + "": api.ESNext, + "esnext": api.ESNext, + "es5": api.ES5, + "es6": api.ES2015, + "es2015": api.ES2015, + "es2016": api.ES2016, + "es2017": api.ES2017, + "es2018": api.ES2018, + "es2019": api.ES2019, + "es2020": api.ES2020, + "es2021": api.ES2021, + "es2022": api.ES2022, + "es2023": api.ES2023, + "es2024": api.ES2024, + } + + // source names: https://github.com/evanw/esbuild/blob/9eca46464ed5615cb36a3beb3f7a7b9a8ffbe7cf/internal/config/config.go#L208 + nameLoader = map[string]api.Loader{ + "none": api.LoaderNone, + "base64": api.LoaderBase64, + "binary": api.LoaderBinary, + "copy": api.LoaderFile, + "css": api.LoaderCSS, + "dataurl": api.LoaderDataURL, + "default": api.LoaderDefault, + "empty": api.LoaderEmpty, + "file": api.LoaderFile, + "global-css": api.LoaderGlobalCSS, + "js": api.LoaderJS, + "json": api.LoaderJSON, + "jsx": api.LoaderJSX, + "local-css": api.LoaderLocalCSS, + "text": api.LoaderText, + "ts": api.LoaderTS, + "tsx": api.LoaderTSX, + } +) + +// DecodeExternalOptions decodes the given map into ExternalOptions. +func DecodeExternalOptions(m map[string]any) (ExternalOptions, error) { + opts := ExternalOptions{ + SourcesContent: true, + } + + if err := mapstructure.WeakDecode(m, &opts); err != nil { + return opts, err + } + + if opts.TargetPath != "" { + opts.TargetPath = paths.ToSlashTrimLeading(opts.TargetPath) + } + + opts.Target = strings.ToLower(opts.Target) + opts.Format = strings.ToLower(opts.Format) + + return opts, nil +} + +// ErrorMessageResolved holds a resolved error message. +type ErrorMessageResolved struct { + Path string + Message string + Content hugio.ReadSeekCloser +} + +// ExternalOptions holds user facing options for the js.Build template function. +type ExternalOptions struct { + // If not set, the source path will be used as the base target path. + // Note that the target path's extension may change if the target MIME type + // is different, e.g. when the source is TypeScript. + TargetPath string + + // Whether to minify to output. + Minify bool + + // One of "inline", "external", "linked" or "none". + SourceMap string + + SourcesContent bool + + // The language target. + // One of: es2015, es2016, es2017, es2018, es2019, es2020 or esnext. + // Default is esnext. + Target string + + // The output format. + // One of: iife, cjs, esm + // Default is to esm. + Format string + + // One of browser, node, neutral. + // Default is browser. + // See https://esbuild.github.io/api/#platform + Platform string + + // External dependencies, e.g. "react". + Externals []string + + // This option allows you to automatically replace a global variable with an import from another file. + // The filenames must be relative to /assets. + // See https://esbuild.github.io/api/#inject + Inject []string + + // User defined symbols. + Defines map[string]any + + // This tells esbuild to edit your source code before building to drop certain constructs. + // See https://esbuild.github.io/api/#drop + Drop string + + // Maps a component import to another. + Shims map[string]string + + // Configuring a loader for a given file type lets you load that file type with an + // import statement or a require call. For example, configuring the .png file extension + // to use the data URL loader means importing a .png file gives you a data URL + // containing the contents of that image + // + // See https://esbuild.github.io/api/#loader + Loaders map[string]string + + // User defined params. Will be marshaled to JSON and available as "@params", e.g. + // import * as params from '@params'; + Params any + + // What to use instead of React.createElement. + JSXFactory string + + // What to use instead of React.Fragment. + JSXFragment string + + // What to do about JSX syntax. + // See https://esbuild.github.io/api/#jsx + JSX string + + // Which library to use to automatically import JSX helper functions from. Only works if JSX is set to automatic. + // See https://esbuild.github.io/api/#jsx-import-source + JSXImportSource string + + // There is/was a bug in WebKit with severe performance issue with the tracking + // of TDZ checks in JavaScriptCore. + // + // Enabling this flag removes the TDZ and `const` assignment checks and + // may improve performance of larger JS codebases until the WebKit fix + // is in widespread use. + // + // See https://bugs.webkit.org/show_bug.cgi?id=199866 + // Deprecated: This no longer have any effect and will be removed. + // TODO(bep) remove. See https://github.com/evanw/esbuild/commit/869e8117b499ca1dbfc5b3021938a53ffe934dba + AvoidTDZ bool +} + +// InternalOptions holds internal options for the js.Build template function. +type InternalOptions struct { + MediaType media.Type + OutDir string + Contents string + SourceDir string + ResolveDir string + AbsWorkingDir string + Metafile bool + + StdinSourcePath string + + DependencyManager identity.Manager + + Stdin bool // Set to true to pass in the entry point as a byte slice. + Splitting bool + TsConfig string + EntryPoints []string + ImportOnResolveFunc func(string, api.OnResolveArgs) string + ImportOnLoadFunc func(api.OnLoadArgs) string + ImportParamsOnLoadFunc func(args api.OnLoadArgs) json.RawMessage + ErrorMessageResolveFunc func(api.Message) *ErrorMessageResolved + ResolveSourceMapSource func(string) string // Used to resolve paths in error source maps. +} + +// Options holds the options passed to Build. +type Options struct { + ExternalOptions + InternalOptions + + compiled api.BuildOptions +} + +func (opts *Options) compile() (err error) { + target, found := nameTarget[opts.Target] + if !found { + err = fmt.Errorf("invalid target: %q", opts.Target) + return + } + + var loaders map[string]api.Loader + if opts.Loaders != nil { + loaders = make(map[string]api.Loader) + for k, v := range opts.Loaders { + loader, found := nameLoader[v] + if !found { + err = fmt.Errorf("invalid loader: %q", v) + return + } + loaders[k] = loader + } + } + + mediaType := opts.MediaType + if mediaType.IsZero() { + mediaType = media.Builtin.JavascriptType + } + + var loader api.Loader + switch mediaType.SubType { + case media.Builtin.JavascriptType.SubType: + loader = api.LoaderJS + case media.Builtin.TypeScriptType.SubType: + loader = api.LoaderTS + case media.Builtin.TSXType.SubType: + loader = api.LoaderTSX + case media.Builtin.JSXType.SubType: + loader = api.LoaderJSX + default: + err = fmt.Errorf("unsupported Media Type: %q", opts.MediaType) + return + } + + var format api.Format + // One of: iife, cjs, esm + switch opts.Format { + case "", "iife": + format = api.FormatIIFE + case "esm": + format = api.FormatESModule + case "cjs": + format = api.FormatCommonJS + default: + err = fmt.Errorf("unsupported script output format: %q", opts.Format) + return + } + + var jsx api.JSX + switch opts.JSX { + case "", "transform": + jsx = api.JSXTransform + case "preserve": + jsx = api.JSXPreserve + case "automatic": + jsx = api.JSXAutomatic + default: + err = fmt.Errorf("unsupported jsx type: %q", opts.JSX) + return + } + + var platform api.Platform + switch opts.Platform { + case "", "browser": + platform = api.PlatformBrowser + case "node": + platform = api.PlatformNode + case "neutral": + platform = api.PlatformNeutral + default: + err = fmt.Errorf("unsupported platform type: %q", opts.Platform) + return + } + + var defines map[string]string + if opts.Defines != nil { + defines = maps.ToStringMapString(opts.Defines) + } + + var drop api.Drop + switch opts.Drop { + case "": + case "console": + drop = api.DropConsole + case "debugger": + drop = api.DropDebugger + default: + err = fmt.Errorf("unsupported drop type: %q", opts.Drop) + } + + // By default we only need to specify outDir and no outFile + outDir := opts.OutDir + outFile := "" + var sourceMap api.SourceMap + switch opts.SourceMap { + case "inline": + sourceMap = api.SourceMapInline + case "external": + sourceMap = api.SourceMapExternal + case "linked": + sourceMap = api.SourceMapLinked + case "", "none": + sourceMap = api.SourceMapNone + default: + err = fmt.Errorf("unsupported sourcemap type: %q", opts.SourceMap) + return + } + + sourcesContent := api.SourcesContentInclude + if !opts.SourcesContent { + sourcesContent = api.SourcesContentExclude + } + + opts.compiled = api.BuildOptions{ + Outfile: outFile, + Bundle: true, + Metafile: opts.Metafile, + AbsWorkingDir: opts.AbsWorkingDir, + + Target: target, + Format: format, + Platform: platform, + Sourcemap: sourceMap, + SourcesContent: sourcesContent, + + Loader: loaders, + + MinifyWhitespace: opts.Minify, + MinifyIdentifiers: opts.Minify, + MinifySyntax: opts.Minify, + + Outdir: outDir, + Splitting: opts.Splitting, + + Define: defines, + External: opts.Externals, + Drop: drop, + + JSXFactory: opts.JSXFactory, + JSXFragment: opts.JSXFragment, + + JSX: jsx, + JSXImportSource: opts.JSXImportSource, + + Tsconfig: opts.TsConfig, + + EntryPoints: opts.EntryPoints, + } + + if opts.Stdin { + // This makes ESBuild pass `stdin` as the Importer to the import. + opts.compiled.Stdin = &api.StdinOptions{ + Contents: opts.Contents, + ResolveDir: opts.ResolveDir, + Loader: loader, + } + } + return +} + +func (o Options) loaderFromFilename(filename string) api.Loader { + ext := filepath.Ext(filename) + if optsLoaders := o.compiled.Loader; optsLoaders != nil { + if l, found := optsLoaders[ext]; found { + return l + } + } + l, found := extensionToLoaderMap[ext] + if found { + return l + } + return api.LoaderJS +} + +func (opts *Options) validate() error { + if opts.ImportOnResolveFunc != nil && opts.ImportOnLoadFunc == nil { + return fmt.Errorf("ImportOnLoadFunc must be set if ImportOnResolveFunc is set") + } + if opts.ImportOnResolveFunc == nil && opts.ImportOnLoadFunc != nil { + return fmt.Errorf("ImportOnResolveFunc must be set if ImportOnLoadFunc is set") + } + if opts.AbsWorkingDir == "" { + return fmt.Errorf("AbsWorkingDir must be set") + } + return nil +} diff --git a/internal/js/esbuild/options_test.go b/internal/js/esbuild/options_test.go new file mode 100644 index 000000000..e92c3bea6 --- /dev/null +++ b/internal/js/esbuild/options_test.go @@ -0,0 +1,262 @@ +// 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 esbuild + +import ( + "testing" + + "github.com/gohugoio/hugo/media" + + "github.com/evanw/esbuild/pkg/api" + + qt "github.com/frankban/quicktest" +) + +func TestToBuildOptions(t *testing.T) { + c := qt.New(t) + + opts := Options{ + InternalOptions: InternalOptions{ + MediaType: media.Builtin.JavascriptType, + Stdin: true, + }, + } + + c.Assert(opts.compile(), qt.IsNil) + c.Assert(opts.compiled, qt.DeepEquals, api.BuildOptions{ + Bundle: true, + Target: api.ESNext, + Format: api.FormatIIFE, + Platform: api.PlatformBrowser, + SourcesContent: 1, + Stdin: &api.StdinOptions{ + Loader: api.LoaderJS, + }, + }) + + opts = Options{ + ExternalOptions: ExternalOptions{ + Target: "es2018", + Format: "cjs", + Minify: true, + AvoidTDZ: true, + }, + InternalOptions: InternalOptions{ + MediaType: media.Builtin.JavascriptType, + Stdin: true, + }, + } + + c.Assert(opts.compile(), qt.IsNil) + c.Assert(opts.compiled, qt.DeepEquals, api.BuildOptions{ + Bundle: true, + Target: api.ES2018, + Format: api.FormatCommonJS, + Platform: api.PlatformBrowser, + SourcesContent: 1, + MinifyIdentifiers: true, + MinifySyntax: true, + MinifyWhitespace: true, + Stdin: &api.StdinOptions{ + Loader: api.LoaderJS, + }, + }) + + opts = Options{ + ExternalOptions: ExternalOptions{ + Target: "es2018", Format: "cjs", Minify: true, + SourceMap: "inline", + }, + InternalOptions: InternalOptions{ + MediaType: media.Builtin.JavascriptType, + Stdin: true, + }, + } + + c.Assert(opts.compile(), qt.IsNil) + c.Assert(opts.compiled, qt.DeepEquals, api.BuildOptions{ + Bundle: true, + Target: api.ES2018, + Format: api.FormatCommonJS, + Platform: api.PlatformBrowser, + MinifyIdentifiers: true, + MinifySyntax: true, + MinifyWhitespace: true, + SourcesContent: 1, + Sourcemap: api.SourceMapInline, + Stdin: &api.StdinOptions{ + Loader: api.LoaderJS, + }, + }) + + opts = Options{ + ExternalOptions: ExternalOptions{ + Target: "es2018", Format: "cjs", Minify: true, + SourceMap: "inline", + }, + InternalOptions: InternalOptions{ + MediaType: media.Builtin.JavascriptType, + Stdin: true, + }, + } + + c.Assert(opts.compile(), qt.IsNil) + c.Assert(opts.compiled, qt.DeepEquals, api.BuildOptions{ + Bundle: true, + Target: api.ES2018, + Format: api.FormatCommonJS, + Platform: api.PlatformBrowser, + MinifyIdentifiers: true, + MinifySyntax: true, + MinifyWhitespace: true, + Sourcemap: api.SourceMapInline, + SourcesContent: 1, + Stdin: &api.StdinOptions{ + Loader: api.LoaderJS, + }, + }) + + opts = Options{ + ExternalOptions: ExternalOptions{ + Target: "es2018", Format: "cjs", Minify: true, + SourceMap: "external", + }, + InternalOptions: InternalOptions{ + MediaType: media.Builtin.JavascriptType, + Stdin: true, + }, + } + + c.Assert(opts.compile(), qt.IsNil) + c.Assert(opts.compiled, qt.DeepEquals, api.BuildOptions{ + Bundle: true, + Target: api.ES2018, + Format: api.FormatCommonJS, + Platform: api.PlatformBrowser, + MinifyIdentifiers: true, + MinifySyntax: true, + MinifyWhitespace: true, + Sourcemap: api.SourceMapExternal, + SourcesContent: 1, + Stdin: &api.StdinOptions{ + Loader: api.LoaderJS, + }, + }) + + opts = Options{ + ExternalOptions: ExternalOptions{ + JSX: "automatic", JSXImportSource: "preact", + }, + InternalOptions: InternalOptions{ + MediaType: media.Builtin.JavascriptType, + Stdin: true, + }, + } + + c.Assert(opts.compile(), qt.IsNil) + c.Assert(opts.compiled, qt.DeepEquals, api.BuildOptions{ + Bundle: true, + Target: api.ESNext, + Format: api.FormatIIFE, + Platform: api.PlatformBrowser, + SourcesContent: 1, + Stdin: &api.StdinOptions{ + Loader: api.LoaderJS, + }, + JSX: api.JSXAutomatic, + JSXImportSource: "preact", + }) + + opts = Options{ + ExternalOptions: ExternalOptions{ + Drop: "console", + }, + } + c.Assert(opts.compile(), qt.IsNil) + c.Assert(opts.compiled.Drop, qt.Equals, api.DropConsole) + opts = Options{ + ExternalOptions: ExternalOptions{ + Drop: "debugger", + }, + } + c.Assert(opts.compile(), qt.IsNil) + c.Assert(opts.compiled.Drop, qt.Equals, api.DropDebugger) + + opts = Options{ + ExternalOptions: ExternalOptions{ + Drop: "adsfadsf", + }, + } + c.Assert(opts.compile(), qt.ErrorMatches, `unsupported drop type: "adsfadsf"`) +} + +func TestToBuildOptionsTarget(t *testing.T) { + c := qt.New(t) + + for _, test := range []struct { + target string + expect api.Target + }{ + {"es2015", api.ES2015}, + {"es2016", api.ES2016}, + {"es2017", api.ES2017}, + {"es2018", api.ES2018}, + {"es2019", api.ES2019}, + {"es2020", api.ES2020}, + {"es2021", api.ES2021}, + {"es2022", api.ES2022}, + {"es2023", api.ES2023}, + {"", api.ESNext}, + {"esnext", api.ESNext}, + } { + c.Run(test.target, func(c *qt.C) { + opts := Options{ + ExternalOptions: ExternalOptions{ + Target: test.target, + }, + InternalOptions: InternalOptions{ + MediaType: media.Builtin.JavascriptType, + }, + } + + c.Assert(opts.compile(), qt.IsNil) + c.Assert(opts.compiled.Target, qt.Equals, test.expect) + }) + } +} + +func TestDecodeExternalOptions(t *testing.T) { + c := qt.New(t) + m := map[string]any{ + "platform": "node", + } + ext, err := DecodeExternalOptions(m) + c.Assert(err, qt.IsNil) + c.Assert(ext, qt.DeepEquals, ExternalOptions{ + SourcesContent: true, + Platform: "node", + }) + + opts := Options{ + ExternalOptions: ext, + } + c.Assert(opts.compile(), qt.IsNil) + c.Assert(opts.compiled, qt.DeepEquals, api.BuildOptions{ + Bundle: true, + Target: api.ESNext, + Format: api.FormatIIFE, + Platform: api.PlatformNode, + SourcesContent: api.SourcesContentInclude, + }) +} diff --git a/internal/js/esbuild/resolve.go b/internal/js/esbuild/resolve.go new file mode 100644 index 000000000..a2516dbd2 --- /dev/null +++ b/internal/js/esbuild/resolve.go @@ -0,0 +1,323 @@ +// 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 esbuild + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/evanw/esbuild/pkg/api" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/hugofs" + "github.com/gohugoio/hugo/identity" + "github.com/gohugoio/hugo/resources" + "github.com/gohugoio/hugo/resources/resource" + "github.com/spf13/afero" + "slices" +) + +const ( + NsHugoImport = "ns-hugo-imp" + NsHugoImportResolveFunc = "ns-hugo-imp-func" + nsHugoParams = "ns-hugo-params" + pathHugoConfigParams = "@params/config" + + stdinImporter = "" +) + +var hugoNamespaces = []string{NsHugoImport, NsHugoImportResolveFunc, nsHugoParams} + +const ( + PrefixHugoVirtual = "__hu_v" + PrefixHugoMemory = "__hu_m" +) + +var extensionToLoaderMap = map[string]api.Loader{ + ".js": api.LoaderJS, + ".mjs": api.LoaderJS, + ".cjs": api.LoaderJS, + ".jsx": api.LoaderJSX, + ".ts": api.LoaderTS, + ".tsx": api.LoaderTSX, + ".css": api.LoaderCSS, + ".json": api.LoaderJSON, + ".txt": api.LoaderText, +} + +// This is a common sub-set of ESBuild's default extensions. +// We assume that imports of JSON, CSS etc. will be using their full +// name with extension. +var commonExtensions = []string{".js", ".ts", ".tsx", ".jsx"} + +// ResolveComponent resolves a component using the given resolver. +func ResolveComponent[T any](impPath string, resolve func(string) (v T, found, isDir bool)) (v T, found bool) { + findFirst := func(base string) (v T, found, isDir bool) { + for _, ext := range commonExtensions { + if strings.HasSuffix(impPath, ext) { + // Import of foo.js.js need the full name. + continue + } + if v, found, isDir = resolve(base + ext); found { + return + } + } + + // Not found. + return + } + + // We need to check if this is a regular file imported without an extension. + // There may be ambiguous situations where both foo.js and foo/index.js exists. + // This import order is in line with both how Node and ESBuild's native + // import resolver works. + + // It may be a regular file imported without an extension, e.g. + // foo or foo/index. + v, found, _ = findFirst(impPath) + if found { + return v, found + } + + base := filepath.Base(impPath) + if base == "index" { + // try index.esm.js etc. + v, found, _ = findFirst(impPath + ".esm") + if found { + return v, found + } + } + + // Check the path as is. + var isDir bool + v, found, isDir = resolve(impPath) + if found && isDir { + v, found, _ = findFirst(filepath.Join(impPath, "index")) + if !found { + v, found, _ = findFirst(filepath.Join(impPath, "index.esm")) + } + } + + if !found && strings.HasSuffix(base, ".js") { + v, found, _ = findFirst(strings.TrimSuffix(impPath, ".js")) + } + + return +} + +// ResolveResource resolves a resource using the given resourceGetter. +func ResolveResource(impPath string, resourceGetter resource.ResourceGetter) (r resource.Resource) { + resolve := func(name string) (v resource.Resource, found, isDir bool) { + r := resourceGetter.Get(name) + return r, r != nil, false + } + r, found := ResolveComponent(impPath, resolve) + if !found { + return nil + } + return r +} + +func newFSResolver(fs afero.Fs) *fsResolver { + return &fsResolver{fs: fs, resolved: maps.NewCache[string, *hugofs.FileMeta]()} +} + +type fsResolver struct { + fs afero.Fs + resolved *maps.Cache[string, *hugofs.FileMeta] +} + +func (r *fsResolver) resolveComponent(impPath string) *hugofs.FileMeta { + v, _ := r.resolved.GetOrCreate(impPath, func() (*hugofs.FileMeta, error) { + resolve := func(name string) (*hugofs.FileMeta, bool, bool) { + if fi, err := r.fs.Stat(name); err == nil { + return fi.(hugofs.FileMetaInfo).Meta(), true, fi.IsDir() + } + return nil, false, false + } + v, _ := ResolveComponent(impPath, resolve) + return v, nil + }) + return v +} + +func createBuildPlugins(rs *resources.Spec, assetsResolver *fsResolver, depsManager identity.Manager, opts Options) ([]api.Plugin, error) { + fs := rs.Assets + + resolveImport := func(args api.OnResolveArgs) (api.OnResolveResult, error) { + impPath := args.Path + shimmed := false + if opts.Shims != nil { + override, found := opts.Shims[impPath] + if found { + impPath = override + shimmed = true + } + } + + if slices.Contains(opts.Externals, impPath) { + return api.OnResolveResult{ + Path: impPath, + External: true, + }, nil + } + + if opts.ImportOnResolveFunc != nil { + if s := opts.ImportOnResolveFunc(impPath, args); s != "" { + return api.OnResolveResult{Path: s, Namespace: NsHugoImportResolveFunc}, nil + } + } + + importer := args.Importer + + isStdin := importer == stdinImporter + var relDir string + if !isStdin { + if strings.HasPrefix(importer, PrefixHugoVirtual) { + relDir = filepath.Dir(strings.TrimPrefix(importer, PrefixHugoVirtual)) + } else { + rel, found := fs.MakePathRelative(importer, true) + + if !found { + if shimmed { + relDir = opts.SourceDir + } else { + // Not in any of the /assets folders. + // This is an import from a node_modules, let + // ESBuild resolve this. + return api.OnResolveResult{}, nil + } + } else { + relDir = filepath.Dir(rel) + } + } + } else { + relDir = opts.SourceDir + } + + // Imports not starting with a "." is assumed to live relative to /assets. + // Hugo makes no assumptions about the directory structure below /assets. + if relDir != "" && strings.HasPrefix(impPath, ".") { + impPath = filepath.Join(relDir, impPath) + } + + m := assetsResolver.resolveComponent(impPath) + + if m != nil { + depsManager.AddIdentity(m.PathInfo) + + // Store the source root so we can create a jsconfig.json + // to help IntelliSense when the build is done. + // This should be a small number of elements, and when + // in server mode, we may get stale entries on renames etc., + // but that shouldn't matter too much. + rs.JSConfigBuilder.AddSourceRoot(m.SourceRoot) + return api.OnResolveResult{Path: m.Filename, Namespace: NsHugoImport}, nil + } + + // Fall back to ESBuild's resolve. + return api.OnResolveResult{}, nil + } + + importResolver := api.Plugin{ + Name: "hugo-import-resolver", + Setup: func(build api.PluginBuild) { + build.OnResolve(api.OnResolveOptions{Filter: `.*`}, + func(args api.OnResolveArgs) (api.OnResolveResult, error) { + return resolveImport(args) + }) + build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: NsHugoImport}, + func(args api.OnLoadArgs) (api.OnLoadResult, error) { + b, err := os.ReadFile(args.Path) + if err != nil { + return api.OnLoadResult{}, fmt.Errorf("failed to read %q: %w", args.Path, err) + } + c := string(b) + + return api.OnLoadResult{ + // See https://github.com/evanw/esbuild/issues/502 + // This allows all modules to resolve dependencies + // in the main project's node_modules. + ResolveDir: opts.ResolveDir, + Contents: &c, + Loader: opts.loaderFromFilename(args.Path), + }, nil + }) + build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: NsHugoImportResolveFunc}, + func(args api.OnLoadArgs) (api.OnLoadResult, error) { + c := opts.ImportOnLoadFunc(args) + if c == "" { + return api.OnLoadResult{}, fmt.Errorf("ImportOnLoadFunc failed to resolve %q", args.Path) + } + + return api.OnLoadResult{ + ResolveDir: opts.ResolveDir, + Contents: &c, + Loader: opts.loaderFromFilename(args.Path), + }, nil + }) + }, + } + + params := opts.Params + if params == nil { + // This way @params will always resolve to something. + params = make(map[string]any) + } + + b, err := json.Marshal(params) + if err != nil { + return nil, fmt.Errorf("failed to marshal params: %w", err) + } + + paramsPlugin := api.Plugin{ + Name: "hugo-params-plugin", + Setup: func(build api.PluginBuild) { + build.OnResolve(api.OnResolveOptions{Filter: `^@params(/config)?$`}, + func(args api.OnResolveArgs) (api.OnResolveResult, error) { + resolvedPath := args.Importer + + if args.Path == pathHugoConfigParams { + resolvedPath = pathHugoConfigParams + } + + return api.OnResolveResult{ + Path: resolvedPath, + Namespace: nsHugoParams, + }, nil + }) + build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: nsHugoParams}, + func(args api.OnLoadArgs) (api.OnLoadResult, error) { + bb := b + if args.Path != pathHugoConfigParams && opts.ImportParamsOnLoadFunc != nil { + bb = opts.ImportParamsOnLoadFunc(args) + } + s := string(bb) + + if s == "" { + s = "{}" + } + + return api.OnLoadResult{ + Contents: &s, + Loader: api.LoaderJSON, + }, nil + }) + }, + } + + return []api.Plugin{importResolver, paramsPlugin}, nil +} diff --git a/internal/js/esbuild/resolve_test.go b/internal/js/esbuild/resolve_test.go new file mode 100644 index 000000000..86e3138f2 --- /dev/null +++ b/internal/js/esbuild/resolve_test.go @@ -0,0 +1,86 @@ +// 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 esbuild + +import ( + "path" + "path/filepath" + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/config/testconfig" + "github.com/gohugoio/hugo/hugofs" + "github.com/gohugoio/hugo/hugolib/filesystems" + "github.com/gohugoio/hugo/hugolib/paths" + "github.com/spf13/afero" +) + +func TestResolveComponentInAssets(t *testing.T) { + c := qt.New(t) + + for _, test := range []struct { + name string + files []string + impPath string + expect string + }{ + {"Basic, extension", []string{"foo.js", "bar.js"}, "foo.js", "foo.js"}, + {"Basic, no extension", []string{"foo.js", "bar.js"}, "foo", "foo.js"}, + {"Basic, no extension, typescript", []string{"foo.ts", "bar.js"}, "foo", "foo.ts"}, + {"Not found", []string{"foo.js", "bar.js"}, "moo.js", ""}, + {"Not found, double js extension", []string{"foo.js.js", "bar.js"}, "foo.js", ""}, + {"Index file, folder only", []string{"foo/index.js", "bar.js"}, "foo", "foo/index.js"}, + {"Index file, folder and index", []string{"foo/index.js", "bar.js"}, "foo/index", "foo/index.js"}, + {"Index file, folder and index and suffix", []string{"foo/index.js", "bar.js"}, "foo/index.js", "foo/index.js"}, + {"Index ESM file, folder only", []string{"foo/index.esm.js", "bar.js"}, "foo", "foo/index.esm.js"}, + {"Index ESM file, folder and index", []string{"foo/index.esm.js", "bar.js"}, "foo/index", "foo/index.esm.js"}, + {"Index ESM file, folder and index and suffix", []string{"foo/index.esm.js", "bar.js"}, "foo/index.esm.js", "foo/index.esm.js"}, + // We added these index.esm.js cases in v0.101.0. The case below is unlikely to happen in the wild, but add a test + // to document Hugo's behavior. We pick the file with the name index.js; anything else would be breaking. + {"Index and Index ESM file, folder only", []string{"foo/index.esm.js", "foo/index.js", "bar.js"}, "foo", "foo/index.js"}, + + // Issue #8949 + {"Check file before directory", []string{"foo.js", "foo/index.js"}, "foo", "foo.js"}, + } { + c.Run(test.name, func(c *qt.C) { + baseDir := "assets" + mfs := afero.NewMemMapFs() + + for _, filename := range test.files { + c.Assert(afero.WriteFile(mfs, filepath.Join(baseDir, filename), []byte("let foo='bar';"), 0o777), qt.IsNil) + } + + conf := testconfig.GetTestConfig(mfs, config.New()) + fs := hugofs.NewFrom(mfs, conf.BaseConfig()) + + p, err := paths.New(fs, conf) + c.Assert(err, qt.IsNil) + bfs, err := filesystems.NewBase(p, nil) + c.Assert(err, qt.IsNil) + resolver := newFSResolver(bfs.Assets.Fs) + + got := resolver.resolveComponent(test.impPath) + + gotPath := "" + expect := test.expect + if got != nil { + gotPath = filepath.ToSlash(got.Filename) + expect = path.Join(baseDir, test.expect) + } + + c.Assert(gotPath, qt.Equals, expect) + }) + } +} diff --git a/internal/js/esbuild/sourcemap.go b/internal/js/esbuild/sourcemap.go new file mode 100644 index 000000000..647f0c081 --- /dev/null +++ b/internal/js/esbuild/sourcemap.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 esbuild + +import ( + "encoding/json" + "strings" + + "github.com/evanw/esbuild/pkg/api" + "github.com/gohugoio/hugo/common/paths" +) + +type sourceMap struct { + Version int `json:"version"` + Sources []string `json:"sources"` + SourcesContent []string `json:"sourcesContent"` + Mappings string `json:"mappings"` + Names []string `json:"names"` +} + +func fixOutputFile(o *api.OutputFile, resolve func(string) string) error { + if strings.HasSuffix(o.Path, ".map") { + b, err := fixSourceMap(o.Contents, resolve) + if err != nil { + return err + } + o.Contents = b + } + return nil +} + +func fixSourceMap(s []byte, resolve func(string) string) ([]byte, error) { + var sm sourceMap + if err := json.Unmarshal([]byte(s), &sm); err != nil { + return nil, err + } + + sm.Sources = fixSourceMapSources(sm.Sources, resolve) + + b, err := json.Marshal(sm) + if err != nil { + return nil, err + } + + return b, nil +} + +func fixSourceMapSources(s []string, resolve func(string) string) []string { + var result []string + for _, src := range s { + if s := resolve(src); s != "" { + // Absolute filenames works fine on U*ix (tested in Chrome on MacOs), but works very poorly on Windows (again Chrome). + // So, convert it to a URL. + if u, err := paths.UrlFromFilename(s); err == nil { + result = append(result, u.String()) + } + } + } + return result +} + +// Used in tests. +func SourcesFromSourceMap(s string) []string { + var sm sourceMap + if err := json.Unmarshal([]byte(s), &sm); err != nil { + return nil + } + return sm.Sources +} diff --git a/internal/warpc/build.sh b/internal/warpc/build.sh new file mode 100755 index 000000000..5e75aa381 --- /dev/null +++ b/internal/warpc/build.sh @@ -0,0 +1,5 @@ +# TODO1 clean up when done. +go generate ./gen +javy compile js/greet.bundle.js -d -o wasm/greet.wasm +javy compile js/renderkatex.bundle.js -d -o wasm/renderkatex.wasm +touch warpc_test.go \ No newline at end of file diff --git a/internal/warpc/gen/main.go b/internal/warpc/gen/main.go new file mode 100644 index 000000000..d3d6562a9 --- /dev/null +++ b/internal/warpc/gen/main.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. + +//go:generate go run main.go +package main + +import ( + "fmt" + "log" + "os" + "path/filepath" + "strings" + + "github.com/evanw/esbuild/pkg/api" +) + +var scripts = []string{ + "greet.js", + "renderkatex.js", +} + +func main() { + for _, script := range scripts { + filename := filepath.Join("../js", script) + err := buildJSBundle(filename) + if err != nil { + log.Fatal(err) + } + } +} + +func buildJSBundle(filename string) error { + minify := true + result := api.Build( + api.BuildOptions{ + EntryPoints: []string{filename}, + Bundle: true, + MinifyWhitespace: minify, + MinifyIdentifiers: minify, + MinifySyntax: minify, + Target: api.ES2020, + Outfile: strings.Replace(filename, ".js", ".bundle.js", 1), + SourceRoot: "../js", + }) + + if len(result.Errors) > 0 { + return fmt.Errorf("build failed: %v", result.Errors) + } + if len(result.OutputFiles) != 1 { + return fmt.Errorf("expected 1 output file, got %d", len(result.OutputFiles)) + } + + of := result.OutputFiles[0] + if err := os.WriteFile(filepath.FromSlash(of.Path), of.Contents, 0o644); err != nil { + return fmt.Errorf("write file failed: %v", err) + } + return nil +} diff --git a/internal/warpc/js/.gitignore b/internal/warpc/js/.gitignore new file mode 100644 index 000000000..ccb2c800f --- /dev/null +++ b/internal/warpc/js/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json \ No newline at end of file diff --git a/internal/warpc/js/common.js b/internal/warpc/js/common.js new file mode 100644 index 000000000..61c535fb7 --- /dev/null +++ b/internal/warpc/js/common.js @@ -0,0 +1,83 @@ +// Read JSONL from stdin. +export function readInput(handle) { + const buffSize = 1024; + let currentLine = []; + const buffer = new Uint8Array(buffSize); + + // These are not implemented by QuickJS. + console.warn = (value) => { + console.log(value); + }; + + console.error = (value) => { + throw new Error(value); + }; + + // Read all the available bytes + while (true) { + // Stdin file descriptor + const fd = 0; + let bytesRead = 0; + try { + bytesRead = Javy.IO.readSync(fd, buffer); + } catch (e) { + // IO.readSync fails with os error 29 when stdin closes. + if (e.message.includes('os error 29')) { + break; + } + throw new Error('Error reading from stdin'); + } + + if (bytesRead < 0) { + throw new Error('Error reading from stdin'); + break; + } + + if (bytesRead === 0) { + break; + } + + currentLine = [...currentLine, ...buffer.subarray(0, bytesRead)]; + + // Check for newline. If not, we need to read more data. + if (!currentLine.includes(10)) { + continue; + } + + // Split array into chunks by newline. + let i = 0; + for (let j = 0; i < currentLine.length; i++) { + if (currentLine[i] === 10) { + const chunk = currentLine.splice(j, i + 1); + const arr = new Uint8Array(chunk); + let message; + try { + message = JSON.parse(new TextDecoder().decode(arr)); + } catch (e) { + throw new Error(`Error parsing JSON '${new TextDecoder().decode(arr)}' from stdin: ${e.message}`); + } + + try { + handle(message); + } catch (e) { + let header = message.header; + header.err = e.message; + writeOutput({ header: header }); + } + + j = i + 1; + } + } + // Remove processed data. + currentLine = currentLine.slice(i); + } +} + +// Write JSONL to stdout +export function writeOutput(output) { + const encodedOutput = new TextEncoder().encode(JSON.stringify(output) + '\n'); + const buffer = new Uint8Array(encodedOutput); + // Stdout file descriptor + const fd = 1; + Javy.IO.writeSync(fd, buffer); +} diff --git a/internal/warpc/js/greet.bundle.js b/internal/warpc/js/greet.bundle.js new file mode 100644 index 000000000..6828d582a --- /dev/null +++ b/internal/warpc/js/greet.bundle.js @@ -0,0 +1,2 @@ +(()=>{function w(r){let e=[],c=new Uint8Array(1024);for(console.warn=n=>{console.log(n)},console.error=n=>{throw new Error(n)};;){let o=0;try{o=Javy.IO.readSync(0,c)}catch(a){if(a.message.includes("os error 29"))break;throw new Error("Error reading from stdin")}if(o<0)throw new Error("Error reading from stdin");if(o===0)break;if(e=[...e,...c.subarray(0,o)],!e.includes(10))continue;let t=0;for(let a=0;t{function Wt(r){let t=[],a=new Uint8Array(1024);for(console.warn=n=>{console.log(n)},console.error=n=>{throw new Error(n)};;){let s=0;try{s=Javy.IO.readSync(0,a)}catch(h){if(h.message.includes("os error 29"))break;throw new Error("Error reading from stdin")}if(s<0)throw new Error("Error reading from stdin");if(s===0)break;if(t=[...t,...a.subarray(0,s)],!t.includes(10))continue;let l=0;for(let h=0;l15?f="\u2026"+h.slice(n-15,n):f=h.slice(0,n);var v;s+15":">","<":"<",'"':""","'":"'"},Ba=/[&><"']/g;function Da(r){return String(r).replace(Ba,e=>qa[e])}var zr=function r(e){return e.type==="ordgroup"||e.type==="color"?e.body.length===1?r(e.body[0]):e:e.type==="font"?r(e.body):e},Ca=function(e){var t=zr(e);return t.type==="mathord"||t.type==="textord"||t.type==="atom"},_a=function(e){if(!e)throw new Error("Expected non-null, but got "+String(e));return e},Na=function(e){var t=/^[\x00-\x20]*([^\\/#?]*?)(:|�*58|�*3a|&colon)/i.exec(e);return t?t[2]!==":"||!/^[a-zA-Z][a-zA-Z0-9+\-.]*$/.test(t[1])?null:t[1].toLowerCase():"_relative"},O={contains:Ma,deflt:za,escape:Da,hyphenate:Ta,getBaseElem:zr,isCharacterBox:Ca,protocolFromUrl:Na},Oe={displayMode:{type:"boolean",description:"Render math in display mode, which puts the math in display style (so \\int and \\sum are large, for example), and centers the math on the page on its own line.",cli:"-d, --display-mode"},output:{type:{enum:["htmlAndMathml","html","mathml"]},description:"Determines the markup language of the output.",cli:"-F, --format "},leqno:{type:"boolean",description:"Render display math in leqno style (left-justified tags)."},fleqn:{type:"boolean",description:"Render display math flush left."},throwOnError:{type:"boolean",default:!0,cli:"-t, --no-throw-on-error",cliDescription:"Render errors (in the color given by --error-color) instead of throwing a ParseError exception when encountering an error."},errorColor:{type:"string",default:"#cc0000",cli:"-c, --error-color ",cliDescription:"A color string given in the format 'rgb' or 'rrggbb' (no #). This option determines the color of errors rendered by the -t option.",cliProcessor:r=>"#"+r},macros:{type:"object",cli:"-m, --macro ",cliDescription:"Define custom macro of the form '\\foo:expansion' (use multiple -m arguments for multiple macros).",cliDefault:[],cliProcessor:(r,e)=>(e.push(r),e)},minRuleThickness:{type:"number",description:"Specifies a minimum thickness, in ems, for fraction lines, `\\sqrt` top lines, `{array}` vertical lines, `\\hline`, `\\hdashline`, `\\underline`, `\\overline`, and the borders of `\\fbox`, `\\boxed`, and `\\fcolorbox`.",processor:r=>Math.max(0,r),cli:"--min-rule-thickness ",cliProcessor:parseFloat},colorIsTextColor:{type:"boolean",description:"Makes \\color behave like LaTeX's 2-argument \\textcolor, instead of LaTeX's one-argument \\color mode change.",cli:"-b, --color-is-text-color"},strict:{type:[{enum:["warn","ignore","error"]},"boolean","function"],description:"Turn on strict / LaTeX faithfulness mode, which throws an error if the input uses features that are not supported by LaTeX.",cli:"-S, --strict",cliDefault:!1},trust:{type:["boolean","function"],description:"Trust the input, enabling all HTML features such as \\url.",cli:"-T, --trust"},maxSize:{type:"number",default:1/0,description:"If non-zero, all user-specified sizes, e.g. in \\rule{500em}{500em}, will be capped to maxSize ems. Otherwise, elements and spaces can be arbitrarily large",processor:r=>Math.max(0,r),cli:"-s, --max-size ",cliProcessor:parseInt},maxExpand:{type:"number",default:1e3,description:"Limit the number of macro expansions to the specified number, to prevent e.g. infinite macro loops. If set to Infinity, the macro expander will try to fully expand as in LaTeX.",processor:r=>Math.max(0,r),cli:"-e, --max-expand ",cliProcessor:r=>r==="Infinity"?1/0:parseInt(r)},globalGroup:{type:"boolean",cli:!1}};function Oa(r){if(r.default)return r.default;var e=r.type,t=Array.isArray(e)?e[0]:e;if(typeof t!="string")return t.enum[0];switch(t){case"boolean":return!1;case"string":return"";case"number":return 0;case"object":return{}}}var de=class{constructor(e){this.displayMode=void 0,this.output=void 0,this.leqno=void 0,this.fleqn=void 0,this.throwOnError=void 0,this.errorColor=void 0,this.macros=void 0,this.minRuleThickness=void 0,this.colorIsTextColor=void 0,this.strict=void 0,this.trust=void 0,this.maxSize=void 0,this.maxExpand=void 0,this.globalGroup=void 0,e=e||{};for(var t in Oe)if(Oe.hasOwnProperty(t)){var a=Oe[t];this[t]=e[t]!==void 0?a.processor?a.processor(e[t]):e[t]:Oa(a)}}reportNonstrict(e,t,a){var n=this.strict;if(typeof n=="function"&&(n=n(e,t,a)),!(!n||n==="ignore")){if(n===!0||n==="error")throw new z("LaTeX-incompatible input and strict mode is set to 'error': "+(t+" ["+e+"]"),a);n==="warn"?typeof console<"u"&&console.warn("LaTeX-incompatible input and strict mode is set to 'warn': "+(t+" ["+e+"]")):typeof console<"u"&&console.warn("LaTeX-incompatible input and strict mode is set to "+("unrecognized '"+n+"': "+t+" ["+e+"]"))}}useStrictBehavior(e,t,a){var n=this.strict;if(typeof n=="function")try{n=n(e,t,a)}catch{n="error"}return!n||n==="ignore"?!1:n===!0||n==="error"?!0:n==="warn"?(typeof console<"u"&&console.warn("LaTeX-incompatible input and strict mode is set to 'warn': "+(t+" ["+e+"]")),!1):(typeof console<"u"&&console.warn("LaTeX-incompatible input and strict mode is set to "+("unrecognized '"+n+"': "+t+" ["+e+"]")),!1)}isTrusted(e){if(e.url&&!e.protocol){var t=O.protocolFromUrl(e.url);if(t==null)return!1;e.protocol=t}var a=typeof this.trust=="function"?this.trust(e):this.trust;return!!a}},k0=class{constructor(e,t,a){this.id=void 0,this.size=void 0,this.cramped=void 0,this.id=e,this.size=t,this.cramped=a}sup(){return M0[Ia[this.id]]}sub(){return M0[Ea[this.id]]}fracNum(){return M0[Ra[this.id]]}fracDen(){return M0[$a[this.id]]}cramp(){return M0[La[this.id]]}text(){return M0[Fa[this.id]]}isTight(){return this.size>=2}},At=0,Ee=1,ae=2,N0=3,pe=4,v0=5,ne=6,o0=7,M0=[new k0(At,0,!1),new k0(Ee,0,!0),new k0(ae,1,!1),new k0(N0,1,!0),new k0(pe,2,!1),new k0(v0,2,!0),new k0(ne,3,!1),new k0(o0,3,!0)],Ia=[pe,v0,pe,v0,ne,o0,ne,o0],Ea=[v0,v0,v0,v0,o0,o0,o0,o0],Ra=[ae,N0,pe,v0,ne,o0,ne,o0],$a=[N0,N0,v0,v0,o0,o0,o0,o0],La=[Ee,Ee,N0,N0,v0,v0,o0,o0],Fa=[At,Ee,ae,N0,ae,N0,ae,N0],E={DISPLAY:M0[At],TEXT:M0[ae],SCRIPT:M0[pe],SCRIPTSCRIPT:M0[ne]},pt=[{name:"latin",blocks:[[256,591],[768,879]]},{name:"cyrillic",blocks:[[1024,1279]]},{name:"armenian",blocks:[[1328,1423]]},{name:"brahmic",blocks:[[2304,4255]]},{name:"georgian",blocks:[[4256,4351]]},{name:"cjk",blocks:[[12288,12543],[19968,40879],[65280,65376]]},{name:"hangul",blocks:[[44032,55215]]}];function Ha(r){for(var e=0;e=n[0]&&r<=n[1])return t.name}return null}var Ie=[];pt.forEach(r=>r.blocks.forEach(e=>Ie.push(...e)));function Ar(r){for(var e=0;e=Ie[e]&&r<=Ie[e+1])return!0;return!1}var re=80,Pa=function(e,t){return"M95,"+(622+e+t)+` +c-2.7,0,-7.17,-2.7,-13.5,-8c-5.8,-5.3,-9.5,-10,-9.5,-14 +c0,-2,0.3,-3.3,1,-4c1.3,-2.7,23.83,-20.7,67.5,-54 +c44.2,-33.3,65.8,-50.3,66.5,-51c1.3,-1.3,3,-2,5,-2c4.7,0,8.7,3.3,12,10 +s173,378,173,378c0.7,0,35.3,-71,104,-213c68.7,-142,137.5,-285,206.5,-429 +c69,-144,104.5,-217.7,106.5,-221 +l`+e/2.075+" -"+e+` +c5.3,-9.3,12,-14,20,-14 +H400000v`+(40+e)+`H845.2724 +s-225.272,467,-225.272,467s-235,486,-235,486c-2.7,4.7,-9,7,-19,7 +c-6,0,-10,-1,-12,-3s-194,-422,-194,-422s-65,47,-65,47z +M`+(834+e)+" "+t+"h400000v"+(40+e)+"h-400000z"},Ga=function(e,t){return"M263,"+(601+e+t)+`c0.7,0,18,39.7,52,119 +c34,79.3,68.167,158.7,102.5,238c34.3,79.3,51.8,119.3,52.5,120 +c340,-704.7,510.7,-1060.3,512,-1067 +l`+e/2.084+" -"+e+` +c4.7,-7.3,11,-11,19,-11 +H40000v`+(40+e)+`H1012.3 +s-271.3,567,-271.3,567c-38.7,80.7,-84,175,-136,283c-52,108,-89.167,185.3,-111.5,232 +c-22.3,46.7,-33.8,70.3,-34.5,71c-4.7,4.7,-12.3,7,-23,7s-12,-1,-12,-1 +s-109,-253,-109,-253c-72.7,-168,-109.3,-252,-110,-252c-10.7,8,-22,16.7,-34,26 +c-22,17.3,-33.3,26,-34,26s-26,-26,-26,-26s76,-59,76,-59s76,-60,76,-60z +M`+(1001+e)+" "+t+"h400000v"+(40+e)+"h-400000z"},Va=function(e,t){return"M983 "+(10+e+t)+` +l`+e/3.13+" -"+e+` +c4,-6.7,10,-10,18,-10 H400000v`+(40+e)+` +H1013.1s-83.4,268,-264.1,840c-180.7,572,-277,876.3,-289,913c-4.7,4.7,-12.7,7,-24,7 +s-12,0,-12,0c-1.3,-3.3,-3.7,-11.7,-7,-25c-35.3,-125.3,-106.7,-373.3,-214,-744 +c-10,12,-21,25,-33,39s-32,39,-32,39c-6,-5.3,-15,-14,-27,-26s25,-30,25,-30 +c26.7,-32.7,52,-63,76,-91s52,-60,52,-60s208,722,208,722 +c56,-175.3,126.3,-397.3,211,-666c84.7,-268.7,153.8,-488.2,207.5,-658.5 +c53.7,-170.3,84.5,-266.8,92.5,-289.5z +M`+(1001+e)+" "+t+"h400000v"+(40+e)+"h-400000z"},Ua=function(e,t){return"M424,"+(2398+e+t)+` +c-1.3,-0.7,-38.5,-172,-111.5,-514c-73,-342,-109.8,-513.3,-110.5,-514 +c0,-2,-10.7,14.3,-32,49c-4.7,7.3,-9.8,15.7,-15.5,25c-5.7,9.3,-9.8,16,-12.5,20 +s-5,7,-5,7c-4,-3.3,-8.3,-7.7,-13,-13s-13,-13,-13,-13s76,-122,76,-122s77,-121,77,-121 +s209,968,209,968c0,-2,84.7,-361.7,254,-1079c169.3,-717.3,254.7,-1077.7,256,-1081 +l`+e/4.223+" -"+e+`c4,-6.7,10,-10,18,-10 H400000 +v`+(40+e)+`H1014.6 +s-87.3,378.7,-272.6,1166c-185.3,787.3,-279.3,1182.3,-282,1185 +c-2,6,-10,9,-24,9 +c-8,0,-12,-0.7,-12,-2z M`+(1001+e)+" "+t+` +h400000v`+(40+e)+"h-400000z"},Xa=function(e,t){return"M473,"+(2713+e+t)+` +c339.3,-1799.3,509.3,-2700,510,-2702 l`+e/5.298+" -"+e+` +c3.3,-7.3,9.3,-11,18,-11 H400000v`+(40+e)+`H1017.7 +s-90.5,478,-276.2,1466c-185.7,988,-279.5,1483,-281.5,1485c-2,6,-10,9,-24,9 +c-8,0,-12,-0.7,-12,-2c0,-1.3,-5.3,-32,-16,-92c-50.7,-293.3,-119.7,-693.3,-207,-1200 +c0,-1.3,-5.3,8.7,-16,30c-10.7,21.3,-21.3,42.7,-32,64s-16,33,-16,33s-26,-26,-26,-26 +s76,-153,76,-153s77,-151,77,-151c0.7,0.7,35.7,202,105,604c67.3,400.7,102,602.7,104, +606zM`+(1001+e)+" "+t+"h400000v"+(40+e)+"H1017.7z"},Wa=function(e){var t=e/2;return"M400000 "+e+" H0 L"+t+" 0 l65 45 L145 "+(e-80)+" H400000z"},Ya=function(e,t,a){var n=a-54-t-e;return"M702 "+(e+t)+"H400000"+(40+e)+` +H742v`+n+`l-4 4-4 4c-.667.7 -2 1.5-4 2.5s-4.167 1.833-6.5 2.5-5.5 1-9.5 1 +h-12l-28-84c-16.667-52-96.667 -294.333-240-727l-212 -643 -85 170 +c-4-3.333-8.333-7.667-13 -13l-13-13l77-155 77-156c66 199.333 139 419.667 +219 661 l218 661zM702 `+t+"H400000v"+(40+e)+"H742z"},Za=function(e,t,a){t=1e3*t;var n="";switch(e){case"sqrtMain":n=Pa(t,re);break;case"sqrtSize1":n=Ga(t,re);break;case"sqrtSize2":n=Va(t,re);break;case"sqrtSize3":n=Ua(t,re);break;case"sqrtSize4":n=Xa(t,re);break;case"sqrtTall":n=Ya(t,re,a)}return n},ja=function(e,t){switch(e){case"\u239C":return"M291 0 H417 V"+t+" H291z M291 0 H417 V"+t+" H291z";case"\u2223":return"M145 0 H188 V"+t+" H145z M145 0 H188 V"+t+" H145z";case"\u2225":return"M145 0 H188 V"+t+" H145z M145 0 H188 V"+t+" H145z"+("M367 0 H410 V"+t+" H367z M367 0 H410 V"+t+" H367z");case"\u239F":return"M457 0 H583 V"+t+" H457z M457 0 H583 V"+t+" H457z";case"\u23A2":return"M319 0 H403 V"+t+" H319z M319 0 H403 V"+t+" H319z";case"\u23A5":return"M263 0 H347 V"+t+" H263z M263 0 H347 V"+t+" H263z";case"\u23AA":return"M384 0 H504 V"+t+" H384z M384 0 H504 V"+t+" H384z";case"\u23D0":return"M312 0 H355 V"+t+" H312z M312 0 H355 V"+t+" H312z";case"\u2016":return"M257 0 H300 V"+t+" H257z M257 0 H300 V"+t+" H257z"+("M478 0 H521 V"+t+" H478z M478 0 H521 V"+t+" H478z");default:return""}},Yt={doubleleftarrow:`M262 157 +l10-10c34-36 62.7-77 86-123 3.3-8 5-13.3 5-16 0-5.3-6.7-8-20-8-7.3 + 0-12.2.5-14.5 1.5-2.3 1-4.8 4.5-7.5 10.5-49.3 97.3-121.7 169.3-217 216-28 + 14-57.3 25-88 33-6.7 2-11 3.8-13 5.5-2 1.7-3 4.2-3 7.5s1 5.8 3 7.5 +c2 1.7 6.3 3.5 13 5.5 68 17.3 128.2 47.8 180.5 91.5 52.3 43.7 93.8 96.2 124.5 + 157.5 9.3 8 15.3 12.3 18 13h6c12-.7 18-4 18-10 0-2-1.7-7-5-15-23.3-46-52-87 +-86-123l-10-10h399738v-40H218c328 0 0 0 0 0l-10-8c-26.7-20-65.7-43-117-69 2.7 +-2 6-3.7 10-5 36.7-16 72.3-37.3 107-64l10-8h399782v-40z +m8 0v40h399730v-40zm0 194v40h399730v-40z`,doublerightarrow:`M399738 392l +-10 10c-34 36-62.7 77-86 123-3.3 8-5 13.3-5 16 0 5.3 6.7 8 20 8 7.3 0 12.2-.5 + 14.5-1.5 2.3-1 4.8-4.5 7.5-10.5 49.3-97.3 121.7-169.3 217-216 28-14 57.3-25 88 +-33 6.7-2 11-3.8 13-5.5 2-1.7 3-4.2 3-7.5s-1-5.8-3-7.5c-2-1.7-6.3-3.5-13-5.5-68 +-17.3-128.2-47.8-180.5-91.5-52.3-43.7-93.8-96.2-124.5-157.5-9.3-8-15.3-12.3-18 +-13h-6c-12 .7-18 4-18 10 0 2 1.7 7 5 15 23.3 46 52 87 86 123l10 10H0v40h399782 +c-328 0 0 0 0 0l10 8c26.7 20 65.7 43 117 69-2.7 2-6 3.7-10 5-36.7 16-72.3 37.3 +-107 64l-10 8H0v40zM0 157v40h399730v-40zm0 194v40h399730v-40z`,leftarrow:`M400000 241H110l3-3c68.7-52.7 113.7-120 + 135-202 4-14.7 6-23 6-25 0-7.3-7-11-21-11-8 0-13.2.8-15.5 2.5-2.3 1.7-4.2 5.8 +-5.5 12.5-1.3 4.7-2.7 10.3-4 17-12 48.7-34.8 92-68.5 130S65.3 228.3 18 247 +c-10 4-16 7.7-18 11 0 8.7 6 14.3 18 17 47.3 18.7 87.8 47 121.5 85S196 441.3 208 + 490c.7 2 1.3 5 2 9s1.2 6.7 1.5 8c.3 1.3 1 3.3 2 6s2.2 4.5 3.5 5.5c1.3 1 3.3 + 1.8 6 2.5s6 1 10 1c14 0 21-3.7 21-11 0-2-2-10.3-6-25-20-79.3-65-146.7-135-202 + l-3-3h399890zM100 241v40h399900v-40z`,leftbrace:`M6 548l-6-6v-35l6-11c56-104 135.3-181.3 238-232 57.3-28.7 117 +-45 179-50h399577v120H403c-43.3 7-81 15-113 26-100.7 33-179.7 91-237 174-2.7 + 5-6 9-10 13-.7 1-7.3 1-20 1H6z`,leftbraceunder:`M0 6l6-6h17c12.688 0 19.313.3 20 1 4 4 7.313 8.3 10 13 + 35.313 51.3 80.813 93.8 136.5 127.5 55.688 33.7 117.188 55.8 184.5 66.5.688 + 0 2 .3 4 1 18.688 2.7 76 4.3 172 5h399450v120H429l-6-1c-124.688-8-235-61.7 +-331-161C60.687 138.7 32.312 99.3 7 54L0 41V6z`,leftgroup:`M400000 80 +H435C64 80 168.3 229.4 21 260c-5.9 1.2-18 0-18 0-2 0-3-1-3-3v-38C76 61 257 0 + 435 0h399565z`,leftgroupunder:`M400000 262 +H435C64 262 168.3 112.6 21 82c-5.9-1.2-18 0-18 0-2 0-3 1-3 3v38c76 158 257 219 + 435 219h399565z`,leftharpoon:`M0 267c.7 5.3 3 10 7 14h399993v-40H93c3.3 +-3.3 10.2-9.5 20.5-18.5s17.8-15.8 22.5-20.5c50.7-52 88-110.3 112-175 4-11.3 5 +-18.3 3-21-1.3-4-7.3-6-18-6-8 0-13 .7-15 2s-4.7 6.7-8 16c-42 98.7-107.3 174.7 +-196 228-6.7 4.7-10.7 8-12 10-1.3 2-2 5.7-2 11zm100-26v40h399900v-40z`,leftharpoonplus:`M0 267c.7 5.3 3 10 7 14h399993v-40H93c3.3-3.3 10.2-9.5 + 20.5-18.5s17.8-15.8 22.5-20.5c50.7-52 88-110.3 112-175 4-11.3 5-18.3 3-21-1.3 +-4-7.3-6-18-6-8 0-13 .7-15 2s-4.7 6.7-8 16c-42 98.7-107.3 174.7-196 228-6.7 4.7 +-10.7 8-12 10-1.3 2-2 5.7-2 11zm100-26v40h399900v-40zM0 435v40h400000v-40z +m0 0v40h400000v-40z`,leftharpoondown:`M7 241c-4 4-6.333 8.667-7 14 0 5.333.667 9 2 11s5.333 + 5.333 12 10c90.667 54 156 130 196 228 3.333 10.667 6.333 16.333 9 17 2 .667 5 + 1 9 1h5c10.667 0 16.667-2 18-6 2-2.667 1-9.667-3-21-32-87.333-82.667-157.667 +-152-211l-3-3h399907v-40zM93 281 H400000 v-40L7 241z`,leftharpoondownplus:`M7 435c-4 4-6.3 8.7-7 14 0 5.3.7 9 2 11s5.3 5.3 12 + 10c90.7 54 156 130 196 228 3.3 10.7 6.3 16.3 9 17 2 .7 5 1 9 1h5c10.7 0 16.7 +-2 18-6 2-2.7 1-9.7-3-21-32-87.3-82.7-157.7-152-211l-3-3h399907v-40H7zm93 0 +v40h399900v-40zM0 241v40h399900v-40zm0 0v40h399900v-40z`,lefthook:`M400000 281 H103s-33-11.2-61-33.5S0 197.3 0 164s14.2-61.2 42.5 +-83.5C70.8 58.2 104 47 142 47 c16.7 0 25 6.7 25 20 0 12-8.7 18.7-26 20-40 3.3 +-68.7 15.7-86 37-10 12-15 25.3-15 40 0 22.7 9.8 40.7 29.5 54 19.7 13.3 43.5 21 + 71.5 23h399859zM103 281v-40h399897v40z`,leftlinesegment:`M40 281 V428 H0 V94 H40 V241 H400000 v40z +M40 281 V428 H0 V94 H40 V241 H400000 v40z`,leftmapsto:`M40 281 V448H0V74H40V241H400000v40z +M40 281 V448H0V74H40V241H400000v40z`,leftToFrom:`M0 147h400000v40H0zm0 214c68 40 115.7 95.7 143 167h22c15.3 0 23 +-.3 23-1 0-1.3-5.3-13.7-16-37-18-35.3-41.3-69-70-101l-7-8h399905v-40H95l7-8 +c28.7-32 52-65.7 70-101 10.7-23.3 16-35.7 16-37 0-.7-7.7-1-23-1h-22C115.7 265.3 + 68 321 0 361zm0-174v-40h399900v40zm100 154v40h399900v-40z`,longequal:`M0 50 h400000 v40H0z m0 194h40000v40H0z +M0 50 h400000 v40H0z m0 194h40000v40H0z`,midbrace:`M200428 334 +c-100.7-8.3-195.3-44-280-108-55.3-42-101.7-93-139-153l-9-14c-2.7 4-5.7 8.7-9 14 +-53.3 86.7-123.7 153-211 199-66.7 36-137.3 56.3-212 62H0V214h199568c178.3-11.7 + 311.7-78.3 403-201 6-8 9.7-12 11-12 .7-.7 6.7-1 18-1s17.3.3 18 1c1.3 0 5 4 11 + 12 44.7 59.3 101.3 106.3 170 141s145.3 54.3 229 60h199572v120z`,midbraceunder:`M199572 214 +c100.7 8.3 195.3 44 280 108 55.3 42 101.7 93 139 153l9 14c2.7-4 5.7-8.7 9-14 + 53.3-86.7 123.7-153 211-199 66.7-36 137.3-56.3 212-62h199568v120H200432c-178.3 + 11.7-311.7 78.3-403 201-6 8-9.7 12-11 12-.7.7-6.7 1-18 1s-17.3-.3-18-1c-1.3 0 +-5-4-11-12-44.7-59.3-101.3-106.3-170-141s-145.3-54.3-229-60H0V214z`,oiintSize1:`M512.6 71.6c272.6 0 320.3 106.8 320.3 178.2 0 70.8-47.7 177.6 +-320.3 177.6S193.1 320.6 193.1 249.8c0-71.4 46.9-178.2 319.5-178.2z +m368.1 178.2c0-86.4-60.9-215.4-368.1-215.4-306.4 0-367.3 129-367.3 215.4 0 85.8 +60.9 214.8 367.3 214.8 307.2 0 368.1-129 368.1-214.8z`,oiintSize2:`M757.8 100.1c384.7 0 451.1 137.6 451.1 230 0 91.3-66.4 228.8 +-451.1 228.8-386.3 0-452.7-137.5-452.7-228.8 0-92.4 66.4-230 452.7-230z +m502.4 230c0-111.2-82.4-277.2-502.4-277.2s-504 166-504 277.2 +c0 110 84 276 504 276s502.4-166 502.4-276z`,oiiintSize1:`M681.4 71.6c408.9 0 480.5 106.8 480.5 178.2 0 70.8-71.6 177.6 +-480.5 177.6S202.1 320.6 202.1 249.8c0-71.4 70.5-178.2 479.3-178.2z +m525.8 178.2c0-86.4-86.8-215.4-525.7-215.4-437.9 0-524.7 129-524.7 215.4 0 +85.8 86.8 214.8 524.7 214.8 438.9 0 525.7-129 525.7-214.8z`,oiiintSize2:`M1021.2 53c603.6 0 707.8 165.8 707.8 277.2 0 110-104.2 275.8 +-707.8 275.8-606 0-710.2-165.8-710.2-275.8C311 218.8 415.2 53 1021.2 53z +m770.4 277.1c0-131.2-126.4-327.6-770.5-327.6S248.4 198.9 248.4 330.1 +c0 130 128.8 326.4 772.7 326.4s770.5-196.4 770.5-326.4z`,rightarrow:`M0 241v40h399891c-47.3 35.3-84 78-110 128 +-16.7 32-27.7 63.7-33 95 0 1.3-.2 2.7-.5 4-.3 1.3-.5 2.3-.5 3 0 7.3 6.7 11 20 + 11 8 0 13.2-.8 15.5-2.5 2.3-1.7 4.2-5.5 5.5-11.5 2-13.3 5.7-27 11-41 14.7-44.7 + 39-84.5 73-119.5s73.7-60.2 119-75.5c6-2 9-5.7 9-11s-3-9-9-11c-45.3-15.3-85 +-40.5-119-75.5s-58.3-74.8-73-119.5c-4.7-14-8.3-27.3-11-40-1.3-6.7-3.2-10.8-5.5 +-12.5-2.3-1.7-7.5-2.5-15.5-2.5-14 0-21 3.7-21 11 0 2 2 10.3 6 25 20.7 83.3 67 + 151.7 139 205zm0 0v40h399900v-40z`,rightbrace:`M400000 542l +-6 6h-17c-12.7 0-19.3-.3-20-1-4-4-7.3-8.3-10-13-35.3-51.3-80.8-93.8-136.5-127.5 +s-117.2-55.8-184.5-66.5c-.7 0-2-.3-4-1-18.7-2.7-76-4.3-172-5H0V214h399571l6 1 +c124.7 8 235 61.7 331 161 31.3 33.3 59.7 72.7 85 118l7 13v35z`,rightbraceunder:`M399994 0l6 6v35l-6 11c-56 104-135.3 181.3-238 232-57.3 + 28.7-117 45-179 50H-300V214h399897c43.3-7 81-15 113-26 100.7-33 179.7-91 237 +-174 2.7-5 6-9 10-13 .7-1 7.3-1 20-1h17z`,rightgroup:`M0 80h399565c371 0 266.7 149.4 414 180 5.9 1.2 18 0 18 0 2 0 + 3-1 3-3v-38c-76-158-257-219-435-219H0z`,rightgroupunder:`M0 262h399565c371 0 266.7-149.4 414-180 5.9-1.2 18 0 18 + 0 2 0 3 1 3 3v38c-76 158-257 219-435 219H0z`,rightharpoon:`M0 241v40h399993c4.7-4.7 7-9.3 7-14 0-9.3 +-3.7-15.3-11-18-92.7-56.7-159-133.7-199-231-3.3-9.3-6-14.7-8-16-2-1.3-7-2-15-2 +-10.7 0-16.7 2-18 6-2 2.7-1 9.7 3 21 15.3 42 36.7 81.8 64 119.5 27.3 37.7 58 + 69.2 92 94.5zm0 0v40h399900v-40z`,rightharpoonplus:`M0 241v40h399993c4.7-4.7 7-9.3 7-14 0-9.3-3.7-15.3-11 +-18-92.7-56.7-159-133.7-199-231-3.3-9.3-6-14.7-8-16-2-1.3-7-2-15-2-10.7 0-16.7 + 2-18 6-2 2.7-1 9.7 3 21 15.3 42 36.7 81.8 64 119.5 27.3 37.7 58 69.2 92 94.5z +m0 0v40h399900v-40z m100 194v40h399900v-40zm0 0v40h399900v-40z`,rightharpoondown:`M399747 511c0 7.3 6.7 11 20 11 8 0 13-.8 15-2.5s4.7-6.8 + 8-15.5c40-94 99.3-166.3 178-217 13.3-8 20.3-12.3 21-13 5.3-3.3 8.5-5.8 9.5 +-7.5 1-1.7 1.5-5.2 1.5-10.5s-2.3-10.3-7-15H0v40h399908c-34 25.3-64.7 57-92 95 +-27.3 38-48.7 77.7-64 119-3.3 8.7-5 14-5 16zM0 241v40h399900v-40z`,rightharpoondownplus:`M399747 705c0 7.3 6.7 11 20 11 8 0 13-.8 + 15-2.5s4.7-6.8 8-15.5c40-94 99.3-166.3 178-217 13.3-8 20.3-12.3 21-13 5.3-3.3 + 8.5-5.8 9.5-7.5 1-1.7 1.5-5.2 1.5-10.5s-2.3-10.3-7-15H0v40h399908c-34 25.3 +-64.7 57-92 95-27.3 38-48.7 77.7-64 119-3.3 8.7-5 14-5 16zM0 435v40h399900v-40z +m0-194v40h400000v-40zm0 0v40h400000v-40z`,righthook:`M399859 241c-764 0 0 0 0 0 40-3.3 68.7-15.7 86-37 10-12 15-25.3 + 15-40 0-22.7-9.8-40.7-29.5-54-19.7-13.3-43.5-21-71.5-23-17.3-1.3-26-8-26-20 0 +-13.3 8.7-20 26-20 38 0 71 11.2 99 33.5 0 0 7 5.6 21 16.7 14 11.2 21 33.5 21 + 66.8s-14 61.2-42 83.5c-28 22.3-61 33.5-99 33.5L0 241z M0 281v-40h399859v40z`,rightlinesegment:`M399960 241 V94 h40 V428 h-40 V281 H0 v-40z +M399960 241 V94 h40 V428 h-40 V281 H0 v-40z`,rightToFrom:`M400000 167c-70.7-42-118-97.7-142-167h-23c-15.3 0-23 .3-23 + 1 0 1.3 5.3 13.7 16 37 18 35.3 41.3 69 70 101l7 8H0v40h399905l-7 8c-28.7 32 +-52 65.7-70 101-10.7 23.3-16 35.7-16 37 0 .7 7.7 1 23 1h23c24-69.3 71.3-125 142 +-167z M100 147v40h399900v-40zM0 341v40h399900v-40z`,twoheadleftarrow:`M0 167c68 40 + 115.7 95.7 143 167h22c15.3 0 23-.3 23-1 0-1.3-5.3-13.7-16-37-18-35.3-41.3-69 +-70-101l-7-8h125l9 7c50.7 39.3 85 86 103 140h46c0-4.7-6.3-18.7-19-42-18-35.3 +-40-67.3-66-96l-9-9h399716v-40H284l9-9c26-28.7 48-60.7 66-96 12.7-23.333 19 +-37.333 19-42h-46c-18 54-52.3 100.7-103 140l-9 7H95l7-8c28.7-32 52-65.7 70-101 + 10.7-23.333 16-35.7 16-37 0-.7-7.7-1-23-1h-22C115.7 71.3 68 127 0 167z`,twoheadrightarrow:`M400000 167 +c-68-40-115.7-95.7-143-167h-22c-15.3 0-23 .3-23 1 0 1.3 5.3 13.7 16 37 18 35.3 + 41.3 69 70 101l7 8h-125l-9-7c-50.7-39.3-85-86-103-140h-46c0 4.7 6.3 18.7 19 42 + 18 35.3 40 67.3 66 96l9 9H0v40h399716l-9 9c-26 28.7-48 60.7-66 96-12.7 23.333 +-19 37.333-19 42h46c18-54 52.3-100.7 103-140l9-7h125l-7 8c-28.7 32-52 65.7-70 + 101-10.7 23.333-16 35.7-16 37 0 .7 7.7 1 23 1h22c27.3-71.3 75-127 143-167z`,tilde1:`M200 55.538c-77 0-168 73.953-177 73.953-3 0-7 +-2.175-9-5.437L2 97c-1-2-2-4-2-6 0-4 2-7 5-9l20-12C116 12 171 0 207 0c86 0 + 114 68 191 68 78 0 168-68 177-68 4 0 7 2 9 5l12 19c1 2.175 2 4.35 2 6.525 0 + 4.35-2 7.613-5 9.788l-19 13.05c-92 63.077-116.937 75.308-183 76.128 +-68.267.847-113-73.952-191-73.952z`,tilde2:`M344 55.266c-142 0-300.638 81.316-311.5 86.418 +-8.01 3.762-22.5 10.91-23.5 5.562L1 120c-1-2-1-3-1-4 0-5 3-9 8-10l18.4-9C160.9 + 31.9 283 0 358 0c148 0 188 122 331 122s314-97 326-97c4 0 8 2 10 7l7 21.114 +c1 2.14 1 3.21 1 4.28 0 5.347-3 9.626-7 10.696l-22.3 12.622C852.6 158.372 751 + 181.476 676 181.476c-149 0-189-126.21-332-126.21z`,tilde3:`M786 59C457 59 32 175.242 13 175.242c-6 0-10-3.457 +-11-10.37L.15 138c-1-7 3-12 10-13l19.2-6.4C378.4 40.7 634.3 0 804.3 0c337 0 + 411.8 157 746.8 157 328 0 754-112 773-112 5 0 10 3 11 9l1 14.075c1 8.066-.697 + 16.595-6.697 17.492l-21.052 7.31c-367.9 98.146-609.15 122.696-778.15 122.696 + -338 0-409-156.573-744-156.573z`,tilde4:`M786 58C457 58 32 177.487 13 177.487c-6 0-10-3.345 +-11-10.035L.15 143c-1-7 3-12 10-13l22-6.7C381.2 35 637.15 0 807.15 0c337 0 409 + 177 744 177 328 0 754-127 773-127 5 0 10 3 11 9l1 14.794c1 7.805-3 13.38-9 + 14.495l-20.7 5.574c-366.85 99.79-607.3 139.372-776.3 139.372-338 0-409 + -175.236-744-175.236z`,vec:`M377 20c0-5.333 1.833-10 5.5-14S391 0 397 0c4.667 0 8.667 1.667 12 5 +3.333 2.667 6.667 9 10 19 6.667 24.667 20.333 43.667 41 57 7.333 4.667 11 +10.667 11 18 0 6-1 10-3 12s-6.667 5-14 9c-28.667 14.667-53.667 35.667-75 63 +-1.333 1.333-3.167 3.5-5.5 6.5s-4 4.833-5 5.5c-1 .667-2.5 1.333-4.5 2s-4.333 1 +-7 1c-4.667 0-9.167-1.833-13.5-5.5S337 184 337 178c0-12.667 15.667-32.333 47-59 +H213l-171-1c-8.667-6-13-12.333-13-19 0-4.667 4.333-11.333 13-20h359 +c-16-25.333-24-45-24-59z`,widehat1:`M529 0h5l519 115c5 1 9 5 9 10 0 1-1 2-1 3l-4 22 +c-1 5-5 9-11 9h-2L532 67 19 159h-2c-5 0-9-4-11-9l-5-22c-1-6 2-12 8-13z`,widehat2:`M1181 0h2l1171 176c6 0 10 5 10 11l-2 23c-1 6-5 10 +-11 10h-1L1182 67 15 220h-1c-6 0-10-4-11-10l-2-23c-1-6 4-11 10-11z`,widehat3:`M1181 0h2l1171 236c6 0 10 5 10 11l-2 23c-1 6-5 10 +-11 10h-1L1182 67 15 280h-1c-6 0-10-4-11-10l-2-23c-1-6 4-11 10-11z`,widehat4:`M1181 0h2l1171 296c6 0 10 5 10 11l-2 23c-1 6-5 10 +-11 10h-1L1182 67 15 340h-1c-6 0-10-4-11-10l-2-23c-1-6 4-11 10-11z`,widecheck1:`M529,159h5l519,-115c5,-1,9,-5,9,-10c0,-1,-1,-2,-1,-3l-4,-22c-1, +-5,-5,-9,-11,-9h-2l-512,92l-513,-92h-2c-5,0,-9,4,-11,9l-5,22c-1,6,2,12,8,13z`,widecheck2:`M1181,220h2l1171,-176c6,0,10,-5,10,-11l-2,-23c-1,-6,-5,-10, +-11,-10h-1l-1168,153l-1167,-153h-1c-6,0,-10,4,-11,10l-2,23c-1,6,4,11,10,11z`,widecheck3:`M1181,280h2l1171,-236c6,0,10,-5,10,-11l-2,-23c-1,-6,-5,-10, +-11,-10h-1l-1168,213l-1167,-213h-1c-6,0,-10,4,-11,10l-2,23c-1,6,4,11,10,11z`,widecheck4:`M1181,340h2l1171,-296c6,0,10,-5,10,-11l-2,-23c-1,-6,-5,-10, +-11,-10h-1l-1168,273l-1167,-273h-1c-6,0,-10,4,-11,10l-2,23c-1,6,4,11,10,11z`,baraboveleftarrow:`M400000 620h-399890l3 -3c68.7 -52.7 113.7 -120 135 -202 +c4 -14.7 6 -23 6 -25c0 -7.3 -7 -11 -21 -11c-8 0 -13.2 0.8 -15.5 2.5 +c-2.3 1.7 -4.2 5.8 -5.5 12.5c-1.3 4.7 -2.7 10.3 -4 17c-12 48.7 -34.8 92 -68.5 130 +s-74.2 66.3 -121.5 85c-10 4 -16 7.7 -18 11c0 8.7 6 14.3 18 17c47.3 18.7 87.8 47 +121.5 85s56.5 81.3 68.5 130c0.7 2 1.3 5 2 9s1.2 6.7 1.5 8c0.3 1.3 1 3.3 2 6 +s2.2 4.5 3.5 5.5c1.3 1 3.3 1.8 6 2.5s6 1 10 1c14 0 21 -3.7 21 -11 +c0 -2 -2 -10.3 -6 -25c-20 -79.3 -65 -146.7 -135 -202l-3 -3h399890z +M100 620v40h399900v-40z M0 241v40h399900v-40zM0 241v40h399900v-40z`,rightarrowabovebar:`M0 241v40h399891c-47.3 35.3-84 78-110 128-16.7 32 +-27.7 63.7-33 95 0 1.3-.2 2.7-.5 4-.3 1.3-.5 2.3-.5 3 0 7.3 6.7 11 20 11 8 0 +13.2-.8 15.5-2.5 2.3-1.7 4.2-5.5 5.5-11.5 2-13.3 5.7-27 11-41 14.7-44.7 39 +-84.5 73-119.5s73.7-60.2 119-75.5c6-2 9-5.7 9-11s-3-9-9-11c-45.3-15.3-85-40.5 +-119-75.5s-58.3-74.8-73-119.5c-4.7-14-8.3-27.3-11-40-1.3-6.7-3.2-10.8-5.5 +-12.5-2.3-1.7-7.5-2.5-15.5-2.5-14 0-21 3.7-21 11 0 2 2 10.3 6 25 20.7 83.3 67 +151.7 139 205zm96 379h399894v40H0zm0 0h399904v40H0z`,baraboveshortleftharpoon:`M507,435c-4,4,-6.3,8.7,-7,14c0,5.3,0.7,9,2,11 +c1.3,2,5.3,5.3,12,10c90.7,54,156,130,196,228c3.3,10.7,6.3,16.3,9,17 +c2,0.7,5,1,9,1c0,0,5,0,5,0c10.7,0,16.7,-2,18,-6c2,-2.7,1,-9.7,-3,-21 +c-32,-87.3,-82.7,-157.7,-152,-211c0,0,-3,-3,-3,-3l399351,0l0,-40 +c-398570,0,-399437,0,-399437,0z M593 435 v40 H399500 v-40z +M0 281 v-40 H399908 v40z M0 281 v-40 H399908 v40z`,rightharpoonaboveshortbar:`M0,241 l0,40c399126,0,399993,0,399993,0 +c4.7,-4.7,7,-9.3,7,-14c0,-9.3,-3.7,-15.3,-11,-18c-92.7,-56.7,-159,-133.7,-199, +-231c-3.3,-9.3,-6,-14.7,-8,-16c-2,-1.3,-7,-2,-15,-2c-10.7,0,-16.7,2,-18,6 +c-2,2.7,-1,9.7,3,21c15.3,42,36.7,81.8,64,119.5c27.3,37.7,58,69.2,92,94.5z +M0 241 v40 H399908 v-40z M0 475 v-40 H399500 v40z M0 475 v-40 H399500 v40z`,shortbaraboveleftharpoon:`M7,435c-4,4,-6.3,8.7,-7,14c0,5.3,0.7,9,2,11 +c1.3,2,5.3,5.3,12,10c90.7,54,156,130,196,228c3.3,10.7,6.3,16.3,9,17c2,0.7,5,1,9, +1c0,0,5,0,5,0c10.7,0,16.7,-2,18,-6c2,-2.7,1,-9.7,-3,-21c-32,-87.3,-82.7,-157.7, +-152,-211c0,0,-3,-3,-3,-3l399907,0l0,-40c-399126,0,-399993,0,-399993,0z +M93 435 v40 H400000 v-40z M500 241 v40 H400000 v-40z M500 241 v40 H400000 v-40z`,shortrightharpoonabovebar:`M53,241l0,40c398570,0,399437,0,399437,0 +c4.7,-4.7,7,-9.3,7,-14c0,-9.3,-3.7,-15.3,-11,-18c-92.7,-56.7,-159,-133.7,-199, +-231c-3.3,-9.3,-6,-14.7,-8,-16c-2,-1.3,-7,-2,-15,-2c-10.7,0,-16.7,2,-18,6 +c-2,2.7,-1,9.7,3,21c15.3,42,36.7,81.8,64,119.5c27.3,37.7,58,69.2,92,94.5z +M500 241 v40 H399408 v-40z M500 435 v40 H400000 v-40z`},Ka=function(e,t){switch(e){case"lbrack":return"M403 1759 V84 H666 V0 H319 V1759 v"+t+` v1759 h347 v-84 +H403z M403 1759 V0 H319 V1759 v`+t+" v1759 h84z";case"rbrack":return"M347 1759 V0 H0 V84 H263 V1759 v"+t+` v1759 H0 v84 H347z +M347 1759 V0 H263 V1759 v`+t+" v1759 h84z";case"vert":return"M145 15 v585 v"+t+` v585 c2.667,10,9.667,15,21,15 +c10,0,16.667,-5,20,-15 v-585 v`+-t+` v-585 c-2.667,-10,-9.667,-15,-21,-15 +c-10,0,-16.667,5,-20,15z M188 15 H145 v585 v`+t+" v585 h43z";case"doublevert":return"M145 15 v585 v"+t+` v585 c2.667,10,9.667,15,21,15 +c10,0,16.667,-5,20,-15 v-585 v`+-t+` v-585 c-2.667,-10,-9.667,-15,-21,-15 +c-10,0,-16.667,5,-20,15z M188 15 H145 v585 v`+t+` v585 h43z +M367 15 v585 v`+t+` v585 c2.667,10,9.667,15,21,15 +c10,0,16.667,-5,20,-15 v-585 v`+-t+` v-585 c-2.667,-10,-9.667,-15,-21,-15 +c-10,0,-16.667,5,-20,15z M410 15 H367 v585 v`+t+" v585 h43z";case"lfloor":return"M319 602 V0 H403 V602 v"+t+` v1715 h263 v84 H319z +MM319 602 V0 H403 V602 v`+t+" v1715 H319z";case"rfloor":return"M319 602 V0 H403 V602 v"+t+` v1799 H0 v-84 H319z +MM319 602 V0 H403 V602 v`+t+" v1715 H319z";case"lceil":return"M403 1759 V84 H666 V0 H319 V1759 v"+t+` v602 h84z +M403 1759 V0 H319 V1759 v`+t+" v602 h84z";case"rceil":return"M347 1759 V0 H0 V84 H263 V1759 v"+t+` v602 h84z +M347 1759 V0 h-84 V1759 v`+t+" v602 h84z";case"lparen":return`M863,9c0,-2,-2,-5,-6,-9c0,0,-17,0,-17,0c-12.7,0,-19.3,0.3,-20,1 +c-5.3,5.3,-10.3,11,-15,17c-242.7,294.7,-395.3,682,-458,1162c-21.3,163.3,-33.3,349, +-36,557 l0,`+(t+84)+`c0.2,6,0,26,0,60c2,159.3,10,310.7,24,454c53.3,528,210, +949.7,470,1265c4.7,6,9.7,11.7,15,17c0.7,0.7,7,1,19,1c0,0,18,0,18,0c4,-4,6,-7,6,-9 +c0,-2.7,-3.3,-8.7,-10,-18c-135.3,-192.7,-235.5,-414.3,-300.5,-665c-65,-250.7,-102.5, +-544.7,-112.5,-882c-2,-104,-3,-167,-3,-189 +l0,-`+(t+92)+`c0,-162.7,5.7,-314,17,-454c20.7,-272,63.7,-513,129,-723c65.3, +-210,155.3,-396.3,270,-559c6.7,-9.3,10,-15.3,10,-18z`;case"rparen":return`M76,0c-16.7,0,-25,3,-25,9c0,2,2,6.3,6,13c21.3,28.7,42.3,60.3, +63,95c96.7,156.7,172.8,332.5,228.5,527.5c55.7,195,92.8,416.5,111.5,664.5 +c11.3,139.3,17,290.7,17,454c0,28,1.7,43,3.3,45l0,`+(t+9)+` +c-3,4,-3.3,16.7,-3.3,38c0,162,-5.7,313.7,-17,455c-18.7,248,-55.8,469.3,-111.5,664 +c-55.7,194.7,-131.8,370.3,-228.5,527c-20.7,34.7,-41.7,66.3,-63,95c-2,3.3,-4,7,-6,11 +c0,7.3,5.7,11,17,11c0,0,11,0,11,0c9.3,0,14.3,-0.3,15,-1c5.3,-5.3,10.3,-11,15,-17 +c242.7,-294.7,395.3,-681.7,458,-1161c21.3,-164.7,33.3,-350.7,36,-558 +l0,-`+(t+144)+`c-2,-159.3,-10,-310.7,-24,-454c-53.3,-528,-210,-949.7, +-470,-1265c-4.7,-6,-9.7,-11.7,-15,-17c-0.7,-0.7,-6.7,-1,-18,-1z`;default:throw new Error("Unknown stretchy delimiter.")}},Y0=class{constructor(e){this.children=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.maxFontSize=void 0,this.style=void 0,this.children=e,this.classes=[],this.height=0,this.depth=0,this.maxFontSize=0,this.style={}}hasClass(e){return O.contains(this.classes,e)}toNode(){for(var e=document.createDocumentFragment(),t=0;tt.toText();return this.children.map(e).join("")}},z0={"AMS-Regular":{32:[0,0,0,0,.25],65:[0,.68889,0,0,.72222],66:[0,.68889,0,0,.66667],67:[0,.68889,0,0,.72222],68:[0,.68889,0,0,.72222],69:[0,.68889,0,0,.66667],70:[0,.68889,0,0,.61111],71:[0,.68889,0,0,.77778],72:[0,.68889,0,0,.77778],73:[0,.68889,0,0,.38889],74:[.16667,.68889,0,0,.5],75:[0,.68889,0,0,.77778],76:[0,.68889,0,0,.66667],77:[0,.68889,0,0,.94445],78:[0,.68889,0,0,.72222],79:[.16667,.68889,0,0,.77778],80:[0,.68889,0,0,.61111],81:[.16667,.68889,0,0,.77778],82:[0,.68889,0,0,.72222],83:[0,.68889,0,0,.55556],84:[0,.68889,0,0,.66667],85:[0,.68889,0,0,.72222],86:[0,.68889,0,0,.72222],87:[0,.68889,0,0,1],88:[0,.68889,0,0,.72222],89:[0,.68889,0,0,.72222],90:[0,.68889,0,0,.66667],107:[0,.68889,0,0,.55556],160:[0,0,0,0,.25],165:[0,.675,.025,0,.75],174:[.15559,.69224,0,0,.94666],240:[0,.68889,0,0,.55556],295:[0,.68889,0,0,.54028],710:[0,.825,0,0,2.33334],732:[0,.9,0,0,2.33334],770:[0,.825,0,0,2.33334],771:[0,.9,0,0,2.33334],989:[.08167,.58167,0,0,.77778],1008:[0,.43056,.04028,0,.66667],8245:[0,.54986,0,0,.275],8463:[0,.68889,0,0,.54028],8487:[0,.68889,0,0,.72222],8498:[0,.68889,0,0,.55556],8502:[0,.68889,0,0,.66667],8503:[0,.68889,0,0,.44445],8504:[0,.68889,0,0,.66667],8513:[0,.68889,0,0,.63889],8592:[-.03598,.46402,0,0,.5],8594:[-.03598,.46402,0,0,.5],8602:[-.13313,.36687,0,0,1],8603:[-.13313,.36687,0,0,1],8606:[.01354,.52239,0,0,1],8608:[.01354,.52239,0,0,1],8610:[.01354,.52239,0,0,1.11111],8611:[.01354,.52239,0,0,1.11111],8619:[0,.54986,0,0,1],8620:[0,.54986,0,0,1],8621:[-.13313,.37788,0,0,1.38889],8622:[-.13313,.36687,0,0,1],8624:[0,.69224,0,0,.5],8625:[0,.69224,0,0,.5],8630:[0,.43056,0,0,1],8631:[0,.43056,0,0,1],8634:[.08198,.58198,0,0,.77778],8635:[.08198,.58198,0,0,.77778],8638:[.19444,.69224,0,0,.41667],8639:[.19444,.69224,0,0,.41667],8642:[.19444,.69224,0,0,.41667],8643:[.19444,.69224,0,0,.41667],8644:[.1808,.675,0,0,1],8646:[.1808,.675,0,0,1],8647:[.1808,.675,0,0,1],8648:[.19444,.69224,0,0,.83334],8649:[.1808,.675,0,0,1],8650:[.19444,.69224,0,0,.83334],8651:[.01354,.52239,0,0,1],8652:[.01354,.52239,0,0,1],8653:[-.13313,.36687,0,0,1],8654:[-.13313,.36687,0,0,1],8655:[-.13313,.36687,0,0,1],8666:[.13667,.63667,0,0,1],8667:[.13667,.63667,0,0,1],8669:[-.13313,.37788,0,0,1],8672:[-.064,.437,0,0,1.334],8674:[-.064,.437,0,0,1.334],8705:[0,.825,0,0,.5],8708:[0,.68889,0,0,.55556],8709:[.08167,.58167,0,0,.77778],8717:[0,.43056,0,0,.42917],8722:[-.03598,.46402,0,0,.5],8724:[.08198,.69224,0,0,.77778],8726:[.08167,.58167,0,0,.77778],8733:[0,.69224,0,0,.77778],8736:[0,.69224,0,0,.72222],8737:[0,.69224,0,0,.72222],8738:[.03517,.52239,0,0,.72222],8739:[.08167,.58167,0,0,.22222],8740:[.25142,.74111,0,0,.27778],8741:[.08167,.58167,0,0,.38889],8742:[.25142,.74111,0,0,.5],8756:[0,.69224,0,0,.66667],8757:[0,.69224,0,0,.66667],8764:[-.13313,.36687,0,0,.77778],8765:[-.13313,.37788,0,0,.77778],8769:[-.13313,.36687,0,0,.77778],8770:[-.03625,.46375,0,0,.77778],8774:[.30274,.79383,0,0,.77778],8776:[-.01688,.48312,0,0,.77778],8778:[.08167,.58167,0,0,.77778],8782:[.06062,.54986,0,0,.77778],8783:[.06062,.54986,0,0,.77778],8785:[.08198,.58198,0,0,.77778],8786:[.08198,.58198,0,0,.77778],8787:[.08198,.58198,0,0,.77778],8790:[0,.69224,0,0,.77778],8791:[.22958,.72958,0,0,.77778],8796:[.08198,.91667,0,0,.77778],8806:[.25583,.75583,0,0,.77778],8807:[.25583,.75583,0,0,.77778],8808:[.25142,.75726,0,0,.77778],8809:[.25142,.75726,0,0,.77778],8812:[.25583,.75583,0,0,.5],8814:[.20576,.70576,0,0,.77778],8815:[.20576,.70576,0,0,.77778],8816:[.30274,.79383,0,0,.77778],8817:[.30274,.79383,0,0,.77778],8818:[.22958,.72958,0,0,.77778],8819:[.22958,.72958,0,0,.77778],8822:[.1808,.675,0,0,.77778],8823:[.1808,.675,0,0,.77778],8828:[.13667,.63667,0,0,.77778],8829:[.13667,.63667,0,0,.77778],8830:[.22958,.72958,0,0,.77778],8831:[.22958,.72958,0,0,.77778],8832:[.20576,.70576,0,0,.77778],8833:[.20576,.70576,0,0,.77778],8840:[.30274,.79383,0,0,.77778],8841:[.30274,.79383,0,0,.77778],8842:[.13597,.63597,0,0,.77778],8843:[.13597,.63597,0,0,.77778],8847:[.03517,.54986,0,0,.77778],8848:[.03517,.54986,0,0,.77778],8858:[.08198,.58198,0,0,.77778],8859:[.08198,.58198,0,0,.77778],8861:[.08198,.58198,0,0,.77778],8862:[0,.675,0,0,.77778],8863:[0,.675,0,0,.77778],8864:[0,.675,0,0,.77778],8865:[0,.675,0,0,.77778],8872:[0,.69224,0,0,.61111],8873:[0,.69224,0,0,.72222],8874:[0,.69224,0,0,.88889],8876:[0,.68889,0,0,.61111],8877:[0,.68889,0,0,.61111],8878:[0,.68889,0,0,.72222],8879:[0,.68889,0,0,.72222],8882:[.03517,.54986,0,0,.77778],8883:[.03517,.54986,0,0,.77778],8884:[.13667,.63667,0,0,.77778],8885:[.13667,.63667,0,0,.77778],8888:[0,.54986,0,0,1.11111],8890:[.19444,.43056,0,0,.55556],8891:[.19444,.69224,0,0,.61111],8892:[.19444,.69224,0,0,.61111],8901:[0,.54986,0,0,.27778],8903:[.08167,.58167,0,0,.77778],8905:[.08167,.58167,0,0,.77778],8906:[.08167,.58167,0,0,.77778],8907:[0,.69224,0,0,.77778],8908:[0,.69224,0,0,.77778],8909:[-.03598,.46402,0,0,.77778],8910:[0,.54986,0,0,.76042],8911:[0,.54986,0,0,.76042],8912:[.03517,.54986,0,0,.77778],8913:[.03517,.54986,0,0,.77778],8914:[0,.54986,0,0,.66667],8915:[0,.54986,0,0,.66667],8916:[0,.69224,0,0,.66667],8918:[.0391,.5391,0,0,.77778],8919:[.0391,.5391,0,0,.77778],8920:[.03517,.54986,0,0,1.33334],8921:[.03517,.54986,0,0,1.33334],8922:[.38569,.88569,0,0,.77778],8923:[.38569,.88569,0,0,.77778],8926:[.13667,.63667,0,0,.77778],8927:[.13667,.63667,0,0,.77778],8928:[.30274,.79383,0,0,.77778],8929:[.30274,.79383,0,0,.77778],8934:[.23222,.74111,0,0,.77778],8935:[.23222,.74111,0,0,.77778],8936:[.23222,.74111,0,0,.77778],8937:[.23222,.74111,0,0,.77778],8938:[.20576,.70576,0,0,.77778],8939:[.20576,.70576,0,0,.77778],8940:[.30274,.79383,0,0,.77778],8941:[.30274,.79383,0,0,.77778],8994:[.19444,.69224,0,0,.77778],8995:[.19444,.69224,0,0,.77778],9416:[.15559,.69224,0,0,.90222],9484:[0,.69224,0,0,.5],9488:[0,.69224,0,0,.5],9492:[0,.37788,0,0,.5],9496:[0,.37788,0,0,.5],9585:[.19444,.68889,0,0,.88889],9586:[.19444,.74111,0,0,.88889],9632:[0,.675,0,0,.77778],9633:[0,.675,0,0,.77778],9650:[0,.54986,0,0,.72222],9651:[0,.54986,0,0,.72222],9654:[.03517,.54986,0,0,.77778],9660:[0,.54986,0,0,.72222],9661:[0,.54986,0,0,.72222],9664:[.03517,.54986,0,0,.77778],9674:[.11111,.69224,0,0,.66667],9733:[.19444,.69224,0,0,.94445],10003:[0,.69224,0,0,.83334],10016:[0,.69224,0,0,.83334],10731:[.11111,.69224,0,0,.66667],10846:[.19444,.75583,0,0,.61111],10877:[.13667,.63667,0,0,.77778],10878:[.13667,.63667,0,0,.77778],10885:[.25583,.75583,0,0,.77778],10886:[.25583,.75583,0,0,.77778],10887:[.13597,.63597,0,0,.77778],10888:[.13597,.63597,0,0,.77778],10889:[.26167,.75726,0,0,.77778],10890:[.26167,.75726,0,0,.77778],10891:[.48256,.98256,0,0,.77778],10892:[.48256,.98256,0,0,.77778],10901:[.13667,.63667,0,0,.77778],10902:[.13667,.63667,0,0,.77778],10933:[.25142,.75726,0,0,.77778],10934:[.25142,.75726,0,0,.77778],10935:[.26167,.75726,0,0,.77778],10936:[.26167,.75726,0,0,.77778],10937:[.26167,.75726,0,0,.77778],10938:[.26167,.75726,0,0,.77778],10949:[.25583,.75583,0,0,.77778],10950:[.25583,.75583,0,0,.77778],10955:[.28481,.79383,0,0,.77778],10956:[.28481,.79383,0,0,.77778],57350:[.08167,.58167,0,0,.22222],57351:[.08167,.58167,0,0,.38889],57352:[.08167,.58167,0,0,.77778],57353:[0,.43056,.04028,0,.66667],57356:[.25142,.75726,0,0,.77778],57357:[.25142,.75726,0,0,.77778],57358:[.41951,.91951,0,0,.77778],57359:[.30274,.79383,0,0,.77778],57360:[.30274,.79383,0,0,.77778],57361:[.41951,.91951,0,0,.77778],57366:[.25142,.75726,0,0,.77778],57367:[.25142,.75726,0,0,.77778],57368:[.25142,.75726,0,0,.77778],57369:[.25142,.75726,0,0,.77778],57370:[.13597,.63597,0,0,.77778],57371:[.13597,.63597,0,0,.77778]},"Caligraphic-Regular":{32:[0,0,0,0,.25],65:[0,.68333,0,.19445,.79847],66:[0,.68333,.03041,.13889,.65681],67:[0,.68333,.05834,.13889,.52653],68:[0,.68333,.02778,.08334,.77139],69:[0,.68333,.08944,.11111,.52778],70:[0,.68333,.09931,.11111,.71875],71:[.09722,.68333,.0593,.11111,.59487],72:[0,.68333,.00965,.11111,.84452],73:[0,.68333,.07382,0,.54452],74:[.09722,.68333,.18472,.16667,.67778],75:[0,.68333,.01445,.05556,.76195],76:[0,.68333,0,.13889,.68972],77:[0,.68333,0,.13889,1.2009],78:[0,.68333,.14736,.08334,.82049],79:[0,.68333,.02778,.11111,.79611],80:[0,.68333,.08222,.08334,.69556],81:[.09722,.68333,0,.11111,.81667],82:[0,.68333,0,.08334,.8475],83:[0,.68333,.075,.13889,.60556],84:[0,.68333,.25417,0,.54464],85:[0,.68333,.09931,.08334,.62583],86:[0,.68333,.08222,0,.61278],87:[0,.68333,.08222,.08334,.98778],88:[0,.68333,.14643,.13889,.7133],89:[.09722,.68333,.08222,.08334,.66834],90:[0,.68333,.07944,.13889,.72473],160:[0,0,0,0,.25]},"Fraktur-Regular":{32:[0,0,0,0,.25],33:[0,.69141,0,0,.29574],34:[0,.69141,0,0,.21471],38:[0,.69141,0,0,.73786],39:[0,.69141,0,0,.21201],40:[.24982,.74947,0,0,.38865],41:[.24982,.74947,0,0,.38865],42:[0,.62119,0,0,.27764],43:[.08319,.58283,0,0,.75623],44:[0,.10803,0,0,.27764],45:[.08319,.58283,0,0,.75623],46:[0,.10803,0,0,.27764],47:[.24982,.74947,0,0,.50181],48:[0,.47534,0,0,.50181],49:[0,.47534,0,0,.50181],50:[0,.47534,0,0,.50181],51:[.18906,.47534,0,0,.50181],52:[.18906,.47534,0,0,.50181],53:[.18906,.47534,0,0,.50181],54:[0,.69141,0,0,.50181],55:[.18906,.47534,0,0,.50181],56:[0,.69141,0,0,.50181],57:[.18906,.47534,0,0,.50181],58:[0,.47534,0,0,.21606],59:[.12604,.47534,0,0,.21606],61:[-.13099,.36866,0,0,.75623],63:[0,.69141,0,0,.36245],65:[0,.69141,0,0,.7176],66:[0,.69141,0,0,.88397],67:[0,.69141,0,0,.61254],68:[0,.69141,0,0,.83158],69:[0,.69141,0,0,.66278],70:[.12604,.69141,0,0,.61119],71:[0,.69141,0,0,.78539],72:[.06302,.69141,0,0,.7203],73:[0,.69141,0,0,.55448],74:[.12604,.69141,0,0,.55231],75:[0,.69141,0,0,.66845],76:[0,.69141,0,0,.66602],77:[0,.69141,0,0,1.04953],78:[0,.69141,0,0,.83212],79:[0,.69141,0,0,.82699],80:[.18906,.69141,0,0,.82753],81:[.03781,.69141,0,0,.82699],82:[0,.69141,0,0,.82807],83:[0,.69141,0,0,.82861],84:[0,.69141,0,0,.66899],85:[0,.69141,0,0,.64576],86:[0,.69141,0,0,.83131],87:[0,.69141,0,0,1.04602],88:[0,.69141,0,0,.71922],89:[.18906,.69141,0,0,.83293],90:[.12604,.69141,0,0,.60201],91:[.24982,.74947,0,0,.27764],93:[.24982,.74947,0,0,.27764],94:[0,.69141,0,0,.49965],97:[0,.47534,0,0,.50046],98:[0,.69141,0,0,.51315],99:[0,.47534,0,0,.38946],100:[0,.62119,0,0,.49857],101:[0,.47534,0,0,.40053],102:[.18906,.69141,0,0,.32626],103:[.18906,.47534,0,0,.5037],104:[.18906,.69141,0,0,.52126],105:[0,.69141,0,0,.27899],106:[0,.69141,0,0,.28088],107:[0,.69141,0,0,.38946],108:[0,.69141,0,0,.27953],109:[0,.47534,0,0,.76676],110:[0,.47534,0,0,.52666],111:[0,.47534,0,0,.48885],112:[.18906,.52396,0,0,.50046],113:[.18906,.47534,0,0,.48912],114:[0,.47534,0,0,.38919],115:[0,.47534,0,0,.44266],116:[0,.62119,0,0,.33301],117:[0,.47534,0,0,.5172],118:[0,.52396,0,0,.5118],119:[0,.52396,0,0,.77351],120:[.18906,.47534,0,0,.38865],121:[.18906,.47534,0,0,.49884],122:[.18906,.47534,0,0,.39054],160:[0,0,0,0,.25],8216:[0,.69141,0,0,.21471],8217:[0,.69141,0,0,.21471],58112:[0,.62119,0,0,.49749],58113:[0,.62119,0,0,.4983],58114:[.18906,.69141,0,0,.33328],58115:[.18906,.69141,0,0,.32923],58116:[.18906,.47534,0,0,.50343],58117:[0,.69141,0,0,.33301],58118:[0,.62119,0,0,.33409],58119:[0,.47534,0,0,.50073]},"Main-Bold":{32:[0,0,0,0,.25],33:[0,.69444,0,0,.35],34:[0,.69444,0,0,.60278],35:[.19444,.69444,0,0,.95833],36:[.05556,.75,0,0,.575],37:[.05556,.75,0,0,.95833],38:[0,.69444,0,0,.89444],39:[0,.69444,0,0,.31944],40:[.25,.75,0,0,.44722],41:[.25,.75,0,0,.44722],42:[0,.75,0,0,.575],43:[.13333,.63333,0,0,.89444],44:[.19444,.15556,0,0,.31944],45:[0,.44444,0,0,.38333],46:[0,.15556,0,0,.31944],47:[.25,.75,0,0,.575],48:[0,.64444,0,0,.575],49:[0,.64444,0,0,.575],50:[0,.64444,0,0,.575],51:[0,.64444,0,0,.575],52:[0,.64444,0,0,.575],53:[0,.64444,0,0,.575],54:[0,.64444,0,0,.575],55:[0,.64444,0,0,.575],56:[0,.64444,0,0,.575],57:[0,.64444,0,0,.575],58:[0,.44444,0,0,.31944],59:[.19444,.44444,0,0,.31944],60:[.08556,.58556,0,0,.89444],61:[-.10889,.39111,0,0,.89444],62:[.08556,.58556,0,0,.89444],63:[0,.69444,0,0,.54305],64:[0,.69444,0,0,.89444],65:[0,.68611,0,0,.86944],66:[0,.68611,0,0,.81805],67:[0,.68611,0,0,.83055],68:[0,.68611,0,0,.88194],69:[0,.68611,0,0,.75555],70:[0,.68611,0,0,.72361],71:[0,.68611,0,0,.90416],72:[0,.68611,0,0,.9],73:[0,.68611,0,0,.43611],74:[0,.68611,0,0,.59444],75:[0,.68611,0,0,.90138],76:[0,.68611,0,0,.69166],77:[0,.68611,0,0,1.09166],78:[0,.68611,0,0,.9],79:[0,.68611,0,0,.86388],80:[0,.68611,0,0,.78611],81:[.19444,.68611,0,0,.86388],82:[0,.68611,0,0,.8625],83:[0,.68611,0,0,.63889],84:[0,.68611,0,0,.8],85:[0,.68611,0,0,.88472],86:[0,.68611,.01597,0,.86944],87:[0,.68611,.01597,0,1.18888],88:[0,.68611,0,0,.86944],89:[0,.68611,.02875,0,.86944],90:[0,.68611,0,0,.70277],91:[.25,.75,0,0,.31944],92:[.25,.75,0,0,.575],93:[.25,.75,0,0,.31944],94:[0,.69444,0,0,.575],95:[.31,.13444,.03194,0,.575],97:[0,.44444,0,0,.55902],98:[0,.69444,0,0,.63889],99:[0,.44444,0,0,.51111],100:[0,.69444,0,0,.63889],101:[0,.44444,0,0,.52708],102:[0,.69444,.10903,0,.35139],103:[.19444,.44444,.01597,0,.575],104:[0,.69444,0,0,.63889],105:[0,.69444,0,0,.31944],106:[.19444,.69444,0,0,.35139],107:[0,.69444,0,0,.60694],108:[0,.69444,0,0,.31944],109:[0,.44444,0,0,.95833],110:[0,.44444,0,0,.63889],111:[0,.44444,0,0,.575],112:[.19444,.44444,0,0,.63889],113:[.19444,.44444,0,0,.60694],114:[0,.44444,0,0,.47361],115:[0,.44444,0,0,.45361],116:[0,.63492,0,0,.44722],117:[0,.44444,0,0,.63889],118:[0,.44444,.01597,0,.60694],119:[0,.44444,.01597,0,.83055],120:[0,.44444,0,0,.60694],121:[.19444,.44444,.01597,0,.60694],122:[0,.44444,0,0,.51111],123:[.25,.75,0,0,.575],124:[.25,.75,0,0,.31944],125:[.25,.75,0,0,.575],126:[.35,.34444,0,0,.575],160:[0,0,0,0,.25],163:[0,.69444,0,0,.86853],168:[0,.69444,0,0,.575],172:[0,.44444,0,0,.76666],176:[0,.69444,0,0,.86944],177:[.13333,.63333,0,0,.89444],184:[.17014,0,0,0,.51111],198:[0,.68611,0,0,1.04166],215:[.13333,.63333,0,0,.89444],216:[.04861,.73472,0,0,.89444],223:[0,.69444,0,0,.59722],230:[0,.44444,0,0,.83055],247:[.13333,.63333,0,0,.89444],248:[.09722,.54167,0,0,.575],305:[0,.44444,0,0,.31944],338:[0,.68611,0,0,1.16944],339:[0,.44444,0,0,.89444],567:[.19444,.44444,0,0,.35139],710:[0,.69444,0,0,.575],711:[0,.63194,0,0,.575],713:[0,.59611,0,0,.575],714:[0,.69444,0,0,.575],715:[0,.69444,0,0,.575],728:[0,.69444,0,0,.575],729:[0,.69444,0,0,.31944],730:[0,.69444,0,0,.86944],732:[0,.69444,0,0,.575],733:[0,.69444,0,0,.575],915:[0,.68611,0,0,.69166],916:[0,.68611,0,0,.95833],920:[0,.68611,0,0,.89444],923:[0,.68611,0,0,.80555],926:[0,.68611,0,0,.76666],928:[0,.68611,0,0,.9],931:[0,.68611,0,0,.83055],933:[0,.68611,0,0,.89444],934:[0,.68611,0,0,.83055],936:[0,.68611,0,0,.89444],937:[0,.68611,0,0,.83055],8211:[0,.44444,.03194,0,.575],8212:[0,.44444,.03194,0,1.14999],8216:[0,.69444,0,0,.31944],8217:[0,.69444,0,0,.31944],8220:[0,.69444,0,0,.60278],8221:[0,.69444,0,0,.60278],8224:[.19444,.69444,0,0,.51111],8225:[.19444,.69444,0,0,.51111],8242:[0,.55556,0,0,.34444],8407:[0,.72444,.15486,0,.575],8463:[0,.69444,0,0,.66759],8465:[0,.69444,0,0,.83055],8467:[0,.69444,0,0,.47361],8472:[.19444,.44444,0,0,.74027],8476:[0,.69444,0,0,.83055],8501:[0,.69444,0,0,.70277],8592:[-.10889,.39111,0,0,1.14999],8593:[.19444,.69444,0,0,.575],8594:[-.10889,.39111,0,0,1.14999],8595:[.19444,.69444,0,0,.575],8596:[-.10889,.39111,0,0,1.14999],8597:[.25,.75,0,0,.575],8598:[.19444,.69444,0,0,1.14999],8599:[.19444,.69444,0,0,1.14999],8600:[.19444,.69444,0,0,1.14999],8601:[.19444,.69444,0,0,1.14999],8636:[-.10889,.39111,0,0,1.14999],8637:[-.10889,.39111,0,0,1.14999],8640:[-.10889,.39111,0,0,1.14999],8641:[-.10889,.39111,0,0,1.14999],8656:[-.10889,.39111,0,0,1.14999],8657:[.19444,.69444,0,0,.70277],8658:[-.10889,.39111,0,0,1.14999],8659:[.19444,.69444,0,0,.70277],8660:[-.10889,.39111,0,0,1.14999],8661:[.25,.75,0,0,.70277],8704:[0,.69444,0,0,.63889],8706:[0,.69444,.06389,0,.62847],8707:[0,.69444,0,0,.63889],8709:[.05556,.75,0,0,.575],8711:[0,.68611,0,0,.95833],8712:[.08556,.58556,0,0,.76666],8715:[.08556,.58556,0,0,.76666],8722:[.13333,.63333,0,0,.89444],8723:[.13333,.63333,0,0,.89444],8725:[.25,.75,0,0,.575],8726:[.25,.75,0,0,.575],8727:[-.02778,.47222,0,0,.575],8728:[-.02639,.47361,0,0,.575],8729:[-.02639,.47361,0,0,.575],8730:[.18,.82,0,0,.95833],8733:[0,.44444,0,0,.89444],8734:[0,.44444,0,0,1.14999],8736:[0,.69224,0,0,.72222],8739:[.25,.75,0,0,.31944],8741:[.25,.75,0,0,.575],8743:[0,.55556,0,0,.76666],8744:[0,.55556,0,0,.76666],8745:[0,.55556,0,0,.76666],8746:[0,.55556,0,0,.76666],8747:[.19444,.69444,.12778,0,.56875],8764:[-.10889,.39111,0,0,.89444],8768:[.19444,.69444,0,0,.31944],8771:[.00222,.50222,0,0,.89444],8773:[.027,.638,0,0,.894],8776:[.02444,.52444,0,0,.89444],8781:[.00222,.50222,0,0,.89444],8801:[.00222,.50222,0,0,.89444],8804:[.19667,.69667,0,0,.89444],8805:[.19667,.69667,0,0,.89444],8810:[.08556,.58556,0,0,1.14999],8811:[.08556,.58556,0,0,1.14999],8826:[.08556,.58556,0,0,.89444],8827:[.08556,.58556,0,0,.89444],8834:[.08556,.58556,0,0,.89444],8835:[.08556,.58556,0,0,.89444],8838:[.19667,.69667,0,0,.89444],8839:[.19667,.69667,0,0,.89444],8846:[0,.55556,0,0,.76666],8849:[.19667,.69667,0,0,.89444],8850:[.19667,.69667,0,0,.89444],8851:[0,.55556,0,0,.76666],8852:[0,.55556,0,0,.76666],8853:[.13333,.63333,0,0,.89444],8854:[.13333,.63333,0,0,.89444],8855:[.13333,.63333,0,0,.89444],8856:[.13333,.63333,0,0,.89444],8857:[.13333,.63333,0,0,.89444],8866:[0,.69444,0,0,.70277],8867:[0,.69444,0,0,.70277],8868:[0,.69444,0,0,.89444],8869:[0,.69444,0,0,.89444],8900:[-.02639,.47361,0,0,.575],8901:[-.02639,.47361,0,0,.31944],8902:[-.02778,.47222,0,0,.575],8968:[.25,.75,0,0,.51111],8969:[.25,.75,0,0,.51111],8970:[.25,.75,0,0,.51111],8971:[.25,.75,0,0,.51111],8994:[-.13889,.36111,0,0,1.14999],8995:[-.13889,.36111,0,0,1.14999],9651:[.19444,.69444,0,0,1.02222],9657:[-.02778,.47222,0,0,.575],9661:[.19444,.69444,0,0,1.02222],9667:[-.02778,.47222,0,0,.575],9711:[.19444,.69444,0,0,1.14999],9824:[.12963,.69444,0,0,.89444],9825:[.12963,.69444,0,0,.89444],9826:[.12963,.69444,0,0,.89444],9827:[.12963,.69444,0,0,.89444],9837:[0,.75,0,0,.44722],9838:[.19444,.69444,0,0,.44722],9839:[.19444,.69444,0,0,.44722],10216:[.25,.75,0,0,.44722],10217:[.25,.75,0,0,.44722],10815:[0,.68611,0,0,.9],10927:[.19667,.69667,0,0,.89444],10928:[.19667,.69667,0,0,.89444],57376:[.19444,.69444,0,0,0]},"Main-BoldItalic":{32:[0,0,0,0,.25],33:[0,.69444,.11417,0,.38611],34:[0,.69444,.07939,0,.62055],35:[.19444,.69444,.06833,0,.94444],37:[.05556,.75,.12861,0,.94444],38:[0,.69444,.08528,0,.88555],39:[0,.69444,.12945,0,.35555],40:[.25,.75,.15806,0,.47333],41:[.25,.75,.03306,0,.47333],42:[0,.75,.14333,0,.59111],43:[.10333,.60333,.03306,0,.88555],44:[.19444,.14722,0,0,.35555],45:[0,.44444,.02611,0,.41444],46:[0,.14722,0,0,.35555],47:[.25,.75,.15806,0,.59111],48:[0,.64444,.13167,0,.59111],49:[0,.64444,.13167,0,.59111],50:[0,.64444,.13167,0,.59111],51:[0,.64444,.13167,0,.59111],52:[.19444,.64444,.13167,0,.59111],53:[0,.64444,.13167,0,.59111],54:[0,.64444,.13167,0,.59111],55:[.19444,.64444,.13167,0,.59111],56:[0,.64444,.13167,0,.59111],57:[0,.64444,.13167,0,.59111],58:[0,.44444,.06695,0,.35555],59:[.19444,.44444,.06695,0,.35555],61:[-.10889,.39111,.06833,0,.88555],63:[0,.69444,.11472,0,.59111],64:[0,.69444,.09208,0,.88555],65:[0,.68611,0,0,.86555],66:[0,.68611,.0992,0,.81666],67:[0,.68611,.14208,0,.82666],68:[0,.68611,.09062,0,.87555],69:[0,.68611,.11431,0,.75666],70:[0,.68611,.12903,0,.72722],71:[0,.68611,.07347,0,.89527],72:[0,.68611,.17208,0,.8961],73:[0,.68611,.15681,0,.47166],74:[0,.68611,.145,0,.61055],75:[0,.68611,.14208,0,.89499],76:[0,.68611,0,0,.69777],77:[0,.68611,.17208,0,1.07277],78:[0,.68611,.17208,0,.8961],79:[0,.68611,.09062,0,.85499],80:[0,.68611,.0992,0,.78721],81:[.19444,.68611,.09062,0,.85499],82:[0,.68611,.02559,0,.85944],83:[0,.68611,.11264,0,.64999],84:[0,.68611,.12903,0,.7961],85:[0,.68611,.17208,0,.88083],86:[0,.68611,.18625,0,.86555],87:[0,.68611,.18625,0,1.15999],88:[0,.68611,.15681,0,.86555],89:[0,.68611,.19803,0,.86555],90:[0,.68611,.14208,0,.70888],91:[.25,.75,.1875,0,.35611],93:[.25,.75,.09972,0,.35611],94:[0,.69444,.06709,0,.59111],95:[.31,.13444,.09811,0,.59111],97:[0,.44444,.09426,0,.59111],98:[0,.69444,.07861,0,.53222],99:[0,.44444,.05222,0,.53222],100:[0,.69444,.10861,0,.59111],101:[0,.44444,.085,0,.53222],102:[.19444,.69444,.21778,0,.4],103:[.19444,.44444,.105,0,.53222],104:[0,.69444,.09426,0,.59111],105:[0,.69326,.11387,0,.35555],106:[.19444,.69326,.1672,0,.35555],107:[0,.69444,.11111,0,.53222],108:[0,.69444,.10861,0,.29666],109:[0,.44444,.09426,0,.94444],110:[0,.44444,.09426,0,.64999],111:[0,.44444,.07861,0,.59111],112:[.19444,.44444,.07861,0,.59111],113:[.19444,.44444,.105,0,.53222],114:[0,.44444,.11111,0,.50167],115:[0,.44444,.08167,0,.48694],116:[0,.63492,.09639,0,.385],117:[0,.44444,.09426,0,.62055],118:[0,.44444,.11111,0,.53222],119:[0,.44444,.11111,0,.76777],120:[0,.44444,.12583,0,.56055],121:[.19444,.44444,.105,0,.56166],122:[0,.44444,.13889,0,.49055],126:[.35,.34444,.11472,0,.59111],160:[0,0,0,0,.25],168:[0,.69444,.11473,0,.59111],176:[0,.69444,0,0,.94888],184:[.17014,0,0,0,.53222],198:[0,.68611,.11431,0,1.02277],216:[.04861,.73472,.09062,0,.88555],223:[.19444,.69444,.09736,0,.665],230:[0,.44444,.085,0,.82666],248:[.09722,.54167,.09458,0,.59111],305:[0,.44444,.09426,0,.35555],338:[0,.68611,.11431,0,1.14054],339:[0,.44444,.085,0,.82666],567:[.19444,.44444,.04611,0,.385],710:[0,.69444,.06709,0,.59111],711:[0,.63194,.08271,0,.59111],713:[0,.59444,.10444,0,.59111],714:[0,.69444,.08528,0,.59111],715:[0,.69444,0,0,.59111],728:[0,.69444,.10333,0,.59111],729:[0,.69444,.12945,0,.35555],730:[0,.69444,0,0,.94888],732:[0,.69444,.11472,0,.59111],733:[0,.69444,.11472,0,.59111],915:[0,.68611,.12903,0,.69777],916:[0,.68611,0,0,.94444],920:[0,.68611,.09062,0,.88555],923:[0,.68611,0,0,.80666],926:[0,.68611,.15092,0,.76777],928:[0,.68611,.17208,0,.8961],931:[0,.68611,.11431,0,.82666],933:[0,.68611,.10778,0,.88555],934:[0,.68611,.05632,0,.82666],936:[0,.68611,.10778,0,.88555],937:[0,.68611,.0992,0,.82666],8211:[0,.44444,.09811,0,.59111],8212:[0,.44444,.09811,0,1.18221],8216:[0,.69444,.12945,0,.35555],8217:[0,.69444,.12945,0,.35555],8220:[0,.69444,.16772,0,.62055],8221:[0,.69444,.07939,0,.62055]},"Main-Italic":{32:[0,0,0,0,.25],33:[0,.69444,.12417,0,.30667],34:[0,.69444,.06961,0,.51444],35:[.19444,.69444,.06616,0,.81777],37:[.05556,.75,.13639,0,.81777],38:[0,.69444,.09694,0,.76666],39:[0,.69444,.12417,0,.30667],40:[.25,.75,.16194,0,.40889],41:[.25,.75,.03694,0,.40889],42:[0,.75,.14917,0,.51111],43:[.05667,.56167,.03694,0,.76666],44:[.19444,.10556,0,0,.30667],45:[0,.43056,.02826,0,.35778],46:[0,.10556,0,0,.30667],47:[.25,.75,.16194,0,.51111],48:[0,.64444,.13556,0,.51111],49:[0,.64444,.13556,0,.51111],50:[0,.64444,.13556,0,.51111],51:[0,.64444,.13556,0,.51111],52:[.19444,.64444,.13556,0,.51111],53:[0,.64444,.13556,0,.51111],54:[0,.64444,.13556,0,.51111],55:[.19444,.64444,.13556,0,.51111],56:[0,.64444,.13556,0,.51111],57:[0,.64444,.13556,0,.51111],58:[0,.43056,.0582,0,.30667],59:[.19444,.43056,.0582,0,.30667],61:[-.13313,.36687,.06616,0,.76666],63:[0,.69444,.1225,0,.51111],64:[0,.69444,.09597,0,.76666],65:[0,.68333,0,0,.74333],66:[0,.68333,.10257,0,.70389],67:[0,.68333,.14528,0,.71555],68:[0,.68333,.09403,0,.755],69:[0,.68333,.12028,0,.67833],70:[0,.68333,.13305,0,.65277],71:[0,.68333,.08722,0,.77361],72:[0,.68333,.16389,0,.74333],73:[0,.68333,.15806,0,.38555],74:[0,.68333,.14028,0,.525],75:[0,.68333,.14528,0,.76888],76:[0,.68333,0,0,.62722],77:[0,.68333,.16389,0,.89666],78:[0,.68333,.16389,0,.74333],79:[0,.68333,.09403,0,.76666],80:[0,.68333,.10257,0,.67833],81:[.19444,.68333,.09403,0,.76666],82:[0,.68333,.03868,0,.72944],83:[0,.68333,.11972,0,.56222],84:[0,.68333,.13305,0,.71555],85:[0,.68333,.16389,0,.74333],86:[0,.68333,.18361,0,.74333],87:[0,.68333,.18361,0,.99888],88:[0,.68333,.15806,0,.74333],89:[0,.68333,.19383,0,.74333],90:[0,.68333,.14528,0,.61333],91:[.25,.75,.1875,0,.30667],93:[.25,.75,.10528,0,.30667],94:[0,.69444,.06646,0,.51111],95:[.31,.12056,.09208,0,.51111],97:[0,.43056,.07671,0,.51111],98:[0,.69444,.06312,0,.46],99:[0,.43056,.05653,0,.46],100:[0,.69444,.10333,0,.51111],101:[0,.43056,.07514,0,.46],102:[.19444,.69444,.21194,0,.30667],103:[.19444,.43056,.08847,0,.46],104:[0,.69444,.07671,0,.51111],105:[0,.65536,.1019,0,.30667],106:[.19444,.65536,.14467,0,.30667],107:[0,.69444,.10764,0,.46],108:[0,.69444,.10333,0,.25555],109:[0,.43056,.07671,0,.81777],110:[0,.43056,.07671,0,.56222],111:[0,.43056,.06312,0,.51111],112:[.19444,.43056,.06312,0,.51111],113:[.19444,.43056,.08847,0,.46],114:[0,.43056,.10764,0,.42166],115:[0,.43056,.08208,0,.40889],116:[0,.61508,.09486,0,.33222],117:[0,.43056,.07671,0,.53666],118:[0,.43056,.10764,0,.46],119:[0,.43056,.10764,0,.66444],120:[0,.43056,.12042,0,.46389],121:[.19444,.43056,.08847,0,.48555],122:[0,.43056,.12292,0,.40889],126:[.35,.31786,.11585,0,.51111],160:[0,0,0,0,.25],168:[0,.66786,.10474,0,.51111],176:[0,.69444,0,0,.83129],184:[.17014,0,0,0,.46],198:[0,.68333,.12028,0,.88277],216:[.04861,.73194,.09403,0,.76666],223:[.19444,.69444,.10514,0,.53666],230:[0,.43056,.07514,0,.71555],248:[.09722,.52778,.09194,0,.51111],338:[0,.68333,.12028,0,.98499],339:[0,.43056,.07514,0,.71555],710:[0,.69444,.06646,0,.51111],711:[0,.62847,.08295,0,.51111],713:[0,.56167,.10333,0,.51111],714:[0,.69444,.09694,0,.51111],715:[0,.69444,0,0,.51111],728:[0,.69444,.10806,0,.51111],729:[0,.66786,.11752,0,.30667],730:[0,.69444,0,0,.83129],732:[0,.66786,.11585,0,.51111],733:[0,.69444,.1225,0,.51111],915:[0,.68333,.13305,0,.62722],916:[0,.68333,0,0,.81777],920:[0,.68333,.09403,0,.76666],923:[0,.68333,0,0,.69222],926:[0,.68333,.15294,0,.66444],928:[0,.68333,.16389,0,.74333],931:[0,.68333,.12028,0,.71555],933:[0,.68333,.11111,0,.76666],934:[0,.68333,.05986,0,.71555],936:[0,.68333,.11111,0,.76666],937:[0,.68333,.10257,0,.71555],8211:[0,.43056,.09208,0,.51111],8212:[0,.43056,.09208,0,1.02222],8216:[0,.69444,.12417,0,.30667],8217:[0,.69444,.12417,0,.30667],8220:[0,.69444,.1685,0,.51444],8221:[0,.69444,.06961,0,.51444],8463:[0,.68889,0,0,.54028]},"Main-Regular":{32:[0,0,0,0,.25],33:[0,.69444,0,0,.27778],34:[0,.69444,0,0,.5],35:[.19444,.69444,0,0,.83334],36:[.05556,.75,0,0,.5],37:[.05556,.75,0,0,.83334],38:[0,.69444,0,0,.77778],39:[0,.69444,0,0,.27778],40:[.25,.75,0,0,.38889],41:[.25,.75,0,0,.38889],42:[0,.75,0,0,.5],43:[.08333,.58333,0,0,.77778],44:[.19444,.10556,0,0,.27778],45:[0,.43056,0,0,.33333],46:[0,.10556,0,0,.27778],47:[.25,.75,0,0,.5],48:[0,.64444,0,0,.5],49:[0,.64444,0,0,.5],50:[0,.64444,0,0,.5],51:[0,.64444,0,0,.5],52:[0,.64444,0,0,.5],53:[0,.64444,0,0,.5],54:[0,.64444,0,0,.5],55:[0,.64444,0,0,.5],56:[0,.64444,0,0,.5],57:[0,.64444,0,0,.5],58:[0,.43056,0,0,.27778],59:[.19444,.43056,0,0,.27778],60:[.0391,.5391,0,0,.77778],61:[-.13313,.36687,0,0,.77778],62:[.0391,.5391,0,0,.77778],63:[0,.69444,0,0,.47222],64:[0,.69444,0,0,.77778],65:[0,.68333,0,0,.75],66:[0,.68333,0,0,.70834],67:[0,.68333,0,0,.72222],68:[0,.68333,0,0,.76389],69:[0,.68333,0,0,.68056],70:[0,.68333,0,0,.65278],71:[0,.68333,0,0,.78472],72:[0,.68333,0,0,.75],73:[0,.68333,0,0,.36111],74:[0,.68333,0,0,.51389],75:[0,.68333,0,0,.77778],76:[0,.68333,0,0,.625],77:[0,.68333,0,0,.91667],78:[0,.68333,0,0,.75],79:[0,.68333,0,0,.77778],80:[0,.68333,0,0,.68056],81:[.19444,.68333,0,0,.77778],82:[0,.68333,0,0,.73611],83:[0,.68333,0,0,.55556],84:[0,.68333,0,0,.72222],85:[0,.68333,0,0,.75],86:[0,.68333,.01389,0,.75],87:[0,.68333,.01389,0,1.02778],88:[0,.68333,0,0,.75],89:[0,.68333,.025,0,.75],90:[0,.68333,0,0,.61111],91:[.25,.75,0,0,.27778],92:[.25,.75,0,0,.5],93:[.25,.75,0,0,.27778],94:[0,.69444,0,0,.5],95:[.31,.12056,.02778,0,.5],97:[0,.43056,0,0,.5],98:[0,.69444,0,0,.55556],99:[0,.43056,0,0,.44445],100:[0,.69444,0,0,.55556],101:[0,.43056,0,0,.44445],102:[0,.69444,.07778,0,.30556],103:[.19444,.43056,.01389,0,.5],104:[0,.69444,0,0,.55556],105:[0,.66786,0,0,.27778],106:[.19444,.66786,0,0,.30556],107:[0,.69444,0,0,.52778],108:[0,.69444,0,0,.27778],109:[0,.43056,0,0,.83334],110:[0,.43056,0,0,.55556],111:[0,.43056,0,0,.5],112:[.19444,.43056,0,0,.55556],113:[.19444,.43056,0,0,.52778],114:[0,.43056,0,0,.39167],115:[0,.43056,0,0,.39445],116:[0,.61508,0,0,.38889],117:[0,.43056,0,0,.55556],118:[0,.43056,.01389,0,.52778],119:[0,.43056,.01389,0,.72222],120:[0,.43056,0,0,.52778],121:[.19444,.43056,.01389,0,.52778],122:[0,.43056,0,0,.44445],123:[.25,.75,0,0,.5],124:[.25,.75,0,0,.27778],125:[.25,.75,0,0,.5],126:[.35,.31786,0,0,.5],160:[0,0,0,0,.25],163:[0,.69444,0,0,.76909],167:[.19444,.69444,0,0,.44445],168:[0,.66786,0,0,.5],172:[0,.43056,0,0,.66667],176:[0,.69444,0,0,.75],177:[.08333,.58333,0,0,.77778],182:[.19444,.69444,0,0,.61111],184:[.17014,0,0,0,.44445],198:[0,.68333,0,0,.90278],215:[.08333,.58333,0,0,.77778],216:[.04861,.73194,0,0,.77778],223:[0,.69444,0,0,.5],230:[0,.43056,0,0,.72222],247:[.08333,.58333,0,0,.77778],248:[.09722,.52778,0,0,.5],305:[0,.43056,0,0,.27778],338:[0,.68333,0,0,1.01389],339:[0,.43056,0,0,.77778],567:[.19444,.43056,0,0,.30556],710:[0,.69444,0,0,.5],711:[0,.62847,0,0,.5],713:[0,.56778,0,0,.5],714:[0,.69444,0,0,.5],715:[0,.69444,0,0,.5],728:[0,.69444,0,0,.5],729:[0,.66786,0,0,.27778],730:[0,.69444,0,0,.75],732:[0,.66786,0,0,.5],733:[0,.69444,0,0,.5],915:[0,.68333,0,0,.625],916:[0,.68333,0,0,.83334],920:[0,.68333,0,0,.77778],923:[0,.68333,0,0,.69445],926:[0,.68333,0,0,.66667],928:[0,.68333,0,0,.75],931:[0,.68333,0,0,.72222],933:[0,.68333,0,0,.77778],934:[0,.68333,0,0,.72222],936:[0,.68333,0,0,.77778],937:[0,.68333,0,0,.72222],8211:[0,.43056,.02778,0,.5],8212:[0,.43056,.02778,0,1],8216:[0,.69444,0,0,.27778],8217:[0,.69444,0,0,.27778],8220:[0,.69444,0,0,.5],8221:[0,.69444,0,0,.5],8224:[.19444,.69444,0,0,.44445],8225:[.19444,.69444,0,0,.44445],8230:[0,.123,0,0,1.172],8242:[0,.55556,0,0,.275],8407:[0,.71444,.15382,0,.5],8463:[0,.68889,0,0,.54028],8465:[0,.69444,0,0,.72222],8467:[0,.69444,0,.11111,.41667],8472:[.19444,.43056,0,.11111,.63646],8476:[0,.69444,0,0,.72222],8501:[0,.69444,0,0,.61111],8592:[-.13313,.36687,0,0,1],8593:[.19444,.69444,0,0,.5],8594:[-.13313,.36687,0,0,1],8595:[.19444,.69444,0,0,.5],8596:[-.13313,.36687,0,0,1],8597:[.25,.75,0,0,.5],8598:[.19444,.69444,0,0,1],8599:[.19444,.69444,0,0,1],8600:[.19444,.69444,0,0,1],8601:[.19444,.69444,0,0,1],8614:[.011,.511,0,0,1],8617:[.011,.511,0,0,1.126],8618:[.011,.511,0,0,1.126],8636:[-.13313,.36687,0,0,1],8637:[-.13313,.36687,0,0,1],8640:[-.13313,.36687,0,0,1],8641:[-.13313,.36687,0,0,1],8652:[.011,.671,0,0,1],8656:[-.13313,.36687,0,0,1],8657:[.19444,.69444,0,0,.61111],8658:[-.13313,.36687,0,0,1],8659:[.19444,.69444,0,0,.61111],8660:[-.13313,.36687,0,0,1],8661:[.25,.75,0,0,.61111],8704:[0,.69444,0,0,.55556],8706:[0,.69444,.05556,.08334,.5309],8707:[0,.69444,0,0,.55556],8709:[.05556,.75,0,0,.5],8711:[0,.68333,0,0,.83334],8712:[.0391,.5391,0,0,.66667],8715:[.0391,.5391,0,0,.66667],8722:[.08333,.58333,0,0,.77778],8723:[.08333,.58333,0,0,.77778],8725:[.25,.75,0,0,.5],8726:[.25,.75,0,0,.5],8727:[-.03472,.46528,0,0,.5],8728:[-.05555,.44445,0,0,.5],8729:[-.05555,.44445,0,0,.5],8730:[.2,.8,0,0,.83334],8733:[0,.43056,0,0,.77778],8734:[0,.43056,0,0,1],8736:[0,.69224,0,0,.72222],8739:[.25,.75,0,0,.27778],8741:[.25,.75,0,0,.5],8743:[0,.55556,0,0,.66667],8744:[0,.55556,0,0,.66667],8745:[0,.55556,0,0,.66667],8746:[0,.55556,0,0,.66667],8747:[.19444,.69444,.11111,0,.41667],8764:[-.13313,.36687,0,0,.77778],8768:[.19444,.69444,0,0,.27778],8771:[-.03625,.46375,0,0,.77778],8773:[-.022,.589,0,0,.778],8776:[-.01688,.48312,0,0,.77778],8781:[-.03625,.46375,0,0,.77778],8784:[-.133,.673,0,0,.778],8801:[-.03625,.46375,0,0,.77778],8804:[.13597,.63597,0,0,.77778],8805:[.13597,.63597,0,0,.77778],8810:[.0391,.5391,0,0,1],8811:[.0391,.5391,0,0,1],8826:[.0391,.5391,0,0,.77778],8827:[.0391,.5391,0,0,.77778],8834:[.0391,.5391,0,0,.77778],8835:[.0391,.5391,0,0,.77778],8838:[.13597,.63597,0,0,.77778],8839:[.13597,.63597,0,0,.77778],8846:[0,.55556,0,0,.66667],8849:[.13597,.63597,0,0,.77778],8850:[.13597,.63597,0,0,.77778],8851:[0,.55556,0,0,.66667],8852:[0,.55556,0,0,.66667],8853:[.08333,.58333,0,0,.77778],8854:[.08333,.58333,0,0,.77778],8855:[.08333,.58333,0,0,.77778],8856:[.08333,.58333,0,0,.77778],8857:[.08333,.58333,0,0,.77778],8866:[0,.69444,0,0,.61111],8867:[0,.69444,0,0,.61111],8868:[0,.69444,0,0,.77778],8869:[0,.69444,0,0,.77778],8872:[.249,.75,0,0,.867],8900:[-.05555,.44445,0,0,.5],8901:[-.05555,.44445,0,0,.27778],8902:[-.03472,.46528,0,0,.5],8904:[.005,.505,0,0,.9],8942:[.03,.903,0,0,.278],8943:[-.19,.313,0,0,1.172],8945:[-.1,.823,0,0,1.282],8968:[.25,.75,0,0,.44445],8969:[.25,.75,0,0,.44445],8970:[.25,.75,0,0,.44445],8971:[.25,.75,0,0,.44445],8994:[-.14236,.35764,0,0,1],8995:[-.14236,.35764,0,0,1],9136:[.244,.744,0,0,.412],9137:[.244,.745,0,0,.412],9651:[.19444,.69444,0,0,.88889],9657:[-.03472,.46528,0,0,.5],9661:[.19444,.69444,0,0,.88889],9667:[-.03472,.46528,0,0,.5],9711:[.19444,.69444,0,0,1],9824:[.12963,.69444,0,0,.77778],9825:[.12963,.69444,0,0,.77778],9826:[.12963,.69444,0,0,.77778],9827:[.12963,.69444,0,0,.77778],9837:[0,.75,0,0,.38889],9838:[.19444,.69444,0,0,.38889],9839:[.19444,.69444,0,0,.38889],10216:[.25,.75,0,0,.38889],10217:[.25,.75,0,0,.38889],10222:[.244,.744,0,0,.412],10223:[.244,.745,0,0,.412],10229:[.011,.511,0,0,1.609],10230:[.011,.511,0,0,1.638],10231:[.011,.511,0,0,1.859],10232:[.024,.525,0,0,1.609],10233:[.024,.525,0,0,1.638],10234:[.024,.525,0,0,1.858],10236:[.011,.511,0,0,1.638],10815:[0,.68333,0,0,.75],10927:[.13597,.63597,0,0,.77778],10928:[.13597,.63597,0,0,.77778],57376:[.19444,.69444,0,0,0]},"Math-BoldItalic":{32:[0,0,0,0,.25],48:[0,.44444,0,0,.575],49:[0,.44444,0,0,.575],50:[0,.44444,0,0,.575],51:[.19444,.44444,0,0,.575],52:[.19444,.44444,0,0,.575],53:[.19444,.44444,0,0,.575],54:[0,.64444,0,0,.575],55:[.19444,.44444,0,0,.575],56:[0,.64444,0,0,.575],57:[.19444,.44444,0,0,.575],65:[0,.68611,0,0,.86944],66:[0,.68611,.04835,0,.8664],67:[0,.68611,.06979,0,.81694],68:[0,.68611,.03194,0,.93812],69:[0,.68611,.05451,0,.81007],70:[0,.68611,.15972,0,.68889],71:[0,.68611,0,0,.88673],72:[0,.68611,.08229,0,.98229],73:[0,.68611,.07778,0,.51111],74:[0,.68611,.10069,0,.63125],75:[0,.68611,.06979,0,.97118],76:[0,.68611,0,0,.75555],77:[0,.68611,.11424,0,1.14201],78:[0,.68611,.11424,0,.95034],79:[0,.68611,.03194,0,.83666],80:[0,.68611,.15972,0,.72309],81:[.19444,.68611,0,0,.86861],82:[0,.68611,.00421,0,.87235],83:[0,.68611,.05382,0,.69271],84:[0,.68611,.15972,0,.63663],85:[0,.68611,.11424,0,.80027],86:[0,.68611,.25555,0,.67778],87:[0,.68611,.15972,0,1.09305],88:[0,.68611,.07778,0,.94722],89:[0,.68611,.25555,0,.67458],90:[0,.68611,.06979,0,.77257],97:[0,.44444,0,0,.63287],98:[0,.69444,0,0,.52083],99:[0,.44444,0,0,.51342],100:[0,.69444,0,0,.60972],101:[0,.44444,0,0,.55361],102:[.19444,.69444,.11042,0,.56806],103:[.19444,.44444,.03704,0,.5449],104:[0,.69444,0,0,.66759],105:[0,.69326,0,0,.4048],106:[.19444,.69326,.0622,0,.47083],107:[0,.69444,.01852,0,.6037],108:[0,.69444,.0088,0,.34815],109:[0,.44444,0,0,1.0324],110:[0,.44444,0,0,.71296],111:[0,.44444,0,0,.58472],112:[.19444,.44444,0,0,.60092],113:[.19444,.44444,.03704,0,.54213],114:[0,.44444,.03194,0,.5287],115:[0,.44444,0,0,.53125],116:[0,.63492,0,0,.41528],117:[0,.44444,0,0,.68102],118:[0,.44444,.03704,0,.56666],119:[0,.44444,.02778,0,.83148],120:[0,.44444,0,0,.65903],121:[.19444,.44444,.03704,0,.59028],122:[0,.44444,.04213,0,.55509],160:[0,0,0,0,.25],915:[0,.68611,.15972,0,.65694],916:[0,.68611,0,0,.95833],920:[0,.68611,.03194,0,.86722],923:[0,.68611,0,0,.80555],926:[0,.68611,.07458,0,.84125],928:[0,.68611,.08229,0,.98229],931:[0,.68611,.05451,0,.88507],933:[0,.68611,.15972,0,.67083],934:[0,.68611,0,0,.76666],936:[0,.68611,.11653,0,.71402],937:[0,.68611,.04835,0,.8789],945:[0,.44444,0,0,.76064],946:[.19444,.69444,.03403,0,.65972],947:[.19444,.44444,.06389,0,.59003],948:[0,.69444,.03819,0,.52222],949:[0,.44444,0,0,.52882],950:[.19444,.69444,.06215,0,.50833],951:[.19444,.44444,.03704,0,.6],952:[0,.69444,.03194,0,.5618],953:[0,.44444,0,0,.41204],954:[0,.44444,0,0,.66759],955:[0,.69444,0,0,.67083],956:[.19444,.44444,0,0,.70787],957:[0,.44444,.06898,0,.57685],958:[.19444,.69444,.03021,0,.50833],959:[0,.44444,0,0,.58472],960:[0,.44444,.03704,0,.68241],961:[.19444,.44444,0,0,.6118],962:[.09722,.44444,.07917,0,.42361],963:[0,.44444,.03704,0,.68588],964:[0,.44444,.13472,0,.52083],965:[0,.44444,.03704,0,.63055],966:[.19444,.44444,0,0,.74722],967:[.19444,.44444,0,0,.71805],968:[.19444,.69444,.03704,0,.75833],969:[0,.44444,.03704,0,.71782],977:[0,.69444,0,0,.69155],981:[.19444,.69444,0,0,.7125],982:[0,.44444,.03194,0,.975],1009:[.19444,.44444,0,0,.6118],1013:[0,.44444,0,0,.48333],57649:[0,.44444,0,0,.39352],57911:[.19444,.44444,0,0,.43889]},"Math-Italic":{32:[0,0,0,0,.25],48:[0,.43056,0,0,.5],49:[0,.43056,0,0,.5],50:[0,.43056,0,0,.5],51:[.19444,.43056,0,0,.5],52:[.19444,.43056,0,0,.5],53:[.19444,.43056,0,0,.5],54:[0,.64444,0,0,.5],55:[.19444,.43056,0,0,.5],56:[0,.64444,0,0,.5],57:[.19444,.43056,0,0,.5],65:[0,.68333,0,.13889,.75],66:[0,.68333,.05017,.08334,.75851],67:[0,.68333,.07153,.08334,.71472],68:[0,.68333,.02778,.05556,.82792],69:[0,.68333,.05764,.08334,.7382],70:[0,.68333,.13889,.08334,.64306],71:[0,.68333,0,.08334,.78625],72:[0,.68333,.08125,.05556,.83125],73:[0,.68333,.07847,.11111,.43958],74:[0,.68333,.09618,.16667,.55451],75:[0,.68333,.07153,.05556,.84931],76:[0,.68333,0,.02778,.68056],77:[0,.68333,.10903,.08334,.97014],78:[0,.68333,.10903,.08334,.80347],79:[0,.68333,.02778,.08334,.76278],80:[0,.68333,.13889,.08334,.64201],81:[.19444,.68333,0,.08334,.79056],82:[0,.68333,.00773,.08334,.75929],83:[0,.68333,.05764,.08334,.6132],84:[0,.68333,.13889,.08334,.58438],85:[0,.68333,.10903,.02778,.68278],86:[0,.68333,.22222,0,.58333],87:[0,.68333,.13889,0,.94445],88:[0,.68333,.07847,.08334,.82847],89:[0,.68333,.22222,0,.58056],90:[0,.68333,.07153,.08334,.68264],97:[0,.43056,0,0,.52859],98:[0,.69444,0,0,.42917],99:[0,.43056,0,.05556,.43276],100:[0,.69444,0,.16667,.52049],101:[0,.43056,0,.05556,.46563],102:[.19444,.69444,.10764,.16667,.48959],103:[.19444,.43056,.03588,.02778,.47697],104:[0,.69444,0,0,.57616],105:[0,.65952,0,0,.34451],106:[.19444,.65952,.05724,0,.41181],107:[0,.69444,.03148,0,.5206],108:[0,.69444,.01968,.08334,.29838],109:[0,.43056,0,0,.87801],110:[0,.43056,0,0,.60023],111:[0,.43056,0,.05556,.48472],112:[.19444,.43056,0,.08334,.50313],113:[.19444,.43056,.03588,.08334,.44641],114:[0,.43056,.02778,.05556,.45116],115:[0,.43056,0,.05556,.46875],116:[0,.61508,0,.08334,.36111],117:[0,.43056,0,.02778,.57246],118:[0,.43056,.03588,.02778,.48472],119:[0,.43056,.02691,.08334,.71592],120:[0,.43056,0,.02778,.57153],121:[.19444,.43056,.03588,.05556,.49028],122:[0,.43056,.04398,.05556,.46505],160:[0,0,0,0,.25],915:[0,.68333,.13889,.08334,.61528],916:[0,.68333,0,.16667,.83334],920:[0,.68333,.02778,.08334,.76278],923:[0,.68333,0,.16667,.69445],926:[0,.68333,.07569,.08334,.74236],928:[0,.68333,.08125,.05556,.83125],931:[0,.68333,.05764,.08334,.77986],933:[0,.68333,.13889,.05556,.58333],934:[0,.68333,0,.08334,.66667],936:[0,.68333,.11,.05556,.61222],937:[0,.68333,.05017,.08334,.7724],945:[0,.43056,.0037,.02778,.6397],946:[.19444,.69444,.05278,.08334,.56563],947:[.19444,.43056,.05556,0,.51773],948:[0,.69444,.03785,.05556,.44444],949:[0,.43056,0,.08334,.46632],950:[.19444,.69444,.07378,.08334,.4375],951:[.19444,.43056,.03588,.05556,.49653],952:[0,.69444,.02778,.08334,.46944],953:[0,.43056,0,.05556,.35394],954:[0,.43056,0,0,.57616],955:[0,.69444,0,0,.58334],956:[.19444,.43056,0,.02778,.60255],957:[0,.43056,.06366,.02778,.49398],958:[.19444,.69444,.04601,.11111,.4375],959:[0,.43056,0,.05556,.48472],960:[0,.43056,.03588,0,.57003],961:[.19444,.43056,0,.08334,.51702],962:[.09722,.43056,.07986,.08334,.36285],963:[0,.43056,.03588,0,.57141],964:[0,.43056,.1132,.02778,.43715],965:[0,.43056,.03588,.02778,.54028],966:[.19444,.43056,0,.08334,.65417],967:[.19444,.43056,0,.05556,.62569],968:[.19444,.69444,.03588,.11111,.65139],969:[0,.43056,.03588,0,.62245],977:[0,.69444,0,.08334,.59144],981:[.19444,.69444,0,.08334,.59583],982:[0,.43056,.02778,0,.82813],1009:[.19444,.43056,0,.08334,.51702],1013:[0,.43056,0,.05556,.4059],57649:[0,.43056,0,.02778,.32246],57911:[.19444,.43056,0,.08334,.38403]},"SansSerif-Bold":{32:[0,0,0,0,.25],33:[0,.69444,0,0,.36667],34:[0,.69444,0,0,.55834],35:[.19444,.69444,0,0,.91667],36:[.05556,.75,0,0,.55],37:[.05556,.75,0,0,1.02912],38:[0,.69444,0,0,.83056],39:[0,.69444,0,0,.30556],40:[.25,.75,0,0,.42778],41:[.25,.75,0,0,.42778],42:[0,.75,0,0,.55],43:[.11667,.61667,0,0,.85556],44:[.10556,.13056,0,0,.30556],45:[0,.45833,0,0,.36667],46:[0,.13056,0,0,.30556],47:[.25,.75,0,0,.55],48:[0,.69444,0,0,.55],49:[0,.69444,0,0,.55],50:[0,.69444,0,0,.55],51:[0,.69444,0,0,.55],52:[0,.69444,0,0,.55],53:[0,.69444,0,0,.55],54:[0,.69444,0,0,.55],55:[0,.69444,0,0,.55],56:[0,.69444,0,0,.55],57:[0,.69444,0,0,.55],58:[0,.45833,0,0,.30556],59:[.10556,.45833,0,0,.30556],61:[-.09375,.40625,0,0,.85556],63:[0,.69444,0,0,.51945],64:[0,.69444,0,0,.73334],65:[0,.69444,0,0,.73334],66:[0,.69444,0,0,.73334],67:[0,.69444,0,0,.70278],68:[0,.69444,0,0,.79445],69:[0,.69444,0,0,.64167],70:[0,.69444,0,0,.61111],71:[0,.69444,0,0,.73334],72:[0,.69444,0,0,.79445],73:[0,.69444,0,0,.33056],74:[0,.69444,0,0,.51945],75:[0,.69444,0,0,.76389],76:[0,.69444,0,0,.58056],77:[0,.69444,0,0,.97778],78:[0,.69444,0,0,.79445],79:[0,.69444,0,0,.79445],80:[0,.69444,0,0,.70278],81:[.10556,.69444,0,0,.79445],82:[0,.69444,0,0,.70278],83:[0,.69444,0,0,.61111],84:[0,.69444,0,0,.73334],85:[0,.69444,0,0,.76389],86:[0,.69444,.01528,0,.73334],87:[0,.69444,.01528,0,1.03889],88:[0,.69444,0,0,.73334],89:[0,.69444,.0275,0,.73334],90:[0,.69444,0,0,.67223],91:[.25,.75,0,0,.34306],93:[.25,.75,0,0,.34306],94:[0,.69444,0,0,.55],95:[.35,.10833,.03056,0,.55],97:[0,.45833,0,0,.525],98:[0,.69444,0,0,.56111],99:[0,.45833,0,0,.48889],100:[0,.69444,0,0,.56111],101:[0,.45833,0,0,.51111],102:[0,.69444,.07639,0,.33611],103:[.19444,.45833,.01528,0,.55],104:[0,.69444,0,0,.56111],105:[0,.69444,0,0,.25556],106:[.19444,.69444,0,0,.28611],107:[0,.69444,0,0,.53056],108:[0,.69444,0,0,.25556],109:[0,.45833,0,0,.86667],110:[0,.45833,0,0,.56111],111:[0,.45833,0,0,.55],112:[.19444,.45833,0,0,.56111],113:[.19444,.45833,0,0,.56111],114:[0,.45833,.01528,0,.37222],115:[0,.45833,0,0,.42167],116:[0,.58929,0,0,.40417],117:[0,.45833,0,0,.56111],118:[0,.45833,.01528,0,.5],119:[0,.45833,.01528,0,.74445],120:[0,.45833,0,0,.5],121:[.19444,.45833,.01528,0,.5],122:[0,.45833,0,0,.47639],126:[.35,.34444,0,0,.55],160:[0,0,0,0,.25],168:[0,.69444,0,0,.55],176:[0,.69444,0,0,.73334],180:[0,.69444,0,0,.55],184:[.17014,0,0,0,.48889],305:[0,.45833,0,0,.25556],567:[.19444,.45833,0,0,.28611],710:[0,.69444,0,0,.55],711:[0,.63542,0,0,.55],713:[0,.63778,0,0,.55],728:[0,.69444,0,0,.55],729:[0,.69444,0,0,.30556],730:[0,.69444,0,0,.73334],732:[0,.69444,0,0,.55],733:[0,.69444,0,0,.55],915:[0,.69444,0,0,.58056],916:[0,.69444,0,0,.91667],920:[0,.69444,0,0,.85556],923:[0,.69444,0,0,.67223],926:[0,.69444,0,0,.73334],928:[0,.69444,0,0,.79445],931:[0,.69444,0,0,.79445],933:[0,.69444,0,0,.85556],934:[0,.69444,0,0,.79445],936:[0,.69444,0,0,.85556],937:[0,.69444,0,0,.79445],8211:[0,.45833,.03056,0,.55],8212:[0,.45833,.03056,0,1.10001],8216:[0,.69444,0,0,.30556],8217:[0,.69444,0,0,.30556],8220:[0,.69444,0,0,.55834],8221:[0,.69444,0,0,.55834]},"SansSerif-Italic":{32:[0,0,0,0,.25],33:[0,.69444,.05733,0,.31945],34:[0,.69444,.00316,0,.5],35:[.19444,.69444,.05087,0,.83334],36:[.05556,.75,.11156,0,.5],37:[.05556,.75,.03126,0,.83334],38:[0,.69444,.03058,0,.75834],39:[0,.69444,.07816,0,.27778],40:[.25,.75,.13164,0,.38889],41:[.25,.75,.02536,0,.38889],42:[0,.75,.11775,0,.5],43:[.08333,.58333,.02536,0,.77778],44:[.125,.08333,0,0,.27778],45:[0,.44444,.01946,0,.33333],46:[0,.08333,0,0,.27778],47:[.25,.75,.13164,0,.5],48:[0,.65556,.11156,0,.5],49:[0,.65556,.11156,0,.5],50:[0,.65556,.11156,0,.5],51:[0,.65556,.11156,0,.5],52:[0,.65556,.11156,0,.5],53:[0,.65556,.11156,0,.5],54:[0,.65556,.11156,0,.5],55:[0,.65556,.11156,0,.5],56:[0,.65556,.11156,0,.5],57:[0,.65556,.11156,0,.5],58:[0,.44444,.02502,0,.27778],59:[.125,.44444,.02502,0,.27778],61:[-.13,.37,.05087,0,.77778],63:[0,.69444,.11809,0,.47222],64:[0,.69444,.07555,0,.66667],65:[0,.69444,0,0,.66667],66:[0,.69444,.08293,0,.66667],67:[0,.69444,.11983,0,.63889],68:[0,.69444,.07555,0,.72223],69:[0,.69444,.11983,0,.59722],70:[0,.69444,.13372,0,.56945],71:[0,.69444,.11983,0,.66667],72:[0,.69444,.08094,0,.70834],73:[0,.69444,.13372,0,.27778],74:[0,.69444,.08094,0,.47222],75:[0,.69444,.11983,0,.69445],76:[0,.69444,0,0,.54167],77:[0,.69444,.08094,0,.875],78:[0,.69444,.08094,0,.70834],79:[0,.69444,.07555,0,.73611],80:[0,.69444,.08293,0,.63889],81:[.125,.69444,.07555,0,.73611],82:[0,.69444,.08293,0,.64584],83:[0,.69444,.09205,0,.55556],84:[0,.69444,.13372,0,.68056],85:[0,.69444,.08094,0,.6875],86:[0,.69444,.1615,0,.66667],87:[0,.69444,.1615,0,.94445],88:[0,.69444,.13372,0,.66667],89:[0,.69444,.17261,0,.66667],90:[0,.69444,.11983,0,.61111],91:[.25,.75,.15942,0,.28889],93:[.25,.75,.08719,0,.28889],94:[0,.69444,.0799,0,.5],95:[.35,.09444,.08616,0,.5],97:[0,.44444,.00981,0,.48056],98:[0,.69444,.03057,0,.51667],99:[0,.44444,.08336,0,.44445],100:[0,.69444,.09483,0,.51667],101:[0,.44444,.06778,0,.44445],102:[0,.69444,.21705,0,.30556],103:[.19444,.44444,.10836,0,.5],104:[0,.69444,.01778,0,.51667],105:[0,.67937,.09718,0,.23889],106:[.19444,.67937,.09162,0,.26667],107:[0,.69444,.08336,0,.48889],108:[0,.69444,.09483,0,.23889],109:[0,.44444,.01778,0,.79445],110:[0,.44444,.01778,0,.51667],111:[0,.44444,.06613,0,.5],112:[.19444,.44444,.0389,0,.51667],113:[.19444,.44444,.04169,0,.51667],114:[0,.44444,.10836,0,.34167],115:[0,.44444,.0778,0,.38333],116:[0,.57143,.07225,0,.36111],117:[0,.44444,.04169,0,.51667],118:[0,.44444,.10836,0,.46111],119:[0,.44444,.10836,0,.68334],120:[0,.44444,.09169,0,.46111],121:[.19444,.44444,.10836,0,.46111],122:[0,.44444,.08752,0,.43472],126:[.35,.32659,.08826,0,.5],160:[0,0,0,0,.25],168:[0,.67937,.06385,0,.5],176:[0,.69444,0,0,.73752],184:[.17014,0,0,0,.44445],305:[0,.44444,.04169,0,.23889],567:[.19444,.44444,.04169,0,.26667],710:[0,.69444,.0799,0,.5],711:[0,.63194,.08432,0,.5],713:[0,.60889,.08776,0,.5],714:[0,.69444,.09205,0,.5],715:[0,.69444,0,0,.5],728:[0,.69444,.09483,0,.5],729:[0,.67937,.07774,0,.27778],730:[0,.69444,0,0,.73752],732:[0,.67659,.08826,0,.5],733:[0,.69444,.09205,0,.5],915:[0,.69444,.13372,0,.54167],916:[0,.69444,0,0,.83334],920:[0,.69444,.07555,0,.77778],923:[0,.69444,0,0,.61111],926:[0,.69444,.12816,0,.66667],928:[0,.69444,.08094,0,.70834],931:[0,.69444,.11983,0,.72222],933:[0,.69444,.09031,0,.77778],934:[0,.69444,.04603,0,.72222],936:[0,.69444,.09031,0,.77778],937:[0,.69444,.08293,0,.72222],8211:[0,.44444,.08616,0,.5],8212:[0,.44444,.08616,0,1],8216:[0,.69444,.07816,0,.27778],8217:[0,.69444,.07816,0,.27778],8220:[0,.69444,.14205,0,.5],8221:[0,.69444,.00316,0,.5]},"SansSerif-Regular":{32:[0,0,0,0,.25],33:[0,.69444,0,0,.31945],34:[0,.69444,0,0,.5],35:[.19444,.69444,0,0,.83334],36:[.05556,.75,0,0,.5],37:[.05556,.75,0,0,.83334],38:[0,.69444,0,0,.75834],39:[0,.69444,0,0,.27778],40:[.25,.75,0,0,.38889],41:[.25,.75,0,0,.38889],42:[0,.75,0,0,.5],43:[.08333,.58333,0,0,.77778],44:[.125,.08333,0,0,.27778],45:[0,.44444,0,0,.33333],46:[0,.08333,0,0,.27778],47:[.25,.75,0,0,.5],48:[0,.65556,0,0,.5],49:[0,.65556,0,0,.5],50:[0,.65556,0,0,.5],51:[0,.65556,0,0,.5],52:[0,.65556,0,0,.5],53:[0,.65556,0,0,.5],54:[0,.65556,0,0,.5],55:[0,.65556,0,0,.5],56:[0,.65556,0,0,.5],57:[0,.65556,0,0,.5],58:[0,.44444,0,0,.27778],59:[.125,.44444,0,0,.27778],61:[-.13,.37,0,0,.77778],63:[0,.69444,0,0,.47222],64:[0,.69444,0,0,.66667],65:[0,.69444,0,0,.66667],66:[0,.69444,0,0,.66667],67:[0,.69444,0,0,.63889],68:[0,.69444,0,0,.72223],69:[0,.69444,0,0,.59722],70:[0,.69444,0,0,.56945],71:[0,.69444,0,0,.66667],72:[0,.69444,0,0,.70834],73:[0,.69444,0,0,.27778],74:[0,.69444,0,0,.47222],75:[0,.69444,0,0,.69445],76:[0,.69444,0,0,.54167],77:[0,.69444,0,0,.875],78:[0,.69444,0,0,.70834],79:[0,.69444,0,0,.73611],80:[0,.69444,0,0,.63889],81:[.125,.69444,0,0,.73611],82:[0,.69444,0,0,.64584],83:[0,.69444,0,0,.55556],84:[0,.69444,0,0,.68056],85:[0,.69444,0,0,.6875],86:[0,.69444,.01389,0,.66667],87:[0,.69444,.01389,0,.94445],88:[0,.69444,0,0,.66667],89:[0,.69444,.025,0,.66667],90:[0,.69444,0,0,.61111],91:[.25,.75,0,0,.28889],93:[.25,.75,0,0,.28889],94:[0,.69444,0,0,.5],95:[.35,.09444,.02778,0,.5],97:[0,.44444,0,0,.48056],98:[0,.69444,0,0,.51667],99:[0,.44444,0,0,.44445],100:[0,.69444,0,0,.51667],101:[0,.44444,0,0,.44445],102:[0,.69444,.06944,0,.30556],103:[.19444,.44444,.01389,0,.5],104:[0,.69444,0,0,.51667],105:[0,.67937,0,0,.23889],106:[.19444,.67937,0,0,.26667],107:[0,.69444,0,0,.48889],108:[0,.69444,0,0,.23889],109:[0,.44444,0,0,.79445],110:[0,.44444,0,0,.51667],111:[0,.44444,0,0,.5],112:[.19444,.44444,0,0,.51667],113:[.19444,.44444,0,0,.51667],114:[0,.44444,.01389,0,.34167],115:[0,.44444,0,0,.38333],116:[0,.57143,0,0,.36111],117:[0,.44444,0,0,.51667],118:[0,.44444,.01389,0,.46111],119:[0,.44444,.01389,0,.68334],120:[0,.44444,0,0,.46111],121:[.19444,.44444,.01389,0,.46111],122:[0,.44444,0,0,.43472],126:[.35,.32659,0,0,.5],160:[0,0,0,0,.25],168:[0,.67937,0,0,.5],176:[0,.69444,0,0,.66667],184:[.17014,0,0,0,.44445],305:[0,.44444,0,0,.23889],567:[.19444,.44444,0,0,.26667],710:[0,.69444,0,0,.5],711:[0,.63194,0,0,.5],713:[0,.60889,0,0,.5],714:[0,.69444,0,0,.5],715:[0,.69444,0,0,.5],728:[0,.69444,0,0,.5],729:[0,.67937,0,0,.27778],730:[0,.69444,0,0,.66667],732:[0,.67659,0,0,.5],733:[0,.69444,0,0,.5],915:[0,.69444,0,0,.54167],916:[0,.69444,0,0,.83334],920:[0,.69444,0,0,.77778],923:[0,.69444,0,0,.61111],926:[0,.69444,0,0,.66667],928:[0,.69444,0,0,.70834],931:[0,.69444,0,0,.72222],933:[0,.69444,0,0,.77778],934:[0,.69444,0,0,.72222],936:[0,.69444,0,0,.77778],937:[0,.69444,0,0,.72222],8211:[0,.44444,.02778,0,.5],8212:[0,.44444,.02778,0,1],8216:[0,.69444,0,0,.27778],8217:[0,.69444,0,0,.27778],8220:[0,.69444,0,0,.5],8221:[0,.69444,0,0,.5]},"Script-Regular":{32:[0,0,0,0,.25],65:[0,.7,.22925,0,.80253],66:[0,.7,.04087,0,.90757],67:[0,.7,.1689,0,.66619],68:[0,.7,.09371,0,.77443],69:[0,.7,.18583,0,.56162],70:[0,.7,.13634,0,.89544],71:[0,.7,.17322,0,.60961],72:[0,.7,.29694,0,.96919],73:[0,.7,.19189,0,.80907],74:[.27778,.7,.19189,0,1.05159],75:[0,.7,.31259,0,.91364],76:[0,.7,.19189,0,.87373],77:[0,.7,.15981,0,1.08031],78:[0,.7,.3525,0,.9015],79:[0,.7,.08078,0,.73787],80:[0,.7,.08078,0,1.01262],81:[0,.7,.03305,0,.88282],82:[0,.7,.06259,0,.85],83:[0,.7,.19189,0,.86767],84:[0,.7,.29087,0,.74697],85:[0,.7,.25815,0,.79996],86:[0,.7,.27523,0,.62204],87:[0,.7,.27523,0,.80532],88:[0,.7,.26006,0,.94445],89:[0,.7,.2939,0,.70961],90:[0,.7,.24037,0,.8212],160:[0,0,0,0,.25]},"Size1-Regular":{32:[0,0,0,0,.25],40:[.35001,.85,0,0,.45834],41:[.35001,.85,0,0,.45834],47:[.35001,.85,0,0,.57778],91:[.35001,.85,0,0,.41667],92:[.35001,.85,0,0,.57778],93:[.35001,.85,0,0,.41667],123:[.35001,.85,0,0,.58334],125:[.35001,.85,0,0,.58334],160:[0,0,0,0,.25],710:[0,.72222,0,0,.55556],732:[0,.72222,0,0,.55556],770:[0,.72222,0,0,.55556],771:[0,.72222,0,0,.55556],8214:[-99e-5,.601,0,0,.77778],8593:[1e-5,.6,0,0,.66667],8595:[1e-5,.6,0,0,.66667],8657:[1e-5,.6,0,0,.77778],8659:[1e-5,.6,0,0,.77778],8719:[.25001,.75,0,0,.94445],8720:[.25001,.75,0,0,.94445],8721:[.25001,.75,0,0,1.05556],8730:[.35001,.85,0,0,1],8739:[-.00599,.606,0,0,.33333],8741:[-.00599,.606,0,0,.55556],8747:[.30612,.805,.19445,0,.47222],8748:[.306,.805,.19445,0,.47222],8749:[.306,.805,.19445,0,.47222],8750:[.30612,.805,.19445,0,.47222],8896:[.25001,.75,0,0,.83334],8897:[.25001,.75,0,0,.83334],8898:[.25001,.75,0,0,.83334],8899:[.25001,.75,0,0,.83334],8968:[.35001,.85,0,0,.47222],8969:[.35001,.85,0,0,.47222],8970:[.35001,.85,0,0,.47222],8971:[.35001,.85,0,0,.47222],9168:[-99e-5,.601,0,0,.66667],10216:[.35001,.85,0,0,.47222],10217:[.35001,.85,0,0,.47222],10752:[.25001,.75,0,0,1.11111],10753:[.25001,.75,0,0,1.11111],10754:[.25001,.75,0,0,1.11111],10756:[.25001,.75,0,0,.83334],10758:[.25001,.75,0,0,.83334]},"Size2-Regular":{32:[0,0,0,0,.25],40:[.65002,1.15,0,0,.59722],41:[.65002,1.15,0,0,.59722],47:[.65002,1.15,0,0,.81111],91:[.65002,1.15,0,0,.47222],92:[.65002,1.15,0,0,.81111],93:[.65002,1.15,0,0,.47222],123:[.65002,1.15,0,0,.66667],125:[.65002,1.15,0,0,.66667],160:[0,0,0,0,.25],710:[0,.75,0,0,1],732:[0,.75,0,0,1],770:[0,.75,0,0,1],771:[0,.75,0,0,1],8719:[.55001,1.05,0,0,1.27778],8720:[.55001,1.05,0,0,1.27778],8721:[.55001,1.05,0,0,1.44445],8730:[.65002,1.15,0,0,1],8747:[.86225,1.36,.44445,0,.55556],8748:[.862,1.36,.44445,0,.55556],8749:[.862,1.36,.44445,0,.55556],8750:[.86225,1.36,.44445,0,.55556],8896:[.55001,1.05,0,0,1.11111],8897:[.55001,1.05,0,0,1.11111],8898:[.55001,1.05,0,0,1.11111],8899:[.55001,1.05,0,0,1.11111],8968:[.65002,1.15,0,0,.52778],8969:[.65002,1.15,0,0,.52778],8970:[.65002,1.15,0,0,.52778],8971:[.65002,1.15,0,0,.52778],10216:[.65002,1.15,0,0,.61111],10217:[.65002,1.15,0,0,.61111],10752:[.55001,1.05,0,0,1.51112],10753:[.55001,1.05,0,0,1.51112],10754:[.55001,1.05,0,0,1.51112],10756:[.55001,1.05,0,0,1.11111],10758:[.55001,1.05,0,0,1.11111]},"Size3-Regular":{32:[0,0,0,0,.25],40:[.95003,1.45,0,0,.73611],41:[.95003,1.45,0,0,.73611],47:[.95003,1.45,0,0,1.04445],91:[.95003,1.45,0,0,.52778],92:[.95003,1.45,0,0,1.04445],93:[.95003,1.45,0,0,.52778],123:[.95003,1.45,0,0,.75],125:[.95003,1.45,0,0,.75],160:[0,0,0,0,.25],710:[0,.75,0,0,1.44445],732:[0,.75,0,0,1.44445],770:[0,.75,0,0,1.44445],771:[0,.75,0,0,1.44445],8730:[.95003,1.45,0,0,1],8968:[.95003,1.45,0,0,.58334],8969:[.95003,1.45,0,0,.58334],8970:[.95003,1.45,0,0,.58334],8971:[.95003,1.45,0,0,.58334],10216:[.95003,1.45,0,0,.75],10217:[.95003,1.45,0,0,.75]},"Size4-Regular":{32:[0,0,0,0,.25],40:[1.25003,1.75,0,0,.79167],41:[1.25003,1.75,0,0,.79167],47:[1.25003,1.75,0,0,1.27778],91:[1.25003,1.75,0,0,.58334],92:[1.25003,1.75,0,0,1.27778],93:[1.25003,1.75,0,0,.58334],123:[1.25003,1.75,0,0,.80556],125:[1.25003,1.75,0,0,.80556],160:[0,0,0,0,.25],710:[0,.825,0,0,1.8889],732:[0,.825,0,0,1.8889],770:[0,.825,0,0,1.8889],771:[0,.825,0,0,1.8889],8730:[1.25003,1.75,0,0,1],8968:[1.25003,1.75,0,0,.63889],8969:[1.25003,1.75,0,0,.63889],8970:[1.25003,1.75,0,0,.63889],8971:[1.25003,1.75,0,0,.63889],9115:[.64502,1.155,0,0,.875],9116:[1e-5,.6,0,0,.875],9117:[.64502,1.155,0,0,.875],9118:[.64502,1.155,0,0,.875],9119:[1e-5,.6,0,0,.875],9120:[.64502,1.155,0,0,.875],9121:[.64502,1.155,0,0,.66667],9122:[-99e-5,.601,0,0,.66667],9123:[.64502,1.155,0,0,.66667],9124:[.64502,1.155,0,0,.66667],9125:[-99e-5,.601,0,0,.66667],9126:[.64502,1.155,0,0,.66667],9127:[1e-5,.9,0,0,.88889],9128:[.65002,1.15,0,0,.88889],9129:[.90001,0,0,0,.88889],9130:[0,.3,0,0,.88889],9131:[1e-5,.9,0,0,.88889],9132:[.65002,1.15,0,0,.88889],9133:[.90001,0,0,0,.88889],9143:[.88502,.915,0,0,1.05556],10216:[1.25003,1.75,0,0,.80556],10217:[1.25003,1.75,0,0,.80556],57344:[-.00499,.605,0,0,1.05556],57345:[-.00499,.605,0,0,1.05556],57680:[0,.12,0,0,.45],57681:[0,.12,0,0,.45],57682:[0,.12,0,0,.45],57683:[0,.12,0,0,.45]},"Typewriter-Regular":{32:[0,0,0,0,.525],33:[0,.61111,0,0,.525],34:[0,.61111,0,0,.525],35:[0,.61111,0,0,.525],36:[.08333,.69444,0,0,.525],37:[.08333,.69444,0,0,.525],38:[0,.61111,0,0,.525],39:[0,.61111,0,0,.525],40:[.08333,.69444,0,0,.525],41:[.08333,.69444,0,0,.525],42:[0,.52083,0,0,.525],43:[-.08056,.53055,0,0,.525],44:[.13889,.125,0,0,.525],45:[-.08056,.53055,0,0,.525],46:[0,.125,0,0,.525],47:[.08333,.69444,0,0,.525],48:[0,.61111,0,0,.525],49:[0,.61111,0,0,.525],50:[0,.61111,0,0,.525],51:[0,.61111,0,0,.525],52:[0,.61111,0,0,.525],53:[0,.61111,0,0,.525],54:[0,.61111,0,0,.525],55:[0,.61111,0,0,.525],56:[0,.61111,0,0,.525],57:[0,.61111,0,0,.525],58:[0,.43056,0,0,.525],59:[.13889,.43056,0,0,.525],60:[-.05556,.55556,0,0,.525],61:[-.19549,.41562,0,0,.525],62:[-.05556,.55556,0,0,.525],63:[0,.61111,0,0,.525],64:[0,.61111,0,0,.525],65:[0,.61111,0,0,.525],66:[0,.61111,0,0,.525],67:[0,.61111,0,0,.525],68:[0,.61111,0,0,.525],69:[0,.61111,0,0,.525],70:[0,.61111,0,0,.525],71:[0,.61111,0,0,.525],72:[0,.61111,0,0,.525],73:[0,.61111,0,0,.525],74:[0,.61111,0,0,.525],75:[0,.61111,0,0,.525],76:[0,.61111,0,0,.525],77:[0,.61111,0,0,.525],78:[0,.61111,0,0,.525],79:[0,.61111,0,0,.525],80:[0,.61111,0,0,.525],81:[.13889,.61111,0,0,.525],82:[0,.61111,0,0,.525],83:[0,.61111,0,0,.525],84:[0,.61111,0,0,.525],85:[0,.61111,0,0,.525],86:[0,.61111,0,0,.525],87:[0,.61111,0,0,.525],88:[0,.61111,0,0,.525],89:[0,.61111,0,0,.525],90:[0,.61111,0,0,.525],91:[.08333,.69444,0,0,.525],92:[.08333,.69444,0,0,.525],93:[.08333,.69444,0,0,.525],94:[0,.61111,0,0,.525],95:[.09514,0,0,0,.525],96:[0,.61111,0,0,.525],97:[0,.43056,0,0,.525],98:[0,.61111,0,0,.525],99:[0,.43056,0,0,.525],100:[0,.61111,0,0,.525],101:[0,.43056,0,0,.525],102:[0,.61111,0,0,.525],103:[.22222,.43056,0,0,.525],104:[0,.61111,0,0,.525],105:[0,.61111,0,0,.525],106:[.22222,.61111,0,0,.525],107:[0,.61111,0,0,.525],108:[0,.61111,0,0,.525],109:[0,.43056,0,0,.525],110:[0,.43056,0,0,.525],111:[0,.43056,0,0,.525],112:[.22222,.43056,0,0,.525],113:[.22222,.43056,0,0,.525],114:[0,.43056,0,0,.525],115:[0,.43056,0,0,.525],116:[0,.55358,0,0,.525],117:[0,.43056,0,0,.525],118:[0,.43056,0,0,.525],119:[0,.43056,0,0,.525],120:[0,.43056,0,0,.525],121:[.22222,.43056,0,0,.525],122:[0,.43056,0,0,.525],123:[.08333,.69444,0,0,.525],124:[.08333,.69444,0,0,.525],125:[.08333,.69444,0,0,.525],126:[0,.61111,0,0,.525],127:[0,.61111,0,0,.525],160:[0,0,0,0,.525],176:[0,.61111,0,0,.525],184:[.19445,0,0,0,.525],305:[0,.43056,0,0,.525],567:[.22222,.43056,0,0,.525],711:[0,.56597,0,0,.525],713:[0,.56555,0,0,.525],714:[0,.61111,0,0,.525],715:[0,.61111,0,0,.525],728:[0,.61111,0,0,.525],730:[0,.61111,0,0,.525],770:[0,.61111,0,0,.525],771:[0,.61111,0,0,.525],776:[0,.61111,0,0,.525],915:[0,.61111,0,0,.525],916:[0,.61111,0,0,.525],920:[0,.61111,0,0,.525],923:[0,.61111,0,0,.525],926:[0,.61111,0,0,.525],928:[0,.61111,0,0,.525],931:[0,.61111,0,0,.525],933:[0,.61111,0,0,.525],934:[0,.61111,0,0,.525],936:[0,.61111,0,0,.525],937:[0,.61111,0,0,.525],8216:[0,.61111,0,0,.525],8217:[0,.61111,0,0,.525],8242:[0,.61111,0,0,.525],9251:[.11111,.21944,0,0,.525]}},ke={slant:[.25,.25,.25],space:[0,0,0],stretch:[0,0,0],shrink:[0,0,0],xHeight:[.431,.431,.431],quad:[1,1.171,1.472],extraSpace:[0,0,0],num1:[.677,.732,.925],num2:[.394,.384,.387],num3:[.444,.471,.504],denom1:[.686,.752,1.025],denom2:[.345,.344,.532],sup1:[.413,.503,.504],sup2:[.363,.431,.404],sup3:[.289,.286,.294],sub1:[.15,.143,.2],sub2:[.247,.286,.4],supDrop:[.386,.353,.494],subDrop:[.05,.071,.1],delim1:[2.39,1.7,1.98],delim2:[1.01,1.157,1.42],axisHeight:[.25,.25,.25],defaultRuleThickness:[.04,.049,.049],bigOpSpacing1:[.111,.111,.111],bigOpSpacing2:[.166,.166,.166],bigOpSpacing3:[.2,.2,.2],bigOpSpacing4:[.6,.611,.611],bigOpSpacing5:[.1,.143,.143],sqrtRuleThickness:[.04,.04,.04],ptPerEm:[10,10,10],doubleRuleSep:[.2,.2,.2],arrayRuleWidth:[.04,.04,.04],fboxsep:[.3,.3,.3],fboxrule:[.04,.04,.04]},Zt={\u00C5:"A",\u00D0:"D",\u00DE:"o",\u00E5:"a",\u00F0:"d",\u00FE:"o",\u0410:"A",\u0411:"B",\u0412:"B",\u0413:"F",\u0414:"A",\u0415:"E",\u0416:"K",\u0417:"3",\u0418:"N",\u0419:"N",\u041A:"K",\u041B:"N",\u041C:"M",\u041D:"H",\u041E:"O",\u041F:"N",\u0420:"P",\u0421:"C",\u0422:"T",\u0423:"y",\u0424:"O",\u0425:"X",\u0426:"U",\u0427:"h",\u0428:"W",\u0429:"W",\u042A:"B",\u042B:"X",\u042C:"B",\u042D:"3",\u042E:"X",\u042F:"R",\u0430:"a",\u0431:"b",\u0432:"a",\u0433:"r",\u0434:"y",\u0435:"e",\u0436:"m",\u0437:"e",\u0438:"n",\u0439:"n",\u043A:"n",\u043B:"n",\u043C:"m",\u043D:"n",\u043E:"o",\u043F:"n",\u0440:"p",\u0441:"c",\u0442:"o",\u0443:"y",\u0444:"b",\u0445:"x",\u0446:"n",\u0447:"n",\u0448:"w",\u0449:"w",\u044A:"a",\u044B:"m",\u044C:"a",\u044D:"e",\u044E:"m",\u044F:"r"};function Ja(r,e){z0[r]=e}function Tt(r,e,t){if(!z0[e])throw new Error("Font metrics not found for font: "+e+".");var a=r.charCodeAt(0),n=z0[e][a];if(!n&&r[0]in Zt&&(a=Zt[r[0]].charCodeAt(0),n=z0[e][a]),!n&&t==="text"&&Ar(a)&&(n=z0[e][77]),n)return{depth:n[0],height:n[1],italic:n[2],skew:n[3],width:n[4]}}var tt={};function Qa(r){var e;if(r>=5?e=0:r>=3?e=1:e=2,!tt[e]){var t=tt[e]={cssEmPerMu:ke.quad[e]/18};for(var a in ke)ke.hasOwnProperty(a)&&(t[a]=ke[a][e])}return tt[e]}var e1=[[1,1,1],[2,1,1],[3,1,1],[4,2,1],[5,2,1],[6,3,1],[7,4,2],[8,6,3],[9,7,6],[10,8,7],[11,10,9]],jt=[.5,.6,.7,.8,.9,1,1.2,1.44,1.728,2.074,2.488],Kt=function(e,t){return t.size<2?e:e1[e-1][t.size-1]},Re=class r{constructor(e){this.style=void 0,this.color=void 0,this.size=void 0,this.textSize=void 0,this.phantom=void 0,this.font=void 0,this.fontFamily=void 0,this.fontWeight=void 0,this.fontShape=void 0,this.sizeMultiplier=void 0,this.maxSize=void 0,this.minRuleThickness=void 0,this._fontMetrics=void 0,this.style=e.style,this.color=e.color,this.size=e.size||r.BASESIZE,this.textSize=e.textSize||this.size,this.phantom=!!e.phantom,this.font=e.font||"",this.fontFamily=e.fontFamily||"",this.fontWeight=e.fontWeight||"",this.fontShape=e.fontShape||"",this.sizeMultiplier=jt[this.size-1],this.maxSize=e.maxSize,this.minRuleThickness=e.minRuleThickness,this._fontMetrics=void 0}extend(e){var t={style:this.style,size:this.size,textSize:this.textSize,color:this.color,phantom:this.phantom,font:this.font,fontFamily:this.fontFamily,fontWeight:this.fontWeight,fontShape:this.fontShape,maxSize:this.maxSize,minRuleThickness:this.minRuleThickness};for(var a in e)e.hasOwnProperty(a)&&(t[a]=e[a]);return new r(t)}havingStyle(e){return this.style===e?this:this.extend({style:e,size:Kt(this.textSize,e)})}havingCrampedStyle(){return this.havingStyle(this.style.cramp())}havingSize(e){return this.size===e&&this.textSize===e?this:this.extend({style:this.style.text(),size:e,textSize:e,sizeMultiplier:jt[e-1]})}havingBaseStyle(e){e=e||this.style.text();var t=Kt(r.BASESIZE,e);return this.size===t&&this.textSize===r.BASESIZE&&this.style===e?this:this.extend({style:e,size:t})}havingBaseSizing(){var e;switch(this.style.id){case 4:case 5:e=3;break;case 6:case 7:e=1;break;default:e=6}return this.extend({style:this.style.text(),size:e})}withColor(e){return this.extend({color:e})}withPhantom(){return this.extend({phantom:!0})}withFont(e){return this.extend({font:e})}withTextFontFamily(e){return this.extend({fontFamily:e,font:""})}withTextFontWeight(e){return this.extend({fontWeight:e,font:""})}withTextFontShape(e){return this.extend({fontShape:e,font:""})}sizingClasses(e){return e.size!==this.size?["sizing","reset-size"+e.size,"size"+this.size]:[]}baseSizingClasses(){return this.size!==r.BASESIZE?["sizing","reset-size"+this.size,"size"+r.BASESIZE]:[]}fontMetrics(){return this._fontMetrics||(this._fontMetrics=Qa(this.size)),this._fontMetrics}getColor(){return this.phantom?"transparent":this.color}};Re.BASESIZE=6;var ft={pt:1,mm:7227/2540,cm:7227/254,in:72.27,bp:803/800,pc:12,dd:1238/1157,cc:14856/1157,nd:685/642,nc:1370/107,sp:1/65536,px:803/800},t1={ex:!0,em:!0,mu:!0},Tr=function(e){return typeof e!="string"&&(e=e.unit),e in ft||e in t1||e==="ex"},Q=function(e,t){var a;if(e.unit in ft)a=ft[e.unit]/t.fontMetrics().ptPerEm/t.sizeMultiplier;else if(e.unit==="mu")a=t.fontMetrics().cssEmPerMu;else{var n;if(t.style.isTight()?n=t.havingStyle(t.style.text()):n=t,e.unit==="ex")a=n.fontMetrics().xHeight;else if(e.unit==="em")a=n.fontMetrics().quad;else throw new z("Invalid unit: '"+e.unit+"'");n!==t&&(a*=n.sizeMultiplier/t.sizeMultiplier)}return Math.min(e.number*a,t.maxSize)},T=function(e){return+e.toFixed(4)+"em"},G0=function(e){return e.filter(t=>t).join(" ")},qr=function(e,t,a){if(this.classes=e||[],this.attributes={},this.height=0,this.depth=0,this.maxFontSize=0,this.style=a||{},t){t.style.isTight()&&this.classes.push("mtight");var n=t.getColor();n&&(this.style.color=n)}},Br=function(e){var t=document.createElement(e);t.className=G0(this.classes);for(var a in this.style)this.style.hasOwnProperty(a)&&(t.style[a]=this.style[a]);for(var n in this.attributes)this.attributes.hasOwnProperty(n)&&t.setAttribute(n,this.attributes[n]);for(var s=0;s/=\x00-\x1f]/,Dr=function(e){var t="<"+e;this.classes.length&&(t+=' class="'+O.escape(G0(this.classes))+'"');var a="";for(var n in this.style)this.style.hasOwnProperty(n)&&(a+=O.hyphenate(n)+":"+this.style[n]+";");a&&(t+=' style="'+O.escape(a)+'"');for(var s in this.attributes)if(this.attributes.hasOwnProperty(s)){if(r1.test(s))throw new z("Invalid attribute name '"+s+"'");t+=" "+s+'="'+O.escape(this.attributes[s])+'"'}t+=">";for(var l=0;l",t},Z0=class{constructor(e,t,a,n){this.children=void 0,this.attributes=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.width=void 0,this.maxFontSize=void 0,this.style=void 0,qr.call(this,e,a,n),this.children=t||[]}setAttribute(e,t){this.attributes[e]=t}hasClass(e){return O.contains(this.classes,e)}toNode(){return Br.call(this,"span")}toMarkup(){return Dr.call(this,"span")}},fe=class{constructor(e,t,a,n){this.children=void 0,this.attributes=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.maxFontSize=void 0,this.style=void 0,qr.call(this,t,n),this.children=a||[],this.setAttribute("href",e)}setAttribute(e,t){this.attributes[e]=t}hasClass(e){return O.contains(this.classes,e)}toNode(){return Br.call(this,"a")}toMarkup(){return Dr.call(this,"a")}},vt=class{constructor(e,t,a){this.src=void 0,this.alt=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.maxFontSize=void 0,this.style=void 0,this.alt=t,this.src=e,this.classes=["mord"],this.style=a}hasClass(e){return O.contains(this.classes,e)}toNode(){var e=document.createElement("img");e.src=this.src,e.alt=this.alt,e.className="mord";for(var t in this.style)this.style.hasOwnProperty(t)&&(e.style[t]=this.style[t]);return e}toMarkup(){var e=''+O.escape(this.alt)+'0&&(t=document.createElement("span"),t.style.marginRight=T(this.italic)),this.classes.length>0&&(t=t||document.createElement("span"),t.className=G0(this.classes));for(var a in this.style)this.style.hasOwnProperty(a)&&(t=t||document.createElement("span"),t.style[a]=this.style[a]);return t?(t.appendChild(e),t):e}toMarkup(){var e=!1,t="0&&(a+="margin-right:"+this.italic+"em;");for(var n in this.style)this.style.hasOwnProperty(n)&&(a+=O.hyphenate(n)+":"+this.style[n]+";");a&&(e=!0,t+=' style="'+O.escape(a)+'"');var s=O.escape(this.text);return e?(t+=">",t+=s,t+="",t):s}},S0=class{constructor(e,t){this.children=void 0,this.attributes=void 0,this.children=e||[],this.attributes=t||{}}toNode(){var e="http://www.w3.org/2000/svg",t=document.createElementNS(e,"svg");for(var a in this.attributes)Object.prototype.hasOwnProperty.call(this.attributes,a)&&t.setAttribute(a,this.attributes[a]);for(var n=0;n':''}},ve=class{constructor(e){this.attributes=void 0,this.attributes=e||{}}toNode(){var e="http://www.w3.org/2000/svg",t=document.createElementNS(e,"line");for(var a in this.attributes)Object.prototype.hasOwnProperty.call(this.attributes,a)&&t.setAttribute(a,this.attributes[a]);return t}toMarkup(){var e=" but got "+String(r)+".")}var i1={bin:1,close:1,inner:1,open:1,punct:1,rel:1},s1={"accent-token":1,mathord:1,"op-token":1,spacing:1,textord:1},Y={math:{},text:{}};function i(r,e,t,a,n,s){Y[r][n]={font:e,group:t,replace:a},s&&a&&(Y[r][a]=Y[r][n])}var o="math",k="text",u="main",d="ams",Z="accent-token",C="bin",l0="close",ie="inner",I="mathord",t0="op-token",p0="open",Ve="punct",p="rel",R0="spacing",g="textord";i(o,u,p,"\u2261","\\equiv",!0);i(o,u,p,"\u227A","\\prec",!0);i(o,u,p,"\u227B","\\succ",!0);i(o,u,p,"\u223C","\\sim",!0);i(o,u,p,"\u22A5","\\perp");i(o,u,p,"\u2AAF","\\preceq",!0);i(o,u,p,"\u2AB0","\\succeq",!0);i(o,u,p,"\u2243","\\simeq",!0);i(o,u,p,"\u2223","\\mid",!0);i(o,u,p,"\u226A","\\ll",!0);i(o,u,p,"\u226B","\\gg",!0);i(o,u,p,"\u224D","\\asymp",!0);i(o,u,p,"\u2225","\\parallel");i(o,u,p,"\u22C8","\\bowtie",!0);i(o,u,p,"\u2323","\\smile",!0);i(o,u,p,"\u2291","\\sqsubseteq",!0);i(o,u,p,"\u2292","\\sqsupseteq",!0);i(o,u,p,"\u2250","\\doteq",!0);i(o,u,p,"\u2322","\\frown",!0);i(o,u,p,"\u220B","\\ni",!0);i(o,u,p,"\u221D","\\propto",!0);i(o,u,p,"\u22A2","\\vdash",!0);i(o,u,p,"\u22A3","\\dashv",!0);i(o,u,p,"\u220B","\\owns");i(o,u,Ve,".","\\ldotp");i(o,u,Ve,"\u22C5","\\cdotp");i(o,u,g,"#","\\#");i(k,u,g,"#","\\#");i(o,u,g,"&","\\&");i(k,u,g,"&","\\&");i(o,u,g,"\u2135","\\aleph",!0);i(o,u,g,"\u2200","\\forall",!0);i(o,u,g,"\u210F","\\hbar",!0);i(o,u,g,"\u2203","\\exists",!0);i(o,u,g,"\u2207","\\nabla",!0);i(o,u,g,"\u266D","\\flat",!0);i(o,u,g,"\u2113","\\ell",!0);i(o,u,g,"\u266E","\\natural",!0);i(o,u,g,"\u2663","\\clubsuit",!0);i(o,u,g,"\u2118","\\wp",!0);i(o,u,g,"\u266F","\\sharp",!0);i(o,u,g,"\u2662","\\diamondsuit",!0);i(o,u,g,"\u211C","\\Re",!0);i(o,u,g,"\u2661","\\heartsuit",!0);i(o,u,g,"\u2111","\\Im",!0);i(o,u,g,"\u2660","\\spadesuit",!0);i(o,u,g,"\xA7","\\S",!0);i(k,u,g,"\xA7","\\S");i(o,u,g,"\xB6","\\P",!0);i(k,u,g,"\xB6","\\P");i(o,u,g,"\u2020","\\dag");i(k,u,g,"\u2020","\\dag");i(k,u,g,"\u2020","\\textdagger");i(o,u,g,"\u2021","\\ddag");i(k,u,g,"\u2021","\\ddag");i(k,u,g,"\u2021","\\textdaggerdbl");i(o,u,l0,"\u23B1","\\rmoustache",!0);i(o,u,p0,"\u23B0","\\lmoustache",!0);i(o,u,l0,"\u27EF","\\rgroup",!0);i(o,u,p0,"\u27EE","\\lgroup",!0);i(o,u,C,"\u2213","\\mp",!0);i(o,u,C,"\u2296","\\ominus",!0);i(o,u,C,"\u228E","\\uplus",!0);i(o,u,C,"\u2293","\\sqcap",!0);i(o,u,C,"\u2217","\\ast");i(o,u,C,"\u2294","\\sqcup",!0);i(o,u,C,"\u25EF","\\bigcirc",!0);i(o,u,C,"\u2219","\\bullet",!0);i(o,u,C,"\u2021","\\ddagger");i(o,u,C,"\u2240","\\wr",!0);i(o,u,C,"\u2A3F","\\amalg");i(o,u,C,"&","\\And");i(o,u,p,"\u27F5","\\longleftarrow",!0);i(o,u,p,"\u21D0","\\Leftarrow",!0);i(o,u,p,"\u27F8","\\Longleftarrow",!0);i(o,u,p,"\u27F6","\\longrightarrow",!0);i(o,u,p,"\u21D2","\\Rightarrow",!0);i(o,u,p,"\u27F9","\\Longrightarrow",!0);i(o,u,p,"\u2194","\\leftrightarrow",!0);i(o,u,p,"\u27F7","\\longleftrightarrow",!0);i(o,u,p,"\u21D4","\\Leftrightarrow",!0);i(o,u,p,"\u27FA","\\Longleftrightarrow",!0);i(o,u,p,"\u21A6","\\mapsto",!0);i(o,u,p,"\u27FC","\\longmapsto",!0);i(o,u,p,"\u2197","\\nearrow",!0);i(o,u,p,"\u21A9","\\hookleftarrow",!0);i(o,u,p,"\u21AA","\\hookrightarrow",!0);i(o,u,p,"\u2198","\\searrow",!0);i(o,u,p,"\u21BC","\\leftharpoonup",!0);i(o,u,p,"\u21C0","\\rightharpoonup",!0);i(o,u,p,"\u2199","\\swarrow",!0);i(o,u,p,"\u21BD","\\leftharpoondown",!0);i(o,u,p,"\u21C1","\\rightharpoondown",!0);i(o,u,p,"\u2196","\\nwarrow",!0);i(o,u,p,"\u21CC","\\rightleftharpoons",!0);i(o,d,p,"\u226E","\\nless",!0);i(o,d,p,"\uE010","\\@nleqslant");i(o,d,p,"\uE011","\\@nleqq");i(o,d,p,"\u2A87","\\lneq",!0);i(o,d,p,"\u2268","\\lneqq",!0);i(o,d,p,"\uE00C","\\@lvertneqq");i(o,d,p,"\u22E6","\\lnsim",!0);i(o,d,p,"\u2A89","\\lnapprox",!0);i(o,d,p,"\u2280","\\nprec",!0);i(o,d,p,"\u22E0","\\npreceq",!0);i(o,d,p,"\u22E8","\\precnsim",!0);i(o,d,p,"\u2AB9","\\precnapprox",!0);i(o,d,p,"\u2241","\\nsim",!0);i(o,d,p,"\uE006","\\@nshortmid");i(o,d,p,"\u2224","\\nmid",!0);i(o,d,p,"\u22AC","\\nvdash",!0);i(o,d,p,"\u22AD","\\nvDash",!0);i(o,d,p,"\u22EA","\\ntriangleleft");i(o,d,p,"\u22EC","\\ntrianglelefteq",!0);i(o,d,p,"\u228A","\\subsetneq",!0);i(o,d,p,"\uE01A","\\@varsubsetneq");i(o,d,p,"\u2ACB","\\subsetneqq",!0);i(o,d,p,"\uE017","\\@varsubsetneqq");i(o,d,p,"\u226F","\\ngtr",!0);i(o,d,p,"\uE00F","\\@ngeqslant");i(o,d,p,"\uE00E","\\@ngeqq");i(o,d,p,"\u2A88","\\gneq",!0);i(o,d,p,"\u2269","\\gneqq",!0);i(o,d,p,"\uE00D","\\@gvertneqq");i(o,d,p,"\u22E7","\\gnsim",!0);i(o,d,p,"\u2A8A","\\gnapprox",!0);i(o,d,p,"\u2281","\\nsucc",!0);i(o,d,p,"\u22E1","\\nsucceq",!0);i(o,d,p,"\u22E9","\\succnsim",!0);i(o,d,p,"\u2ABA","\\succnapprox",!0);i(o,d,p,"\u2246","\\ncong",!0);i(o,d,p,"\uE007","\\@nshortparallel");i(o,d,p,"\u2226","\\nparallel",!0);i(o,d,p,"\u22AF","\\nVDash",!0);i(o,d,p,"\u22EB","\\ntriangleright");i(o,d,p,"\u22ED","\\ntrianglerighteq",!0);i(o,d,p,"\uE018","\\@nsupseteqq");i(o,d,p,"\u228B","\\supsetneq",!0);i(o,d,p,"\uE01B","\\@varsupsetneq");i(o,d,p,"\u2ACC","\\supsetneqq",!0);i(o,d,p,"\uE019","\\@varsupsetneqq");i(o,d,p,"\u22AE","\\nVdash",!0);i(o,d,p,"\u2AB5","\\precneqq",!0);i(o,d,p,"\u2AB6","\\succneqq",!0);i(o,d,p,"\uE016","\\@nsubseteqq");i(o,d,C,"\u22B4","\\unlhd");i(o,d,C,"\u22B5","\\unrhd");i(o,d,p,"\u219A","\\nleftarrow",!0);i(o,d,p,"\u219B","\\nrightarrow",!0);i(o,d,p,"\u21CD","\\nLeftarrow",!0);i(o,d,p,"\u21CF","\\nRightarrow",!0);i(o,d,p,"\u21AE","\\nleftrightarrow",!0);i(o,d,p,"\u21CE","\\nLeftrightarrow",!0);i(o,d,p,"\u25B3","\\vartriangle");i(o,d,g,"\u210F","\\hslash");i(o,d,g,"\u25BD","\\triangledown");i(o,d,g,"\u25CA","\\lozenge");i(o,d,g,"\u24C8","\\circledS");i(o,d,g,"\xAE","\\circledR");i(k,d,g,"\xAE","\\circledR");i(o,d,g,"\u2221","\\measuredangle",!0);i(o,d,g,"\u2204","\\nexists");i(o,d,g,"\u2127","\\mho");i(o,d,g,"\u2132","\\Finv",!0);i(o,d,g,"\u2141","\\Game",!0);i(o,d,g,"\u2035","\\backprime");i(o,d,g,"\u25B2","\\blacktriangle");i(o,d,g,"\u25BC","\\blacktriangledown");i(o,d,g,"\u25A0","\\blacksquare");i(o,d,g,"\u29EB","\\blacklozenge");i(o,d,g,"\u2605","\\bigstar");i(o,d,g,"\u2222","\\sphericalangle",!0);i(o,d,g,"\u2201","\\complement",!0);i(o,d,g,"\xF0","\\eth",!0);i(k,u,g,"\xF0","\xF0");i(o,d,g,"\u2571","\\diagup");i(o,d,g,"\u2572","\\diagdown");i(o,d,g,"\u25A1","\\square");i(o,d,g,"\u25A1","\\Box");i(o,d,g,"\u25CA","\\Diamond");i(o,d,g,"\xA5","\\yen",!0);i(k,d,g,"\xA5","\\yen",!0);i(o,d,g,"\u2713","\\checkmark",!0);i(k,d,g,"\u2713","\\checkmark");i(o,d,g,"\u2136","\\beth",!0);i(o,d,g,"\u2138","\\daleth",!0);i(o,d,g,"\u2137","\\gimel",!0);i(o,d,g,"\u03DD","\\digamma",!0);i(o,d,g,"\u03F0","\\varkappa");i(o,d,p0,"\u250C","\\@ulcorner",!0);i(o,d,l0,"\u2510","\\@urcorner",!0);i(o,d,p0,"\u2514","\\@llcorner",!0);i(o,d,l0,"\u2518","\\@lrcorner",!0);i(o,d,p,"\u2266","\\leqq",!0);i(o,d,p,"\u2A7D","\\leqslant",!0);i(o,d,p,"\u2A95","\\eqslantless",!0);i(o,d,p,"\u2272","\\lesssim",!0);i(o,d,p,"\u2A85","\\lessapprox",!0);i(o,d,p,"\u224A","\\approxeq",!0);i(o,d,C,"\u22D6","\\lessdot");i(o,d,p,"\u22D8","\\lll",!0);i(o,d,p,"\u2276","\\lessgtr",!0);i(o,d,p,"\u22DA","\\lesseqgtr",!0);i(o,d,p,"\u2A8B","\\lesseqqgtr",!0);i(o,d,p,"\u2251","\\doteqdot");i(o,d,p,"\u2253","\\risingdotseq",!0);i(o,d,p,"\u2252","\\fallingdotseq",!0);i(o,d,p,"\u223D","\\backsim",!0);i(o,d,p,"\u22CD","\\backsimeq",!0);i(o,d,p,"\u2AC5","\\subseteqq",!0);i(o,d,p,"\u22D0","\\Subset",!0);i(o,d,p,"\u228F","\\sqsubset",!0);i(o,d,p,"\u227C","\\preccurlyeq",!0);i(o,d,p,"\u22DE","\\curlyeqprec",!0);i(o,d,p,"\u227E","\\precsim",!0);i(o,d,p,"\u2AB7","\\precapprox",!0);i(o,d,p,"\u22B2","\\vartriangleleft");i(o,d,p,"\u22B4","\\trianglelefteq");i(o,d,p,"\u22A8","\\vDash",!0);i(o,d,p,"\u22AA","\\Vvdash",!0);i(o,d,p,"\u2323","\\smallsmile");i(o,d,p,"\u2322","\\smallfrown");i(o,d,p,"\u224F","\\bumpeq",!0);i(o,d,p,"\u224E","\\Bumpeq",!0);i(o,d,p,"\u2267","\\geqq",!0);i(o,d,p,"\u2A7E","\\geqslant",!0);i(o,d,p,"\u2A96","\\eqslantgtr",!0);i(o,d,p,"\u2273","\\gtrsim",!0);i(o,d,p,"\u2A86","\\gtrapprox",!0);i(o,d,C,"\u22D7","\\gtrdot");i(o,d,p,"\u22D9","\\ggg",!0);i(o,d,p,"\u2277","\\gtrless",!0);i(o,d,p,"\u22DB","\\gtreqless",!0);i(o,d,p,"\u2A8C","\\gtreqqless",!0);i(o,d,p,"\u2256","\\eqcirc",!0);i(o,d,p,"\u2257","\\circeq",!0);i(o,d,p,"\u225C","\\triangleq",!0);i(o,d,p,"\u223C","\\thicksim");i(o,d,p,"\u2248","\\thickapprox");i(o,d,p,"\u2AC6","\\supseteqq",!0);i(o,d,p,"\u22D1","\\Supset",!0);i(o,d,p,"\u2290","\\sqsupset",!0);i(o,d,p,"\u227D","\\succcurlyeq",!0);i(o,d,p,"\u22DF","\\curlyeqsucc",!0);i(o,d,p,"\u227F","\\succsim",!0);i(o,d,p,"\u2AB8","\\succapprox",!0);i(o,d,p,"\u22B3","\\vartriangleright");i(o,d,p,"\u22B5","\\trianglerighteq");i(o,d,p,"\u22A9","\\Vdash",!0);i(o,d,p,"\u2223","\\shortmid");i(o,d,p,"\u2225","\\shortparallel");i(o,d,p,"\u226C","\\between",!0);i(o,d,p,"\u22D4","\\pitchfork",!0);i(o,d,p,"\u221D","\\varpropto");i(o,d,p,"\u25C0","\\blacktriangleleft");i(o,d,p,"\u2234","\\therefore",!0);i(o,d,p,"\u220D","\\backepsilon");i(o,d,p,"\u25B6","\\blacktriangleright");i(o,d,p,"\u2235","\\because",!0);i(o,d,p,"\u22D8","\\llless");i(o,d,p,"\u22D9","\\gggtr");i(o,d,C,"\u22B2","\\lhd");i(o,d,C,"\u22B3","\\rhd");i(o,d,p,"\u2242","\\eqsim",!0);i(o,u,p,"\u22C8","\\Join");i(o,d,p,"\u2251","\\Doteq",!0);i(o,d,C,"\u2214","\\dotplus",!0);i(o,d,C,"\u2216","\\smallsetminus");i(o,d,C,"\u22D2","\\Cap",!0);i(o,d,C,"\u22D3","\\Cup",!0);i(o,d,C,"\u2A5E","\\doublebarwedge",!0);i(o,d,C,"\u229F","\\boxminus",!0);i(o,d,C,"\u229E","\\boxplus",!0);i(o,d,C,"\u22C7","\\divideontimes",!0);i(o,d,C,"\u22C9","\\ltimes",!0);i(o,d,C,"\u22CA","\\rtimes",!0);i(o,d,C,"\u22CB","\\leftthreetimes",!0);i(o,d,C,"\u22CC","\\rightthreetimes",!0);i(o,d,C,"\u22CF","\\curlywedge",!0);i(o,d,C,"\u22CE","\\curlyvee",!0);i(o,d,C,"\u229D","\\circleddash",!0);i(o,d,C,"\u229B","\\circledast",!0);i(o,d,C,"\u22C5","\\centerdot");i(o,d,C,"\u22BA","\\intercal",!0);i(o,d,C,"\u22D2","\\doublecap");i(o,d,C,"\u22D3","\\doublecup");i(o,d,C,"\u22A0","\\boxtimes",!0);i(o,d,p,"\u21E2","\\dashrightarrow",!0);i(o,d,p,"\u21E0","\\dashleftarrow",!0);i(o,d,p,"\u21C7","\\leftleftarrows",!0);i(o,d,p,"\u21C6","\\leftrightarrows",!0);i(o,d,p,"\u21DA","\\Lleftarrow",!0);i(o,d,p,"\u219E","\\twoheadleftarrow",!0);i(o,d,p,"\u21A2","\\leftarrowtail",!0);i(o,d,p,"\u21AB","\\looparrowleft",!0);i(o,d,p,"\u21CB","\\leftrightharpoons",!0);i(o,d,p,"\u21B6","\\curvearrowleft",!0);i(o,d,p,"\u21BA","\\circlearrowleft",!0);i(o,d,p,"\u21B0","\\Lsh",!0);i(o,d,p,"\u21C8","\\upuparrows",!0);i(o,d,p,"\u21BF","\\upharpoonleft",!0);i(o,d,p,"\u21C3","\\downharpoonleft",!0);i(o,u,p,"\u22B6","\\origof",!0);i(o,u,p,"\u22B7","\\imageof",!0);i(o,d,p,"\u22B8","\\multimap",!0);i(o,d,p,"\u21AD","\\leftrightsquigarrow",!0);i(o,d,p,"\u21C9","\\rightrightarrows",!0);i(o,d,p,"\u21C4","\\rightleftarrows",!0);i(o,d,p,"\u21A0","\\twoheadrightarrow",!0);i(o,d,p,"\u21A3","\\rightarrowtail",!0);i(o,d,p,"\u21AC","\\looparrowright",!0);i(o,d,p,"\u21B7","\\curvearrowright",!0);i(o,d,p,"\u21BB","\\circlearrowright",!0);i(o,d,p,"\u21B1","\\Rsh",!0);i(o,d,p,"\u21CA","\\downdownarrows",!0);i(o,d,p,"\u21BE","\\upharpoonright",!0);i(o,d,p,"\u21C2","\\downharpoonright",!0);i(o,d,p,"\u21DD","\\rightsquigarrow",!0);i(o,d,p,"\u21DD","\\leadsto");i(o,d,p,"\u21DB","\\Rrightarrow",!0);i(o,d,p,"\u21BE","\\restriction");i(o,u,g,"\u2018","`");i(o,u,g,"$","\\$");i(k,u,g,"$","\\$");i(k,u,g,"$","\\textdollar");i(o,u,g,"%","\\%");i(k,u,g,"%","\\%");i(o,u,g,"_","\\_");i(k,u,g,"_","\\_");i(k,u,g,"_","\\textunderscore");i(o,u,g,"\u2220","\\angle",!0);i(o,u,g,"\u221E","\\infty",!0);i(o,u,g,"\u2032","\\prime");i(o,u,g,"\u25B3","\\triangle");i(o,u,g,"\u0393","\\Gamma",!0);i(o,u,g,"\u0394","\\Delta",!0);i(o,u,g,"\u0398","\\Theta",!0);i(o,u,g,"\u039B","\\Lambda",!0);i(o,u,g,"\u039E","\\Xi",!0);i(o,u,g,"\u03A0","\\Pi",!0);i(o,u,g,"\u03A3","\\Sigma",!0);i(o,u,g,"\u03A5","\\Upsilon",!0);i(o,u,g,"\u03A6","\\Phi",!0);i(o,u,g,"\u03A8","\\Psi",!0);i(o,u,g,"\u03A9","\\Omega",!0);i(o,u,g,"A","\u0391");i(o,u,g,"B","\u0392");i(o,u,g,"E","\u0395");i(o,u,g,"Z","\u0396");i(o,u,g,"H","\u0397");i(o,u,g,"I","\u0399");i(o,u,g,"K","\u039A");i(o,u,g,"M","\u039C");i(o,u,g,"N","\u039D");i(o,u,g,"O","\u039F");i(o,u,g,"P","\u03A1");i(o,u,g,"T","\u03A4");i(o,u,g,"X","\u03A7");i(o,u,g,"\xAC","\\neg",!0);i(o,u,g,"\xAC","\\lnot");i(o,u,g,"\u22A4","\\top");i(o,u,g,"\u22A5","\\bot");i(o,u,g,"\u2205","\\emptyset");i(o,d,g,"\u2205","\\varnothing");i(o,u,I,"\u03B1","\\alpha",!0);i(o,u,I,"\u03B2","\\beta",!0);i(o,u,I,"\u03B3","\\gamma",!0);i(o,u,I,"\u03B4","\\delta",!0);i(o,u,I,"\u03F5","\\epsilon",!0);i(o,u,I,"\u03B6","\\zeta",!0);i(o,u,I,"\u03B7","\\eta",!0);i(o,u,I,"\u03B8","\\theta",!0);i(o,u,I,"\u03B9","\\iota",!0);i(o,u,I,"\u03BA","\\kappa",!0);i(o,u,I,"\u03BB","\\lambda",!0);i(o,u,I,"\u03BC","\\mu",!0);i(o,u,I,"\u03BD","\\nu",!0);i(o,u,I,"\u03BE","\\xi",!0);i(o,u,I,"\u03BF","\\omicron",!0);i(o,u,I,"\u03C0","\\pi",!0);i(o,u,I,"\u03C1","\\rho",!0);i(o,u,I,"\u03C3","\\sigma",!0);i(o,u,I,"\u03C4","\\tau",!0);i(o,u,I,"\u03C5","\\upsilon",!0);i(o,u,I,"\u03D5","\\phi",!0);i(o,u,I,"\u03C7","\\chi",!0);i(o,u,I,"\u03C8","\\psi",!0);i(o,u,I,"\u03C9","\\omega",!0);i(o,u,I,"\u03B5","\\varepsilon",!0);i(o,u,I,"\u03D1","\\vartheta",!0);i(o,u,I,"\u03D6","\\varpi",!0);i(o,u,I,"\u03F1","\\varrho",!0);i(o,u,I,"\u03C2","\\varsigma",!0);i(o,u,I,"\u03C6","\\varphi",!0);i(o,u,C,"\u2217","*",!0);i(o,u,C,"+","+");i(o,u,C,"\u2212","-",!0);i(o,u,C,"\u22C5","\\cdot",!0);i(o,u,C,"\u2218","\\circ",!0);i(o,u,C,"\xF7","\\div",!0);i(o,u,C,"\xB1","\\pm",!0);i(o,u,C,"\xD7","\\times",!0);i(o,u,C,"\u2229","\\cap",!0);i(o,u,C,"\u222A","\\cup",!0);i(o,u,C,"\u2216","\\setminus",!0);i(o,u,C,"\u2227","\\land");i(o,u,C,"\u2228","\\lor");i(o,u,C,"\u2227","\\wedge",!0);i(o,u,C,"\u2228","\\vee",!0);i(o,u,g,"\u221A","\\surd");i(o,u,p0,"\u27E8","\\langle",!0);i(o,u,p0,"\u2223","\\lvert");i(o,u,p0,"\u2225","\\lVert");i(o,u,l0,"?","?");i(o,u,l0,"!","!");i(o,u,l0,"\u27E9","\\rangle",!0);i(o,u,l0,"\u2223","\\rvert");i(o,u,l0,"\u2225","\\rVert");i(o,u,p,"=","=");i(o,u,p,":",":");i(o,u,p,"\u2248","\\approx",!0);i(o,u,p,"\u2245","\\cong",!0);i(o,u,p,"\u2265","\\ge");i(o,u,p,"\u2265","\\geq",!0);i(o,u,p,"\u2190","\\gets");i(o,u,p,">","\\gt",!0);i(o,u,p,"\u2208","\\in",!0);i(o,u,p,"\uE020","\\@not");i(o,u,p,"\u2282","\\subset",!0);i(o,u,p,"\u2283","\\supset",!0);i(o,u,p,"\u2286","\\subseteq",!0);i(o,u,p,"\u2287","\\supseteq",!0);i(o,d,p,"\u2288","\\nsubseteq",!0);i(o,d,p,"\u2289","\\nsupseteq",!0);i(o,u,p,"\u22A8","\\models");i(o,u,p,"\u2190","\\leftarrow",!0);i(o,u,p,"\u2264","\\le");i(o,u,p,"\u2264","\\leq",!0);i(o,u,p,"<","\\lt",!0);i(o,u,p,"\u2192","\\rightarrow",!0);i(o,u,p,"\u2192","\\to");i(o,d,p,"\u2271","\\ngeq",!0);i(o,d,p,"\u2270","\\nleq",!0);i(o,u,R0,"\xA0","\\ ");i(o,u,R0,"\xA0","\\space");i(o,u,R0,"\xA0","\\nobreakspace");i(k,u,R0,"\xA0","\\ ");i(k,u,R0,"\xA0"," ");i(k,u,R0,"\xA0","\\space");i(k,u,R0,"\xA0","\\nobreakspace");i(o,u,R0,null,"\\nobreak");i(o,u,R0,null,"\\allowbreak");i(o,u,Ve,",",",");i(o,u,Ve,";",";");i(o,d,C,"\u22BC","\\barwedge",!0);i(o,d,C,"\u22BB","\\veebar",!0);i(o,u,C,"\u2299","\\odot",!0);i(o,u,C,"\u2295","\\oplus",!0);i(o,u,C,"\u2297","\\otimes",!0);i(o,u,g,"\u2202","\\partial",!0);i(o,u,C,"\u2298","\\oslash",!0);i(o,d,C,"\u229A","\\circledcirc",!0);i(o,d,C,"\u22A1","\\boxdot",!0);i(o,u,C,"\u25B3","\\bigtriangleup");i(o,u,C,"\u25BD","\\bigtriangledown");i(o,u,C,"\u2020","\\dagger");i(o,u,C,"\u22C4","\\diamond");i(o,u,C,"\u22C6","\\star");i(o,u,C,"\u25C3","\\triangleleft");i(o,u,C,"\u25B9","\\triangleright");i(o,u,p0,"{","\\{");i(k,u,g,"{","\\{");i(k,u,g,"{","\\textbraceleft");i(o,u,l0,"}","\\}");i(k,u,g,"}","\\}");i(k,u,g,"}","\\textbraceright");i(o,u,p0,"{","\\lbrace");i(o,u,l0,"}","\\rbrace");i(o,u,p0,"[","\\lbrack",!0);i(k,u,g,"[","\\lbrack",!0);i(o,u,l0,"]","\\rbrack",!0);i(k,u,g,"]","\\rbrack",!0);i(o,u,p0,"(","\\lparen",!0);i(o,u,l0,")","\\rparen",!0);i(k,u,g,"<","\\textless",!0);i(k,u,g,">","\\textgreater",!0);i(o,u,p0,"\u230A","\\lfloor",!0);i(o,u,l0,"\u230B","\\rfloor",!0);i(o,u,p0,"\u2308","\\lceil",!0);i(o,u,l0,"\u2309","\\rceil",!0);i(o,u,g,"\\","\\backslash");i(o,u,g,"\u2223","|");i(o,u,g,"\u2223","\\vert");i(k,u,g,"|","\\textbar",!0);i(o,u,g,"\u2225","\\|");i(o,u,g,"\u2225","\\Vert");i(k,u,g,"\u2225","\\textbardbl");i(k,u,g,"~","\\textasciitilde");i(k,u,g,"\\","\\textbackslash");i(k,u,g,"^","\\textasciicircum");i(o,u,p,"\u2191","\\uparrow",!0);i(o,u,p,"\u21D1","\\Uparrow",!0);i(o,u,p,"\u2193","\\downarrow",!0);i(o,u,p,"\u21D3","\\Downarrow",!0);i(o,u,p,"\u2195","\\updownarrow",!0);i(o,u,p,"\u21D5","\\Updownarrow",!0);i(o,u,t0,"\u2210","\\coprod");i(o,u,t0,"\u22C1","\\bigvee");i(o,u,t0,"\u22C0","\\bigwedge");i(o,u,t0,"\u2A04","\\biguplus");i(o,u,t0,"\u22C2","\\bigcap");i(o,u,t0,"\u22C3","\\bigcup");i(o,u,t0,"\u222B","\\int");i(o,u,t0,"\u222B","\\intop");i(o,u,t0,"\u222C","\\iint");i(o,u,t0,"\u222D","\\iiint");i(o,u,t0,"\u220F","\\prod");i(o,u,t0,"\u2211","\\sum");i(o,u,t0,"\u2A02","\\bigotimes");i(o,u,t0,"\u2A01","\\bigoplus");i(o,u,t0,"\u2A00","\\bigodot");i(o,u,t0,"\u222E","\\oint");i(o,u,t0,"\u222F","\\oiint");i(o,u,t0,"\u2230","\\oiiint");i(o,u,t0,"\u2A06","\\bigsqcup");i(o,u,t0,"\u222B","\\smallint");i(k,u,ie,"\u2026","\\textellipsis");i(o,u,ie,"\u2026","\\mathellipsis");i(k,u,ie,"\u2026","\\ldots",!0);i(o,u,ie,"\u2026","\\ldots",!0);i(o,u,ie,"\u22EF","\\@cdots",!0);i(o,u,ie,"\u22F1","\\ddots",!0);i(o,u,g,"\u22EE","\\varvdots");i(k,u,g,"\u22EE","\\varvdots");i(o,u,Z,"\u02CA","\\acute");i(o,u,Z,"\u02CB","\\grave");i(o,u,Z,"\xA8","\\ddot");i(o,u,Z,"~","\\tilde");i(o,u,Z,"\u02C9","\\bar");i(o,u,Z,"\u02D8","\\breve");i(o,u,Z,"\u02C7","\\check");i(o,u,Z,"^","\\hat");i(o,u,Z,"\u20D7","\\vec");i(o,u,Z,"\u02D9","\\dot");i(o,u,Z,"\u02DA","\\mathring");i(o,u,I,"\uE131","\\@imath");i(o,u,I,"\uE237","\\@jmath");i(o,u,g,"\u0131","\u0131");i(o,u,g,"\u0237","\u0237");i(k,u,g,"\u0131","\\i",!0);i(k,u,g,"\u0237","\\j",!0);i(k,u,g,"\xDF","\\ss",!0);i(k,u,g,"\xE6","\\ae",!0);i(k,u,g,"\u0153","\\oe",!0);i(k,u,g,"\xF8","\\o",!0);i(k,u,g,"\xC6","\\AE",!0);i(k,u,g,"\u0152","\\OE",!0);i(k,u,g,"\xD8","\\O",!0);i(k,u,Z,"\u02CA","\\'");i(k,u,Z,"\u02CB","\\`");i(k,u,Z,"\u02C6","\\^");i(k,u,Z,"\u02DC","\\~");i(k,u,Z,"\u02C9","\\=");i(k,u,Z,"\u02D8","\\u");i(k,u,Z,"\u02D9","\\.");i(k,u,Z,"\xB8","\\c");i(k,u,Z,"\u02DA","\\r");i(k,u,Z,"\u02C7","\\v");i(k,u,Z,"\xA8",'\\"');i(k,u,Z,"\u02DD","\\H");i(k,u,Z,"\u25EF","\\textcircled");var Cr={"--":!0,"---":!0,"``":!0,"''":!0};i(k,u,g,"\u2013","--",!0);i(k,u,g,"\u2013","\\textendash");i(k,u,g,"\u2014","---",!0);i(k,u,g,"\u2014","\\textemdash");i(k,u,g,"\u2018","`",!0);i(k,u,g,"\u2018","\\textquoteleft");i(k,u,g,"\u2019","'",!0);i(k,u,g,"\u2019","\\textquoteright");i(k,u,g,"\u201C","``",!0);i(k,u,g,"\u201C","\\textquotedblleft");i(k,u,g,"\u201D","''",!0);i(k,u,g,"\u201D","\\textquotedblright");i(o,u,g,"\xB0","\\degree",!0);i(k,u,g,"\xB0","\\degree");i(k,u,g,"\xB0","\\textdegree",!0);i(o,u,g,"\xA3","\\pounds");i(o,u,g,"\xA3","\\mathsterling",!0);i(k,u,g,"\xA3","\\pounds");i(k,u,g,"\xA3","\\textsterling",!0);i(o,d,g,"\u2720","\\maltese");i(k,d,g,"\u2720","\\maltese");var Qt='0123456789/@."';for(Me=0;Me0)return w0(s,f,n,t,l.concat(v));if(c){var b,x;if(c==="boldsymbol"){var w=u1(s,n,t,l,a);b=w.fontName,x=[w.fontClass]}else h?(b=Or[c].fontName,x=[c]):(b=Be(c,t.fontWeight,t.fontShape),x=[c,t.fontWeight,t.fontShape]);if(Ue(s,b,n).metrics)return w0(s,b,n,t,l.concat(x));if(Cr.hasOwnProperty(s)&&b.slice(0,10)==="Typewriter"){for(var A=[],q=0;q{if(G0(r.classes)!==G0(e.classes)||r.skew!==e.skew||r.maxFontSize!==e.maxFontSize)return!1;if(r.classes.length===1){var t=r.classes[0];if(t==="mbin"||t==="mord")return!1}for(var a in r.style)if(r.style.hasOwnProperty(a)&&r.style[a]!==e.style[a])return!1;for(var n in e.style)if(e.style.hasOwnProperty(n)&&r.style[n]!==e.style[n])return!1;return!0},m1=r=>{for(var e=0;et&&(t=l.height),l.depth>a&&(a=l.depth),l.maxFontSize>n&&(n=l.maxFontSize)}e.height=t,e.depth=a,e.maxFontSize=n},h0=function(e,t,a,n){var s=new Z0(e,t,a,n);return qt(s),s},_r=(r,e,t,a)=>new Z0(r,e,t,a),d1=function(e,t,a){var n=h0([e],[],t);return n.height=Math.max(a||t.fontMetrics().defaultRuleThickness,t.minRuleThickness),n.style.borderBottomWidth=T(n.height),n.maxFontSize=1,n},p1=function(e,t,a,n){var s=new fe(e,t,a,n);return qt(s),s},Nr=function(e){var t=new Y0(e);return qt(t),t},f1=function(e,t){return e instanceof Y0?h0([],[e],t):e},v1=function(e){if(e.positionType==="individualShift"){for(var t=e.children,a=[t[0]],n=-t[0].shift-t[0].elem.depth,s=n,l=1;l{var t=h0(["mspace"],[],e),a=Q(r,e);return t.style.marginRight=T(a),t},Be=function(e,t,a){var n="";switch(e){case"amsrm":n="AMS";break;case"textrm":n="Main";break;case"textsf":n="SansSerif";break;case"texttt":n="Typewriter";break;default:n=e}var s;return t==="textbf"&&a==="textit"?s="BoldItalic":t==="textbf"?s="Bold":t==="textit"?s="Italic":s="Regular",n+"-"+s},Or={mathbf:{variant:"bold",fontName:"Main-Bold"},mathrm:{variant:"normal",fontName:"Main-Regular"},textit:{variant:"italic",fontName:"Main-Italic"},mathit:{variant:"italic",fontName:"Main-Italic"},mathnormal:{variant:"italic",fontName:"Math-Italic"},mathsfit:{variant:"sans-serif-italic",fontName:"SansSerif-Italic"},mathbb:{variant:"double-struck",fontName:"AMS-Regular"},mathcal:{variant:"script",fontName:"Caligraphic-Regular"},mathfrak:{variant:"fraktur",fontName:"Fraktur-Regular"},mathscr:{variant:"script",fontName:"Script-Regular"},mathsf:{variant:"sans-serif",fontName:"SansSerif-Regular"},mathtt:{variant:"monospace",fontName:"Typewriter-Regular"}},Ir={vec:["vec",.471,.714],oiintSize1:["oiintSize1",.957,.499],oiintSize2:["oiintSize2",1.472,.659],oiiintSize1:["oiiintSize1",1.304,.499],oiiintSize2:["oiiintSize2",1.98,.659]},y1=function(e,t){var[a,n,s]=Ir[e],l=new A0(a),h=new S0([l],{width:T(n),height:T(s),style:"width:"+T(n),viewBox:"0 0 "+1e3*n+" "+1e3*s,preserveAspectRatio:"xMinYMin"}),c=_r(["overlay"],[h],t);return c.height=s,c.style.height=T(s),c.style.width=T(n),c},y={fontMap:Or,makeSymbol:w0,mathsym:l1,makeSpan:h0,makeSvgSpan:_r,makeLineSpan:d1,makeAnchor:p1,makeFragment:Nr,wrapFragment:f1,makeVList:g1,makeOrd:h1,makeGlue:b1,staticSvg:y1,svgData:Ir,tryCombineChars:m1},J={number:3,unit:"mu"},W0={number:4,unit:"mu"},_0={number:5,unit:"mu"},x1={mord:{mop:J,mbin:W0,mrel:_0,minner:J},mop:{mord:J,mop:J,mrel:_0,minner:J},mbin:{mord:W0,mop:W0,mopen:W0,minner:W0},mrel:{mord:_0,mop:_0,mopen:_0,minner:_0},mopen:{},mclose:{mop:J,mbin:W0,mrel:_0,minner:J},mpunct:{mord:J,mop:J,mrel:_0,mopen:J,mclose:J,mpunct:J,minner:J},minner:{mord:J,mop:J,mbin:W0,mrel:_0,mopen:J,mpunct:J,minner:J}},w1={mord:{mop:J},mop:{mord:J,mop:J},mbin:{},mrel:{},mopen:{},mclose:{mop:J},mpunct:{},minner:{mop:J}},Er={},Le={},Fe={};function B(r){for(var{type:e,names:t,props:a,handler:n,htmlBuilder:s,mathmlBuilder:l}=r,h={type:e,numArgs:a.numArgs,argTypes:a.argTypes,allowedInArgument:!!a.allowedInArgument,allowedInText:!!a.allowedInText,allowedInMath:a.allowedInMath===void 0?!0:a.allowedInMath,numOptionalArgs:a.numOptionalArgs||0,infix:!!a.infix,primitive:!!a.primitive,handler:n},c=0;c{var _=q.classes[0],D=A.classes[0];_==="mbin"&&O.contains(k1,D)?q.classes[0]="mord":D==="mbin"&&O.contains(S1,_)&&(A.classes[0]="mord")},{node:b},x,w),rr(s,(A,q)=>{var _=bt(q),D=bt(A),N=_&&D?A.hasClass("mtight")?w1[_][D]:x1[_][D]:null;if(N)return y.makeGlue(N,f)},{node:b},x,w),s},rr=function r(e,t,a,n,s){n&&e.push(n);for(var l=0;lx=>{e.splice(b+1,0,x),l++})(l)}n&&e.pop()},Rr=function(e){return e instanceof Y0||e instanceof fe||e instanceof Z0&&e.hasClass("enclosing")?e:null},A1=function r(e,t){var a=Rr(e);if(a){var n=a.children;if(n.length){if(t==="right")return r(n[n.length-1],"right");if(t==="left")return r(n[0],"left")}}return e},bt=function(e,t){return e?(t&&(e=A1(e,t)),z1[e.classes[0]]||null):null},ge=function(e,t){var a=["nulldelimiter"].concat(e.baseSizingClasses());return I0(t.concat(a))},G=function(e,t,a){if(!e)return I0();if(Le[e.type]){var n=Le[e.type](e,t);if(a&&t.size!==a.size){n=I0(t.sizingClasses(a),[n],t);var s=t.sizeMultiplier/a.sizeMultiplier;n.height*=s,n.depth*=s}return n}else throw new z("Got group of unknown type: '"+e.type+"'")};function De(r,e){var t=I0(["base"],r,e),a=I0(["strut"]);return a.style.height=T(t.height+t.depth),t.depth&&(a.style.verticalAlign=T(-t.depth)),t.children.unshift(a),t}function yt(r,e){var t=null;r.length===1&&r[0].type==="tag"&&(t=r[0].tag,r=r[0].body);var a=a0(r,e,"root"),n;a.length===2&&a[1].hasClass("tag")&&(n=a.pop());for(var s=[],l=[],h=0;h0&&(s.push(De(l,e)),l=[]),s.push(a[h]));l.length>0&&s.push(De(l,e));var f;t?(f=De(a0(t,e,!0)),f.classes=["tag"],s.push(f)):n&&s.push(n);var v=I0(["katex-html"],s);if(v.setAttribute("aria-hidden","true"),f){var b=f.children[0];b.style.height=T(v.height+v.depth),v.depth&&(b.style.verticalAlign=T(-v.depth))}return v}function $r(r){return new Y0(r)}var s0=class{constructor(e,t,a){this.type=void 0,this.attributes=void 0,this.children=void 0,this.classes=void 0,this.type=e,this.attributes={},this.children=t||[],this.classes=a||[]}setAttribute(e,t){this.attributes[e]=t}getAttribute(e){return this.attributes[e]}toNode(){var e=document.createElementNS("http://www.w3.org/1998/Math/MathML",this.type);for(var t in this.attributes)Object.prototype.hasOwnProperty.call(this.attributes,t)&&e.setAttribute(t,this.attributes[t]);this.classes.length>0&&(e.className=G0(this.classes));for(var a=0;a0&&(e+=' class ="'+O.escape(G0(this.classes))+'"'),e+=">";for(var a=0;a",e}toText(){return this.children.map(e=>e.toText()).join("")}},g0=class{constructor(e){this.text=void 0,this.text=e}toNode(){return document.createTextNode(this.text)}toMarkup(){return O.escape(this.toText())}toText(){return this.text}},xt=class{constructor(e){this.width=void 0,this.character=void 0,this.width=e,e>=.05555&&e<=.05556?this.character="\u200A":e>=.1666&&e<=.1667?this.character="\u2009":e>=.2222&&e<=.2223?this.character="\u2005":e>=.2777&&e<=.2778?this.character="\u2005\u200A":e>=-.05556&&e<=-.05555?this.character="\u200A\u2063":e>=-.1667&&e<=-.1666?this.character="\u2009\u2063":e>=-.2223&&e<=-.2222?this.character="\u205F\u2063":e>=-.2778&&e<=-.2777?this.character="\u2005\u2063":this.character=null}toNode(){if(this.character)return document.createTextNode(this.character);var e=document.createElementNS("http://www.w3.org/1998/Math/MathML","mspace");return e.setAttribute("width",T(this.width)),e}toMarkup(){return this.character?""+this.character+"":''}toText(){return this.character?this.character:" "}},M={MathNode:s0,TextNode:g0,SpaceNode:xt,newDocumentFragment:$r},y0=function(e,t,a){return Y[t][e]&&Y[t][e].replace&&e.charCodeAt(0)!==55349&&!(Cr.hasOwnProperty(e)&&a&&(a.fontFamily&&a.fontFamily.slice(4,6)==="tt"||a.font&&a.font.slice(4,6)==="tt"))&&(e=Y[t][e].replace),new M.TextNode(e)},Bt=function(e){return e.length===1?e[0]:new M.MathNode("mrow",e)},Dt=function(e,t){if(t.fontFamily==="texttt")return"monospace";if(t.fontFamily==="textsf")return t.fontShape==="textit"&&t.fontWeight==="textbf"?"sans-serif-bold-italic":t.fontShape==="textit"?"sans-serif-italic":t.fontWeight==="textbf"?"bold-sans-serif":"sans-serif";if(t.fontShape==="textit"&&t.fontWeight==="textbf")return"bold-italic";if(t.fontShape==="textit")return"italic";if(t.fontWeight==="textbf")return"bold";var a=t.font;if(!a||a==="mathnormal")return null;var n=e.mode;if(a==="mathit")return"italic";if(a==="boldsymbol")return e.type==="textord"?"bold":"bold-italic";if(a==="mathbf")return"bold";if(a==="mathbb")return"double-struck";if(a==="mathsfit")return"sans-serif-italic";if(a==="mathfrak")return"fraktur";if(a==="mathscr"||a==="mathcal")return"script";if(a==="mathsf")return"sans-serif";if(a==="mathtt")return"monospace";var s=e.text;if(O.contains(["\\imath","\\jmath"],s))return null;Y[n][s]&&Y[n][s].replace&&(s=Y[n][s].replace);var l=y.fontMap[a].fontName;return Tt(s,l,n)?y.fontMap[a].variant:null};function nt(r){if(!r)return!1;if(r.type==="mi"&&r.children.length===1){var e=r.children[0];return e instanceof g0&&e.text==="."}else if(r.type==="mo"&&r.children.length===1&&r.getAttribute("separator")==="true"&&r.getAttribute("lspace")==="0em"&&r.getAttribute("rspace")==="0em"){var t=r.children[0];return t instanceof g0&&t.text===","}else return!1}var m0=function(e,t,a){if(e.length===1){var n=X(e[0],t);return a&&n instanceof s0&&n.type==="mo"&&(n.setAttribute("lspace","0em"),n.setAttribute("rspace","0em")),[n]}for(var s=[],l,h=0;h=1&&(l.type==="mn"||nt(l))){var f=c.children[0];f instanceof s0&&f.type==="mn"&&(f.children=[...l.children,...f.children],s.pop())}else if(l.type==="mi"&&l.children.length===1){var v=l.children[0];if(v instanceof g0&&v.text==="\u0338"&&(c.type==="mo"||c.type==="mi"||c.type==="mn")){var b=c.children[0];b instanceof g0&&b.text.length>0&&(b.text=b.text.slice(0,1)+"\u0338"+b.text.slice(1),s.pop())}}}s.push(c),l=c}return s},V0=function(e,t,a){return Bt(m0(e,t,a))},X=function(e,t){if(!e)return new M.MathNode("mrow");if(Fe[e.type]){var a=Fe[e.type](e,t);return a}else throw new z("Got group of unknown type: '"+e.type+"'")};function ar(r,e,t,a,n){var s=m0(r,t),l;s.length===1&&s[0]instanceof s0&&O.contains(["mrow","mtable"],s[0].type)?l=s[0]:l=new M.MathNode("mrow",s);var h=new M.MathNode("annotation",[new M.TextNode(e)]);h.setAttribute("encoding","application/x-tex");var c=new M.MathNode("semantics",[l,h]),f=new M.MathNode("math",[c]);f.setAttribute("xmlns","http://www.w3.org/1998/Math/MathML"),a&&f.setAttribute("display","block");var v=n?"katex":"katex-mathml";return y.makeSpan([v],[f])}var Lr=function(e){return new Re({style:e.displayMode?E.DISPLAY:E.TEXT,maxSize:e.maxSize,minRuleThickness:e.minRuleThickness})},Fr=function(e,t){if(t.displayMode){var a=["katex-display"];t.leqno&&a.push("leqno"),t.fleqn&&a.push("fleqn"),e=y.makeSpan(a,[e])}return e},T1=function(e,t,a){var n=Lr(a),s;if(a.output==="mathml")return ar(e,t,n,a.displayMode,!0);if(a.output==="html"){var l=yt(e,n);s=y.makeSpan(["katex"],[l])}else{var h=ar(e,t,n,a.displayMode,!1),c=yt(e,n);s=y.makeSpan(["katex"],[h,c])}return Fr(s,a)},q1=function(e,t,a){var n=Lr(a),s=yt(e,n),l=y.makeSpan(["katex"],[s]);return Fr(l,a)},B1={widehat:"^",widecheck:"\u02C7",widetilde:"~",utilde:"~",overleftarrow:"\u2190",underleftarrow:"\u2190",xleftarrow:"\u2190",overrightarrow:"\u2192",underrightarrow:"\u2192",xrightarrow:"\u2192",underbrace:"\u23DF",overbrace:"\u23DE",overgroup:"\u23E0",undergroup:"\u23E1",overleftrightarrow:"\u2194",underleftrightarrow:"\u2194",xleftrightarrow:"\u2194",Overrightarrow:"\u21D2",xRightarrow:"\u21D2",overleftharpoon:"\u21BC",xleftharpoonup:"\u21BC",overrightharpoon:"\u21C0",xrightharpoonup:"\u21C0",xLeftarrow:"\u21D0",xLeftrightarrow:"\u21D4",xhookleftarrow:"\u21A9",xhookrightarrow:"\u21AA",xmapsto:"\u21A6",xrightharpoondown:"\u21C1",xleftharpoondown:"\u21BD",xrightleftharpoons:"\u21CC",xleftrightharpoons:"\u21CB",xtwoheadleftarrow:"\u219E",xtwoheadrightarrow:"\u21A0",xlongequal:"=",xtofrom:"\u21C4",xrightleftarrows:"\u21C4",xrightequilibrium:"\u21CC",xleftequilibrium:"\u21CB","\\cdrightarrow":"\u2192","\\cdleftarrow":"\u2190","\\cdlongequal":"="},D1=function(e){var t=new M.MathNode("mo",[new M.TextNode(B1[e.replace(/^\\/,"")])]);return t.setAttribute("stretchy","true"),t},C1={overrightarrow:[["rightarrow"],.888,522,"xMaxYMin"],overleftarrow:[["leftarrow"],.888,522,"xMinYMin"],underrightarrow:[["rightarrow"],.888,522,"xMaxYMin"],underleftarrow:[["leftarrow"],.888,522,"xMinYMin"],xrightarrow:[["rightarrow"],1.469,522,"xMaxYMin"],"\\cdrightarrow":[["rightarrow"],3,522,"xMaxYMin"],xleftarrow:[["leftarrow"],1.469,522,"xMinYMin"],"\\cdleftarrow":[["leftarrow"],3,522,"xMinYMin"],Overrightarrow:[["doublerightarrow"],.888,560,"xMaxYMin"],xRightarrow:[["doublerightarrow"],1.526,560,"xMaxYMin"],xLeftarrow:[["doubleleftarrow"],1.526,560,"xMinYMin"],overleftharpoon:[["leftharpoon"],.888,522,"xMinYMin"],xleftharpoonup:[["leftharpoon"],.888,522,"xMinYMin"],xleftharpoondown:[["leftharpoondown"],.888,522,"xMinYMin"],overrightharpoon:[["rightharpoon"],.888,522,"xMaxYMin"],xrightharpoonup:[["rightharpoon"],.888,522,"xMaxYMin"],xrightharpoondown:[["rightharpoondown"],.888,522,"xMaxYMin"],xlongequal:[["longequal"],.888,334,"xMinYMin"],"\\cdlongequal":[["longequal"],3,334,"xMinYMin"],xtwoheadleftarrow:[["twoheadleftarrow"],.888,334,"xMinYMin"],xtwoheadrightarrow:[["twoheadrightarrow"],.888,334,"xMaxYMin"],overleftrightarrow:[["leftarrow","rightarrow"],.888,522],overbrace:[["leftbrace","midbrace","rightbrace"],1.6,548],underbrace:[["leftbraceunder","midbraceunder","rightbraceunder"],1.6,548],underleftrightarrow:[["leftarrow","rightarrow"],.888,522],xleftrightarrow:[["leftarrow","rightarrow"],1.75,522],xLeftrightarrow:[["doubleleftarrow","doublerightarrow"],1.75,560],xrightleftharpoons:[["leftharpoondownplus","rightharpoonplus"],1.75,716],xleftrightharpoons:[["leftharpoonplus","rightharpoondownplus"],1.75,716],xhookleftarrow:[["leftarrow","righthook"],1.08,522],xhookrightarrow:[["lefthook","rightarrow"],1.08,522],overlinesegment:[["leftlinesegment","rightlinesegment"],.888,522],underlinesegment:[["leftlinesegment","rightlinesegment"],.888,522],overgroup:[["leftgroup","rightgroup"],.888,342],undergroup:[["leftgroupunder","rightgroupunder"],.888,342],xmapsto:[["leftmapsto","rightarrow"],1.5,522],xtofrom:[["leftToFrom","rightToFrom"],1.75,528],xrightleftarrows:[["baraboveleftarrow","rightarrowabovebar"],1.75,901],xrightequilibrium:[["baraboveshortleftharpoon","rightharpoonaboveshortbar"],1.75,716],xleftequilibrium:[["shortbaraboveleftharpoon","shortrightharpoonabovebar"],1.75,716]},_1=function(e){return e.type==="ordgroup"?e.body.length:1},N1=function(e,t){function a(){var h=4e5,c=e.label.slice(1);if(O.contains(["widehat","widecheck","widetilde","utilde"],c)){var f=e,v=_1(f.base),b,x,w;if(v>5)c==="widehat"||c==="widecheck"?(b=420,h=2364,w=.42,x=c+"4"):(b=312,h=2340,w=.34,x="tilde4");else{var A=[1,1,2,2,3,3][v];c==="widehat"||c==="widecheck"?(h=[0,1062,2364,2364,2364][A],b=[0,239,300,360,420][A],w=[0,.24,.3,.3,.36,.42][A],x=c+A):(h=[0,600,1033,2339,2340][A],b=[0,260,286,306,312][A],w=[0,.26,.286,.3,.306,.34][A],x="tilde"+A)}var q=new A0(x),_=new S0([q],{width:"100%",height:T(w),viewBox:"0 0 "+h+" "+b,preserveAspectRatio:"none"});return{span:y.makeSvgSpan([],[_],t),minWidth:0,height:w}}else{var D=[],N=C1[c],[$,H,F]=N,P=F/1e3,V=$.length,j,U;if(V===1){var D0=N[3];j=["hide-tail"],U=[D0]}else if(V===2)j=["halfarrow-left","halfarrow-right"],U=["xMinYMin","xMaxYMin"];else if(V===3)j=["brace-left","brace-center","brace-right"],U=["xMinYMin","xMidYMin","xMaxYMin"];else throw new Error(`Correct katexImagesData or update code here to support + `+V+" children.");for(var i0=0;i00&&(n.style.minWidth=T(s)),n},O1=function(e,t,a,n,s){var l,h=e.height+e.depth+a+n;if(/fbox|color|angl/.test(t)){if(l=y.makeSpan(["stretchy",t],[],s),t==="fbox"){var c=s.color&&s.getColor();c&&(l.style.borderColor=c)}}else{var f=[];/^[bx]cancel$/.test(t)&&f.push(new ve({x1:"0",y1:"0",x2:"100%",y2:"100%","stroke-width":"0.046em"})),/^x?cancel$/.test(t)&&f.push(new ve({x1:"0",y1:"100%",x2:"100%",y2:"0","stroke-width":"0.046em"}));var v=new S0(f,{width:"100%",height:T(h)});l=y.makeSvgSpan([],[v],s)}return l.height=h,l.style.height=T(h),l},E0={encloseSpan:O1,mathMLnode:D1,svgSpan:N1};function L(r,e){if(!r||r.type!==e)throw new Error("Expected node of type "+e+", but got "+(r?"node of type "+r.type:String(r)));return r}function Ct(r){var e=Xe(r);if(!e)throw new Error("Expected node of symbol group type, but got "+(r?"node of type "+r.type:String(r)));return e}function Xe(r){return r&&(r.type==="atom"||s1.hasOwnProperty(r.type))?r:null}var _t=(r,e)=>{var t,a,n;r&&r.type==="supsub"?(a=L(r.base,"accent"),t=a.base,r.base=t,n=n1(G(r,e)),r.base=a):(a=L(r,"accent"),t=a.base);var s=G(t,e.havingCrampedStyle()),l=a.isShifty&&O.isCharacterBox(t),h=0;if(l){var c=O.getBaseElem(t),f=G(c,e.havingCrampedStyle());h=Jt(f).skew}var v=a.label==="\\c",b=v?s.height+s.depth:Math.min(s.height,e.fontMetrics().xHeight),x;if(a.isStretchy)x=E0.svgSpan(a,e),x=y.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:s},{type:"elem",elem:x,wrapperClasses:["svg-align"],wrapperStyle:h>0?{width:"calc(100% - "+T(2*h)+")",marginLeft:T(2*h)}:void 0}]},e);else{var w,A;a.label==="\\vec"?(w=y.staticSvg("vec",e),A=y.svgData.vec[1]):(w=y.makeOrd({mode:a.mode,text:a.label},e,"textord"),w=Jt(w),w.italic=0,A=w.width,v&&(b+=w.depth)),x=y.makeSpan(["accent-body"],[w]);var q=a.label==="\\textcircled";q&&(x.classes.push("accent-full"),b=s.height);var _=h;q||(_-=A/2),x.style.left=T(_),a.label==="\\textcircled"&&(x.style.top=".2em"),x=y.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:s},{type:"kern",size:-b},{type:"elem",elem:x}]},e)}var D=y.makeSpan(["mord","accent"],[x],e);return n?(n.children[0]=D,n.height=Math.max(D.height,n.height),n.classes[0]="mord",n):D},Hr=(r,e)=>{var t=r.isStretchy?E0.mathMLnode(r.label):new M.MathNode("mo",[y0(r.label,r.mode)]),a=new M.MathNode("mover",[X(r.base,e),t]);return a.setAttribute("accent","true"),a},I1=new RegExp(["\\acute","\\grave","\\ddot","\\tilde","\\bar","\\breve","\\check","\\hat","\\vec","\\dot","\\mathring"].map(r=>"\\"+r).join("|"));B({type:"accent",names:["\\acute","\\grave","\\ddot","\\tilde","\\bar","\\breve","\\check","\\hat","\\vec","\\dot","\\mathring","\\widecheck","\\widehat","\\widetilde","\\overrightarrow","\\overleftarrow","\\Overrightarrow","\\overleftrightarrow","\\overgroup","\\overlinesegment","\\overleftharpoon","\\overrightharpoon"],props:{numArgs:1},handler:(r,e)=>{var t=He(e[0]),a=!I1.test(r.funcName),n=!a||r.funcName==="\\widehat"||r.funcName==="\\widetilde"||r.funcName==="\\widecheck";return{type:"accent",mode:r.parser.mode,label:r.funcName,isStretchy:a,isShifty:n,base:t}},htmlBuilder:_t,mathmlBuilder:Hr});B({type:"accent",names:["\\'","\\`","\\^","\\~","\\=","\\u","\\.",'\\"',"\\c","\\r","\\H","\\v","\\textcircled"],props:{numArgs:1,allowedInText:!0,allowedInMath:!0,argTypes:["primitive"]},handler:(r,e)=>{var t=e[0],a=r.parser.mode;return a==="math"&&(r.parser.settings.reportNonstrict("mathVsTextAccents","LaTeX's accent "+r.funcName+" works only in text mode"),a="text"),{type:"accent",mode:a,label:r.funcName,isStretchy:!1,isShifty:!0,base:t}},htmlBuilder:_t,mathmlBuilder:Hr});B({type:"accentUnder",names:["\\underleftarrow","\\underrightarrow","\\underleftrightarrow","\\undergroup","\\underlinesegment","\\utilde"],props:{numArgs:1},handler:(r,e)=>{var{parser:t,funcName:a}=r,n=e[0];return{type:"accentUnder",mode:t.mode,label:a,base:n}},htmlBuilder:(r,e)=>{var t=G(r.base,e),a=E0.svgSpan(r,e),n=r.label==="\\utilde"?.12:0,s=y.makeVList({positionType:"top",positionData:t.height,children:[{type:"elem",elem:a,wrapperClasses:["svg-align"]},{type:"kern",size:n},{type:"elem",elem:t}]},e);return y.makeSpan(["mord","accentunder"],[s],e)},mathmlBuilder:(r,e)=>{var t=E0.mathMLnode(r.label),a=new M.MathNode("munder",[X(r.base,e),t]);return a.setAttribute("accentunder","true"),a}});var Ce=r=>{var e=new M.MathNode("mpadded",r?[r]:[]);return e.setAttribute("width","+0.6em"),e.setAttribute("lspace","0.3em"),e};B({type:"xArrow",names:["\\xleftarrow","\\xrightarrow","\\xLeftarrow","\\xRightarrow","\\xleftrightarrow","\\xLeftrightarrow","\\xhookleftarrow","\\xhookrightarrow","\\xmapsto","\\xrightharpoondown","\\xrightharpoonup","\\xleftharpoondown","\\xleftharpoonup","\\xrightleftharpoons","\\xleftrightharpoons","\\xlongequal","\\xtwoheadrightarrow","\\xtwoheadleftarrow","\\xtofrom","\\xrightleftarrows","\\xrightequilibrium","\\xleftequilibrium","\\\\cdrightarrow","\\\\cdleftarrow","\\\\cdlongequal"],props:{numArgs:1,numOptionalArgs:1},handler(r,e,t){var{parser:a,funcName:n}=r;return{type:"xArrow",mode:a.mode,label:n,body:e[0],below:t[0]}},htmlBuilder(r,e){var t=e.style,a=e.havingStyle(t.sup()),n=y.wrapFragment(G(r.body,a,e),e),s=r.label.slice(0,2)==="\\x"?"x":"cd";n.classes.push(s+"-arrow-pad");var l;r.below&&(a=e.havingStyle(t.sub()),l=y.wrapFragment(G(r.below,a,e),e),l.classes.push(s+"-arrow-pad"));var h=E0.svgSpan(r,e),c=-e.fontMetrics().axisHeight+.5*h.height,f=-e.fontMetrics().axisHeight-.5*h.height-.111;(n.depth>.25||r.label==="\\xleftequilibrium")&&(f-=n.depth);var v;if(l){var b=-e.fontMetrics().axisHeight+l.height+.5*h.height+.111;v=y.makeVList({positionType:"individualShift",children:[{type:"elem",elem:n,shift:f},{type:"elem",elem:h,shift:c},{type:"elem",elem:l,shift:b}]},e)}else v=y.makeVList({positionType:"individualShift",children:[{type:"elem",elem:n,shift:f},{type:"elem",elem:h,shift:c}]},e);return v.children[0].children[0].children[1].classes.push("svg-align"),y.makeSpan(["mrel","x-arrow"],[v],e)},mathmlBuilder(r,e){var t=E0.mathMLnode(r.label);t.setAttribute("minsize",r.label.charAt(0)==="x"?"1.75em":"3.0em");var a;if(r.body){var n=Ce(X(r.body,e));if(r.below){var s=Ce(X(r.below,e));a=new M.MathNode("munderover",[t,s,n])}else a=new M.MathNode("mover",[t,n])}else if(r.below){var l=Ce(X(r.below,e));a=new M.MathNode("munder",[t,l])}else a=Ce(),a=new M.MathNode("mover",[t,a]);return a}});var E1=y.makeSpan;function Pr(r,e){var t=a0(r.body,e,!0);return E1([r.mclass],t,e)}function Gr(r,e){var t,a=m0(r.body,e);return r.mclass==="minner"?t=new M.MathNode("mpadded",a):r.mclass==="mord"?r.isCharacterBox?(t=a[0],t.type="mi"):t=new M.MathNode("mi",a):(r.isCharacterBox?(t=a[0],t.type="mo"):t=new M.MathNode("mo",a),r.mclass==="mbin"?(t.attributes.lspace="0.22em",t.attributes.rspace="0.22em"):r.mclass==="mpunct"?(t.attributes.lspace="0em",t.attributes.rspace="0.17em"):r.mclass==="mopen"||r.mclass==="mclose"?(t.attributes.lspace="0em",t.attributes.rspace="0em"):r.mclass==="minner"&&(t.attributes.lspace="0.0556em",t.attributes.width="+0.1111em")),t}B({type:"mclass",names:["\\mathord","\\mathbin","\\mathrel","\\mathopen","\\mathclose","\\mathpunct","\\mathinner"],props:{numArgs:1,primitive:!0},handler(r,e){var{parser:t,funcName:a}=r,n=e[0];return{type:"mclass",mode:t.mode,mclass:"m"+a.slice(5),body:e0(n),isCharacterBox:O.isCharacterBox(n)}},htmlBuilder:Pr,mathmlBuilder:Gr});var We=r=>{var e=r.type==="ordgroup"&&r.body.length?r.body[0]:r;return e.type==="atom"&&(e.family==="bin"||e.family==="rel")?"m"+e.family:"mord"};B({type:"mclass",names:["\\@binrel"],props:{numArgs:2},handler(r,e){var{parser:t}=r;return{type:"mclass",mode:t.mode,mclass:We(e[0]),body:e0(e[1]),isCharacterBox:O.isCharacterBox(e[1])}}});B({type:"mclass",names:["\\stackrel","\\overset","\\underset"],props:{numArgs:2},handler(r,e){var{parser:t,funcName:a}=r,n=e[1],s=e[0],l;a!=="\\stackrel"?l=We(n):l="mrel";var h={type:"op",mode:n.mode,limits:!0,alwaysHandleSupSub:!0,parentIsSupSub:!1,symbol:!1,suppressBaseShift:a!=="\\stackrel",body:e0(n)},c={type:"supsub",mode:s.mode,base:h,sup:a==="\\underset"?null:s,sub:a==="\\underset"?s:null};return{type:"mclass",mode:t.mode,mclass:l,body:[c],isCharacterBox:O.isCharacterBox(c)}},htmlBuilder:Pr,mathmlBuilder:Gr});B({type:"pmb",names:["\\pmb"],props:{numArgs:1,allowedInText:!0},handler(r,e){var{parser:t}=r;return{type:"pmb",mode:t.mode,mclass:We(e[0]),body:e0(e[0])}},htmlBuilder(r,e){var t=a0(r.body,e,!0),a=y.makeSpan([r.mclass],t,e);return a.style.textShadow="0.02em 0.01em 0.04px",a},mathmlBuilder(r,e){var t=m0(r.body,e),a=new M.MathNode("mstyle",t);return a.setAttribute("style","text-shadow: 0.02em 0.01em 0.04px"),a}});var R1={">":"\\\\cdrightarrow","<":"\\\\cdleftarrow","=":"\\\\cdlongequal",A:"\\uparrow",V:"\\downarrow","|":"\\Vert",".":"no arrow"},nr=()=>({type:"styling",body:[],mode:"math",style:"display"}),ir=r=>r.type==="textord"&&r.text==="@",$1=(r,e)=>(r.type==="mathord"||r.type==="atom")&&r.text===e;function L1(r,e,t){var a=R1[r];switch(a){case"\\\\cdrightarrow":case"\\\\cdleftarrow":return t.callFunction(a,[e[0]],[e[1]]);case"\\uparrow":case"\\downarrow":{var n=t.callFunction("\\\\cdleft",[e[0]],[]),s={type:"atom",text:a,mode:"math",family:"rel"},l=t.callFunction("\\Big",[s],[]),h=t.callFunction("\\\\cdright",[e[1]],[]),c={type:"ordgroup",mode:"math",body:[n,l,h]};return t.callFunction("\\\\cdparent",[c],[])}case"\\\\cdlongequal":return t.callFunction("\\\\cdlongequal",[],[]);case"\\Vert":{var f={type:"textord",text:"\\Vert",mode:"math"};return t.callFunction("\\Big",[f],[])}default:return{type:"textord",text:" ",mode:"math"}}}function F1(r){var e=[];for(r.gullet.beginGroup(),r.gullet.macros.set("\\cr","\\\\\\relax"),r.gullet.beginGroup();;){e.push(r.parseExpression(!1,"\\\\")),r.gullet.endGroup(),r.gullet.beginGroup();var t=r.fetch().text;if(t==="&"||t==="\\\\")r.consume();else if(t==="\\end"){e[e.length-1].length===0&&e.pop();break}else throw new z("Expected \\\\ or \\cr or \\end",r.nextToken)}for(var a=[],n=[a],s=0;s-1))if("<>AV".indexOf(f)>-1)for(var b=0;b<2;b++){for(var x=!0,w=c+1;wAV=|." after @',l[c]);var A=L1(f,v,r),q={type:"styling",body:[A],mode:"math",style:"display"};a.push(q),h=nr()}s%2===0?a.push(h):a.shift(),a=[],n.push(a)}r.gullet.endGroup(),r.gullet.endGroup();var _=new Array(n[0].length).fill({type:"align",align:"c",pregap:.25,postgap:.25});return{type:"array",mode:"math",body:n,arraystretch:1,addJot:!0,rowGaps:[null],cols:_,colSeparationType:"CD",hLinesBeforeRow:new Array(n.length+1).fill([])}}B({type:"cdlabel",names:["\\\\cdleft","\\\\cdright"],props:{numArgs:1},handler(r,e){var{parser:t,funcName:a}=r;return{type:"cdlabel",mode:t.mode,side:a.slice(4),label:e[0]}},htmlBuilder(r,e){var t=e.havingStyle(e.style.sup()),a=y.wrapFragment(G(r.label,t,e),e);return a.classes.push("cd-label-"+r.side),a.style.bottom=T(.8-a.depth),a.height=0,a.depth=0,a},mathmlBuilder(r,e){var t=new M.MathNode("mrow",[X(r.label,e)]);return t=new M.MathNode("mpadded",[t]),t.setAttribute("width","0"),r.side==="left"&&t.setAttribute("lspace","-1width"),t.setAttribute("voffset","0.7em"),t=new M.MathNode("mstyle",[t]),t.setAttribute("displaystyle","false"),t.setAttribute("scriptlevel","1"),t}});B({type:"cdlabelparent",names:["\\\\cdparent"],props:{numArgs:1},handler(r,e){var{parser:t}=r;return{type:"cdlabelparent",mode:t.mode,fragment:e[0]}},htmlBuilder(r,e){var t=y.wrapFragment(G(r.fragment,e),e);return t.classes.push("cd-vert-arrow"),t},mathmlBuilder(r,e){return new M.MathNode("mrow",[X(r.fragment,e)])}});B({type:"textord",names:["\\@char"],props:{numArgs:1,allowedInText:!0},handler(r,e){for(var{parser:t}=r,a=L(e[0],"ordgroup"),n=a.body,s="",l=0;l=1114111)throw new z("\\@char with invalid code point "+s);return c<=65535?f=String.fromCharCode(c):(c-=65536,f=String.fromCharCode((c>>10)+55296,(c&1023)+56320)),{type:"textord",mode:t.mode,text:f}}});var Vr=(r,e)=>{var t=a0(r.body,e.withColor(r.color),!1);return y.makeFragment(t)},Ur=(r,e)=>{var t=m0(r.body,e.withColor(r.color)),a=new M.MathNode("mstyle",t);return a.setAttribute("mathcolor",r.color),a};B({type:"color",names:["\\textcolor"],props:{numArgs:2,allowedInText:!0,argTypes:["color","original"]},handler(r,e){var{parser:t}=r,a=L(e[0],"color-token").color,n=e[1];return{type:"color",mode:t.mode,color:a,body:e0(n)}},htmlBuilder:Vr,mathmlBuilder:Ur});B({type:"color",names:["\\color"],props:{numArgs:1,allowedInText:!0,argTypes:["color"]},handler(r,e){var{parser:t,breakOnTokenText:a}=r,n=L(e[0],"color-token").color;t.gullet.macros.set("\\current@color",n);var s=t.parseExpression(!0,a);return{type:"color",mode:t.mode,color:n,body:s}},htmlBuilder:Vr,mathmlBuilder:Ur});B({type:"cr",names:["\\\\"],props:{numArgs:0,numOptionalArgs:0,allowedInText:!0},handler(r,e,t){var{parser:a}=r,n=a.gullet.future().text==="["?a.parseSizeGroup(!0):null,s=!a.settings.displayMode||!a.settings.useStrictBehavior("newLineInDisplayMode","In LaTeX, \\\\ or \\newline does nothing in display mode");return{type:"cr",mode:a.mode,newLine:s,size:n&&L(n,"size").value}},htmlBuilder(r,e){var t=y.makeSpan(["mspace"],[],e);return r.newLine&&(t.classes.push("newline"),r.size&&(t.style.marginTop=T(Q(r.size,e)))),t},mathmlBuilder(r,e){var t=new M.MathNode("mspace");return r.newLine&&(t.setAttribute("linebreak","newline"),r.size&&t.setAttribute("height",T(Q(r.size,e)))),t}});var wt={"\\global":"\\global","\\long":"\\\\globallong","\\\\globallong":"\\\\globallong","\\def":"\\gdef","\\gdef":"\\gdef","\\edef":"\\xdef","\\xdef":"\\xdef","\\let":"\\\\globallet","\\futurelet":"\\\\globalfuture"},Xr=r=>{var e=r.text;if(/^(?:[\\{}$&#^_]|EOF)$/.test(e))throw new z("Expected a control sequence",r);return e},H1=r=>{var e=r.gullet.popToken();return e.text==="="&&(e=r.gullet.popToken(),e.text===" "&&(e=r.gullet.popToken())),e},Wr=(r,e,t,a)=>{var n=r.gullet.macros.get(t.text);n==null&&(t.noexpand=!0,n={tokens:[t],numArgs:0,unexpandable:!r.gullet.isExpandable(t.text)}),r.gullet.macros.set(e,n,a)};B({type:"internal",names:["\\global","\\long","\\\\globallong"],props:{numArgs:0,allowedInText:!0},handler(r){var{parser:e,funcName:t}=r;e.consumeSpaces();var a=e.fetch();if(wt[a.text])return(t==="\\global"||t==="\\\\globallong")&&(a.text=wt[a.text]),L(e.parseFunction(),"internal");throw new z("Invalid token after macro prefix",a)}});B({type:"internal",names:["\\def","\\gdef","\\edef","\\xdef"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler(r){var{parser:e,funcName:t}=r,a=e.gullet.popToken(),n=a.text;if(/^(?:[\\{}$&#^_]|EOF)$/.test(n))throw new z("Expected a control sequence",a);for(var s=0,l,h=[[]];e.gullet.future().text!=="{";)if(a=e.gullet.popToken(),a.text==="#"){if(e.gullet.future().text==="{"){l=e.gullet.future(),h[s].push("{");break}if(a=e.gullet.popToken(),!/^[1-9]$/.test(a.text))throw new z('Invalid argument number "'+a.text+'"');if(parseInt(a.text)!==s+1)throw new z('Argument number "'+a.text+'" out of order');s++,h.push([])}else{if(a.text==="EOF")throw new z("Expected a macro definition");h[s].push(a.text)}var{tokens:c}=e.gullet.consumeArg();return l&&c.unshift(l),(t==="\\edef"||t==="\\xdef")&&(c=e.gullet.expandTokens(c),c.reverse()),e.gullet.macros.set(n,{tokens:c,numArgs:s,delimiters:h},t===wt[t]),{type:"internal",mode:e.mode}}});B({type:"internal",names:["\\let","\\\\globallet"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler(r){var{parser:e,funcName:t}=r,a=Xr(e.gullet.popToken());e.gullet.consumeSpaces();var n=H1(e);return Wr(e,a,n,t==="\\\\globallet"),{type:"internal",mode:e.mode}}});B({type:"internal",names:["\\futurelet","\\\\globalfuture"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler(r){var{parser:e,funcName:t}=r,a=Xr(e.gullet.popToken()),n=e.gullet.popToken(),s=e.gullet.popToken();return Wr(e,a,s,t==="\\\\globalfuture"),e.gullet.pushToken(s),e.gullet.pushToken(n),{type:"internal",mode:e.mode}}});var ce=function(e,t,a){var n=Y.math[e]&&Y.math[e].replace,s=Tt(n||e,t,a);if(!s)throw new Error("Unsupported symbol "+e+" and font size "+t+".");return s},Nt=function(e,t,a,n){var s=a.havingBaseStyle(t),l=y.makeSpan(n.concat(s.sizingClasses(a)),[e],a),h=s.sizeMultiplier/a.sizeMultiplier;return l.height*=h,l.depth*=h,l.maxFontSize=s.sizeMultiplier,l},Yr=function(e,t,a){var n=t.havingBaseStyle(a),s=(1-t.sizeMultiplier/n.sizeMultiplier)*t.fontMetrics().axisHeight;e.classes.push("delimcenter"),e.style.top=T(s),e.height-=s,e.depth+=s},P1=function(e,t,a,n,s,l){var h=y.makeSymbol(e,"Main-Regular",s,n),c=Nt(h,t,n,l);return a&&Yr(c,n,t),c},G1=function(e,t,a,n){return y.makeSymbol(e,"Size"+t+"-Regular",a,n)},Zr=function(e,t,a,n,s,l){var h=G1(e,t,s,n),c=Nt(y.makeSpan(["delimsizing","size"+t],[h],n),E.TEXT,n,l);return a&&Yr(c,n,E.TEXT),c},it=function(e,t,a){var n;t==="Size1-Regular"?n="delim-size1":n="delim-size4";var s=y.makeSpan(["delimsizinginner",n],[y.makeSpan([],[y.makeSymbol(e,t,a)])]);return{type:"elem",elem:s}},st=function(e,t,a){var n=z0["Size4-Regular"][e.charCodeAt(0)]?z0["Size4-Regular"][e.charCodeAt(0)][4]:z0["Size1-Regular"][e.charCodeAt(0)][4],s=new A0("inner",ja(e,Math.round(1e3*t))),l=new S0([s],{width:T(n),height:T(t),style:"width:"+T(n),viewBox:"0 0 "+1e3*n+" "+Math.round(1e3*t),preserveAspectRatio:"xMinYMin"}),h=y.makeSvgSpan([],[l],a);return h.height=t,h.style.height=T(t),h.style.width=T(n),{type:"elem",elem:h}},St=.008,_e={type:"kern",size:-1*St},V1=["|","\\lvert","\\rvert","\\vert"],U1=["\\|","\\lVert","\\rVert","\\Vert"],jr=function(e,t,a,n,s,l){var h,c,f,v,b="",x=0;h=f=v=e,c=null;var w="Size1-Regular";e==="\\uparrow"?f=v="\u23D0":e==="\\Uparrow"?f=v="\u2016":e==="\\downarrow"?h=f="\u23D0":e==="\\Downarrow"?h=f="\u2016":e==="\\updownarrow"?(h="\\uparrow",f="\u23D0",v="\\downarrow"):e==="\\Updownarrow"?(h="\\Uparrow",f="\u2016",v="\\Downarrow"):O.contains(V1,e)?(f="\u2223",b="vert",x=333):O.contains(U1,e)?(f="\u2225",b="doublevert",x=556):e==="["||e==="\\lbrack"?(h="\u23A1",f="\u23A2",v="\u23A3",w="Size4-Regular",b="lbrack",x=667):e==="]"||e==="\\rbrack"?(h="\u23A4",f="\u23A5",v="\u23A6",w="Size4-Regular",b="rbrack",x=667):e==="\\lfloor"||e==="\u230A"?(f=h="\u23A2",v="\u23A3",w="Size4-Regular",b="lfloor",x=667):e==="\\lceil"||e==="\u2308"?(h="\u23A1",f=v="\u23A2",w="Size4-Regular",b="lceil",x=667):e==="\\rfloor"||e==="\u230B"?(f=h="\u23A5",v="\u23A6",w="Size4-Regular",b="rfloor",x=667):e==="\\rceil"||e==="\u2309"?(h="\u23A4",f=v="\u23A5",w="Size4-Regular",b="rceil",x=667):e==="("||e==="\\lparen"?(h="\u239B",f="\u239C",v="\u239D",w="Size4-Regular",b="lparen",x=875):e===")"||e==="\\rparen"?(h="\u239E",f="\u239F",v="\u23A0",w="Size4-Regular",b="rparen",x=875):e==="\\{"||e==="\\lbrace"?(h="\u23A7",c="\u23A8",v="\u23A9",f="\u23AA",w="Size4-Regular"):e==="\\}"||e==="\\rbrace"?(h="\u23AB",c="\u23AC",v="\u23AD",f="\u23AA",w="Size4-Regular"):e==="\\lgroup"||e==="\u27EE"?(h="\u23A7",v="\u23A9",f="\u23AA",w="Size4-Regular"):e==="\\rgroup"||e==="\u27EF"?(h="\u23AB",v="\u23AD",f="\u23AA",w="Size4-Regular"):e==="\\lmoustache"||e==="\u23B0"?(h="\u23A7",v="\u23AD",f="\u23AA",w="Size4-Regular"):(e==="\\rmoustache"||e==="\u23B1")&&(h="\u23AB",v="\u23A9",f="\u23AA",w="Size4-Regular");var A=ce(h,w,s),q=A.height+A.depth,_=ce(f,w,s),D=_.height+_.depth,N=ce(v,w,s),$=N.height+N.depth,H=0,F=1;if(c!==null){var P=ce(c,w,s);H=P.height+P.depth,F=2}var V=q+$+H,j=Math.max(0,Math.ceil((t-V)/(F*D))),U=V+j*F*D,D0=n.fontMetrics().axisHeight;a&&(D0*=n.sizeMultiplier);var i0=U/2-D0,r0=[];if(b.length>0){var X0=U-q-$,u0=Math.round(U*1e3),x0=Ka(b,Math.round(X0*1e3)),$0=new A0(b,x0),K0=(x/1e3).toFixed(3)+"em",J0=(u0/1e3).toFixed(3)+"em",je=new S0([$0],{width:K0,height:J0,viewBox:"0 0 "+x+" "+u0}),L0=y.makeSvgSpan([],[je],n);L0.height=u0/1e3,L0.style.width=K0,L0.style.height=J0,r0.push({type:"elem",elem:L0})}else{if(r0.push(it(v,w,s)),r0.push(_e),c===null){var F0=U-q-$+2*St;r0.push(st(f,F0,n))}else{var f0=(U-q-$-H)/2+2*St;r0.push(st(f,f0,n)),r0.push(_e),r0.push(it(c,w,s)),r0.push(_e),r0.push(st(f,f0,n))}r0.push(_e),r0.push(it(h,w,s))}var le=n.havingBaseStyle(E.TEXT),Ke=y.makeVList({positionType:"bottom",positionData:i0,children:r0},le);return Nt(y.makeSpan(["delimsizing","mult"],[Ke],le),E.TEXT,n,l)},ot=80,lt=.08,ut=function(e,t,a,n,s){var l=Za(e,n,a),h=new A0(e,l),c=new S0([h],{width:"400em",height:T(t),viewBox:"0 0 400000 "+a,preserveAspectRatio:"xMinYMin slice"});return y.makeSvgSpan(["hide-tail"],[c],s)},X1=function(e,t){var a=t.havingBaseSizing(),n=ea("\\surd",e*a.sizeMultiplier,Qr,a),s=a.sizeMultiplier,l=Math.max(0,t.minRuleThickness-t.fontMetrics().sqrtRuleThickness),h,c=0,f=0,v=0,b;return n.type==="small"?(v=1e3+1e3*l+ot,e<1?s=1:e<1.4&&(s=.7),c=(1+l+lt)/s,f=(1+l)/s,h=ut("sqrtMain",c,v,l,t),h.style.minWidth="0.853em",b=.833/s):n.type==="large"?(v=(1e3+ot)*me[n.size],f=(me[n.size]+l)/s,c=(me[n.size]+l+lt)/s,h=ut("sqrtSize"+n.size,c,v,l,t),h.style.minWidth="1.02em",b=1/s):(c=e+l+lt,f=e+l,v=Math.floor(1e3*e+l)+ot,h=ut("sqrtTall",c,v,l,t),h.style.minWidth="0.742em",b=1.056),h.height=f,h.style.height=T(c),{span:h,advanceWidth:b,ruleWidth:(t.fontMetrics().sqrtRuleThickness+l)*s}},Kr=["(","\\lparen",")","\\rparen","[","\\lbrack","]","\\rbrack","\\{","\\lbrace","\\}","\\rbrace","\\lfloor","\\rfloor","\u230A","\u230B","\\lceil","\\rceil","\u2308","\u2309","\\surd"],W1=["\\uparrow","\\downarrow","\\updownarrow","\\Uparrow","\\Downarrow","\\Updownarrow","|","\\|","\\vert","\\Vert","\\lvert","\\rvert","\\lVert","\\rVert","\\lgroup","\\rgroup","\u27EE","\u27EF","\\lmoustache","\\rmoustache","\u23B0","\u23B1"],Jr=["<",">","\\langle","\\rangle","/","\\backslash","\\lt","\\gt"],me=[0,1.2,1.8,2.4,3],Y1=function(e,t,a,n,s){if(e==="<"||e==="\\lt"||e==="\u27E8"?e="\\langle":(e===">"||e==="\\gt"||e==="\u27E9")&&(e="\\rangle"),O.contains(Kr,e)||O.contains(Jr,e))return Zr(e,t,!1,a,n,s);if(O.contains(W1,e))return jr(e,me[t],!1,a,n,s);throw new z("Illegal delimiter: '"+e+"'")},Z1=[{type:"small",style:E.SCRIPTSCRIPT},{type:"small",style:E.SCRIPT},{type:"small",style:E.TEXT},{type:"large",size:1},{type:"large",size:2},{type:"large",size:3},{type:"large",size:4}],j1=[{type:"small",style:E.SCRIPTSCRIPT},{type:"small",style:E.SCRIPT},{type:"small",style:E.TEXT},{type:"stack"}],Qr=[{type:"small",style:E.SCRIPTSCRIPT},{type:"small",style:E.SCRIPT},{type:"small",style:E.TEXT},{type:"large",size:1},{type:"large",size:2},{type:"large",size:3},{type:"large",size:4},{type:"stack"}],K1=function(e){if(e.type==="small")return"Main-Regular";if(e.type==="large")return"Size"+e.size+"-Regular";if(e.type==="stack")return"Size4-Regular";throw new Error("Add support for delim type '"+e.type+"' here.")},ea=function(e,t,a,n){for(var s=Math.min(2,3-n.style.size),l=s;lt)return a[l]}return a[a.length-1]},ta=function(e,t,a,n,s,l){e==="<"||e==="\\lt"||e==="\u27E8"?e="\\langle":(e===">"||e==="\\gt"||e==="\u27E9")&&(e="\\rangle");var h;O.contains(Jr,e)?h=Z1:O.contains(Kr,e)?h=Qr:h=j1;var c=ea(e,t,h,n);return c.type==="small"?P1(e,c.style,a,n,s,l):c.type==="large"?Zr(e,c.size,a,n,s,l):jr(e,t,a,n,s,l)},J1=function(e,t,a,n,s,l){var h=n.fontMetrics().axisHeight*n.sizeMultiplier,c=901,f=5/n.fontMetrics().ptPerEm,v=Math.max(t-h,a+h),b=Math.max(v/500*c,2*v-f);return ta(e,b,!0,n,s,l)},O0={sqrtImage:X1,sizedDelim:Y1,sizeToMaxHeight:me,customSizedDelim:ta,leftRightDelim:J1},sr={"\\bigl":{mclass:"mopen",size:1},"\\Bigl":{mclass:"mopen",size:2},"\\biggl":{mclass:"mopen",size:3},"\\Biggl":{mclass:"mopen",size:4},"\\bigr":{mclass:"mclose",size:1},"\\Bigr":{mclass:"mclose",size:2},"\\biggr":{mclass:"mclose",size:3},"\\Biggr":{mclass:"mclose",size:4},"\\bigm":{mclass:"mrel",size:1},"\\Bigm":{mclass:"mrel",size:2},"\\biggm":{mclass:"mrel",size:3},"\\Biggm":{mclass:"mrel",size:4},"\\big":{mclass:"mord",size:1},"\\Big":{mclass:"mord",size:2},"\\bigg":{mclass:"mord",size:3},"\\Bigg":{mclass:"mord",size:4}},Q1=["(","\\lparen",")","\\rparen","[","\\lbrack","]","\\rbrack","\\{","\\lbrace","\\}","\\rbrace","\\lfloor","\\rfloor","\u230A","\u230B","\\lceil","\\rceil","\u2308","\u2309","<",">","\\langle","\u27E8","\\rangle","\u27E9","\\lt","\\gt","\\lvert","\\rvert","\\lVert","\\rVert","\\lgroup","\\rgroup","\u27EE","\u27EF","\\lmoustache","\\rmoustache","\u23B0","\u23B1","/","\\backslash","|","\\vert","\\|","\\Vert","\\uparrow","\\Uparrow","\\downarrow","\\Downarrow","\\updownarrow","\\Updownarrow","."];function Ye(r,e){var t=Xe(r);if(t&&O.contains(Q1,t.text))return t;throw t?new z("Invalid delimiter '"+t.text+"' after '"+e.funcName+"'",r):new z("Invalid delimiter type '"+r.type+"'",r)}B({type:"delimsizing",names:["\\bigl","\\Bigl","\\biggl","\\Biggl","\\bigr","\\Bigr","\\biggr","\\Biggr","\\bigm","\\Bigm","\\biggm","\\Biggm","\\big","\\Big","\\bigg","\\Bigg"],props:{numArgs:1,argTypes:["primitive"]},handler:(r,e)=>{var t=Ye(e[0],r);return{type:"delimsizing",mode:r.parser.mode,size:sr[r.funcName].size,mclass:sr[r.funcName].mclass,delim:t.text}},htmlBuilder:(r,e)=>r.delim==="."?y.makeSpan([r.mclass]):O0.sizedDelim(r.delim,r.size,e,r.mode,[r.mclass]),mathmlBuilder:r=>{var e=[];r.delim!=="."&&e.push(y0(r.delim,r.mode));var t=new M.MathNode("mo",e);r.mclass==="mopen"||r.mclass==="mclose"?t.setAttribute("fence","true"):t.setAttribute("fence","false"),t.setAttribute("stretchy","true");var a=T(O0.sizeToMaxHeight[r.size]);return t.setAttribute("minsize",a),t.setAttribute("maxsize",a),t}});function or(r){if(!r.body)throw new Error("Bug: The leftright ParseNode wasn't fully parsed.")}B({type:"leftright-right",names:["\\right"],props:{numArgs:1,primitive:!0},handler:(r,e)=>{var t=r.parser.gullet.macros.get("\\current@color");if(t&&typeof t!="string")throw new z("\\current@color set to non-string in \\right");return{type:"leftright-right",mode:r.parser.mode,delim:Ye(e[0],r).text,color:t}}});B({type:"leftright",names:["\\left"],props:{numArgs:1,primitive:!0},handler:(r,e)=>{var t=Ye(e[0],r),a=r.parser;++a.leftrightDepth;var n=a.parseExpression(!1);--a.leftrightDepth,a.expect("\\right",!1);var s=L(a.parseFunction(),"leftright-right");return{type:"leftright",mode:a.mode,body:n,left:t.text,right:s.delim,rightColor:s.color}},htmlBuilder:(r,e)=>{or(r);for(var t=a0(r.body,e,!0,["mopen","mclose"]),a=0,n=0,s=!1,l=0;l{or(r);var t=m0(r.body,e);if(r.left!=="."){var a=new M.MathNode("mo",[y0(r.left,r.mode)]);a.setAttribute("fence","true"),t.unshift(a)}if(r.right!=="."){var n=new M.MathNode("mo",[y0(r.right,r.mode)]);n.setAttribute("fence","true"),r.rightColor&&n.setAttribute("mathcolor",r.rightColor),t.push(n)}return Bt(t)}});B({type:"middle",names:["\\middle"],props:{numArgs:1,primitive:!0},handler:(r,e)=>{var t=Ye(e[0],r);if(!r.parser.leftrightDepth)throw new z("\\middle without preceding \\left",t);return{type:"middle",mode:r.parser.mode,delim:t.text}},htmlBuilder:(r,e)=>{var t;if(r.delim===".")t=ge(e,[]);else{t=O0.sizedDelim(r.delim,1,e,r.mode,[]);var a={delim:r.delim,options:e};t.isMiddle=a}return t},mathmlBuilder:(r,e)=>{var t=r.delim==="\\vert"||r.delim==="|"?y0("|","text"):y0(r.delim,r.mode),a=new M.MathNode("mo",[t]);return a.setAttribute("fence","true"),a.setAttribute("lspace","0.05em"),a.setAttribute("rspace","0.05em"),a}});var Ot=(r,e)=>{var t=y.wrapFragment(G(r.body,e),e),a=r.label.slice(1),n=e.sizeMultiplier,s,l=0,h=O.isCharacterBox(r.body);if(a==="sout")s=y.makeSpan(["stretchy","sout"]),s.height=e.fontMetrics().defaultRuleThickness/n,l=-.5*e.fontMetrics().xHeight;else if(a==="phase"){var c=Q({number:.6,unit:"pt"},e),f=Q({number:.35,unit:"ex"},e),v=e.havingBaseSizing();n=n/v.sizeMultiplier;var b=t.height+t.depth+c+f;t.style.paddingLeft=T(b/2+c);var x=Math.floor(1e3*b*n),w=Wa(x),A=new S0([new A0("phase",w)],{width:"400em",height:T(x/1e3),viewBox:"0 0 400000 "+x,preserveAspectRatio:"xMinYMin slice"});s=y.makeSvgSpan(["hide-tail"],[A],e),s.style.height=T(b),l=t.depth+c+f}else{/cancel/.test(a)?h||t.classes.push("cancel-pad"):a==="angl"?t.classes.push("anglpad"):t.classes.push("boxpad");var q=0,_=0,D=0;/box/.test(a)?(D=Math.max(e.fontMetrics().fboxrule,e.minRuleThickness),q=e.fontMetrics().fboxsep+(a==="colorbox"?0:D),_=q):a==="angl"?(D=Math.max(e.fontMetrics().defaultRuleThickness,e.minRuleThickness),q=4*D,_=Math.max(0,.25-t.depth)):(q=h?.2:0,_=q),s=E0.encloseSpan(t,a,q,_,e),/fbox|boxed|fcolorbox/.test(a)?(s.style.borderStyle="solid",s.style.borderWidth=T(D)):a==="angl"&&D!==.049&&(s.style.borderTopWidth=T(D),s.style.borderRightWidth=T(D)),l=t.depth+_,r.backgroundColor&&(s.style.backgroundColor=r.backgroundColor,r.borderColor&&(s.style.borderColor=r.borderColor))}var N;if(r.backgroundColor)N=y.makeVList({positionType:"individualShift",children:[{type:"elem",elem:s,shift:l},{type:"elem",elem:t,shift:0}]},e);else{var $=/cancel|phase/.test(a)?["svg-align"]:[];N=y.makeVList({positionType:"individualShift",children:[{type:"elem",elem:t,shift:0},{type:"elem",elem:s,shift:l,wrapperClasses:$}]},e)}return/cancel/.test(a)&&(N.height=t.height,N.depth=t.depth),/cancel/.test(a)&&!h?y.makeSpan(["mord","cancel-lap"],[N],e):y.makeSpan(["mord"],[N],e)},It=(r,e)=>{var t=0,a=new M.MathNode(r.label.indexOf("colorbox")>-1?"mpadded":"menclose",[X(r.body,e)]);switch(r.label){case"\\cancel":a.setAttribute("notation","updiagonalstrike");break;case"\\bcancel":a.setAttribute("notation","downdiagonalstrike");break;case"\\phase":a.setAttribute("notation","phasorangle");break;case"\\sout":a.setAttribute("notation","horizontalstrike");break;case"\\fbox":a.setAttribute("notation","box");break;case"\\angl":a.setAttribute("notation","actuarial");break;case"\\fcolorbox":case"\\colorbox":if(t=e.fontMetrics().fboxsep*e.fontMetrics().ptPerEm,a.setAttribute("width","+"+2*t+"pt"),a.setAttribute("height","+"+2*t+"pt"),a.setAttribute("lspace",t+"pt"),a.setAttribute("voffset",t+"pt"),r.label==="\\fcolorbox"){var n=Math.max(e.fontMetrics().fboxrule,e.minRuleThickness);a.setAttribute("style","border: "+n+"em solid "+String(r.borderColor))}break;case"\\xcancel":a.setAttribute("notation","updiagonalstrike downdiagonalstrike");break}return r.backgroundColor&&a.setAttribute("mathbackground",r.backgroundColor),a};B({type:"enclose",names:["\\colorbox"],props:{numArgs:2,allowedInText:!0,argTypes:["color","text"]},handler(r,e,t){var{parser:a,funcName:n}=r,s=L(e[0],"color-token").color,l=e[1];return{type:"enclose",mode:a.mode,label:n,backgroundColor:s,body:l}},htmlBuilder:Ot,mathmlBuilder:It});B({type:"enclose",names:["\\fcolorbox"],props:{numArgs:3,allowedInText:!0,argTypes:["color","color","text"]},handler(r,e,t){var{parser:a,funcName:n}=r,s=L(e[0],"color-token").color,l=L(e[1],"color-token").color,h=e[2];return{type:"enclose",mode:a.mode,label:n,backgroundColor:l,borderColor:s,body:h}},htmlBuilder:Ot,mathmlBuilder:It});B({type:"enclose",names:["\\fbox"],props:{numArgs:1,argTypes:["hbox"],allowedInText:!0},handler(r,e){var{parser:t}=r;return{type:"enclose",mode:t.mode,label:"\\fbox",body:e[0]}}});B({type:"enclose",names:["\\cancel","\\bcancel","\\xcancel","\\sout","\\phase"],props:{numArgs:1},handler(r,e){var{parser:t,funcName:a}=r,n=e[0];return{type:"enclose",mode:t.mode,label:a,body:n}},htmlBuilder:Ot,mathmlBuilder:It});B({type:"enclose",names:["\\angl"],props:{numArgs:1,argTypes:["hbox"],allowedInText:!1},handler(r,e){var{parser:t}=r;return{type:"enclose",mode:t.mode,label:"\\angl",body:e[0]}}});var ra={};function T0(r){for(var{type:e,names:t,props:a,handler:n,htmlBuilder:s,mathmlBuilder:l}=r,h={type:e,numArgs:a.numArgs||0,allowedInText:!1,numOptionalArgs:0,handler:n},c=0;c{var e=r.parser.settings;if(!e.displayMode)throw new z("{"+r.envName+"} can be used only in display mode.")};function Et(r){if(r.indexOf("ed")===-1)return r.indexOf("*")===-1}function U0(r,e,t){var{hskipBeforeAndAfter:a,addJot:n,cols:s,arraystretch:l,colSeparationType:h,autoTag:c,singleRow:f,emptySingleRow:v,maxNumCols:b,leqno:x}=e;if(r.gullet.beginGroup(),f||r.gullet.macros.set("\\cr","\\\\\\relax"),!l){var w=r.gullet.expandMacroAsText("\\arraystretch");if(w==null)l=1;else if(l=parseFloat(w),!l||l<0)throw new z("Invalid \\arraystretch: "+w)}r.gullet.beginGroup();var A=[],q=[A],_=[],D=[],N=c!=null?[]:void 0;function $(){c&&r.gullet.macros.set("\\@eqnsw","1",!0)}function H(){N&&(r.gullet.macros.get("\\df@tag")?(N.push(r.subparse([new b0("\\df@tag")])),r.gullet.macros.set("\\df@tag",void 0,!0)):N.push(!!c&&r.gullet.macros.get("\\@eqnsw")==="1"))}for($(),D.push(lr(r));;){var F=r.parseExpression(!1,f?"\\end":"\\\\");r.gullet.endGroup(),r.gullet.beginGroup(),F={type:"ordgroup",mode:r.mode,body:F},t&&(F={type:"styling",mode:r.mode,style:t,body:[F]}),A.push(F);var P=r.fetch().text;if(P==="&"){if(b&&A.length===b){if(f||h)throw new z("Too many tab characters: &",r.nextToken);r.settings.reportNonstrict("textEnv","Too few columns specified in the {array} column argument.")}r.consume()}else if(P==="\\end"){H(),A.length===1&&F.type==="styling"&&F.body[0].body.length===0&&(q.length>1||!v)&&q.pop(),D.length0&&($+=.25),f.push({pos:$,isDashed:we[Se]})}for(H(l[0]),a=0;a0&&(i0+=N,Vwe))for(a=0;a=h)){var ee=void 0;(n>0||e.hskipBeforeAndAfter)&&(ee=O.deflt(f0.pregap,x),ee!==0&&(x0=y.makeSpan(["arraycolsep"],[]),x0.style.width=T(ee),u0.push(x0)));var te=[];for(a=0;a0){for(var Sa=y.makeLineSpan("hline",t,v),ka=y.makeLineSpan("hdashline",t,v),Je=[{type:"elem",elem:c,shift:0}];f.length>0;){var Ut=f.pop(),Xt=Ut.pos-r0;Ut.isDashed?Je.push({type:"elem",elem:ka,shift:Xt}):Je.push({type:"elem",elem:Sa,shift:Xt})}c=y.makeVList({positionType:"individualShift",children:Je},t)}if(K0.length===0)return y.makeSpan(["mord"],[c],t);var Qe=y.makeVList({positionType:"individualShift",children:K0},t);return Qe=y.makeSpan(["tag"],[Qe],t),y.makeFragment([c,Qe])},en={c:"center ",l:"left ",r:"right "},B0=function(e,t){for(var a=[],n=new M.MathNode("mtd",[],["mtr-glue"]),s=new M.MathNode("mtd",[],["mml-eqn-num"]),l=0;l0){var A=e.cols,q="",_=!1,D=0,N=A.length;A[0].type==="separator"&&(x+="top ",D=1),A[A.length-1].type==="separator"&&(x+="bottom ",N-=1);for(var $=D;$0?"left ":"",x+=j[j.length-1].length>0?"right ":"";for(var U=1;U-1?"alignat":"align",s=e.envName==="split",l=U0(e.parser,{cols:a,addJot:!0,autoTag:s?void 0:Et(e.envName),emptySingleRow:!0,colSeparationType:n,maxNumCols:s?2:void 0,leqno:e.parser.settings.leqno},"display"),h,c=0,f={type:"ordgroup",mode:e.mode,body:[]};if(t[0]&&t[0].type==="ordgroup"){for(var v="",b=0;b0&&w&&(_=1),a[A]={type:"align",align:q,pregap:_,postgap:0}}return l.colSeparationType=w?"align":"alignat",l};T0({type:"array",names:["array","darray"],props:{numArgs:1},handler(r,e){var t=Xe(e[0]),a=t?[e[0]]:L(e[0],"ordgroup").body,n=a.map(function(l){var h=Ct(l),c=h.text;if("lcr".indexOf(c)!==-1)return{type:"align",align:c};if(c==="|")return{type:"separator",separator:"|"};if(c===":")return{type:"separator",separator:":"};throw new z("Unknown column alignment: "+c,l)}),s={cols:n,hskipBeforeAndAfter:!0,maxNumCols:n.length};return U0(r.parser,s,Rt(r.envName))},htmlBuilder:q0,mathmlBuilder:B0});T0({type:"array",names:["matrix","pmatrix","bmatrix","Bmatrix","vmatrix","Vmatrix","matrix*","pmatrix*","bmatrix*","Bmatrix*","vmatrix*","Vmatrix*"],props:{numArgs:0},handler(r){var e={matrix:null,pmatrix:["(",")"],bmatrix:["[","]"],Bmatrix:["\\{","\\}"],vmatrix:["|","|"],Vmatrix:["\\Vert","\\Vert"]}[r.envName.replace("*","")],t="c",a={hskipBeforeAndAfter:!1,cols:[{type:"align",align:t}]};if(r.envName.charAt(r.envName.length-1)==="*"){var n=r.parser;if(n.consumeSpaces(),n.fetch().text==="["){if(n.consume(),n.consumeSpaces(),t=n.fetch().text,"lcr".indexOf(t)===-1)throw new z("Expected l or c or r",n.nextToken);n.consume(),n.consumeSpaces(),n.expect("]"),n.consume(),a.cols=[{type:"align",align:t}]}}var s=U0(r.parser,a,Rt(r.envName)),l=Math.max(0,...s.body.map(h=>h.length));return s.cols=new Array(l).fill({type:"align",align:t}),e?{type:"leftright",mode:r.mode,body:[s],left:e[0],right:e[1],rightColor:void 0}:s},htmlBuilder:q0,mathmlBuilder:B0});T0({type:"array",names:["smallmatrix"],props:{numArgs:0},handler(r){var e={arraystretch:.5},t=U0(r.parser,e,"script");return t.colSeparationType="small",t},htmlBuilder:q0,mathmlBuilder:B0});T0({type:"array",names:["subarray"],props:{numArgs:1},handler(r,e){var t=Xe(e[0]),a=t?[e[0]]:L(e[0],"ordgroup").body,n=a.map(function(l){var h=Ct(l),c=h.text;if("lc".indexOf(c)!==-1)return{type:"align",align:c};throw new z("Unknown column alignment: "+c,l)});if(n.length>1)throw new z("{subarray} can contain only one column");var s={cols:n,hskipBeforeAndAfter:!1,arraystretch:.5};if(s=U0(r.parser,s,"script"),s.body.length>0&&s.body[0].length>1)throw new z("{subarray} can contain only one column");return s},htmlBuilder:q0,mathmlBuilder:B0});T0({type:"array",names:["cases","dcases","rcases","drcases"],props:{numArgs:0},handler(r){var e={arraystretch:1.2,cols:[{type:"align",align:"l",pregap:0,postgap:1},{type:"align",align:"l",pregap:0,postgap:0}]},t=U0(r.parser,e,Rt(r.envName));return{type:"leftright",mode:r.mode,body:[t],left:r.envName.indexOf("r")>-1?".":"\\{",right:r.envName.indexOf("r")>-1?"\\}":".",rightColor:void 0}},htmlBuilder:q0,mathmlBuilder:B0});T0({type:"array",names:["align","align*","aligned","split"],props:{numArgs:0},handler:na,htmlBuilder:q0,mathmlBuilder:B0});T0({type:"array",names:["gathered","gather","gather*"],props:{numArgs:0},handler(r){O.contains(["gather","gather*"],r.envName)&&Ze(r);var e={cols:[{type:"align",align:"c"}],addJot:!0,colSeparationType:"gather",autoTag:Et(r.envName),emptySingleRow:!0,leqno:r.parser.settings.leqno};return U0(r.parser,e,"display")},htmlBuilder:q0,mathmlBuilder:B0});T0({type:"array",names:["alignat","alignat*","alignedat"],props:{numArgs:1},handler:na,htmlBuilder:q0,mathmlBuilder:B0});T0({type:"array",names:["equation","equation*"],props:{numArgs:0},handler(r){Ze(r);var e={autoTag:Et(r.envName),emptySingleRow:!0,singleRow:!0,maxNumCols:1,leqno:r.parser.settings.leqno};return U0(r.parser,e,"display")},htmlBuilder:q0,mathmlBuilder:B0});T0({type:"array",names:["CD"],props:{numArgs:0},handler(r){return Ze(r),F1(r.parser)},htmlBuilder:q0,mathmlBuilder:B0});m("\\nonumber","\\gdef\\@eqnsw{0}");m("\\notag","\\nonumber");B({type:"text",names:["\\hline","\\hdashline"],props:{numArgs:0,allowedInText:!0,allowedInMath:!0},handler(r,e){throw new z(r.funcName+" valid only within array environment")}});var ur=ra;B({type:"environment",names:["\\begin","\\end"],props:{numArgs:1,argTypes:["text"]},handler(r,e){var{parser:t,funcName:a}=r,n=e[0];if(n.type!=="ordgroup")throw new z("Invalid environment name",n);for(var s="",l=0;l{var t=r.font,a=e.withFont(t);return G(r.body,a)},sa=(r,e)=>{var t=r.font,a=e.withFont(t);return X(r.body,a)},hr={"\\Bbb":"\\mathbb","\\bold":"\\mathbf","\\frak":"\\mathfrak","\\bm":"\\boldsymbol"};B({type:"font",names:["\\mathrm","\\mathit","\\mathbf","\\mathnormal","\\mathsfit","\\mathbb","\\mathcal","\\mathfrak","\\mathscr","\\mathsf","\\mathtt","\\Bbb","\\bold","\\frak"],props:{numArgs:1,allowedInArgument:!0},handler:(r,e)=>{var{parser:t,funcName:a}=r,n=He(e[0]),s=a;return s in hr&&(s=hr[s]),{type:"font",mode:t.mode,font:s.slice(1),body:n}},htmlBuilder:ia,mathmlBuilder:sa});B({type:"mclass",names:["\\boldsymbol","\\bm"],props:{numArgs:1},handler:(r,e)=>{var{parser:t}=r,a=e[0],n=O.isCharacterBox(a);return{type:"mclass",mode:t.mode,mclass:We(a),body:[{type:"font",mode:t.mode,font:"boldsymbol",body:a}],isCharacterBox:n}}});B({type:"font",names:["\\rm","\\sf","\\tt","\\bf","\\it","\\cal"],props:{numArgs:0,allowedInText:!0},handler:(r,e)=>{var{parser:t,funcName:a,breakOnTokenText:n}=r,{mode:s}=t,l=t.parseExpression(!0,n),h="math"+a.slice(1);return{type:"font",mode:s,font:h,body:{type:"ordgroup",mode:t.mode,body:l}}},htmlBuilder:ia,mathmlBuilder:sa});var oa=(r,e)=>{var t=e;return r==="display"?t=t.id>=E.SCRIPT.id?t.text():E.DISPLAY:r==="text"&&t.size===E.DISPLAY.size?t=E.TEXT:r==="script"?t=E.SCRIPT:r==="scriptscript"&&(t=E.SCRIPTSCRIPT),t},$t=(r,e)=>{var t=oa(r.size,e.style),a=t.fracNum(),n=t.fracDen(),s;s=e.havingStyle(a);var l=G(r.numer,s,e);if(r.continued){var h=8.5/e.fontMetrics().ptPerEm,c=3.5/e.fontMetrics().ptPerEm;l.height=l.height0?A=3*x:A=7*x,q=e.fontMetrics().denom1):(b>0?(w=e.fontMetrics().num2,A=x):(w=e.fontMetrics().num3,A=3*x),q=e.fontMetrics().denom2);var _;if(v){var N=e.fontMetrics().axisHeight;w-l.depth-(N+.5*b){var t=new M.MathNode("mfrac",[X(r.numer,e),X(r.denom,e)]);if(!r.hasBarLine)t.setAttribute("linethickness","0px");else if(r.barSize){var a=Q(r.barSize,e);t.setAttribute("linethickness",T(a))}var n=oa(r.size,e.style);if(n.size!==e.style.size){t=new M.MathNode("mstyle",[t]);var s=n.size===E.DISPLAY.size?"true":"false";t.setAttribute("displaystyle",s),t.setAttribute("scriptlevel","0")}if(r.leftDelim!=null||r.rightDelim!=null){var l=[];if(r.leftDelim!=null){var h=new M.MathNode("mo",[new M.TextNode(r.leftDelim.replace("\\",""))]);h.setAttribute("fence","true"),l.push(h)}if(l.push(t),r.rightDelim!=null){var c=new M.MathNode("mo",[new M.TextNode(r.rightDelim.replace("\\",""))]);c.setAttribute("fence","true"),l.push(c)}return Bt(l)}return t};B({type:"genfrac",names:["\\dfrac","\\frac","\\tfrac","\\dbinom","\\binom","\\tbinom","\\\\atopfrac","\\\\bracefrac","\\\\brackfrac"],props:{numArgs:2,allowedInArgument:!0},handler:(r,e)=>{var{parser:t,funcName:a}=r,n=e[0],s=e[1],l,h=null,c=null,f="auto";switch(a){case"\\dfrac":case"\\frac":case"\\tfrac":l=!0;break;case"\\\\atopfrac":l=!1;break;case"\\dbinom":case"\\binom":case"\\tbinom":l=!1,h="(",c=")";break;case"\\\\bracefrac":l=!1,h="\\{",c="\\}";break;case"\\\\brackfrac":l=!1,h="[",c="]";break;default:throw new Error("Unrecognized genfrac command")}switch(a){case"\\dfrac":case"\\dbinom":f="display";break;case"\\tfrac":case"\\tbinom":f="text";break}return{type:"genfrac",mode:t.mode,continued:!1,numer:n,denom:s,hasBarLine:l,leftDelim:h,rightDelim:c,size:f,barSize:null}},htmlBuilder:$t,mathmlBuilder:Lt});B({type:"genfrac",names:["\\cfrac"],props:{numArgs:2},handler:(r,e)=>{var{parser:t,funcName:a}=r,n=e[0],s=e[1];return{type:"genfrac",mode:t.mode,continued:!0,numer:n,denom:s,hasBarLine:!0,leftDelim:null,rightDelim:null,size:"display",barSize:null}}});B({type:"infix",names:["\\over","\\choose","\\atop","\\brace","\\brack"],props:{numArgs:0,infix:!0},handler(r){var{parser:e,funcName:t,token:a}=r,n;switch(t){case"\\over":n="\\frac";break;case"\\choose":n="\\binom";break;case"\\atop":n="\\\\atopfrac";break;case"\\brace":n="\\\\bracefrac";break;case"\\brack":n="\\\\brackfrac";break;default:throw new Error("Unrecognized infix genfrac command")}return{type:"infix",mode:e.mode,replaceWith:n,token:a}}});var cr=["display","text","script","scriptscript"],mr=function(e){var t=null;return e.length>0&&(t=e,t=t==="."?null:t),t};B({type:"genfrac",names:["\\genfrac"],props:{numArgs:6,allowedInArgument:!0,argTypes:["math","math","size","text","math","math"]},handler(r,e){var{parser:t}=r,a=e[4],n=e[5],s=He(e[0]),l=s.type==="atom"&&s.family==="open"?mr(s.text):null,h=He(e[1]),c=h.type==="atom"&&h.family==="close"?mr(h.text):null,f=L(e[2],"size"),v,b=null;f.isBlank?v=!0:(b=f.value,v=b.number>0);var x="auto",w=e[3];if(w.type==="ordgroup"){if(w.body.length>0){var A=L(w.body[0],"textord");x=cr[Number(A.text)]}}else w=L(w,"textord"),x=cr[Number(w.text)];return{type:"genfrac",mode:t.mode,numer:a,denom:n,continued:!1,hasBarLine:v,barSize:b,leftDelim:l,rightDelim:c,size:x}},htmlBuilder:$t,mathmlBuilder:Lt});B({type:"infix",names:["\\above"],props:{numArgs:1,argTypes:["size"],infix:!0},handler(r,e){var{parser:t,funcName:a,token:n}=r;return{type:"infix",mode:t.mode,replaceWith:"\\\\abovefrac",size:L(e[0],"size").value,token:n}}});B({type:"genfrac",names:["\\\\abovefrac"],props:{numArgs:3,argTypes:["math","size","math"]},handler:(r,e)=>{var{parser:t,funcName:a}=r,n=e[0],s=_a(L(e[1],"infix").size),l=e[2],h=s.number>0;return{type:"genfrac",mode:t.mode,numer:n,denom:l,continued:!1,hasBarLine:h,barSize:s,leftDelim:null,rightDelim:null,size:"auto"}},htmlBuilder:$t,mathmlBuilder:Lt});var la=(r,e)=>{var t=e.style,a,n;r.type==="supsub"?(a=r.sup?G(r.sup,e.havingStyle(t.sup()),e):G(r.sub,e.havingStyle(t.sub()),e),n=L(r.base,"horizBrace")):n=L(r,"horizBrace");var s=G(n.base,e.havingBaseStyle(E.DISPLAY)),l=E0.svgSpan(n,e),h;if(n.isOver?(h=y.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:s},{type:"kern",size:.1},{type:"elem",elem:l}]},e),h.children[0].children[0].children[1].classes.push("svg-align")):(h=y.makeVList({positionType:"bottom",positionData:s.depth+.1+l.height,children:[{type:"elem",elem:l},{type:"kern",size:.1},{type:"elem",elem:s}]},e),h.children[0].children[0].children[0].classes.push("svg-align")),a){var c=y.makeSpan(["mord",n.isOver?"mover":"munder"],[h],e);n.isOver?h=y.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:c},{type:"kern",size:.2},{type:"elem",elem:a}]},e):h=y.makeVList({positionType:"bottom",positionData:c.depth+.2+a.height+a.depth,children:[{type:"elem",elem:a},{type:"kern",size:.2},{type:"elem",elem:c}]},e)}return y.makeSpan(["mord",n.isOver?"mover":"munder"],[h],e)},tn=(r,e)=>{var t=E0.mathMLnode(r.label);return new M.MathNode(r.isOver?"mover":"munder",[X(r.base,e),t])};B({type:"horizBrace",names:["\\overbrace","\\underbrace"],props:{numArgs:1},handler(r,e){var{parser:t,funcName:a}=r;return{type:"horizBrace",mode:t.mode,label:a,isOver:/^\\over/.test(a),base:e[0]}},htmlBuilder:la,mathmlBuilder:tn});B({type:"href",names:["\\href"],props:{numArgs:2,argTypes:["url","original"],allowedInText:!0},handler:(r,e)=>{var{parser:t}=r,a=e[1],n=L(e[0],"url").url;return t.settings.isTrusted({command:"\\href",url:n})?{type:"href",mode:t.mode,href:n,body:e0(a)}:t.formatUnsupportedCmd("\\href")},htmlBuilder:(r,e)=>{var t=a0(r.body,e,!1);return y.makeAnchor(r.href,[],t,e)},mathmlBuilder:(r,e)=>{var t=V0(r.body,e);return t instanceof s0||(t=new s0("mrow",[t])),t.setAttribute("href",r.href),t}});B({type:"href",names:["\\url"],props:{numArgs:1,argTypes:["url"],allowedInText:!0},handler:(r,e)=>{var{parser:t}=r,a=L(e[0],"url").url;if(!t.settings.isTrusted({command:"\\url",url:a}))return t.formatUnsupportedCmd("\\url");for(var n=[],s=0;s{var{parser:t,funcName:a,token:n}=r,s=L(e[0],"raw").string,l=e[1];t.settings.strict&&t.settings.reportNonstrict("htmlExtension","HTML extension is disabled on strict mode");var h,c={};switch(a){case"\\htmlClass":c.class=s,h={command:"\\htmlClass",class:s};break;case"\\htmlId":c.id=s,h={command:"\\htmlId",id:s};break;case"\\htmlStyle":c.style=s,h={command:"\\htmlStyle",style:s};break;case"\\htmlData":{for(var f=s.split(","),v=0;v{var t=a0(r.body,e,!1),a=["enclosing"];r.attributes.class&&a.push(...r.attributes.class.trim().split(/\s+/));var n=y.makeSpan(a,t,e);for(var s in r.attributes)s!=="class"&&r.attributes.hasOwnProperty(s)&&n.setAttribute(s,r.attributes[s]);return n},mathmlBuilder:(r,e)=>V0(r.body,e)});B({type:"htmlmathml",names:["\\html@mathml"],props:{numArgs:2,allowedInText:!0},handler:(r,e)=>{var{parser:t}=r;return{type:"htmlmathml",mode:t.mode,html:e0(e[0]),mathml:e0(e[1])}},htmlBuilder:(r,e)=>{var t=a0(r.html,e,!1);return y.makeFragment(t)},mathmlBuilder:(r,e)=>V0(r.mathml,e)});var ht=function(e){if(/^[-+]? *(\d+(\.\d*)?|\.\d+)$/.test(e))return{number:+e,unit:"bp"};var t=/([-+]?) *(\d+(?:\.\d*)?|\.\d+) *([a-z]{2})/.exec(e);if(!t)throw new z("Invalid size: '"+e+"' in \\includegraphics");var a={number:+(t[1]+t[2]),unit:t[3]};if(!Tr(a))throw new z("Invalid unit: '"+a.unit+"' in \\includegraphics.");return a};B({type:"includegraphics",names:["\\includegraphics"],props:{numArgs:1,numOptionalArgs:1,argTypes:["raw","url"],allowedInText:!1},handler:(r,e,t)=>{var{parser:a}=r,n={number:0,unit:"em"},s={number:.9,unit:"em"},l={number:0,unit:"em"},h="";if(t[0])for(var c=L(t[0],"raw").string,f=c.split(","),v=0;v{var t=Q(r.height,e),a=0;r.totalheight.number>0&&(a=Q(r.totalheight,e)-t);var n=0;r.width.number>0&&(n=Q(r.width,e));var s={height:T(t+a)};n>0&&(s.width=T(n)),a>0&&(s.verticalAlign=T(-a));var l=new vt(r.src,r.alt,s);return l.height=t,l.depth=a,l},mathmlBuilder:(r,e)=>{var t=new M.MathNode("mglyph",[]);t.setAttribute("alt",r.alt);var a=Q(r.height,e),n=0;if(r.totalheight.number>0&&(n=Q(r.totalheight,e)-a,t.setAttribute("valign",T(-n))),t.setAttribute("height",T(a+n)),r.width.number>0){var s=Q(r.width,e);t.setAttribute("width",T(s))}return t.setAttribute("src",r.src),t}});B({type:"kern",names:["\\kern","\\mkern","\\hskip","\\mskip"],props:{numArgs:1,argTypes:["size"],primitive:!0,allowedInText:!0},handler(r,e){var{parser:t,funcName:a}=r,n=L(e[0],"size");if(t.settings.strict){var s=a[1]==="m",l=n.value.unit==="mu";s?(l||t.settings.reportNonstrict("mathVsTextUnits","LaTeX's "+a+" supports only mu units, "+("not "+n.value.unit+" units")),t.mode!=="math"&&t.settings.reportNonstrict("mathVsTextUnits","LaTeX's "+a+" works only in math mode")):l&&t.settings.reportNonstrict("mathVsTextUnits","LaTeX's "+a+" doesn't support mu units")}return{type:"kern",mode:t.mode,dimension:n.value}},htmlBuilder(r,e){return y.makeGlue(r.dimension,e)},mathmlBuilder(r,e){var t=Q(r.dimension,e);return new M.SpaceNode(t)}});B({type:"lap",names:["\\mathllap","\\mathrlap","\\mathclap"],props:{numArgs:1,allowedInText:!0},handler:(r,e)=>{var{parser:t,funcName:a}=r,n=e[0];return{type:"lap",mode:t.mode,alignment:a.slice(5),body:n}},htmlBuilder:(r,e)=>{var t;r.alignment==="clap"?(t=y.makeSpan([],[G(r.body,e)]),t=y.makeSpan(["inner"],[t],e)):t=y.makeSpan(["inner"],[G(r.body,e)]);var a=y.makeSpan(["fix"],[]),n=y.makeSpan([r.alignment],[t,a],e),s=y.makeSpan(["strut"]);return s.style.height=T(n.height+n.depth),n.depth&&(s.style.verticalAlign=T(-n.depth)),n.children.unshift(s),n=y.makeSpan(["thinbox"],[n],e),y.makeSpan(["mord","vbox"],[n],e)},mathmlBuilder:(r,e)=>{var t=new M.MathNode("mpadded",[X(r.body,e)]);if(r.alignment!=="rlap"){var a=r.alignment==="llap"?"-1":"-0.5";t.setAttribute("lspace",a+"width")}return t.setAttribute("width","0px"),t}});B({type:"styling",names:["\\(","$"],props:{numArgs:0,allowedInText:!0,allowedInMath:!1},handler(r,e){var{funcName:t,parser:a}=r,n=a.mode;a.switchMode("math");var s=t==="\\("?"\\)":"$",l=a.parseExpression(!1,s);return a.expect(s),a.switchMode(n),{type:"styling",mode:a.mode,style:"text",body:l}}});B({type:"text",names:["\\)","\\]"],props:{numArgs:0,allowedInText:!0,allowedInMath:!1},handler(r,e){throw new z("Mismatched "+r.funcName)}});var dr=(r,e)=>{switch(e.style.size){case E.DISPLAY.size:return r.display;case E.TEXT.size:return r.text;case E.SCRIPT.size:return r.script;case E.SCRIPTSCRIPT.size:return r.scriptscript;default:return r.text}};B({type:"mathchoice",names:["\\mathchoice"],props:{numArgs:4,primitive:!0},handler:(r,e)=>{var{parser:t}=r;return{type:"mathchoice",mode:t.mode,display:e0(e[0]),text:e0(e[1]),script:e0(e[2]),scriptscript:e0(e[3])}},htmlBuilder:(r,e)=>{var t=dr(r,e),a=a0(t,e,!1);return y.makeFragment(a)},mathmlBuilder:(r,e)=>{var t=dr(r,e);return V0(t,e)}});var ua=(r,e,t,a,n,s,l)=>{r=y.makeSpan([],[r]);var h=t&&O.isCharacterBox(t),c,f;if(e){var v=G(e,a.havingStyle(n.sup()),a);f={elem:v,kern:Math.max(a.fontMetrics().bigOpSpacing1,a.fontMetrics().bigOpSpacing3-v.depth)}}if(t){var b=G(t,a.havingStyle(n.sub()),a);c={elem:b,kern:Math.max(a.fontMetrics().bigOpSpacing2,a.fontMetrics().bigOpSpacing4-b.height)}}var x;if(f&&c){var w=a.fontMetrics().bigOpSpacing5+c.elem.height+c.elem.depth+c.kern+r.depth+l;x=y.makeVList({positionType:"bottom",positionData:w,children:[{type:"kern",size:a.fontMetrics().bigOpSpacing5},{type:"elem",elem:c.elem,marginLeft:T(-s)},{type:"kern",size:c.kern},{type:"elem",elem:r},{type:"kern",size:f.kern},{type:"elem",elem:f.elem,marginLeft:T(s)},{type:"kern",size:a.fontMetrics().bigOpSpacing5}]},a)}else if(c){var A=r.height-l;x=y.makeVList({positionType:"top",positionData:A,children:[{type:"kern",size:a.fontMetrics().bigOpSpacing5},{type:"elem",elem:c.elem,marginLeft:T(-s)},{type:"kern",size:c.kern},{type:"elem",elem:r}]},a)}else if(f){var q=r.depth+l;x=y.makeVList({positionType:"bottom",positionData:q,children:[{type:"elem",elem:r},{type:"kern",size:f.kern},{type:"elem",elem:f.elem,marginLeft:T(s)},{type:"kern",size:a.fontMetrics().bigOpSpacing5}]},a)}else return r;var _=[x];if(c&&s!==0&&!h){var D=y.makeSpan(["mspace"],[],a);D.style.marginRight=T(s),_.unshift(D)}return y.makeSpan(["mop","op-limits"],_,a)},ha=["\\smallint"],se=(r,e)=>{var t,a,n=!1,s;r.type==="supsub"?(t=r.sup,a=r.sub,s=L(r.base,"op"),n=!0):s=L(r,"op");var l=e.style,h=!1;l.size===E.DISPLAY.size&&s.symbol&&!O.contains(ha,s.name)&&(h=!0);var c;if(s.symbol){var f=h?"Size2-Regular":"Size1-Regular",v="";if((s.name==="\\oiint"||s.name==="\\oiiint")&&(v=s.name.slice(1),s.name=v==="oiint"?"\\iint":"\\iiint"),c=y.makeSymbol(s.name,f,"math",e,["mop","op-symbol",h?"large-op":"small-op"]),v.length>0){var b=c.italic,x=y.staticSvg(v+"Size"+(h?"2":"1"),e);c=y.makeVList({positionType:"individualShift",children:[{type:"elem",elem:c,shift:0},{type:"elem",elem:x,shift:h?.08:0}]},e),s.name="\\"+v,c.classes.unshift("mop"),c.italic=b}}else if(s.body){var w=a0(s.body,e,!0);w.length===1&&w[0]instanceof c0?(c=w[0],c.classes[0]="mop"):c=y.makeSpan(["mop"],w,e)}else{for(var A=[],q=1;q{var t;if(r.symbol)t=new s0("mo",[y0(r.name,r.mode)]),O.contains(ha,r.name)&&t.setAttribute("largeop","false");else if(r.body)t=new s0("mo",m0(r.body,e));else{t=new s0("mi",[new g0(r.name.slice(1))]);var a=new s0("mo",[y0("\u2061","text")]);r.parentIsSupSub?t=new s0("mrow",[t,a]):t=$r([t,a])}return t},rn={"\u220F":"\\prod","\u2210":"\\coprod","\u2211":"\\sum","\u22C0":"\\bigwedge","\u22C1":"\\bigvee","\u22C2":"\\bigcap","\u22C3":"\\bigcup","\u2A00":"\\bigodot","\u2A01":"\\bigoplus","\u2A02":"\\bigotimes","\u2A04":"\\biguplus","\u2A06":"\\bigsqcup"};B({type:"op",names:["\\coprod","\\bigvee","\\bigwedge","\\biguplus","\\bigcap","\\bigcup","\\intop","\\prod","\\sum","\\bigotimes","\\bigoplus","\\bigodot","\\bigsqcup","\\smallint","\u220F","\u2210","\u2211","\u22C0","\u22C1","\u22C2","\u22C3","\u2A00","\u2A01","\u2A02","\u2A04","\u2A06"],props:{numArgs:0},handler:(r,e)=>{var{parser:t,funcName:a}=r,n=a;return n.length===1&&(n=rn[n]),{type:"op",mode:t.mode,limits:!0,parentIsSupSub:!1,symbol:!0,name:n}},htmlBuilder:se,mathmlBuilder:be});B({type:"op",names:["\\mathop"],props:{numArgs:1,primitive:!0},handler:(r,e)=>{var{parser:t}=r,a=e[0];return{type:"op",mode:t.mode,limits:!1,parentIsSupSub:!1,symbol:!1,body:e0(a)}},htmlBuilder:se,mathmlBuilder:be});var an={"\u222B":"\\int","\u222C":"\\iint","\u222D":"\\iiint","\u222E":"\\oint","\u222F":"\\oiint","\u2230":"\\oiiint"};B({type:"op",names:["\\arcsin","\\arccos","\\arctan","\\arctg","\\arcctg","\\arg","\\ch","\\cos","\\cosec","\\cosh","\\cot","\\cotg","\\coth","\\csc","\\ctg","\\cth","\\deg","\\dim","\\exp","\\hom","\\ker","\\lg","\\ln","\\log","\\sec","\\sin","\\sinh","\\sh","\\tan","\\tanh","\\tg","\\th"],props:{numArgs:0},handler(r){var{parser:e,funcName:t}=r;return{type:"op",mode:e.mode,limits:!1,parentIsSupSub:!1,symbol:!1,name:t}},htmlBuilder:se,mathmlBuilder:be});B({type:"op",names:["\\det","\\gcd","\\inf","\\lim","\\max","\\min","\\Pr","\\sup"],props:{numArgs:0},handler(r){var{parser:e,funcName:t}=r;return{type:"op",mode:e.mode,limits:!0,parentIsSupSub:!1,symbol:!1,name:t}},htmlBuilder:se,mathmlBuilder:be});B({type:"op",names:["\\int","\\iint","\\iiint","\\oint","\\oiint","\\oiiint","\u222B","\u222C","\u222D","\u222E","\u222F","\u2230"],props:{numArgs:0},handler(r){var{parser:e,funcName:t}=r,a=t;return a.length===1&&(a=an[a]),{type:"op",mode:e.mode,limits:!1,parentIsSupSub:!1,symbol:!0,name:a}},htmlBuilder:se,mathmlBuilder:be});var ca=(r,e)=>{var t,a,n=!1,s;r.type==="supsub"?(t=r.sup,a=r.sub,s=L(r.base,"operatorname"),n=!0):s=L(r,"operatorname");var l;if(s.body.length>0){for(var h=s.body.map(b=>{var x=b.text;return typeof x=="string"?{type:"textord",mode:b.mode,text:x}:b}),c=a0(h,e.withFont("mathrm"),!0),f=0;f{for(var t=m0(r.body,e.withFont("mathrm")),a=!0,n=0;nv.toText()).join("");t=[new M.TextNode(h)]}var c=new M.MathNode("mi",t);c.setAttribute("mathvariant","normal");var f=new M.MathNode("mo",[y0("\u2061","text")]);return r.parentIsSupSub?new M.MathNode("mrow",[c,f]):M.newDocumentFragment([c,f])};B({type:"operatorname",names:["\\operatorname@","\\operatornamewithlimits"],props:{numArgs:1},handler:(r,e)=>{var{parser:t,funcName:a}=r,n=e[0];return{type:"operatorname",mode:t.mode,body:e0(n),alwaysHandleSupSub:a==="\\operatornamewithlimits",limits:!1,parentIsSupSub:!1}},htmlBuilder:ca,mathmlBuilder:nn});m("\\operatorname","\\@ifstar\\operatornamewithlimits\\operatorname@");j0({type:"ordgroup",htmlBuilder(r,e){return r.semisimple?y.makeFragment(a0(r.body,e,!1)):y.makeSpan(["mord"],a0(r.body,e,!0),e)},mathmlBuilder(r,e){return V0(r.body,e,!0)}});B({type:"overline",names:["\\overline"],props:{numArgs:1},handler(r,e){var{parser:t}=r,a=e[0];return{type:"overline",mode:t.mode,body:a}},htmlBuilder(r,e){var t=G(r.body,e.havingCrampedStyle()),a=y.makeLineSpan("overline-line",e),n=e.fontMetrics().defaultRuleThickness,s=y.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:t},{type:"kern",size:3*n},{type:"elem",elem:a},{type:"kern",size:n}]},e);return y.makeSpan(["mord","overline"],[s],e)},mathmlBuilder(r,e){var t=new M.MathNode("mo",[new M.TextNode("\u203E")]);t.setAttribute("stretchy","true");var a=new M.MathNode("mover",[X(r.body,e),t]);return a.setAttribute("accent","true"),a}});B({type:"phantom",names:["\\phantom"],props:{numArgs:1,allowedInText:!0},handler:(r,e)=>{var{parser:t}=r,a=e[0];return{type:"phantom",mode:t.mode,body:e0(a)}},htmlBuilder:(r,e)=>{var t=a0(r.body,e.withPhantom(),!1);return y.makeFragment(t)},mathmlBuilder:(r,e)=>{var t=m0(r.body,e);return new M.MathNode("mphantom",t)}});B({type:"hphantom",names:["\\hphantom"],props:{numArgs:1,allowedInText:!0},handler:(r,e)=>{var{parser:t}=r,a=e[0];return{type:"hphantom",mode:t.mode,body:a}},htmlBuilder:(r,e)=>{var t=y.makeSpan([],[G(r.body,e.withPhantom())]);if(t.height=0,t.depth=0,t.children)for(var a=0;a{var t=m0(e0(r.body),e),a=new M.MathNode("mphantom",t),n=new M.MathNode("mpadded",[a]);return n.setAttribute("height","0px"),n.setAttribute("depth","0px"),n}});B({type:"vphantom",names:["\\vphantom"],props:{numArgs:1,allowedInText:!0},handler:(r,e)=>{var{parser:t}=r,a=e[0];return{type:"vphantom",mode:t.mode,body:a}},htmlBuilder:(r,e)=>{var t=y.makeSpan(["inner"],[G(r.body,e.withPhantom())]),a=y.makeSpan(["fix"],[]);return y.makeSpan(["mord","rlap"],[t,a],e)},mathmlBuilder:(r,e)=>{var t=m0(e0(r.body),e),a=new M.MathNode("mphantom",t),n=new M.MathNode("mpadded",[a]);return n.setAttribute("width","0px"),n}});B({type:"raisebox",names:["\\raisebox"],props:{numArgs:2,argTypes:["size","hbox"],allowedInText:!0},handler(r,e){var{parser:t}=r,a=L(e[0],"size").value,n=e[1];return{type:"raisebox",mode:t.mode,dy:a,body:n}},htmlBuilder(r,e){var t=G(r.body,e),a=Q(r.dy,e);return y.makeVList({positionType:"shift",positionData:-a,children:[{type:"elem",elem:t}]},e)},mathmlBuilder(r,e){var t=new M.MathNode("mpadded",[X(r.body,e)]),a=r.dy.number+r.dy.unit;return t.setAttribute("voffset",a),t}});B({type:"internal",names:["\\relax"],props:{numArgs:0,allowedInText:!0,allowedInArgument:!0},handler(r){var{parser:e}=r;return{type:"internal",mode:e.mode}}});B({type:"rule",names:["\\rule"],props:{numArgs:2,numOptionalArgs:1,allowedInText:!0,allowedInMath:!0,argTypes:["size","size","size"]},handler(r,e,t){var{parser:a}=r,n=t[0],s=L(e[0],"size"),l=L(e[1],"size");return{type:"rule",mode:a.mode,shift:n&&L(n,"size").value,width:s.value,height:l.value}},htmlBuilder(r,e){var t=y.makeSpan(["mord","rule"],[],e),a=Q(r.width,e),n=Q(r.height,e),s=r.shift?Q(r.shift,e):0;return t.style.borderRightWidth=T(a),t.style.borderTopWidth=T(n),t.style.bottom=T(s),t.width=a,t.height=n+s,t.depth=-s,t.maxFontSize=n*1.125*e.sizeMultiplier,t},mathmlBuilder(r,e){var t=Q(r.width,e),a=Q(r.height,e),n=r.shift?Q(r.shift,e):0,s=e.color&&e.getColor()||"black",l=new M.MathNode("mspace");l.setAttribute("mathbackground",s),l.setAttribute("width",T(t)),l.setAttribute("height",T(a));var h=new M.MathNode("mpadded",[l]);return n>=0?h.setAttribute("height",T(n)):(h.setAttribute("height",T(n)),h.setAttribute("depth",T(-n))),h.setAttribute("voffset",T(n)),h}});function ma(r,e,t){for(var a=a0(r,e,!1),n=e.sizeMultiplier/t.sizeMultiplier,s=0;s{var t=e.havingSize(r.size);return ma(r.body,t,e)};B({type:"sizing",names:pr,props:{numArgs:0,allowedInText:!0},handler:(r,e)=>{var{breakOnTokenText:t,funcName:a,parser:n}=r,s=n.parseExpression(!1,t);return{type:"sizing",mode:n.mode,size:pr.indexOf(a)+1,body:s}},htmlBuilder:sn,mathmlBuilder:(r,e)=>{var t=e.havingSize(r.size),a=m0(r.body,t),n=new M.MathNode("mstyle",a);return n.setAttribute("mathsize",T(t.sizeMultiplier)),n}});B({type:"smash",names:["\\smash"],props:{numArgs:1,numOptionalArgs:1,allowedInText:!0},handler:(r,e,t)=>{var{parser:a}=r,n=!1,s=!1,l=t[0]&&L(t[0],"ordgroup");if(l)for(var h="",c=0;c{var t=y.makeSpan([],[G(r.body,e)]);if(!r.smashHeight&&!r.smashDepth)return t;if(r.smashHeight&&(t.height=0,t.children))for(var a=0;a{var t=new M.MathNode("mpadded",[X(r.body,e)]);return r.smashHeight&&t.setAttribute("height","0px"),r.smashDepth&&t.setAttribute("depth","0px"),t}});B({type:"sqrt",names:["\\sqrt"],props:{numArgs:1,numOptionalArgs:1},handler(r,e,t){var{parser:a}=r,n=t[0],s=e[0];return{type:"sqrt",mode:a.mode,body:s,index:n}},htmlBuilder(r,e){var t=G(r.body,e.havingCrampedStyle());t.height===0&&(t.height=e.fontMetrics().xHeight),t=y.wrapFragment(t,e);var a=e.fontMetrics(),n=a.defaultRuleThickness,s=n;e.style.idt.height+t.depth+l&&(l=(l+b-t.height-t.depth)/2);var x=c.height-t.height-l-f;t.style.paddingLeft=T(v);var w=y.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:t,wrapperClasses:["svg-align"]},{type:"kern",size:-(t.height+x)},{type:"elem",elem:c},{type:"kern",size:f}]},e);if(r.index){var A=e.havingStyle(E.SCRIPTSCRIPT),q=G(r.index,A,e),_=.6*(w.height-w.depth),D=y.makeVList({positionType:"shift",positionData:-_,children:[{type:"elem",elem:q}]},e),N=y.makeSpan(["root"],[D]);return y.makeSpan(["mord","sqrt"],[N,w],e)}else return y.makeSpan(["mord","sqrt"],[w],e)},mathmlBuilder(r,e){var{body:t,index:a}=r;return a?new M.MathNode("mroot",[X(t,e),X(a,e)]):new M.MathNode("msqrt",[X(t,e)])}});var fr={display:E.DISPLAY,text:E.TEXT,script:E.SCRIPT,scriptscript:E.SCRIPTSCRIPT};B({type:"styling",names:["\\displaystyle","\\textstyle","\\scriptstyle","\\scriptscriptstyle"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler(r,e){var{breakOnTokenText:t,funcName:a,parser:n}=r,s=n.parseExpression(!0,t),l=a.slice(1,a.length-5);return{type:"styling",mode:n.mode,style:l,body:s}},htmlBuilder(r,e){var t=fr[r.style],a=e.havingStyle(t).withFont("");return ma(r.body,a,e)},mathmlBuilder(r,e){var t=fr[r.style],a=e.havingStyle(t),n=m0(r.body,a),s=new M.MathNode("mstyle",n),l={display:["0","true"],text:["0","false"],script:["1","false"],scriptscript:["2","false"]},h=l[r.style];return s.setAttribute("scriptlevel",h[0]),s.setAttribute("displaystyle",h[1]),s}});var on=function(e,t){var a=e.base;if(a)if(a.type==="op"){var n=a.limits&&(t.style.size===E.DISPLAY.size||a.alwaysHandleSupSub);return n?se:null}else if(a.type==="operatorname"){var s=a.alwaysHandleSupSub&&(t.style.size===E.DISPLAY.size||a.limits);return s?ca:null}else{if(a.type==="accent")return O.isCharacterBox(a.base)?_t:null;if(a.type==="horizBrace"){var l=!e.sub;return l===a.isOver?la:null}else return null}else return null};j0({type:"supsub",htmlBuilder(r,e){var t=on(r,e);if(t)return t(r,e);var{base:a,sup:n,sub:s}=r,l=G(a,e),h,c,f=e.fontMetrics(),v=0,b=0,x=a&&O.isCharacterBox(a);if(n){var w=e.havingStyle(e.style.sup());h=G(n,w,e),x||(v=l.height-w.fontMetrics().supDrop*w.sizeMultiplier/e.sizeMultiplier)}if(s){var A=e.havingStyle(e.style.sub());c=G(s,A,e),x||(b=l.depth+A.fontMetrics().subDrop*A.sizeMultiplier/e.sizeMultiplier)}var q;e.style===E.DISPLAY?q=f.sup1:e.style.cramped?q=f.sup3:q=f.sup2;var _=e.sizeMultiplier,D=T(.5/f.ptPerEm/_),N=null;if(c){var $=r.base&&r.base.type==="op"&&r.base.name&&(r.base.name==="\\oiint"||r.base.name==="\\oiiint");(l instanceof c0||$)&&(N=T(-l.italic))}var H;if(h&&c){v=Math.max(v,q,h.depth+.25*f.xHeight),b=Math.max(b,f.sub2);var F=f.defaultRuleThickness,P=4*F;if(v-h.depth-(c.height-b)0&&(v+=V,b-=V)}var j=[{type:"elem",elem:c,shift:b,marginRight:D,marginLeft:N},{type:"elem",elem:h,shift:-v,marginRight:D}];H=y.makeVList({positionType:"individualShift",children:j},e)}else if(c){b=Math.max(b,f.sub1,c.height-.8*f.xHeight);var U=[{type:"elem",elem:c,marginLeft:N,marginRight:D}];H=y.makeVList({positionType:"shift",positionData:b,children:U},e)}else if(h)v=Math.max(v,q,h.depth+.25*f.xHeight),H=y.makeVList({positionType:"shift",positionData:-v,children:[{type:"elem",elem:h,marginRight:D}]},e);else throw new Error("supsub must have either sup or sub.");var D0=bt(l,"right")||"mord";return y.makeSpan([D0],[l,y.makeSpan(["msupsub"],[H])],e)},mathmlBuilder(r,e){var t=!1,a,n;r.base&&r.base.type==="horizBrace"&&(n=!!r.sup,n===r.base.isOver&&(t=!0,a=r.base.isOver)),r.base&&(r.base.type==="op"||r.base.type==="operatorname")&&(r.base.parentIsSupSub=!0);var s=[X(r.base,e)];r.sub&&s.push(X(r.sub,e)),r.sup&&s.push(X(r.sup,e));var l;if(t)l=a?"mover":"munder";else if(r.sub)if(r.sup){var f=r.base;f&&f.type==="op"&&f.limits&&e.style===E.DISPLAY||f&&f.type==="operatorname"&&f.alwaysHandleSupSub&&(e.style===E.DISPLAY||f.limits)?l="munderover":l="msubsup"}else{var c=r.base;c&&c.type==="op"&&c.limits&&(e.style===E.DISPLAY||c.alwaysHandleSupSub)||c&&c.type==="operatorname"&&c.alwaysHandleSupSub&&(c.limits||e.style===E.DISPLAY)?l="munder":l="msub"}else{var h=r.base;h&&h.type==="op"&&h.limits&&(e.style===E.DISPLAY||h.alwaysHandleSupSub)||h&&h.type==="operatorname"&&h.alwaysHandleSupSub&&(h.limits||e.style===E.DISPLAY)?l="mover":l="msup"}return new M.MathNode(l,s)}});j0({type:"atom",htmlBuilder(r,e){return y.mathsym(r.text,r.mode,e,["m"+r.family])},mathmlBuilder(r,e){var t=new M.MathNode("mo",[y0(r.text,r.mode)]);if(r.family==="bin"){var a=Dt(r,e);a==="bold-italic"&&t.setAttribute("mathvariant",a)}else r.family==="punct"?t.setAttribute("separator","true"):(r.family==="open"||r.family==="close")&&t.setAttribute("stretchy","false");return t}});var da={mi:"italic",mn:"normal",mtext:"normal"};j0({type:"mathord",htmlBuilder(r,e){return y.makeOrd(r,e,"mathord")},mathmlBuilder(r,e){var t=new M.MathNode("mi",[y0(r.text,r.mode,e)]),a=Dt(r,e)||"italic";return a!==da[t.type]&&t.setAttribute("mathvariant",a),t}});j0({type:"textord",htmlBuilder(r,e){return y.makeOrd(r,e,"textord")},mathmlBuilder(r,e){var t=y0(r.text,r.mode,e),a=Dt(r,e)||"normal",n;return r.mode==="text"?n=new M.MathNode("mtext",[t]):/[0-9]/.test(r.text)?n=new M.MathNode("mn",[t]):r.text==="\\prime"?n=new M.MathNode("mo",[t]):n=new M.MathNode("mi",[t]),a!==da[n.type]&&n.setAttribute("mathvariant",a),n}});var ct={"\\nobreak":"nobreak","\\allowbreak":"allowbreak"},mt={" ":{},"\\ ":{},"~":{className:"nobreak"},"\\space":{},"\\nobreakspace":{className:"nobreak"}};j0({type:"spacing",htmlBuilder(r,e){if(mt.hasOwnProperty(r.text)){var t=mt[r.text].className||"";if(r.mode==="text"){var a=y.makeOrd(r,e,"textord");return a.classes.push(t),a}else return y.makeSpan(["mspace",t],[y.mathsym(r.text,r.mode,e)],e)}else{if(ct.hasOwnProperty(r.text))return y.makeSpan(["mspace",ct[r.text]],[],e);throw new z('Unknown type of space "'+r.text+'"')}},mathmlBuilder(r,e){var t;if(mt.hasOwnProperty(r.text))t=new M.MathNode("mtext",[new M.TextNode("\xA0")]);else{if(ct.hasOwnProperty(r.text))return new M.MathNode("mspace");throw new z('Unknown type of space "'+r.text+'"')}return t}});var vr=()=>{var r=new M.MathNode("mtd",[]);return r.setAttribute("width","50%"),r};j0({type:"tag",mathmlBuilder(r,e){var t=new M.MathNode("mtable",[new M.MathNode("mtr",[vr(),new M.MathNode("mtd",[V0(r.body,e)]),vr(),new M.MathNode("mtd",[V0(r.tag,e)])])]);return t.setAttribute("width","100%"),t}});var gr={"\\text":void 0,"\\textrm":"textrm","\\textsf":"textsf","\\texttt":"texttt","\\textnormal":"textrm"},br={"\\textbf":"textbf","\\textmd":"textmd"},ln={"\\textit":"textit","\\textup":"textup"},yr=(r,e)=>{var t=r.font;if(t){if(gr[t])return e.withTextFontFamily(gr[t]);if(br[t])return e.withTextFontWeight(br[t]);if(t==="\\emph")return e.fontShape==="textit"?e.withTextFontShape("textup"):e.withTextFontShape("textit")}else return e;return e.withTextFontShape(ln[t])};B({type:"text",names:["\\text","\\textrm","\\textsf","\\texttt","\\textnormal","\\textbf","\\textmd","\\textit","\\textup","\\emph"],props:{numArgs:1,argTypes:["text"],allowedInArgument:!0,allowedInText:!0},handler(r,e){var{parser:t,funcName:a}=r,n=e[0];return{type:"text",mode:t.mode,body:e0(n),font:a}},htmlBuilder(r,e){var t=yr(r,e),a=a0(r.body,t,!0);return y.makeSpan(["mord","text"],a,t)},mathmlBuilder(r,e){var t=yr(r,e);return V0(r.body,t)}});B({type:"underline",names:["\\underline"],props:{numArgs:1,allowedInText:!0},handler(r,e){var{parser:t}=r;return{type:"underline",mode:t.mode,body:e[0]}},htmlBuilder(r,e){var t=G(r.body,e),a=y.makeLineSpan("underline-line",e),n=e.fontMetrics().defaultRuleThickness,s=y.makeVList({positionType:"top",positionData:t.height,children:[{type:"kern",size:n},{type:"elem",elem:a},{type:"kern",size:3*n},{type:"elem",elem:t}]},e);return y.makeSpan(["mord","underline"],[s],e)},mathmlBuilder(r,e){var t=new M.MathNode("mo",[new M.TextNode("\u203E")]);t.setAttribute("stretchy","true");var a=new M.MathNode("munder",[X(r.body,e),t]);return a.setAttribute("accentunder","true"),a}});B({type:"vcenter",names:["\\vcenter"],props:{numArgs:1,argTypes:["original"],allowedInText:!1},handler(r,e){var{parser:t}=r;return{type:"vcenter",mode:t.mode,body:e[0]}},htmlBuilder(r,e){var t=G(r.body,e),a=e.fontMetrics().axisHeight,n=.5*(t.height-a-(t.depth+a));return y.makeVList({positionType:"shift",positionData:n,children:[{type:"elem",elem:t}]},e)},mathmlBuilder(r,e){return new M.MathNode("mpadded",[X(r.body,e)],["vcenter"])}});B({type:"verb",names:["\\verb"],props:{numArgs:0,allowedInText:!0},handler(r,e,t){throw new z("\\verb ended by end of line instead of matching delimiter")},htmlBuilder(r,e){for(var t=xr(r),a=[],n=e.havingStyle(e.style.text()),s=0;sr.body.replace(/ /g,r.star?"\u2423":"\xA0"),P0=Er,pa=`[ \r + ]`,un="\\\\[a-zA-Z@]+",hn="\\\\[^\uD800-\uDFFF]",cn="("+un+")"+pa+"*",mn=`\\\\( +|[ \r ]+ +?)[ \r ]*`,kt="[\u0300-\u036F]",dn=new RegExp(kt+"+$"),pn="("+pa+"+)|"+(mn+"|")+"([!-\\[\\]-\u2027\u202A-\uD7FF\uF900-\uFFFF]"+(kt+"*")+"|[\uD800-\uDBFF][\uDC00-\uDFFF]"+(kt+"*")+"|\\\\verb\\*([^]).*?\\4|\\\\verb([^*a-zA-Z]).*?\\5"+("|"+cn)+("|"+hn+")"),Pe=class{constructor(e,t){this.input=void 0,this.settings=void 0,this.tokenRegex=void 0,this.catcodes=void 0,this.input=e,this.settings=t,this.tokenRegex=new RegExp(pn,"g"),this.catcodes={"%":14,"~":13}}setCatcode(e,t){this.catcodes[e]=t}lex(){var e=this.input,t=this.tokenRegex.lastIndex;if(t===e.length)return new b0("EOF",new d0(this,t,t));var a=this.tokenRegex.exec(e);if(a===null||a.index!==t)throw new z("Unexpected character: '"+e[t]+"'",new b0(e[t],new d0(this,t,t+1)));var n=a[6]||a[3]||(a[2]?"\\ ":" ");if(this.catcodes[n]===14){var s=e.indexOf(` +`,this.tokenRegex.lastIndex);return s===-1?(this.tokenRegex.lastIndex=e.length,this.settings.reportNonstrict("commentAtEnd","% comment has no terminating newline; LaTeX would fail because of commenting the end of math mode (e.g. $)")):this.tokenRegex.lastIndex=s+1,this.lex()}return new b0(n,new d0(this,t,this.tokenRegex.lastIndex))}},Mt=class{constructor(e,t){e===void 0&&(e={}),t===void 0&&(t={}),this.current=void 0,this.builtins=void 0,this.undefStack=void 0,this.current=t,this.builtins=e,this.undefStack=[]}beginGroup(){this.undefStack.push({})}endGroup(){if(this.undefStack.length===0)throw new z("Unbalanced namespace destruction: attempt to pop global namespace; please report this as a bug");var e=this.undefStack.pop();for(var t in e)e.hasOwnProperty(t)&&(e[t]==null?delete this.current[t]:this.current[t]=e[t])}endGroups(){for(;this.undefStack.length>0;)this.endGroup()}has(e){return this.current.hasOwnProperty(e)||this.builtins.hasOwnProperty(e)}get(e){return this.current.hasOwnProperty(e)?this.current[e]:this.builtins[e]}set(e,t,a){if(a===void 0&&(a=!1),a){for(var n=0;n0&&(this.undefStack[this.undefStack.length-1][e]=t)}else{var s=this.undefStack[this.undefStack.length-1];s&&!s.hasOwnProperty(e)&&(s[e]=this.current[e])}t==null?delete this.current[e]:this.current[e]=t}},fn=aa;m("\\noexpand",function(r){var e=r.popToken();return r.isExpandable(e.text)&&(e.noexpand=!0,e.treatAsRelax=!0),{tokens:[e],numArgs:0}});m("\\expandafter",function(r){var e=r.popToken();return r.expandOnce(!0),{tokens:[e],numArgs:0}});m("\\@firstoftwo",function(r){var e=r.consumeArgs(2);return{tokens:e[0],numArgs:0}});m("\\@secondoftwo",function(r){var e=r.consumeArgs(2);return{tokens:e[1],numArgs:0}});m("\\@ifnextchar",function(r){var e=r.consumeArgs(3);r.consumeSpaces();var t=r.future();return e[0].length===1&&e[0][0].text===t.text?{tokens:e[1],numArgs:0}:{tokens:e[2],numArgs:0}});m("\\@ifstar","\\@ifnextchar *{\\@firstoftwo{#1}}");m("\\TextOrMath",function(r){var e=r.consumeArgs(2);return r.mode==="text"?{tokens:e[0],numArgs:0}:{tokens:e[1],numArgs:0}});var wr={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,a:10,A:10,b:11,B:11,c:12,C:12,d:13,D:13,e:14,E:14,f:15,F:15};m("\\char",function(r){var e=r.popToken(),t,a="";if(e.text==="'")t=8,e=r.popToken();else if(e.text==='"')t=16,e=r.popToken();else if(e.text==="`")if(e=r.popToken(),e.text[0]==="\\")a=e.text.charCodeAt(1);else{if(e.text==="EOF")throw new z("\\char` missing argument");a=e.text.charCodeAt(0)}else t=10;if(t){if(a=wr[e.text],a==null||a>=t)throw new z("Invalid base-"+t+" digit "+e.text);for(var n;(n=wr[r.future().text])!=null&&n{var n=r.consumeArg().tokens;if(n.length!==1)throw new z("\\newcommand's first argument must be a macro name");var s=n[0].text,l=r.isDefined(s);if(l&&!e)throw new z("\\newcommand{"+s+"} attempting to redefine "+(s+"; use \\renewcommand"));if(!l&&!t)throw new z("\\renewcommand{"+s+"} when command "+s+" does not yet exist; use \\newcommand");var h=0;if(n=r.consumeArg().tokens,n.length===1&&n[0].text==="["){for(var c="",f=r.expandNextToken();f.text!=="]"&&f.text!=="EOF";)c+=f.text,f=r.expandNextToken();if(!c.match(/^\s*[0-9]+\s*$/))throw new z("Invalid number of arguments: "+c);h=parseInt(c),n=r.consumeArg().tokens}return l&&a||r.macros.set(s,{tokens:n,numArgs:h}),""};m("\\newcommand",r=>Ft(r,!1,!0,!1));m("\\renewcommand",r=>Ft(r,!0,!1,!1));m("\\providecommand",r=>Ft(r,!0,!0,!0));m("\\message",r=>{var e=r.consumeArgs(1)[0];return console.log(e.reverse().map(t=>t.text).join("")),""});m("\\errmessage",r=>{var e=r.consumeArgs(1)[0];return console.error(e.reverse().map(t=>t.text).join("")),""});m("\\show",r=>{var e=r.popToken(),t=e.text;return console.log(e,r.macros.get(t),P0[t],Y.math[t],Y.text[t]),""});m("\\bgroup","{");m("\\egroup","}");m("~","\\nobreakspace");m("\\lq","`");m("\\rq","'");m("\\aa","\\r a");m("\\AA","\\r A");m("\\textcopyright","\\html@mathml{\\textcircled{c}}{\\char`\xA9}");m("\\copyright","\\TextOrMath{\\textcopyright}{\\text{\\textcopyright}}");m("\\textregistered","\\html@mathml{\\textcircled{\\scriptsize R}}{\\char`\xAE}");m("\u212C","\\mathscr{B}");m("\u2130","\\mathscr{E}");m("\u2131","\\mathscr{F}");m("\u210B","\\mathscr{H}");m("\u2110","\\mathscr{I}");m("\u2112","\\mathscr{L}");m("\u2133","\\mathscr{M}");m("\u211B","\\mathscr{R}");m("\u212D","\\mathfrak{C}");m("\u210C","\\mathfrak{H}");m("\u2128","\\mathfrak{Z}");m("\\Bbbk","\\Bbb{k}");m("\xB7","\\cdotp");m("\\llap","\\mathllap{\\textrm{#1}}");m("\\rlap","\\mathrlap{\\textrm{#1}}");m("\\clap","\\mathclap{\\textrm{#1}}");m("\\mathstrut","\\vphantom{(}");m("\\underbar","\\underline{\\text{#1}}");m("\\not",'\\html@mathml{\\mathrel{\\mathrlap\\@not}}{\\char"338}');m("\\neq","\\html@mathml{\\mathrel{\\not=}}{\\mathrel{\\char`\u2260}}");m("\\ne","\\neq");m("\u2260","\\neq");m("\\notin","\\html@mathml{\\mathrel{{\\in}\\mathllap{/\\mskip1mu}}}{\\mathrel{\\char`\u2209}}");m("\u2209","\\notin");m("\u2258","\\html@mathml{\\mathrel{=\\kern{-1em}\\raisebox{0.4em}{$\\scriptsize\\frown$}}}{\\mathrel{\\char`\u2258}}");m("\u2259","\\html@mathml{\\stackrel{\\tiny\\wedge}{=}}{\\mathrel{\\char`\u2258}}");m("\u225A","\\html@mathml{\\stackrel{\\tiny\\vee}{=}}{\\mathrel{\\char`\u225A}}");m("\u225B","\\html@mathml{\\stackrel{\\scriptsize\\star}{=}}{\\mathrel{\\char`\u225B}}");m("\u225D","\\html@mathml{\\stackrel{\\tiny\\mathrm{def}}{=}}{\\mathrel{\\char`\u225D}}");m("\u225E","\\html@mathml{\\stackrel{\\tiny\\mathrm{m}}{=}}{\\mathrel{\\char`\u225E}}");m("\u225F","\\html@mathml{\\stackrel{\\tiny?}{=}}{\\mathrel{\\char`\u225F}}");m("\u27C2","\\perp");m("\u203C","\\mathclose{!\\mkern-0.8mu!}");m("\u220C","\\notni");m("\u231C","\\ulcorner");m("\u231D","\\urcorner");m("\u231E","\\llcorner");m("\u231F","\\lrcorner");m("\xA9","\\copyright");m("\xAE","\\textregistered");m("\uFE0F","\\textregistered");m("\\ulcorner",'\\html@mathml{\\@ulcorner}{\\mathop{\\char"231c}}');m("\\urcorner",'\\html@mathml{\\@urcorner}{\\mathop{\\char"231d}}');m("\\llcorner",'\\html@mathml{\\@llcorner}{\\mathop{\\char"231e}}');m("\\lrcorner",'\\html@mathml{\\@lrcorner}{\\mathop{\\char"231f}}');m("\\vdots","{\\varvdots\\rule{0pt}{15pt}}");m("\u22EE","\\vdots");m("\\varGamma","\\mathit{\\Gamma}");m("\\varDelta","\\mathit{\\Delta}");m("\\varTheta","\\mathit{\\Theta}");m("\\varLambda","\\mathit{\\Lambda}");m("\\varXi","\\mathit{\\Xi}");m("\\varPi","\\mathit{\\Pi}");m("\\varSigma","\\mathit{\\Sigma}");m("\\varUpsilon","\\mathit{\\Upsilon}");m("\\varPhi","\\mathit{\\Phi}");m("\\varPsi","\\mathit{\\Psi}");m("\\varOmega","\\mathit{\\Omega}");m("\\substack","\\begin{subarray}{c}#1\\end{subarray}");m("\\colon","\\nobreak\\mskip2mu\\mathpunct{}\\mathchoice{\\mkern-3mu}{\\mkern-3mu}{}{}{:}\\mskip6mu\\relax");m("\\boxed","\\fbox{$\\displaystyle{#1}$}");m("\\iff","\\DOTSB\\;\\Longleftrightarrow\\;");m("\\implies","\\DOTSB\\;\\Longrightarrow\\;");m("\\impliedby","\\DOTSB\\;\\Longleftarrow\\;");m("\\dddot","{\\overset{\\raisebox{-0.1ex}{\\normalsize ...}}{#1}}");m("\\ddddot","{\\overset{\\raisebox{-0.1ex}{\\normalsize ....}}{#1}}");var Sr={",":"\\dotsc","\\not":"\\dotsb","+":"\\dotsb","=":"\\dotsb","<":"\\dotsb",">":"\\dotsb","-":"\\dotsb","*":"\\dotsb",":":"\\dotsb","\\DOTSB":"\\dotsb","\\coprod":"\\dotsb","\\bigvee":"\\dotsb","\\bigwedge":"\\dotsb","\\biguplus":"\\dotsb","\\bigcap":"\\dotsb","\\bigcup":"\\dotsb","\\prod":"\\dotsb","\\sum":"\\dotsb","\\bigotimes":"\\dotsb","\\bigoplus":"\\dotsb","\\bigodot":"\\dotsb","\\bigsqcup":"\\dotsb","\\And":"\\dotsb","\\longrightarrow":"\\dotsb","\\Longrightarrow":"\\dotsb","\\longleftarrow":"\\dotsb","\\Longleftarrow":"\\dotsb","\\longleftrightarrow":"\\dotsb","\\Longleftrightarrow":"\\dotsb","\\mapsto":"\\dotsb","\\longmapsto":"\\dotsb","\\hookrightarrow":"\\dotsb","\\doteq":"\\dotsb","\\mathbin":"\\dotsb","\\mathrel":"\\dotsb","\\relbar":"\\dotsb","\\Relbar":"\\dotsb","\\xrightarrow":"\\dotsb","\\xleftarrow":"\\dotsb","\\DOTSI":"\\dotsi","\\int":"\\dotsi","\\oint":"\\dotsi","\\iint":"\\dotsi","\\iiint":"\\dotsi","\\iiiint":"\\dotsi","\\idotsint":"\\dotsi","\\DOTSX":"\\dotsx"};m("\\dots",function(r){var e="\\dotso",t=r.expandAfterFuture().text;return t in Sr?e=Sr[t]:(t.slice(0,4)==="\\not"||t in Y.math&&O.contains(["bin","rel"],Y.math[t].group))&&(e="\\dotsb"),e});var Ht={")":!0,"]":!0,"\\rbrack":!0,"\\}":!0,"\\rbrace":!0,"\\rangle":!0,"\\rceil":!0,"\\rfloor":!0,"\\rgroup":!0,"\\rmoustache":!0,"\\right":!0,"\\bigr":!0,"\\biggr":!0,"\\Bigr":!0,"\\Biggr":!0,$:!0,";":!0,".":!0,",":!0};m("\\dotso",function(r){var e=r.future().text;return e in Ht?"\\ldots\\,":"\\ldots"});m("\\dotsc",function(r){var e=r.future().text;return e in Ht&&e!==","?"\\ldots\\,":"\\ldots"});m("\\cdots",function(r){var e=r.future().text;return e in Ht?"\\@cdots\\,":"\\@cdots"});m("\\dotsb","\\cdots");m("\\dotsm","\\cdots");m("\\dotsi","\\!\\cdots");m("\\dotsx","\\ldots\\,");m("\\DOTSI","\\relax");m("\\DOTSB","\\relax");m("\\DOTSX","\\relax");m("\\tmspace","\\TextOrMath{\\kern#1#3}{\\mskip#1#2}\\relax");m("\\,","\\tmspace+{3mu}{.1667em}");m("\\thinspace","\\,");m("\\>","\\mskip{4mu}");m("\\:","\\tmspace+{4mu}{.2222em}");m("\\medspace","\\:");m("\\;","\\tmspace+{5mu}{.2777em}");m("\\thickspace","\\;");m("\\!","\\tmspace-{3mu}{.1667em}");m("\\negthinspace","\\!");m("\\negmedspace","\\tmspace-{4mu}{.2222em}");m("\\negthickspace","\\tmspace-{5mu}{.277em}");m("\\enspace","\\kern.5em ");m("\\enskip","\\hskip.5em\\relax");m("\\quad","\\hskip1em\\relax");m("\\qquad","\\hskip2em\\relax");m("\\tag","\\@ifstar\\tag@literal\\tag@paren");m("\\tag@paren","\\tag@literal{({#1})}");m("\\tag@literal",r=>{if(r.macros.get("\\df@tag"))throw new z("Multiple \\tag");return"\\gdef\\df@tag{\\text{#1}}"});m("\\bmod","\\mathchoice{\\mskip1mu}{\\mskip1mu}{\\mskip5mu}{\\mskip5mu}\\mathbin{\\rm mod}\\mathchoice{\\mskip1mu}{\\mskip1mu}{\\mskip5mu}{\\mskip5mu}");m("\\pod","\\allowbreak\\mathchoice{\\mkern18mu}{\\mkern8mu}{\\mkern8mu}{\\mkern8mu}(#1)");m("\\pmod","\\pod{{\\rm mod}\\mkern6mu#1}");m("\\mod","\\allowbreak\\mathchoice{\\mkern18mu}{\\mkern12mu}{\\mkern12mu}{\\mkern12mu}{\\rm mod}\\,\\,#1");m("\\newline","\\\\\\relax");m("\\TeX","\\textrm{\\html@mathml{T\\kern-.1667em\\raisebox{-.5ex}{E}\\kern-.125emX}{TeX}}");var fa=T(z0["Main-Regular"][84][1]-.7*z0["Main-Regular"][65][1]);m("\\LaTeX","\\textrm{\\html@mathml{"+("L\\kern-.36em\\raisebox{"+fa+"}{\\scriptstyle A}")+"\\kern-.15em\\TeX}{LaTeX}}");m("\\KaTeX","\\textrm{\\html@mathml{"+("K\\kern-.17em\\raisebox{"+fa+"}{\\scriptstyle A}")+"\\kern-.15em\\TeX}{KaTeX}}");m("\\hspace","\\@ifstar\\@hspacer\\@hspace");m("\\@hspace","\\hskip #1\\relax");m("\\@hspacer","\\rule{0pt}{0pt}\\hskip #1\\relax");m("\\ordinarycolon",":");m("\\vcentcolon","\\mathrel{\\mathop\\ordinarycolon}");m("\\dblcolon",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-.9mu}\\vcentcolon}}{\\mathop{\\char"2237}}');m("\\coloneqq",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}=}}{\\mathop{\\char"2254}}');m("\\Coloneqq",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}=}}{\\mathop{\\char"2237\\char"3d}}');m("\\coloneq",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}\\mathrel{-}}}{\\mathop{\\char"3a\\char"2212}}');m("\\Coloneq",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}\\mathrel{-}}}{\\mathop{\\char"2237\\char"2212}}');m("\\eqqcolon",'\\html@mathml{\\mathrel{=\\mathrel{\\mkern-1.2mu}\\vcentcolon}}{\\mathop{\\char"2255}}');m("\\Eqqcolon",'\\html@mathml{\\mathrel{=\\mathrel{\\mkern-1.2mu}\\dblcolon}}{\\mathop{\\char"3d\\char"2237}}');m("\\eqcolon",'\\html@mathml{\\mathrel{\\mathrel{-}\\mathrel{\\mkern-1.2mu}\\vcentcolon}}{\\mathop{\\char"2239}}');m("\\Eqcolon",'\\html@mathml{\\mathrel{\\mathrel{-}\\mathrel{\\mkern-1.2mu}\\dblcolon}}{\\mathop{\\char"2212\\char"2237}}');m("\\colonapprox",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}\\approx}}{\\mathop{\\char"3a\\char"2248}}');m("\\Colonapprox",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}\\approx}}{\\mathop{\\char"2237\\char"2248}}');m("\\colonsim",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}\\sim}}{\\mathop{\\char"3a\\char"223c}}');m("\\Colonsim",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}\\sim}}{\\mathop{\\char"2237\\char"223c}}');m("\u2237","\\dblcolon");m("\u2239","\\eqcolon");m("\u2254","\\coloneqq");m("\u2255","\\eqqcolon");m("\u2A74","\\Coloneqq");m("\\ratio","\\vcentcolon");m("\\coloncolon","\\dblcolon");m("\\colonequals","\\coloneqq");m("\\coloncolonequals","\\Coloneqq");m("\\equalscolon","\\eqqcolon");m("\\equalscoloncolon","\\Eqqcolon");m("\\colonminus","\\coloneq");m("\\coloncolonminus","\\Coloneq");m("\\minuscolon","\\eqcolon");m("\\minuscoloncolon","\\Eqcolon");m("\\coloncolonapprox","\\Colonapprox");m("\\coloncolonsim","\\Colonsim");m("\\simcolon","\\mathrel{\\sim\\mathrel{\\mkern-1.2mu}\\vcentcolon}");m("\\simcoloncolon","\\mathrel{\\sim\\mathrel{\\mkern-1.2mu}\\dblcolon}");m("\\approxcolon","\\mathrel{\\approx\\mathrel{\\mkern-1.2mu}\\vcentcolon}");m("\\approxcoloncolon","\\mathrel{\\approx\\mathrel{\\mkern-1.2mu}\\dblcolon}");m("\\notni","\\html@mathml{\\not\\ni}{\\mathrel{\\char`\u220C}}");m("\\limsup","\\DOTSB\\operatorname*{lim\\,sup}");m("\\liminf","\\DOTSB\\operatorname*{lim\\,inf}");m("\\injlim","\\DOTSB\\operatorname*{inj\\,lim}");m("\\projlim","\\DOTSB\\operatorname*{proj\\,lim}");m("\\varlimsup","\\DOTSB\\operatorname*{\\overline{lim}}");m("\\varliminf","\\DOTSB\\operatorname*{\\underline{lim}}");m("\\varinjlim","\\DOTSB\\operatorname*{\\underrightarrow{lim}}");m("\\varprojlim","\\DOTSB\\operatorname*{\\underleftarrow{lim}}");m("\\gvertneqq","\\html@mathml{\\@gvertneqq}{\u2269}");m("\\lvertneqq","\\html@mathml{\\@lvertneqq}{\u2268}");m("\\ngeqq","\\html@mathml{\\@ngeqq}{\u2271}");m("\\ngeqslant","\\html@mathml{\\@ngeqslant}{\u2271}");m("\\nleqq","\\html@mathml{\\@nleqq}{\u2270}");m("\\nleqslant","\\html@mathml{\\@nleqslant}{\u2270}");m("\\nshortmid","\\html@mathml{\\@nshortmid}{\u2224}");m("\\nshortparallel","\\html@mathml{\\@nshortparallel}{\u2226}");m("\\nsubseteqq","\\html@mathml{\\@nsubseteqq}{\u2288}");m("\\nsupseteqq","\\html@mathml{\\@nsupseteqq}{\u2289}");m("\\varsubsetneq","\\html@mathml{\\@varsubsetneq}{\u228A}");m("\\varsubsetneqq","\\html@mathml{\\@varsubsetneqq}{\u2ACB}");m("\\varsupsetneq","\\html@mathml{\\@varsupsetneq}{\u228B}");m("\\varsupsetneqq","\\html@mathml{\\@varsupsetneqq}{\u2ACC}");m("\\imath","\\html@mathml{\\@imath}{\u0131}");m("\\jmath","\\html@mathml{\\@jmath}{\u0237}");m("\\llbracket","\\html@mathml{\\mathopen{[\\mkern-3.2mu[}}{\\mathopen{\\char`\u27E6}}");m("\\rrbracket","\\html@mathml{\\mathclose{]\\mkern-3.2mu]}}{\\mathclose{\\char`\u27E7}}");m("\u27E6","\\llbracket");m("\u27E7","\\rrbracket");m("\\lBrace","\\html@mathml{\\mathopen{\\{\\mkern-3.2mu[}}{\\mathopen{\\char`\u2983}}");m("\\rBrace","\\html@mathml{\\mathclose{]\\mkern-3.2mu\\}}}{\\mathclose{\\char`\u2984}}");m("\u2983","\\lBrace");m("\u2984","\\rBrace");m("\\minuso","\\mathbin{\\html@mathml{{\\mathrlap{\\mathchoice{\\kern{0.145em}}{\\kern{0.145em}}{\\kern{0.1015em}}{\\kern{0.0725em}}\\circ}{-}}}{\\char`\u29B5}}");m("\u29B5","\\minuso");m("\\darr","\\downarrow");m("\\dArr","\\Downarrow");m("\\Darr","\\Downarrow");m("\\lang","\\langle");m("\\rang","\\rangle");m("\\uarr","\\uparrow");m("\\uArr","\\Uparrow");m("\\Uarr","\\Uparrow");m("\\N","\\mathbb{N}");m("\\R","\\mathbb{R}");m("\\Z","\\mathbb{Z}");m("\\alef","\\aleph");m("\\alefsym","\\aleph");m("\\Alpha","\\mathrm{A}");m("\\Beta","\\mathrm{B}");m("\\bull","\\bullet");m("\\Chi","\\mathrm{X}");m("\\clubs","\\clubsuit");m("\\cnums","\\mathbb{C}");m("\\Complex","\\mathbb{C}");m("\\Dagger","\\ddagger");m("\\diamonds","\\diamondsuit");m("\\empty","\\emptyset");m("\\Epsilon","\\mathrm{E}");m("\\Eta","\\mathrm{H}");m("\\exist","\\exists");m("\\harr","\\leftrightarrow");m("\\hArr","\\Leftrightarrow");m("\\Harr","\\Leftrightarrow");m("\\hearts","\\heartsuit");m("\\image","\\Im");m("\\infin","\\infty");m("\\Iota","\\mathrm{I}");m("\\isin","\\in");m("\\Kappa","\\mathrm{K}");m("\\larr","\\leftarrow");m("\\lArr","\\Leftarrow");m("\\Larr","\\Leftarrow");m("\\lrarr","\\leftrightarrow");m("\\lrArr","\\Leftrightarrow");m("\\Lrarr","\\Leftrightarrow");m("\\Mu","\\mathrm{M}");m("\\natnums","\\mathbb{N}");m("\\Nu","\\mathrm{N}");m("\\Omicron","\\mathrm{O}");m("\\plusmn","\\pm");m("\\rarr","\\rightarrow");m("\\rArr","\\Rightarrow");m("\\Rarr","\\Rightarrow");m("\\real","\\Re");m("\\reals","\\mathbb{R}");m("\\Reals","\\mathbb{R}");m("\\Rho","\\mathrm{P}");m("\\sdot","\\cdot");m("\\sect","\\S");m("\\spades","\\spadesuit");m("\\sub","\\subset");m("\\sube","\\subseteq");m("\\supe","\\supseteq");m("\\Tau","\\mathrm{T}");m("\\thetasym","\\vartheta");m("\\weierp","\\wp");m("\\Zeta","\\mathrm{Z}");m("\\argmin","\\DOTSB\\operatorname*{arg\\,min}");m("\\argmax","\\DOTSB\\operatorname*{arg\\,max}");m("\\plim","\\DOTSB\\mathop{\\operatorname{plim}}\\limits");m("\\bra","\\mathinner{\\langle{#1}|}");m("\\ket","\\mathinner{|{#1}\\rangle}");m("\\braket","\\mathinner{\\langle{#1}\\rangle}");m("\\Bra","\\left\\langle#1\\right|");m("\\Ket","\\left|#1\\right\\rangle");var va=r=>e=>{var t=e.consumeArg().tokens,a=e.consumeArg().tokens,n=e.consumeArg().tokens,s=e.consumeArg().tokens,l=e.macros.get("|"),h=e.macros.get("\\|");e.macros.beginGroup();var c=b=>x=>{r&&(x.macros.set("|",l),n.length&&x.macros.set("\\|",h));var w=b;if(!b&&n.length){var A=x.future();A.text==="|"&&(x.popToken(),w=!0)}return{tokens:w?n:a,numArgs:0}};e.macros.set("|",c(!1)),n.length&&e.macros.set("\\|",c(!0));var f=e.consumeArg().tokens,v=e.expandTokens([...s,...f,...t]);return e.macros.endGroup(),{tokens:v.reverse(),numArgs:0}};m("\\bra@ket",va(!1));m("\\bra@set",va(!0));m("\\Braket","\\bra@ket{\\left\\langle}{\\,\\middle\\vert\\,}{\\,\\middle\\vert\\,}{\\right\\rangle}");m("\\Set","\\bra@set{\\left\\{\\:}{\\;\\middle\\vert\\;}{\\;\\middle\\Vert\\;}{\\:\\right\\}}");m("\\set","\\bra@set{\\{\\,}{\\mid}{}{\\,\\}}");m("\\angln","{\\angl n}");m("\\blue","\\textcolor{##6495ed}{#1}");m("\\orange","\\textcolor{##ffa500}{#1}");m("\\pink","\\textcolor{##ff00af}{#1}");m("\\red","\\textcolor{##df0030}{#1}");m("\\green","\\textcolor{##28ae7b}{#1}");m("\\gray","\\textcolor{gray}{#1}");m("\\purple","\\textcolor{##9d38bd}{#1}");m("\\blueA","\\textcolor{##ccfaff}{#1}");m("\\blueB","\\textcolor{##80f6ff}{#1}");m("\\blueC","\\textcolor{##63d9ea}{#1}");m("\\blueD","\\textcolor{##11accd}{#1}");m("\\blueE","\\textcolor{##0c7f99}{#1}");m("\\tealA","\\textcolor{##94fff5}{#1}");m("\\tealB","\\textcolor{##26edd5}{#1}");m("\\tealC","\\textcolor{##01d1c1}{#1}");m("\\tealD","\\textcolor{##01a995}{#1}");m("\\tealE","\\textcolor{##208170}{#1}");m("\\greenA","\\textcolor{##b6ffb0}{#1}");m("\\greenB","\\textcolor{##8af281}{#1}");m("\\greenC","\\textcolor{##74cf70}{#1}");m("\\greenD","\\textcolor{##1fab54}{#1}");m("\\greenE","\\textcolor{##0d923f}{#1}");m("\\goldA","\\textcolor{##ffd0a9}{#1}");m("\\goldB","\\textcolor{##ffbb71}{#1}");m("\\goldC","\\textcolor{##ff9c39}{#1}");m("\\goldD","\\textcolor{##e07d10}{#1}");m("\\goldE","\\textcolor{##a75a05}{#1}");m("\\redA","\\textcolor{##fca9a9}{#1}");m("\\redB","\\textcolor{##ff8482}{#1}");m("\\redC","\\textcolor{##f9685d}{#1}");m("\\redD","\\textcolor{##e84d39}{#1}");m("\\redE","\\textcolor{##bc2612}{#1}");m("\\maroonA","\\textcolor{##ffbde0}{#1}");m("\\maroonB","\\textcolor{##ff92c6}{#1}");m("\\maroonC","\\textcolor{##ed5fa6}{#1}");m("\\maroonD","\\textcolor{##ca337c}{#1}");m("\\maroonE","\\textcolor{##9e034e}{#1}");m("\\purpleA","\\textcolor{##ddd7ff}{#1}");m("\\purpleB","\\textcolor{##c6b9fc}{#1}");m("\\purpleC","\\textcolor{##aa87ff}{#1}");m("\\purpleD","\\textcolor{##7854ab}{#1}");m("\\purpleE","\\textcolor{##543b78}{#1}");m("\\mintA","\\textcolor{##f5f9e8}{#1}");m("\\mintB","\\textcolor{##edf2df}{#1}");m("\\mintC","\\textcolor{##e0e5cc}{#1}");m("\\grayA","\\textcolor{##f6f7f7}{#1}");m("\\grayB","\\textcolor{##f0f1f2}{#1}");m("\\grayC","\\textcolor{##e3e5e6}{#1}");m("\\grayD","\\textcolor{##d6d8da}{#1}");m("\\grayE","\\textcolor{##babec2}{#1}");m("\\grayF","\\textcolor{##888d93}{#1}");m("\\grayG","\\textcolor{##626569}{#1}");m("\\grayH","\\textcolor{##3b3e40}{#1}");m("\\grayI","\\textcolor{##21242c}{#1}");m("\\kaBlue","\\textcolor{##314453}{#1}");m("\\kaGreen","\\textcolor{##71B307}{#1}");var ga={"^":!0,_:!0,"\\limits":!0,"\\nolimits":!0},zt=class{constructor(e,t,a){this.settings=void 0,this.expansionCount=void 0,this.lexer=void 0,this.macros=void 0,this.stack=void 0,this.mode=void 0,this.settings=t,this.expansionCount=0,this.feed(e),this.macros=new Mt(fn,t.macros),this.mode=a,this.stack=[]}feed(e){this.lexer=new Pe(e,this.settings)}switchMode(e){this.mode=e}beginGroup(){this.macros.beginGroup()}endGroup(){this.macros.endGroup()}endGroups(){this.macros.endGroups()}future(){return this.stack.length===0&&this.pushToken(this.lexer.lex()),this.stack[this.stack.length-1]}popToken(){return this.future(),this.stack.pop()}pushToken(e){this.stack.push(e)}pushTokens(e){this.stack.push(...e)}scanArgument(e){var t,a,n;if(e){if(this.consumeSpaces(),this.future().text!=="[")return null;t=this.popToken(),{tokens:n,end:a}=this.consumeArg(["]"])}else({tokens:n,start:t,end:a}=this.consumeArg());return this.pushToken(new b0("EOF",a.loc)),this.pushTokens(n),t.range(a,"")}consumeSpaces(){for(;;){var e=this.future();if(e.text===" ")this.stack.pop();else break}}consumeArg(e){var t=[],a=e&&e.length>0;a||this.consumeSpaces();var n=this.future(),s,l=0,h=0;do{if(s=this.popToken(),t.push(s),s.text==="{")++l;else if(s.text==="}"){if(--l,l===-1)throw new z("Extra }",s)}else if(s.text==="EOF")throw new z("Unexpected end of input in a macro argument, expected '"+(e&&a?e[h]:"}")+"'",s);if(e&&a)if((l===0||l===1&&e[h]==="{")&&s.text===e[h]){if(++h,h===e.length){t.splice(-h,h);break}}else h=0}while(l!==0||a);return n.text==="{"&&t[t.length-1].text==="}"&&(t.pop(),t.shift()),t.reverse(),{tokens:t,start:n,end:s}}consumeArgs(e,t){if(t){if(t.length!==e+1)throw new z("The length of delimiters doesn't match the number of args!");for(var a=t[0],n=0;nthis.settings.maxExpand)throw new z("Too many expansions: infinite loop or need to increase maxExpand setting")}expandOnce(e){var t=this.popToken(),a=t.text,n=t.noexpand?null:this._getExpansion(a);if(n==null||e&&n.unexpandable){if(e&&n==null&&a[0]==="\\"&&!this.isDefined(a))throw new z("Undefined control sequence: "+a);return this.pushToken(t),!1}this.countExpansion(1);var s=n.tokens,l=this.consumeArgs(n.numArgs,n.delimiters);if(n.numArgs){s=s.slice();for(var h=s.length-1;h>=0;--h){var c=s[h];if(c.text==="#"){if(h===0)throw new z("Incomplete placeholder at end of macro body",c);if(c=s[--h],c.text==="#")s.splice(h+1,1);else if(/^[1-9]$/.test(c.text))s.splice(h,2,...l[+c.text-1]);else throw new z("Not a valid argument number",c)}}}return this.pushTokens(s),s.length}expandAfterFuture(){return this.expandOnce(),this.future()}expandNextToken(){for(;;)if(this.expandOnce()===!1){var e=this.stack.pop();return e.treatAsRelax&&(e.text="\\relax"),e}throw new Error}expandMacro(e){return this.macros.has(e)?this.expandTokens([new b0(e)]):void 0}expandTokens(e){var t=[],a=this.stack.length;for(this.pushTokens(e);this.stack.length>a;)if(this.expandOnce(!0)===!1){var n=this.stack.pop();n.treatAsRelax&&(n.noexpand=!1,n.treatAsRelax=!1),t.push(n)}return this.countExpansion(t.length),t}expandMacroAsText(e){var t=this.expandMacro(e);return t&&t.map(a=>a.text).join("")}_getExpansion(e){var t=this.macros.get(e);if(t==null)return t;if(e.length===1){var a=this.lexer.catcodes[e];if(a!=null&&a!==13)return}var n=typeof t=="function"?t(this):t;if(typeof n=="string"){var s=0;if(n.indexOf("#")!==-1)for(var l=n.replace(/##/g,"");l.indexOf("#"+(s+1))!==-1;)++s;for(var h=new Pe(n,this.settings),c=[],f=h.lex();f.text!=="EOF";)c.push(f),f=h.lex();c.reverse();var v={tokens:c,numArgs:s};return v}return n}isDefined(e){return this.macros.has(e)||P0.hasOwnProperty(e)||Y.math.hasOwnProperty(e)||Y.text.hasOwnProperty(e)||ga.hasOwnProperty(e)}isExpandable(e){var t=this.macros.get(e);return t!=null?typeof t=="string"||typeof t=="function"||!t.unexpandable:P0.hasOwnProperty(e)&&!P0[e].primitive}},kr=/^[₊₋₌₍₎₀₁₂₃₄₅₆₇₈₉ₐₑₕᵢⱼₖₗₘₙₒₚᵣₛₜᵤᵥₓᵦᵧᵨᵩᵪ]/,Ne=Object.freeze({"\u208A":"+","\u208B":"-","\u208C":"=","\u208D":"(","\u208E":")","\u2080":"0","\u2081":"1","\u2082":"2","\u2083":"3","\u2084":"4","\u2085":"5","\u2086":"6","\u2087":"7","\u2088":"8","\u2089":"9","\u2090":"a","\u2091":"e","\u2095":"h","\u1D62":"i","\u2C7C":"j","\u2096":"k","\u2097":"l","\u2098":"m","\u2099":"n","\u2092":"o","\u209A":"p","\u1D63":"r","\u209B":"s","\u209C":"t","\u1D64":"u","\u1D65":"v","\u2093":"x","\u1D66":"\u03B2","\u1D67":"\u03B3","\u1D68":"\u03C1","\u1D69":"\u03D5","\u1D6A":"\u03C7","\u207A":"+","\u207B":"-","\u207C":"=","\u207D":"(","\u207E":")","\u2070":"0","\xB9":"1","\xB2":"2","\xB3":"3","\u2074":"4","\u2075":"5","\u2076":"6","\u2077":"7","\u2078":"8","\u2079":"9","\u1D2C":"A","\u1D2E":"B","\u1D30":"D","\u1D31":"E","\u1D33":"G","\u1D34":"H","\u1D35":"I","\u1D36":"J","\u1D37":"K","\u1D38":"L","\u1D39":"M","\u1D3A":"N","\u1D3C":"O","\u1D3E":"P","\u1D3F":"R","\u1D40":"T","\u1D41":"U","\u2C7D":"V","\u1D42":"W","\u1D43":"a","\u1D47":"b","\u1D9C":"c","\u1D48":"d","\u1D49":"e","\u1DA0":"f","\u1D4D":"g",\u02B0:"h","\u2071":"i",\u02B2:"j","\u1D4F":"k",\u02E1:"l","\u1D50":"m",\u207F:"n","\u1D52":"o","\u1D56":"p",\u02B3:"r",\u02E2:"s","\u1D57":"t","\u1D58":"u","\u1D5B":"v",\u02B7:"w",\u02E3:"x",\u02B8:"y","\u1DBB":"z","\u1D5D":"\u03B2","\u1D5E":"\u03B3","\u1D5F":"\u03B4","\u1D60":"\u03D5","\u1D61":"\u03C7","\u1DBF":"\u03B8"}),dt={"\u0301":{text:"\\'",math:"\\acute"},"\u0300":{text:"\\`",math:"\\grave"},"\u0308":{text:'\\"',math:"\\ddot"},"\u0303":{text:"\\~",math:"\\tilde"},"\u0304":{text:"\\=",math:"\\bar"},"\u0306":{text:"\\u",math:"\\breve"},"\u030C":{text:"\\v",math:"\\check"},"\u0302":{text:"\\^",math:"\\hat"},"\u0307":{text:"\\.",math:"\\dot"},"\u030A":{text:"\\r",math:"\\mathring"},"\u030B":{text:"\\H"},"\u0327":{text:"\\c"}},Mr={\u00E1:"a\u0301",\u00E0:"a\u0300",\u00E4:"a\u0308",\u01DF:"a\u0308\u0304",\u00E3:"a\u0303",\u0101:"a\u0304",\u0103:"a\u0306",\u1EAF:"a\u0306\u0301",\u1EB1:"a\u0306\u0300",\u1EB5:"a\u0306\u0303",\u01CE:"a\u030C",\u00E2:"a\u0302",\u1EA5:"a\u0302\u0301",\u1EA7:"a\u0302\u0300",\u1EAB:"a\u0302\u0303",\u0227:"a\u0307",\u01E1:"a\u0307\u0304",\u00E5:"a\u030A",\u01FB:"a\u030A\u0301",\u1E03:"b\u0307",\u0107:"c\u0301",\u1E09:"c\u0327\u0301",\u010D:"c\u030C",\u0109:"c\u0302",\u010B:"c\u0307",\u00E7:"c\u0327",\u010F:"d\u030C",\u1E0B:"d\u0307",\u1E11:"d\u0327",\u00E9:"e\u0301",\u00E8:"e\u0300",\u00EB:"e\u0308",\u1EBD:"e\u0303",\u0113:"e\u0304",\u1E17:"e\u0304\u0301",\u1E15:"e\u0304\u0300",\u0115:"e\u0306",\u1E1D:"e\u0327\u0306",\u011B:"e\u030C",\u00EA:"e\u0302",\u1EBF:"e\u0302\u0301",\u1EC1:"e\u0302\u0300",\u1EC5:"e\u0302\u0303",\u0117:"e\u0307",\u0229:"e\u0327",\u1E1F:"f\u0307",\u01F5:"g\u0301",\u1E21:"g\u0304",\u011F:"g\u0306",\u01E7:"g\u030C",\u011D:"g\u0302",\u0121:"g\u0307",\u0123:"g\u0327",\u1E27:"h\u0308",\u021F:"h\u030C",\u0125:"h\u0302",\u1E23:"h\u0307",\u1E29:"h\u0327",\u00ED:"i\u0301",\u00EC:"i\u0300",\u00EF:"i\u0308",\u1E2F:"i\u0308\u0301",\u0129:"i\u0303",\u012B:"i\u0304",\u012D:"i\u0306",\u01D0:"i\u030C",\u00EE:"i\u0302",\u01F0:"j\u030C",\u0135:"j\u0302",\u1E31:"k\u0301",\u01E9:"k\u030C",\u0137:"k\u0327",\u013A:"l\u0301",\u013E:"l\u030C",\u013C:"l\u0327",\u1E3F:"m\u0301",\u1E41:"m\u0307",\u0144:"n\u0301",\u01F9:"n\u0300",\u00F1:"n\u0303",\u0148:"n\u030C",\u1E45:"n\u0307",\u0146:"n\u0327",\u00F3:"o\u0301",\u00F2:"o\u0300",\u00F6:"o\u0308",\u022B:"o\u0308\u0304",\u00F5:"o\u0303",\u1E4D:"o\u0303\u0301",\u1E4F:"o\u0303\u0308",\u022D:"o\u0303\u0304",\u014D:"o\u0304",\u1E53:"o\u0304\u0301",\u1E51:"o\u0304\u0300",\u014F:"o\u0306",\u01D2:"o\u030C",\u00F4:"o\u0302",\u1ED1:"o\u0302\u0301",\u1ED3:"o\u0302\u0300",\u1ED7:"o\u0302\u0303",\u022F:"o\u0307",\u0231:"o\u0307\u0304",\u0151:"o\u030B",\u1E55:"p\u0301",\u1E57:"p\u0307",\u0155:"r\u0301",\u0159:"r\u030C",\u1E59:"r\u0307",\u0157:"r\u0327",\u015B:"s\u0301",\u1E65:"s\u0301\u0307",\u0161:"s\u030C",\u1E67:"s\u030C\u0307",\u015D:"s\u0302",\u1E61:"s\u0307",\u015F:"s\u0327",\u1E97:"t\u0308",\u0165:"t\u030C",\u1E6B:"t\u0307",\u0163:"t\u0327",\u00FA:"u\u0301",\u00F9:"u\u0300",\u00FC:"u\u0308",\u01D8:"u\u0308\u0301",\u01DC:"u\u0308\u0300",\u01D6:"u\u0308\u0304",\u01DA:"u\u0308\u030C",\u0169:"u\u0303",\u1E79:"u\u0303\u0301",\u016B:"u\u0304",\u1E7B:"u\u0304\u0308",\u016D:"u\u0306",\u01D4:"u\u030C",\u00FB:"u\u0302",\u016F:"u\u030A",\u0171:"u\u030B",\u1E7D:"v\u0303",\u1E83:"w\u0301",\u1E81:"w\u0300",\u1E85:"w\u0308",\u0175:"w\u0302",\u1E87:"w\u0307",\u1E98:"w\u030A",\u1E8D:"x\u0308",\u1E8B:"x\u0307",\u00FD:"y\u0301",\u1EF3:"y\u0300",\u00FF:"y\u0308",\u1EF9:"y\u0303",\u0233:"y\u0304",\u0177:"y\u0302",\u1E8F:"y\u0307",\u1E99:"y\u030A",\u017A:"z\u0301",\u017E:"z\u030C",\u1E91:"z\u0302",\u017C:"z\u0307",\u00C1:"A\u0301",\u00C0:"A\u0300",\u00C4:"A\u0308",\u01DE:"A\u0308\u0304",\u00C3:"A\u0303",\u0100:"A\u0304",\u0102:"A\u0306",\u1EAE:"A\u0306\u0301",\u1EB0:"A\u0306\u0300",\u1EB4:"A\u0306\u0303",\u01CD:"A\u030C",\u00C2:"A\u0302",\u1EA4:"A\u0302\u0301",\u1EA6:"A\u0302\u0300",\u1EAA:"A\u0302\u0303",\u0226:"A\u0307",\u01E0:"A\u0307\u0304",\u00C5:"A\u030A",\u01FA:"A\u030A\u0301",\u1E02:"B\u0307",\u0106:"C\u0301",\u1E08:"C\u0327\u0301",\u010C:"C\u030C",\u0108:"C\u0302",\u010A:"C\u0307",\u00C7:"C\u0327",\u010E:"D\u030C",\u1E0A:"D\u0307",\u1E10:"D\u0327",\u00C9:"E\u0301",\u00C8:"E\u0300",\u00CB:"E\u0308",\u1EBC:"E\u0303",\u0112:"E\u0304",\u1E16:"E\u0304\u0301",\u1E14:"E\u0304\u0300",\u0114:"E\u0306",\u1E1C:"E\u0327\u0306",\u011A:"E\u030C",\u00CA:"E\u0302",\u1EBE:"E\u0302\u0301",\u1EC0:"E\u0302\u0300",\u1EC4:"E\u0302\u0303",\u0116:"E\u0307",\u0228:"E\u0327",\u1E1E:"F\u0307",\u01F4:"G\u0301",\u1E20:"G\u0304",\u011E:"G\u0306",\u01E6:"G\u030C",\u011C:"G\u0302",\u0120:"G\u0307",\u0122:"G\u0327",\u1E26:"H\u0308",\u021E:"H\u030C",\u0124:"H\u0302",\u1E22:"H\u0307",\u1E28:"H\u0327",\u00CD:"I\u0301",\u00CC:"I\u0300",\u00CF:"I\u0308",\u1E2E:"I\u0308\u0301",\u0128:"I\u0303",\u012A:"I\u0304",\u012C:"I\u0306",\u01CF:"I\u030C",\u00CE:"I\u0302",\u0130:"I\u0307",\u0134:"J\u0302",\u1E30:"K\u0301",\u01E8:"K\u030C",\u0136:"K\u0327",\u0139:"L\u0301",\u013D:"L\u030C",\u013B:"L\u0327",\u1E3E:"M\u0301",\u1E40:"M\u0307",\u0143:"N\u0301",\u01F8:"N\u0300",\u00D1:"N\u0303",\u0147:"N\u030C",\u1E44:"N\u0307",\u0145:"N\u0327",\u00D3:"O\u0301",\u00D2:"O\u0300",\u00D6:"O\u0308",\u022A:"O\u0308\u0304",\u00D5:"O\u0303",\u1E4C:"O\u0303\u0301",\u1E4E:"O\u0303\u0308",\u022C:"O\u0303\u0304",\u014C:"O\u0304",\u1E52:"O\u0304\u0301",\u1E50:"O\u0304\u0300",\u014E:"O\u0306",\u01D1:"O\u030C",\u00D4:"O\u0302",\u1ED0:"O\u0302\u0301",\u1ED2:"O\u0302\u0300",\u1ED6:"O\u0302\u0303",\u022E:"O\u0307",\u0230:"O\u0307\u0304",\u0150:"O\u030B",\u1E54:"P\u0301",\u1E56:"P\u0307",\u0154:"R\u0301",\u0158:"R\u030C",\u1E58:"R\u0307",\u0156:"R\u0327",\u015A:"S\u0301",\u1E64:"S\u0301\u0307",\u0160:"S\u030C",\u1E66:"S\u030C\u0307",\u015C:"S\u0302",\u1E60:"S\u0307",\u015E:"S\u0327",\u0164:"T\u030C",\u1E6A:"T\u0307",\u0162:"T\u0327",\u00DA:"U\u0301",\u00D9:"U\u0300",\u00DC:"U\u0308",\u01D7:"U\u0308\u0301",\u01DB:"U\u0308\u0300",\u01D5:"U\u0308\u0304",\u01D9:"U\u0308\u030C",\u0168:"U\u0303",\u1E78:"U\u0303\u0301",\u016A:"U\u0304",\u1E7A:"U\u0304\u0308",\u016C:"U\u0306",\u01D3:"U\u030C",\u00DB:"U\u0302",\u016E:"U\u030A",\u0170:"U\u030B",\u1E7C:"V\u0303",\u1E82:"W\u0301",\u1E80:"W\u0300",\u1E84:"W\u0308",\u0174:"W\u0302",\u1E86:"W\u0307",\u1E8C:"X\u0308",\u1E8A:"X\u0307",\u00DD:"Y\u0301",\u1EF2:"Y\u0300",\u0178:"Y\u0308",\u1EF8:"Y\u0303",\u0232:"Y\u0304",\u0176:"Y\u0302",\u1E8E:"Y\u0307",\u0179:"Z\u0301",\u017D:"Z\u030C",\u1E90:"Z\u0302",\u017B:"Z\u0307",\u03AC:"\u03B1\u0301",\u1F70:"\u03B1\u0300",\u1FB1:"\u03B1\u0304",\u1FB0:"\u03B1\u0306",\u03AD:"\u03B5\u0301",\u1F72:"\u03B5\u0300",\u03AE:"\u03B7\u0301",\u1F74:"\u03B7\u0300",\u03AF:"\u03B9\u0301",\u1F76:"\u03B9\u0300",\u03CA:"\u03B9\u0308",\u0390:"\u03B9\u0308\u0301",\u1FD2:"\u03B9\u0308\u0300",\u1FD1:"\u03B9\u0304",\u1FD0:"\u03B9\u0306",\u03CC:"\u03BF\u0301",\u1F78:"\u03BF\u0300",\u03CD:"\u03C5\u0301",\u1F7A:"\u03C5\u0300",\u03CB:"\u03C5\u0308",\u03B0:"\u03C5\u0308\u0301",\u1FE2:"\u03C5\u0308\u0300",\u1FE1:"\u03C5\u0304",\u1FE0:"\u03C5\u0306",\u03CE:"\u03C9\u0301",\u1F7C:"\u03C9\u0300",\u038E:"\u03A5\u0301",\u1FEA:"\u03A5\u0300",\u03AB:"\u03A5\u0308",\u1FE9:"\u03A5\u0304",\u1FE8:"\u03A5\u0306",\u038F:"\u03A9\u0301",\u1FFA:"\u03A9\u0300"},Ge=class r{constructor(e,t){this.mode=void 0,this.gullet=void 0,this.settings=void 0,this.leftrightDepth=void 0,this.nextToken=void 0,this.mode="math",this.gullet=new zt(e,t,this.mode),this.settings=t,this.leftrightDepth=0}expect(e,t){if(t===void 0&&(t=!0),this.fetch().text!==e)throw new z("Expected '"+e+"', got '"+this.fetch().text+"'",this.fetch());t&&this.consume()}consume(){this.nextToken=null}fetch(){return this.nextToken==null&&(this.nextToken=this.gullet.expandNextToken()),this.nextToken}switchMode(e){this.mode=e,this.gullet.switchMode(e)}parse(){this.settings.globalGroup||this.gullet.beginGroup(),this.settings.colorIsTextColor&&this.gullet.macros.set("\\color","\\textcolor");try{var e=this.parseExpression(!1);return this.expect("EOF"),this.settings.globalGroup||this.gullet.endGroup(),e}finally{this.gullet.endGroups()}}subparse(e){var t=this.nextToken;this.consume(),this.gullet.pushToken(new b0("}")),this.gullet.pushTokens(e);var a=this.parseExpression(!1);return this.expect("}"),this.nextToken=t,a}parseExpression(e,t){for(var a=[];;){this.mode==="math"&&this.consumeSpaces();var n=this.fetch();if(r.endOfExpression.indexOf(n.text)!==-1||t&&n.text===t||e&&P0[n.text]&&P0[n.text].infix)break;var s=this.parseAtom(t);if(s){if(s.type==="internal")continue}else break;a.push(s)}return this.mode==="text"&&this.formLigatures(a),this.handleInfixNodes(a)}handleInfixNodes(e){for(var t=-1,a,n=0;n=0&&this.settings.reportNonstrict("unicodeTextInMathMode",'Latin-1/Unicode text character "'+t[0]+'" used in math mode',e);var h=Y[this.mode][t].group,c=d0.range(e),f;if(i1.hasOwnProperty(h)){var v=h;f={type:"atom",mode:this.mode,family:v,loc:c,text:t}}else f={type:h,mode:this.mode,loc:c,text:t};l=f}else if(t.charCodeAt(0)>=128)this.settings.strict&&(Ar(t.charCodeAt(0))?this.mode==="math"&&this.settings.reportNonstrict("unicodeTextInMathMode",'Unicode text character "'+t[0]+'" used in math mode',e):this.settings.reportNonstrict("unknownSymbol",'Unrecognized Unicode character "'+t[0]+'"'+(" ("+t.charCodeAt(0)+")"),e)),l={type:"textord",mode:"text",loc:d0.range(e),text:t};else return null;if(this.consume(),s)for(var b=0;b=0;n--)r[n].loc.start>a&&(t+=" ",a=r[n].loc.start),t+=r[n].text,a+=r[n].text.length;var s=W.go(S.go(t,e));return s},S={go:function(r,e){if(!r)return[];e===void 0&&(e="ce");var t="0",a={};a.parenthesisLevel=0,r=r.replace(/\n/g," "),r=r.replace(/[\u2212\u2013\u2014\u2010]/g,"-"),r=r.replace(/[\u2026]/g,"...");for(var n,s=10,l=[];;){n!==r?(s=10,n=r):s--;var h=S.stateMachines[e],c=h.transitions[t]||h.transitions["*"];e:for(var f=0;f0){if(b.revisit||(r=v.remainder),!b.toContinue)break e}else return l}}if(s<=0)throw["MhchemBugU","mhchem bug U. Please report."]}},concatArray:function(r,e){if(e)if(Array.isArray(e))for(var t=0;t":/^[=<>]/,"#":/^[#\u2261]/,"+":/^\+/,"-$":/^-(?=[\s_},;\]/]|$|\([a-z]+\))/,"-9":/^-(?=[0-9])/,"- orbital overlap":/^-(?=(?:[spd]|sp)(?:$|[\s,;\)\]\}]))/,"-":/^-/,"pm-operator":/^(?:\\pm|\$\\pm\$|\+-|\+\/-)/,operator:/^(?:\+|(?:[\-=<>]|<<|>>|\\approx|\$\\approx\$)(?=\s|$|-?[0-9]))/,arrowUpDown:/^(?:v|\(v\)|\^|\(\^\))(?=$|[\s,;\)\]\}])/,"\\bond{(...)}":function(r){return S.patterns.findObserveGroups(r,"\\bond{","","","}")},"->":/^(?:<->|<-->|->|<-|<=>>|<<=>|<=>|[\u2192\u27F6\u21CC])/,CMT:/^[CMT](?=\[)/,"[(...)]":function(r){return S.patterns.findObserveGroups(r,"[","","","]")},"1st-level escape":/^(&|\\\\|\\hline)\s*/,"\\,":/^(?:\\[,\ ;:])/,"\\x{}{}":function(r){return S.patterns.findObserveGroups(r,"",/^\\[a-zA-Z]+\{/,"}","","","{","}","",!0)},"\\x{}":function(r){return S.patterns.findObserveGroups(r,"",/^\\[a-zA-Z]+\{/,"}","")},"\\ca":/^\\ca(?:\s+|(?![a-zA-Z]))/,"\\x":/^(?:\\[a-zA-Z]+\s*|\\[_&{}%])/,orbital:/^(?:[0-9]{1,2}[spdfgh]|[0-9]{0,2}sp)(?=$|[^a-zA-Z])/,others:/^[\/~|]/,"\\frac{(...)}":function(r){return S.patterns.findObserveGroups(r,"\\frac{","","","}","{","","","}")},"\\overset{(...)}":function(r){return S.patterns.findObserveGroups(r,"\\overset{","","","}","{","","","}")},"\\underset{(...)}":function(r){return S.patterns.findObserveGroups(r,"\\underset{","","","}","{","","","}")},"\\underbrace{(...)}":function(r){return S.patterns.findObserveGroups(r,"\\underbrace{","","","}_","{","","","}")},"\\color{(...)}0":function(r){return S.patterns.findObserveGroups(r,"\\color{","","","}")},"\\color{(...)}{(...)}1":function(r){return S.patterns.findObserveGroups(r,"\\color{","","","}","{","","","}")},"\\color(...){(...)}2":function(r){return S.patterns.findObserveGroups(r,"\\color","\\","",/^(?=\{)/,"{","","","}")},"\\ce{(...)}":function(r){return S.patterns.findObserveGroups(r,"\\ce{","","","}")},oxidation$:/^(?:[+-][IVX]+|\\pm\s*0|\$\\pm\$\s*0)$/,"d-oxidation$":/^(?:[+-]?\s?[IVX]+|\\pm\s*0|\$\\pm\$\s*0)$/,"roman numeral":/^[IVX]+/,"1/2$":/^[+\-]?(?:[0-9]+|\$[a-z]\$|[a-z])\/[0-9]+(?:\$[a-z]\$|[a-z])?$/,amount:function(r){var e;if(e=r.match(/^(?:(?:(?:\([+\-]?[0-9]+\/[0-9]+\)|[+\-]?(?:[0-9]+|\$[a-z]\$|[a-z])\/[0-9]+|[+\-]?[0-9]+[.,][0-9]+|[+\-]?\.[0-9]+|[+\-]?[0-9]+)(?:[a-z](?=\s*[A-Z]))?)|[+\-]?[a-z](?=\s*[A-Z])|\+(?!\s))/),e)return{match_:e[0],remainder:r.substr(e[0].length)};var t=S.patterns.findObserveGroups(r,"","$","$","");return t&&(e=t.match_.match(/^\$(?:\(?[+\-]?(?:[0-9]*[a-z]?[+\-])?[0-9]*[a-z](?:[+\-][0-9]*[a-z]?)?\)?|\+|-)\$$/),e)?{match_:e[0],remainder:r.substr(e[0].length)}:null},amount2:function(r){return this.amount(r)},"(KV letters),":/^(?:[A-Z][a-z]{0,2}|i)(?=,)/,formula$:function(r){if(r.match(/^\([a-z]+\)$/))return null;var e=r.match(/^(?:[a-z]|(?:[0-9\ \+\-\,\.\(\)]+[a-z])+[0-9\ \+\-\,\.\(\)]*|(?:[a-z][0-9\ \+\-\,\.\(\)]+)+[a-z]?)$/);return e?{match_:e[0],remainder:r.substr(e[0].length)}:null},uprightEntities:/^(?:pH|pOH|pC|pK|iPr|iBu)(?=$|[^a-zA-Z])/,"/":/^\s*(\/)\s*/,"//":/^\s*(\/\/)\s*/,"*":/^\s*[*.]\s*/},findObserveGroups:function(r,e,t,a,n,s,l,h,c,f){var v=function(D,N){if(typeof N=="string")return D.indexOf(N)!==0?null:N;var $=D.match(N);return $?$[0]:null},b=function(D,N,$){for(var H=0;N0,null},x=v(r,e);if(x===null||(r=r.substr(x.length),x=v(r,t),x===null))return null;var w=b(r,x.length,a||n);if(w===null)return null;var A=r.substring(0,a?w.endMatchEnd:w.endMatchBegin);if(s||l){var q=this.findObserveGroups(r.substr(w.endMatchEnd),s,l,h,c);if(q===null)return null;var _=[A,q.match_];return{match_:f?_.join(""):_,remainder:q.remainder}}else return{match_:A,remainder:r.substr(w.endMatchEnd)}},match_:function(r,e){var t=S.patterns.patterns[r];if(t===void 0)throw["MhchemBugP","mhchem bug P. Please report. ("+r+")"];if(typeof t=="function")return S.patterns.patterns[r](e);var a=e.match(t);if(a){var n;return a[2]?n=[a[1],a[2]]:a[1]?n=a[1]:n=a[0],{match_:n,remainder:e.substr(a[0].length)}}return null}},actions:{"a=":function(r,e){r.a=(r.a||"")+e},"b=":function(r,e){r.b=(r.b||"")+e},"p=":function(r,e){r.p=(r.p||"")+e},"o=":function(r,e){r.o=(r.o||"")+e},"q=":function(r,e){r.q=(r.q||"")+e},"d=":function(r,e){r.d=(r.d||"")+e},"rm=":function(r,e){r.rm=(r.rm||"")+e},"text=":function(r,e){r.text_=(r.text_||"")+e},insert:function(r,e,t){return{type_:t}},"insert+p1":function(r,e,t){return{type_:t,p1:e}},"insert+p1+p2":function(r,e,t){return{type_:t,p1:e[0],p2:e[1]}},copy:function(r,e){return e},rm:function(r,e){return{type_:"rm",p1:e||""}},text:function(r,e){return S.go(e,"text")},"{text}":function(r,e){var t=["{"];return S.concatArray(t,S.go(e,"text")),t.push("}"),t},"tex-math":function(r,e){return S.go(e,"tex-math")},"tex-math tight":function(r,e){return S.go(e,"tex-math tight")},bond:function(r,e,t){return{type_:"bond",kind_:t||e}},"color0-output":function(r,e){return{type_:"color0",color:e[0]}},ce:function(r,e){return S.go(e)},"1/2":function(r,e){var t=[];e.match(/^[+\-]/)&&(t.push(e.substr(0,1)),e=e.substr(1));var a=e.match(/^([0-9]+|\$[a-z]\$|[a-z])\/([0-9]+)(\$[a-z]\$|[a-z])?$/);return a[1]=a[1].replace(/\$/g,""),t.push({type_:"frac",p1:a[1],p2:a[2]}),a[3]&&(a[3]=a[3].replace(/\$/g,""),t.push({type_:"tex-math",p1:a[3]})),t},"9,9":function(r,e){return S.go(e,"9,9")}},createTransitions:function(r){var e,t,a,n,s={};for(e in r)for(t in r[e])for(a=t.split("|"),r[e][t].stateArray=a,n=0;n":{"0|1|2|3":{action_:"r=",nextState:"r"},"a|as":{action_:["output","r="],nextState:"r"},"*":{action_:["output","r="],nextState:"r"}},"+":{o:{action_:"d= kv",nextState:"d"},"d|D":{action_:"d=",nextState:"d"},q:{action_:"d=",nextState:"qd"},"qd|qD":{action_:"d=",nextState:"qd"},dq:{action_:["output","d="],nextState:"d"},3:{action_:["sb=false","output","operator"],nextState:"0"}},amount:{"0|2":{action_:"a=",nextState:"a"}},"pm-operator":{"0|1|2|a|as":{action_:["sb=false","output",{type_:"operator",option:"\\pm"}],nextState:"0"}},operator:{"0|1|2|a|as":{action_:["sb=false","output","operator"],nextState:"0"}},"-$":{"o|q":{action_:["charge or bond","output"],nextState:"qd"},d:{action_:"d=",nextState:"d"},D:{action_:["output",{type_:"bond",option:"-"}],nextState:"3"},q:{action_:"d=",nextState:"qd"},qd:{action_:"d=",nextState:"qd"},"qD|dq":{action_:["output",{type_:"bond",option:"-"}],nextState:"3"}},"-9":{"3|o":{action_:["output",{type_:"insert",option:"hyphen"}],nextState:"3"}},"- orbital overlap":{o:{action_:["output",{type_:"insert",option:"hyphen"}],nextState:"2"},d:{action_:["output",{type_:"insert",option:"hyphen"}],nextState:"2"}},"-":{"0|1|2":{action_:[{type_:"output",option:1},"beginsWithBond=true",{type_:"bond",option:"-"}],nextState:"3"},3:{action_:{type_:"bond",option:"-"}},a:{action_:["output",{type_:"insert",option:"hyphen"}],nextState:"2"},as:{action_:[{type_:"output",option:2},{type_:"bond",option:"-"}],nextState:"3"},b:{action_:"b="},o:{action_:{type_:"- after o/d",option:!1},nextState:"2"},q:{action_:{type_:"- after o/d",option:!1},nextState:"2"},"d|qd|dq":{action_:{type_:"- after o/d",option:!0},nextState:"2"},"D|qD|p":{action_:["output",{type_:"bond",option:"-"}],nextState:"3"}},amount2:{"1|3":{action_:"a=",nextState:"a"}},letters:{"0|1|2|3|a|as|b|p|bp|o":{action_:"o=",nextState:"o"},"q|dq":{action_:["output","o="],nextState:"o"},"d|D|qd|qD":{action_:"o after d",nextState:"o"}},digits:{o:{action_:"q=",nextState:"q"},"d|D":{action_:"q=",nextState:"dq"},q:{action_:["output","o="],nextState:"o"},a:{action_:"o=",nextState:"o"}},"space A":{"b|p|bp":{}},space:{a:{nextState:"as"},0:{action_:"sb=false"},"1|2":{action_:"sb=true"},"r|rt|rd|rdt|rdq":{action_:"output",nextState:"0"},"*":{action_:["output","sb=true"],nextState:"1"}},"1st-level escape":{"1|2":{action_:["output",{type_:"insert+p1",option:"1st-level escape"}]},"*":{action_:["output",{type_:"insert+p1",option:"1st-level escape"}],nextState:"0"}},"[(...)]":{"r|rt":{action_:"rd=",nextState:"rd"},"rd|rdt":{action_:"rq=",nextState:"rdq"}},"...":{"o|d|D|dq|qd|qD":{action_:["output",{type_:"bond",option:"..."}],nextState:"3"},"*":{action_:[{type_:"output",option:1},{type_:"insert",option:"ellipsis"}],nextState:"1"}},". |* ":{"*":{action_:["output",{type_:"insert",option:"addition compound"}],nextState:"1"}},"state of aggregation $":{"*":{action_:["output","state of aggregation"],nextState:"1"}},"{[(":{"a|as|o":{action_:["o=","output","parenthesisLevel++"],nextState:"2"},"0|1|2|3":{action_:["o=","output","parenthesisLevel++"],nextState:"2"},"*":{action_:["output","o=","output","parenthesisLevel++"],nextState:"2"}},")]}":{"0|1|2|3|b|p|bp|o":{action_:["o=","parenthesisLevel--"],nextState:"o"},"a|as|d|D|q|qd|qD|dq":{action_:["output","o=","parenthesisLevel--"],nextState:"o"}},", ":{"*":{action_:["output","comma"],nextState:"0"}},"^_":{"*":{}},"^{(...)}|^($...$)":{"0|1|2|as":{action_:"b=",nextState:"b"},p:{action_:"b=",nextState:"bp"},"3|o":{action_:"d= kv",nextState:"D"},q:{action_:"d=",nextState:"qD"},"d|D|qd|qD|dq":{action_:["output","d="],nextState:"D"}},"^a|^\\x{}{}|^\\x{}|^\\x|'":{"0|1|2|as":{action_:"b=",nextState:"b"},p:{action_:"b=",nextState:"bp"},"3|o":{action_:"d= kv",nextState:"d"},q:{action_:"d=",nextState:"qd"},"d|qd|D|qD":{action_:"d="},dq:{action_:["output","d="],nextState:"d"}},"_{(state of aggregation)}$":{"d|D|q|qd|qD|dq":{action_:["output","q="],nextState:"q"}},"_{(...)}|_($...$)|_9|_\\x{}{}|_\\x{}|_\\x":{"0|1|2|as":{action_:"p=",nextState:"p"},b:{action_:"p=",nextState:"bp"},"3|o":{action_:"q=",nextState:"q"},"d|D":{action_:"q=",nextState:"dq"},"q|qd|qD|dq":{action_:["output","q="],nextState:"q"}},"=<>":{"0|1|2|3|a|as|o|q|d|D|qd|qD|dq":{action_:[{type_:"output",option:2},"bond"],nextState:"3"}},"#":{"0|1|2|3|a|as|o":{action_:[{type_:"output",option:2},{type_:"bond",option:"#"}],nextState:"3"}},"{}":{"*":{action_:{type_:"output",option:1},nextState:"1"}},"{...}":{"0|1|2|3|a|as|b|p|bp":{action_:"o=",nextState:"o"},"o|d|D|q|qd|qD|dq":{action_:["output","o="],nextState:"o"}},"$...$":{a:{action_:"a="},"0|1|2|3|as|b|p|bp|o":{action_:"o=",nextState:"o"},"as|o":{action_:"o="},"q|d|D|qd|qD|dq":{action_:["output","o="],nextState:"o"}},"\\bond{(...)}":{"*":{action_:[{type_:"output",option:2},"bond"],nextState:"3"}},"\\frac{(...)}":{"*":{action_:[{type_:"output",option:1},"frac-output"],nextState:"3"}},"\\overset{(...)}":{"*":{action_:[{type_:"output",option:2},"overset-output"],nextState:"3"}},"\\underset{(...)}":{"*":{action_:[{type_:"output",option:2},"underset-output"],nextState:"3"}},"\\underbrace{(...)}":{"*":{action_:[{type_:"output",option:2},"underbrace-output"],nextState:"3"}},"\\color{(...)}{(...)}1|\\color(...){(...)}2":{"*":{action_:[{type_:"output",option:2},"color-output"],nextState:"3"}},"\\color{(...)}0":{"*":{action_:[{type_:"output",option:2},"color0-output"]}},"\\ce{(...)}":{"*":{action_:[{type_:"output",option:2},"ce"],nextState:"3"}},"\\,":{"*":{action_:[{type_:"output",option:1},"copy"],nextState:"1"}},"\\x{}{}|\\x{}|\\x":{"0|1|2|3|a|as|b|p|bp|o|c0":{action_:["o=","output"],nextState:"3"},"*":{action_:["output","o=","output"],nextState:"3"}},others:{"*":{action_:[{type_:"output",option:1},"copy"],nextState:"3"}},else2:{a:{action_:"a to o",nextState:"o",revisit:!0},as:{action_:["output","sb=true"],nextState:"1",revisit:!0},"r|rt|rd|rdt|rdq":{action_:["output"],nextState:"0",revisit:!0},"*":{action_:["output","copy"],nextState:"3"}}}),actions:{"o after d":function(r,e){var t;if((r.d||"").match(/^[0-9]+$/)){var a=r.d;r.d=void 0,t=this.output(r),r.b=a}else t=this.output(r);return S.actions["o="](r,e),t},"d= kv":function(r,e){r.d=e,r.dType="kv"},"charge or bond":function(r,e){if(r.beginsWithBond){var t=[];return S.concatArray(t,this.output(r)),S.concatArray(t,S.actions.bond(r,e,"-")),t}else r.d=e},"- after o/d":function(r,e,t){var a=S.patterns.match_("orbital",r.o||""),n=S.patterns.match_("one lowercase greek letter $",r.o||""),s=S.patterns.match_("one lowercase latin letter $",r.o||""),l=S.patterns.match_("$one lowercase latin letter$ $",r.o||""),h=e==="-"&&(a&&a.remainder===""||n||s||l);h&&!r.a&&!r.b&&!r.p&&!r.d&&!r.q&&!a&&s&&(r.o="$"+r.o+"$");var c=[];return h?(S.concatArray(c,this.output(r)),c.push({type_:"hyphen"})):(a=S.patterns.match_("digits",r.d||""),t&&a&&a.remainder===""?(S.concatArray(c,S.actions["d="](r,e)),S.concatArray(c,this.output(r))):(S.concatArray(c,this.output(r)),S.concatArray(c,S.actions.bond(r,e,"-")))),c},"a to o":function(r){r.o=r.a,r.a=void 0},"sb=true":function(r){r.sb=!0},"sb=false":function(r){r.sb=!1},"beginsWithBond=true":function(r){r.beginsWithBond=!0},"beginsWithBond=false":function(r){r.beginsWithBond=!1},"parenthesisLevel++":function(r){r.parenthesisLevel++},"parenthesisLevel--":function(r){r.parenthesisLevel--},"state of aggregation":function(r,e){return{type_:"state of aggregation",p1:S.go(e,"o")}},comma:function(r,e){var t=e.replace(/\s*$/,""),a=t!==e;return a&&r.parenthesisLevel===0?{type_:"comma enumeration L",p1:t}:{type_:"comma enumeration M",p1:t}},output:function(r,e,t){var a;if(!r.r)a=[],!r.a&&!r.b&&!r.p&&!r.o&&!r.q&&!r.d&&!t||(r.sb&&a.push({type_:"entitySkip"}),!r.o&&!r.q&&!r.d&&!r.b&&!r.p&&t!==2?(r.o=r.a,r.a=void 0):!r.o&&!r.q&&!r.d&&(r.b||r.p)?(r.o=r.a,r.d=r.b,r.q=r.p,r.a=r.b=r.p=void 0):r.o&&r.dType==="kv"&&S.patterns.match_("d-oxidation$",r.d||"")?r.dType="oxidation":r.o&&r.dType==="kv"&&!r.q&&(r.dType=void 0),a.push({type_:"chemfive",a:S.go(r.a,"a"),b:S.go(r.b,"bd"),p:S.go(r.p,"pq"),o:S.go(r.o,"o"),q:S.go(r.q,"pq"),d:S.go(r.d,r.dType==="oxidation"?"oxidation":"bd"),dType:r.dType}));else{var n;r.rdt==="M"?n=S.go(r.rd,"tex-math"):r.rdt==="T"?n=[{type_:"text",p1:r.rd||""}]:n=S.go(r.rd);var s;r.rqt==="M"?s=S.go(r.rq,"tex-math"):r.rqt==="T"?s=[{type_:"text",p1:r.rq||""}]:s=S.go(r.rq),a={type_:"arrow",r:r.r,rd:n,rq:s}}for(var l in r)l!=="parenthesisLevel"&&l!=="beginsWithBond"&&delete r[l];return a},"oxidation-output":function(r,e){var t=["{"];return S.concatArray(t,S.go(e,"oxidation")),t.push("}"),t},"frac-output":function(r,e){return{type_:"frac-ce",p1:S.go(e[0]),p2:S.go(e[1])}},"overset-output":function(r,e){return{type_:"overset",p1:S.go(e[0]),p2:S.go(e[1])}},"underset-output":function(r,e){return{type_:"underset",p1:S.go(e[0]),p2:S.go(e[1])}},"underbrace-output":function(r,e){return{type_:"underbrace",p1:S.go(e[0]),p2:S.go(e[1])}},"color-output":function(r,e){return{type_:"color",color1:e[0],color2:S.go(e[1])}},"r=":function(r,e){r.r=e},"rdt=":function(r,e){r.rdt=e},"rd=":function(r,e){r.rd=e},"rqt=":function(r,e){r.rqt=e},"rq=":function(r,e){r.rq=e},operator:function(r,e,t){return{type_:"operator",kind_:t||e}}}},a:{transitions:S.createTransitions({empty:{"*":{}},"1/2$":{0:{action_:"1/2"}},else:{0:{nextState:"1",revisit:!0}},"$(...)$":{"*":{action_:"tex-math tight",nextState:"1"}},",":{"*":{action_:{type_:"insert",option:"commaDecimal"}}},else2:{"*":{action_:"copy"}}}),actions:{}},o:{transitions:S.createTransitions({empty:{"*":{}},"1/2$":{0:{action_:"1/2"}},else:{0:{nextState:"1",revisit:!0}},letters:{"*":{action_:"rm"}},"\\ca":{"*":{action_:{type_:"insert",option:"circa"}}},"\\x{}{}|\\x{}|\\x":{"*":{action_:"copy"}},"${(...)}$|$(...)$":{"*":{action_:"tex-math"}},"{(...)}":{"*":{action_:"{text}"}},else2:{"*":{action_:"copy"}}}),actions:{}},text:{transitions:S.createTransitions({empty:{"*":{action_:"output"}},"{...}":{"*":{action_:"text="}},"${(...)}$|$(...)$":{"*":{action_:"tex-math"}},"\\greek":{"*":{action_:["output","rm"]}},"\\,|\\x{}{}|\\x{}|\\x":{"*":{action_:["output","copy"]}},else:{"*":{action_:"text="}}}),actions:{output:function(r){if(r.text_){var e={type_:"text",p1:r.text_};for(var t in r)delete r[t];return e}}}},pq:{transitions:S.createTransitions({empty:{"*":{}},"state of aggregation $":{"*":{action_:"state of aggregation"}},i$:{0:{nextState:"!f",revisit:!0}},"(KV letters),":{0:{action_:"rm",nextState:"0"}},formula$:{0:{nextState:"f",revisit:!0}},"1/2$":{0:{action_:"1/2"}},else:{0:{nextState:"!f",revisit:!0}},"${(...)}$|$(...)$":{"*":{action_:"tex-math"}},"{(...)}":{"*":{action_:"text"}},"a-z":{f:{action_:"tex-math"}},letters:{"*":{action_:"rm"}},"-9.,9":{"*":{action_:"9,9"}},",":{"*":{action_:{type_:"insert+p1",option:"comma enumeration S"}}},"\\color{(...)}{(...)}1|\\color(...){(...)}2":{"*":{action_:"color-output"}},"\\color{(...)}0":{"*":{action_:"color0-output"}},"\\ce{(...)}":{"*":{action_:"ce"}},"\\,|\\x{}{}|\\x{}|\\x":{"*":{action_:"copy"}},else2:{"*":{action_:"copy"}}}),actions:{"state of aggregation":function(r,e){return{type_:"state of aggregation subscript",p1:S.go(e,"o")}},"color-output":function(r,e){return{type_:"color",color1:e[0],color2:S.go(e[1],"pq")}}}},bd:{transitions:S.createTransitions({empty:{"*":{}},x$:{0:{nextState:"!f",revisit:!0}},formula$:{0:{nextState:"f",revisit:!0}},else:{0:{nextState:"!f",revisit:!0}},"-9.,9 no missing 0":{"*":{action_:"9,9"}},".":{"*":{action_:{type_:"insert",option:"electron dot"}}},"a-z":{f:{action_:"tex-math"}},x:{"*":{action_:{type_:"insert",option:"KV x"}}},letters:{"*":{action_:"rm"}},"'":{"*":{action_:{type_:"insert",option:"prime"}}},"${(...)}$|$(...)$":{"*":{action_:"tex-math"}},"{(...)}":{"*":{action_:"text"}},"\\color{(...)}{(...)}1|\\color(...){(...)}2":{"*":{action_:"color-output"}},"\\color{(...)}0":{"*":{action_:"color0-output"}},"\\ce{(...)}":{"*":{action_:"ce"}},"\\,|\\x{}{}|\\x{}|\\x":{"*":{action_:"copy"}},else2:{"*":{action_:"copy"}}}),actions:{"color-output":function(r,e){return{type_:"color",color1:e[0],color2:S.go(e[1],"bd")}}}},oxidation:{transitions:S.createTransitions({empty:{"*":{}},"roman numeral":{"*":{action_:"roman-numeral"}},"${(...)}$|$(...)$":{"*":{action_:"tex-math"}},else:{"*":{action_:"copy"}}}),actions:{"roman-numeral":function(r,e){return{type_:"roman numeral",p1:e||""}}}},"tex-math":{transitions:S.createTransitions({empty:{"*":{action_:"output"}},"\\ce{(...)}":{"*":{action_:["output","ce"]}},"{...}|\\,|\\x{}{}|\\x{}|\\x":{"*":{action_:"o="}},else:{"*":{action_:"o="}}}),actions:{output:function(r){if(r.o){var e={type_:"tex-math",p1:r.o};for(var t in r)delete r[t];return e}}}},"tex-math tight":{transitions:S.createTransitions({empty:{"*":{action_:"output"}},"\\ce{(...)}":{"*":{action_:["output","ce"]}},"{...}|\\,|\\x{}{}|\\x{}|\\x":{"*":{action_:"o="}},"-|+":{"*":{action_:"tight operator"}},else:{"*":{action_:"o="}}}),actions:{"tight operator":function(r,e){r.o=(r.o||"")+"{"+e+"}"},output:function(r){if(r.o){var e={type_:"tex-math",p1:r.o};for(var t in r)delete r[t];return e}}}},"9,9":{transitions:S.createTransitions({empty:{"*":{}},",":{"*":{action_:"comma"}},else:{"*":{action_:"copy"}}}),actions:{comma:function(){return{type_:"commaDecimal"}}}},pu:{transitions:S.createTransitions({empty:{"*":{action_:"output"}},space$:{"*":{action_:["output","space"]}},"{[(|)]}":{"0|a":{action_:"copy"}},"(-)(9)^(-9)":{0:{action_:"number^",nextState:"a"}},"(-)(9.,9)(e)(99)":{0:{action_:"enumber",nextState:"a"}},space:{"0|a":{}},"pm-operator":{"0|a":{action_:{type_:"operator",option:"\\pm"},nextState:"0"}},operator:{"0|a":{action_:"copy",nextState:"0"}},"//":{d:{action_:"o=",nextState:"/"}},"/":{d:{action_:"o=",nextState:"/"}},"{...}|else":{"0|d":{action_:"d=",nextState:"d"},a:{action_:["space","d="],nextState:"d"},"/|q":{action_:"q=",nextState:"q"}}}),actions:{enumber:function(r,e){var t=[];return e[0]==="+-"||e[0]==="+/-"?t.push("\\pm "):e[0]&&t.push(e[0]),e[1]&&(S.concatArray(t,S.go(e[1],"pu-9,9")),e[2]&&(e[2].match(/[,.]/)?S.concatArray(t,S.go(e[2],"pu-9,9")):t.push(e[2])),e[3]=e[4]||e[3],e[3]&&(e[3]=e[3].trim(),e[3]==="e"||e[3].substr(0,1)==="*"?t.push({type_:"cdot"}):t.push({type_:"times"}))),e[3]&&t.push("10^{"+e[5]+"}"),t},"number^":function(r,e){var t=[];return e[0]==="+-"||e[0]==="+/-"?t.push("\\pm "):e[0]&&t.push(e[0]),S.concatArray(t,S.go(e[1],"pu-9,9")),t.push("^{"+e[2]+"}"),t},operator:function(r,e,t){return{type_:"operator",kind_:t||e}},space:function(){return{type_:"pu-space-1"}},output:function(r){var e,t=S.patterns.match_("{(...)}",r.d||"");t&&t.remainder===""&&(r.d=t.match_);var a=S.patterns.match_("{(...)}",r.q||"");if(a&&a.remainder===""&&(r.q=a.match_),r.d&&(r.d=r.d.replace(/\u00B0C|\^oC|\^{o}C/g,"{}^{\\circ}C"),r.d=r.d.replace(/\u00B0F|\^oF|\^{o}F/g,"{}^{\\circ}F")),r.q){r.q=r.q.replace(/\u00B0C|\^oC|\^{o}C/g,"{}^{\\circ}C"),r.q=r.q.replace(/\u00B0F|\^oF|\^{o}F/g,"{}^{\\circ}F");var n={d:S.go(r.d,"pu"),q:S.go(r.q,"pu")};r.o==="//"?e={type_:"pu-frac",p1:n.d,p2:n.q}:(e=n.d,n.d.length>1||n.q.length>1?e.push({type_:" / "}):e.push({type_:"/"}),S.concatArray(e,n.q))}else e=S.go(r.d,"pu-2");for(var s in r)delete r[s];return e}}},"pu-2":{transitions:S.createTransitions({empty:{"*":{action_:"output"}},"*":{"*":{action_:["output","cdot"],nextState:"0"}},"\\x":{"*":{action_:"rm="}},space:{"*":{action_:["output","space"],nextState:"0"}},"^{(...)}|^(-1)":{1:{action_:"^(-1)"}},"-9.,9":{0:{action_:"rm=",nextState:"0"},1:{action_:"^(-1)",nextState:"0"}},"{...}|else":{"*":{action_:"rm=",nextState:"1"}}}),actions:{cdot:function(){return{type_:"tight cdot"}},"^(-1)":function(r,e){r.rm+="^{"+e+"}"},space:function(){return{type_:"pu-space-2"}},output:function(r){var e=[];if(r.rm){var t=S.patterns.match_("{(...)}",r.rm||"");t&&t.remainder===""?e=S.go(t.match_,"pu"):e={type_:"rm",p1:r.rm}}for(var a in r)delete r[a];return e}}},"pu-9,9":{transitions:S.createTransitions({empty:{0:{action_:"output-0"},o:{action_:"output-o"}},",":{0:{action_:["output-0","comma"],nextState:"o"}},".":{0:{action_:["output-0","copy"],nextState:"o"}},else:{"*":{action_:"text="}}}),actions:{comma:function(){return{type_:"commaDecimal"}},"output-0":function(r){var e=[];if(r.text_=r.text_||"",r.text_.length>4){var t=r.text_.length%3;t===0&&(t=3);for(var a=r.text_.length-3;a>0;a-=3)e.push(r.text_.substr(a,3)),e.push({type_:"1000 separator"});e.push(r.text_.substr(0,t)),e.reverse()}else e.push(r.text_);for(var n in r)delete r[n];return e},"output-o":function(r){var e=[];if(r.text_=r.text_||"",r.text_.length>4){for(var t=r.text_.length-3,a=0;a":return"rightarrow";case"\u2192":return"rightarrow";case"\u27F6":return"rightarrow";case"<-":return"leftarrow";case"<->":return"leftrightarrow";case"<-->":return"rightleftarrows";case"<=>":return"rightleftharpoons";case"\u21CC":return"rightleftharpoons";case"<=>>":return"rightequilibrium";case"<<=>":return"leftequilibrium";default:throw["MhchemBugT","mhchem bug T. Please report."]}},_getBond:function(r){switch(r){case"-":return"{-}";case"1":return"{-}";case"=":return"{=}";case"2":return"{=}";case"#":return"{\\equiv}";case"3":return"{\\equiv}";case"~":return"{\\tripledash}";case"~-":return"{\\mathrlap{\\raisebox{-.1em}{$-$}}\\raisebox{.1em}{$\\tripledash$}}";case"~=":return"{\\mathrlap{\\raisebox{-.2em}{$-$}}\\mathrlap{\\raisebox{.2em}{$\\tripledash$}}-}";case"~--":return"{\\mathrlap{\\raisebox{-.2em}{$-$}}\\mathrlap{\\raisebox{.2em}{$\\tripledash$}}-}";case"-~-":return"{\\mathrlap{\\raisebox{-.2em}{$-$}}\\mathrlap{\\raisebox{.2em}{$-$}}\\tripledash}";case"...":return"{{\\cdot}{\\cdot}{\\cdot}}";case"....":return"{{\\cdot}{\\cdot}{\\cdot}{\\cdot}}";case"->":return"{\\rightarrow}";case"<-":return"{\\leftarrow}";case"<":return"{<}";case">":return"{>}";default:throw["MhchemBugT","mhchem bug T. Please report."]}},_getOperator:function(r){switch(r){case"+":return" {}+{} ";case"-":return" {}-{} ";case"=":return" {}={} ";case"<":return" {}<{} ";case">":return" {}>{} ";case"<<":return" {}\\ll{} ";case">>":return" {}\\gg{} ";case"\\pm":return" {}\\pm{} ";case"\\approx":return" {}\\approx{} ";case"$\\approx$":return" {}\\approx{} ";case"v":return" \\downarrow{} ";case"(v)":return" \\downarrow{} ";case"^":return" \\uparrow{} ";case"(^)":return" \\uparrow{} ";default:throw["MhchemBugT","mhchem bug T. Please report."]}}};var wn=function(r){let e=r.data,t=e.expression,a=e.options,n=r.header;n.warnings=[],a.strict=="warn"&&(a.strict=(l,h)=>{n.warnings.push(`katex: LaTeX-incompatible input and strict mode is set to 'warn': ${h} [${l}]`)});let s=oe.renderToString(t,a);et({header:n,data:{output:s}})};Wt(wn);})(); diff --git a/internal/warpc/js/renderkatex.js b/internal/warpc/js/renderkatex.js new file mode 100644 index 000000000..7c8ac25ee --- /dev/null +++ b/internal/warpc/js/renderkatex.js @@ -0,0 +1,25 @@ +import { readInput, writeOutput } from './common'; +import katex from 'katex'; +import 'katex/contrib/mhchem/mhchem.js'; + +const render = function (input) { + const data = input.data; + const expression = data.expression; + const options = data.options; + const header = input.header; + header.warnings = []; + + if (options.strict == 'warn') { + // By default, KaTeX will write to console.warn, that's a little hard to handle. + options.strict = (errorCode, errorMsg) => { + header.warnings.push( + `katex: LaTeX-incompatible input and strict mode is set to 'warn': ${errorMsg} [${errorCode}]`, + ); + }; + } + // Any error thrown here will be caught by the common.js readInput function. + const output = katex.renderToString(expression, options); + writeOutput({ header: header, data: { output: output } }); +}; + +readInput(render); diff --git a/internal/warpc/katex.go b/internal/warpc/katex.go new file mode 100644 index 000000000..75c20117f --- /dev/null +++ b/internal/warpc/katex.go @@ -0,0 +1,76 @@ +// 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 warpc + +import ( + _ "embed" +) + +//go:embed wasm/renderkatex.wasm +var katexWasm []byte + +// See https://katex.org/docs/options.html +type KatexInput struct { + Expression string `json:"expression"` + Options KatexOptions `json:"options"` +} + +// KatexOptions defines the options for the KaTeX rendering. +// See https://katex.org/docs/options.html +type KatexOptions struct { + // html, mathml (default), htmlAndMathml + Output string `json:"output"` + + // If true, display math in display mode, false in inline mode. + DisplayMode bool `json:"displayMode"` + + // Render \tags on the left side instead of the right. + Leqno bool `json:"leqno"` + + // If true, render flush left with a 2em left margin. + Fleqn bool `json:"fleqn"` + + // The color used for typesetting errors. + // A color string given in the format "#XXX" or "#XXXXXX" + ErrorColor string `json:"errorColor"` + + // A collection of custom macros. + Macros map[string]string `json:"macros,omitempty"` + + // Specifies a minimum thickness, in ems, for fraction lines. + MinRuleThickness float64 `json:"minRuleThickness"` + + // If true, KaTeX will throw a ParseError when it encounters an unsupported command. + ThrowOnError bool `json:"throwOnError"` + + // Controls how KaTeX handles LaTeX features that offer convenience but + // aren't officially supported, one of error (default), ignore, or warn. + // + // - error: Throws an error when convenient, unsupported LaTeX features + // are encountered. + // - ignore: Allows convenient, unsupported LaTeX features without any + // feedback. + // - warn: Emits a warning when convenient, unsupported LaTeX features are + // encountered. + // + // The "newLineInDisplayMode" error code, which flags the use of \\ + // or \newline in display mode outside an array or tabular environment, is + // intentionally designed not to throw an error, despite this behavior + // being questionable. + Strict string `json:"strict"` +} + +type KatexOutput struct { + Output string `json:"output"` +} diff --git a/internal/warpc/warpc.go b/internal/warpc/warpc.go new file mode 100644 index 000000000..e21fefa8a --- /dev/null +++ b/internal/warpc/warpc.go @@ -0,0 +1,589 @@ +// 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 warpc + +import ( + "bytes" + "context" + _ "embed" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/gohugoio/hugo/common/hugio" + "golang.org/x/sync/errgroup" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" + "github.com/tetratelabs/wazero/experimental" + "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" +) + +const currentVersion = 1 + +//go:embed wasm/quickjs.wasm +var quickjsWasm []byte + +// Header is in both the request and response. +type Header struct { + // Major version of the protocol. + Version uint16 `json:"version"` + + // Unique ID for the request. + // Note that this only needs to be unique within the current request set time window. + ID uint32 `json:"id"` + + // Set in the response if there was an error. + Err string `json:"err"` + + // Warnings is a list of warnings that may be returned in the response. + Warnings []string `json:"warnings,omitempty"` +} + +type Message[T any] struct { + Header Header `json:"header"` + Data T `json:"data"` +} + +func (m Message[T]) GetID() uint32 { + return m.Header.ID +} + +type Dispatcher[Q, R any] interface { + Execute(ctx context.Context, q Message[Q]) (Message[R], error) + Close() error +} + +func (p *dispatcherPool[Q, R]) getDispatcher() *dispatcher[Q, R] { + i := int(p.counter.Add(1)) % len(p.dispatchers) + return p.dispatchers[i] +} + +func (p *dispatcherPool[Q, R]) Close() error { + return p.close() +} + +type dispatcher[Q, R any] struct { + zero Message[R] + + mu sync.RWMutex + encMu sync.Mutex + + pending map[uint32]*call[Q, R] + + inOut *inOut + + shutdown bool + closing bool +} + +type inOut struct { + sync.Mutex + stdin hugio.ReadWriteCloser + stdout hugio.ReadWriteCloser + dec *json.Decoder + enc *json.Encoder +} + +var ErrShutdown = fmt.Errorf("dispatcher is shutting down") + +var timerPool = sync.Pool{} + +func getTimer(d time.Duration) *time.Timer { + if v := timerPool.Get(); v != nil { + timer := v.(*time.Timer) + timer.Reset(d) + return timer + } + return time.NewTimer(d) +} + +func putTimer(t *time.Timer) { + if !t.Stop() { + select { + case <-t.C: + default: + } + } + timerPool.Put(t) +} + +// Execute sends a request to the dispatcher and waits for the response. +func (p *dispatcherPool[Q, R]) Execute(ctx context.Context, q Message[Q]) (Message[R], error) { + d := p.getDispatcher() + if q.GetID() == 0 { + return d.zero, errors.New("ID must not be 0 (note that this must be unique within the current request set time window)") + } + + call, err := d.newCall(q) + if err != nil { + return d.zero, err + } + + if err := d.send(call); err != nil { + return d.zero, err + } + + timer := getTimer(30 * time.Second) + defer putTimer(timer) + + select { + case call = <-call.donec: + case <-p.donec: + return d.zero, p.Err() + case <-ctx.Done(): + return d.zero, ctx.Err() + case <-timer.C: + return d.zero, errors.New("timeout") + } + + if call.err != nil { + return d.zero, call.err + } + + resp, err := call.response, p.Err() + + if err == nil && resp.Header.Err != "" { + err = errors.New(resp.Header.Err) + } + return resp, err +} + +func (d *dispatcher[Q, R]) newCall(q Message[Q]) (*call[Q, R], error) { + call := &call[Q, R]{ + donec: make(chan *call[Q, R], 1), + request: q, + } + + if d.shutdown || d.closing { + call.err = ErrShutdown + call.done() + return call, nil + } + + d.mu.Lock() + d.pending[q.GetID()] = call + d.mu.Unlock() + + return call, nil +} + +func (d *dispatcher[Q, R]) send(call *call[Q, R]) error { + d.mu.RLock() + if d.closing || d.shutdown { + d.mu.RUnlock() + return ErrShutdown + } + d.mu.RUnlock() + + d.encMu.Lock() + defer d.encMu.Unlock() + err := d.inOut.enc.Encode(call.request) + if err != nil { + return err + } + return nil +} + +func (d *dispatcher[Q, R]) input() { + var inputErr error + + for d.inOut.dec.More() { + var r Message[R] + if err := d.inOut.dec.Decode(&r); err != nil { + inputErr = fmt.Errorf("decoding response: %w", err) + break + } + + d.mu.Lock() + call, found := d.pending[r.GetID()] + if !found { + d.mu.Unlock() + panic(fmt.Errorf("call with ID %d not found", r.GetID())) + } + delete(d.pending, r.GetID()) + d.mu.Unlock() + call.response = r + call.done() + } + + // Terminate pending calls. + d.shutdown = true + if inputErr != nil { + isEOF := inputErr == io.EOF || strings.Contains(inputErr.Error(), "already closed") + if isEOF { + if d.closing { + inputErr = ErrShutdown + } else { + inputErr = io.ErrUnexpectedEOF + } + } + } + + d.mu.Lock() + defer d.mu.Unlock() + for _, call := range d.pending { + call.err = inputErr + call.done() + } +} + +type call[Q, R any] struct { + request Message[Q] + response Message[R] + err error + donec chan *call[Q, R] +} + +func (call *call[Q, R]) done() { + select { + case call.donec <- call: + default: + } +} + +// Binary represents a WebAssembly binary. +type Binary struct { + // The name of the binary. + // For quickjs, this must match the instance import name, "javy_quickjs_provider_v2". + // For the main module, we only use this for caching. + Name string + + // THe wasm binary. + Data []byte +} + +type Options struct { + Ctx context.Context + + Infof func(format string, v ...any) + + Warnf func(format string, v ...any) + + // E.g. quickjs wasm. May be omitted if not needed. + Runtime Binary + + // The main module to instantiate. + Main Binary + + CompilationCacheDir string + PoolSize int + + // Memory limit in MiB. + Memory int +} + +type CompileModuleContext struct { + Opts Options + Runtime wazero.Runtime +} + +type CompiledModule struct { + // Runtime (e.g. QuickJS) may be nil if not needed (e.g. embedded in Module). + Runtime wazero.CompiledModule + + // If Runtime is not nil, this should be the name of the instance. + RuntimeName string + + // The main module to instantiate. + // This will be insantiated multiple times in a pool, + // so it does not need a name. + Module wazero.CompiledModule +} + +// Start creates a new dispatcher pool. +func Start[Q, R any](opts Options) (Dispatcher[Q, R], error) { + if opts.Main.Data == nil { + return nil, errors.New("Main.Data must be set") + } + if opts.Main.Name == "" { + return nil, errors.New("Main.Name must be set") + } + + if opts.Runtime.Data != nil && opts.Runtime.Name == "" { + return nil, errors.New("Runtime.Name must be set") + } + + if opts.PoolSize == 0 { + opts.PoolSize = 1 + } + + return newDispatcher[Q, R](opts) +} + +type dispatcherPool[Q, R any] struct { + counter atomic.Uint32 + dispatchers []*dispatcher[Q, R] + close func() error + opts Options + + errc chan error + donec chan struct{} +} + +func (p *dispatcherPool[Q, R]) SendIfErr(err error) { + if err != nil { + p.errc <- err + } +} + +func (p *dispatcherPool[Q, R]) Err() error { + select { + case err := <-p.errc: + return err + default: + return nil + } +} + +func newDispatcher[Q, R any](opts Options) (*dispatcherPool[Q, R], error) { + if opts.Ctx == nil { + opts.Ctx = context.Background() + } + + if opts.Infof == nil { + opts.Infof = func(format string, v ...any) { + // noop + } + } + if opts.Warnf == nil { + opts.Warnf = func(format string, v ...any) { + // noop + } + } + + if opts.Memory <= 0 { + // 32 MiB + opts.Memory = 32 + } + + ctx := opts.Ctx + + // Page size is 64KB. + numPages := opts.Memory * 1024 / 64 + runtimeConfig := wazero.NewRuntimeConfig().WithMemoryLimitPages(uint32(numPages)) + + if opts.CompilationCacheDir != "" { + compilationCache, err := wazero.NewCompilationCacheWithDir(opts.CompilationCacheDir) + if err != nil { + return nil, err + } + runtimeConfig = runtimeConfig.WithCompilationCache(compilationCache) + } + + // Create a new WebAssembly Runtime. + r := wazero.NewRuntimeWithConfig(opts.Ctx, runtimeConfig) + + // Instantiate WASI, which implements system I/O such as console output. + if _, err := wasi_snapshot_preview1.Instantiate(ctx, r); err != nil { + return nil, err + } + + inOuts := make([]*inOut, opts.PoolSize) + for i := range opts.PoolSize { + var stdin, stdout hugio.ReadWriteCloser + + stdin = hugio.NewPipeReadWriteCloser() + stdout = hugio.NewPipeReadWriteCloser() + + inOuts[i] = &inOut{ + stdin: stdin, + stdout: stdout, + dec: json.NewDecoder(stdout), + enc: json.NewEncoder(stdin), + } + } + + var ( + runtimeModule wazero.CompiledModule + mainModule wazero.CompiledModule + err error + ) + + if opts.Runtime.Data != nil { + runtimeModule, err = r.CompileModule(ctx, opts.Runtime.Data) + if err != nil { + return nil, err + } + } + + mainModule, err = r.CompileModule(ctx, opts.Main.Data) + if err != nil { + return nil, err + } + + toErr := func(what string, errBuff bytes.Buffer, err error) error { + return fmt.Errorf("%s: %s: %w", what, errBuff.String(), err) + } + + run := func() error { + g, ctx := errgroup.WithContext(ctx) + for _, c := range inOuts { + c := c + g.Go(func() error { + var errBuff bytes.Buffer + ctx := context.WithoutCancel(ctx) + configBase := wazero.NewModuleConfig().WithStderr(&errBuff).WithStdout(c.stdout).WithStdin(c.stdin).WithStartFunctions() + if opts.Runtime.Data != nil { + // This needs to be anonymous, it will be resolved in the import resolver below. + runtimeInstance, err := r.InstantiateModule(ctx, runtimeModule, configBase.WithName("")) + if err != nil { + return toErr("quickjs", errBuff, err) + } + ctx = experimental.WithImportResolver(ctx, + func(name string) api.Module { + if name == opts.Runtime.Name { + return runtimeInstance + } + return nil + }, + ) + } + + mainInstance, err := r.InstantiateModule(ctx, mainModule, configBase.WithName("")) + if err != nil { + return toErr(opts.Main.Name, errBuff, err) + } + if _, err := mainInstance.ExportedFunction("_start").Call(ctx); err != nil { + return toErr(opts.Main.Name, errBuff, err) + } + + // The console.log in the Javy/quickjs WebAssembly module will write to stderr. + // In non-error situations, write that to the provided infof logger. + if errBuff.Len() > 0 { + opts.Infof("%s", errBuff.String()) + } + + return nil + }) + } + return g.Wait() + } + + dp := &dispatcherPool[Q, R]{ + dispatchers: make([]*dispatcher[Q, R], len(inOuts)), + opts: opts, + + errc: make(chan error, 10), + donec: make(chan struct{}), + } + + go func() { + // This will block until stdin is closed or it encounters an error. + err := run() + dp.SendIfErr(err) + close(dp.donec) + }() + + for i := range inOuts { + d := &dispatcher[Q, R]{ + pending: make(map[uint32]*call[Q, R]), + inOut: inOuts[i], + } + go d.input() + dp.dispatchers[i] = d + } + + dp.close = func() error { + for _, d := range dp.dispatchers { + d.closing = true + if err := d.inOut.stdin.Close(); err != nil { + return err + } + if err := d.inOut.stdout.Close(); err != nil { + return err + } + } + + // We need to wait for the WebAssembly instances to finish executing before we can close the runtime. + <-dp.donec + + if err := r.Close(ctx); err != nil { + return err + } + + // Return potential late compilation errors. + return dp.Err() + } + + return dp, dp.Err() +} + +type lazyDispatcher[Q, R any] struct { + opts Options + + dispatcher Dispatcher[Q, R] + startOnce sync.Once + started bool + startErr error +} + +func (d *lazyDispatcher[Q, R]) start() (Dispatcher[Q, R], error) { + d.startOnce.Do(func() { + start := time.Now() + d.dispatcher, d.startErr = Start[Q, R](d.opts) + d.started = true + d.opts.Infof("started dispatcher in %s", time.Since(start)) + }) + return d.dispatcher, d.startErr +} + +// Dispatchers holds all the dispatchers for the warpc package. +type Dispatchers struct { + katex *lazyDispatcher[KatexInput, KatexOutput] +} + +func (d *Dispatchers) Katex() (Dispatcher[KatexInput, KatexOutput], error) { + return d.katex.start() +} + +func (d *Dispatchers) Close() error { + var errs []error + if d.katex.started { + if err := d.katex.dispatcher.Close(); err != nil { + errs = append(errs, err) + } + } + if len(errs) == 0 { + return nil + } + return fmt.Errorf("%v", errs) +} + +// AllDispatchers creates all the dispatchers for the warpc package. +// Note that the individual dispatchers are started lazily. +// Remember to call Close on the returned Dispatchers when done. +func AllDispatchers(katexOpts Options) *Dispatchers { + if katexOpts.Runtime.Data == nil { + katexOpts.Runtime = Binary{Name: "javy_quickjs_provider_v2", Data: quickjsWasm} + } + if katexOpts.Main.Data == nil { + katexOpts.Main = Binary{Name: "renderkatex", Data: katexWasm} + } + + if katexOpts.Infof == nil { + katexOpts.Infof = func(format string, v ...any) { + // noop + } + } + + return &Dispatchers{ + katex: &lazyDispatcher[KatexInput, KatexOutput]{opts: katexOpts}, + } +} diff --git a/internal/warpc/warpc_test.go b/internal/warpc/warpc_test.go new file mode 100644 index 000000000..2ee4c3de5 --- /dev/null +++ b/internal/warpc/warpc_test.go @@ -0,0 +1,475 @@ +// 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 warpc + +import ( + "context" + _ "embed" + "fmt" + "sync" + "sync/atomic" + "testing" + + qt "github.com/frankban/quicktest" +) + +//go:embed wasm/greet.wasm +var greetWasm []byte + +type person struct { + Name string `json:"name"` +} + +func TestKatex(t *testing.T) { + c := qt.New(t) + + opts := Options{ + PoolSize: 8, + Runtime: quickjsBinary, + Main: katexBinary, + } + + d, err := Start[KatexInput, KatexOutput](opts) + c.Assert(err, qt.IsNil) + + defer d.Close() + + runExpression := func(c *qt.C, id uint32, expression string) (Message[KatexOutput], error) { + c.Helper() + + ctx := context.Background() + + input := KatexInput{ + Expression: expression, + Options: KatexOptions{ + Output: "html", + DisplayMode: true, + ThrowOnError: true, + }, + } + + message := Message[KatexInput]{ + Header: Header{ + Version: currentVersion, + ID: uint32(id), + }, + Data: input, + } + + return d.Execute(ctx, message) + } + + c.Run("Simple", func(c *qt.C) { + id := uint32(32) + result, err := runExpression(c, id, "c = \\pm\\sqrt{a^2 + b^2}") + c.Assert(err, qt.IsNil) + c.Assert(result.GetID(), qt.Equals, id) + }) + + c.Run("Chemistry", func(c *qt.C) { + id := uint32(32) + result, err := runExpression(c, id, "C_p[\\ce{H2O(l)}] = \\pu{75.3 J // mol K}") + c.Assert(err, qt.IsNil) + c.Assert(result.GetID(), qt.Equals, id) + }) + + c.Run("Invalid expression", func(c *qt.C) { + id := uint32(32) + result, err := runExpression(c, id, "c & \\foo\\") + c.Assert(err, qt.IsNotNil) + c.Assert(result.GetID(), qt.Equals, id) + }) +} + +func TestGreet(t *testing.T) { + c := qt.New(t) + opts := Options{ + PoolSize: 1, + Runtime: quickjsBinary, + Main: greetBinary, + Infof: t.Logf, + } + + for range 2 { + func() { + d, err := Start[person, greeting](opts) + if err != nil { + t.Fatal(err) + } + + defer func() { + c.Assert(d.Close(), qt.IsNil) + }() + + ctx := context.Background() + + inputMessage := Message[person]{ + Header: Header{ + Version: currentVersion, + }, + Data: person{ + Name: "Person", + }, + } + + for j := range 20 { + inputMessage.Header.ID = uint32(j + 1) + g, err := d.Execute(ctx, inputMessage) + if err != nil { + t.Fatal(err) + } + if g.Data.Greeting != "Hello Person!" { + t.Fatalf("got: %v", g) + } + if g.GetID() != inputMessage.GetID() { + t.Fatalf("%d vs %d", g.GetID(), inputMessage.GetID()) + } + } + }() + } +} + +func TestGreetParallel(t *testing.T) { + c := qt.New(t) + + opts := Options{ + Runtime: quickjsBinary, + Main: greetBinary, + PoolSize: 4, + } + d, err := Start[person, greeting](opts) + c.Assert(err, qt.IsNil) + defer func() { + c.Assert(d.Close(), qt.IsNil) + }() + + var wg sync.WaitGroup + + for i := 1; i <= 10; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + + ctx := context.Background() + + for j := range 5 { + base := i * 100 + id := uint32(base + j) + + inputPerson := person{ + Name: fmt.Sprintf("Person %d", id), + } + inputMessage := Message[person]{ + Header: Header{ + Version: currentVersion, + ID: id, + }, + Data: inputPerson, + } + g, err := d.Execute(ctx, inputMessage) + if err != nil { + t.Error(err) + return + } + + c.Assert(g.Data.Greeting, qt.Equals, fmt.Sprintf("Hello Person %d!", id)) + c.Assert(g.GetID(), qt.Equals, inputMessage.GetID()) + + } + }(i) + + } + + wg.Wait() +} + +func TestKatexParallel(t *testing.T) { + c := qt.New(t) + + opts := Options{ + Runtime: quickjsBinary, + Main: katexBinary, + PoolSize: 6, + } + d, err := Start[KatexInput, KatexOutput](opts) + c.Assert(err, qt.IsNil) + defer func() { + c.Assert(d.Close(), qt.IsNil) + }() + + var wg sync.WaitGroup + + for i := 1; i <= 10; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + + ctx := context.Background() + + for j := range 1 { + base := i * 100 + id := uint32(base + j) + + input := katexInputTemplate + inputMessage := Message[KatexInput]{ + Header: Header{ + Version: currentVersion, + ID: id, + }, + Data: input, + } + + result, err := d.Execute(ctx, inputMessage) + if err != nil { + t.Error(err) + return + } + + if result.GetID() != inputMessage.GetID() { + t.Errorf("%d vs %d", result.GetID(), inputMessage.GetID()) + return + } + } + }(i) + + } + + wg.Wait() +} + +func BenchmarkExecuteKatex(b *testing.B) { + opts := Options{ + Runtime: quickjsBinary, + Main: katexBinary, + } + d, err := Start[KatexInput, KatexOutput](opts) + if err != nil { + b.Fatal(err) + } + defer d.Close() + + ctx := context.Background() + + input := katexInputTemplate + + b.ResetTimer() + for i := 0; i < b.N; i++ { + message := Message[KatexInput]{ + Header: Header{ + Version: currentVersion, + ID: uint32(i + 1), + }, + Data: input, + } + + result, err := d.Execute(ctx, message) + if err != nil { + b.Fatal(err) + } + + if result.GetID() != message.GetID() { + b.Fatalf("%d vs %d", result.GetID(), message.GetID()) + } + + } +} + +func BenchmarkKatexStartStop(b *testing.B) { + optsTemplate := Options{ + Runtime: quickjsBinary, + Main: katexBinary, + CompilationCacheDir: b.TempDir(), + } + + runBench := func(b *testing.B, opts Options) { + for i := 0; i < b.N; i++ { + d, err := Start[KatexInput, KatexOutput](opts) + if err != nil { + b.Fatal(err) + } + if err := d.Close(); err != nil { + b.Fatal(err) + } + } + } + + for _, poolSize := range []int{1, 8, 16} { + + name := fmt.Sprintf("PoolSize%d", poolSize) + + b.Run(name, func(b *testing.B) { + opts := optsTemplate + opts.PoolSize = poolSize + runBench(b, opts) + }) + + } +} + +var katexInputTemplate = KatexInput{ + Expression: "c = \\pm\\sqrt{a^2 + b^2}", + Options: KatexOptions{Output: "html", DisplayMode: true}, +} + +func BenchmarkExecuteKatexPara(b *testing.B) { + optsTemplate := Options{ + Runtime: quickjsBinary, + Main: katexBinary, + } + + runBench := func(b *testing.B, opts Options) { + d, err := Start[KatexInput, KatexOutput](opts) + if err != nil { + b.Fatal(err) + } + defer d.Close() + + ctx := context.Background() + + b.ResetTimer() + + var id atomic.Uint32 + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + message := Message[KatexInput]{ + Header: Header{ + Version: currentVersion, + ID: id.Add(1), + }, + Data: katexInputTemplate, + } + + result, err := d.Execute(ctx, message) + if err != nil { + b.Fatal(err) + } + if result.GetID() != message.GetID() { + b.Fatalf("%d vs %d", result.GetID(), message.GetID()) + } + } + }) + } + + for _, poolSize := range []int{1, 8, 16} { + name := fmt.Sprintf("PoolSize%d", poolSize) + + b.Run(name, func(b *testing.B) { + opts := optsTemplate + opts.PoolSize = poolSize + runBench(b, opts) + }) + } +} + +func BenchmarkExecuteGreet(b *testing.B) { + opts := Options{ + Runtime: quickjsBinary, + Main: greetBinary, + } + d, err := Start[person, greeting](opts) + if err != nil { + b.Fatal(err) + } + defer d.Close() + + ctx := context.Background() + + input := person{ + Name: "Person", + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + message := Message[person]{ + Header: Header{ + Version: currentVersion, + ID: uint32(i + 1), + }, + Data: input, + } + result, err := d.Execute(ctx, message) + if err != nil { + b.Fatal(err) + } + + if result.GetID() != message.GetID() { + b.Fatalf("%d vs %d", result.GetID(), message.GetID()) + } + + } +} + +func BenchmarkExecuteGreetPara(b *testing.B) { + opts := Options{ + Runtime: quickjsBinary, + Main: greetBinary, + PoolSize: 8, + } + + d, err := Start[person, greeting](opts) + if err != nil { + b.Fatal(err) + } + defer d.Close() + + ctx := context.Background() + + inputTemplate := person{ + Name: "Person", + } + + b.ResetTimer() + + var id atomic.Uint32 + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + message := Message[person]{ + Header: Header{ + Version: currentVersion, + ID: id.Add(1), + }, + Data: inputTemplate, + } + + result, err := d.Execute(ctx, message) + if err != nil { + b.Fatal(err) + } + if result.GetID() != message.GetID() { + b.Fatalf("%d vs %d", result.GetID(), message.GetID()) + } + } + }) +} + +type greeting struct { + Greeting string `json:"greeting"` +} + +var ( + greetBinary = Binary{ + Name: "greet", + Data: greetWasm, + } + + katexBinary = Binary{ + Name: "renderkatex", + Data: katexWasm, + } + + quickjsBinary = Binary{ + Name: "javy_quickjs_provider_v2", + Data: quickjsWasm, + } +) diff --git a/internal/warpc/wasm/greet.wasm b/internal/warpc/wasm/greet.wasm new file mode 100644 index 000000000..944199b40 Binary files /dev/null and b/internal/warpc/wasm/greet.wasm differ diff --git a/internal/warpc/wasm/quickjs.wasm b/internal/warpc/wasm/quickjs.wasm new file mode 100644 index 000000000..569c53a23 Binary files /dev/null and b/internal/warpc/wasm/quickjs.wasm differ diff --git a/internal/warpc/wasm/renderkatex.wasm b/internal/warpc/wasm/renderkatex.wasm new file mode 100644 index 000000000..b8b21c16b Binary files /dev/null and b/internal/warpc/wasm/renderkatex.wasm differ diff --git a/internal/warpc/watchtestscripts.sh b/internal/warpc/watchtestscripts.sh new file mode 100755 index 000000000..fbc90b648 --- /dev/null +++ b/internal/warpc/watchtestscripts.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +trap exit SIGINT + +while true; do find . -type f -name "*.js" | entr -pd ./build.sh; done \ No newline at end of file diff --git a/langs/config.go b/langs/config.go new file mode 100644 index 000000000..7cca0f5e7 --- /dev/null +++ b/langs/config.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. + +package langs + +import ( + "errors" + + "github.com/gohugoio/hugo/common/maps" + "github.com/mitchellh/mapstructure" +) + +// LanguageConfig holds the configuration for a single language. +// This is what is read from the config file. +type LanguageConfig struct { + // The language name, e.g. "English". + LanguageName string + + // The language code, e.g. "en-US". + LanguageCode string + + // The language title. When set, this will + // override site.Title for this language. + Title string + + // The language direction, e.g. "ltr" or "rtl". + LanguageDirection string + + // The language weight. When set to a non-zero value, this will + // be the main sort criteria for the language. + Weight int + + // Set to true to disable this language. + Disabled bool +} + +func DecodeConfig(m map[string]any) (map[string]LanguageConfig, error) { + m = maps.CleanConfigStringMap(m) + var langs map[string]LanguageConfig + + if err := mapstructure.WeakDecode(m, &langs); err != nil { + return nil, err + } + if len(langs) == 0 { + return nil, errors.New("no languages configured") + } + return langs, nil +} diff --git a/langs/i18n/i18n.go b/langs/i18n/i18n.go new file mode 100644 index 000000000..e97ec8b8d --- /dev/null +++ b/langs/i18n/i18n.go @@ -0,0 +1,205 @@ +// 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 i18n + +import ( + "context" + "fmt" + "reflect" + "strings" + + "github.com/spf13/cast" + + "github.com/gohugoio/hugo/common/hreflect" + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/resources/page" + + "github.com/gohugoio/go-i18n/v2/i18n" +) + +type translateFunc func(ctx context.Context, translationID string, templateData any) string + +// Translator handles i18n translations. +type Translator struct { + translateFuncs map[string]translateFunc + cfg config.AllProvider + logger loggers.Logger +} + +// NewTranslator creates a new Translator for the given language bundle and configuration. +func NewTranslator(b *i18n.Bundle, cfg config.AllProvider, logger loggers.Logger) Translator { + t := Translator{cfg: cfg, logger: logger, translateFuncs: make(map[string]translateFunc)} + t.initFuncs(b) + return t +} + +// Func gets the translate func for the given language, or for the default +// configured language if not found. +func (t Translator) Func(lang string) translateFunc { + if f, ok := t.translateFuncs[lang]; ok { + return f + } + t.logger.Infof("Translation func for language %v not found, use default.", lang) + if f, ok := t.translateFuncs[t.cfg.DefaultContentLanguage()]; ok { + return f + } + + t.logger.Infoln("i18n not initialized; if you need string translations, check that you have a bundle in /i18n that matches the site language or the default language.") + return func(ctx context.Context, translationID string, args any) string { + return "" + } +} + +func (t Translator) initFuncs(bndl *i18n.Bundle) { + enableMissingTranslationPlaceholders := t.cfg.EnableMissingTranslationPlaceholders() + for _, lang := range bndl.LanguageTags() { + currentLang := lang + currentLangStr := currentLang.String() + // This may be pt-BR; make it case insensitive. + currentLangKey := strings.ToLower(strings.TrimPrefix(currentLangStr, artificialLangTagPrefix)) + localizer := i18n.NewLocalizer(bndl, currentLangStr) + t.translateFuncs[currentLangKey] = func(ctx context.Context, translationID string, templateData any) string { + pluralCount := getPluralCount(templateData) + + if templateData != nil { + tp := reflect.TypeOf(templateData) + if hreflect.IsInt(tp.Kind()) { + // This was how go-i18n worked in v1, + // and we keep it like this to avoid breaking + // lots of sites in the wild. + templateData = intCount(cast.ToInt(templateData)) + } else { + if p, ok := templateData.(page.Page); ok { + // See issue 10782. + // The i18n has its own template handling and does not know about + // the context.Context. + // A common pattern is to pass Page to i18n, and use .ReadingTime etc. + // We need to improve this, but that requires some upstream changes. + // For now, just create a wrapper. + templateData = page.PageWithContext{Page: p, Ctx: ctx} + } + } + } + + translated, translatedLang, err := localizer.LocalizeWithTag(&i18n.LocalizeConfig{ + MessageID: translationID, + TemplateData: templateData, + PluralCount: pluralCount, + }) + + sameLang := currentLang == translatedLang + + if err == nil && sameLang { + return translated + } + + if err != nil && sameLang && translated != "" { + // See #8492 + // TODO(bep) this needs to be improved/fixed upstream, + // but currently we get an error even if the fallback to + // "other" succeeds. + if fmt.Sprintf("%T", err) == "i18n.pluralFormNotFoundError" { + return translated + } + } + + if _, ok := err.(*i18n.MessageNotFoundErr); !ok { + t.logger.Warnf("Failed to get translated string for language %q and ID %q: %s", currentLangStr, translationID, err) + } + + if t.cfg.PrintI18nWarnings() { + t.logger.Warnf("i18n|MISSING_TRANSLATION|%s|%s", currentLangStr, translationID) + } + + if enableMissingTranslationPlaceholders { + return "[i18n] " + translationID + } + + return translated + } + } +} + +// intCount wraps the Count method. +type intCount int + +func (c intCount) Count() int { + return int(c) +} + +const countFieldName = "Count" + +// getPluralCount gets the plural count as a string (floats) or an integer. +// If v is nil, nil is returned. +func getPluralCount(v any) any { + if v == nil { + // i18n called without any argument, make sure it does not + // get any plural count. + return nil + } + + switch v := v.(type) { + case map[string]any: + for k, vv := range v { + if strings.EqualFold(k, countFieldName) { + return toPluralCountValue(vv) + } + } + default: + vv := reflect.Indirect(reflect.ValueOf(v)) + if vv.Kind() == reflect.Interface && !vv.IsNil() { + vv = vv.Elem() + } + tp := vv.Type() + + if tp.Kind() == reflect.Struct { + f := vv.FieldByName(countFieldName) + if f.IsValid() { + return toPluralCountValue(f.Interface()) + } + m := hreflect.GetMethodByName(vv, countFieldName) + if m.IsValid() && m.Type().NumIn() == 0 && m.Type().NumOut() == 1 { + c := m.Call(nil) + return toPluralCountValue(c[0].Interface()) + } + } + } + + return toPluralCountValue(v) +} + +// go-i18n expects floats to be represented by string. +func toPluralCountValue(in any) any { + k := reflect.TypeOf(in).Kind() + switch { + case hreflect.IsFloat(k): + f := cast.ToString(in) + if !strings.Contains(f, ".") { + f += ".0" + } + return f + case k == reflect.String: + if _, err := cast.ToFloat64E(in); err == nil { + return in + } + // A non-numeric value. + return nil + default: + if i, err := cast.ToIntE(in); err == nil { + return i + } + return nil + } +} diff --git a/langs/i18n/i18n_integration_test.go b/langs/i18n/i18n_integration_test.go new file mode 100644 index 000000000..b62a2900e --- /dev/null +++ b/langs/i18n/i18n_integration_test.go @@ -0,0 +1,128 @@ +// 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 i18n_test + +import ( + "testing" + + "github.com/gohugoio/hugo/hugolib" +) + +func TestI18nFromTheme(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +[module] +[[module.imports]] +path = "mytheme" +-- i18n/en.toml -- +[l1] +other = 'l1main' +[l2] +other = 'l2main' +-- themes/mytheme/i18n/en.toml -- +[l1] +other = 'l1theme' +[l2] +other = 'l2theme' +[l3] +other = 'l3theme' +-- layouts/index.html -- +l1: {{ i18n "l1" }}|l2: {{ i18n "l2" }}|l3: {{ i18n "l3" }} + +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", ` +l1: l1main|l2: l2main|l3: l3theme + `) +} + +func TestPassPageToI18n(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +-- content/_index.md -- +--- +title: "Home" +--- +Duis quis irure id nisi sunt minim aliqua occaecat. Aliqua cillum labore consectetur quis culpa tempor quis non officia cupidatat in ad cillum. Velit irure pariatur nisi adipisicing officia reprehenderit commodo esse non. + +Ullamco cupidatat nostrud ut reprehenderit. Consequat nisi culpa magna amet tempor velit reprehenderit. Ad minim eiusmod tempor nostrud eu aliquip consectetur commodo ut in aliqua enim. Cupidatat voluptate laborum consequat qui nulla laborum laborum aute ea culpa nulla dolor cillum veniam. Commodo esse tempor qui labore aute aliqua sint nulla do. + +Ad deserunt esse nostrud labore. Amet reprehenderit fugiat nostrud eu reprehenderit sit reprehenderit minim deserunt esse id occaecat cillum. Ad qui Lorem cillum laboris ipsum anim in culpa ad dolor consectetur minim culpa. + +Lorem cupidatat officia aute in eu commodo anim nulla deserunt occaecat reprehenderit dolore. Eu cupidatat reprehenderit ipsum sit laboris proident. Duis quis nulla tempor adipisicing. Adipisicing amet ad reprehenderit non mollit. Cupidatat proident tempor laborum sit ipsum adipisicing sunt magna labore. Eu irure nostrud cillum exercitation tempor proident. Laborum magna nisi consequat do sint occaecat magna incididunt. + +Sit mollit amet esse dolore in labore aliquip eu duis officia incididunt. Esse veniam labore excepteur eiusmod occaecat ullamco magna sunt. Ipsum occaecat exercitation anim fugiat in amet excepteur excepteur aliquip laborum. Aliquip aliqua consequat officia sit sint amet aliqua ipsum eu veniam. Id enim quis ea in eu consequat exercitation occaecat veniam consequat anim nulla adipisicing minim. Ut duis cillum laboris duis non commodo eu aliquip tempor nisi aute do. + +Ipsum nulla esse excepteur ut aliqua esse incididunt deserunt veniam dolore est laborum nisi veniam. Magna eiusmod Lorem do tempor incididunt ut aute aliquip ipsum ea laboris culpa. Occaecat do officia velit fugiat culpa eu minim magna sint occaecat sunt. Duis magna proident incididunt est cupidatat proident esse proident ut ipsum non dolor Lorem eiusmod. Officia quis irure id eu aliquip. + +Duis anim elit in officia in in aliquip est. Aliquip nisi labore qui elit elit cupidatat ut labore incididunt eiusmod ipsum. Sit irure nulla non cupidatat exercitation sit culpa nisi ex dolore. Culpa nisi duis duis eiusmod commodo nulla. + +Et magna aliqua amet qui mollit. Eiusmod aute ut anim ea est fugiat non nisi in laborum ullamco. Proident mollit sunt nostrud irure esse sunt eiusmod deserunt dolor. Irure aute ad magna est consequat duis cupidatat consequat. Enim tempor aute cillum quis ea do enim proident incididunt aliquip cillum tempor minim. Nulla minim tempor proident in excepteur consectetur veniam. + +Exercitation tempor nulla incididunt deserunt laboris ad incididunt aliqua exercitation. Adipisicing laboris veniam aute eiusmod qui magna fugiat velit. Aute quis officia anim commodo id fugiat nostrud est. Quis ipsum amet velit adipisicing eu anim minim eu est in culpa aute. Esse in commodo irure enim proident reprehenderit ullamco in dolore aute cillum. + +Irure excepteur ex occaecat ipsum laboris fugiat exercitation. Exercitation adipisicing velit excepteur eu culpa consequat exercitation dolore. In laboris aute quis qui mollit minim culpa. Magna velit ea aliquip veniam fugiat mollit veniam. +-- i18n/en.toml -- +[a] +other = 'Reading time: {{ .ReadingTime }}' +-- layouts/index.html -- +i18n: {{ i18n "a" . }}| + +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", ` + i18n: Reading time: 3| + `) +} + +// Issue 9216 +func TestI18nDefaultContentLanguage(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +disableKinds = ['RSS','sitemap','taxonomy','term','page','section'] +defaultContentLanguage = 'es' +defaultContentLanguageInSubdir = true +[languages.es] +[languages.fr] +-- i18n/es.toml -- +cat = 'gato' +-- i18n/fr.toml -- +# this file intentionally empty +-- layouts/index.html -- +{{ .Title }}_{{ T "cat" }} +-- content/_index.fr.md -- +--- +title: home_fr +--- +-- content/_index.md -- +--- +title: home_es +--- +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/es/index.html", `home_es_gato`) + b.AssertFileContent("public/fr/index.html", `home_fr_gato`) +} diff --git a/langs/i18n/i18n_test.go b/langs/i18n/i18n_test.go new file mode 100644 index 000000000..a23cee539 --- /dev/null +++ b/langs/i18n/i18n_test.go @@ -0,0 +1,519 @@ +// 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 i18n + +import ( + "context" + "fmt" + "path/filepath" + "testing" + + "github.com/bep/logg" + "github.com/gohugoio/hugo/common/types" + "github.com/gohugoio/hugo/config/testconfig" + + "github.com/gohugoio/hugo/resources/page" + "github.com/spf13/afero" + + "github.com/gohugoio/hugo/deps" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/config" +) + +type i18nTest struct { + name string + data map[string][]byte + args any + lang, id, expected, expectedFlag string +} + +var i18nTests = []i18nTest{ + // All translations present + { + name: "all-present", + data: map[string][]byte{ + "en.toml": []byte("[hello]\nother = \"Hello, World!\""), + "es.toml": []byte("[hello]\nother = \"¡Hola, Mundo!\""), + }, + args: nil, + lang: "es", + id: "hello", + expected: "¡Hola, Mundo!", + expectedFlag: "¡Hola, Mundo!", + }, + // Translation missing in current language but present in default + { + name: "present-in-default", + data: map[string][]byte{ + "en.toml": []byte("[hello]\nother = \"Hello, World!\""), + "es.toml": []byte("[goodbye]\nother = \"¡Adiós, Mundo!\""), + }, + args: nil, + lang: "es", + id: "hello", + expected: "Hello, World!", + expectedFlag: "[i18n] hello", + }, + // Translation missing in default language but present in current + { + name: "present-in-current", + data: map[string][]byte{ + "en.toml": []byte("[goodbye]\nother = \"Goodbye, World!\""), + "es.toml": []byte("[hello]\nother = \"¡Hola, Mundo!\""), + }, + args: nil, + lang: "es", + id: "hello", + expected: "¡Hola, Mundo!", + expectedFlag: "¡Hola, Mundo!", + }, + // Translation missing in both default and current language + { + name: "missing", + data: map[string][]byte{ + "en.toml": []byte("[goodbye]\nother = \"Goodbye, World!\""), + "es.toml": []byte("[goodbye]\nother = \"¡Adiós, Mundo!\""), + }, + args: nil, + lang: "es", + id: "hello", + expected: "", + expectedFlag: "[i18n] hello", + }, + // Default translation file missing or empty + { + name: "file-missing", + data: map[string][]byte{ + "en.toml": []byte(""), + }, + args: nil, + lang: "es", + id: "hello", + expected: "", + expectedFlag: "[i18n] hello", + }, + // Context provided + { + name: "context-provided", + data: map[string][]byte{ + "en.toml": []byte("[wordCount]\nother = \"Hello, {{.WordCount}} people!\""), + "es.toml": []byte("[wordCount]\nother = \"¡Hola, {{.WordCount}} gente!\""), + }, + args: struct { + WordCount int + }{ + 50, + }, + lang: "es", + id: "wordCount", + expected: "¡Hola, 50 gente!", + expectedFlag: "¡Hola, 50 gente!", + }, + // https://github.com/gohugoio/hugo/issues/7787 + { + name: "readingTime-one", + data: map[string][]byte{ + "en.toml": []byte(`[readingTime] +one = "One minute to read" +other = "{{ .Count }} minutes to read" +`), + }, + args: 1, + lang: "en", + id: "readingTime", + expected: "One minute to read", + expectedFlag: "One minute to read", + }, + { + name: "readingTime-many-dot", + data: map[string][]byte{ + "en.toml": []byte(`[readingTime] +one = "One minute to read" +other = "{{ . }} minutes to read" +`), + }, + args: 21, + lang: "en", + id: "readingTime", + expected: "21 minutes to read", + expectedFlag: "21 minutes to read", + }, + { + name: "readingTime-many", + data: map[string][]byte{ + "en.toml": []byte(`[readingTime] +one = "One minute to read" +other = "{{ .Count }} minutes to read" +`), + }, + args: 21, + lang: "en", + id: "readingTime", + expected: "21 minutes to read", + expectedFlag: "21 minutes to read", + }, + // Issue #8454 + { + name: "readingTime-map-one", + data: map[string][]byte{ + "en.toml": []byte(`[readingTime] +one = "One minute to read" +other = "{{ .Count }} minutes to read" +`), + }, + args: map[string]any{"Count": 1}, + lang: "en", + id: "readingTime", + expected: "One minute to read", + expectedFlag: "One minute to read", + }, + { + name: "readingTime-string-one", + data: map[string][]byte{ + "en.toml": []byte(`[readingTime] +one = "One minute to read" +other = "{{ . }} minutes to read" +`), + }, + args: "1", + lang: "en", + id: "readingTime", + expected: "One minute to read", + expectedFlag: "One minute to read", + }, + { + name: "readingTime-map-many", + data: map[string][]byte{ + "en.toml": []byte(`[readingTime] +one = "One minute to read" +other = "{{ .Count }} minutes to read" +`), + }, + args: map[string]any{"Count": 21}, + lang: "en", + id: "readingTime", + expected: "21 minutes to read", + expectedFlag: "21 minutes to read", + }, + { + name: "argument-float", + data: map[string][]byte{ + "en.toml": []byte(`[float] +other = "Number is {{ . }}" +`), + }, + args: 22.5, + lang: "en", + id: "float", + expected: "Number is 22.5", + expectedFlag: "Number is 22.5", + }, + // Same id and translation in current language + // https://github.com/gohugoio/hugo/issues/2607 + { + name: "same-id-and-translation", + data: map[string][]byte{ + "es.toml": []byte("[hello]\nother = \"hello\""), + "en.toml": []byte("[hello]\nother = \"hi\""), + }, + args: nil, + lang: "es", + id: "hello", + expected: "hello", + expectedFlag: "hello", + }, + // Translation missing in current language, but same id and translation in default + { + name: "same-id-and-translation-default", + data: map[string][]byte{ + "es.toml": []byte("[bye]\nother = \"bye\""), + "en.toml": []byte("[hello]\nother = \"hello\""), + }, + args: nil, + lang: "es", + id: "hello", + expected: "hello", + expectedFlag: "[i18n] hello", + }, + // Unknown language code should get its plural spec from en + { + name: "unknown-language-code", + data: map[string][]byte{ + "en.toml": []byte(`[readingTime] +one ="one minute read" +other = "{{.Count}} minutes read"`), + "klingon.toml": []byte(`[readingTime] +one = "eitt minutt med lesing" +other = "{{ .Count }} minuttar lesing"`), + }, + args: 3, + lang: "klingon", + id: "readingTime", + expected: "3 minuttar lesing", + expectedFlag: "3 minuttar lesing", + }, + // Issue #7838 + { + name: "unknown-language-codes", + data: map[string][]byte{ + "en.toml": []byte(`[readingTime] +one ="en one" +other = "en count {{.Count}}"`), + "a1.toml": []byte(`[readingTime] +one = "a1 one" +other = "a1 count {{ .Count }}"`), + "a2.toml": []byte(`[readingTime] +one = "a2 one" +other = "a2 count {{ .Count }}"`), + }, + args: 3, + lang: "a2", + id: "readingTime", + expected: "a2 count 3", + expectedFlag: "a2 count 3", + }, + // https://github.com/gohugoio/hugo/issues/7798 + { + name: "known-language-missing-plural", + data: map[string][]byte{ + "oc.toml": []byte(`[oc] +one = "abc"`), + }, + args: 1, + lang: "oc", + id: "oc", + expected: "abc", + expectedFlag: "abc", + }, + // https://github.com/gohugoio/hugo/issues/7794 + { + name: "dotted-bare-key", + data: map[string][]byte{ + "en.toml": []byte(`"shop_nextPage.one" = "Show Me The Money" +`), + }, + args: nil, + lang: "en", + id: "shop_nextPage.one", + expected: "Show Me The Money", + expectedFlag: "Show Me The Money", + }, + // https: //github.com/gohugoio/hugo/issues/7804 + { + name: "lang-with-hyphen", + data: map[string][]byte{ + "pt-br.toml": []byte(`foo.one = "abc"`), + }, + args: 1, + lang: "pt-br", + id: "foo", + expected: "abc", + expectedFlag: "abc", + }, +} + +func TestPlural(t *testing.T) { + c := qt.New(t) + + for _, test := range []struct { + name string + lang string + id string + templ string + variants []types.KeyValue + }{ + { + name: "English", + lang: "en", + id: "hour", + templ: ` +[hour] +one = "{{ . }} hour" +other = "{{ . }} hours"`, + variants: []types.KeyValue{ + {Key: 1, Value: "1 hour"}, + {Key: "1", Value: "1 hour"}, + {Key: 1.5, Value: "1.5 hours"}, + {Key: "1.5", Value: "1.5 hours"}, + {Key: 2, Value: "2 hours"}, + {Key: "2", Value: "2 hours"}, + }, + }, + { + name: "Other only", + lang: "en", + id: "hour", + templ: ` +[hour] +other = "{{ with . }}{{ . }}{{ end }} hours"`, + variants: []types.KeyValue{ + {Key: 1, Value: "1 hours"}, + {Key: "1", Value: "1 hours"}, + {Key: 2, Value: "2 hours"}, + {Key: nil, Value: " hours"}, + }, + }, + { + name: "Polish", + lang: "pl", + id: "day", + templ: ` +[day] +one = "{{ . }} miesiąc" +few = "{{ . }} miesiące" +many = "{{ . }} miesięcy" +other = "{{ . }} miesiąca" +`, + variants: []types.KeyValue{ + {Key: 1, Value: "1 miesiąc"}, + {Key: 2, Value: "2 miesiące"}, + {Key: 100, Value: "100 miesięcy"}, + {Key: "100.0", Value: "100.0 miesiąca"}, + {Key: 100.0, Value: "100 miesiąca"}, + }, + }, + } { + c.Run(test.name, func(c *qt.C) { + cfg := config.New() + cfg.Set("enableMissingTranslationPlaceholders", true) + cfg.Set("publishDir", "public") + afs := afero.NewMemMapFs() + + err := afero.WriteFile(afs, filepath.Join("i18n", test.lang+".toml"), []byte(test.templ), 0o755) + c.Assert(err, qt.IsNil) + + d, tp := prepareDeps(afs, cfg) + + f := tp.t.Func(test.lang) + ctx := context.Background() + + for _, variant := range test.variants { + c.Assert(f(ctx, test.id, variant.Key), qt.Equals, variant.Value, qt.Commentf("input: %v", variant.Key)) + c.Assert(d.Log.LoggCount(logg.LevelWarn), qt.Equals, 0) + } + }) + } +} + +func doTestI18nTranslate(t testing.TB, test i18nTest, cfg config.Provider) string { + tp := prepareTranslationProvider(t, test, cfg) + f := tp.t.Func(test.lang) + return f(context.Background(), test.id, test.args) +} + +type countField struct { + Count any +} + +type noCountField struct { + Counts int +} + +type countMethod struct{} + +func (c countMethod) Count() any { + return 32.5 +} + +func TestGetPluralCount(t *testing.T) { + c := qt.New(t) + + c.Assert(getPluralCount(map[string]any{"Count": 32}), qt.Equals, 32) + c.Assert(getPluralCount(map[string]any{"Count": 1}), qt.Equals, 1) + c.Assert(getPluralCount(map[string]any{"Count": 1.5}), qt.Equals, "1.5") + c.Assert(getPluralCount(map[string]any{"Count": "32"}), qt.Equals, "32") + c.Assert(getPluralCount(map[string]any{"Count": "32.5"}), qt.Equals, "32.5") + c.Assert(getPluralCount(map[string]any{"count": 32}), qt.Equals, 32) + c.Assert(getPluralCount(map[string]any{"Count": "32"}), qt.Equals, "32") + c.Assert(getPluralCount(map[string]any{"Counts": 32}), qt.Equals, nil) + c.Assert(getPluralCount("foo"), qt.Equals, nil) + c.Assert(getPluralCount(countField{Count: 22}), qt.Equals, 22) + c.Assert(getPluralCount(countField{Count: 1.5}), qt.Equals, "1.5") + c.Assert(getPluralCount(&countField{Count: 22}), qt.Equals, 22) + c.Assert(getPluralCount(noCountField{Counts: 23}), qt.Equals, nil) + c.Assert(getPluralCount(countMethod{}), qt.Equals, "32.5") + c.Assert(getPluralCount(&countMethod{}), qt.Equals, "32.5") + + c.Assert(getPluralCount(1234), qt.Equals, 1234) + c.Assert(getPluralCount(1234.4), qt.Equals, "1234.4") + c.Assert(getPluralCount(1234.0), qt.Equals, "1234.0") + c.Assert(getPluralCount("1234"), qt.Equals, "1234") + c.Assert(getPluralCount("0.5"), qt.Equals, "0.5") + c.Assert(getPluralCount(nil), qt.Equals, nil) +} + +func prepareTranslationProvider(t testing.TB, test i18nTest, cfg config.Provider) *TranslationProvider { + c := qt.New(t) + afs := afero.NewMemMapFs() + + for file, content := range test.data { + err := afero.WriteFile(afs, filepath.Join("i18n", file), []byte(content), 0o755) + c.Assert(err, qt.IsNil) + } + + _, tp := prepareDeps(afs, cfg) + return tp +} + +func prepareDeps(afs afero.Fs, cfg config.Provider) (*deps.Deps, *TranslationProvider) { + d := testconfig.GetTestDeps(afs, cfg) + translationProvider := NewTranslationProvider() + d.TranslationProvider = translationProvider + d.Site = page.NewDummyHugoSite(d.Conf) + if err := d.Compile(nil); err != nil { + panic(err) + } + return d, translationProvider +} + +func TestI18nTranslate(t *testing.T) { + c := qt.New(t) + var actual, expected string + v := config.New() + + // Test without and with placeholders + for _, enablePlaceholders := range []bool{false, true} { + v.Set("enableMissingTranslationPlaceholders", enablePlaceholders) + + for _, test := range i18nTests { + c.Run(fmt.Sprintf("%s-%t", test.name, enablePlaceholders), func(c *qt.C) { + if enablePlaceholders { + expected = test.expectedFlag + } else { + expected = test.expected + } + actual = doTestI18nTranslate(c, test, v) + c.Assert(actual, qt.Equals, expected) + }) + } + } +} + +func BenchmarkI18nTranslate(b *testing.B) { + v := config.New() + for _, test := range i18nTests { + b.Run(test.name, func(b *testing.B) { + tp := prepareTranslationProvider(b, test, v) + b.ResetTimer() + for i := 0; i < b.N; i++ { + f := tp.t.Func(test.lang) + actual := f(context.Background(), test.id, test.args) + if actual != test.expected { + b.Fatalf("expected %v got %v", test.expected, actual) + } + } + }) + } +} diff --git a/langs/i18n/translationProvider.go b/langs/i18n/translationProvider.go new file mode 100644 index 000000000..9ede538d2 --- /dev/null +++ b/langs/i18n/translationProvider.go @@ -0,0 +1,139 @@ +// 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 i18n + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/gohugoio/hugo/common/paths" + + "github.com/gohugoio/hugo/common/herrors" + "golang.org/x/text/language" + yaml "gopkg.in/yaml.v2" + + "github.com/gohugoio/go-i18n/v2/i18n" + "github.com/gohugoio/hugo/helpers" + toml "github.com/pelletier/go-toml/v2" + + "github.com/gohugoio/hugo/deps" + "github.com/gohugoio/hugo/hugofs" + "github.com/gohugoio/hugo/source" +) + +// TranslationProvider provides translation handling, i.e. loading +// of bundles etc. +type TranslationProvider struct { + t Translator +} + +// NewTranslationProvider creates a new translation provider. +func NewTranslationProvider() *TranslationProvider { + return &TranslationProvider{} +} + +// Update updates the i18n func in the provided Deps. +func (tp *TranslationProvider) NewResource(dst *deps.Deps) error { + defaultLangTag, err := language.Parse(dst.Conf.DefaultContentLanguage()) + if err != nil { + defaultLangTag = language.English + } + bundle := i18n.NewBundle(defaultLangTag) + + bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) + bundle.RegisterUnmarshalFunc("yaml", yaml.Unmarshal) + bundle.RegisterUnmarshalFunc("yml", yaml.Unmarshal) + bundle.RegisterUnmarshalFunc("json", json.Unmarshal) + + w := hugofs.NewWalkway( + hugofs.WalkwayConfig{ + Fs: dst.BaseFs.I18n.Fs, + IgnoreFile: dst.SourceSpec.IgnoreFile, + PathParser: dst.SourceSpec.Cfg.PathParser(), + WalkFn: func(path string, info hugofs.FileMetaInfo) error { + if info.IsDir() { + return nil + } + return addTranslationFile(bundle, source.NewFileInfo(info)) + }, + }) + + if err := w.Walk(); err != nil { + return err + } + + tp.t = NewTranslator(bundle, dst.Conf, dst.Log) + + dst.Translate = tp.t.Func(dst.Conf.Language().Lang) + + return nil +} + +const artificialLangTagPrefix = "art-x-" + +func addTranslationFile(bundle *i18n.Bundle, r *source.File) error { + f, err := r.FileInfo().Meta().Open() + if err != nil { + return fmt.Errorf("failed to open translations file %q:: %w", r.LogicalName(), err) + } + + b := helpers.ReaderToBytes(f) + f.Close() + + name := r.LogicalName() + lang := paths.Filename(name) + tag := language.Make(lang) + if tag == language.Und { + try := artificialLangTagPrefix + lang + _, err = language.Parse(try) + if err != nil { + return fmt.Errorf("%q: %s", try, err) + } + name = artificialLangTagPrefix + name + } + + _, err = bundle.ParseMessageFileBytes(b, name) + if err != nil { + if strings.Contains(err.Error(), "no plural rule") { + // https://github.com/gohugoio/hugo/issues/7798 + name = artificialLangTagPrefix + name + _, err = bundle.ParseMessageFileBytes(b, name) + if err == nil { + return nil + } + } + return errWithFileContext(fmt.Errorf("failed to load translations: %w", err), r) + } + + return nil +} + +// CloneResource sets the language func for the new language. +func (tp *TranslationProvider) CloneResource(dst, src *deps.Deps) error { + dst.Translate = tp.t.Func(dst.Conf.Language().Lang) + return nil +} + +func errWithFileContext(inerr error, r *source.File) error { + meta := r.FileInfo().Meta() + realFilename := meta.Filename + f, err := meta.Open() + if err != nil { + return inerr + } + defer f.Close() + + return herrors.NewFileErrorFromName(inerr, realFilename).UpdateContent(f, nil) +} diff --git a/langs/language.go b/langs/language.go new file mode 100644 index 000000000..d34ea1cc7 --- /dev/null +++ b/langs/language.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 langs contains the language related types and function. +package langs + +import ( + "fmt" + "sync" + "time" + + "golang.org/x/text/collate" + "golang.org/x/text/language" + + "github.com/gohugoio/hugo/common/htime" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/locales" + translators "github.com/gohugoio/localescompressed" +) + +type Language struct { + // The language code, e.g. "en" or "no". + // This is currently only settable as the key in the language map in the config. + Lang string + + // Fields from the language config. + LanguageConfig + + // Used for date formatting etc. We don't want these exported to the + // templates. + translator locales.Translator + timeFormatter htime.TimeFormatter + tag language.Tag + // collator1 and collator2 are the same, we have 2 to prevent deadlocks. + collator1 *Collator + collator2 *Collator + + location *time.Location + + // This is just an alias of Site.Params. + params maps.Params +} + +// NewLanguage creates a new language. +func NewLanguage(lang, defaultContentLanguage, timeZone string, languageConfig LanguageConfig) (*Language, error) { + translator := translators.GetTranslator(lang) + if translator == nil { + translator = translators.GetTranslator(defaultContentLanguage) + if translator == nil { + translator = translators.GetTranslator("en") + } + } + + var coll1, coll2 *Collator + tag, err := language.Parse(lang) + if err == nil { + coll1 = &Collator{ + c: collate.New(tag), + } + coll2 = &Collator{ + c: collate.New(tag), + } + } else { + coll1 = &Collator{ + c: collate.New(language.English), + } + coll2 = &Collator{ + c: collate.New(language.English), + } + } + + l := &Language{ + Lang: lang, + LanguageConfig: languageConfig, + translator: translator, + timeFormatter: htime.NewTimeFormatter(translator), + tag: tag, + collator1: coll1, + collator2: coll2, + } + + return l, l.loadLocation(timeZone) +} + +// This is injected from hugolib to avoid circular dependencies. +var DeprecationFunc = func(item, alternative string, err bool) {} + +// Params returns the language params. +// Note that this is the same as the Site.Params, but we keep it here for legacy reasons. +// Deprecated: Use the site.Params instead. +func (l *Language) Params() maps.Params { + // TODO(bep) Remove this for now as it created a little too much noise. Need to think about this. + // See https://github.com/gohugoio/hugo/issues/11025 + // DeprecationFunc(".Language.Params", paramsDeprecationWarning, false) + return l.params +} + +func (l *Language) LanguageCode() string { + if l.LanguageConfig.LanguageCode != "" { + return l.LanguageConfig.LanguageCode + } + return l.Lang +} + +func (l *Language) loadLocation(tzStr string) error { + location, err := time.LoadLocation(tzStr) + if err != nil { + return fmt.Errorf("invalid timeZone for language %q: %w", l.Lang, err) + } + l.location = location + + return nil +} + +func (l *Language) String() string { + return l.Lang +} + +// Languages is a sortable list of languages. +type Languages []*Language + +func (l Languages) AsSet() map[string]bool { + m := make(map[string]bool) + for _, lang := range l { + m[lang.Lang] = true + } + + return m +} + +// AsIndexSet returns a map with the language code as key and index in l as value. +func (l Languages) AsIndexSet() map[string]int { + m := make(map[string]int) + for i, lang := range l { + m[lang.Lang] = i + } + + return m +} + +// Internal access to unexported Language fields. +// This construct is to prevent them from leaking to the templates. + +func SetParams(l *Language, params maps.Params) { + l.params = params +} + +func GetTimeFormatter(l *Language) htime.TimeFormatter { + return l.timeFormatter +} + +func GetTranslator(l *Language) locales.Translator { + return l.translator +} + +func GetLocation(l *Language) *time.Location { + return l.location +} + +func GetCollator1(l *Language) *Collator { + return l.collator1 +} + +func GetCollator2(l *Language) *Collator { + return l.collator2 +} + +type Collator struct { + sync.Mutex + c *collate.Collator +} + +// CompareStrings compares a and b. +// It returns -1 if a < b, 1 if a > b and 0 if a == b. +// Note that the Collator is not thread safe, so you may want +// to acquire a lock on it before calling this method. +func (c *Collator) CompareStrings(a, b string) int { + return c.c.CompareString(a, b) +} diff --git a/langs/language_test.go b/langs/language_test.go new file mode 100644 index 000000000..33240f3f4 --- /dev/null +++ b/langs/language_test.go @@ -0,0 +1,76 @@ +// 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 langs + +import ( + "sync" + "testing" + + qt "github.com/frankban/quicktest" + "golang.org/x/text/collate" + "golang.org/x/text/language" +) + +func TestCollator(t *testing.T) { + c := qt.New(t) + + var wg sync.WaitGroup + + coll := &Collator{c: collate.New(language.English, collate.Loose)} + + for range 10 { + wg.Add(1) + go func() { + coll.Lock() + defer coll.Unlock() + defer wg.Done() + for range 10 { + k := coll.CompareStrings("abc", "def") + c.Assert(k, qt.Equals, -1) + } + }() + } + wg.Wait() +} + +func BenchmarkCollator(b *testing.B) { + s := []string{"foo", "bar", "éntre", "baz", "qux", "quux", "corge", "grault", "garply", "waldo", "fred", "plugh", "xyzzy", "thud"} + + doWork := func(coll *Collator) { + for i := range s { + for j := i + 1; j < len(s); j++ { + _ = coll.CompareStrings(s[i], s[j]) + } + } + } + + b.Run("Single", func(b *testing.B) { + coll := &Collator{c: collate.New(language.English, collate.Loose)} + for i := 0; i < b.N; i++ { + doWork(coll) + } + }) + + b.Run("Para", func(b *testing.B) { + b.RunParallel(func(pb *testing.PB) { + coll := &Collator{c: collate.New(language.English, collate.Loose)} + + for pb.Next() { + coll.Lock() + doWork(coll) + coll.Unlock() + } + }) + }) +} diff --git a/lazy/init.go b/lazy/init.go new file mode 100644 index 000000000..bef3867a9 --- /dev/null +++ b/lazy/init.go @@ -0,0 +1,209 @@ +// 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 lazy + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "time" +) + +// New creates a new empty Init. +func New() *Init { + return &Init{} +} + +// Init holds a graph of lazily initialized dependencies. +type Init struct { + // Used mainly for testing. + initCount uint64 + + mu sync.Mutex + + prev *Init + children []*Init + + init OnceMore + out any + err error + f func(context.Context) (any, error) +} + +// Add adds a func as a new child dependency. +func (ini *Init) Add(initFn func(context.Context) (any, error)) *Init { + if ini == nil { + ini = New() + } + return ini.add(false, initFn) +} + +// InitCount gets the number of this this Init has been initialized. +func (ini *Init) InitCount() int { + i := atomic.LoadUint64(&ini.initCount) + return int(i) +} + +// AddWithTimeout is same as Add, but with a timeout that aborts initialization. +func (ini *Init) AddWithTimeout(timeout time.Duration, f func(ctx context.Context) (any, error)) *Init { + return ini.Add(func(ctx context.Context) (any, error) { + return ini.withTimeout(ctx, timeout, f) + }) +} + +// Branch creates a new dependency branch based on an existing and adds +// the given dependency as a child. +func (ini *Init) Branch(initFn func(context.Context) (any, error)) *Init { + if ini == nil { + ini = New() + } + return ini.add(true, initFn) +} + +// BranchWithTimeout is same as Branch, but with a timeout. +func (ini *Init) BranchWithTimeout(timeout time.Duration, f func(ctx context.Context) (any, error)) *Init { + return ini.Branch(func(ctx context.Context) (any, error) { + return ini.withTimeout(ctx, timeout, f) + }) +} + +// Do initializes the entire dependency graph. +func (ini *Init) Do(ctx context.Context) (any, error) { + if ini == nil { + panic("init is nil") + } + + ini.init.Do(func() { + atomic.AddUint64(&ini.initCount, 1) + prev := ini.prev + if prev != nil { + // A branch. Initialize the ancestors. + if prev.shouldInitialize() { + _, err := prev.Do(ctx) + if err != nil { + ini.err = err + return + } + } else if prev.inProgress() { + // Concurrent initialization. The following init func + // may depend on earlier state, so wait. + prev.wait() + } + } + + if ini.f != nil { + ini.out, ini.err = ini.f(ctx) + } + + for _, child := range ini.children { + if child.shouldInitialize() { + _, err := child.Do(ctx) + if err != nil { + ini.err = err + return + } + } + } + }) + + ini.wait() + + return ini.out, ini.err +} + +// TODO(bep) investigate if we can use sync.Cond for this. +func (ini *Init) wait() { + var counter time.Duration + for !ini.init.Done() { + counter += 10 + if counter > 600000000 { + panic("BUG: timed out in lazy init") + } + time.Sleep(counter * time.Microsecond) + } +} + +func (ini *Init) inProgress() bool { + return ini != nil && ini.init.InProgress() +} + +func (ini *Init) shouldInitialize() bool { + return !(ini == nil || ini.init.Done() || ini.init.InProgress()) +} + +// Reset resets the current and all its dependencies. +func (ini *Init) Reset() { + mu := ini.init.ResetWithLock() + ini.err = nil + defer mu.Unlock() + for _, d := range ini.children { + d.Reset() + } +} + +func (ini *Init) add(branch bool, initFn func(context.Context) (any, error)) *Init { + ini.mu.Lock() + defer ini.mu.Unlock() + + if branch { + return &Init{ + f: initFn, + prev: ini, + } + } + + ini.checkDone() + ini.children = append(ini.children, &Init{ + f: initFn, + }) + + return ini +} + +func (ini *Init) checkDone() { + if ini.init.Done() { + panic("init cannot be added to after it has run") + } +} + +func (ini *Init) withTimeout(ctx context.Context, timeout time.Duration, f func(ctx context.Context) (any, error)) (any, error) { + // Create a new context with a timeout not connected to the incoming context. + waitCtx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + c := make(chan verr, 1) + + go func() { + v, err := f(ctx) + select { + case <-waitCtx.Done(): + return + default: + c <- verr{v: v, err: err} + } + }() + + select { + case <-waitCtx.Done(): + //lint:ignore ST1005 end user message. + return nil, errors.New("timed out initializing value. You may have a circular loop in a shortcode, or your site may have resources that take longer to build than the `timeout` limit in your Hugo config file.") + case ve := <-c: + return ve.v, ve.err + } +} + +type verr struct { + v any + err error +} diff --git a/lazy/init_test.go b/lazy/init_test.go new file mode 100644 index 000000000..94736fab8 --- /dev/null +++ b/lazy/init_test.go @@ -0,0 +1,237 @@ +// 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 lazy + +import ( + "context" + "errors" + "math/rand" + "strings" + "sync" + "testing" + "time" + + qt "github.com/frankban/quicktest" +) + +var ( + rnd = rand.New(rand.NewSource(time.Now().UnixNano())) + bigOrSmall = func() int { + if rnd.Intn(10) < 5 { + return 10000 + rnd.Intn(100000) + } + return 1 + rnd.Intn(50) + } +) + +func doWork() { + doWorkOfSize(bigOrSmall()) +} + +func doWorkOfSize(size int) { + _ = strings.Repeat("Hugo Rocks! ", size) +} + +func TestInit(t *testing.T) { + c := qt.New(t) + + var result string + + f1 := func(name string) func(context.Context) (any, error) { + return func(context.Context) (any, error) { + result += name + "|" + doWork() + return name, nil + } + } + + f2 := func() func(context.Context) (any, error) { + return func(context.Context) (any, error) { + doWork() + return nil, nil + } + } + + root := New() + + root.Add(f1("root(1)")) + root.Add(f1("root(2)")) + + branch1 := root.Branch(f1("branch_1")) + branch1.Add(f1("branch_1_1")) + branch1_2 := branch1.Add(f1("branch_1_2")) + branch1_2_1 := branch1_2.Add(f1("branch_1_2_1")) + + var wg sync.WaitGroup + + ctx := context.Background() + + // Add some concurrency and randomness to verify thread safety and + // init order. + for i := range 100 { + wg.Add(1) + go func(i int) { + defer wg.Done() + var err error + if rnd.Intn(10) < 5 { + _, err = root.Do(ctx) + c.Assert(err, qt.IsNil) + } + + // Add a new branch on the fly. + if rnd.Intn(10) > 5 { + branch := branch1_2.Branch(f2()) + _, err = branch.Do(ctx) + c.Assert(err, qt.IsNil) + } else { + _, err = branch1_2_1.Do(ctx) + c.Assert(err, qt.IsNil) + } + _, err = branch1_2.Do(ctx) + c.Assert(err, qt.IsNil) + }(i) + + wg.Wait() + + c.Assert(result, qt.Equals, "root(1)|root(2)|branch_1|branch_1_1|branch_1_2|branch_1_2_1|") + + } +} + +func TestInitAddWithTimeout(t *testing.T) { + c := qt.New(t) + + init := New().AddWithTimeout(100*time.Millisecond, func(ctx context.Context) (any, error) { + return nil, nil + }) + + _, err := init.Do(context.Background()) + + c.Assert(err, qt.IsNil) +} + +func TestInitAddWithTimeoutTimeout(t *testing.T) { + c := qt.New(t) + + init := New().AddWithTimeout(100*time.Millisecond, func(ctx context.Context) (any, error) { + time.Sleep(500 * time.Millisecond) + return nil, nil + }) + + _, err := init.Do(context.Background()) + + c.Assert(err, qt.Not(qt.IsNil)) + + c.Assert(err.Error(), qt.Contains, "timed out") + + time.Sleep(1 * time.Second) +} + +func TestInitAddWithTimeoutError(t *testing.T) { + c := qt.New(t) + + init := New().AddWithTimeout(100*time.Millisecond, func(ctx context.Context) (any, error) { + return nil, errors.New("failed") + }) + + _, err := init.Do(context.Background()) + + c.Assert(err, qt.Not(qt.IsNil)) +} + +type T struct { + sync.Mutex + V1 string + V2 string +} + +func (t *T) Add1(v string) { + t.Lock() + t.V1 += v + t.Unlock() +} + +func (t *T) Add2(v string) { + t.Lock() + t.V2 += v + t.Unlock() +} + +// https://github.com/gohugoio/hugo/issues/5901 +func TestInitBranchOrder(t *testing.T) { + c := qt.New(t) + + base := New() + + work := func(size int, f func()) func(context.Context) (any, error) { + return func(context.Context) (any, error) { + doWorkOfSize(size) + if f != nil { + f() + } + + return nil, nil + } + } + + state := &T{} + + base = base.Add(work(10000, func() { + state.Add1("A") + })) + + inits := make([]*Init, 2) + for i := range inits { + inits[i] = base.Branch(work(i+1*100, func() { + // V1 is A + ab := state.V1 + "B" + state.Add2(ab) + })) + } + + var wg sync.WaitGroup + ctx := context.Background() + + for _, v := range inits { + v := v + wg.Add(1) + go func() { + defer wg.Done() + _, err := v.Do(ctx) + c.Assert(err, qt.IsNil) + }() + } + + wg.Wait() + + c.Assert(state.V2, qt.Equals, "ABAB") +} + +// See issue 7043 +func TestResetError(t *testing.T) { + c := qt.New(t) + r := false + i := New().Add(func(context.Context) (any, error) { + if r { + return nil, nil + } + return nil, errors.New("r is false") + }) + _, err := i.Do(context.Background()) + c.Assert(err, qt.IsNotNil) + i.Reset() + r = true + _, err = i.Do(context.Background()) + c.Assert(err, qt.IsNil) +} diff --git a/lazy/once.go b/lazy/once.go new file mode 100644 index 000000000..dac689df3 --- /dev/null +++ b/lazy/once.go @@ -0,0 +1,68 @@ +// 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 lazy + +import ( + "sync" + "sync/atomic" +) + +// OnceMore is similar to sync.Once. +// +// Additional features are: +// * it can be reset, so the action can be repeated if needed +// * it has methods to check if it's done or in progress + +type OnceMore struct { + mu sync.Mutex + lock uint32 + done uint32 +} + +func (t *OnceMore) Do(f func()) { + if atomic.LoadUint32(&t.done) == 1 { + return + } + + // f may call this Do and we would get a deadlock. + locked := atomic.CompareAndSwapUint32(&t.lock, 0, 1) + if !locked { + return + } + defer atomic.StoreUint32(&t.lock, 0) + + t.mu.Lock() + defer t.mu.Unlock() + + // Double check + if t.done == 1 { + return + } + defer atomic.StoreUint32(&t.done, 1) + f() +} + +func (t *OnceMore) InProgress() bool { + return atomic.LoadUint32(&t.lock) == 1 +} + +func (t *OnceMore) Done() bool { + return atomic.LoadUint32(&t.done) == 1 +} + +func (t *OnceMore) ResetWithLock() *sync.Mutex { + t.mu.Lock() + defer atomic.StoreUint32(&t.done, 0) + return &t.mu +} diff --git a/livereload/connection.go b/livereload/connection.go index 4e94e2ee0..0c6c6e108 100644 --- a/livereload/connection.go +++ b/livereload/connection.go @@ -28,7 +28,7 @@ type connection struct { send chan []byte // There is a potential data race, especially visible with large files. - // This is protected by synchronisation of the send channel's close. + // This is protected by synchronization of the send channel's close. closer sync.Once } diff --git a/livereload/gen/livereload-hugo-plugin.js b/livereload/gen/livereload-hugo-plugin.js new file mode 100644 index 000000000..c4c6aa487 --- /dev/null +++ b/livereload/gen/livereload-hugo-plugin.js @@ -0,0 +1,34 @@ +/* +Hugo adds a specific prefix, "__hugo_navigate", to the path in certain situations to signal +navigation to another content page. +*/ +function HugoReload() {} + +HugoReload.identifier = 'hugoReloader'; +HugoReload.version = '0.9'; + +HugoReload.prototype.reload = function (path, options) { + var prefix = '__hugo_navigate'; + + if (path.lastIndexOf(prefix, 0) !== 0) { + return false; + } + + path = path.substring(prefix.length); + + var portChanged = options.overrideURL && options.overrideURL != window.location.port; + + if (!portChanged && window.location.pathname === path) { + window.location.reload(); + } else { + if (portChanged) { + window.location = location.protocol + '//' + location.hostname + ':' + options.overrideURL + path; + } else { + window.location.pathname = path; + } + } + + return true; +}; + +LiveReload.addPlugin(HugoReload); diff --git a/livereload/gen/main.go b/livereload/gen/main.go new file mode 100644 index 000000000..d69ff9206 --- /dev/null +++ b/livereload/gen/main.go @@ -0,0 +1,61 @@ +//go:generate go run main.go +package main + +import ( + _ "embed" + "fmt" + "io" + "log" + "net/http" + "os" + + "github.com/evanw/esbuild/pkg/api" +) + +//go:embed livereload-hugo-plugin.js +var livereloadHugoPluginJS string + +func main() { + // 4.0.2 + // To upgrade to a new version, change to the commit hash of the version you want to upgrade to + // then run mage generate from the root. + const liveReloadCommit = "d803a41804d2d71e0814c4e9e3233e78991024d9" + liveReloadSourceURL := fmt.Sprintf("https://raw.githubusercontent.com/livereload/livereload-js/%s/dist/livereload.js", liveReloadCommit) + + func() { + resp, err := http.Get(liveReloadSourceURL) + must(err) + defer resp.Body.Close() + + b, err := io.ReadAll(resp.Body) + must(err) + + // Write the unminified livereload.js file. + err = os.WriteFile("../livereload.js", b, 0o644) + must(err) + + // Bundle and minify with ESBuild. + result := api.Build(api.BuildOptions{ + Stdin: &api.StdinOptions{ + Contents: string(b) + livereloadHugoPluginJS, + }, + Outfile: "../livereload.min.js", + Bundle: true, + Target: api.ES2015, + Write: true, + MinifyWhitespace: true, + MinifyIdentifiers: true, + MinifySyntax: true, + }) + + if len(result.Errors) > 0 { + log.Fatal(result.Errors) + } + }() +} + +func must(err error) { + if err != nil { + log.Fatal(err) + } +} diff --git a/livereload/livereload.go b/livereload/livereload.go index 2f3cee8f0..0d24ada98 100644 --- a/livereload/livereload.go +++ b/livereload/livereload.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. @@ -43,10 +43,14 @@ import ( "net/url" "path/filepath" + _ "embed" + + "github.com/gohugoio/hugo/media" "github.com/gorilla/websocket" ) // Prefix to signal to LiveReload that we need to navigate to another path. +// Do not change this. const hugoNavigatePrefix = "__hugo_navigate" var upgrader = &websocket.Upgrader{ @@ -62,7 +66,13 @@ var upgrader = &websocket.Upgrader{ return false } - if u.Host == r.Host { + rHost := r.Host + // For Github codespace in browser #9936 + if forwardedHost := r.Header.Get("X-Forwarded-Host"); forwardedHost != "" { + rHost = forwardedHost + } + + if u.Host == rHost { return true } @@ -77,7 +87,8 @@ var upgrader = &websocket.Upgrader{ return h1 == h2 }, - ReadBufferSize: 1024, WriteBufferSize: 1024} + ReadBufferSize: 1024, WriteBufferSize: 1024, +} // Handler is a HandlerFunc handling the livereload // Websocket interaction. @@ -103,12 +114,6 @@ func ForceRefresh() { RefreshPath("/x.js") } -// NavigateToPath tells livereload to navigate to the given path. -// This translates to `window.location.href = path` in the client. -func NavigateToPath(path string) { - RefreshPath(hugoNavigatePrefix + path) -} - // NavigateToPathForPort is similar to NavigateToPath but will also // set window.location.port to the given port value. func NavigateToPathForPort(path string, port int) { @@ -133,56 +138,15 @@ func refreshPathForPort(s string, port int) { wsHub.broadcast <- []byte(msg) } -// ServeJS serves the liverreload.js who's reference is injected into the page. +// ServeJS serves the livereload.js who's reference is injected into the page. func ServeJS(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/javascript") + w.Header().Set("Content-Type", media.Builtin.JavascriptType.Type) w.Write(liveReloadJS()) } func liveReloadJS() []byte { - return []byte(livereloadJS + hugoLiveReloadPlugin) + return []byte(livereloadJS) } -var ( - // This is temporary patched with this PR (enables sensible error messages): - // https://github.com/livereload/livereload-js/pull/64 - // TODO(bep) replace with distribution once merged. - livereloadJS = `(function e(t,n,o){function i(s,l){if(!n[s]){if(!t[s]){var c=typeof require=="function"&&require;if(!l&&c)return c(s,!0);if(r)return r(s,!0);var a=new Error("Cannot find module '"+s+"'");throw a.code="MODULE_NOT_FOUND",a}var h=n[s]={exports:{}};t[s][0].call(h.exports,function(e){var n=t[s][1][e];return i(n?n:e)},h,h.exports,e,t,n,o)}return n[s].exports}var r=typeof require=="function"&&require;for(var s=0;s tag");return}}this.reloader=new s(this.window,this.console,l);this.connector=new t(this.options,this.WebSocket,l,{connecting:function(e){return function(){}}(this),socketConnected:function(e){return function(){}}(this),connected:function(e){return function(t){var n;if(typeof(n=e.listeners).connect==="function"){n.connect()}e.log("LiveReload is connected to "+e.options.host+":"+e.options.port+" (protocol v"+t+").");return e.analyze()}}(this),error:function(e){return function(e){if(e instanceof r){if(typeof console!=="undefined"&&console!==null){return console.log(""+e.message+".")}}else{if(typeof console!=="undefined"&&console!==null){return console.log("LiveReload internal error: "+e.message)}}}}(this),disconnected:function(e){return function(t,n){var o;if(typeof(o=e.listeners).disconnect==="function"){o.disconnect()}switch(t){case"cannot-connect":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+", will retry in "+n+" sec.");case"broken":return e.log("LiveReload disconnected from "+e.options.host+":"+e.options.port+", reconnecting in "+n+" sec.");case"handshake-timeout":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+" (handshake timeout), will retry in "+n+" sec.");case"handshake-failed":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+" (handshake failed), will retry in "+n+" sec.");case"manual":break;case"error":break;default:return e.log("LiveReload disconnected from "+e.options.host+":"+e.options.port+" ("+t+"), reconnecting in "+n+" sec.")}}}(this),message:function(e){return function(t){switch(t.command){case"reload":return e.performReload(t);case"alert":return e.performAlert(t)}}}(this)});this.initialized=true}e.prototype.on=function(e,t){return this.listeners[e]=t};e.prototype.log=function(e){return this.console.log(""+e)};e.prototype.performReload=function(e){var t,n;this.log("LiveReload received reload request: "+JSON.stringify(e,null,2));return this.reloader.reload(e.path,{liveCSS:(t=e.liveCSS)!=null?t:true,liveImg:(n=e.liveImg)!=null?n:true,originalPath:e.originalPath||"",overrideURL:e.overrideURL||"",serverURL:"http://"+this.options.host+":"+this.options.port})};e.prototype.performAlert=function(e){return alert(e.message)};e.prototype.shutDown=function(){var e;if(!this.initialized){return}this.connector.disconnect();this.log("LiveReload disconnected.");return typeof(e=this.listeners).shutdown==="function"?e.shutdown():void 0};e.prototype.hasPlugin=function(e){return!!this.pluginIdentifiers[e]};e.prototype.addPlugin=function(e){var t;if(!this.initialized){return}if(this.hasPlugin(e.identifier)){return}this.pluginIdentifiers[e.identifier]=true;t=new e(this.window,{_livereload:this,_reloader:this.reloader,_connector:this.connector,console:this.console,Timer:l,generateCacheBustUrl:function(e){return function(t){return e.reloader.generateCacheBustUrl(t)}}(this)});this.plugins.push(t);this.reloader.addPlugin(t)};e.prototype.analyze=function(){var e,t,n,o,i,r;if(!this.initialized){return}if(!(this.connector.protocol>=7)){return}n={};r=this.plugins;for(o=0,i=r.length;o1){s.set(o[0].replace(/-/g,"_"),o.slice(1).join("="))}}}return s}}return null}}).call(this)},{}],6:[function(e,t,n){(function(){var e,t,o,i,r=[].indexOf||function(e){for(var t=0,n=this.length;t=0){this.protocol=7}else if(r.call(l.protocols,e)>=0){this.protocol=6}else{throw new i("no supported protocols found")}}return this.handlers.connected(this.protocol)}else if(this.protocol===6){l=JSON.parse(n);if(!l.length){throw new i("protocol 6 messages must be arrays")}o=l[0],c=l[1];if(o!=="refresh"){throw new i("unknown protocol 6 command")}return this.handlers.message({command:"reload",path:c.path,liveCSS:(a=c.apply_css_live)!=null?a:true})}else{l=this._parseMessage(n,["reload","alert"]);return this.handlers.message(l)}}catch(e){s=e;if(s instanceof i){return this.handlers.error(s)}else{throw s}}};n.prototype._parseMessage=function(e,t){var n,o,s;try{o=JSON.parse(e)}catch(t){n=t;throw new i("unparsable JSON",e)}if(!o.command){throw new i('missing "command" key',e)}if(s=o.command,r.call(t,s)<0){throw new i("invalid command '"+o.command+"', only valid commands are: "+t.join(", ")+")",e)}return o};return n}()}).call(this)},{}],7:[function(e,t,n){(function(){var e,t,o,i,r,s,l;l=function(e){var t,n,o;if((n=e.indexOf("#"))>=0){t=e.slice(n);e=e.slice(0,n)}else{t=""}if((n=e.indexOf("?"))>=0){o=e.slice(n);e=e.slice(0,n)}else{o=""}return{url:e,params:o,hash:t}};i=function(e){var t;e=l(e).url;if(e.indexOf("file://")===0){t=e.replace(/^file:\/\/(localhost)?/,"")}else{t=e.replace(/^([^:]+:)?\/\/([^:\/]+)(:\d*)?\//,"/")}return decodeURIComponent(t)};s=function(e,t,n){var i,r,s,l,c;i={score:0};for(l=0,c=t.length;li.score){i={object:r,score:s}}}if(i.score>0){return i}else{return null}};o=function(e,t){var n,o,i,r;e=e.replace(/^\/+/,"").toLowerCase();t=t.replace(/^\/+/,"").toLowerCase();if(e===t){return 1e4}n=e.split("/").reverse();o=t.split("/").reverse();r=Math.min(n.length,o.length);i=0;while(i0};e=[{selector:"background",styleNames:["backgroundImage"]},{selector:"border",styleNames:["borderImage","webkitBorderImage","MozBorderImage"]}];n.Reloader=t=function(){function t(e,t,n){this.window=e;this.console=t;this.Timer=n;this.document=this.window.document;this.importCacheWaitPeriod=200;this.plugins=[]}t.prototype.addPlugin=function(e){return this.plugins.push(e)};t.prototype.analyze=function(e){return results};t.prototype.reload=function(e,t){var n,o,i,r,s;this.options=t;if((o=this.options).stylesheetReloadTimeout==null){o.stylesheetReloadTimeout=15e3}s=this.plugins;for(i=0,r=s.length;i Array#indexOf +// true -> Array#includes +var toIObject = require('./_to-iobject'); +var toLength = require('./_to-length'); +var toAbsoluteIndex = require('./_to-absolute-index'); +module.exports = function (IS_INCLUDES) { + return function ($this, el, fromIndex) { + var O = toIObject($this); + var length = toLength(O.length); + var index = toAbsoluteIndex(fromIndex, length); + var value; + // Array#includes uses SameValueZero equality algorithm + // eslint-disable-next-line no-self-compare + if (IS_INCLUDES && el != el) while (length > index) { + value = O[index++]; + // eslint-disable-next-line no-self-compare + if (value != value) return true; + // Array#indexOf ignores holes, Array#includes - not + } else for (;length > index; index++) if (IS_INCLUDES || index in O) { + if (O[index] === el) return IS_INCLUDES || index || 0; + } return !IS_INCLUDES && -1; + }; +}; + +},{"./_to-absolute-index":72,"./_to-iobject":74,"./_to-length":75}],6:[function(require,module,exports){ +// 0 -> Array#forEach +// 1 -> Array#map +// 2 -> Array#filter +// 3 -> Array#some +// 4 -> Array#every +// 5 -> Array#find +// 6 -> Array#findIndex +var ctx = require('./_ctx'); +var IObject = require('./_iobject'); +var toObject = require('./_to-object'); +var toLength = require('./_to-length'); +var asc = require('./_array-species-create'); +module.exports = function (TYPE, $create) { + var IS_MAP = TYPE == 1; + var IS_FILTER = TYPE == 2; + var IS_SOME = TYPE == 3; + var IS_EVERY = TYPE == 4; + var IS_FIND_INDEX = TYPE == 6; + var NO_HOLES = TYPE == 5 || IS_FIND_INDEX; + var create = $create || asc; + return function ($this, callbackfn, that) { + var O = toObject($this); + var self = IObject(O); + var f = ctx(callbackfn, that, 3); + var length = toLength(self.length); + var index = 0; + var result = IS_MAP ? create($this, length) : IS_FILTER ? create($this, 0) : undefined; + var val, res; + for (;length > index; index++) if (NO_HOLES || index in self) { + val = self[index]; + res = f(val, index, O); + if (TYPE) { + if (IS_MAP) result[index] = res; // map + else if (res) switch (TYPE) { + case 3: return true; // some + case 5: return val; // find + case 6: return index; // findIndex + case 2: result.push(val); // filter + } else if (IS_EVERY) return false; // every + } + } + return IS_FIND_INDEX ? -1 : IS_SOME || IS_EVERY ? IS_EVERY : result; + }; +}; + +},{"./_array-species-create":8,"./_ctx":13,"./_iobject":31,"./_to-length":75,"./_to-object":76}],7:[function(require,module,exports){ +var isObject = require('./_is-object'); +var isArray = require('./_is-array'); +var SPECIES = require('./_wks')('species'); + +module.exports = function (original) { + var C; + if (isArray(original)) { + C = original.constructor; + // cross-realm fallback + if (typeof C == 'function' && (C === Array || isArray(C.prototype))) C = undefined; + if (isObject(C)) { + C = C[SPECIES]; + if (C === null) C = undefined; + } + } return C === undefined ? Array : C; +}; + +},{"./_is-array":33,"./_is-object":34,"./_wks":81}],8:[function(require,module,exports){ +// 9.4.2.3 ArraySpeciesCreate(originalArray, length) +var speciesConstructor = require('./_array-species-constructor'); + +module.exports = function (original, length) { + return new (speciesConstructor(original))(length); +}; + +},{"./_array-species-constructor":7}],9:[function(require,module,exports){ +// getting tag from 19.1.3.6 Object.prototype.toString() +var cof = require('./_cof'); +var TAG = require('./_wks')('toStringTag'); +// ES3 wrong here +var ARG = cof(function () { return arguments; }()) == 'Arguments'; + +// fallback for IE11 Script Access Denied error +var tryGet = function (it, key) { + try { + return it[key]; + } catch (e) { /* empty */ } +}; + +module.exports = function (it) { + var O, T, B; + return it === undefined ? 'Undefined' : it === null ? 'Null' + // @@toStringTag case + : typeof (T = tryGet(O = Object(it), TAG)) == 'string' ? T + // builtinTag case + : ARG ? cof(O) + // ES3 arguments fallback + : (B = cof(O)) == 'Object' && typeof O.callee == 'function' ? 'Arguments' : B; +}; + +},{"./_cof":10,"./_wks":81}],10:[function(require,module,exports){ +var toString = {}.toString; + +module.exports = function (it) { + return toString.call(it).slice(8, -1); +}; + +},{}],11:[function(require,module,exports){ +var core = module.exports = { version: '2.6.12' }; +if (typeof __e == 'number') __e = core; // eslint-disable-line no-undef + +},{}],12:[function(require,module,exports){ +'use strict'; +var $defineProperty = require('./_object-dp'); +var createDesc = require('./_property-desc'); + +module.exports = function (object, index, value) { + if (index in object) $defineProperty.f(object, index, createDesc(0, value)); + else object[index] = value; +}; + +},{"./_object-dp":45,"./_property-desc":57}],13:[function(require,module,exports){ +// optional / simple context binding +var aFunction = require('./_a-function'); +module.exports = function (fn, that, length) { + aFunction(fn); + if (that === undefined) return fn; + switch (length) { + case 1: return function (a) { + return fn.call(that, a); + }; + case 2: return function (a, b) { + return fn.call(that, a, b); + }; + case 3: return function (a, b, c) { + return fn.call(that, a, b, c); + }; + } + return function (/* ...args */) { + return fn.apply(that, arguments); + }; +}; + +},{"./_a-function":1}],14:[function(require,module,exports){ +// 7.2.1 RequireObjectCoercible(argument) +module.exports = function (it) { + if (it == undefined) throw TypeError("Can't call method on " + it); + return it; +}; + +},{}],15:[function(require,module,exports){ +// Thank's IE8 for his funny defineProperty +module.exports = !require('./_fails')(function () { + return Object.defineProperty({}, 'a', { get: function () { return 7; } }).a != 7; +}); + +},{"./_fails":21}],16:[function(require,module,exports){ +var isObject = require('./_is-object'); +var document = require('./_global').document; +// typeof document.createElement is 'object' in old IE +var is = isObject(document) && isObject(document.createElement); +module.exports = function (it) { + return is ? document.createElement(it) : {}; +}; + +},{"./_global":25,"./_is-object":34}],17:[function(require,module,exports){ +// IE 8- don't enum bug keys +module.exports = ( + 'constructor,hasOwnProperty,isPrototypeOf,propertyIsEnumerable,toLocaleString,toString,valueOf' +).split(','); + +},{}],18:[function(require,module,exports){ +// all enumerable object keys, includes symbols +var getKeys = require('./_object-keys'); +var gOPS = require('./_object-gops'); +var pIE = require('./_object-pie'); +module.exports = function (it) { + var result = getKeys(it); + var getSymbols = gOPS.f; + if (getSymbols) { + var symbols = getSymbols(it); + var isEnum = pIE.f; + var i = 0; + var key; + while (symbols.length > i) if (isEnum.call(it, key = symbols[i++])) result.push(key); + } return result; +}; + +},{"./_object-gops":50,"./_object-keys":53,"./_object-pie":54}],19:[function(require,module,exports){ +var global = require('./_global'); +var core = require('./_core'); +var hide = require('./_hide'); +var redefine = require('./_redefine'); +var ctx = require('./_ctx'); +var PROTOTYPE = 'prototype'; + +var $export = function (type, name, source) { + var IS_FORCED = type & $export.F; + var IS_GLOBAL = type & $export.G; + var IS_STATIC = type & $export.S; + var IS_PROTO = type & $export.P; + var IS_BIND = type & $export.B; + var target = IS_GLOBAL ? global : IS_STATIC ? global[name] || (global[name] = {}) : (global[name] || {})[PROTOTYPE]; + var exports = IS_GLOBAL ? core : core[name] || (core[name] = {}); + var expProto = exports[PROTOTYPE] || (exports[PROTOTYPE] = {}); + var key, own, out, exp; + if (IS_GLOBAL) source = name; + for (key in source) { + // contains in native + own = !IS_FORCED && target && target[key] !== undefined; + // export native or passed + out = (own ? target : source)[key]; + // bind timers to global for call from export context + exp = IS_BIND && own ? ctx(out, global) : IS_PROTO && typeof out == 'function' ? ctx(Function.call, out) : out; + // extend global + if (target) redefine(target, key, out, type & $export.U); + // export + if (exports[key] != out) hide(exports, key, exp); + if (IS_PROTO && expProto[key] != out) expProto[key] = out; + } +}; +global.core = core; +// type bitmap +$export.F = 1; // forced +$export.G = 2; // global +$export.S = 4; // static +$export.P = 8; // proto +$export.B = 16; // bind +$export.W = 32; // wrap +$export.U = 64; // safe +$export.R = 128; // real proto method for `library` +module.exports = $export; + +},{"./_core":11,"./_ctx":13,"./_global":25,"./_hide":27,"./_redefine":58}],20:[function(require,module,exports){ +var MATCH = require('./_wks')('match'); +module.exports = function (KEY) { + var re = /./; + try { + '/./'[KEY](re); + } catch (e) { + try { + re[MATCH] = false; + return !'/./'[KEY](re); + } catch (f) { /* empty */ } + } return true; +}; + +},{"./_wks":81}],21:[function(require,module,exports){ +module.exports = function (exec) { + try { + return !!exec(); + } catch (e) { + return true; + } +}; + +},{}],22:[function(require,module,exports){ +'use strict'; +require('./es6.regexp.exec'); +var redefine = require('./_redefine'); +var hide = require('./_hide'); +var fails = require('./_fails'); +var defined = require('./_defined'); +var wks = require('./_wks'); +var regexpExec = require('./_regexp-exec'); + +var SPECIES = wks('species'); + +var REPLACE_SUPPORTS_NAMED_GROUPS = !fails(function () { + // #replace needs built-in support for named groups. + // #match works fine because it just return the exec results, even if it has + // a "grops" property. + var re = /./; + re.exec = function () { + var result = []; + result.groups = { a: '7' }; + return result; + }; + return ''.replace(re, '$
    ') !== '7'; +}); + +var SPLIT_WORKS_WITH_OVERWRITTEN_EXEC = (function () { + // Chrome 51 has a buggy "split" implementation when RegExp#exec !== nativeExec + var re = /(?:)/; + var originalExec = re.exec; + re.exec = function () { return originalExec.apply(this, arguments); }; + var result = 'ab'.split(re); + return result.length === 2 && result[0] === 'a' && result[1] === 'b'; +})(); + +module.exports = function (KEY, length, exec) { + var SYMBOL = wks(KEY); + + var DELEGATES_TO_SYMBOL = !fails(function () { + // String methods call symbol-named RegEp methods + var O = {}; + O[SYMBOL] = function () { return 7; }; + return ''[KEY](O) != 7; + }); + + var DELEGATES_TO_EXEC = DELEGATES_TO_SYMBOL ? !fails(function () { + // Symbol-named RegExp methods call .exec + var execCalled = false; + var re = /a/; + re.exec = function () { execCalled = true; return null; }; + if (KEY === 'split') { + // RegExp[@@split] doesn't call the regex's exec method, but first creates + // a new one. We need to return the patched regex when creating the new one. + re.constructor = {}; + re.constructor[SPECIES] = function () { return re; }; + } + re[SYMBOL](''); + return !execCalled; + }) : undefined; + + if ( + !DELEGATES_TO_SYMBOL || + !DELEGATES_TO_EXEC || + (KEY === 'replace' && !REPLACE_SUPPORTS_NAMED_GROUPS) || + (KEY === 'split' && !SPLIT_WORKS_WITH_OVERWRITTEN_EXEC) + ) { + var nativeRegExpMethod = /./[SYMBOL]; + var fns = exec( + defined, + SYMBOL, + ''[KEY], + function maybeCallNative(nativeMethod, regexp, str, arg2, forceStringMethod) { + if (regexp.exec === regexpExec) { + if (DELEGATES_TO_SYMBOL && !forceStringMethod) { + // The native String method already delegates to @@method (this + // polyfilled function), leasing to infinite recursion. + // We avoid it by directly calling the native @@method method. + return { done: true, value: nativeRegExpMethod.call(regexp, str, arg2) }; + } + return { done: true, value: nativeMethod.call(str, regexp, arg2) }; + } + return { done: false }; + } + ); + var strfn = fns[0]; + var rxfn = fns[1]; + + redefine(String.prototype, KEY, strfn); + hide(RegExp.prototype, SYMBOL, length == 2 + // 21.2.5.8 RegExp.prototype[@@replace](string, replaceValue) + // 21.2.5.11 RegExp.prototype[@@split](string, limit) + ? function (string, arg) { return rxfn.call(string, this, arg); } + // 21.2.5.6 RegExp.prototype[@@match](string) + // 21.2.5.9 RegExp.prototype[@@search](string) + : function (string) { return rxfn.call(string, this); } + ); + } +}; + +},{"./_defined":14,"./_fails":21,"./_hide":27,"./_redefine":58,"./_regexp-exec":60,"./_wks":81,"./es6.regexp.exec":93}],23:[function(require,module,exports){ +'use strict'; +// 21.2.5.3 get RegExp.prototype.flags +var anObject = require('./_an-object'); +module.exports = function () { + var that = anObject(this); + var result = ''; + if (that.global) result += 'g'; + if (that.ignoreCase) result += 'i'; + if (that.multiline) result += 'm'; + if (that.unicode) result += 'u'; + if (that.sticky) result += 'y'; + return result; +}; + +},{"./_an-object":4}],24:[function(require,module,exports){ +module.exports = require('./_shared')('native-function-to-string', Function.toString); + +},{"./_shared":65}],25:[function(require,module,exports){ +// https://github.com/zloirock/core-js/issues/86#issuecomment-115759028 +var global = module.exports = typeof window != 'undefined' && window.Math == Math + ? window : typeof self != 'undefined' && self.Math == Math ? self + // eslint-disable-next-line no-new-func + : Function('return this')(); +if (typeof __g == 'number') __g = global; // eslint-disable-line no-undef + +},{}],26:[function(require,module,exports){ +var hasOwnProperty = {}.hasOwnProperty; +module.exports = function (it, key) { + return hasOwnProperty.call(it, key); +}; + +},{}],27:[function(require,module,exports){ +var dP = require('./_object-dp'); +var createDesc = require('./_property-desc'); +module.exports = require('./_descriptors') ? function (object, key, value) { + return dP.f(object, key, createDesc(1, value)); +} : function (object, key, value) { + object[key] = value; + return object; +}; + +},{"./_descriptors":15,"./_object-dp":45,"./_property-desc":57}],28:[function(require,module,exports){ +var document = require('./_global').document; +module.exports = document && document.documentElement; + +},{"./_global":25}],29:[function(require,module,exports){ +module.exports = !require('./_descriptors') && !require('./_fails')(function () { + return Object.defineProperty(require('./_dom-create')('div'), 'a', { get: function () { return 7; } }).a != 7; +}); + +},{"./_descriptors":15,"./_dom-create":16,"./_fails":21}],30:[function(require,module,exports){ +var isObject = require('./_is-object'); +var setPrototypeOf = require('./_set-proto').set; +module.exports = function (that, target, C) { + var S = target.constructor; + var P; + if (S !== C && typeof S == 'function' && (P = S.prototype) !== C.prototype && isObject(P) && setPrototypeOf) { + setPrototypeOf(that, P); + } return that; +}; + +},{"./_is-object":34,"./_set-proto":61}],31:[function(require,module,exports){ +// fallback for non-array-like ES3 and non-enumerable old V8 strings +var cof = require('./_cof'); +// eslint-disable-next-line no-prototype-builtins +module.exports = Object('z').propertyIsEnumerable(0) ? Object : function (it) { + return cof(it) == 'String' ? it.split('') : Object(it); +}; + +},{"./_cof":10}],32:[function(require,module,exports){ +// check on default Array iterator +var Iterators = require('./_iterators'); +var ITERATOR = require('./_wks')('iterator'); +var ArrayProto = Array.prototype; + +module.exports = function (it) { + return it !== undefined && (Iterators.Array === it || ArrayProto[ITERATOR] === it); +}; + +},{"./_iterators":41,"./_wks":81}],33:[function(require,module,exports){ +// 7.2.2 IsArray(argument) +var cof = require('./_cof'); +module.exports = Array.isArray || function isArray(arg) { + return cof(arg) == 'Array'; +}; + +},{"./_cof":10}],34:[function(require,module,exports){ +module.exports = function (it) { + return typeof it === 'object' ? it !== null : typeof it === 'function'; +}; + +},{}],35:[function(require,module,exports){ +// 7.2.8 IsRegExp(argument) +var isObject = require('./_is-object'); +var cof = require('./_cof'); +var MATCH = require('./_wks')('match'); +module.exports = function (it) { + var isRegExp; + return isObject(it) && ((isRegExp = it[MATCH]) !== undefined ? !!isRegExp : cof(it) == 'RegExp'); +}; + +},{"./_cof":10,"./_is-object":34,"./_wks":81}],36:[function(require,module,exports){ +// call something on iterator step with safe closing on error +var anObject = require('./_an-object'); +module.exports = function (iterator, fn, value, entries) { + try { + return entries ? fn(anObject(value)[0], value[1]) : fn(value); + // 7.4.6 IteratorClose(iterator, completion) + } catch (e) { + var ret = iterator['return']; + if (ret !== undefined) anObject(ret.call(iterator)); + throw e; + } +}; + +},{"./_an-object":4}],37:[function(require,module,exports){ +'use strict'; +var create = require('./_object-create'); +var descriptor = require('./_property-desc'); +var setToStringTag = require('./_set-to-string-tag'); +var IteratorPrototype = {}; + +// 25.1.2.1.1 %IteratorPrototype%[@@iterator]() +require('./_hide')(IteratorPrototype, require('./_wks')('iterator'), function () { return this; }); + +module.exports = function (Constructor, NAME, next) { + Constructor.prototype = create(IteratorPrototype, { next: descriptor(1, next) }); + setToStringTag(Constructor, NAME + ' Iterator'); +}; + +},{"./_hide":27,"./_object-create":44,"./_property-desc":57,"./_set-to-string-tag":63,"./_wks":81}],38:[function(require,module,exports){ +'use strict'; +var LIBRARY = require('./_library'); +var $export = require('./_export'); +var redefine = require('./_redefine'); +var hide = require('./_hide'); +var Iterators = require('./_iterators'); +var $iterCreate = require('./_iter-create'); +var setToStringTag = require('./_set-to-string-tag'); +var getPrototypeOf = require('./_object-gpo'); +var ITERATOR = require('./_wks')('iterator'); +var BUGGY = !([].keys && 'next' in [].keys()); // Safari has buggy iterators w/o `next` +var FF_ITERATOR = '@@iterator'; +var KEYS = 'keys'; +var VALUES = 'values'; + +var returnThis = function () { return this; }; + +module.exports = function (Base, NAME, Constructor, next, DEFAULT, IS_SET, FORCED) { + $iterCreate(Constructor, NAME, next); + var getMethod = function (kind) { + if (!BUGGY && kind in proto) return proto[kind]; + switch (kind) { + case KEYS: return function keys() { return new Constructor(this, kind); }; + case VALUES: return function values() { return new Constructor(this, kind); }; + } return function entries() { return new Constructor(this, kind); }; + }; + var TAG = NAME + ' Iterator'; + var DEF_VALUES = DEFAULT == VALUES; + var VALUES_BUG = false; + var proto = Base.prototype; + var $native = proto[ITERATOR] || proto[FF_ITERATOR] || DEFAULT && proto[DEFAULT]; + var $default = $native || getMethod(DEFAULT); + var $entries = DEFAULT ? !DEF_VALUES ? $default : getMethod('entries') : undefined; + var $anyNative = NAME == 'Array' ? proto.entries || $native : $native; + var methods, key, IteratorPrototype; + // Fix native + if ($anyNative) { + IteratorPrototype = getPrototypeOf($anyNative.call(new Base())); + if (IteratorPrototype !== Object.prototype && IteratorPrototype.next) { + // Set @@toStringTag to native iterators + setToStringTag(IteratorPrototype, TAG, true); + // fix for some old engines + if (!LIBRARY && typeof IteratorPrototype[ITERATOR] != 'function') hide(IteratorPrototype, ITERATOR, returnThis); + } + } + // fix Array#{values, @@iterator}.name in V8 / FF + if (DEF_VALUES && $native && $native.name !== VALUES) { + VALUES_BUG = true; + $default = function values() { return $native.call(this); }; + } + // Define iterator + if ((!LIBRARY || FORCED) && (BUGGY || VALUES_BUG || !proto[ITERATOR])) { + hide(proto, ITERATOR, $default); + } + // Plug for library + Iterators[NAME] = $default; + Iterators[TAG] = returnThis; + if (DEFAULT) { + methods = { + values: DEF_VALUES ? $default : getMethod(VALUES), + keys: IS_SET ? $default : getMethod(KEYS), + entries: $entries + }; + if (FORCED) for (key in methods) { + if (!(key in proto)) redefine(proto, key, methods[key]); + } else $export($export.P + $export.F * (BUGGY || VALUES_BUG), NAME, methods); + } + return methods; +}; + +},{"./_export":19,"./_hide":27,"./_iter-create":37,"./_iterators":41,"./_library":42,"./_object-gpo":51,"./_redefine":58,"./_set-to-string-tag":63,"./_wks":81}],39:[function(require,module,exports){ +var ITERATOR = require('./_wks')('iterator'); +var SAFE_CLOSING = false; + +try { + var riter = [7][ITERATOR](); + riter['return'] = function () { SAFE_CLOSING = true; }; + // eslint-disable-next-line no-throw-literal + Array.from(riter, function () { throw 2; }); +} catch (e) { /* empty */ } + +module.exports = function (exec, skipClosing) { + if (!skipClosing && !SAFE_CLOSING) return false; + var safe = false; + try { + var arr = [7]; + var iter = arr[ITERATOR](); + iter.next = function () { return { done: safe = true }; }; + arr[ITERATOR] = function () { return iter; }; + exec(arr); + } catch (e) { /* empty */ } + return safe; +}; + +},{"./_wks":81}],40:[function(require,module,exports){ +module.exports = function (done, value) { + return { value: value, done: !!done }; +}; + +},{}],41:[function(require,module,exports){ +module.exports = {}; + +},{}],42:[function(require,module,exports){ +module.exports = false; + +},{}],43:[function(require,module,exports){ +var META = require('./_uid')('meta'); +var isObject = require('./_is-object'); +var has = require('./_has'); +var setDesc = require('./_object-dp').f; +var id = 0; +var isExtensible = Object.isExtensible || function () { + return true; +}; +var FREEZE = !require('./_fails')(function () { + return isExtensible(Object.preventExtensions({})); +}); +var setMeta = function (it) { + setDesc(it, META, { value: { + i: 'O' + ++id, // object ID + w: {} // weak collections IDs + } }); +}; +var fastKey = function (it, create) { + // return primitive with prefix + if (!isObject(it)) return typeof it == 'symbol' ? it : (typeof it == 'string' ? 'S' : 'P') + it; + if (!has(it, META)) { + // can't set metadata to uncaught frozen object + if (!isExtensible(it)) return 'F'; + // not necessary to add metadata + if (!create) return 'E'; + // add missing metadata + setMeta(it); + // return object ID + } return it[META].i; +}; +var getWeak = function (it, create) { + if (!has(it, META)) { + // can't set metadata to uncaught frozen object + if (!isExtensible(it)) return true; + // not necessary to add metadata + if (!create) return false; + // add missing metadata + setMeta(it); + // return hash weak collections IDs + } return it[META].w; +}; +// add metadata on freeze-family methods calling +var onFreeze = function (it) { + if (FREEZE && meta.NEED && isExtensible(it) && !has(it, META)) setMeta(it); + return it; +}; +var meta = module.exports = { + KEY: META, + NEED: false, + fastKey: fastKey, + getWeak: getWeak, + onFreeze: onFreeze +}; + +},{"./_fails":21,"./_has":26,"./_is-object":34,"./_object-dp":45,"./_uid":78}],44:[function(require,module,exports){ +// 19.1.2.2 / 15.2.3.5 Object.create(O [, Properties]) +var anObject = require('./_an-object'); +var dPs = require('./_object-dps'); +var enumBugKeys = require('./_enum-bug-keys'); +var IE_PROTO = require('./_shared-key')('IE_PROTO'); +var Empty = function () { /* empty */ }; +var PROTOTYPE = 'prototype'; + +// Create object with fake `null` prototype: use iframe Object with cleared prototype +var createDict = function () { + // Thrash, waste and sodomy: IE GC bug + var iframe = require('./_dom-create')('iframe'); + var i = enumBugKeys.length; + var lt = '<'; + var gt = '>'; + var iframeDocument; + iframe.style.display = 'none'; + require('./_html').appendChild(iframe); + iframe.src = 'javascript:'; // eslint-disable-line no-script-url + // createDict = iframe.contentWindow.Object; + // html.removeChild(iframe); + iframeDocument = iframe.contentWindow.document; + iframeDocument.open(); + iframeDocument.write(lt + 'script' + gt + 'document.F=Object' + lt + '/script' + gt); + iframeDocument.close(); + createDict = iframeDocument.F; + while (i--) delete createDict[PROTOTYPE][enumBugKeys[i]]; + return createDict(); +}; + +module.exports = Object.create || function create(O, Properties) { + var result; + if (O !== null) { + Empty[PROTOTYPE] = anObject(O); + result = new Empty(); + Empty[PROTOTYPE] = null; + // add "__proto__" for Object.getPrototypeOf polyfill + result[IE_PROTO] = O; + } else result = createDict(); + return Properties === undefined ? result : dPs(result, Properties); +}; + +},{"./_an-object":4,"./_dom-create":16,"./_enum-bug-keys":17,"./_html":28,"./_object-dps":46,"./_shared-key":64}],45:[function(require,module,exports){ +var anObject = require('./_an-object'); +var IE8_DOM_DEFINE = require('./_ie8-dom-define'); +var toPrimitive = require('./_to-primitive'); +var dP = Object.defineProperty; + +exports.f = require('./_descriptors') ? Object.defineProperty : function defineProperty(O, P, Attributes) { + anObject(O); + P = toPrimitive(P, true); + anObject(Attributes); + if (IE8_DOM_DEFINE) try { + return dP(O, P, Attributes); + } catch (e) { /* empty */ } + if ('get' in Attributes || 'set' in Attributes) throw TypeError('Accessors not supported!'); + if ('value' in Attributes) O[P] = Attributes.value; + return O; +}; + +},{"./_an-object":4,"./_descriptors":15,"./_ie8-dom-define":29,"./_to-primitive":77}],46:[function(require,module,exports){ +var dP = require('./_object-dp'); +var anObject = require('./_an-object'); +var getKeys = require('./_object-keys'); + +module.exports = require('./_descriptors') ? Object.defineProperties : function defineProperties(O, Properties) { + anObject(O); + var keys = getKeys(Properties); + var length = keys.length; + var i = 0; + var P; + while (length > i) dP.f(O, P = keys[i++], Properties[P]); + return O; +}; + +},{"./_an-object":4,"./_descriptors":15,"./_object-dp":45,"./_object-keys":53}],47:[function(require,module,exports){ +var pIE = require('./_object-pie'); +var createDesc = require('./_property-desc'); +var toIObject = require('./_to-iobject'); +var toPrimitive = require('./_to-primitive'); +var has = require('./_has'); +var IE8_DOM_DEFINE = require('./_ie8-dom-define'); +var gOPD = Object.getOwnPropertyDescriptor; + +exports.f = require('./_descriptors') ? gOPD : function getOwnPropertyDescriptor(O, P) { + O = toIObject(O); + P = toPrimitive(P, true); + if (IE8_DOM_DEFINE) try { + return gOPD(O, P); + } catch (e) { /* empty */ } + if (has(O, P)) return createDesc(!pIE.f.call(O, P), O[P]); +}; + +},{"./_descriptors":15,"./_has":26,"./_ie8-dom-define":29,"./_object-pie":54,"./_property-desc":57,"./_to-iobject":74,"./_to-primitive":77}],48:[function(require,module,exports){ +// fallback for IE11 buggy Object.getOwnPropertyNames with iframe and window +var toIObject = require('./_to-iobject'); +var gOPN = require('./_object-gopn').f; +var toString = {}.toString; + +var windowNames = typeof window == 'object' && window && Object.getOwnPropertyNames + ? Object.getOwnPropertyNames(window) : []; + +var getWindowNames = function (it) { + try { + return gOPN(it); + } catch (e) { + return windowNames.slice(); + } +}; + +module.exports.f = function getOwnPropertyNames(it) { + return windowNames && toString.call(it) == '[object Window]' ? getWindowNames(it) : gOPN(toIObject(it)); +}; + +},{"./_object-gopn":49,"./_to-iobject":74}],49:[function(require,module,exports){ +// 19.1.2.7 / 15.2.3.4 Object.getOwnPropertyNames(O) +var $keys = require('./_object-keys-internal'); +var hiddenKeys = require('./_enum-bug-keys').concat('length', 'prototype'); + +exports.f = Object.getOwnPropertyNames || function getOwnPropertyNames(O) { + return $keys(O, hiddenKeys); +}; + +},{"./_enum-bug-keys":17,"./_object-keys-internal":52}],50:[function(require,module,exports){ +exports.f = Object.getOwnPropertySymbols; + +},{}],51:[function(require,module,exports){ +// 19.1.2.9 / 15.2.3.2 Object.getPrototypeOf(O) +var has = require('./_has'); +var toObject = require('./_to-object'); +var IE_PROTO = require('./_shared-key')('IE_PROTO'); +var ObjectProto = Object.prototype; + +module.exports = Object.getPrototypeOf || function (O) { + O = toObject(O); + if (has(O, IE_PROTO)) return O[IE_PROTO]; + if (typeof O.constructor == 'function' && O instanceof O.constructor) { + return O.constructor.prototype; + } return O instanceof Object ? ObjectProto : null; +}; + +},{"./_has":26,"./_shared-key":64,"./_to-object":76}],52:[function(require,module,exports){ +var has = require('./_has'); +var toIObject = require('./_to-iobject'); +var arrayIndexOf = require('./_array-includes')(false); +var IE_PROTO = require('./_shared-key')('IE_PROTO'); + +module.exports = function (object, names) { + var O = toIObject(object); + var i = 0; + var result = []; + var key; + for (key in O) if (key != IE_PROTO) has(O, key) && result.push(key); + // Don't enum bug & hidden keys + while (names.length > i) if (has(O, key = names[i++])) { + ~arrayIndexOf(result, key) || result.push(key); + } + return result; +}; + +},{"./_array-includes":5,"./_has":26,"./_shared-key":64,"./_to-iobject":74}],53:[function(require,module,exports){ +// 19.1.2.14 / 15.2.3.14 Object.keys(O) +var $keys = require('./_object-keys-internal'); +var enumBugKeys = require('./_enum-bug-keys'); + +module.exports = Object.keys || function keys(O) { + return $keys(O, enumBugKeys); +}; + +},{"./_enum-bug-keys":17,"./_object-keys-internal":52}],54:[function(require,module,exports){ +exports.f = {}.propertyIsEnumerable; + +},{}],55:[function(require,module,exports){ +// most Object methods by ES6 should accept primitives +var $export = require('./_export'); +var core = require('./_core'); +var fails = require('./_fails'); +module.exports = function (KEY, exec) { + var fn = (core.Object || {})[KEY] || Object[KEY]; + var exp = {}; + exp[KEY] = exec(fn); + $export($export.S + $export.F * fails(function () { fn(1); }), 'Object', exp); +}; + +},{"./_core":11,"./_export":19,"./_fails":21}],56:[function(require,module,exports){ +// all object keys, includes non-enumerable and symbols +var gOPN = require('./_object-gopn'); +var gOPS = require('./_object-gops'); +var anObject = require('./_an-object'); +var Reflect = require('./_global').Reflect; +module.exports = Reflect && Reflect.ownKeys || function ownKeys(it) { + var keys = gOPN.f(anObject(it)); + var getSymbols = gOPS.f; + return getSymbols ? keys.concat(getSymbols(it)) : keys; +}; + +},{"./_an-object":4,"./_global":25,"./_object-gopn":49,"./_object-gops":50}],57:[function(require,module,exports){ +module.exports = function (bitmap, value) { + return { + enumerable: !(bitmap & 1), + configurable: !(bitmap & 2), + writable: !(bitmap & 4), + value: value + }; +}; + +},{}],58:[function(require,module,exports){ +var global = require('./_global'); +var hide = require('./_hide'); +var has = require('./_has'); +var SRC = require('./_uid')('src'); +var $toString = require('./_function-to-string'); +var TO_STRING = 'toString'; +var TPL = ('' + $toString).split(TO_STRING); + +require('./_core').inspectSource = function (it) { + return $toString.call(it); +}; + +(module.exports = function (O, key, val, safe) { + var isFunction = typeof val == 'function'; + if (isFunction) has(val, 'name') || hide(val, 'name', key); + if (O[key] === val) return; + if (isFunction) has(val, SRC) || hide(val, SRC, O[key] ? '' + O[key] : TPL.join(String(key))); + if (O === global) { + O[key] = val; + } else if (!safe) { + delete O[key]; + hide(O, key, val); + } else if (O[key]) { + O[key] = val; + } else { + hide(O, key, val); + } +// add fake Function#toString for correct work wrapped methods / constructors with methods like LoDash isNative +})(Function.prototype, TO_STRING, function toString() { + return typeof this == 'function' && this[SRC] || $toString.call(this); +}); + +},{"./_core":11,"./_function-to-string":24,"./_global":25,"./_has":26,"./_hide":27,"./_uid":78}],59:[function(require,module,exports){ +'use strict'; + +var classof = require('./_classof'); +var builtinExec = RegExp.prototype.exec; + + // `RegExpExec` abstract operation +// https://tc39.github.io/ecma262/#sec-regexpexec +module.exports = function (R, S) { + var exec = R.exec; + if (typeof exec === 'function') { + var result = exec.call(R, S); + if (typeof result !== 'object') { + throw new TypeError('RegExp exec method returned something other than an Object or null'); + } + return result; + } + if (classof(R) !== 'RegExp') { + throw new TypeError('RegExp#exec called on incompatible receiver'); + } + return builtinExec.call(R, S); +}; + +},{"./_classof":9}],60:[function(require,module,exports){ +'use strict'; + +var regexpFlags = require('./_flags'); + +var nativeExec = RegExp.prototype.exec; +// This always refers to the native implementation, because the +// String#replace polyfill uses ./fix-regexp-well-known-symbol-logic.js, +// which loads this file before patching the method. +var nativeReplace = String.prototype.replace; + +var patchedExec = nativeExec; + +var LAST_INDEX = 'lastIndex'; + +var UPDATES_LAST_INDEX_WRONG = (function () { + var re1 = /a/, + re2 = /b*/g; + nativeExec.call(re1, 'a'); + nativeExec.call(re2, 'a'); + return re1[LAST_INDEX] !== 0 || re2[LAST_INDEX] !== 0; +})(); + +// nonparticipating capturing group, copied from es5-shim's String#split patch. +var NPCG_INCLUDED = /()??/.exec('')[1] !== undefined; + +var PATCH = UPDATES_LAST_INDEX_WRONG || NPCG_INCLUDED; + +if (PATCH) { + patchedExec = function exec(str) { + var re = this; + var lastIndex, reCopy, match, i; + + if (NPCG_INCLUDED) { + reCopy = new RegExp('^' + re.source + '$(?!\\s)', regexpFlags.call(re)); + } + if (UPDATES_LAST_INDEX_WRONG) lastIndex = re[LAST_INDEX]; + + match = nativeExec.call(re, str); + + if (UPDATES_LAST_INDEX_WRONG && match) { + re[LAST_INDEX] = re.global ? match.index + match[0].length : lastIndex; + } + if (NPCG_INCLUDED && match && match.length > 1) { + // Fix browsers whose `exec` methods don't consistently return `undefined` + // for NPCG, like IE8. NOTE: This doesn' work for /(.?)?/ + // eslint-disable-next-line no-loop-func + nativeReplace.call(match[0], reCopy, function () { + for (i = 1; i < arguments.length - 2; i++) { + if (arguments[i] === undefined) match[i] = undefined; + } + }); + } + + return match; + }; +} + +module.exports = patchedExec; + +},{"./_flags":23}],61:[function(require,module,exports){ +// Works with __proto__ only. Old v8 can't work with null proto objects. +/* eslint-disable no-proto */ +var isObject = require('./_is-object'); +var anObject = require('./_an-object'); +var check = function (O, proto) { + anObject(O); + if (!isObject(proto) && proto !== null) throw TypeError(proto + ": can't set as prototype!"); +}; +module.exports = { + set: Object.setPrototypeOf || ('__proto__' in {} ? // eslint-disable-line + function (test, buggy, set) { + try { + set = require('./_ctx')(Function.call, require('./_object-gopd').f(Object.prototype, '__proto__').set, 2); + set(test, []); + buggy = !(test instanceof Array); + } catch (e) { buggy = true; } + return function setPrototypeOf(O, proto) { + check(O, proto); + if (buggy) O.__proto__ = proto; + else set(O, proto); + return O; + }; + }({}, false) : undefined), + check: check +}; + +},{"./_an-object":4,"./_ctx":13,"./_is-object":34,"./_object-gopd":47}],62:[function(require,module,exports){ +'use strict'; +var global = require('./_global'); +var dP = require('./_object-dp'); +var DESCRIPTORS = require('./_descriptors'); +var SPECIES = require('./_wks')('species'); + +module.exports = function (KEY) { + var C = global[KEY]; + if (DESCRIPTORS && C && !C[SPECIES]) dP.f(C, SPECIES, { + configurable: true, + get: function () { return this; } + }); +}; + +},{"./_descriptors":15,"./_global":25,"./_object-dp":45,"./_wks":81}],63:[function(require,module,exports){ +var def = require('./_object-dp').f; +var has = require('./_has'); +var TAG = require('./_wks')('toStringTag'); + +module.exports = function (it, tag, stat) { + if (it && !has(it = stat ? it : it.prototype, TAG)) def(it, TAG, { configurable: true, value: tag }); +}; + +},{"./_has":26,"./_object-dp":45,"./_wks":81}],64:[function(require,module,exports){ +var shared = require('./_shared')('keys'); +var uid = require('./_uid'); +module.exports = function (key) { + return shared[key] || (shared[key] = uid(key)); +}; + +},{"./_shared":65,"./_uid":78}],65:[function(require,module,exports){ +var core = require('./_core'); +var global = require('./_global'); +var SHARED = '__core-js_shared__'; +var store = global[SHARED] || (global[SHARED] = {}); + +(module.exports = function (key, value) { + return store[key] || (store[key] = value !== undefined ? value : {}); +})('versions', []).push({ + version: core.version, + mode: require('./_library') ? 'pure' : 'global', + copyright: '© 2020 Denis Pushkarev (zloirock.ru)' +}); + +},{"./_core":11,"./_global":25,"./_library":42}],66:[function(require,module,exports){ +// 7.3.20 SpeciesConstructor(O, defaultConstructor) +var anObject = require('./_an-object'); +var aFunction = require('./_a-function'); +var SPECIES = require('./_wks')('species'); +module.exports = function (O, D) { + var C = anObject(O).constructor; + var S; + return C === undefined || (S = anObject(C)[SPECIES]) == undefined ? D : aFunction(S); +}; + +},{"./_a-function":1,"./_an-object":4,"./_wks":81}],67:[function(require,module,exports){ +'use strict'; +var fails = require('./_fails'); + +module.exports = function (method, arg) { + return !!method && fails(function () { + // eslint-disable-next-line no-useless-call + arg ? method.call(null, function () { /* empty */ }, 1) : method.call(null); + }); +}; + +},{"./_fails":21}],68:[function(require,module,exports){ +var toInteger = require('./_to-integer'); +var defined = require('./_defined'); +// true -> String#at +// false -> String#codePointAt +module.exports = function (TO_STRING) { + return function (that, pos) { + var s = String(defined(that)); + var i = toInteger(pos); + var l = s.length; + var a, b; + if (i < 0 || i >= l) return TO_STRING ? '' : undefined; + a = s.charCodeAt(i); + return a < 0xd800 || a > 0xdbff || i + 1 === l || (b = s.charCodeAt(i + 1)) < 0xdc00 || b > 0xdfff + ? TO_STRING ? s.charAt(i) : a + : TO_STRING ? s.slice(i, i + 2) : (a - 0xd800 << 10) + (b - 0xdc00) + 0x10000; + }; +}; + +},{"./_defined":14,"./_to-integer":73}],69:[function(require,module,exports){ +// helper for String#{startsWith, endsWith, includes} +var isRegExp = require('./_is-regexp'); +var defined = require('./_defined'); + +module.exports = function (that, searchString, NAME) { + if (isRegExp(searchString)) throw TypeError('String#' + NAME + " doesn't accept regex!"); + return String(defined(that)); +}; + +},{"./_defined":14,"./_is-regexp":35}],70:[function(require,module,exports){ +var $export = require('./_export'); +var defined = require('./_defined'); +var fails = require('./_fails'); +var spaces = require('./_string-ws'); +var space = '[' + spaces + ']'; +var non = '\u200b\u0085'; +var ltrim = RegExp('^' + space + space + '*'); +var rtrim = RegExp(space + space + '*$'); + +var exporter = function (KEY, exec, ALIAS) { + var exp = {}; + var FORCE = fails(function () { + return !!spaces[KEY]() || non[KEY]() != non; + }); + var fn = exp[KEY] = FORCE ? exec(trim) : spaces[KEY]; + if (ALIAS) exp[ALIAS] = fn; + $export($export.P + $export.F * FORCE, 'String', exp); +}; + +// 1 -> String#trimLeft +// 2 -> String#trimRight +// 3 -> String#trim +var trim = exporter.trim = function (string, TYPE) { + string = String(defined(string)); + if (TYPE & 1) string = string.replace(ltrim, ''); + if (TYPE & 2) string = string.replace(rtrim, ''); + return string; +}; + +module.exports = exporter; + +},{"./_defined":14,"./_export":19,"./_fails":21,"./_string-ws":71}],71:[function(require,module,exports){ +module.exports = '\x09\x0A\x0B\x0C\x0D\x20\xA0\u1680\u180E\u2000\u2001\u2002\u2003' + + '\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000\u2028\u2029\uFEFF'; + +},{}],72:[function(require,module,exports){ +var toInteger = require('./_to-integer'); +var max = Math.max; +var min = Math.min; +module.exports = function (index, length) { + index = toInteger(index); + return index < 0 ? max(index + length, 0) : min(index, length); +}; + +},{"./_to-integer":73}],73:[function(require,module,exports){ +// 7.1.4 ToInteger +var ceil = Math.ceil; +var floor = Math.floor; +module.exports = function (it) { + return isNaN(it = +it) ? 0 : (it > 0 ? floor : ceil)(it); +}; + +},{}],74:[function(require,module,exports){ +// to indexed object, toObject with fallback for non-array-like ES3 strings +var IObject = require('./_iobject'); +var defined = require('./_defined'); +module.exports = function (it) { + return IObject(defined(it)); +}; + +},{"./_defined":14,"./_iobject":31}],75:[function(require,module,exports){ +// 7.1.15 ToLength +var toInteger = require('./_to-integer'); +var min = Math.min; +module.exports = function (it) { + return it > 0 ? min(toInteger(it), 0x1fffffffffffff) : 0; // pow(2, 53) - 1 == 9007199254740991 +}; + +},{"./_to-integer":73}],76:[function(require,module,exports){ +// 7.1.13 ToObject(argument) +var defined = require('./_defined'); +module.exports = function (it) { + return Object(defined(it)); +}; + +},{"./_defined":14}],77:[function(require,module,exports){ +// 7.1.1 ToPrimitive(input [, PreferredType]) +var isObject = require('./_is-object'); +// instead of the ES6 spec version, we didn't implement @@toPrimitive case +// and the second argument - flag - preferred type is a string +module.exports = function (it, S) { + if (!isObject(it)) return it; + var fn, val; + if (S && typeof (fn = it.toString) == 'function' && !isObject(val = fn.call(it))) return val; + if (typeof (fn = it.valueOf) == 'function' && !isObject(val = fn.call(it))) return val; + if (!S && typeof (fn = it.toString) == 'function' && !isObject(val = fn.call(it))) return val; + throw TypeError("Can't convert object to primitive value"); +}; + +},{"./_is-object":34}],78:[function(require,module,exports){ +var id = 0; +var px = Math.random(); +module.exports = function (key) { + return 'Symbol('.concat(key === undefined ? '' : key, ')_', (++id + px).toString(36)); +}; + +},{}],79:[function(require,module,exports){ +var global = require('./_global'); +var core = require('./_core'); +var LIBRARY = require('./_library'); +var wksExt = require('./_wks-ext'); +var defineProperty = require('./_object-dp').f; +module.exports = function (name) { + var $Symbol = core.Symbol || (core.Symbol = LIBRARY ? {} : global.Symbol || {}); + if (name.charAt(0) != '_' && !(name in $Symbol)) defineProperty($Symbol, name, { value: wksExt.f(name) }); +}; + +},{"./_core":11,"./_global":25,"./_library":42,"./_object-dp":45,"./_wks-ext":80}],80:[function(require,module,exports){ +exports.f = require('./_wks'); + +},{"./_wks":81}],81:[function(require,module,exports){ +var store = require('./_shared')('wks'); +var uid = require('./_uid'); +var Symbol = require('./_global').Symbol; +var USE_SYMBOL = typeof Symbol == 'function'; + +var $exports = module.exports = function (name) { + return store[name] || (store[name] = + USE_SYMBOL && Symbol[name] || (USE_SYMBOL ? Symbol : uid)('Symbol.' + name)); +}; + +$exports.store = store; + +},{"./_global":25,"./_shared":65,"./_uid":78}],82:[function(require,module,exports){ +var classof = require('./_classof'); +var ITERATOR = require('./_wks')('iterator'); +var Iterators = require('./_iterators'); +module.exports = require('./_core').getIteratorMethod = function (it) { + if (it != undefined) return it[ITERATOR] + || it['@@iterator'] + || Iterators[classof(it)]; +}; + +},{"./_classof":9,"./_core":11,"./_iterators":41,"./_wks":81}],83:[function(require,module,exports){ +'use strict'; +var $export = require('./_export'); +var $filter = require('./_array-methods')(2); + +$export($export.P + $export.F * !require('./_strict-method')([].filter, true), 'Array', { + // 22.1.3.7 / 15.4.4.20 Array.prototype.filter(callbackfn [, thisArg]) + filter: function filter(callbackfn /* , thisArg */) { + return $filter(this, callbackfn, arguments[1]); + } +}); + +},{"./_array-methods":6,"./_export":19,"./_strict-method":67}],84:[function(require,module,exports){ +'use strict'; +var ctx = require('./_ctx'); +var $export = require('./_export'); +var toObject = require('./_to-object'); +var call = require('./_iter-call'); +var isArrayIter = require('./_is-array-iter'); +var toLength = require('./_to-length'); +var createProperty = require('./_create-property'); +var getIterFn = require('./core.get-iterator-method'); + +$export($export.S + $export.F * !require('./_iter-detect')(function (iter) { Array.from(iter); }), 'Array', { + // 22.1.2.1 Array.from(arrayLike, mapfn = undefined, thisArg = undefined) + from: function from(arrayLike /* , mapfn = undefined, thisArg = undefined */) { + var O = toObject(arrayLike); + var C = typeof this == 'function' ? this : Array; + var aLen = arguments.length; + var mapfn = aLen > 1 ? arguments[1] : undefined; + var mapping = mapfn !== undefined; + var index = 0; + var iterFn = getIterFn(O); + var length, result, step, iterator; + if (mapping) mapfn = ctx(mapfn, aLen > 2 ? arguments[2] : undefined, 2); + // if object isn't iterable or it's array with default iterator - use simple case + if (iterFn != undefined && !(C == Array && isArrayIter(iterFn))) { + for (iterator = iterFn.call(O), result = new C(); !(step = iterator.next()).done; index++) { + createProperty(result, index, mapping ? call(iterator, mapfn, [step.value, index], true) : step.value); + } + } else { + length = toLength(O.length); + for (result = new C(length); length > index; index++) { + createProperty(result, index, mapping ? mapfn(O[index], index) : O[index]); + } + } + result.length = index; + return result; + } +}); + +},{"./_create-property":12,"./_ctx":13,"./_export":19,"./_is-array-iter":32,"./_iter-call":36,"./_iter-detect":39,"./_to-length":75,"./_to-object":76,"./core.get-iterator-method":82}],85:[function(require,module,exports){ +'use strict'; +var addToUnscopables = require('./_add-to-unscopables'); +var step = require('./_iter-step'); +var Iterators = require('./_iterators'); +var toIObject = require('./_to-iobject'); + +// 22.1.3.4 Array.prototype.entries() +// 22.1.3.13 Array.prototype.keys() +// 22.1.3.29 Array.prototype.values() +// 22.1.3.30 Array.prototype[@@iterator]() +module.exports = require('./_iter-define')(Array, 'Array', function (iterated, kind) { + this._t = toIObject(iterated); // target + this._i = 0; // next index + this._k = kind; // kind +// 22.1.5.2.1 %ArrayIteratorPrototype%.next() +}, function () { + var O = this._t; + var kind = this._k; + var index = this._i++; + if (!O || index >= O.length) { + this._t = undefined; + return step(1); + } + if (kind == 'keys') return step(0, index); + if (kind == 'values') return step(0, O[index]); + return step(0, [index, O[index]]); +}, 'values'); + +// argumentsList[@@iterator] is %ArrayProto_values% (9.4.4.6, 9.4.4.7) +Iterators.Arguments = Iterators.Array; + +addToUnscopables('keys'); +addToUnscopables('values'); +addToUnscopables('entries'); + +},{"./_add-to-unscopables":2,"./_iter-define":38,"./_iter-step":40,"./_iterators":41,"./_to-iobject":74}],86:[function(require,module,exports){ +'use strict'; +var $export = require('./_export'); +var $map = require('./_array-methods')(1); + +$export($export.P + $export.F * !require('./_strict-method')([].map, true), 'Array', { + // 22.1.3.15 / 15.4.4.19 Array.prototype.map(callbackfn [, thisArg]) + map: function map(callbackfn /* , thisArg */) { + return $map(this, callbackfn, arguments[1]); + } +}); + +},{"./_array-methods":6,"./_export":19,"./_strict-method":67}],87:[function(require,module,exports){ +'use strict'; +var $export = require('./_export'); +var html = require('./_html'); +var cof = require('./_cof'); +var toAbsoluteIndex = require('./_to-absolute-index'); +var toLength = require('./_to-length'); +var arraySlice = [].slice; + +// fallback for not array-like ES3 strings and DOM objects +$export($export.P + $export.F * require('./_fails')(function () { + if (html) arraySlice.call(html); +}), 'Array', { + slice: function slice(begin, end) { + var len = toLength(this.length); + var klass = cof(this); + end = end === undefined ? len : end; + if (klass == 'Array') return arraySlice.call(this, begin, end); + var start = toAbsoluteIndex(begin, len); + var upTo = toAbsoluteIndex(end, len); + var size = toLength(upTo - start); + var cloned = new Array(size); + var i = 0; + for (; i < size; i++) cloned[i] = klass == 'String' + ? this.charAt(start + i) + : this[start + i]; + return cloned; + } +}); + +},{"./_cof":10,"./_export":19,"./_fails":21,"./_html":28,"./_to-absolute-index":72,"./_to-length":75}],88:[function(require,module,exports){ +'use strict'; +var global = require('./_global'); +var has = require('./_has'); +var cof = require('./_cof'); +var inheritIfRequired = require('./_inherit-if-required'); +var toPrimitive = require('./_to-primitive'); +var fails = require('./_fails'); +var gOPN = require('./_object-gopn').f; +var gOPD = require('./_object-gopd').f; +var dP = require('./_object-dp').f; +var $trim = require('./_string-trim').trim; +var NUMBER = 'Number'; +var $Number = global[NUMBER]; +var Base = $Number; +var proto = $Number.prototype; +// Opera ~12 has broken Object#toString +var BROKEN_COF = cof(require('./_object-create')(proto)) == NUMBER; +var TRIM = 'trim' in String.prototype; + +// 7.1.3 ToNumber(argument) +var toNumber = function (argument) { + var it = toPrimitive(argument, false); + if (typeof it == 'string' && it.length > 2) { + it = TRIM ? it.trim() : $trim(it, 3); + var first = it.charCodeAt(0); + var third, radix, maxCode; + if (first === 43 || first === 45) { + third = it.charCodeAt(2); + if (third === 88 || third === 120) return NaN; // Number('+0x1') should be NaN, old V8 fix + } else if (first === 48) { + switch (it.charCodeAt(1)) { + case 66: case 98: radix = 2; maxCode = 49; break; // fast equal /^0b[01]+$/i + case 79: case 111: radix = 8; maxCode = 55; break; // fast equal /^0o[0-7]+$/i + default: return +it; + } + for (var digits = it.slice(2), i = 0, l = digits.length, code; i < l; i++) { + code = digits.charCodeAt(i); + // parseInt parses a string to a first unavailable symbol + // but ToNumber should return NaN if a string contains unavailable symbols + if (code < 48 || code > maxCode) return NaN; + } return parseInt(digits, radix); + } + } return +it; +}; + +if (!$Number(' 0o1') || !$Number('0b1') || $Number('+0x1')) { + $Number = function Number(value) { + var it = arguments.length < 1 ? 0 : value; + var that = this; + return that instanceof $Number + // check on 1..constructor(foo) case + && (BROKEN_COF ? fails(function () { proto.valueOf.call(that); }) : cof(that) != NUMBER) + ? inheritIfRequired(new Base(toNumber(it)), that, $Number) : toNumber(it); + }; + for (var keys = require('./_descriptors') ? gOPN(Base) : ( + // ES3: + 'MAX_VALUE,MIN_VALUE,NaN,NEGATIVE_INFINITY,POSITIVE_INFINITY,' + + // ES6 (in case, if modules with ES6 Number statics required before): + 'EPSILON,isFinite,isInteger,isNaN,isSafeInteger,MAX_SAFE_INTEGER,' + + 'MIN_SAFE_INTEGER,parseFloat,parseInt,isInteger' + ).split(','), j = 0, key; keys.length > j; j++) { + if (has(Base, key = keys[j]) && !has($Number, key)) { + dP($Number, key, gOPD(Base, key)); + } + } + $Number.prototype = proto; + proto.constructor = $Number; + require('./_redefine')(global, NUMBER, $Number); +} + +},{"./_cof":10,"./_descriptors":15,"./_fails":21,"./_global":25,"./_has":26,"./_inherit-if-required":30,"./_object-create":44,"./_object-dp":45,"./_object-gopd":47,"./_object-gopn":49,"./_redefine":58,"./_string-trim":70,"./_to-primitive":77}],89:[function(require,module,exports){ +// 19.1.2.6 Object.getOwnPropertyDescriptor(O, P) +var toIObject = require('./_to-iobject'); +var $getOwnPropertyDescriptor = require('./_object-gopd').f; + +require('./_object-sap')('getOwnPropertyDescriptor', function () { + return function getOwnPropertyDescriptor(it, key) { + return $getOwnPropertyDescriptor(toIObject(it), key); + }; +}); + +},{"./_object-gopd":47,"./_object-sap":55,"./_to-iobject":74}],90:[function(require,module,exports){ +// 19.1.2.14 Object.keys(O) +var toObject = require('./_to-object'); +var $keys = require('./_object-keys'); + +require('./_object-sap')('keys', function () { + return function keys(it) { + return $keys(toObject(it)); + }; +}); + +},{"./_object-keys":53,"./_object-sap":55,"./_to-object":76}],91:[function(require,module,exports){ +'use strict'; +// 19.1.3.6 Object.prototype.toString() +var classof = require('./_classof'); +var test = {}; +test[require('./_wks')('toStringTag')] = 'z'; +if (test + '' != '[object z]') { + require('./_redefine')(Object.prototype, 'toString', function toString() { + return '[object ' + classof(this) + ']'; + }, true); +} + +},{"./_classof":9,"./_redefine":58,"./_wks":81}],92:[function(require,module,exports){ +var global = require('./_global'); +var inheritIfRequired = require('./_inherit-if-required'); +var dP = require('./_object-dp').f; +var gOPN = require('./_object-gopn').f; +var isRegExp = require('./_is-regexp'); +var $flags = require('./_flags'); +var $RegExp = global.RegExp; +var Base = $RegExp; +var proto = $RegExp.prototype; +var re1 = /a/g; +var re2 = /a/g; +// "new" creates a new object, old webkit buggy here +var CORRECT_NEW = new $RegExp(re1) !== re1; + +if (require('./_descriptors') && (!CORRECT_NEW || require('./_fails')(function () { + re2[require('./_wks')('match')] = false; + // RegExp constructor can alter flags and IsRegExp works correct with @@match + return $RegExp(re1) != re1 || $RegExp(re2) == re2 || $RegExp(re1, 'i') != '/a/i'; +}))) { + $RegExp = function RegExp(p, f) { + var tiRE = this instanceof $RegExp; + var piRE = isRegExp(p); + var fiU = f === undefined; + return !tiRE && piRE && p.constructor === $RegExp && fiU ? p + : inheritIfRequired(CORRECT_NEW + ? new Base(piRE && !fiU ? p.source : p, f) + : Base((piRE = p instanceof $RegExp) ? p.source : p, piRE && fiU ? $flags.call(p) : f) + , tiRE ? this : proto, $RegExp); + }; + var proxy = function (key) { + key in $RegExp || dP($RegExp, key, { + configurable: true, + get: function () { return Base[key]; }, + set: function (it) { Base[key] = it; } + }); + }; + for (var keys = gOPN(Base), i = 0; keys.length > i;) proxy(keys[i++]); + proto.constructor = $RegExp; + $RegExp.prototype = proto; + require('./_redefine')(global, 'RegExp', $RegExp); +} + +require('./_set-species')('RegExp'); + +},{"./_descriptors":15,"./_fails":21,"./_flags":23,"./_global":25,"./_inherit-if-required":30,"./_is-regexp":35,"./_object-dp":45,"./_object-gopn":49,"./_redefine":58,"./_set-species":62,"./_wks":81}],93:[function(require,module,exports){ +'use strict'; +var regexpExec = require('./_regexp-exec'); +require('./_export')({ + target: 'RegExp', + proto: true, + forced: regexpExec !== /./.exec +}, { + exec: regexpExec +}); + +},{"./_export":19,"./_regexp-exec":60}],94:[function(require,module,exports){ +'use strict'; + +var anObject = require('./_an-object'); +var toLength = require('./_to-length'); +var advanceStringIndex = require('./_advance-string-index'); +var regExpExec = require('./_regexp-exec-abstract'); + +// @@match logic +require('./_fix-re-wks')('match', 1, function (defined, MATCH, $match, maybeCallNative) { + return [ + // `String.prototype.match` method + // https://tc39.github.io/ecma262/#sec-string.prototype.match + function match(regexp) { + var O = defined(this); + var fn = regexp == undefined ? undefined : regexp[MATCH]; + return fn !== undefined ? fn.call(regexp, O) : new RegExp(regexp)[MATCH](String(O)); + }, + // `RegExp.prototype[@@match]` method + // https://tc39.github.io/ecma262/#sec-regexp.prototype-@@match + function (regexp) { + var res = maybeCallNative($match, regexp, this); + if (res.done) return res.value; + var rx = anObject(regexp); + var S = String(this); + if (!rx.global) return regExpExec(rx, S); + var fullUnicode = rx.unicode; + rx.lastIndex = 0; + var A = []; + var n = 0; + var result; + while ((result = regExpExec(rx, S)) !== null) { + var matchStr = String(result[0]); + A[n] = matchStr; + if (matchStr === '') rx.lastIndex = advanceStringIndex(S, toLength(rx.lastIndex), fullUnicode); + n++; + } + return n === 0 ? null : A; + } + ]; +}); + +},{"./_advance-string-index":3,"./_an-object":4,"./_fix-re-wks":22,"./_regexp-exec-abstract":59,"./_to-length":75}],95:[function(require,module,exports){ +'use strict'; + +var anObject = require('./_an-object'); +var toObject = require('./_to-object'); +var toLength = require('./_to-length'); +var toInteger = require('./_to-integer'); +var advanceStringIndex = require('./_advance-string-index'); +var regExpExec = require('./_regexp-exec-abstract'); +var max = Math.max; +var min = Math.min; +var floor = Math.floor; +var SUBSTITUTION_SYMBOLS = /\$([$&`']|\d\d?|<[^>]*>)/g; +var SUBSTITUTION_SYMBOLS_NO_NAMED = /\$([$&`']|\d\d?)/g; + +var maybeToString = function (it) { + return it === undefined ? it : String(it); +}; + +// @@replace logic +require('./_fix-re-wks')('replace', 2, function (defined, REPLACE, $replace, maybeCallNative) { + return [ + // `String.prototype.replace` method + // https://tc39.github.io/ecma262/#sec-string.prototype.replace + function replace(searchValue, replaceValue) { + var O = defined(this); + var fn = searchValue == undefined ? undefined : searchValue[REPLACE]; + return fn !== undefined + ? fn.call(searchValue, O, replaceValue) + : $replace.call(String(O), searchValue, replaceValue); + }, + // `RegExp.prototype[@@replace]` method + // https://tc39.github.io/ecma262/#sec-regexp.prototype-@@replace + function (regexp, replaceValue) { + var res = maybeCallNative($replace, regexp, this, replaceValue); + if (res.done) return res.value; + + var rx = anObject(regexp); + var S = String(this); + var functionalReplace = typeof replaceValue === 'function'; + if (!functionalReplace) replaceValue = String(replaceValue); + var global = rx.global; + if (global) { + var fullUnicode = rx.unicode; + rx.lastIndex = 0; + } + var results = []; + while (true) { + var result = regExpExec(rx, S); + if (result === null) break; + results.push(result); + if (!global) break; + var matchStr = String(result[0]); + if (matchStr === '') rx.lastIndex = advanceStringIndex(S, toLength(rx.lastIndex), fullUnicode); + } + var accumulatedResult = ''; + var nextSourcePosition = 0; + for (var i = 0; i < results.length; i++) { + result = results[i]; + var matched = String(result[0]); + var position = max(min(toInteger(result.index), S.length), 0); + var captures = []; + // NOTE: This is equivalent to + // captures = result.slice(1).map(maybeToString) + // but for some reason `nativeSlice.call(result, 1, result.length)` (called in + // the slice polyfill when slicing native arrays) "doesn't work" in safari 9 and + // causes a crash (https://pastebin.com/N21QzeQA) when trying to debug it. + for (var j = 1; j < result.length; j++) captures.push(maybeToString(result[j])); + var namedCaptures = result.groups; + if (functionalReplace) { + var replacerArgs = [matched].concat(captures, position, S); + if (namedCaptures !== undefined) replacerArgs.push(namedCaptures); + var replacement = String(replaceValue.apply(undefined, replacerArgs)); + } else { + replacement = getSubstitution(matched, S, position, captures, namedCaptures, replaceValue); + } + if (position >= nextSourcePosition) { + accumulatedResult += S.slice(nextSourcePosition, position) + replacement; + nextSourcePosition = position + matched.length; + } + } + return accumulatedResult + S.slice(nextSourcePosition); + } + ]; + + // https://tc39.github.io/ecma262/#sec-getsubstitution + function getSubstitution(matched, str, position, captures, namedCaptures, replacement) { + var tailPos = position + matched.length; + var m = captures.length; + var symbols = SUBSTITUTION_SYMBOLS_NO_NAMED; + if (namedCaptures !== undefined) { + namedCaptures = toObject(namedCaptures); + symbols = SUBSTITUTION_SYMBOLS; + } + return $replace.call(replacement, symbols, function (match, ch) { + var capture; + switch (ch.charAt(0)) { + case '$': return '$'; + case '&': return matched; + case '`': return str.slice(0, position); + case "'": return str.slice(tailPos); + case '<': + capture = namedCaptures[ch.slice(1, -1)]; + break; + default: // \d\d? + var n = +ch; + if (n === 0) return match; + if (n > m) { + var f = floor(n / 10); + if (f === 0) return match; + if (f <= m) return captures[f - 1] === undefined ? ch.charAt(1) : captures[f - 1] + ch.charAt(1); + return match; + } + capture = captures[n - 1]; + } + return capture === undefined ? '' : capture; + }); + } +}); + +},{"./_advance-string-index":3,"./_an-object":4,"./_fix-re-wks":22,"./_regexp-exec-abstract":59,"./_to-integer":73,"./_to-length":75,"./_to-object":76}],96:[function(require,module,exports){ +'use strict'; + +var isRegExp = require('./_is-regexp'); +var anObject = require('./_an-object'); +var speciesConstructor = require('./_species-constructor'); +var advanceStringIndex = require('./_advance-string-index'); +var toLength = require('./_to-length'); +var callRegExpExec = require('./_regexp-exec-abstract'); +var regexpExec = require('./_regexp-exec'); +var fails = require('./_fails'); +var $min = Math.min; +var $push = [].push; +var $SPLIT = 'split'; +var LENGTH = 'length'; +var LAST_INDEX = 'lastIndex'; +var MAX_UINT32 = 0xffffffff; + +// babel-minify transpiles RegExp('x', 'y') -> /x/y and it causes SyntaxError +var SUPPORTS_Y = !fails(function () { RegExp(MAX_UINT32, 'y'); }); + +// @@split logic +require('./_fix-re-wks')('split', 2, function (defined, SPLIT, $split, maybeCallNative) { + var internalSplit; + if ( + 'abbc'[$SPLIT](/(b)*/)[1] == 'c' || + 'test'[$SPLIT](/(?:)/, -1)[LENGTH] != 4 || + 'ab'[$SPLIT](/(?:ab)*/)[LENGTH] != 2 || + '.'[$SPLIT](/(.?)(.?)/)[LENGTH] != 4 || + '.'[$SPLIT](/()()/)[LENGTH] > 1 || + ''[$SPLIT](/.?/)[LENGTH] + ) { + // based on es5-shim implementation, need to rework it + internalSplit = function (separator, limit) { + var string = String(this); + if (separator === undefined && limit === 0) return []; + // If `separator` is not a regex, use native split + if (!isRegExp(separator)) return $split.call(string, separator, limit); + var output = []; + var flags = (separator.ignoreCase ? 'i' : '') + + (separator.multiline ? 'm' : '') + + (separator.unicode ? 'u' : '') + + (separator.sticky ? 'y' : ''); + var lastLastIndex = 0; + var splitLimit = limit === undefined ? MAX_UINT32 : limit >>> 0; + // Make `global` and avoid `lastIndex` issues by working with a copy + var separatorCopy = new RegExp(separator.source, flags + 'g'); + var match, lastIndex, lastLength; + while (match = regexpExec.call(separatorCopy, string)) { + lastIndex = separatorCopy[LAST_INDEX]; + if (lastIndex > lastLastIndex) { + output.push(string.slice(lastLastIndex, match.index)); + if (match[LENGTH] > 1 && match.index < string[LENGTH]) $push.apply(output, match.slice(1)); + lastLength = match[0][LENGTH]; + lastLastIndex = lastIndex; + if (output[LENGTH] >= splitLimit) break; + } + if (separatorCopy[LAST_INDEX] === match.index) separatorCopy[LAST_INDEX]++; // Avoid an infinite loop + } + if (lastLastIndex === string[LENGTH]) { + if (lastLength || !separatorCopy.test('')) output.push(''); + } else output.push(string.slice(lastLastIndex)); + return output[LENGTH] > splitLimit ? output.slice(0, splitLimit) : output; + }; + // Chakra, V8 + } else if ('0'[$SPLIT](undefined, 0)[LENGTH]) { + internalSplit = function (separator, limit) { + return separator === undefined && limit === 0 ? [] : $split.call(this, separator, limit); + }; + } else { + internalSplit = $split; + } + + return [ + // `String.prototype.split` method + // https://tc39.github.io/ecma262/#sec-string.prototype.split + function split(separator, limit) { + var O = defined(this); + var splitter = separator == undefined ? undefined : separator[SPLIT]; + return splitter !== undefined + ? splitter.call(separator, O, limit) + : internalSplit.call(String(O), separator, limit); + }, + // `RegExp.prototype[@@split]` method + // https://tc39.github.io/ecma262/#sec-regexp.prototype-@@split + // + // NOTE: This cannot be properly polyfilled in engines that don't support + // the 'y' flag. + function (regexp, limit) { + var res = maybeCallNative(internalSplit, regexp, this, limit, internalSplit !== $split); + if (res.done) return res.value; + + var rx = anObject(regexp); + var S = String(this); + var C = speciesConstructor(rx, RegExp); + + var unicodeMatching = rx.unicode; + var flags = (rx.ignoreCase ? 'i' : '') + + (rx.multiline ? 'm' : '') + + (rx.unicode ? 'u' : '') + + (SUPPORTS_Y ? 'y' : 'g'); + + // ^(? + rx + ) is needed, in combination with some S slicing, to + // simulate the 'y' flag. + var splitter = new C(SUPPORTS_Y ? rx : '^(?:' + rx.source + ')', flags); + var lim = limit === undefined ? MAX_UINT32 : limit >>> 0; + if (lim === 0) return []; + if (S.length === 0) return callRegExpExec(splitter, S) === null ? [S] : []; + var p = 0; + var q = 0; + var A = []; + while (q < S.length) { + splitter.lastIndex = SUPPORTS_Y ? q : 0; + var z = callRegExpExec(splitter, SUPPORTS_Y ? S : S.slice(q)); + var e; + if ( + z === null || + (e = $min(toLength(splitter.lastIndex + (SUPPORTS_Y ? 0 : q)), S.length)) === p + ) { + q = advanceStringIndex(S, q, unicodeMatching); + } else { + A.push(S.slice(p, q)); + if (A.length === lim) return A; + for (var i = 1; i <= z.length - 1; i++) { + A.push(z[i]); + if (A.length === lim) return A; + } + q = p = e; + } + } + A.push(S.slice(p)); + return A; + } + ]; +}); + +},{"./_advance-string-index":3,"./_an-object":4,"./_fails":21,"./_fix-re-wks":22,"./_is-regexp":35,"./_regexp-exec":60,"./_regexp-exec-abstract":59,"./_species-constructor":66,"./_to-length":75}],97:[function(require,module,exports){ +// 21.1.3.7 String.prototype.includes(searchString, position = 0) +'use strict'; +var $export = require('./_export'); +var context = require('./_string-context'); +var INCLUDES = 'includes'; + +$export($export.P + $export.F * require('./_fails-is-regexp')(INCLUDES), 'String', { + includes: function includes(searchString /* , position = 0 */) { + return !!~context(this, searchString, INCLUDES) + .indexOf(searchString, arguments.length > 1 ? arguments[1] : undefined); + } +}); + +},{"./_export":19,"./_fails-is-regexp":20,"./_string-context":69}],98:[function(require,module,exports){ +'use strict'; +var $at = require('./_string-at')(true); + +// 21.1.3.27 String.prototype[@@iterator]() +require('./_iter-define')(String, 'String', function (iterated) { + this._t = String(iterated); // target + this._i = 0; // next index +// 21.1.5.2.1 %StringIteratorPrototype%.next() +}, function () { + var O = this._t; + var index = this._i; + var point; + if (index >= O.length) return { value: undefined, done: true }; + point = $at(O, index); + this._i += point.length; + return { value: point, done: false }; +}); + +},{"./_iter-define":38,"./_string-at":68}],99:[function(require,module,exports){ +'use strict'; +// ECMAScript 6 symbols shim +var global = require('./_global'); +var has = require('./_has'); +var DESCRIPTORS = require('./_descriptors'); +var $export = require('./_export'); +var redefine = require('./_redefine'); +var META = require('./_meta').KEY; +var $fails = require('./_fails'); +var shared = require('./_shared'); +var setToStringTag = require('./_set-to-string-tag'); +var uid = require('./_uid'); +var wks = require('./_wks'); +var wksExt = require('./_wks-ext'); +var wksDefine = require('./_wks-define'); +var enumKeys = require('./_enum-keys'); +var isArray = require('./_is-array'); +var anObject = require('./_an-object'); +var isObject = require('./_is-object'); +var toObject = require('./_to-object'); +var toIObject = require('./_to-iobject'); +var toPrimitive = require('./_to-primitive'); +var createDesc = require('./_property-desc'); +var _create = require('./_object-create'); +var gOPNExt = require('./_object-gopn-ext'); +var $GOPD = require('./_object-gopd'); +var $GOPS = require('./_object-gops'); +var $DP = require('./_object-dp'); +var $keys = require('./_object-keys'); +var gOPD = $GOPD.f; +var dP = $DP.f; +var gOPN = gOPNExt.f; +var $Symbol = global.Symbol; +var $JSON = global.JSON; +var _stringify = $JSON && $JSON.stringify; +var PROTOTYPE = 'prototype'; +var HIDDEN = wks('_hidden'); +var TO_PRIMITIVE = wks('toPrimitive'); +var isEnum = {}.propertyIsEnumerable; +var SymbolRegistry = shared('symbol-registry'); +var AllSymbols = shared('symbols'); +var OPSymbols = shared('op-symbols'); +var ObjectProto = Object[PROTOTYPE]; +var USE_NATIVE = typeof $Symbol == 'function' && !!$GOPS.f; +var QObject = global.QObject; +// Don't use setters in Qt Script, https://github.com/zloirock/core-js/issues/173 +var setter = !QObject || !QObject[PROTOTYPE] || !QObject[PROTOTYPE].findChild; + +// fallback for old Android, https://code.google.com/p/v8/issues/detail?id=687 +var setSymbolDesc = DESCRIPTORS && $fails(function () { + return _create(dP({}, 'a', { + get: function () { return dP(this, 'a', { value: 7 }).a; } + })).a != 7; +}) ? function (it, key, D) { + var protoDesc = gOPD(ObjectProto, key); + if (protoDesc) delete ObjectProto[key]; + dP(it, key, D); + if (protoDesc && it !== ObjectProto) dP(ObjectProto, key, protoDesc); +} : dP; + +var wrap = function (tag) { + var sym = AllSymbols[tag] = _create($Symbol[PROTOTYPE]); + sym._k = tag; + return sym; +}; + +var isSymbol = USE_NATIVE && typeof $Symbol.iterator == 'symbol' ? function (it) { + return typeof it == 'symbol'; +} : function (it) { + return it instanceof $Symbol; +}; + +var $defineProperty = function defineProperty(it, key, D) { + if (it === ObjectProto) $defineProperty(OPSymbols, key, D); + anObject(it); + key = toPrimitive(key, true); + anObject(D); + if (has(AllSymbols, key)) { + if (!D.enumerable) { + if (!has(it, HIDDEN)) dP(it, HIDDEN, createDesc(1, {})); + it[HIDDEN][key] = true; + } else { + if (has(it, HIDDEN) && it[HIDDEN][key]) it[HIDDEN][key] = false; + D = _create(D, { enumerable: createDesc(0, false) }); + } return setSymbolDesc(it, key, D); + } return dP(it, key, D); +}; +var $defineProperties = function defineProperties(it, P) { + anObject(it); + var keys = enumKeys(P = toIObject(P)); + var i = 0; + var l = keys.length; + var key; + while (l > i) $defineProperty(it, key = keys[i++], P[key]); + return it; +}; +var $create = function create(it, P) { + return P === undefined ? _create(it) : $defineProperties(_create(it), P); +}; +var $propertyIsEnumerable = function propertyIsEnumerable(key) { + var E = isEnum.call(this, key = toPrimitive(key, true)); + if (this === ObjectProto && has(AllSymbols, key) && !has(OPSymbols, key)) return false; + return E || !has(this, key) || !has(AllSymbols, key) || has(this, HIDDEN) && this[HIDDEN][key] ? E : true; +}; +var $getOwnPropertyDescriptor = function getOwnPropertyDescriptor(it, key) { + it = toIObject(it); + key = toPrimitive(key, true); + if (it === ObjectProto && has(AllSymbols, key) && !has(OPSymbols, key)) return; + var D = gOPD(it, key); + if (D && has(AllSymbols, key) && !(has(it, HIDDEN) && it[HIDDEN][key])) D.enumerable = true; + return D; +}; +var $getOwnPropertyNames = function getOwnPropertyNames(it) { + var names = gOPN(toIObject(it)); + var result = []; + var i = 0; + var key; + while (names.length > i) { + if (!has(AllSymbols, key = names[i++]) && key != HIDDEN && key != META) result.push(key); + } return result; +}; +var $getOwnPropertySymbols = function getOwnPropertySymbols(it) { + var IS_OP = it === ObjectProto; + var names = gOPN(IS_OP ? OPSymbols : toIObject(it)); + var result = []; + var i = 0; + var key; + while (names.length > i) { + if (has(AllSymbols, key = names[i++]) && (IS_OP ? has(ObjectProto, key) : true)) result.push(AllSymbols[key]); + } return result; +}; + +// 19.4.1.1 Symbol([description]) +if (!USE_NATIVE) { + $Symbol = function Symbol() { + if (this instanceof $Symbol) throw TypeError('Symbol is not a constructor!'); + var tag = uid(arguments.length > 0 ? arguments[0] : undefined); + var $set = function (value) { + if (this === ObjectProto) $set.call(OPSymbols, value); + if (has(this, HIDDEN) && has(this[HIDDEN], tag)) this[HIDDEN][tag] = false; + setSymbolDesc(this, tag, createDesc(1, value)); + }; + if (DESCRIPTORS && setter) setSymbolDesc(ObjectProto, tag, { configurable: true, set: $set }); + return wrap(tag); + }; + redefine($Symbol[PROTOTYPE], 'toString', function toString() { + return this._k; + }); + + $GOPD.f = $getOwnPropertyDescriptor; + $DP.f = $defineProperty; + require('./_object-gopn').f = gOPNExt.f = $getOwnPropertyNames; + require('./_object-pie').f = $propertyIsEnumerable; + $GOPS.f = $getOwnPropertySymbols; + + if (DESCRIPTORS && !require('./_library')) { + redefine(ObjectProto, 'propertyIsEnumerable', $propertyIsEnumerable, true); + } + + wksExt.f = function (name) { + return wrap(wks(name)); + }; +} + +$export($export.G + $export.W + $export.F * !USE_NATIVE, { Symbol: $Symbol }); + +for (var es6Symbols = ( + // 19.4.2.2, 19.4.2.3, 19.4.2.4, 19.4.2.6, 19.4.2.8, 19.4.2.9, 19.4.2.10, 19.4.2.11, 19.4.2.12, 19.4.2.13, 19.4.2.14 + 'hasInstance,isConcatSpreadable,iterator,match,replace,search,species,split,toPrimitive,toStringTag,unscopables' +).split(','), j = 0; es6Symbols.length > j;)wks(es6Symbols[j++]); + +for (var wellKnownSymbols = $keys(wks.store), k = 0; wellKnownSymbols.length > k;) wksDefine(wellKnownSymbols[k++]); + +$export($export.S + $export.F * !USE_NATIVE, 'Symbol', { + // 19.4.2.1 Symbol.for(key) + 'for': function (key) { + return has(SymbolRegistry, key += '') + ? SymbolRegistry[key] + : SymbolRegistry[key] = $Symbol(key); + }, + // 19.4.2.5 Symbol.keyFor(sym) + keyFor: function keyFor(sym) { + if (!isSymbol(sym)) throw TypeError(sym + ' is not a symbol!'); + for (var key in SymbolRegistry) if (SymbolRegistry[key] === sym) return key; + }, + useSetter: function () { setter = true; }, + useSimple: function () { setter = false; } +}); + +$export($export.S + $export.F * !USE_NATIVE, 'Object', { + // 19.1.2.2 Object.create(O [, Properties]) + create: $create, + // 19.1.2.4 Object.defineProperty(O, P, Attributes) + defineProperty: $defineProperty, + // 19.1.2.3 Object.defineProperties(O, Properties) + defineProperties: $defineProperties, + // 19.1.2.6 Object.getOwnPropertyDescriptor(O, P) + getOwnPropertyDescriptor: $getOwnPropertyDescriptor, + // 19.1.2.7 Object.getOwnPropertyNames(O) + getOwnPropertyNames: $getOwnPropertyNames, + // 19.1.2.8 Object.getOwnPropertySymbols(O) + getOwnPropertySymbols: $getOwnPropertySymbols +}); + +// Chrome 38 and 39 `Object.getOwnPropertySymbols` fails on primitives +// https://bugs.chromium.org/p/v8/issues/detail?id=3443 +var FAILS_ON_PRIMITIVES = $fails(function () { $GOPS.f(1); }); + +$export($export.S + $export.F * FAILS_ON_PRIMITIVES, 'Object', { + getOwnPropertySymbols: function getOwnPropertySymbols(it) { + return $GOPS.f(toObject(it)); + } +}); + +// 24.3.2 JSON.stringify(value [, replacer [, space]]) +$JSON && $export($export.S + $export.F * (!USE_NATIVE || $fails(function () { + var S = $Symbol(); + // MS Edge converts symbol values to JSON as {} + // WebKit converts symbol values to JSON as null + // V8 throws on boxed symbols + return _stringify([S]) != '[null]' || _stringify({ a: S }) != '{}' || _stringify(Object(S)) != '{}'; +})), 'JSON', { + stringify: function stringify(it) { + var args = [it]; + var i = 1; + var replacer, $replacer; + while (arguments.length > i) args.push(arguments[i++]); + $replacer = replacer = args[1]; + if (!isObject(replacer) && it === undefined || isSymbol(it)) return; // IE8 returns string on undefined + if (!isArray(replacer)) replacer = function (key, value) { + if (typeof $replacer == 'function') value = $replacer.call(this, key, value); + if (!isSymbol(value)) return value; + }; + args[1] = replacer; + return _stringify.apply($JSON, args); + } +}); + +// 19.4.3.4 Symbol.prototype[@@toPrimitive](hint) +$Symbol[PROTOTYPE][TO_PRIMITIVE] || require('./_hide')($Symbol[PROTOTYPE], TO_PRIMITIVE, $Symbol[PROTOTYPE].valueOf); +// 19.4.3.5 Symbol.prototype[@@toStringTag] +setToStringTag($Symbol, 'Symbol'); +// 20.2.1.9 Math[@@toStringTag] +setToStringTag(Math, 'Math', true); +// 24.3.3 JSON[@@toStringTag] +setToStringTag(global.JSON, 'JSON', true); + +},{"./_an-object":4,"./_descriptors":15,"./_enum-keys":18,"./_export":19,"./_fails":21,"./_global":25,"./_has":26,"./_hide":27,"./_is-array":33,"./_is-object":34,"./_library":42,"./_meta":43,"./_object-create":44,"./_object-dp":45,"./_object-gopd":47,"./_object-gopn":49,"./_object-gopn-ext":48,"./_object-gops":50,"./_object-keys":53,"./_object-pie":54,"./_property-desc":57,"./_redefine":58,"./_set-to-string-tag":63,"./_shared":65,"./_to-iobject":74,"./_to-object":76,"./_to-primitive":77,"./_uid":78,"./_wks":81,"./_wks-define":79,"./_wks-ext":80}],100:[function(require,module,exports){ +'use strict'; +// https://github.com/tc39/Array.prototype.includes +var $export = require('./_export'); +var $includes = require('./_array-includes')(true); + +$export($export.P, 'Array', { + includes: function includes(el /* , fromIndex = 0 */) { + return $includes(this, el, arguments.length > 1 ? arguments[1] : undefined); + } +}); + +require('./_add-to-unscopables')('includes'); + +},{"./_add-to-unscopables":2,"./_array-includes":5,"./_export":19}],101:[function(require,module,exports){ +// https://github.com/tc39/proposal-object-getownpropertydescriptors +var $export = require('./_export'); +var ownKeys = require('./_own-keys'); +var toIObject = require('./_to-iobject'); +var gOPD = require('./_object-gopd'); +var createProperty = require('./_create-property'); + +$export($export.S, 'Object', { + getOwnPropertyDescriptors: function getOwnPropertyDescriptors(object) { + var O = toIObject(object); + var getDesc = gOPD.f; + var keys = ownKeys(O); + var result = {}; + var i = 0; + var key, desc; + while (keys.length > i) { + desc = getDesc(O, key = keys[i++]); + if (desc !== undefined) createProperty(result, key, desc); + } + return result; + } +}); + +},{"./_create-property":12,"./_export":19,"./_object-gopd":47,"./_own-keys":56,"./_to-iobject":74}],102:[function(require,module,exports){ +var $iterators = require('./es6.array.iterator'); +var getKeys = require('./_object-keys'); +var redefine = require('./_redefine'); +var global = require('./_global'); +var hide = require('./_hide'); +var Iterators = require('./_iterators'); +var wks = require('./_wks'); +var ITERATOR = wks('iterator'); +var TO_STRING_TAG = wks('toStringTag'); +var ArrayValues = Iterators.Array; + +var DOMIterables = { + CSSRuleList: true, // TODO: Not spec compliant, should be false. + CSSStyleDeclaration: false, + CSSValueList: false, + ClientRectList: false, + DOMRectList: false, + DOMStringList: false, + DOMTokenList: true, + DataTransferItemList: false, + FileList: false, + HTMLAllCollection: false, + HTMLCollection: false, + HTMLFormElement: false, + HTMLSelectElement: false, + MediaList: true, // TODO: Not spec compliant, should be false. + MimeTypeArray: false, + NamedNodeMap: false, + NodeList: true, + PaintRequestList: false, + Plugin: false, + PluginArray: false, + SVGLengthList: false, + SVGNumberList: false, + SVGPathSegList: false, + SVGPointList: false, + SVGStringList: false, + SVGTransformList: false, + SourceBufferList: false, + StyleSheetList: true, // TODO: Not spec compliant, should be false. + TextTrackCueList: false, + TextTrackList: false, + TouchList: false +}; + +for (var collections = getKeys(DOMIterables), i = 0; i < collections.length; i++) { + var NAME = collections[i]; + var explicit = DOMIterables[NAME]; + var Collection = global[NAME]; + var proto = Collection && Collection.prototype; + var key; + if (proto) { + if (!proto[ITERATOR]) hide(proto, ITERATOR, ArrayValues); + if (!proto[TO_STRING_TAG]) hide(proto, TO_STRING_TAG, NAME); + Iterators[NAME] = ArrayValues; + if (explicit) for (key in $iterators) if (!proto[key]) redefine(proto, key, $iterators[key], true); + } +} + +},{"./_global":25,"./_hide":27,"./_iterators":41,"./_object-keys":53,"./_redefine":58,"./_wks":81,"./es6.array.iterator":85}],103:[function(require,module,exports){ +"use strict"; + +require("core-js/modules/es6.symbol.js"); +require("core-js/modules/es6.number.constructor.js"); +require("core-js/modules/es6.string.iterator.js"); +require("core-js/modules/es6.object.to-string.js"); +require("core-js/modules/es6.array.iterator.js"); +require("core-js/modules/web.dom.iterable.js"); +function _typeof(o) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { return typeof o; } : function (o) { return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; }, _typeof(o); } +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, _toPropertyKey(descriptor.key), descriptor); } } +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return _typeof(key) === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (_typeof(input) !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (_typeof(res) !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } +var _require = require('./protocol'), + Parser = _require.Parser, + PROTOCOL_6 = _require.PROTOCOL_6, + PROTOCOL_7 = _require.PROTOCOL_7; +var VERSION = "4.0.2"; +var Connector = /*#__PURE__*/function () { + function Connector(options, WebSocket, Timer, handlers) { + var _this = this; + _classCallCheck(this, Connector); + this.options = options; + this.WebSocket = WebSocket; + this.Timer = Timer; + this.handlers = handlers; + var path = this.options.path ? "".concat(this.options.path) : 'livereload'; + var port = this.options.port ? ":".concat(this.options.port) : ''; + this._uri = "ws".concat(this.options.https ? 's' : '', "://").concat(this.options.host).concat(port, "/").concat(path); + this._nextDelay = this.options.mindelay; + this._connectionDesired = false; + this.protocol = 0; + this.protocolParser = new Parser({ + connected: function connected(protocol) { + _this.protocol = protocol; + _this._handshakeTimeout.stop(); + _this._nextDelay = _this.options.mindelay; + _this._disconnectionReason = 'broken'; + return _this.handlers.connected(_this.protocol); + }, + error: function error(e) { + _this.handlers.error(e); + return _this._closeOnError(); + }, + message: function message(_message) { + return _this.handlers.message(_message); + } + }); + this._handshakeTimeout = new this.Timer(function () { + if (!_this._isSocketConnected()) { + return; + } + _this._disconnectionReason = 'handshake-timeout'; + return _this.socket.close(); + }); + this._reconnectTimer = new this.Timer(function () { + if (!_this._connectionDesired) { + // shouldn't hit this, but just in case + return; + } + return _this.connect(); + }); + this.connect(); + } + _createClass(Connector, [{ + key: "_isSocketConnected", + value: function _isSocketConnected() { + return this.socket && this.socket.readyState === this.WebSocket.OPEN; + } + }, { + key: "connect", + value: function connect() { + var _this2 = this; + this._connectionDesired = true; + if (this._isSocketConnected()) { + return; + } + + // prepare for a new connection + this._reconnectTimer.stop(); + this._disconnectionReason = 'cannot-connect'; + this.protocolParser.reset(); + this.handlers.connecting(); + this.socket = new this.WebSocket(this._uri); + this.socket.onopen = function (e) { + return _this2._onopen(e); + }; + this.socket.onclose = function (e) { + return _this2._onclose(e); + }; + this.socket.onmessage = function (e) { + return _this2._onmessage(e); + }; + this.socket.onerror = function (e) { + return _this2._onerror(e); + }; + } + }, { + key: "disconnect", + value: function disconnect() { + this._connectionDesired = false; + this._reconnectTimer.stop(); // in case it was running + + if (!this._isSocketConnected()) { + return; + } + this._disconnectionReason = 'manual'; + return this.socket.close(); + } + }, { + key: "_scheduleReconnection", + value: function _scheduleReconnection() { + if (!this._connectionDesired) { + // don't reconnect after manual disconnection + return; + } + if (!this._reconnectTimer.running) { + this._reconnectTimer.start(this._nextDelay); + this._nextDelay = Math.min(this.options.maxdelay, this._nextDelay * 2); + } + } + }, { + key: "sendCommand", + value: function sendCommand(command) { + if (!this.protocol) { + return; + } + return this._sendCommand(command); + } + }, { + key: "_sendCommand", + value: function _sendCommand(command) { + return this.socket.send(JSON.stringify(command)); + } + }, { + key: "_closeOnError", + value: function _closeOnError() { + this._handshakeTimeout.stop(); + this._disconnectionReason = 'error'; + return this.socket.close(); + } + }, { + key: "_onopen", + value: function _onopen(e) { + this.handlers.socketConnected(); + this._disconnectionReason = 'handshake-failed'; + + // start handshake + var hello = { + command: 'hello', + protocols: [PROTOCOL_6, PROTOCOL_7] + }; + hello.ver = VERSION; + if (this.options.ext) { + hello.ext = this.options.ext; + } + if (this.options.extver) { + hello.extver = this.options.extver; + } + if (this.options.snipver) { + hello.snipver = this.options.snipver; + } + this._sendCommand(hello); + return this._handshakeTimeout.start(this.options.handshake_timeout); + } + }, { + key: "_onclose", + value: function _onclose(e) { + this.protocol = 0; + this.handlers.disconnected(this._disconnectionReason, this._nextDelay); + return this._scheduleReconnection(); + } + }, { + key: "_onerror", + value: function _onerror(e) {} + }, { + key: "_onmessage", + value: function _onmessage(e) { + return this.protocolParser.process(e.data); + } + }]); + return Connector; +}(); +; +exports.Connector = Connector; + +},{"./protocol":108,"core-js/modules/es6.array.iterator.js":85,"core-js/modules/es6.number.constructor.js":88,"core-js/modules/es6.object.to-string.js":91,"core-js/modules/es6.string.iterator.js":98,"core-js/modules/es6.symbol.js":99,"core-js/modules/web.dom.iterable.js":102}],104:[function(require,module,exports){ +"use strict"; + +var CustomEvents = { + bind: function bind(element, eventName, handler) { + if (element.addEventListener) { + return element.addEventListener(eventName, handler, false); + } + if (element.attachEvent) { + element[eventName] = 1; + return element.attachEvent('onpropertychange', function (event) { + if (event.propertyName === eventName) { + return handler(); + } + }); + } + throw new Error("Attempt to attach custom event ".concat(eventName, " to something which isn't a DOMElement")); + }, + fire: function fire(element, eventName) { + if (element.addEventListener) { + var event = document.createEvent('HTMLEvents'); + event.initEvent(eventName, true, true); + return document.dispatchEvent(event); + } else if (element.attachEvent) { + if (element[eventName]) { + return element[eventName]++; + } + } else { + throw new Error("Attempt to fire custom event ".concat(eventName, " on something which isn't a DOMElement")); + } + } +}; +exports.bind = CustomEvents.bind; +exports.fire = CustomEvents.fire; + +},{}],105:[function(require,module,exports){ +"use strict"; + +require("core-js/modules/es6.regexp.match.js"); +require("core-js/modules/es6.symbol.js"); +require("core-js/modules/es6.array.from.js"); +require("core-js/modules/es6.string.iterator.js"); +require("core-js/modules/es6.object.to-string.js"); +require("core-js/modules/es6.array.iterator.js"); +require("core-js/modules/web.dom.iterable.js"); +require("core-js/modules/es6.number.constructor.js"); +function _typeof(o) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { return typeof o; } : function (o) { return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; }, _typeof(o); } +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, _toPropertyKey(descriptor.key), descriptor); } } +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return _typeof(key) === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (_typeof(input) !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (_typeof(res) !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } +var LessPlugin = /*#__PURE__*/function () { + function LessPlugin(window, host) { + _classCallCheck(this, LessPlugin); + this.window = window; + this.host = host; + } + _createClass(LessPlugin, [{ + key: "reload", + value: function reload(path, options) { + if (this.window.less && this.window.less.refresh) { + if (path.match(/\.less$/i)) { + return this.reloadLess(path); + } + if (options.originalPath.match(/\.less$/i)) { + return this.reloadLess(options.originalPath); + } + } + return false; + } + }, { + key: "reloadLess", + value: function reloadLess(path) { + var link; + var links = function () { + var result = []; + for (var _i = 0, _Array$from = Array.from(document.getElementsByTagName('link')); _i < _Array$from.length; _i++) { + link = _Array$from[_i]; + if (link.href && link.rel.match(/^stylesheet\/less$/i) || link.rel.match(/stylesheet/i) && link.type.match(/^text\/(x-)?less$/i)) { + result.push(link); + } + } + return result; + }(); + if (links.length === 0) { + return false; + } + for (var _i2 = 0, _Array$from2 = Array.from(links); _i2 < _Array$from2.length; _i2++) { + link = _Array$from2[_i2]; + link.href = this.host.generateCacheBustUrl(link.href); + } + this.host.console.log('LiveReload is asking LESS to recompile all stylesheets'); + this.window.less.refresh(true); + return true; + } + }, { + key: "analyze", + value: function analyze() { + return { + disable: !!(this.window.less && this.window.less.refresh) + }; + } + }]); + return LessPlugin; +}(); +; +LessPlugin.identifier = 'less'; +LessPlugin.version = '1.0'; +module.exports = LessPlugin; + +},{"core-js/modules/es6.array.from.js":84,"core-js/modules/es6.array.iterator.js":85,"core-js/modules/es6.number.constructor.js":88,"core-js/modules/es6.object.to-string.js":91,"core-js/modules/es6.regexp.match.js":94,"core-js/modules/es6.string.iterator.js":98,"core-js/modules/es6.symbol.js":99,"core-js/modules/web.dom.iterable.js":102}],106:[function(require,module,exports){ +"use strict"; + +require("core-js/modules/es6.symbol.js"); +require("core-js/modules/es6.number.constructor.js"); +require("core-js/modules/es6.array.slice.js"); +require("core-js/modules/es6.object.to-string.js"); +require("core-js/modules/es6.array.from.js"); +require("core-js/modules/es6.string.iterator.js"); +require("core-js/modules/es6.array.iterator.js"); +require("core-js/modules/web.dom.iterable.js"); +function _typeof(o) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { return typeof o; } : function (o) { return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; }, _typeof(o); } +require("core-js/modules/es6.regexp.match.js"); +require("core-js/modules/es6.object.keys.js"); +function _createForOfIteratorHelper(o, allowArrayLike) { var it = typeof Symbol !== "undefined" && o[Symbol.iterator] || o["@@iterator"]; if (!it) { if (Array.isArray(o) || (it = _unsupportedIterableToArray(o)) || allowArrayLike && o && typeof o.length === "number") { if (it) o = it; var i = 0; var F = function F() {}; return { s: F, n: function n() { if (i >= o.length) return { done: true }; return { done: false, value: o[i++] }; }, e: function e(_e) { throw _e; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var normalCompletion = true, didErr = false, err; return { s: function s() { it = it.call(o); }, n: function n() { var step = it.next(); normalCompletion = step.done; return step; }, e: function e(_e2) { didErr = true; err = _e2; }, f: function f() { try { if (!normalCompletion && it.return != null) it.return(); } finally { if (didErr) throw err; } } }; } +function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); } +function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i]; return arr2; } +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, _toPropertyKey(descriptor.key), descriptor); } } +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return _typeof(key) === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (_typeof(input) !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (_typeof(res) !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } +/* global alert */ +var _require = require('./connector'), + Connector = _require.Connector; +var _require2 = require('./timer'), + Timer = _require2.Timer; +var _require3 = require('./options'), + Options = _require3.Options; +var _require4 = require('./reloader'), + Reloader = _require4.Reloader; +var _require5 = require('./protocol'), + ProtocolError = _require5.ProtocolError; +var LiveReload = /*#__PURE__*/function () { + function LiveReload(window) { + var _this = this; + _classCallCheck(this, LiveReload); + this.window = window; + this.listeners = {}; + this.plugins = []; + this.pluginIdentifiers = {}; + + // i can haz console? + this.console = this.window.console && this.window.console.log && this.window.console.error ? this.window.location.href.match(/LR-verbose/) ? this.window.console : { + log: function log() {}, + error: this.window.console.error.bind(this.window.console) + } : { + log: function log() {}, + error: function error() {} + }; + + // i can haz sockets? + if (!(this.WebSocket = this.window.WebSocket || this.window.MozWebSocket)) { + this.console.error('LiveReload disabled because the browser does not seem to support web sockets'); + return; + } + + // i can haz options? + if ('LiveReloadOptions' in window) { + this.options = new Options(); + for (var _i = 0, _Object$keys = Object.keys(window.LiveReloadOptions || {}); _i < _Object$keys.length; _i++) { + var k = _Object$keys[_i]; + var v = window.LiveReloadOptions[k]; + this.options.set(k, v); + } + } else { + this.options = Options.extract(this.window.document); + if (!this.options) { + this.console.error('LiveReload disabled because it could not find its own + + +## More + +This is a word. + +This is a word. + +This is a word. + +This is a word. + +This is a word. + + +-- layouts/_default/single.html -- +{{ .Content }} +` + + b := hugolib.Test(t, files, hugolib.TestOptWarn()) + + b.AssertFileContent("public/p1/index.html", + "! ", + "! ", + "! ", + "! script", + ) + b.AssertLogContains("! WARN") + + b = hugolib.Test(t, strings.ReplaceAll(files, "markup.goldmark.renderer.unsafe = false", "markup.goldmark.renderer.unsafe = true"), hugolib.TestOptWarn()) + b.AssertFileContent("public/p1/index.html", + "! ", + "", + "", + ) + b.AssertLogContains("! WARN") +} diff --git a/markup/goldmark/hugocontext/hugocontext.go b/markup/goldmark/hugocontext/hugocontext.go new file mode 100644 index 000000000..7a556083c --- /dev/null +++ b/markup/goldmark/hugocontext/hugocontext.go @@ -0,0 +1,317 @@ +// 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 hugocontext + +import ( + "bytes" + "fmt" + "regexp" + "strconv" + + "github.com/gohugoio/hugo/bufferpool" + "github.com/gohugoio/hugo/common/constants" + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/markup/goldmark/internal/render" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +func New(logger loggers.Logger) goldmark.Extender { + return &hugoContextExtension{logger: logger} +} + +// Wrap wraps the given byte slice in a Hugo context that used to determine the correct Page +// in .RenderShortcodes. +func Wrap(b []byte, pid uint64) string { + buf := bufferpool.GetBuffer() + defer bufferpool.PutBuffer(buf) + buf.Write(hugoCtxPrefix) + buf.WriteString(" pid=") + buf.WriteString(strconv.FormatUint(pid, 10)) + buf.Write(hugoCtxEndDelim) + buf.WriteByte('\n') + buf.Write(b) + // To make sure that we're able to parse it, make sure it ends with a newline. + if len(b) > 0 && b[len(b)-1] != '\n' { + buf.WriteByte('\n') + } + buf.Write(hugoCtxPrefix) + buf.Write(hugoCtxClosingDelim) + buf.WriteByte('\n') + return buf.String() +} + +var kindHugoContext = ast.NewNodeKind("HugoContext") + +// HugoContext is a node that represents a Hugo context. +type HugoContext struct { + ast.BaseInline + + Closing bool + + // Internal page ID. Not persisted. + Pid uint64 +} + +// Dump implements Node.Dump. +func (n *HugoContext) Dump(source []byte, level int) { + m := map[string]string{} + m["Pid"] = fmt.Sprintf("%v", n.Pid) + ast.DumpHelper(n, source, level, m, nil) +} + +func (n *HugoContext) parseAttrs(attrBytes []byte) { + keyPairs := bytes.Split(attrBytes, []byte(" ")) + for _, keyPair := range keyPairs { + kv := bytes.Split(keyPair, []byte("=")) + if len(kv) != 2 { + continue + } + key := string(kv[0]) + val := string(kv[1]) + switch key { + case "pid": + pid, _ := strconv.ParseUint(val, 10, 64) + n.Pid = pid + } + } +} + +func (h *HugoContext) Kind() ast.NodeKind { + return kindHugoContext +} + +var ( + hugoCtxPrefix = []byte("{{__hugo_ctx") + hugoCtxEndDelim = []byte("}}") + hugoCtxClosingDelim = []byte("/}}") + hugoCtxRe = regexp.MustCompile(`{{__hugo_ctx( pid=\d+)?/?}}\n?`) +) + +var _ parser.InlineParser = (*hugoContextParser)(nil) + +type hugoContextParser struct{} + +func (a *hugoContextParser) Trigger() []byte { + return []byte{'{'} +} + +func (s *hugoContextParser) Parse(parent ast.Node, reader text.Reader, pc parser.Context) ast.Node { + line, _ := reader.PeekLine() + if !bytes.HasPrefix(line, hugoCtxPrefix) { + return nil + } + end := bytes.Index(line, hugoCtxEndDelim) + if end == -1 { + return nil + } + + reader.Advance(end + len(hugoCtxEndDelim) + 1) // +1 for the newline + + if line[end-1] == '/' { + return &HugoContext{Closing: true} + } + + attrBytes := line[len(hugoCtxPrefix)+1 : end] + h := &HugoContext{} + h.parseAttrs(attrBytes) + return h +} + +type hugoContextRenderer struct { + logger loggers.Logger + html.Config +} + +func (r *hugoContextRenderer) SetOption(name renderer.OptionName, value any) { + r.Config.SetOption(name, value) +} + +func (r *hugoContextRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(kindHugoContext, r.handleHugoContext) + reg.Register(ast.KindRawHTML, r.renderRawHTML) + reg.Register(ast.KindHTMLBlock, r.renderHTMLBlock) +} + +func (r *hugoContextRenderer) stripHugoCtx(b []byte) ([]byte, bool) { + if !bytes.Contains(b, hugoCtxPrefix) { + return b, false + } + return hugoCtxRe.ReplaceAll(b, nil), true +} + +func (r *hugoContextRenderer) logRawHTMLEmittedWarn(w util.BufWriter) { + r.logger.Warnidf(constants.WarnGoldmarkRawHTML, "Raw HTML omitted while rendering %q; see https://gohugo.io/getting-started/configuration-markup/#rendererunsafe", r.getPage(w)) +} + +func (r *hugoContextRenderer) getPage(w util.BufWriter) any { + var p any + ctx, ok := w.(*render.Context) + if ok { + p, _ = render.GetPageAndPageInner(ctx) + } + return p +} + +func (r *hugoContextRenderer) isHTMLComment(b []byte) bool { + return len(b) > 4 && b[0] == '<' && b[1] == '!' && b[2] == '-' && b[3] == '-' +} + +// HTML rendering based on Goldmark implementation. +func (r *hugoContextRenderer) renderHTMLBlock( + w util.BufWriter, source []byte, node ast.Node, entering bool, +) (ast.WalkStatus, error) { + n := node.(*ast.HTMLBlock) + + if entering { + if r.Unsafe { + l := n.Lines().Len() + for i := range l { + line := n.Lines().At(i) + linev := line.Value(source) + var stripped bool + linev, stripped = r.stripHugoCtx(linev) + if stripped { + r.logger.Warnidf(constants.WarnRenderShortcodesInHTML, ".RenderShortcodes detected inside HTML block in %q; this may not be what you intended, see https://gohugo.io/methods/page/rendershortcodes/#limitations", r.getPage(w)) + } + r.Writer.SecureWrite(w, linev) + } + } else { + l := n.Lines().At(0) + v := l.Value(source) + if !r.isHTMLComment(v) { + r.logRawHTMLEmittedWarn(w) + _, _ = w.WriteString("\n") + } + } + } else { + if n.HasClosure() { + if r.Unsafe { + closure := n.ClosureLine + r.Writer.SecureWrite(w, closure.Value(source)) + } else { + l := n.Lines().At(0) + v := l.Value(source) + if !r.isHTMLComment(v) { + _, _ = w.WriteString("\n") + } + } + } + } + return ast.WalkContinue, nil +} + +func (r *hugoContextRenderer) renderRawHTML( + w util.BufWriter, source []byte, node ast.Node, entering bool, +) (ast.WalkStatus, error) { + if !entering { + return ast.WalkSkipChildren, nil + } + n := node.(*ast.RawHTML) + l := n.Segments.Len() + if r.Unsafe { + for i := range l { + segment := n.Segments.At(i) + _, _ = w.Write(segment.Value(source)) + } + return ast.WalkSkipChildren, nil + } + segment := n.Segments.At(0) + v := segment.Value(source) + if !r.isHTMLComment(v) { + r.logRawHTMLEmittedWarn(w) + _, _ = w.WriteString("") + } + return ast.WalkSkipChildren, nil +} + +func (r *hugoContextRenderer) handleHugoContext(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + + hctx := node.(*HugoContext) + ctx, ok := w.(*render.Context) + if !ok { + return ast.WalkContinue, nil + } + if hctx.Closing { + _ = ctx.PopPid() + } else { + ctx.PushPid(hctx.Pid) + } + return ast.WalkContinue, nil +} + +type hugoContextTransformer struct{} + +var _ parser.ASTTransformer = (*hugoContextTransformer)(nil) + +func (a *hugoContextTransformer) Transform(n *ast.Document, reader text.Reader, pc parser.Context) { + ast.Walk(n, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + s := ast.WalkContinue + if !entering || n.Kind() != kindHugoContext { + return s, nil + } + + if p, ok := n.Parent().(*ast.Paragraph); ok { + if p.ChildCount() == 1 { + // Avoid empty paragraphs. + p.Parent().ReplaceChild(p.Parent(), p, n) + } else { + if t, ok := n.PreviousSibling().(*ast.Text); ok { + // Remove the newline produced by the Hugo context markers. + if t.SoftLineBreak() { + if t.Segment.Len() == 0 { + p.RemoveChild(p, t) + } else { + t.SetSoftLineBreak(false) + } + } + } + } + } + + return s, nil + }) +} + +type hugoContextExtension struct { + logger loggers.Logger +} + +func (a *hugoContextExtension) Extend(m goldmark.Markdown) { + m.Parser().AddOptions( + parser.WithInlineParsers( + util.Prioritized(&hugoContextParser{}, 50), + ), + parser.WithASTTransformers(util.Prioritized(&hugoContextTransformer{}, 10)), + ) + + m.Renderer().AddOptions( + renderer.WithNodeRenderers( + util.Prioritized(&hugoContextRenderer{ + logger: a.logger, + Config: html.Config{ + Writer: html.DefaultWriter, + }, + }, 50), + ), + ) +} diff --git a/markup/goldmark/hugocontext/hugocontext_test.go b/markup/goldmark/hugocontext/hugocontext_test.go new file mode 100644 index 000000000..62769f4d0 --- /dev/null +++ b/markup/goldmark/hugocontext/hugocontext_test.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 hugocontext + +import ( + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestWrap(t *testing.T) { + c := qt.New(t) + + b := []byte("test") + + c.Assert(Wrap(b, 42), qt.Equals, "{{__hugo_ctx pid=42}}\ntest\n{{__hugo_ctx/}}\n") +} + +func BenchmarkWrap(b *testing.B) { + for i := 0; i < b.N; i++ { + Wrap([]byte("test"), 42) + } +} diff --git a/markup/goldmark/images/images_integration_test.go b/markup/goldmark/images/images_integration_test.go new file mode 100644 index 000000000..387287e7a --- /dev/null +++ b/markup/goldmark/images/images_integration_test.go @@ -0,0 +1,88 @@ +package images_test + +import ( + "strings" + "testing" + + "github.com/gohugoio/hugo/hugolib" +) + +func TestDisableWrapStandAloneImageWithinParagraph(t *testing.T) { + t.Parallel() + + filesTemplate := ` +-- config.toml -- +[markup.goldmark.renderer] + unsafe = false +[markup.goldmark.parser] +wrapStandAloneImageWithinParagraph = CONFIG_VALUE +[markup.goldmark.parser.attribute] + block = true + title = true +-- content/p1.md -- +--- +title: "p1" +--- + +This is an inline image: ![Inline Image](/inline.jpg). Some more text. + +![Block Image](/block.jpg) +{.b} + + +-- layouts/_default/single.html -- +{{ .Content }} +` + + t.Run("With Hook, no wrap", func(t *testing.T) { + files := strings.ReplaceAll(filesTemplate, "CONFIG_VALUE", "false") + files = files + `-- layouts/_default/_markup/render-image.html -- +{{ if .IsBlock }} +
    + {{ .Text }}|{{ .Ordinal }} +
    +{{ else }} + {{ .Text }}|{{ .Ordinal }} +{{ end }} +` + b := hugolib.Test(t, files) + + b.AssertFileContent("public/p1/index.html", + "This is an inline image: \n\t\"Inline\n. Some more text.

    ", + "
    \n\t\"Block", + ) + }) + + t.Run("With Hook, wrap", func(t *testing.T) { + files := strings.ReplaceAll(filesTemplate, "CONFIG_VALUE", "true") + files = files + `-- layouts/_default/_markup/render-image.html -- +{{ if .IsBlock }} +
    + {{ .Text }} +
    +{{ else }} + {{ .Text }} +{{ end }} +` + b := hugolib.Test(t, files) + + b.AssertFileContent("public/p1/index.html", + "This is an inline image: \n\t\"Inline\n. Some more text.

    ", + "

    \n\t\"Block\n

    ", + ) + }) + + t.Run("No Hook, no wrap", func(t *testing.T) { + files := strings.ReplaceAll(filesTemplate, "CONFIG_VALUE", "false") + b := hugolib.Test(t, files) + + b.AssertFileContent("public/p1/index.html", "

    This is an inline image: \"Inline. Some more text.

    \n\"Block") + }) + + t.Run("No Hook, wrap", func(t *testing.T) { + files := strings.ReplaceAll(filesTemplate, "CONFIG_VALUE", "true") + b := hugolib.Test(t, files) + + b.AssertFileContent("public/p1/index.html", "

    \"Block

    ") + }) +} diff --git a/markup/goldmark/images/transform.go b/markup/goldmark/images/transform.go new file mode 100644 index 000000000..2e97a6791 --- /dev/null +++ b/markup/goldmark/images/transform.go @@ -0,0 +1,75 @@ +package images + +import ( + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +type ( + imagesExtension struct { + wrapStandAloneImageWithinParagraph bool + } +) + +const ( + // Used to signal to the rendering step that an image is used in a block context. + // Dont's change this; the prefix must match the internalAttrPrefix in the root goldmark package. + AttrIsBlock = "_h__isBlock" + AttrOrdinal = "_h__ordinal" +) + +func New(wrapStandAloneImageWithinParagraph bool) goldmark.Extender { + return &imagesExtension{wrapStandAloneImageWithinParagraph: wrapStandAloneImageWithinParagraph} +} + +func (e *imagesExtension) Extend(m goldmark.Markdown) { + m.Parser().AddOptions( + parser.WithASTTransformers( + util.Prioritized(&Transformer{wrapStandAloneImageWithinParagraph: e.wrapStandAloneImageWithinParagraph}, 300), + ), + ) +} + +type Transformer struct { + wrapStandAloneImageWithinParagraph bool +} + +// Transform transforms the provided Markdown AST. +func (t *Transformer) Transform(doc *ast.Document, reader text.Reader, pctx parser.Context) { + var ordinal int + ast.Walk(doc, func(node ast.Node, enter bool) (ast.WalkStatus, error) { + if !enter { + return ast.WalkContinue, nil + } + + if n, ok := node.(*ast.Image); ok { + parent := n.Parent() + n.SetAttributeString(AttrOrdinal, ordinal) + ordinal++ + + if !t.wrapStandAloneImageWithinParagraph { + isBlock := parent.ChildCount() == 1 + if isBlock { + n.SetAttributeString(AttrIsBlock, true) + } + + if isBlock && parent.Kind() == ast.KindParagraph { + for _, attr := range parent.Attributes() { + // Transfer any attribute set down to the image. + // Image elements does not support attributes on its own, + // so it's safe to just set without checking first. + n.SetAttribute(attr.Name, attr.Value) + } + grandParent := parent.Parent() + grandParent.ReplaceChild(grandParent, parent, n) + } + } + + } + + return ast.WalkContinue, nil + }) +} diff --git a/markup/goldmark/internal/extensions/attributes/attributes.go b/markup/goldmark/internal/extensions/attributes/attributes.go new file mode 100644 index 000000000..50ccb2ed4 --- /dev/null +++ b/markup/goldmark/internal/extensions/attributes/attributes.go @@ -0,0 +1,204 @@ +package attributes + +import ( + "strings" + + "github.com/gohugoio/hugo/markup/goldmark/goldmark_config" + "github.com/gohugoio/hugo/markup/goldmark/internal/render" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + east "github.com/yuin/goldmark/extension/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +// This extension is based on/inspired by https://github.com/mdigger/goldmark-attributes +// MIT License +// Copyright (c) 2019 Dmitry Sedykh + +var ( + kindAttributesBlock = ast.NewNodeKind("AttributesBlock") + attrNameID = []byte("id") + + defaultParser = new(attrParser) +) + +func New(cfg goldmark_config.Parser) goldmark.Extender { + return &attrExtension{cfg: cfg} +} + +type attrExtension struct { + cfg goldmark_config.Parser +} + +func (a *attrExtension) Extend(m goldmark.Markdown) { + if a.cfg.Attribute.Block { + m.Parser().AddOptions( + parser.WithBlockParsers( + util.Prioritized(defaultParser, 100)), + ) + } + m.Parser().AddOptions( + parser.WithASTTransformers( + util.Prioritized(&transformer{cfg: a.cfg}, 100), + ), + ) +} + +type attrParser struct{} + +func (a *attrParser) CanAcceptIndentedLine() bool { + return false +} + +func (a *attrParser) CanInterruptParagraph() bool { + return true +} + +func (a *attrParser) Close(node ast.Node, reader text.Reader, pc parser.Context) { +} + +func (a *attrParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State { + return parser.Close +} + +func (a *attrParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) { + if attrs, ok := parser.ParseAttributes(reader); ok { + // add attributes + node := &attributesBlock{ + BaseBlock: ast.BaseBlock{}, + } + for _, attr := range attrs { + node.SetAttribute(attr.Name, attr.Value) + } + return node, parser.NoChildren + } + return nil, parser.RequireParagraph +} + +func (a *attrParser) Trigger() []byte { + return []byte{'{'} +} + +type attributesBlock struct { + ast.BaseBlock +} + +func (a *attributesBlock) Dump(source []byte, level int) { + attrs := a.Attributes() + list := make(map[string]string, len(attrs)) + for _, attr := range attrs { + var ( + name = util.BytesToReadOnlyString(attr.Name) + value = util.BytesToReadOnlyString(util.EscapeHTML(attr.Value.([]byte))) + ) + list[name] = value + } + ast.DumpHelper(a, source, level, list, nil) +} + +func (a *attributesBlock) Kind() ast.NodeKind { + return kindAttributesBlock +} + +type transformer struct { + cfg goldmark_config.Parser +} + +func (a *transformer) isFragmentNode(n ast.Node) bool { + switch n.Kind() { + case east.KindDefinitionTerm, ast.KindHeading: + return true + default: + return false + } +} + +func (a *transformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) { + var attributes []ast.Node + var solitaryAttributeNodes []ast.Node + if a.cfg.Attribute.Block { + attributes = make([]ast.Node, 0, 100) + } + ast.Walk(node, func(node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + + if a.isFragmentNode(node) { + if id, found := node.Attribute(attrNameID); !found { + a.generateAutoID(node, reader, pc) + } else { + pc.IDs().Put(id.([]byte)) + } + } + + if a.cfg.Attribute.Block && node.Kind() == kindAttributesBlock { + // Attributes for fenced code blocks are handled in their own extension, + // but note that we currently only support code block attributes when + // CodeFences=true. + if node.PreviousSibling() != nil && node.PreviousSibling().Kind() != ast.KindFencedCodeBlock && !node.HasBlankPreviousLines() { + attributes = append(attributes, node) + return ast.WalkSkipChildren, nil + } else { + solitaryAttributeNodes = append(solitaryAttributeNodes, node) + } + } + + return ast.WalkContinue, nil + }) + + for _, attr := range attributes { + if prev := attr.PreviousSibling(); prev != nil && + prev.Type() == ast.TypeBlock { + for _, attr := range attr.Attributes() { + if _, found := prev.Attribute(attr.Name); !found { + prev.SetAttribute(attr.Name, attr.Value) + } + } + } + // remove attributes node + attr.Parent().RemoveChild(attr.Parent(), attr) + } + + // Remove any solitary attribute nodes. + for _, n := range solitaryAttributeNodes { + n.Parent().RemoveChild(n.Parent(), n) + } +} + +func (a *transformer) generateAutoID(n ast.Node, reader text.Reader, pc parser.Context) { + var text []byte + switch n := n.(type) { + case *ast.Heading: + if a.cfg.AutoHeadingID { + text = textHeadingID(n, reader) + } + case *east.DefinitionTerm: + if a.cfg.AutoDefinitionTermID { + text = []byte(render.TextPlain(n, reader.Source())) + } + } + + if len(text) > 0 { + headingID := pc.IDs().Generate(text, n.Kind()) + n.SetAttribute(attrNameID, headingID) + } +} + +// Markdown settext headers can have multiple lines, use the last line for the ID. +func textHeadingID(n *ast.Heading, reader text.Reader) []byte { + text := render.TextPlain(n, reader.Source()) + if n.Lines().Len() > 1 { + + // For multiline headings, Goldmark's extension for headings returns the last line. + // We have a slightly different approach, but in most cases the end result should be the same. + // Instead of looking at the text segments in Lines (see #13405 for issues with that), + // we split the text above and use the last line. + parts := strings.Split(text, "\n") + text = parts[len(parts)-1] + } + + return []byte(text) +} diff --git a/markup/goldmark/internal/extensions/attributes/attributes_integration_test.go b/markup/goldmark/internal/extensions/attributes/attributes_integration_test.go new file mode 100644 index 000000000..e56c52550 --- /dev/null +++ b/markup/goldmark/internal/extensions/attributes/attributes_integration_test.go @@ -0,0 +1,110 @@ +package attributes_test + +import ( + "testing" + + "github.com/gohugoio/hugo/hugolib" +) + +func TestDescriptionListAutoID(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +[markup.goldmark.parser] +autoHeadingID = true +autoDefinitionTermID = true +autoIDType = 'github-ascii' +-- content/p1.md -- +--- +title: "Title" +--- + +## Title with id set {#title-with-id} + +## Title with id set duplicate {#title-with-id} + +## My Title + +Base Name +: Base name of the file. + +Base Name +: Duplicate term name. + +My Title +: Term with same name as title. + +Foo@Bar +: The foo bar. + +foo [something](/a/b/) bar +: A foo bar. + +良善天父 +: The good father. + +Ā ā Ă ă Ą ą Ć ć Ĉ ĉ Ċ ċ Č č Ď +: Testing accents. + +Multiline set text header +Second line +--------------- + +## Example [hyperlink](https://example.com/) in a header + +-- layouts/_default/single.html -- +{{ .Content }}|Identifiers: {{ .Fragments.Identifiers }}| +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/p1/index.html", + `
    Base Name
    `, + `
    Base Name
    `, + `
    Foo@Bar
    `, + `

    My Title

    `, + `
    foo something bar
    `, + `

    Title with id set

    `, + `

    Title with id set duplicate

    `, + `
    My Title
    `, + `
    良善天父
    `, + `
    Ā ā Ă ă Ą ą Ć ć Ĉ ĉ Ċ ċ Č č Ď
    `, + `

    `, + `